(.*?)<\/subject>/si", $template, $matches);
+ $subject = $matches[1];
+ $subject = str_replace(
+ ["{{hostname}}", "{{appname}}", "{{username}}", "{{domain}}"],
+ [
+ get_hostname(),
+ $_SESSION["APP_NAME"],
+ $user_plain . "_" . $v_ftp_username_for_emailing,
+ $v_domain,
+ ],
+ $subject,
+ );
+ $template = str_replace($matches[0], "", $template);
+ } else {
+ $template = _(
+ "FTP account has been created and ready to use.\n" .
+ "\n" .
+ "Hostname: {{domain}}\n" .
+ "Username: {{username}}\n" .
+ "Password: {{password}}\n" .
+ "\n" .
+ "Best regards,\n" .
+ "\n" .
+ "--\n" .
+ "{{appname}}",
+ );
+ }
+ if (empty($subject)) {
+ $subject = str_replace(
+ ["{{subject}}", "{{hostname}}", "{{appname}}"],
+ [
+ sprintf(
+ _("FTP Account Credentials: %s"),
+ $user_plain . "_" . $v_ftp_username_for_emailing,
+ ),
+ get_hostname(),
+ $_SESSION["APP_NAME"],
+ ],
+ $_SESSION["SUBJECT_EMAIL"],
+ );
+ }
+
+ $mailtext = translate_email($template, [
+ "domain" => $v_domain,
+ "username" => $user_plain . "_" . $v_ftp_username_for_emailing,
+ "password" => $v_ftp_user_data["v_ftp_password"],
+ "appname" => $_SESSION["APP_NAME"],
+ ]);
+
+ send_email($to, $subject, $mailtext, $from, $from_name);
+ unset($v_ftp_email);
+ }
+ if (empty($v_ftp_user_data["v_ftp_email"])) {
+ $v_ftp_user_data["v_ftp_email"] = "";
+ }
+ $v_ftp_users_updated[] = [
+ "is_new" => 0,
+ "v_ftp_user" => $v_ftp_username,
+ "v_ftp_password" => $v_ftp_user_data["v_ftp_password"],
+ "v_ftp_path" => $v_ftp_user_data["v_ftp_path"],
+ "v_ftp_email" => $v_ftp_user_data["v_ftp_email"],
+ "v_ftp_pre_path" => $v_ftp_user_prepath,
+ ];
+ }
+ }
+ }
+ //custom docoot with check box disabled
+ if (!empty($v_custom_doc_root) && empty($_POST["v_custom_doc_root_check"])) {
+ exec(
+ HESTIA_CMD .
+ "v-change-web-domain-docroot " .
+ $user .
+ " " .
+ quoteshellarg($v_domain) .
+ " default",
+ $output,
+ $return_var,
+ );
+ check_return_code($return_var, $output);
+ unset($output);
+ unset($_POST["v-custom-doc-domain"], $_POST["v-custom-doc-folder"]);
+ $restart_web = "yes";
+ $restart_proxy = "yes";
+ }
+
+ if (
+ !empty($_POST["v-custom-doc-domain"]) &&
+ !empty($_POST["v_custom_doc_root_check"]) &&
+ $v_custom_doc_root_prepath . $v_custom_doc_domain . "/public_html" . $v_custom_doc_folder !=
+ $v_custom_doc_root
+ ) {
+ if ($_POST["v-custom-doc-domain"] == $v_domain && empty($_POST["v-custom-doc-folder"])) {
+ exec(
+ HESTIA_CMD .
+ "v-change-web-domain-docroot " .
+ $user .
+ " " .
+ quoteshellarg($v_domain) .
+ " default",
+ $output,
+ $return_var,
+ );
+ check_return_code($return_var, $output);
+ unset($output);
+ } else {
+ $v_custom_doc_folder = quoteshellarg(rtrim($_POST["v-custom-doc-folder"], "/"));
+ $v_custom_doc_domain = quoteshellarg($_POST["v-custom-doc-domain"]);
+
+ exec(
+ HESTIA_CMD .
+ "v-change-web-domain-docroot " .
+ $user .
+ " " .
+ quoteshellarg($v_domain) .
+ " " .
+ $v_custom_doc_domain .
+ " " .
+ $v_custom_doc_folder .
+ " yes",
+ $output,
+ $return_var,
+ );
+ check_return_code($return_var, $output);
+ unset($output);
+ $v_custom_doc_root = 1;
+ }
+ $restart_web = "yes";
+ $restart_proxy = "yes";
+ } else {
+ unset($v_custom_doc_root);
+ }
+
+ if (!empty($v_redirect) && empty($_POST["v-redirect-checkbox"])) {
+ exec(
+ HESTIA_CMD . "v-delete-web-domain-redirect " . $user . " " . quoteshellarg($v_domain),
+ $output,
+ $return_var,
+ );
+ check_return_code($return_var, $output);
+ unset($output);
+ unset($_POST["v-redirect"]);
+ $restart_web = "yes";
+ $restart_proxy = "yes";
+ }
+
+ if (!empty($_POST["v-redirect"]) && !empty($_POST["v-redirect-checkbox"])) {
+ if (empty($v_redirect)) {
+ if ($_POST["v-redirect"] == "custom" && empty($_POST["v-redirect-custom"])) {
+ } else {
+ if ($_POST["v-redirect"] == "custom") {
+ $_POST["v-redirect"] = $_POST["v-redirect-custom"];
+ }
+ exec(
+ HESTIA_CMD .
+ "v-add-web-domain-redirect " .
+ $user .
+ " " .
+ quoteshellarg($v_domain) .
+ " " .
+ quoteshellarg($_POST["v-redirect"]) .
+ " " .
+ quoteshellarg($_POST["v-redirect-code"]),
+ $output,
+ $return_var,
+ );
+ check_return_code($return_var, $output);
+ unset($output);
+ $restart_web = "yes";
+ $restart_proxy = "yes";
+ }
+ } else {
+ if ($_POST["v-redirect"] == "custom") {
+ $_POST["v-redirect"] = $_POST["v-redirect-custom"];
+ }
+ if (
+ $_POST["v-redirect"] != $v_redirect ||
+ $_POST["v-redirect-code"] != $v_redirect_code
+ ) {
+ exec(
+ HESTIA_CMD .
+ "v-add-web-domain-redirect " .
+ $user .
+ " " .
+ quoteshellarg($v_domain) .
+ " " .
+ quoteshellarg($_POST["v-redirect"]) .
+ " " .
+ quoteshellarg($_POST["v-redirect-code"]),
+ $output,
+ $return_var,
+ );
+ check_return_code($return_var, $output);
+ unset($output);
+ $restart_web = "yes";
+ $restart_proxy = "yes";
+ }
+ }
+ }
+ // Restart web server
+ if (!empty($restart_web) && empty($_SESSION["error_msg"])) {
+ exec(HESTIA_CMD . "v-restart-web", $output, $return_var);
+ check_return_code($return_var, $output);
+ unset($output);
+ }
+
+ // Restart proxy server
+ if (
+ !empty($_SESSION["PROXY_SYSTEM"]) &&
+ !empty($restart_proxy) &&
+ empty($_SESSION["error_msg"])
+ ) {
+ exec(HESTIA_CMD . "v-restart-proxy", $output, $return_var);
+ check_return_code($return_var, $output);
+ unset($output);
+ }
+
+ // Restart dns server
+ if (!empty($restart_dns) && empty($_SESSION["error_msg"])) {
+ exec(HESTIA_CMD . "v-restart-dns", $output, $return_var);
+ check_return_code($return_var, $output);
+ unset($output);
+ }
+
+ // Set success message
+ if (empty($_SESSION["error_msg"])) {
+ $_SESSION["ok_msg"] = _("Changes have been saved.");
+ header("Location: /edit/web/?domain=" . $v_domain);
+ exit();
+ }
+}
+
+$v_ftp_users_raw = explode(":", $v_ftp_user);
+$v_ftp_users_paths_raw = explode(":", $data[$v_domain]["FTP_PATH"]);
+$v_ftp_users = [];
+foreach ($v_ftp_users_raw as $v_ftp_user_index => $v_ftp_user_val) {
+ if (empty($v_ftp_user_val)) {
+ continue;
+ }
+ $v_ftp_users[] = [
+ "is_new" => 0,
+ "v_ftp_user" => preg_replace("/^" . $user_plain . "_/", "", $v_ftp_user_val),
+ "v_ftp_password" => $v_ftp_password,
+ "v_ftp_path" => isset($v_ftp_users_paths_raw[$v_ftp_user_index])
+ ? $v_ftp_users_paths_raw[$v_ftp_user_index]
+ : "",
+ "v_ftp_email" => $v_ftp_email,
+ "v_ftp_pre_path" => $v_ftp_user_prepath,
+ ];
+}
+
+if (empty($v_ftp_users)) {
+ $v_ftp_user = null;
+ $v_ftp_users[] = [
+ "is_new" => 1,
+ "v_ftp_user" => "",
+ "v_ftp_password" => "",
+ "v_ftp_path" => isset($v_ftp_users_paths_raw[$v_ftp_user_index])
+ ? $v_ftp_users_paths_raw[$v_ftp_user_index]
+ : "",
+ "v_ftp_email" => "",
+ "v_ftp_pre_path" => $v_ftp_user_prepath,
+ ];
+}
+
+// set default pre path for newly created users
+$v_ftp_pre_path_new_user = $v_ftp_user_prepath;
+if (isset($v_ftp_users_updated)) {
+ $v_ftp_users = $v_ftp_users_updated;
+ if (empty($v_ftp_users_updated)) {
+ $v_ftp_user = null;
+ $v_ftp_users[] = [
+ "is_new" => 1,
+ "v_ftp_user" => "",
+ "v_ftp_password" => "",
+ "v_ftp_path" => isset($v_ftp_users_paths_raw[$v_ftp_user_index])
+ ? $v_ftp_users_paths_raw[$v_ftp_user_index]
+ : "",
+ "v_ftp_email" => "",
+ "v_ftp_pre_path" => $v_ftp_user_prepath,
+ ];
+ }
+}
+
+// Render page
+render_page($user, $TAB, "edit_web");
+
+// Flush session messages
+unset($_SESSION["error_msg"]);
+unset($_SESSION["ok_msg"]);
diff --git a/web/error/403.html b/web/error/403.html
new file mode 100644
index 0000000..2c045f4
--- /dev/null
+++ b/web/error/403.html
@@ -0,0 +1,119 @@
+
+
+
+
+
+ Access Denied
+
+
+
+
+
+
+
+
Access Denied
+
+
You do not have permission to view this page.
+
Please check your credentials and try again.
+
+
+
+
+
+
+
diff --git a/web/error/404.html b/web/error/404.html
new file mode 100644
index 0000000..3c7a82a
--- /dev/null
+++ b/web/error/404.html
@@ -0,0 +1,119 @@
+
+
+
+
+
+ Page Not Found
+
+
+
+
+
+
+
+
Page Not Found
+
+
Oops! We couldn't find the page that you're looking for.
+
Please check the address and try again.
+
+
+
+
+
+
+
diff --git a/web/error/410.html b/web/error/410.html
new file mode 100644
index 0000000..515d849
--- /dev/null
+++ b/web/error/410.html
@@ -0,0 +1,119 @@
+
+
+
+
+
+ Resource is Gone
+
+
+
+
+
+
+
+
Resource is Gone
+
+
Oops! The requested resource is no longer available.
+
Please check the address and try again.
+
+
+
+
+
+
+
diff --git a/web/error/50x.html b/web/error/50x.html
new file mode 100644
index 0000000..b8f51ba
--- /dev/null
+++ b/web/error/50x.html
@@ -0,0 +1,122 @@
+
+
+
+
+
+ Internal Server Error
+
+
+
+
+
+
+
+
Internal Server Error
+
+
Oops! Something went wrong.
+
+ The server encountered an internal error or misconfiguration and was unable to
+ complete your request.
+
+
+
+
+
+
+
+
diff --git a/web/favicon.ico b/web/favicon.ico
new file mode 100644
index 0000000..890a361
Binary files /dev/null and b/web/favicon.ico differ
diff --git a/web/generate/ssl/index.php b/web/generate/ssl/index.php
new file mode 100644
index 0000000..bfa8c0d
--- /dev/null
+++ b/web/generate/ssl/index.php
@@ -0,0 +1,147 @@
+ $error) {
+ if ($i == 0) {
+ $error_msg = $error;
+ } else {
+ $error_msg = $error_msg . ", " . $error;
+ }
+ }
+ $_SESSION["error_msg"] = sprintf(_('Field "%s" can not be blank.'), $error_msg);
+ render_page($user, $TAB, "generate_ssl");
+ unset($_SESSION["error_msg"]);
+ exit();
+}
+
+// Protect input
+$v_domain = quoteshellarg($_POST["v_domain"]);
+$waliases = preg_replace("/\n/", " ", $_POST["v_aliases"]);
+$waliases = preg_replace("/,/", " ", $waliases);
+$waliases = preg_replace("/\s+/", " ", $waliases);
+$waliases = trim($waliases);
+$aliases = explode(" ", $waliases);
+$v_aliases = quoteshellarg(str_replace(" ", "\n", $waliases));
+
+$v_email = quoteshellarg($_POST["v_email"]);
+$v_country = quoteshellarg($_POST["v_country"]);
+$v_state = quoteshellarg($_POST["v_state"]);
+$v_locality = quoteshellarg($_POST["v_locality"]);
+$v_org = quoteshellarg($_POST["v_org"]);
+
+exec(
+ HESTIA_CMD .
+ "v-generate-ssl-cert " .
+ $v_domain .
+ " " .
+ $v_email .
+ " " .
+ $v_country .
+ " " .
+ $v_state .
+ " " .
+ $v_locality .
+ " " .
+ $v_org .
+ " IT " .
+ $v_aliases .
+ " json",
+ $output,
+ $return_var,
+);
+
+// Revert to raw values
+$v_domain = $_POST["v_domain"];
+$v_email = $_POST["v_email"];
+$v_country = $_POST["v_country"];
+$v_state = $_POST["v_state"];
+$v_locality = $_POST["v_locality"];
+$v_org = $_POST["v_org"];
+
+// Check return code
+if ($return_var != 0) {
+ $error = implode("
", $output);
+ if (empty($error)) {
+ $error = sprintf(_("Error code: %s"), $return_var);
+ }
+ $_SESSION["error_msg"] = $error;
+ render_page($user, $TAB, "generate_ssl");
+ unset($_SESSION["error_msg"]);
+ exit();
+}
+
+// OK message
+$_SESSION["ok_msg"] = _("Certificate has been generated successfully.");
+
+// Parse output
+$data = json_decode(implode("", $output), true);
+unset($output);
+$v_crt = $data[$v_domain]["CRT"];
+$v_key = $data[$v_domain]["KEY"];
+$v_csr = $data[$v_domain]["CSR"];
+
+// Back uri
+$_SESSION["back"] = $_SERVER["REQUEST_URI"];
+
+// Render page
+render_page($user, $TAB, "list_ssl");
+
+unset($_SESSION["ok_msg"]);
diff --git a/web/images/arrow.svg b/web/images/arrow.svg
new file mode 100644
index 0000000..6f092ee
--- /dev/null
+++ b/web/images/arrow.svg
@@ -0,0 +1 @@
+
diff --git a/web/images/favicon.png b/web/images/favicon.png
new file mode 100644
index 0000000..a0bab28
Binary files /dev/null and b/web/images/favicon.png differ
diff --git a/web/images/logo-header.svg b/web/images/logo-header.svg
new file mode 100644
index 0000000..26d3db8
--- /dev/null
+++ b/web/images/logo-header.svg
@@ -0,0 +1,18 @@
+
diff --git a/web/images/logo.png b/web/images/logo.png
new file mode 100644
index 0000000..5248c56
Binary files /dev/null and b/web/images/logo.png differ
diff --git a/web/images/logo.svg b/web/images/logo.svg
new file mode 100644
index 0000000..e5461e1
--- /dev/null
+++ b/web/images/logo.svg
@@ -0,0 +1,117 @@
+
\ No newline at end of file
diff --git a/web/inc/2fa/check.php b/web/inc/2fa/check.php
new file mode 100644
index 0000000..1a56e2f
--- /dev/null
+++ b/web/inc/2fa/check.php
@@ -0,0 +1,23 @@
+verifyCode($secret, $token);
+
+if ($result) {
+ echo "ok";
+}
diff --git a/web/inc/2fa/secret.php b/web/inc/2fa/secret.php
new file mode 100644
index 0000000..bae6793
--- /dev/null
+++ b/web/inc/2fa/secret.php
@@ -0,0 +1,10 @@
+createSecret(160); // Though the default is an 80 bits secret (for backwards compatibility reasons) we recommend creating 160+ bits secrets (see RFC 4226 - Algorithm Requirements)
+$qrcode = $tfa->getQRCodeImageAsDataUri(gethostname(), $secret);
+
+echo $secret . "-" . $qrcode;
diff --git a/web/inc/composer.json b/web/inc/composer.json
new file mode 100644
index 0000000..343d604
--- /dev/null
+++ b/web/inc/composer.json
@@ -0,0 +1,7 @@
+{
+ "require": {
+ "phpmailer/phpmailer": "6.8.0",
+ "hestiacp/phpquoteshellarg": "1.0.2",
+ "robthree/twofactorauth": "2.0.0"
+ }
+}
diff --git a/web/inc/composer.lock b/web/inc/composer.lock
new file mode 100644
index 0000000..742be60
--- /dev/null
+++ b/web/inc/composer.lock
@@ -0,0 +1,218 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "bd5fba3223573f480531e48f5e3ce14e",
+ "packages": [
+ {
+ "name": "hestiacp/phpquoteshellarg",
+ "version": "v1.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/hestiacp/phpquoteshellarg.git",
+ "reference": "7fd1a3a648cdc39a3fe2aab78a1a3a0267f92f49"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/hestiacp/phpquoteshellarg/zipball/7fd1a3a648cdc39a3fe2aab78a1a3a0267f92f49",
+ "reference": "7fd1a3a648cdc39a3fe2aab78a1a3a0267f92f49",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/Hestiacp/quoteshellarg/quoteshellarg.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Unlicense"
+ ],
+ "description": "Improved escape shell arguments for support of special charactars",
+ "homepage": "https://github.com/hestiacp",
+ "keywords": [
+ "escapeshellarg",
+ "quoteshellarg"
+ ],
+ "support": {
+ "source": "https://github.com/hestiacp/phpquoteshellarg/tree/v1.0.2"
+ },
+ "time": "2023-07-23T09:16:27+00:00"
+ },
+ {
+ "name": "phpmailer/phpmailer",
+ "version": "v6.8.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPMailer/PHPMailer.git",
+ "reference": "df16b615e371d81fb79e506277faea67a1be18f1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/df16b615e371d81fb79e506277faea67a1be18f1",
+ "reference": "df16b615e371d81fb79e506277faea67a1be18f1",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-filter": "*",
+ "ext-hash": "*",
+ "php": ">=5.5.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^0.7.2",
+ "doctrine/annotations": "^1.2.6 || ^1.13.3",
+ "php-parallel-lint/php-console-highlighter": "^1.0.0",
+ "php-parallel-lint/php-parallel-lint": "^1.3.2",
+ "phpcompatibility/php-compatibility": "^9.3.5",
+ "roave/security-advisories": "dev-latest",
+ "squizlabs/php_codesniffer": "^3.7.1",
+ "yoast/phpunit-polyfills": "^1.0.4"
+ },
+ "suggest": {
+ "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
+ "ext-openssl": "Needed for secure SMTP sending and DKIM signing",
+ "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
+ "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
+ "league/oauth2-google": "Needed for Google XOAUTH2 authentication",
+ "psr/log": "For optional PSR-3 debug logging",
+ "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)",
+ "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PHPMailer\\PHPMailer\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-2.1-only"
+ ],
+ "authors": [
+ {
+ "name": "Marcus Bointon",
+ "email": "phpmailer@synchromedia.co.uk"
+ },
+ {
+ "name": "Jim Jagielski",
+ "email": "jimjag@gmail.com"
+ },
+ {
+ "name": "Andy Prevost",
+ "email": "codeworxtech@users.sourceforge.net"
+ },
+ {
+ "name": "Brent R. Matzelle"
+ }
+ ],
+ "description": "PHPMailer is a full-featured email creation and transfer class for PHP",
+ "support": {
+ "issues": "https://github.com/PHPMailer/PHPMailer/issues",
+ "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.8.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Synchro",
+ "type": "github"
+ }
+ ],
+ "time": "2023-03-06T14:43:22+00:00"
+ },
+ {
+ "name": "robthree/twofactorauth",
+ "version": "v2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/RobThree/TwoFactorAuth.git",
+ "reference": "27cd1e1392d19f178398e892f59062003c8998a4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/RobThree/TwoFactorAuth/zipball/27cd1e1392d19f178398e892f59062003c8998a4",
+ "reference": "27cd1e1392d19f178398e892f59062003c8998a4",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.13",
+ "phpstan/phpstan": "^1.9",
+ "phpunit/phpunit": "^9"
+ },
+ "suggest": {
+ "bacon/bacon-qr-code": "Needed for BaconQrCodeProvider provider",
+ "endroid/qr-code": "Needed for EndroidQrCodeProvider"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "RobThree\\Auth\\": "lib"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Rob Janssen",
+ "homepage": "http://robiii.me",
+ "role": "Developer"
+ },
+ {
+ "name": "Nicolas CARPi",
+ "homepage": "https://github.com/NicolasCARPi",
+ "role": "Developer"
+ },
+ {
+ "name": "Will Power",
+ "homepage": "https://github.com/willpower232",
+ "role": "Developer"
+ }
+ ],
+ "description": "Two Factor Authentication",
+ "homepage": "https://github.com/RobThree/TwoFactorAuth",
+ "keywords": [
+ "Authentication",
+ "MFA",
+ "Multi Factor Authentication",
+ "Two Factor Authentication",
+ "authenticator",
+ "authy",
+ "php",
+ "tfa"
+ ],
+ "support": {
+ "issues": "https://github.com/RobThree/TwoFactorAuth/issues",
+ "source": "https://github.com/RobThree/TwoFactorAuth"
+ },
+ "funding": [
+ {
+ "url": "https://paypal.me/robiii",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/RobThree",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-25T11:33:28+00:00"
+ }
+ ],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": [],
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": [],
+ "platform-dev": [],
+ "plugin-api-version": "2.3.0"
+}
diff --git a/web/inc/helpers.php b/web/inc/helpers.php
new file mode 100644
index 0000000..b20a2f2
--- /dev/null
+++ b/web/inc/helpers.php
@@ -0,0 +1,178 @@
+ $value) {
+ $array1[] = "{{" . $key . "}}";
+ $array2[] = $value;
+ }
+ return str_replace($array1, $array2, $string);
+}
+/**
+ * Detects user language .
+ * @param string Fallback language (default: 'en')
+ * @return string Language code (such as 'en' and 'ja')
+ */
+
+function detect_login_language() {
+}
diff --git a/web/inc/mail-wrapper.php b/web/inc/mail-wrapper.php
new file mode 100755
index 0000000..fcb281b
--- /dev/null
+++ b/web/inc/mail-wrapper.php
@@ -0,0 +1,43 @@
+#!/usr/local/hestia/php/bin/php
+getMessage();
+ trigger_error($errstr);
+ echo $errstr;
+ exit(1);
+}
+
+define("HESTIA_DIR_BIN", "/usr/local/hestia/bin/");
+define("HESTIA_CMD", "/usr/bin/sudo /usr/local/hestia/bin/");
+define("DEFAULT_PHP_VERSION", "php-" . exec('php -r "echo substr(phpversion(),0,3);"'));
+
+// Load Hestia Config directly
+load_hestia_config();
+require_once dirname(__FILE__) . "/prevent_csrf.php";
+require_once dirname(__FILE__) . "/helpers.php";
+$root_directory = dirname(__FILE__) . "/../../";
+
+function destroy_sessions() {
+ unset($_SESSION);
+ session_unset();
+ session_destroy();
+ session_start();
+}
+
+$i = 0;
+
+// Saving user IPs to the session for preventing session hijacking
+$user_combined_ip = "";
+if (isset($_SERVER["REMOTE_ADDR"])) {
+ $user_combined_ip = $_SERVER["REMOTE_ADDR"];
+}
+if (isset($_SERVER["HTTP_CLIENT_IP"])) {
+ $user_combined_ip .= "|" . $_SERVER["HTTP_CLIENT_IP"];
+}
+if (isset($_SERVER["HTTP_X_FORWARDED_FOR"])) {
+ $user_combined_ip .= "|" . $_SERVER["HTTP_X_FORWARDED_FOR"];
+}
+if (isset($_SERVER["HTTP_FORWARDED_FOR"])) {
+ $user_combined_ip .= "|" . $_SERVER["HTTP_FORWARDED_FOR"];
+}
+if (isset($_SERVER["HTTP_X_FORWARDED"])) {
+ $user_combined_ip .= "|" . $_SERVER["HTTP_X_FORWARDED"];
+}
+if (isset($_SERVER["HTTP_FORWARDED"])) {
+ $user_combined_ip .= "|" . $_SERVER["HTTP_FORWARDED"];
+}
+if (isset($_SERVER["HTTP_CF_CONNECTING_IP"])) {
+ if (!empty($_SERVER["HTTP_CF_CONNECTING_IP"])) {
+ $user_combined_ip = $_SERVER["HTTP_CF_CONNECTING_IP"];
+ }
+}
+
+if (!isset($_SESSION["user_combined_ip"])) {
+ $_SESSION["user_combined_ip"] = $user_combined_ip;
+}
+
+// Checking user to use session from the same IP he has been logged in
+if (
+ $_SESSION["user_combined_ip"] != $user_combined_ip &&
+ isset($_SESSION["user"]) &&
+ $_SESSION["DISABLE_IP_CHECK"] != "yes"
+) {
+ $v_user = quoteshellarg($_SESSION["user"]);
+ $v_session_id = quoteshellarg($_SESSION["token"]);
+ exec(HESTIA_CMD . "v-log-user-logout " . $v_user . " " . $v_session_id, $output, $return_var);
+ destroy_sessions();
+ header("Location: /login/");
+ exit();
+}
+
+// Check system settings
+if (!isset($_SESSION["VERSION"]) && !defined("NO_AUTH_REQUIRED")) {
+ destroy_sessions();
+ header("Location: /login/");
+ exit();
+}
+
+// Check user session
+if (!isset($_SESSION["user"]) && !defined("NO_AUTH_REQUIRED")) {
+ destroy_sessions();
+ header("Location: /login/");
+ exit();
+}
+
+// Generate CSRF Token
+if (isset($_SESSION["user"])) {
+ if (!isset($_SESSION["token"])) {
+ $token = bin2hex(random_bytes(16));
+ $_SESSION["token"] = $token;
+ }
+}
+
+if ($_SESSION["RELEASE_BRANCH"] == "release" && $_SESSION["DEBUG_MODE"] == "false") {
+ define("JS_LATEST_UPDATE", "v=" . $_SESSION["VERSION"]);
+} else {
+ define("JS_LATEST_UPDATE", "r=" . time());
+}
+
+if (!defined("NO_AUTH_REQUIRED")) {
+ if (empty($_SESSION["LAST_ACTIVITY"]) || empty($_SESSION["INACTIVE_SESSION_TIMEOUT"])) {
+ destroy_sessions();
+ header("Location: /login/");
+ } elseif ($_SESSION["INACTIVE_SESSION_TIMEOUT"] * 60 + $_SESSION["LAST_ACTIVITY"] < time()) {
+ $v_user = quoteshellarg($_SESSION["user"]);
+ $v_session_id = quoteshellarg($_SESSION["token"]);
+ exec(
+ HESTIA_CMD . "v-log-user-logout " . $v_user . " " . $v_session_id,
+ $output,
+ $return_var,
+ );
+ destroy_sessions();
+ header("Location: /login/");
+ exit();
+ } else {
+ $_SESSION["LAST_ACTIVITY"] = time();
+ }
+}
+
+function ipUsed() {
+ [$http_host, $port] = explode(":", $_SERVER["HTTP_HOST"] . ":");
+ if (filter_var($http_host, FILTER_VALIDATE_IP)) {
+ return true;
+ } else {
+ return false;
+ }
+}
+
+if (isset($_SESSION["user"])) {
+ $user = quoteshellarg($_SESSION["user"]);
+ $user_plain = htmlentities($_SESSION["user"]);
+}
+
+if (isset($_SESSION["look"]) && $_SESSION["look"] != "" && $_SESSION["userContext"] === "admin") {
+ $user = quoteshellarg($_SESSION["look"]);
+ $user_plain = htmlentities($_SESSION["look"]);
+}
+if (empty($user_plain)) {
+ $user_plain = "";
+}
+if (empty($_SESSION["look"])) {
+ $_SESSION["look"] = "";
+}
+
+require_once dirname(__FILE__) . "/i18n.php";
+
+function check_error($return_var) {
+ if ($return_var > 0) {
+ header("Location: /error/");
+ exit();
+ }
+}
+
+function check_return_code($return_var, $output) {
+ if ($return_var != 0) {
+ $error = implode("
", $output);
+ if (empty($error)) {
+ $error = sprintf(_("Error code: %s"), $return_var);
+ }
+ $_SESSION["error_msg"] = $error;
+ }
+}
+function check_return_code_redirect($return_var, $output, $location) {
+ if ($return_var != 0) {
+ $error = implode("
", $output);
+ if (empty($error)) {
+ $error = sprintf(_("Error code: %s"), $return_var);
+ }
+ $_SESSION["error_msg"] = $error;
+ header("Location:" . $location);
+ }
+}
+
+function render_page($user, $TAB, $page) {
+ $__template_dir = dirname(__DIR__) . "/templates/";
+
+ // Extract global variables
+ // I think those variables should be passed via arguments
+ extract($GLOBALS, EXTR_SKIP);
+
+ // Header
+ include $__template_dir . "header.php";
+
+ // Panel
+ $panel = top_panel(empty($_SESSION["look"]) ? $_SESSION["user"] : $_SESSION["look"], $TAB);
+
+ // Policies controller
+ @include_once dirname(__DIR__) . "/inc/policies.php";
+
+ // Body
+ include $__template_dir . "pages/" . $page . ".php";
+
+ // Footer
+ include $__template_dir . "footer.php";
+}
+
+// Match $_SESSION['token'] against $_GET['token'] or $_POST['token']
+// Usage: verify_csrf($_POST) or verify_csrf($_GET); Use verify_csrf($_POST,true) to return on failure instead of redirect
+function verify_csrf($method, $return = false) {
+ if (
+ $method["token"] !== $_SESSION["token"] ||
+ empty($method["token"]) ||
+ empty($_SESSION["token"])
+ ) {
+ if ($return === true) {
+ return false;
+ } else {
+ header("Location: /login/");
+ die();
+ }
+ } else {
+ return true;
+ }
+}
+
+function show_alert_message($data) {
+ $msgIcon = "";
+ $msgText = "";
+ $msgClass = "";
+ if (!empty($data["error_msg"])) {
+ $msgIcon = "fa-circle-exclamation";
+ $msgText = htmlentities($data["error_msg"]);
+ $msgClass = "inline-alert-danger";
+ } elseif (!empty($data["ok_msg"])) {
+ $msgIcon = "fa-circle-check";
+ $msgText = $data["ok_msg"];
+ $msgClass = "inline-alert-success";
+ }
+
+ if (!empty($msgText)) {
+ printf(
+ '',
+ $msgClass,
+ $msgIcon,
+ $msgText,
+ );
+ }
+}
+
+function top_panel($user, $TAB) {
+ $command = HESTIA_CMD . "v-list-user " . $user . " 'json'";
+ exec($command, $output, $return_var);
+ if ($return_var > 0) {
+ destroy_sessions();
+ $_SESSION["error_msg"] = _("You are logged out, please log in again.");
+ header("Location: /login/");
+ exit();
+ }
+ $panel = json_decode(implode("", $output), true);
+ unset($output);
+
+ // Log out active sessions for suspended users
+ if ($panel[$user]["SUSPENDED"] === "yes" && $_SESSION["POLICY_USER_VIEW_SUSPENDED"] !== "yes") {
+ if (empty($_SESSION["look"])) {
+ destroy_sessions();
+ $_SESSION["error_msg"] = _("You are logged out, please log in again.");
+ header("Location: /login/");
+ }
+ }
+
+ // Reset user permissions if changed while logged in
+ if ($panel[$user]["ROLE"] !== $_SESSION["userContext"] && !isset($_SESSION["look"])) {
+ unset($_SESSION["userContext"]);
+ $_SESSION["userContext"] = $panel[$user]["ROLE"];
+ }
+
+ // Load user's selected theme and do not change it when impersonting user
+ if (isset($panel[$user]["THEME"]) && !isset($_SESSION["look"])) {
+ $_SESSION["userTheme"] = $panel[$user]["THEME"];
+ }
+
+ // Unset userTheme override variable if POLICY_USER_CHANGE_THEME is set to no
+ if ($_SESSION["POLICY_USER_CHANGE_THEME"] === "no") {
+ unset($_SESSION["userTheme"]);
+ }
+
+ // Set preferred sort order
+ if (!isset($_SESSION["look"])) {
+ $_SESSION["userSortOrder"] = $panel[$user]["PREF_UI_SORT"];
+ }
+
+ // Set home location URLs
+ if ($_SESSION["userContext"] === "admin" && empty($_SESSION["look"])) {
+ // Display users list for administrators unless they are impersonating a user account
+ $home_url = "/list/user/";
+ } else {
+ // Set home location URL based on available package features from account
+ if ($panel[$user]["WEB_DOMAINS"] != "0") {
+ $home_url = "/list/web/";
+ } elseif ($panel[$user]["DNS_DOMAINS"] != "0") {
+ $home_url = "/list/dns/";
+ } elseif ($panel[$user]["MAIL_DOMAINS"] != "0") {
+ $home_url = "/list/mail/";
+ } elseif ($panel[$user]["DATABASES"] != "0") {
+ $home_url = "/list/db/";
+ } elseif ($panel[$user]["CRON_JOBS"] != "0") {
+ $home_url = "/list/cron/";
+ } elseif ($panel[$user]["BACKUPS"] != "0") {
+ $home_url = "/list/backups/";
+ }
+ }
+
+ include dirname(__FILE__) . "/../templates/includes/panel.php";
+ return $panel;
+}
+
+function translate_date($date) {
+ $date = new DateTime($date);
+ return $date->format("d") . " " . _($date->format("M")) . " " . $date->format("Y");
+}
+
+function humanize_time($usage) {
+ if ($usage > 60) {
+ $usage = $usage / 60;
+ if ($usage > 24) {
+ $usage = $usage / 24;
+ $usage = number_format($usage);
+ return sprintf(ngettext("%d day", "%d days", $usage), $usage);
+ } else {
+ $usage = round($usage);
+ return sprintf(ngettext("%d hour", "%d hours", $usage), $usage);
+ }
+ } else {
+ $usage = round($usage);
+ return sprintf(ngettext("%d minute", "%d minutes", $usage), $usage);
+ }
+}
+
+function humanize_usage_size($usage, $round = 2) {
+ if ($usage == "unlimited") {
+ return "∞";
+ }
+ $display_usage = $usage;
+ if ($usage > 1024) {
+ $usage = $usage / 1024;
+ if ($usage > 1024) {
+ $usage = $usage / 1024;
+ if ($usage > 1024) {
+ $usage = $usage / 1024;
+ $display_usage = number_format($usage, $round);
+ } else {
+ if ($usage > 999) {
+ $usage = $usage / 1024;
+ }
+ $display_usage = number_format($usage, $round);
+ }
+ } else {
+ if ($usage > 999) {
+ $usage = $usage / 1024;
+ }
+ $display_usage = number_format($usage, $round);
+ }
+ } else {
+ if ($usage > 999) {
+ $usage = $usage / 1024;
+ }
+ $display_usage = number_format($usage, $round);
+ }
+ return $display_usage;
+}
+
+function humanize_usage_measure($usage) {
+ if ($usage == "unlimited") {
+ return;
+ }
+
+ $measure = "kb";
+ if ($usage > 1024) {
+ $usage = $usage / 1024;
+ if ($usage > 1024) {
+ $usage = $usage / 1024;
+ $measure = $usage < 1024 ? "tb" : "pb";
+ if ($usage > 999) {
+ $usage = $usage / 1024;
+ $measure = "pb";
+ }
+ } else {
+ $measure = $usage < 1024 ? "gb" : "tb";
+ if ($usage > 999) {
+ $usage = $usage / 1024;
+ $measure = "tb";
+ }
+ }
+ } else {
+ $measure = $usage < 1024 ? "mb" : "gb";
+ if ($usage > 999) {
+ $measure = "gb";
+ }
+ }
+ return $measure;
+}
+
+function get_percentage($used, $total) {
+ if ($total = "unlimited") {
+ //return 0 if unlimited
+ return 0;
+ }
+ if (!isset($total)) {
+ $total = 0;
+ }
+ if (!isset($used)) {
+ $used = 0;
+ }
+ if ($total == 0) {
+ $percent = 0;
+ } else {
+ $percent = $used / $total;
+ $percent = $percent * 100;
+ $percent = number_format($percent, 0, "", "");
+ if ($percent < 0) {
+ $percent = 0;
+ } elseif ($percent > 100) {
+ $percent = 100;
+ }
+ }
+ return $percent;
+}
+
+function send_email($to, $subject, $mailtext, $from, $from_name, $to_name = "") {
+ $mail = new PHPMailer();
+
+ if (isset($_SESSION["USE_SERVER_SMTP"]) && $_SESSION["USE_SERVER_SMTP"] == "true") {
+ if (!empty($_SESSION["SERVER_SMTP_ADDR"]) && $_SESSION["SERVER_SMTP_ADDR"] != "") {
+ if (filter_var($_SESSION["SERVER_SMTP_ADDR"], FILTER_VALIDATE_EMAIL)) {
+ $from = $_SESSION["SERVER_SMTP_ADDR"];
+ }
+ }
+
+ $mail->IsSMTP();
+ $mail->Mailer = "smtp";
+ $mail->SMTPDebug = 0;
+ $mail->SMTPAuth = true;
+ $mail->SMTPSecure = $_SESSION["SERVER_SMTP_SECURITY"];
+ $mail->Port = $_SESSION["SERVER_SMTP_PORT"];
+ $mail->Host = $_SESSION["SERVER_SMTP_HOST"];
+ $mail->Username = $_SESSION["SERVER_SMTP_USER"];
+ $mail->Password = $_SESSION["SERVER_SMTP_PASSWD"];
+ }
+
+ $mail->IsHTML(true);
+ $mail->ClearReplyTos();
+ if (empty($to_name)) {
+ $mail->AddAddress($to);
+ } else {
+ $mail->AddAddress($to, $to_name);
+ }
+ $mail->SetFrom($from, $from_name);
+
+ $mail->CharSet = "utf-8";
+ $mail->Subject = $subject;
+ $content = $mailtext;
+ $content = nl2br($content);
+ $mail->MsgHTML($content);
+ $mail->Send();
+}
+
+function list_timezones() {
+ foreach (
+ ["AKST", "AKDT", "PST", "PDT", "MST", "MDT", "CST", "CDT", "EST", "EDT", "AST", "ADT"]
+ as $timezone
+ ) {
+ $tz = new DateTimeZone($timezone);
+ $timezone_offsets[$timezone] = $tz->getOffset(new DateTime());
+ }
+
+ foreach (DateTimeZone::listIdentifiers() as $timezone) {
+ $tz = new DateTimeZone($timezone);
+ $timezone_offsets[$timezone] = $tz->getOffset(new DateTime());
+ }
+
+ foreach ($timezone_offsets as $timezone => $offset) {
+ $offset_prefix = $offset < 0 ? "-" : "+";
+ $offset_formatted = gmdate("H:i", abs($offset));
+ $pretty_offset = "UTC{$offset_prefix}{$offset_formatted}";
+ $c = new DateTime(gmdate("Y-M-d H:i:s"), new DateTimeZone("UTC"));
+ $c->setTimezone(new DateTimeZone($timezone));
+ $current_time = $c->format("H:i:s");
+ $timezone_list[$timezone] = "$timezone [ $current_time ] {$pretty_offset}";
+ #$timezone_list[$timezone] = "$timezone ${pretty_offset}";
+ }
+ return $timezone_list;
+}
+
+/**
+ * A function that tells is it MySQL installed on the system, or it is MariaDB.
+ *
+ * Explanation:
+ * $_SESSION['DB_SYSTEM'] has 'mysql' value even if MariaDB is installed, so you can't figure out is it really MySQL or it's MariaDB.
+ * So, this function will make it clear.
+ *
+ * If MySQL is installed, function will return 'mysql' as a string.
+ * If MariaDB is installed, function will return 'mariadb' as a string.
+ *
+ * Hint: if you want to check if PostgreSQL is installed - check value of $_SESSION['DB_SYSTEM']
+ *
+ * @return string
+ */
+function is_it_mysql_or_mariadb() {
+ exec(HESTIA_CMD . "v-list-sys-services json", $output, $return_var);
+ $data = json_decode(implode("", $output), true);
+ unset($output);
+ $mysqltype = "mysql";
+ if (isset($data["mariadb"])) {
+ $mysqltype = "mariadb";
+ }
+ return $mysqltype;
+}
+
+function load_hestia_config() {
+ // Check system configuration
+ exec(HESTIA_CMD . "v-list-sys-config json", $output, $return_var);
+ $data = json_decode(implode("", $output), true);
+ $sys_arr = $data["config"];
+ foreach ($sys_arr as $key => $value) {
+ $_SESSION[$key] = $value;
+ }
+}
+
+/**
+ * Returns the list of all web domains from all users grouped by Backend Template used and owner
+ *
+ * @return array
+ */
+function backendtpl_with_webdomains() {
+ exec(HESTIA_CMD . "v-list-users json", $output, $return_var);
+ $users = json_decode(implode("", $output), true);
+ unset($output);
+
+ $backend_list = [];
+ foreach ($users as $user => $user_details) {
+ exec(
+ HESTIA_CMD . "v-list-web-domains " . quoteshellarg($user) . " json",
+ $output,
+ $return_var,
+ );
+ $domains = json_decode(implode("", $output), true);
+ unset($output);
+ foreach ($domains as $domain => $domain_details) {
+ if (!empty($domain_details["BACKEND"])) {
+ $backend = $domain_details["BACKEND"];
+ $backend_list[$backend][$user][] = $domain;
+ }
+ }
+ }
+ return $backend_list;
+}
+/**
+ * Check if password is valid
+ *
+ * @return int; 1 / 0
+ */
+function validate_password($password) {
+ return preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(.){8,}$/', $password);
+}
+
+function unset_alerts() {
+ if (!empty($_SESSION["unset_alerts"])) {
+ if (!empty($_SESSION["error_msg"])) {
+ unset($_SESSION["error_msg"]);
+ }
+ if (!empty($_SESSION["ok_msg"])) {
+ unset($_SESSION["ok_msg"]);
+ }
+ unset($_SESSION["unset_alerts"]);
+ }
+}
+register_shutdown_function("unset_alerts");
diff --git a/web/inc/policies.php b/web/inc/policies.php
new file mode 100644
index 0000000..1e76c2d
--- /dev/null
+++ b/web/inc/policies.php
@@ -0,0 +1,20 @@
+= $_SESSION["POLICY_CSRF_STRICTNESS"]) {
+ return true;
+ } else {
+ http_response_code(400);
+ echo "Potential CSRF use detected
\n" .
+ "Please disable any plugins/add-ons inside your browser or contact your system administrator. If you are the system administrator you can run v-change-sys-config-value 'POLICY_CSRF_STRICTNESS' '0' as root to disable this check.
" .
+ "
If you followed a bookmark or an static link please navigate to root";
+ die();
+ }
+}
+
+function prevent_post_csrf() {
+ if (!empty($_SERVER["REQUEST_METHOD"])) {
+ if ($_SERVER["REQUEST_METHOD"] === "POST") {
+ if (!empty($_SERVER["HTTP_HOST"])) {
+ $hostname = preg_replace(
+ "/(\[?[^]]*\]?):([0-9]{1,5})$/",
+ "$1",
+ $_SERVER["HTTP_HOST"],
+ );
+ $port_is_defined = preg_match("/\[?[^]]*\]?:[0-9]{1,5}$/", $_SERVER["HTTP_HOST"]);
+ if ($port_is_defined) {
+ $port = preg_replace(
+ "/(\[?[^]]*\]?):([0-9]{1,5})$/",
+ "$2",
+ $_SERVER["HTTP_HOST"],
+ );
+ } else {
+ $port = 443;
+ }
+ } else {
+ $hostname = gethostname();
+ $port = 443;
+ }
+ if (isset($_SERVER["HTTP_ORIGIN"])) {
+ $origin_host = parse_url($_SERVER["HTTP_ORIGIN"], PHP_URL_HOST);
+ if (
+ strcmp($origin_host, gethostname()) === 0 &&
+ in_array($port, ["443", $_SERVER["SERVER_PORT"]])
+ ) {
+ return checkStrictness(2);
+ } else {
+ if (
+ strcmp($origin_host, $hostname) === 0 &&
+ in_array($port, ["443", $_SERVER["SERVER_PORT"]])
+ ) {
+ return checkStrictness(1);
+ } else {
+ return checkStrictness(0);
+ }
+ }
+ }
+ }
+ }
+}
+
+function prevent_get_csrf() {
+ if (!empty($_SERVER["REQUEST_METHOD"])) {
+ if ($_SERVER["REQUEST_METHOD"] === "GET") {
+ if (!empty($_SERVER["HTTP_HOST"])) {
+ $hostname = preg_replace(
+ "/(\[?[^]]*\]?):([0-9]{1,5})$/",
+ "$1",
+ $_SERVER["HTTP_HOST"],
+ );
+ $port_is_defined = preg_match("/\[?[^]]*\]?:[0-9]{1,5}$/", $_SERVER["HTTP_HOST"]);
+ if ($port_is_defined) {
+ $port = preg_replace(
+ "/(\[?[^]]*\]?):([0-9]{1,5})$/",
+ "$2",
+ $_SERVER["HTTP_HOST"],
+ );
+ } else {
+ $port = 443;
+ }
+ } else {
+ $hostname = gethostname();
+ $port = 443;
+ }
+
+ //list of possible entries route and these should never be blocked
+ if (
+ in_array($_SERVER["DOCUMENT_URI"], [
+ "/list/user/index.php",
+ "/login/index.php",
+ "/list/web/index.php",
+ "/list/dns/index.php",
+ "/list/mail/index.php",
+ "/list/db/index.php",
+ "/list/cron/index.php",
+ "/list/backup/index.php",
+ "/reset/index.php",
+ ])
+ ) {
+ return true;
+ }
+ if (isset($_SERVER["HTTP_REFERER"])) {
+ $referrer_host = parse_url($_SERVER["HTTP_REFERER"], PHP_URL_HOST);
+ if (
+ strcmp($referrer_host, gethostname()) === 0 &&
+ in_array($port, ["443", $_SERVER["SERVER_PORT"]])
+ ) {
+ return checkStrictness(2);
+ } else {
+ if (
+ strcmp($referrer_host, $hostname) === 0 &&
+ in_array($port, ["443", $_SERVER["SERVER_PORT"]])
+ ) {
+ return checkStrictness(1);
+ } else {
+ return checkStrictness(0);
+ }
+ }
+ } else {
+ return checkStrictness(0);
+ }
+ }
+ }
+}
+
+if ($check_csrf == true) {
+ prevent_post_csrf();
+ prevent_get_csrf();
+}
diff --git a/web/inc/secure_login.php b/web/inc/secure_login.php
new file mode 100644
index 0000000..d1a69f0
--- /dev/null
+++ b/web/inc/secure_login.php
@@ -0,0 +1,31 @@
+ {
+ const ipOption = document.createElement('option');
+ ipOption.textContent = ipSet.name;
+ ipOption.value = `ipset:${ipSet.name}`;
+ ipListSelect.appendChild(ipOption);
+ });
+}
diff --git a/web/js/src/alpineInit.js b/web/js/src/alpineInit.js
new file mode 100644
index 0000000..ed8d52a
--- /dev/null
+++ b/web/js/src/alpineInit.js
@@ -0,0 +1,75 @@
+// Set up various Alpine things after it's initialized
+export default function alpineInit() {
+ // Bulk edit forms
+ Alpine.bind('BulkEdit', () => ({
+ /** @param {SubmitEvent} evt */
+ '@submit'(evt) {
+ evt.preventDefault();
+ document.querySelectorAll('.js-unit-checkbox').forEach((el) => {
+ if (el.checked) {
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = el.name;
+ input.value = el.value;
+ evt.target.appendChild(input);
+ }
+ });
+
+ evt.target.submit();
+ },
+ }));
+
+ // Form state
+ Alpine.store('form', {
+ dirty: false,
+ makeDirty() {
+ this.dirty = true;
+ },
+ });
+ document
+ .querySelectorAll('#main-form input, #main-form select, #main-form textarea')
+ .forEach((el) => {
+ el.addEventListener('change', () => {
+ Alpine.store('form').makeDirty();
+ });
+ });
+
+ // Notifications methods called by the view code
+ Alpine.data('notifications', () => ({
+ initialized: false,
+ open: false,
+ notifications: [],
+ toggle() {
+ this.open = !this.open;
+ if (!this.initialized) {
+ this.list();
+ }
+ },
+ async list() {
+ const token = document.querySelector('#token').getAttribute('token');
+ const res = await fetch(`/list/notifications/?ajax=1&token=${token}`);
+ this.initialized = true;
+ if (!res.ok) {
+ throw new Error('An error occurred while listing notifications.');
+ }
+
+ this.notifications = Object.values(await res.json());
+ },
+ async remove(id) {
+ const token = document.querySelector('#token').getAttribute('token');
+ await fetch(`/delete/notification/?delete=1¬ification_id=${id}&token=${token}`);
+
+ this.notifications = this.notifications.filter((notification) => notification.ID != id);
+ if (this.notifications.length === 0) {
+ this.open = false;
+ }
+ },
+ async removeAll() {
+ const token = document.querySelector('#token').getAttribute('token');
+ await fetch(`/delete/notification/?delete=1&token=${token}`);
+
+ this.notifications = [];
+ this.open = false;
+ },
+ }));
+}
diff --git a/web/js/src/confirmAction.js b/web/js/src/confirmAction.js
new file mode 100644
index 0000000..0d06f8b
--- /dev/null
+++ b/web/js/src/confirmAction.js
@@ -0,0 +1,16 @@
+import { createConfirmationDialog } from './helpers';
+
+// Listen to .js-confirm-action links and intercept clicks with a confirmation dialog
+export default function handleConfirmAction() {
+ document.querySelectorAll('.js-confirm-action').forEach((triggerLink) => {
+ triggerLink.addEventListener('click', (evt) => {
+ evt.preventDefault();
+
+ const title = triggerLink.dataset.confirmTitle;
+ const message = triggerLink.dataset.confirmMessage;
+ const targetUrl = triggerLink.getAttribute('href');
+
+ createConfirmationDialog({ title, message, targetUrl, spinner: true });
+ });
+ });
+}
diff --git a/web/js/src/copyCreds.js b/web/js/src/copyCreds.js
new file mode 100644
index 0000000..6882e3a
--- /dev/null
+++ b/web/js/src/copyCreds.js
@@ -0,0 +1,27 @@
+import { debounce } from './helpers';
+
+// Monitor "Account" and "Password" inputs on "Add/Edit Mail Account"
+// page and update the sidebar "Account" and "Password" output
+export default function handleCopyCreds() {
+ monitorAndUpdate('.js-account-input', '.js-account-output');
+ monitorAndUpdate('.js-password-input', '.js-password-output');
+}
+
+function monitorAndUpdate(inputSelector, outputSelector) {
+ const inputElement = document.querySelector(inputSelector);
+ const outputElement = document.querySelector(outputSelector);
+
+ if (!inputElement || !outputElement) {
+ return;
+ }
+
+ function updateOutput(value) {
+ outputElement.textContent = value;
+ }
+
+ inputElement.addEventListener(
+ 'input',
+ debounce((evt) => updateOutput(evt.target.value))
+ );
+ updateOutput(inputElement.value);
+}
diff --git a/web/js/src/cronGenerator.js b/web/js/src/cronGenerator.js
new file mode 100644
index 0000000..d05812a
--- /dev/null
+++ b/web/js/src/cronGenerator.js
@@ -0,0 +1,25 @@
+// Copies values from cron generator fields to main cron fields when "Generate" is clicked
+export default function handleCronGenerator() {
+ document.querySelectorAll('.js-generate-cron').forEach((button) => {
+ button.addEventListener('click', () => {
+ const fieldset = button.closest('fieldset');
+ const inputNames = ['min', 'hour', 'day', 'month', 'wday'];
+
+ inputNames.forEach((inputName) => {
+ const value = fieldset.querySelector(`[name=h_${inputName}]`).value;
+ const formInput = document.querySelector(`#main-form input[name=v_${inputName}]`);
+
+ formInput.value = value;
+ formInput.classList.add('highlighted');
+
+ formInput.addEventListener(
+ 'transitionend',
+ () => {
+ formInput.classList.remove('highlighted');
+ },
+ { once: true }
+ );
+ });
+ });
+ });
+}
diff --git a/web/js/src/databaseHints.js b/web/js/src/databaseHints.js
new file mode 100644
index 0000000..7383c2e
--- /dev/null
+++ b/web/js/src/databaseHints.js
@@ -0,0 +1,44 @@
+import { debounce } from './helpers';
+
+// Attach listener to database "Name" and "Username" fields to update their hints
+export default function handleDatabaseHints() {
+ const usernameInput = document.querySelector('.js-db-hint-username');
+ const databaseNameInput = document.querySelector('.js-db-hint-database-name');
+
+ if (!usernameInput || !databaseNameInput) {
+ return;
+ }
+
+ removeUserPrefix(databaseNameInput);
+ attachUpdateHintListener(usernameInput);
+ attachUpdateHintListener(databaseNameInput);
+}
+
+// Remove prefix from "Database" input if it exists during initial load (for editing)
+function removeUserPrefix(input) {
+ const prefixIndex = input.value.indexOf(Alpine.store('globals').USER_PREFIX);
+ if (prefixIndex === 0) {
+ input.value = input.value.slice(Alpine.store('globals').USER_PREFIX.length);
+ }
+}
+
+function attachUpdateHintListener(input) {
+ if (input.value.trim() !== '') {
+ updateHint(input);
+ }
+
+ input.addEventListener(
+ 'input',
+ debounce((evt) => updateHint(evt.target))
+ );
+}
+
+function updateHint(input) {
+ const hintElement = input.parentElement.querySelector('.hint');
+
+ if (input.value.trim() === '') {
+ hintElement.textContent = '';
+ }
+
+ hintElement.textContent = Alpine.store('globals').USER_PREFIX + input.value;
+}
diff --git a/web/js/src/discardAllMail.js b/web/js/src/discardAllMail.js
new file mode 100644
index 0000000..8cb5933
--- /dev/null
+++ b/web/js/src/discardAllMail.js
@@ -0,0 +1,30 @@
+// "Discard all mail" checkbox behavior on Add/Edit Mail Account pages
+export default function handleDiscardAllMail() {
+ const discardAllMailCheckbox = document.querySelector('.js-discard-all-mail');
+
+ if (!discardAllMailCheckbox) {
+ return;
+ }
+
+ discardAllMailCheckbox.addEventListener('click', () => {
+ const forwardToTextarea = document.querySelector('.js-forward-to-textarea');
+ const doNotStoreCheckbox = document.querySelector('.js-do-not-store-checkbox');
+
+ if (discardAllMailCheckbox.checked) {
+ // Disable "Forward to" textarea
+ forwardToTextarea.disabled = true;
+
+ // Check "Do not store forwarded mail" checkbox
+ doNotStoreCheckbox.checked = true;
+
+ // Hide "Do not store forwarded mail" checkbox container
+ doNotStoreCheckbox.parentElement.classList.add('u-hidden');
+ } else {
+ // Enable "Forward to" textarea
+ forwardToTextarea.disabled = false;
+
+ // Show "Do not store forwarded mail" checkbox container
+ doNotStoreCheckbox.parentElement.classList.remove('u-hidden');
+ }
+ });
+}
diff --git a/web/js/src/dnsRecordHint.js b/web/js/src/dnsRecordHint.js
new file mode 100644
index 0000000..d9c747d
--- /dev/null
+++ b/web/js/src/dnsRecordHint.js
@@ -0,0 +1,49 @@
+import { debounce } from './helpers';
+
+// Attach listener to DNS "Record" field to update its hint
+export default function handleDnsRecordHint() {
+ const recordInput = document.querySelector('.js-dns-record-input');
+
+ if (!recordInput) {
+ return;
+ }
+
+ if (recordInput.value.trim() != '') {
+ updateHint(recordInput);
+ }
+
+ recordInput.addEventListener(
+ 'input',
+ debounce((evt) => updateHint(evt.target))
+ );
+}
+
+// Update DNS "Record" field hint
+function updateHint(input) {
+ const domainInput = document.querySelector('.js-dns-record-domain');
+ const hintElement = input.parentElement.querySelector('.hint');
+ let hint = input.value.trim();
+
+ // Clear the hint if input is empty
+ if (hint === '') {
+ hintElement.textContent = '';
+ return;
+ }
+
+ // Set domain name without rec in case of @ entries
+ if (hint === '@') {
+ hint = '';
+ }
+
+ // Don't show prefix if domain name equals rec value
+ if (hint === domainInput.value) {
+ hint = '';
+ }
+
+ // Add dot at the end if needed
+ if (hint !== '' && hint.slice(-1) !== '.') {
+ hint += '.';
+ }
+
+ hintElement.textContent = hint + domainInput.value;
+}
diff --git a/web/js/src/docRootHint.js b/web/js/src/docRootHint.js
new file mode 100644
index 0000000..a7a7d96
--- /dev/null
+++ b/web/js/src/docRootHint.js
@@ -0,0 +1,29 @@
+import { debounce } from './helpers';
+
+// Handle "Custom document root -> Directory" hint on Edit Web Domain page
+export default function handleDocRootHint() {
+ const domainSelect = document.querySelector('.js-custom-docroot-domain');
+ const dirInput = document.querySelector('.js-custom-docroot-dir');
+ const prepathHiddenInput = document.querySelector('.js-custom-docroot-prepath');
+ const docRootHint = document.querySelector('.js-custom-docroot-hint');
+
+ if (!domainSelect || !dirInput || !prepathHiddenInput || !docRootHint) {
+ return;
+ }
+
+ // Set initial hint on page load
+ updateDocRootHint();
+
+ // Add input listeners
+ dirInput.addEventListener('input', debounce(updateDocRootHint));
+ domainSelect.addEventListener('change', updateDocRootHint);
+
+ // Update hint value
+ function updateDocRootHint() {
+ const prepath = prepathHiddenInput.value;
+ const domain = domainSelect.value;
+ const folder = dirInput.value;
+
+ docRootHint.textContent = `${prepath}${domain}/public_html/${folder}`;
+ }
+}
diff --git a/web/js/src/editWebListeners.js b/web/js/src/editWebListeners.js
new file mode 100644
index 0000000..f49622b
--- /dev/null
+++ b/web/js/src/editWebListeners.js
@@ -0,0 +1,66 @@
+// Simple hide/show input listeners specific to Edit Web Domain form
+// TODO: Replace these with Alpine.js usage consistently
+// NOTE: Some functions use inline styles, as Alpine.js also uses them
+export default function handleEditWebListeners() {
+ // Listen to "Web Statistics" select menu to hide/show
+ // "Statistics Authorization" checkbox and inner fields
+ const statsSelect = document.querySelector('.js-stats-select');
+ const statsAuthContainers = document.querySelectorAll('.js-stats-auth');
+ if (statsSelect && statsAuthContainers.length) {
+ statsSelect.addEventListener('change', () => {
+ if (statsSelect.value === 'none') {
+ statsAuthContainers.forEach((container) => {
+ container.style.display = 'none';
+ });
+ } else {
+ statsAuthContainers.forEach((container) => {
+ container.style.display = 'block';
+ });
+ }
+ });
+ }
+
+ // Listen to "Enable domain redirection" radio items to show
+ // additional inputs if radio with value "custom" is selected
+ document.querySelectorAll('.js-redirect-custom-value').forEach((element) => {
+ element.addEventListener('change', () => {
+ const customRedirectFields = document.querySelector('.js-custom-redirect-fields');
+ if (customRedirectFields) {
+ if (element.value === 'custom') {
+ customRedirectFields.classList.remove('u-hidden');
+ } else {
+ customRedirectFields.classList.add('u-hidden');
+ }
+ }
+ });
+ });
+
+ // Listen to "Use Lets Encrypt to obtain SSL certificate" checkbox to
+ // hide/show SSL textareas
+ const toggleLetsEncryptCheckbox = document.querySelector('.js-toggle-lets-encrypt');
+ const sslDetails = document.querySelector('.js-ssl-details');
+ if (toggleLetsEncryptCheckbox && sslDetails) {
+ toggleLetsEncryptCheckbox.addEventListener('change', () => {
+ if (toggleLetsEncryptCheckbox.checked) {
+ sslDetails.style.display = 'none';
+ } else {
+ sslDetails.style.display = 'block';
+ }
+ });
+ }
+
+ // Listen to "Advanced Options -> Proxy Template" select menu to
+ // show "Purge Nginx Cache" button if "caching" selected
+ const proxyTemplateSelect = document.querySelector('.js-proxy-template-select');
+ const clearCacheButton = document.querySelector('.js-clear-cache-button');
+ if (proxyTemplateSelect && clearCacheButton) {
+ proxyTemplateSelect.addEventListener('change', () => {
+ // NOTE: Match "caching" and "caching-*" values
+ if (proxyTemplateSelect.value === 'caching' || proxyTemplateSelect.value.match(/^caching-/)) {
+ clearCacheButton.classList.remove('u-hidden');
+ } else {
+ clearCacheButton.classList.add('u-hidden');
+ }
+ });
+ }
+}
diff --git a/web/js/src/errorHandler.js b/web/js/src/errorHandler.js
new file mode 100644
index 0000000..fe0a4ad
--- /dev/null
+++ b/web/js/src/errorHandler.js
@@ -0,0 +1,10 @@
+import { createConfirmationDialog } from './helpers';
+
+// Displays page error message/notice in a confirmation dialog
+export default function handleErrorMessage() {
+ const errorMessage = Alpine.store('globals').ERROR_MESSAGE;
+
+ if (errorMessage) {
+ createConfirmationDialog({ message: errorMessage });
+ }
+}
diff --git a/web/js/src/focusFirstInput.js b/web/js/src/focusFirstInput.js
new file mode 100644
index 0000000..6ad007c
--- /dev/null
+++ b/web/js/src/focusFirstInput.js
@@ -0,0 +1,13 @@
+// If no dialog is open, focus first input in main content form
+// TODO: Replace this with autofocus attributes in the HTML
+export default function focusFirstInput() {
+ const openDialogs = document.querySelectorAll('dialog[open]');
+ if (openDialogs.length === 0) {
+ const input = document.querySelector(
+ '#main-form .form-control:not([disabled]), #main-form .form-select:not([disabled])'
+ );
+ if (input) {
+ input.focus();
+ }
+ }
+}
diff --git a/web/js/src/formSubmit.js b/web/js/src/formSubmit.js
new file mode 100644
index 0000000..0ac0ef1
--- /dev/null
+++ b/web/js/src/formSubmit.js
@@ -0,0 +1,37 @@
+import { enableUnlimitedInputs } from './unlimitedInput';
+import { updateAdvancedTextarea } from './toggleAdvanced';
+import { showSpinner } from './helpers';
+
+export default function handleFormSubmit() {
+ const mainForm = document.querySelector('#main-form');
+ if (mainForm) {
+ mainForm.addEventListener('submit', () => {
+ // Show loading spinner
+ showSpinner();
+
+ // Enable any disabled inputs to ensure all fields are submitted
+ if (mainForm.classList.contains('js-enable-inputs-on-submit')) {
+ document.querySelectorAll('input[disabled]').forEach((input) => {
+ input.disabled = false;
+ });
+ }
+
+ // Enable any disabled unlimited inputs and set their value to "unlimited"
+ enableUnlimitedInputs();
+
+ // Update the "advanced options" textarea with "basic options" input values
+ const basicOptionsWrapper = document.querySelector('.js-basic-options');
+ if (basicOptionsWrapper && !basicOptionsWrapper.classList.contains('u-hidden')) {
+ updateAdvancedTextarea();
+ }
+ });
+ }
+
+ const bulkEditForm = document.querySelector('[x-bind="BulkEdit"]');
+ if (bulkEditForm) {
+ bulkEditForm.addEventListener('submit', () => {
+ // Show loading spinner
+ showSpinner();
+ });
+ }
+}
diff --git a/web/js/src/ftpAccountHints.js b/web/js/src/ftpAccountHints.js
new file mode 100644
index 0000000..9d7fc76
--- /dev/null
+++ b/web/js/src/ftpAccountHints.js
@@ -0,0 +1,54 @@
+import { debounce } from './helpers';
+
+// Attach event listeners to FTP account "Username" and "Path" fields to update their hints
+export default function handleFtpAccountHints() {
+ addHintListeners('.js-ftp-user', updateFtpUsernameHint);
+ addHintListeners('.js-ftp-path', updateFtpPathHint);
+}
+
+function addHintListeners(selector, updateHintFunction) {
+ document.querySelectorAll(selector).forEach((inputElement) => {
+ const currentValue = inputElement.value.trim();
+
+ if (currentValue !== '') {
+ updateHintFunction(inputElement, currentValue);
+ }
+
+ inputElement.addEventListener(
+ 'input',
+ debounce((event) => updateHintFunction(event.target, event.target.value))
+ );
+ });
+}
+
+function updateFtpUsernameHint(usernameInput, username) {
+ const inputWrapper = usernameInput.parentElement;
+ const hintElement = inputWrapper.querySelector('.js-ftp-user-hint');
+
+ // Remove special characters
+ const sanitizedUsername = username.replace(/[^\w\d]/gi, '');
+
+ if (sanitizedUsername !== username) {
+ usernameInput.value = sanitizedUsername;
+ }
+
+ hintElement.textContent = Alpine.store('globals').USER_PREFIX + sanitizedUsername;
+}
+
+function updateFtpPathHint(pathInput, path) {
+ const inputWrapper = pathInput.parentElement;
+ const hintElement = inputWrapper.querySelector('.js-ftp-path-hint');
+ const normalizedPath = normalizePath(path);
+
+ hintElement.textContent = normalizedPath;
+}
+
+function normalizePath(path) {
+ // Add leading slash
+ if (path[0] !== '/') {
+ path = '/' + path;
+ }
+
+ // Remove double slashes
+ return path.replace(/\/(\/+)/g, '/');
+}
diff --git a/web/js/src/ftpAccounts.js b/web/js/src/ftpAccounts.js
new file mode 100644
index 0000000..c61db75
--- /dev/null
+++ b/web/js/src/ftpAccounts.js
@@ -0,0 +1,156 @@
+import handleFtpAccountHints from './ftpAccountHints';
+import { debounce, randomPassword } from './helpers';
+
+// Add/remove FTP accounts on Edit Web Domain page
+export default function handleFtpAccounts() {
+ // Listen to FTP user "Password" field changes and insert
+ // "Send FTP credentials to email" field if it doesn't exist
+ handlePasswordInputChange();
+
+ // Listen to FTP user "Password" generate button clicks and generate a random password
+ // Also insert "Send FTP credentials to email" field if it doesn't exist
+ handleGeneratePasswordClick();
+
+ // Listen to "Add FTP account" button click and add new FTP account form
+ handleAddAccountClick();
+
+ // Listen to FTP account "Delete" button clicks and delete FTP account
+ handleDeleteAccountClick();
+
+ // Listen to "Additional FTP account(s)" checkbox and show/hide FTP accounts section
+ handleToggleFtpAccountsCheckbox();
+}
+
+function handlePasswordInputChange() {
+ document.querySelectorAll('.js-ftp-user-psw').forEach((ftpPasswordInput) => {
+ ftpPasswordInput.addEventListener(
+ 'input',
+ debounce((evt) => insertEmailField(evt.target))
+ );
+ });
+}
+
+function handleGeneratePasswordClick() {
+ document.querySelectorAll('.js-ftp-password-generate').forEach((generateButton) => {
+ generateButton.addEventListener('click', () => {
+ const ftpPasswordInput =
+ generateButton.parentElement.parentElement.querySelector('.js-ftp-user-psw');
+
+ ftpPasswordInput.value = randomPassword();
+ insertEmailField(ftpPasswordInput);
+ });
+ });
+}
+
+function handleAddAccountClick() {
+ const addFtpAccountButton = document.querySelector('.js-add-ftp-account');
+ if (addFtpAccountButton) {
+ addFtpAccountButton.addEventListener('click', () => {
+ const template = document
+ .querySelector('.js-ftp-account-template .js-ftp-account-nrm')
+ .cloneNode(true);
+ const ftpAccounts = document.querySelectorAll('.js-active-ftp-accounts .js-ftp-account');
+ const newIndex = ftpAccounts.length;
+
+ template.querySelectorAll('input').forEach((input) => {
+ const name = input.getAttribute('name');
+ const id = input.getAttribute('id');
+ input.setAttribute('name', name.replace('%INDEX%', newIndex));
+ if (id) {
+ input.setAttribute('id', id.replace('%INDEX%', newIndex));
+ }
+ });
+
+ template.querySelectorAll('input + label').forEach((label) => {
+ const forAttr = label.getAttribute('for');
+ label.setAttribute('for', forAttr.replace('%INDEX%', newIndex));
+ });
+
+ template.querySelector('.js-ftp-user-number').textContent = newIndex;
+ document.querySelector('.js-active-ftp-accounts').appendChild(template);
+
+ updateUserNumbers();
+
+ // Refresh input listeners
+ handleFtpAccountHints();
+ handleGeneratePasswordClick();
+ handleDeleteAccountClick();
+ });
+ }
+}
+
+function handleDeleteAccountClick() {
+ document.querySelectorAll('.js-delete-ftp-account').forEach((deleteButton) => {
+ deleteButton.addEventListener('click', () => {
+ const ftpAccount = deleteButton.closest('.js-ftp-account');
+ ftpAccount.querySelector('.js-ftp-user-deleted').value = '1';
+ if (ftpAccount.querySelector('.js-ftp-user-is-new').value == 1) {
+ return ftpAccount.remove();
+ }
+ ftpAccount.classList.remove('js-ftp-account-nrm');
+ ftpAccount.style.display = 'none';
+
+ updateUserNumbers();
+
+ if (document.querySelectorAll('.js-active-ftp-accounts .js-ftp-account-nrm').length == 0) {
+ document.querySelector('.js-add-ftp-account').style.display = 'none';
+ document.querySelector('input[name="v_ftp"]').checked = false;
+ }
+ });
+ });
+}
+
+function updateUserNumbers() {
+ const ftpUserNumbers = document.querySelectorAll('.js-active-ftp-accounts .js-ftp-user-number');
+ ftpUserNumbers.forEach((number, index) => {
+ number.textContent = index + 1;
+ });
+}
+
+function handleToggleFtpAccountsCheckbox() {
+ const toggleFtpAccountsCheckbox = document.querySelector('.js-toggle-ftp-accounts');
+
+ if (!toggleFtpAccountsCheckbox) {
+ return;
+ }
+
+ toggleFtpAccountsCheckbox.addEventListener('change', (evt) => {
+ const isChecked = evt.target.checked;
+ const addFtpAccountButton = document.querySelector('.js-add-ftp-account');
+ const ftpAccounts = document.querySelectorAll('.js-ftp-account-nrm');
+
+ addFtpAccountButton.style.display = isChecked ? 'block' : 'none';
+
+ ftpAccounts.forEach((ftpAccount) => {
+ const usernameInput = ftpAccount.querySelector('.js-ftp-user');
+ const hiddenUserDeletedInput = ftpAccount.querySelector('.js-ftp-user-deleted');
+
+ if (usernameInput.value.trim() !== '') {
+ hiddenUserDeletedInput.value = isChecked ? '0' : '1';
+ }
+
+ ftpAccount.style.display = isChecked ? 'block' : 'none';
+ });
+ });
+}
+
+// Insert "Send FTP credentials to email" field if not present in FTP account
+function insertEmailField(ftpPasswordInput) {
+ const accountWrapper = ftpPasswordInput.closest('.js-ftp-account');
+
+ if (accountWrapper.querySelector('.js-email-alert-on-psw')) {
+ return;
+ }
+
+ const hiddenIsNewInput = accountWrapper.querySelector('.js-ftp-user-is-new');
+ const inputName = hiddenIsNewInput.name.replace('is_new', 'v_ftp_email');
+ const emailFieldHTML = `
+
+
+
+
`;
+ accountWrapper.insertAdjacentHTML('beforeend', emailFieldHTML);
+}
diff --git a/web/js/src/helpers.js b/web/js/src/helpers.js
new file mode 100644
index 0000000..f850e13
--- /dev/null
+++ b/web/js/src/helpers.js
@@ -0,0 +1,138 @@
+import { customAlphabet } from 'nanoid';
+
+// Generates a random password that always passes password requirements
+export function randomPassword(length = 16) {
+ const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+ const lowercase = 'abcdefghijklmnopqrstuvwxyz';
+ const numbers = '0123456789';
+ const symbols = '!@#$%^&*()_+-=[]{}|;:/?';
+ const allCharacters = uppercase + lowercase + numbers + symbols;
+ const generate = customAlphabet(allCharacters, length);
+
+ let password;
+ do {
+ password = generate();
+ // Must contain at least one uppercase letter, one lowercase letter, and one number
+ } while (!(/[a-z]/.test(password) && /[A-Z]/.test(password) && /\d/.test(password)));
+
+ return password;
+}
+
+// Debounces a function to avoid excessive calls
+export function debounce(func, wait = 100) {
+ let timeout;
+ return function (...args) {
+ clearTimeout(timeout);
+ timeout = setTimeout(() => func.apply(this, args), wait);
+ };
+}
+
+// Returns the value of a CSS variable
+export function getCssVariable(variableName) {
+ return getComputedStyle(document.documentElement).getPropertyValue(variableName).trim();
+}
+
+// Shows the loading spinner overlay
+export function showSpinner() {
+ document.querySelector('.js-spinner').classList.add('active');
+}
+
+// Parses and sorts IP lists from HTML
+export function parseAndSortIpLists(ipListsData) {
+ const ipLists = JSON.parse(ipListsData || '[]');
+ return ipLists.sort((a, b) => a.name.localeCompare(b.name));
+}
+
+// Posts data to the given URL and returns the response
+export async function post(url, data, headers = {}) {
+ const requestOptions = {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...headers,
+ },
+ body: JSON.stringify(data),
+ };
+
+ const response = await fetch(url, requestOptions);
+ if (!response.ok) {
+ throw new Error(`HTTP error ${response.status}: ${response.statusText}`);
+ }
+
+ return response.json();
+}
+
+// Creates a confirmation