#!/bin/bash
# alpha-site — privileged site manager for the Alpha panel. Root-owned; called
# by www-data via NOPASSWD sudoers. ALL input validated here (trust boundary).
#   create <sub> <stack> <phpver> <port> <startcmd>
#   secure <sub> | delete <sub>
#   app <start|stop|restart|status|logs|setcmd> <sub> [cmd]
#   attach <sub> <folder> <stack> <phpver> <port> <startcmd>   (website in a folder, same subdomain)
#   detach <sub> <folder>                                      (remove folder site + its wiring)
#   quota <sub> <gb|0>                                         (real per-site volume; 0 removes it)
#   limits <sub> <upload_mb|0> <rate_kb|0>                     (transfer limits; 0 = default/none)
#   wpguard <sub> [folder]                                     (no PHP in uploads, plugin private stores denied)
#   wppass <sub> [folder]          stdin: pass=<b64>            (set the WP admin password; none = generate one)
#   wplogin <sub> [folder]         stdin: slug=<b64>            (custom sign-in address; empty = wp-login.php)
#   wpmailfrom <sub> [folder]                                  (sender-address guard mu-plugin; new WP sites get it)
#   wpinfo <sub> [folder]                                      (→ WPINFO <admin login> <slug|->)
# WordPress options for create/attach (and the two above) come on STDIN as key=base64(value) lines — user, pass,
# email, slug — never in argv, where a password would be readable by every local user in `ps` while it runs.
set -euo pipefail
ACTION="${1:-}"
SUB="${2:-}"
[ -r /etc/alpha-panel.conf ] && . /etc/alpha-panel.conf
DOMAIN="${PRIMARY_DOMAIN:-$(hostname -d 2>/dev/null || true)}"; DOMAIN="${DOMAIN:-localhost}"   # the owner's domain, never a literal
EMAIL="${ADMIN_EMAIL:-root@localhost}"
SITES_ROOT="/var/www/sites"

valid() { [[ "$1" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$ ]] && [[ "$1" != "www" ]] && [[ "$1" != "mail" ]]; }

# nginx ignores .htaccess, so the "deny from all" files WordPress plugins drop into uploads protect nothing here:
# no PHP may run inside uploads, and the private stores of the Alpha WordPress plugins (backups, paid downloads,
# quarantine, shared files, snapshots — "<slug>-<random>/") are never served directly (the plugins stream them).
# [.] instead of \. keeps the text free of backslashes: it passes through both printf (create) and awk -v (attach).
WP_PRIVATE_RX='(alphaai|alphalogin|codeforge|codesell|dbdoctor|diagramblock|folderpress|geogate|licenseguard|linkpress|maildoctor|mailflow|meetpress|paystudio|pushpress|secureshield|serverpulse|sharevault|sitestats|statuspage|vpnstore)-[^/]+/'
wp_guard() {  # $1 = URL prefix of the install ("" or "/folder"); prints two locations joined by a literal \n + indent
  printf '%s' "location ~* ^$1/wp-content/uploads/.+[.](php[0-9]?|phtml|phar|pht)\$ { deny all; }\\n    location ~* ^$1/wp-content/uploads/${WP_PRIVATE_RX} { deny all; }"
}
unit_of() { echo "alpha-app-${1}"; }

# ---- WordPress install options (stdin) ----
wp_opts_read() {  # fills WPO_USER WPO_PASS WPO_EMAIL WPO_SLUG from stdin key=base64 lines (nothing piped = all empty)
  WPO_USER=""; WPO_PASS=""; WPO_EMAIL=""; WPO_SLUG=""
  [ -t 0 ] && return 0
  local line k v
  while IFS= read -r line || [ -n "${line:-}" ]; do
    k="${line%%=*}"; v="${line#*=}"           # split on the FIRST "=" only — base64 ends in "=" padding
    [ "$k" = "$line" ] && { line=""; continue; }
    v=$(printf '%s' "$v" | base64 -d 2>/dev/null || true)
    case "$k" in user) WPO_USER="$v" ;; pass) WPO_PASS="$v" ;; email) WPO_EMAIL="$v" ;; slug) WPO_SLUG="$v" ;; esac
    line=""
  done
}
wp_slug_ok() {  # a sign-in address: 3–40 of a-z 0-9 -, not a WordPress path
  [[ "$1" =~ ^[a-z0-9][a-z0-9-]{2,39}$ ]] || return 1
  case "$1" in wp-admin|wp-login|wp-content|wp-includes|wp-json|wp-cron|xmlrpc|feed|embed) return 1 ;; esac
}
wp_opts_check() {  # refuse bad input BEFORE anything is created
  [ -z "$WPO_USER" ] || [[ "$WPO_USER" =~ ^[A-Za-z0-9._@-]{3,60}$ ]] || { echo "BAD_WPUSER"; exit 10; }
  [ -z "$WPO_PASS" ] || [[ "$WPO_PASS" =~ ^[[:graph:]]{8,128}$ ]] || { echo "BAD_WPPASS"; exit 10; }
  [ -z "$WPO_EMAIL" ] || [[ "$WPO_EMAIL" =~ ^[^@[:space:]]+@[^@[:space:]]+[.][^@[:space:]]+$ ]] || { echo "BAD_WPEMAIL"; exit 10; }
  [ -z "$WPO_SLUG" ] || wp_slug_ok "$WPO_SLUG" || { echo "BAD_WPSLUG"; exit 10; }
}
wp_gen_pass() { openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 16; }
WPSLUGOK=0
wp_report() {  # the WordPress lines after create/attach: WPADMIN <user> <pass> | WPADMIN_NOPASS <user>, then WPSLUG <slug>
  [ "${STACK:-}" = wordpress ] || return 0
  if [ "${WPINSTALLED:-0}" = 1 ]; then echo "WPADMIN ${WPU} ${WPADMINPASS}"; elif [ "${WPINSTALLED:-0}" = 2 ]; then echo "WPADMIN_NOPASS ${WPU}"; fi
  if [ "$WPSLUGOK" = 1 ]; then echo "WPSLUG ${WPO_SLUG}"; fi
  return 0
}
wp_set_pass() {  # wp_set_pass <wp-root> <login> <password> — the password travels in the environment, never in argv
  ALPHA_WPP="$3" ALPHA_WPU="$2" wp eval '$u = get_user_by("login", getenv("ALPHA_WPU")); if (!$u) { echo "NOUSER"; exit(1); } wp_set_password(getenv("ALPHA_WPP"), $u->ID); echo "OK";' --path="$1" --allow-root 2>/dev/null | grep -q '^OK'
}
wp_root_of() {  # wp_root_of <sub> [folder] → prints the WordPress root or exits NOT_WP / BAD_FOLDER
  local r="${SITES_ROOT}/${1}" f="${2:-}"
  if [ -n "$f" ]; then
    local FRE='^[A-Za-z0-9._ -]+(/[A-Za-z0-9._ -]+)?$'
    { [[ "$f" =~ $FRE ]] && [[ "$f" != *..* ]] && [[ "$f" != .* ]] && [[ "$f" != */.* ]]; } || { echo "BAD_FOLDER"; exit 9; }
    r="${r}/${f}"
  fi
  [ -f "$r/wp-config.php" ] || { echo "NOT_WP"; exit 3; }
  printf '%s' "$r"
}
# Custom sign-in address: a must-use plugin (inert without its option) + option alpha_login_slug. "" = back to
# wp-login.php: the option is deleted and the file removed. The plugin answers 404 for wp-login.php and for
# wp-admin/ to visitors who are not signed in, so the address is the only way to the form.
wp_login_apply() {  # wp_login_apply <wp-root> <slug|"">
  local R="$1" S="$2" MU="$1/wp-content/mu-plugins/alpha-login-url.php"
  if [ -z "$S" ]; then
    wp option delete alpha_login_slug --path="$R" --allow-root >/dev/null 2>&1 || true
    rm -f "$MU"
    return 0
  fi
  mkdir -p "$R/wp-content/mu-plugins"
  cat > "$MU" <<'MUPHP'
<?php
/**
 * Plugin Name: Custom sign-in address
 * Description: Set from the Alpha panel (Sites ▸ Manage ▸ WordPress). The sign-in page answers at the address in the
 *              "alpha_login_slug" option; wp-login.php and wp-admin/ answer 404 to visitors who are not signed in.
 *              Delete the option (or this file) to go back to wp-login.php.
 * Version: 1.0.0
 */
if ( ! defined( 'ABSPATH' ) || ( defined( 'WP_CLI' ) && WP_CLI ) ) {
	return;
}
function alpha_login_slug() {
	static $s = null;
	if ( $s === null ) {
		$v = get_option( 'alpha_login_slug', '' );
		$s = ( is_string( $v ) && preg_match( '/^[a-z0-9][a-z0-9-]{2,39}$/', $v ) ) ? $v : '';
	}
	return $s;
}
if ( alpha_login_slug() === '' ) {
	return;
}
function alpha_login_url( $query = '' ) {
	$u = trailingslashit( home_url( '/' . alpha_login_slug() ) );
	return $query !== '' ? $u . '?' . $query : $u;
}
/* every wp-login.php link — forms, e-mails, redirects — points at the custom address (password-protected posts keep theirs) */
function alpha_login_rewrite( $url ) {
	if ( ! is_string( $url ) || strpos( $url, 'wp-login.php' ) === false || strpos( $url, 'action=postpass' ) !== false ) {
		return $url;
	}
	return alpha_login_url( (string) wp_parse_url( $url, PHP_URL_QUERY ) );
}
foreach ( array( 'site_url', 'network_site_url', 'wp_redirect', 'login_url', 'logout_url', 'lostpassword_url', 'register_url' ) as $alpha_f ) {
	add_filter( $alpha_f, 'alpha_login_rewrite', 100 );
}
/* /login and /admin would redirect to the sign-in page and give the address away */
remove_action( 'template_redirect', 'wp_redirect_admin_locations', 1000 );

function alpha_login_is_custom() {
	static $r = null;
	if ( $r === null ) {
		$path = untrailingslashit( rawurldecode( (string) wp_parse_url( $_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH ) ) );
		$home = untrailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) );
		$r    = $path === $home . '/' . alpha_login_slug();
	}
	return $r;
}
function alpha_login_404() {
	status_header( 404 );
	nocache_headers();
	header( 'Content-Type: text/html; charset=utf-8' );
	echo '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>Page not found</title></head>'
		. '<body style="margin:0;min-height:100vh;display:grid;place-items:center;font-family:system-ui,-apple-system,sans-serif;color:#475569;background:#f8fafc">'
		. '<div style="text-align:center"><div style="font-size:72px;font-weight:800;color:#0f172a;line-height:1">404</div><p>That page does not exist.</p>'
		. '<p><a style="color:#2563eb" href="' . esc_url( home_url( '/' ) ) . '">Go to the home page</a></p></div></body></html>';
	exit;
}
add_action(
	'plugins_loaded',
	function () {
		global $pagenow;
		if ( alpha_login_is_custom() ) {
			$pagenow = 'wp-login.php'; // plugins asking "is this the sign-in page?" get the right answer
			return;
		}
		$script = basename( (string) wp_parse_url( $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH ) );
		if ( ( $pagenow === 'wp-login.php' || $script === 'wp-login.php' ) && ! is_user_logged_in() ) {
			if ( ( $_REQUEST['action'] ?? '' ) !== 'postpass' ) { // phpcs:ignore WordPress.Security.NonceVerification
				alpha_login_404();
			}
			return;
		}
		if ( is_admin() && ! is_user_logged_in() && ! wp_doing_ajax() && ! in_array( $pagenow, array( 'admin-ajax.php', 'admin-post.php', 'async-upload.php' ), true ) ) {
			alpha_login_404();
		}
	},
	1
);
/* the custom address serves the real wp-login.php once WordPress is fully loaded */
add_action(
	'wp_loaded',
	function () {
		if ( ! alpha_login_is_custom() ) {
			return;
		}
		global $error, $errors, $interim_login, $action, $user_login, $user, $redirect_to, $requested_redirect_to, $reauth, $secure_cookie, $rp_cookie, $rp_path, $customize_login;
		require_once ABSPATH . 'wp-login.php';
		exit;
	},
	1
);
MUPHP
  chown -R www-data:www-data "$R/wp-content/mu-plugins"
  wp option update alpha_login_slug "$S" --path="$R" --allow-root >/dev/null 2>&1
}

# Sender safety net for every WordPress site: a must-use plugin that, when the site sends THROUGH THIS SERVER (PHP
# mail/sendmail), moves a From on a foreign domain (gmail.com, outlook.com …) to wordpress@<site> and keeps the original
# in Reply-To. This server can only DKIM-sign its own domains, and Gmail rejects the rest ("550 5.7.30 DKIM authentication
# didn't pass" / "5.7.26 unauthenticated email from gmail.com") — a plugin that sends "From: <admin_email>" was doing
# exactly that. Sites using an SMTP plugin (PHPMailer Mailer = smtp) are left alone: their provider signs for them.
wp_permalinks_apply() {  # wp_permalinks_apply <wp-root> — pretty permalinks (/post-name/)
  # With WordPress's default PLAIN permalinks every unknown path reaches index.php through nginx's fallback, WordPress
  # reads it as the home page and canonicalises it with a slash: /favicon.ico, /robots.txt and /wp-sitemap.xml all
  # answered 301 -> "…/" -> the home page HTML (a live WordPress site, 2026-10-22). Pretty permalinks route them.
  wp rewrite structure '/%postname%/' --allow-root --path="$1" >/dev/null 2>&1
}

wp_mailfrom_apply() {  # wp_mailfrom_apply <wp-root>
  local R="$1" MU="$1/wp-content/mu-plugins/alpha-mail-from.php"
  mkdir -p "$R/wp-content/mu-plugins"
  cat > "$MU" <<'MUPHP'
<?php
/**
 * Plugin Name: Sender address guard
 * Description: Installed by the Alpha panel. Mail this site sends through the server must come from the site's own
 *              domain, or Gmail and others reject it (the server can only DKIM-sign its own domains). A From on another
 *              domain becomes wordpress@<this site> and the original address goes to Reply-To. Mail sent through an
 *              SMTP plugin is not touched.
 * Version: 1.0.0
 */
defined( 'ABSPATH' ) || exit;
add_action(
	'phpmailer_init',
	static function ( $m ) {
		if ( ! in_array( $m->Mailer, array( 'mail', 'sendmail', 'qmail' ), true ) ) {
			return;   // an SMTP plugin delivers through its own provider, which signs for that address
		}
		$host = preg_replace( '/^www\./', '', strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) );
		$from = strtolower( (string) $m->From );
		$dom  = (string) substr( (string) strrchr( $from, '@' ), 1 );
		if ( $host === '' || $dom === '' || $dom === $host || substr( $host, -strlen( '.' . $dom ) ) === '.' . $dom || substr( $dom, -strlen( '.' . $host ) ) === '.' . $host ) {
			return;   // already the site's own domain (or its parent / a subdomain of it)
		}
		$orig = $m->From;
		try {
			$m->setFrom( 'wordpress@' . $host, $m->FromName, false );
			if ( ! $m->getReplyToAddresses() ) {
				$m->addReplyTo( $orig );
			}
		} catch ( \Throwable $e ) {
			return;
		}
	},
	999
);
MUPHP
  chown -R www-data:www-data "$R/wp-content/mu-plugins"
}

write_app_unit() {  # $1=sub $2=root $3=port $4=startcmd
  local sub="$1" root="$2" port="$3" cmd="$4"
  printf '#!/bin/bash\ncd %q\nexport PORT=%q\nexport NODE_ENV=production\n%s\n' "$root" "$port" "$cmd" > "$root/.alpha-run.sh"
  chmod 0755 "$root/.alpha-run.sh"; chown www-data:www-data "$root/.alpha-run.sh"
  cat > "/etc/systemd/system/$(unit_of "$sub").service" <<UNIT
[Unit]
Description=Alpha app ${sub}.${DOMAIN}
After=network.target docker.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=${root}
ExecStart=/bin/bash ${root}/.alpha-run.sh
Restart=on-failure
RestartSec=4
[Install]
WantedBy=multi-user.target
UNIT
  systemctl daemon-reload
}

OLS_CONF="/usr/local/lsws/conf/httpd_config.conf"

# Apache backend vhost on 127.0.0.1:8080 (PHP via php-fpm). $1=sub $2=fqdn $3=root $4=phpver
apache_write() {
  local sub="$1" fqdn="$2" root="$3" ver="$4"
  cat > "/etc/apache2/sites-available/alpha-${sub}.conf" <<AP
<VirtualHost 127.0.0.1:8080>
    ServerName ${fqdn}
    DocumentRoot ${root}
    <Directory ${root}>
        AllowOverride All
        Require all granted
    </Directory>
    <FilesMatch \\.php\$>
        SetHandler "proxy:unix:/run/php/php${ver}-fpm.sock|fcgi://localhost"
    </FilesMatch>
    DirectoryIndex index.php index.html
</VirtualHost>
AP
  a2ensite "alpha-${sub}" >/dev/null 2>&1
  apachectl configtest >/dev/null 2>&1 && systemctl reload apache2 || true
}
apache_remove() {
  a2dissite "alpha-${1}" >/dev/null 2>&1 || true
  rm -f "/etc/apache2/sites-available/alpha-${1}.conf"
  systemctl reload apache2 >/dev/null 2>&1 || true
}

# OpenLiteSpeed backend vhost on 127.0.0.1:8088. $1=sub $2=fqdn $3=root $4=phpver
ols_write() {
  local sub="$1" fqdn="$2" root="$3" ver="${4:-8.3}" lsv
  lsv="${ver//./}"                                              # 8.3 -> 83
  [ -x "/usr/local/lsws/lsphp${lsv}/bin/lsphp" ] || lsv="83"   # fall back if that lsphp isn't installed
  grep -q "# ALPHA-MAPS" "$OLS_CONF" || sed -i "/map  *Example \*/a\\    # ALPHA-MAPS" "$OLS_CONF"
  mkdir -p "/usr/local/lsws/conf/vhosts/${sub}"
  cat > "/usr/local/lsws/conf/vhosts/${sub}/vhconf.conf" <<VHC
docRoot                   ${root}
index  {
  useServer               0
  indexFiles              index.php, index.html
}
scripthandler  {
  add                     lsapi:lsphp php
}
extprocessor lsphp {
  type                    lsapi
  address                 uds://tmp/lshttpd/${sub}.sock
  maxConns                10
  path                    /usr/local/lsws/lsphp${lsv}/bin/lsphp
  runOnStartUp            1
  instances               1
}
VHC
  sed -i "/# ALPHA-VH-${sub}-START/,/# ALPHA-VH-${sub}-END/d; /map  *${sub} /d" "$OLS_CONF"
  cat >> "$OLS_CONF" <<VH

# ALPHA-VH-${sub}-START
virtualhost ${sub} {
  vhRoot                  ${root}
  configFile              \$SERVER_ROOT/conf/vhosts/${sub}/vhconf.conf
  allowSymbolLink         1
  enableScript            1
  restrained              1
}
# ALPHA-VH-${sub}-END
VH
  sed -i "/# ALPHA-MAPS/i\\    map                      ${sub} ${fqdn}" "$OLS_CONF"
  chown -R lsadm:lsadm "/usr/local/lsws/conf/vhosts/${sub}" 2>/dev/null || true
  systemctl restart lsws >/dev/null 2>&1 || true
}
ols_remove() {
  sed -i "/# ALPHA-VH-${1}-START/,/# ALPHA-VH-${1}-END/d; /map  *${1} /d" "$OLS_CONF" 2>/dev/null || true
  rm -rf "/usr/local/lsws/conf/vhosts/${1}"
  systemctl restart lsws >/dev/null 2>&1 || true
}

case "$ACTION" in
  create)
    valid "$SUB" || { echo "INVALID_NAME"; exit 2; }
    STACK="${3:-static}"; PHPVER="${4:-8.3}"; PORT="${5:-0}"; STARTCMD="${6:-}"; WEBSERVER="${7:-nginx}"
    WPINSTALLED=0; WPADMINPASS=""; WPO_USER=""; WPO_PASS=""; WPO_EMAIL=""; WPO_SLUG=""
    case "$STACK" in static|php|wordpress|node|python|docker) ;; *) echo "BAD_STACK"; exit 6 ;; esac
    [ "$STACK" = wordpress ] && { wp_opts_read; wp_opts_check; }
    case "$WEBSERVER" in nginx|apache|litespeed) ;; *) WEBSERVER="nginx" ;; esac
    case "$STACK" in node|python|docker) WEBSERVER="nginx" ;; esac  # proxy stacks: nginx fronts the app
    case "$PHPVER" in 7.4|8.0|8.1|8.2|8.3|8.4|8.5) ;; *) PHPVER="8.3" ;; esac
    FPM="unix:/run/php/php${PHPVER}-fpm.sock"
    if [ "$WEBSERVER" = litespeed ]; then                           # litespeed uses lsphp, not php-fpm
      [ -x "/usr/local/lsws/lsphp${PHPVER//./}/bin/lsphp" ] || { echo "BAD_PHPVER"; exit 7; }
    else
      [ -S "/run/php/php${PHPVER}-fpm.sock" ] || { echo "BAD_PHPVER"; exit 7; }
    fi
    if [ "$STACK" = node ] || [ "$STACK" = python ] || [ "$STACK" = docker ]; then
      { [[ "$PORT" =~ ^[0-9]+$ ]] && [ "$PORT" -ge 1024 ] && [ "$PORT" -le 65535 ]; } || { echo "BAD_PORT"; exit 8; }
    fi
    FQDN="${SUB}.${DOMAIN}"; ROOT="${SITES_ROOT}/${SUB}"; VHOST="/etc/nginx/sites-available/sub-${SUB}"
    [ -e "$VHOST" ] && { echo "EXISTS"; exit 3; }
    mkdir -p "$ROOT"

    PHP_LOC="location ~ \\.php\$ { include snippets/fastcgi-php.conf; fastcgi_pass ${FPM}; }"
    PROXY_LOC="location / { proxy_pass http://127.0.0.1:${PORT}; proxy_http_version 1.1; proxy_set_header Upgrade \$http_upgrade; proxy_set_header Connection \"upgrade\"; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto \$scheme; }"

    case "$STACK" in
      static)
        BODY="root ${ROOT};\n    index index.html index.htm;\n    location / { try_files \$uri \$uri/ =404; }"
        [ -f "$ROOT/index.html" ] || printf '<!doctype html><meta charset=utf-8><title>%s</title><body style="font-family:system-ui;display:grid;place-items:center;min-height:100vh;margin:0;background:#0c0d12;color:#e7e9f0"><div style="text-align:center"><h1>%s</h1><p style="color:#9aa0b4">Static site is live. Upload your files from the panel.</p></div>' "$FQDN" "$FQDN" > "$ROOT/index.html"
        ;;
      php)
        # public-aware docroot: Laravel/Acelle/Symfony-style apps live in public/; plain PHP apps live at root.
        # Resolved per-request so it works whether the app is uploaded before or after the site is created.
        # The /index.php?$query_string fallback is Laravel's front controller (the nginx equivalent of the
        # framework's shipped .htaccess — which nginx ignores), so framework routes work with zero extra setup.
        BODY="set \$sr ${ROOT};\n    if (-d ${ROOT}/public) { set \$sr ${ROOT}/public; }\n    root \$sr;\n    index index.php index.html;\n    client_max_body_size 64m;\n    location / { try_files \$uri \$uri/ /index.php?\$query_string; }\n    $(wp_guard '')\n    ${PHP_LOC}"
        [ -f "$ROOT/index.php" ] || printf '<?php echo "<h1>%s</h1><p>PHP %s is live.</p>"; phpinfo();' "$FQDN" "$PHPVER" > "$ROOT/index.php"
        ;;
      wordpress)
        BODY="set \$sr ${ROOT};\n    if (-d ${ROOT}/public) { set \$sr ${ROOT}/public; }\n    root \$sr;\n    index index.php index.html;\n    client_max_body_size 64m;\n    location / { try_files \$uri \$uri/ /index.php?\$query_string; }\n    $(wp_guard '')\n    ${PHP_LOC}"
        curl -fsSL https://wordpress.org/latest.tar.gz | tar xz --strip-components=1 -C "$ROOT" 2>/dev/null || { rm -rf "${SITES_ROOT:?}/${SUB}"; echo "WP_DOWNLOAD_FAIL"; exit 9; }
        DBKEY=$(echo "$SUB" | tr '.-' '_'); DBKEY="${DBKEY:0:24}"
        WPDB="wp_${DBKEY}"; WPUSER="wp_${DBKEY}"; WPPASS=$(openssl rand -hex 16)
        mariadb -e "CREATE DATABASE IF NOT EXISTS \`${WPDB}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER IF NOT EXISTS '${WPUSER}'@'localhost' IDENTIFIED BY '${WPPASS}'; GRANT ALL PRIVILEGES ON \`${WPDB}\`.* TO '${WPUSER}'@'localhost'; FLUSH PRIVILEGES;"
        SALTS=$(curl -fsSL https://api.wordpress.org/secret-key/1.1/salt/ || echo "")
        { echo "<?php"; echo "define('DB_NAME','${WPDB}'); define('DB_USER','${WPUSER}'); define('DB_PASSWORD','${WPPASS}');"; echo "define('DB_HOST','localhost'); define('DB_CHARSET','utf8mb4'); define('DB_COLLATE','');"; echo "$SALTS"; echo "\$table_prefix='wp_';"; echo "if (!defined('WP_DEBUG')) define('WP_DEBUG', false);"; echo "if (isset(\$_SERVER['HTTP_X_FORWARDED_PROTO']) && \$_SERVER['HTTP_X_FORWARDED_PROTO']==='https') \$_SERVER['HTTPS']='on';"; echo "define('FS_METHOD','direct'); // PHP-FPM owns the files — plugin/theme installs never prompt for FTP"; echo "if (!defined('ABSPATH')) define('ABSPATH', __DIR__ . '/');"; echo "require_once ABSPATH . 'wp-settings.php';"; } > "$ROOT/wp-config.php"
        # auto-install WordPress so it is a ready-to-use test site (admin login created)
        chown -R www-data:www-data "$ROOT"
        if command -v wp >/dev/null 2>&1; then
          # the admin of the panel's choosing (or admin + a generated password); the real password is set through
          # the environment right after the install — the throwaway one in argv is never used or shown
          WPU="${WPO_USER:-admin}"; WPADMINPASS="${WPO_PASS:-$(wp_gen_pass)}"
          if wp core install --allow-root --path="$ROOT" --url="https://${FQDN}" --title="${SUB} (test site)" --admin_user="$WPU" --admin_password="$(openssl rand -hex 24)" --admin_email="${WPO_EMAIL:-$EMAIL}" --skip-email >/dev/null 2>&1; then
            if wp_set_pass "$ROOT" "$WPU" "$WPADMINPASS"; then WPINSTALLED=1; else WPINSTALLED=2; fi   # 2 = installed, password not set
            if [ -n "$WPO_SLUG" ]; then wp_login_apply "$ROOT" "$WPO_SLUG" && WPSLUGOK=1 || true; fi
            wp_mailfrom_apply "$ROOT" || true
            wp_permalinks_apply "$ROOT" || true
          fi
        fi
        ;;
      node|python|docker)
        BODY="${PROXY_LOC}"
        [ -n "$STARTCMD" ] || case "$STACK" in node) STARTCMD='npm install --omit=dev && npm start' ;; python) STARTCMD='python3 app.py' ;; docker) STARTCMD='docker compose up' ;; esac
        [ -f "$ROOT/index.html" ] || printf '<!doctype html><title>%s</title><body style="font-family:system-ui;padding:40px;background:#0c0d12;color:#e7e9f0">Proxy ready on port %s. Upload your app and start it from the panel.</body>' "$FQDN" "$PORT" > "$ROOT/index.html"
        ;;
    esac

    # web server: for static/php/wordpress, nginx serves directly OR fronts apache/litespeed
    PROXY_HDR="proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto \$scheme;"
    if [ "$WEBSERVER" = apache ]; then
      apache_write "$SUB" "$FQDN" "$ROOT" "$PHPVER"
      BODY="location / { proxy_pass http://127.0.0.1:8080; ${PROXY_HDR} }"
    elif [ "$WEBSERVER" = litespeed ]; then
      ols_write "$SUB" "$FQDN" "$ROOT" "$PHPVER"
      BODY="location / { proxy_pass http://127.0.0.1:8088; ${PROXY_HDR} }"
    fi

    chown -R www-data:www-data "$ROOT"
    MARKER=""; [ "$WEBSERVER" = litespeed ] && MARKER="# alpha-lsphp ${PHPVER}\n"  # www-data-readable version hint (OLS conf isn't)
    printf "${MARKER}server {\n    listen 80;\n    listen [::]:80;\n    server_name ${FQDN};\n    ${BODY}\n    location ~ /\\.(?!well-known) { deny all; }\n}\n" > "$VHOST"
    ln -sf "$VHOST" "/etc/nginx/sites-enabled/sub-${SUB}"
    if ! nginx -t >/dev/null 2>&1; then rm -f "$VHOST" "/etc/nginx/sites-enabled/sub-${SUB}"; echo "NGINX_FAIL"; exit 4; fi
    systemctl reload nginx
    # process unit for proxy stacks (auto-start; may fail until code is uploaded)
    if [ "$STACK" = node ] || [ "$STACK" = python ] || [ "$STACK" = docker ]; then
      write_app_unit "$SUB" "$ROOT" "$PORT" "$STARTCMD"
      systemctl enable "$(unit_of "$SUB")" >/dev/null 2>&1 || true
      systemctl start "$(unit_of "$SUB")" >/dev/null 2>&1 || true
    fi
    if certbot --nginx -d "$FQDN" --non-interactive --agree-tos -m "$EMAIL" --redirect >/dev/null 2>&1; then echo "OK_HTTPS"; else echo "OK_HTTP"; fi
    wp_report
    ;;

  secure)
    valid "$SUB" || { echo "INVALID_NAME"; exit 2; }
    [ -e "/etc/nginx/sites-available/sub-${SUB}" ] || { echo "NO_SITE"; exit 3; }
    if certbot --nginx -d "${SUB}.${DOMAIN}" --non-interactive --agree-tos -m "$EMAIL" --redirect >/dev/null 2>&1; then echo "OK"; else echo "CERT_FAIL"; exit 5; fi
    ;;

  delete)
    valid "$SUB" || { echo "INVALID_NAME"; exit 2; }
    U="$(unit_of "$SUB")"
    systemctl stop "$U" >/dev/null 2>&1 || true; systemctl disable "$U" >/dev/null 2>&1 || true
    rm -f "/etc/systemd/system/${U}.service"; systemctl daemon-reload || true
    DBKEY=$(echo "$SUB" | tr '.-' '_'); DBKEY="${DBKEY:0:24}"
    rm -f "/etc/nginx/sites-available/sub-${SUB}" "/etc/nginx/sites-enabled/sub-${SUB}"
    apache_remove "$SUB"; ols_remove "$SUB"
    if mountpoint -q "${SITES_ROOT}/${SUB}"; then umount "${SITES_ROOT}/${SUB}" 2>/dev/null || umount -l "${SITES_ROOT}/${SUB}"; fi
    rm -f "/var/lib/alpha-quota/${SUB}.img"; sed -i "\\#alpha-quota/${SUB}.img#d" /etc/fstab 2>/dev/null || true
    rm -rf "/var/www/alpha-static/${SUB}"
    rm -rf "${SITES_ROOT:?}/${SUB}"
    mariadb -e "DROP DATABASE IF EXISTS \`wp_${DBKEY}\`; DROP USER IF EXISTS 'wp_${DBKEY}'@'localhost';" 2>/dev/null || true
    certbot delete --cert-name "${SUB}.${DOMAIN}" --non-interactive >/dev/null 2>&1 || true
    nginx -t >/dev/null 2>&1 && systemctl reload nginx || true
    echo "OK"
    ;;

  phpver)
    SUB="${2:-}"; VER="${3:-}"
    valid "$SUB" || { echo "INVALID_NAME"; exit 2; }
    case "$VER" in 7.4|8.0|8.1|8.2|8.3|8.4|8.5) ;; *) echo "BAD_PHPVER"; exit 7 ;; esac
    VHOST="/etc/nginx/sites-available/sub-${SUB}"
    APCONF="/etc/apache2/sites-available/alpha-${SUB}.conf"
    OLSVH="/usr/local/lsws/conf/vhosts/${SUB}/vhconf.conf"
    [ -e "$VHOST" ] || { echo "NO_SITE"; exit 3; }
    if grep -q "fastcgi_pass unix:/run/php/" "$VHOST"; then          # nginx-served PHP
      [ -S "/run/php/php${VER}-fpm.sock" ] || { echo "BAD_PHPVER"; exit 7; }
      sed -i -E "s#fastcgi_pass unix:/run/php/php[0-9.]+-fpm\\.sock#fastcgi_pass unix:/run/php/php${VER}-fpm.sock#g" "$VHOST"
      if nginx -t >/dev/null 2>&1; then systemctl reload nginx; echo "OK"; else echo "NGINX_FAIL"; exit 4; fi
    elif [ -f "$APCONF" ]; then                                      # apache-served PHP
      [ -S "/run/php/php${VER}-fpm.sock" ] || { echo "BAD_PHPVER"; exit 7; }
      sed -i -E "s#php[0-9.]+-fpm\\.sock#php${VER}-fpm.sock#g" "$APCONF"
      if apachectl configtest >/dev/null 2>&1; then systemctl reload apache2; echo "OK"; else echo "NGINX_FAIL"; exit 4; fi
    elif [ -f "$OLSVH" ]; then                                       # litespeed-served PHP (lsphp)
      LSV="${VER//./}"
      [ -x "/usr/local/lsws/lsphp${LSV}/bin/lsphp" ] || { echo "BAD_PHPVER"; exit 7; }
      sed -i -E "s#/usr/local/lsws/lsphp[0-9]+/bin/lsphp#/usr/local/lsws/lsphp${LSV}/bin/lsphp#g" "$OLSVH"
      if grep -q "# alpha-lsphp" "$VHOST"; then sed -i -E "s/# alpha-lsphp [0-9.]+/# alpha-lsphp ${VER}/" "$VHOST"; else sed -i "1i # alpha-lsphp ${VER}" "$VHOST"; fi
      systemctl restart lsws >/dev/null 2>&1; echo "OK"
    else
      echo "NOT_PHP"; exit 8                                         # static
    fi
    ;;

  attach)
    # attach <sub> <folder> <stack> <phpver> <port> <startcmd> — a second website in a
    # folder of an existing subdomain. nginx gets a tagged location block scoped to
    # /<folder>/ (root-based, so it works whatever the parent vhost serves), removable
    # by `detach`. WordPress gets its own DB; node/python/docker get a port proxy + unit.
    valid "$SUB" || { echo "INVALID_NAME"; exit 2; }
    F="${3:-}"; STACK="${4:-static}"; PHPVER="${5:-8.3}"; PORT="${6:-0}"; STARTCMD="${7:-}"
    { [[ "$F" =~ ^[a-z0-9][a-z0-9._-]{0,62}$ ]] && [[ "$F" != *..* ]]; } || { echo "BAD_FOLDER"; exit 9; }
    case "$F" in public|vendor|node_modules|storage|bootstrap|wp-admin|wp-content|wp-includes) echo "BAD_FOLDER"; exit 9 ;; esac
    case "$STACK" in static|php|wordpress|node|python|docker) ;; *) echo "BAD_STACK"; exit 6 ;; esac
    WPO_USER=""; WPO_PASS=""; WPO_EMAIL=""; WPO_SLUG=""
    [ "$STACK" = wordpress ] && { wp_opts_read; wp_opts_check; }
    case "$PHPVER" in 7.4|8.0|8.1|8.2|8.3|8.4|8.5) ;; *) PHPVER="8.3" ;; esac
    VHOST="/etc/nginx/sites-available/sub-${SUB}"
    [ -e "$VHOST" ] || { echo "NO_SITE"; exit 3; }
    PROOT="${SITES_ROOT}/${SUB}"; DIR="${PROOT}/${F}"; FQDN="${SUB}.${DOMAIN}"
    TAG="$(echo "${SUB}_${F}" | tr -c 'A-Za-z0-9' '_')"; TAG="${TAG%_}"
    grep -q "# ALPHA-ATTACH-${TAG}-START" "$VHOST" && { echo "EXISTS"; exit 3; }
    if [ "$STACK" = php ] || [ "$STACK" = wordpress ]; then
      [ -S "/run/php/php${PHPVER}-fpm.sock" ] || { echo "BAD_PHPVER"; exit 7; }
    fi
    if [ "$STACK" = node ] || [ "$STACK" = python ] || [ "$STACK" = docker ]; then
      { [[ "$PORT" =~ ^[0-9]+$ ]] && [ "$PORT" -ge 1024 ] && [ "$PORT" -le 65535 ]; } || { echo "BAD_PORT"; exit 8; }
    fi
    SCHEME="http"; grep -q "ssl_certificate" "$VHOST" && SCHEME="https"
    WPINSTALLED=0; WPADMINPASS=""
    mkdir -p "$DIR"
    PROXY_HDR="proxy_http_version 1.1; proxy_set_header Upgrade \$http_upgrade; proxy_set_header Connection \"upgrade\"; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto \$scheme;"
    case "$STACK" in
      static)
        [ -f "$DIR/index.html" ] || printf '<!doctype html><meta charset=utf-8><title>%s/%s</title><body style="font-family:system-ui;display:grid;place-items:center;min-height:100vh;margin:0;background:#0c0d12;color:#e7e9f0"><div style="text-align:center"><h1>%s/%s</h1><p style="color:#9aa0b4">Attached website is live. Upload your files from the panel.</p></div>' "$FQDN" "$F" "$FQDN" "$F" > "$DIR/index.html"
        INJ="    # ALPHA-ATTACH-${TAG}-START\n    location /${F}/ { root ${PROOT}; index index.html index.htm; try_files \$uri \$uri/ =404; }\n    # ALPHA-ATTACH-${TAG}-END"
        ;;
      php|wordpress)
        # NB: the injected text passes through awk -v, which interprets backslash escapes —
        # \\\\. in bash → \\. in the var → \. in the vhost (the literal-dot regex nginx needs)
        INJ="    # ALPHA-ATTACH-${TAG}-START\n    location /${F}/ { root ${PROOT}; index index.php index.html; client_max_body_size 64m; try_files \$uri \$uri/ /${F}/index.php?\$query_string; }\n    $(wp_guard "/${F}")\n    location ~ ^/${F}/.+\\\\.php\$ { root ${PROOT}; include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php${PHPVER}-fpm.sock; }\n    # ALPHA-ATTACH-${TAG}-END"
        if [ "$STACK" = php ]; then
          [ -f "$DIR/index.php" ] || printf '<?php echo "<h1>%s/%s</h1><p>PHP %s is live.</p>";' "$FQDN" "$F" "$PHPVER" > "$DIR/index.php"
        else
          curl -fsSL https://wordpress.org/latest.tar.gz | tar xz --strip-components=1 -C "$DIR" 2>/dev/null || { rm -rf "$DIR"; echo "WP_DOWNLOAD_FAIL"; exit 9; }
          DBKEY=$(echo "${SUB}_${F}" | tr './-' '___'); DBKEY="${DBKEY:0:24}"
          WPDB="wp_${DBKEY}"; WPUSER="wp_${DBKEY}"; WPPASS=$(openssl rand -hex 16)
          mariadb -e "CREATE DATABASE IF NOT EXISTS \`${WPDB}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER IF NOT EXISTS '${WPUSER}'@'localhost' IDENTIFIED BY '${WPPASS}'; GRANT ALL PRIVILEGES ON \`${WPDB}\`.* TO '${WPUSER}'@'localhost'; FLUSH PRIVILEGES;"
          SALTS=$(curl -fsSL https://api.wordpress.org/secret-key/1.1/salt/ || echo "")
          { echo "<?php"; echo "define('DB_NAME','${WPDB}'); define('DB_USER','${WPUSER}'); define('DB_PASSWORD','${WPPASS}');"; echo "define('DB_HOST','localhost'); define('DB_CHARSET','utf8mb4'); define('DB_COLLATE','');"; echo "$SALTS"; echo "\$table_prefix='wp_';"; echo "if (!defined('WP_DEBUG')) define('WP_DEBUG', false);"; echo "if (isset(\$_SERVER['HTTP_X_FORWARDED_PROTO']) && \$_SERVER['HTTP_X_FORWARDED_PROTO']==='https') \$_SERVER['HTTPS']='on';"; echo "define('FS_METHOD','direct');"; echo "if (!defined('ABSPATH')) define('ABSPATH', __DIR__ . '/');"; echo "require_once ABSPATH . 'wp-settings.php';"; } > "$DIR/wp-config.php"
          chown -R www-data:www-data "$DIR"
        fi
        ;;
      node|python|docker)
        [ -n "$STARTCMD" ] || case "$STACK" in node) STARTCMD='npm install --omit=dev && npm start' ;; python) STARTCMD='python3 app.py' ;; docker) STARTCMD='docker compose up' ;; esac
        INJ="    # ALPHA-ATTACH-${TAG}-START\n    location /${F}/ { proxy_pass http://127.0.0.1:${PORT}/; ${PROXY_HDR} }\n    # ALPHA-ATTACH-${TAG}-END"
        ;;
    esac
    chown -R www-data:www-data "$DIR"
    cp "$VHOST" "${VHOST}.bak.$(date +%s)"
    ls -1t "${VHOST}".bak.* 2>/dev/null | tail -n +4 | xargs -r rm -f
    awk -v inj="$INJ" 'BEGIN{d=0} {print} (!d && $1=="server_name"){printf "%s\n", inj; d=1}' "$VHOST" > "${VHOST}.tmp" && mv "${VHOST}.tmp" "$VHOST"
    if ! nginx -t >/dev/null 2>&1; then cp "$(ls -1t ${VHOST}.bak.* | head -1)" "$VHOST"; echo "NGINX_FAIL"; exit 4; fi
    systemctl reload nginx
    if [ "$STACK" = node ] || [ "$STACK" = python ] || [ "$STACK" = docker ]; then
      write_app_unit "${SUB}--${F}" "$DIR" "$PORT" "$STARTCMD"
      systemctl enable "$(unit_of "${SUB}--${F}")" >/dev/null 2>&1 || true
      systemctl start "$(unit_of "${SUB}--${F}")" >/dev/null 2>&1 || true
    fi
    # WordPress: install after nginx routes exist so the site URL responds
    if [ "$STACK" = wordpress ] && command -v wp >/dev/null 2>&1; then
      WPU="${WPO_USER:-admin}"; WPADMINPASS="${WPO_PASS:-$(wp_gen_pass)}"
      if wp core install --allow-root --path="$DIR" --url="${SCHEME}://${FQDN}/${F}" --title="${SUB}/${F} (test site)" --admin_user="$WPU" --admin_password="$(openssl rand -hex 24)" --admin_email="${WPO_EMAIL:-$EMAIL}" --skip-email >/dev/null 2>&1; then
        if wp_set_pass "$DIR" "$WPU" "$WPADMINPASS"; then WPINSTALLED=1; else WPINSTALLED=2; fi
        if [ -n "$WPO_SLUG" ]; then wp_login_apply "$DIR" "$WPO_SLUG" && WPSLUGOK=1 || true; fi
        wp_mailfrom_apply "$DIR" || true
        wp_permalinks_apply "$DIR" || true
      fi
      chown -R www-data:www-data "$DIR"
    fi
    echo "OK"
    wp_report
    ;;

  detach)
    # detach <sub> <folder> — remove an attached folder site: nginx block, app unit,
    # WordPress DB (if any) and the folder itself. Also used for plain folder deletes.
    valid "$SUB" || { echo "INVALID_NAME"; exit 2; }
    F="${3:-}"
    FRE='^[A-Za-z0-9._ -]+(/[A-Za-z0-9._ -]+)?$'
    { [[ "$F" =~ $FRE ]] && [[ "$F" != *..* ]] && [[ "$F" != .* ]] && [[ "$F" != */.* ]]; } || { echo "BAD_FOLDER"; exit 9; }
    VHOST="/etc/nginx/sites-available/sub-${SUB}"
    DIR="${SITES_ROOT}/${SUB}/${F}"
    [ -d "$DIR" ] || { echo "NO_FOLDER"; exit 3; }
    TAG="$(echo "${SUB}_${F}" | tr -c 'A-Za-z0-9' '_')"; TAG="${TAG%_}"
    if [ -e "$VHOST" ] && grep -q "# ALPHA-ATTACH-${TAG}-START" "$VHOST"; then
      cp "$VHOST" "${VHOST}.bak.$(date +%s)"
      ls -1t "${VHOST}".bak.* 2>/dev/null | tail -n +4 | xargs -r rm -f
      sed -i "/# ALPHA-ATTACH-${TAG}-START/,/# ALPHA-ATTACH-${TAG}-END/d" "$VHOST"
      if nginx -t >/dev/null 2>&1; then systemctl reload nginx; else cp "$(ls -1t ${VHOST}.bak.* | head -1)" "$VHOST"; fi
    fi
    U="$(unit_of "${SUB}--${F}")"
    if [ -f "/etc/systemd/system/${U}.service" ]; then
      systemctl stop "$U" >/dev/null 2>&1 || true; systemctl disable "$U" >/dev/null 2>&1 || true
      rm -f "/etc/systemd/system/${U}.service"; systemctl daemon-reload || true
    fi
    if [ -f "$DIR/wp-config.php" ]; then
      # NB: '-' must be LAST in the tr set or '.-/' parses as a range that skips it
      DBKEY=$(echo "${SUB}_${F}" | tr './-' '___'); DBKEY="${DBKEY:0:24}"
      mariadb -e "DROP DATABASE IF EXISTS \`wp_${DBKEY}\`; DROP USER IF EXISTS 'wp_${DBKEY}'@'localhost';" 2>/dev/null || true
    fi
    rm -rf "$DIR"
    echo "OK"
    ;;

  quota)
    # quota <sub> <gb|0> — REAL per-site storage: the site root becomes a fixed-size
    # loop-mounted ext4 volume. Everything writing to it (panel, FTP, SSH, the app
    # itself) hits the cap, and df / PHP disk_total_space INSIDE the site report the
    # allocated size — the server's real disk stays hidden from the website.
    # ⚠️ From the panel this runs inside php-fpm's sandbox (PrivateDevices=yes): no /dev/loop*, so the loop mount
    #    failed ("mount: /tmp/tmp.…: mount failed: No such file or directory" → MOUNT_FAIL), and a mount made in that
    #    private namespace would never reach nginx anyway. Re-run on the host as a transient unit and wait for it.
    if [ -z "${ALPHA_SITE_HOST:-}" ] && [ ! -e /dev/loop-control ]; then
      exec systemd-run --quiet --pipe --wait --collect --setenv=ALPHA_SITE_HOST=1 -- "$0" "$@"
    fi
    valid "$SUB" || { echo "INVALID_NAME"; exit 2; }
    GB="${3:-0}"
    { [[ "$GB" =~ ^[0-9]+$ ]] && [ "$GB" -le 10000 ]; } || { echo "BAD_SIZE"; exit 9; }
    ROOT="${SITES_ROOT}/${SUB}"
    [ -d "$ROOT" ] || { echo "NO_SITE"; exit 3; }
    QDIR="/var/lib/alpha-quota"; IMG="${QDIR}/${SUB}.img"; mkdir -p "$QDIR"; chmod 0700 "$QDIR"
    if [ "$GB" = 0 ]; then                                   # remove the limit — back to a plain folder
      if mountpoint -q "$ROOT"; then
        TMP="${SITES_ROOT}/.dequota-${SUB}"
        rm -rf "$TMP"; mkdir -p "$TMP"
        cp -a "$ROOT/." "$TMP/" || { rm -rf "$TMP"; echo "COPY_FAIL"; exit 10; }
        umount "$ROOT" 2>/dev/null || umount -l "$ROOT"
        rm -rf "$ROOT"; mv "$TMP" "$ROOT"; chmod 0755 "$ROOT"
      fi
      rm -f "$IMG"; sed -i "\\#alpha-quota/${SUB}.img#d" /etc/fstab; systemctl daemon-reload
      echo "OK"; exit 0
    fi
    USED_K=$(du -sk "$ROOT" 2>/dev/null | cut -f1); USED_K="${USED_K:-0}"
    NEED_K=$((GB * 1024 * 1024))
    # ext4 metadata eats a slice — demand ~5% + 20M headroom so the copy actually fits
    MIN_K=$((USED_K + USED_K / 20 + 20480))
    [ "$MIN_K" -lt "$NEED_K" ] || { echo "TOO_SMALL $((MIN_K / 1048576 + 1))"; exit 12; }
    if mountpoint -q "$ROOT"; then                           # resize an existing volume
      CUR=$(( $(stat -c %s "$IMG") / 1073741824 ))
      DEV=$(losetup -j "$IMG" | head -1 | cut -d: -f1)
      [ -n "$DEV" ] || { echo "NO_LOOPDEV"; exit 13; }
      if [ "$GB" -gt "$CUR" ]; then                          # grow online
        { truncate -s "${GB}G" "$IMG" && losetup -c "$DEV" && resize2fs "$DEV" >/dev/null 2>&1; } || { echo "RESIZE_FAIL"; exit 14; }
      elif [ "$GB" -lt "$CUR" ]; then                        # shrink needs the volume offline
        umount "$ROOT" 2>/dev/null || umount -l "$ROOT"
        RC=0; e2fsck -fp "$IMG" >/dev/null 2>&1 || RC=$?
        [ "$RC" -le 1 ] || { mount "$ROOT" 2>/dev/null || true; echo "FSCK_FAIL"; exit 15; }
        resize2fs "$IMG" "${GB}G" >/dev/null 2>&1 || { mount "$ROOT" 2>/dev/null || true; echo "RESIZE_FAIL"; exit 14; }
        truncate -s "${GB}G" "$IMG"
        mount "$ROOT" || { echo "MOUNT_FAIL"; exit 16; }
      fi
      echo "OK"; exit 0
    fi
    # first time: build the volume, copy the site in, swap it over the root
    truncate -s "${GB}G" "$IMG" || { echo "IMG_FAIL"; exit 17; }         # sparse — uses real disk only as written
    mkfs.ext4 -q -F "$IMG" >/dev/null 2>&1 || { rm -f "$IMG"; echo "MKFS_FAIL"; exit 18; }
    TMPM=$(mktemp -d)
    mount -o loop "$IMG" "$TMPM" || { rm -f "$IMG"; rmdir "$TMPM"; echo "MOUNT_FAIL"; exit 16; }
    rm -rf "$TMPM/lost+found"
    if ! cp -a "$ROOT/." "$TMPM/"; then umount "$TMPM"; rmdir "$TMPM"; rm -f "$IMG"; echo "COPY_FAIL"; exit 10; fi
    chown www-data:www-data "$TMPM"
    umount "$TMPM"; rmdir "$TMPM"
    grep -q "alpha-quota/${SUB}.img" /etc/fstab || echo "${IMG} ${ROOT} ext4 loop,nofail 0 0" >> /etc/fstab
    systemctl daemon-reload
    OLD="${SITES_ROOT}/.prequota-${SUB}"
    rm -rf "$OLD"; mv "$ROOT" "$OLD"; mkdir -p "$ROOT"
    if ! mount "$ROOT"; then
      rmdir "$ROOT"; mv "$OLD" "$ROOT"
      sed -i "\\#alpha-quota/${SUB}.img#d" /etc/fstab; rm -f "$IMG"
      echo "MOUNT_FAIL"; exit 16
    fi
    chmod 0755 "$ROOT"
    rm -rf "$OLD"
    echo "OK"
    ;;

  limits)
    # limits <sub> <upload_mb|0> <rate_kb|0> — per-site transfer limits at the nginx edge.
    # Upload cap: requests over it get a friendly popup-style 413 notice page.
    # Rate cap: per-connection download speed. 0 = panel default (64m) / no speed cap.
    valid "$SUB" || { echo "INVALID_NAME"; exit 2; }
    UP="${3:-0}"; RATE="${4:-0}"
    { [[ "$UP" =~ ^[0-9]+$ ]] && [ "$UP" -le 10240 ]; } || { echo "BAD_SIZE"; exit 9; }
    { [[ "$RATE" =~ ^[0-9]+$ ]] && [ "$RATE" -le 1048576 ]; } || { echo "BAD_SIZE"; exit 9; }
    VHOST="/etc/nginx/sites-available/sub-${SUB}"
    [ -e "$VHOST" ] || { echo "NO_SITE"; exit 3; }
    UPV="$UP"; [ "$UP" = 0 ] && UPV=64                          # 0 = back to the panel default
    cp "$VHOST" "${VHOST}.bak.$(date +%s)"
    ls -1t "${VHOST}".bak.* 2>/dev/null | tail -n +4 | xargs -r rm -f
    # drop any previous limits block and the create-time server-level default (ours replaces it);
    # location-level client_max_body_size lines (attach blocks) are indented differently and kept
    sed -i "/# ALPHA-LIMITS-START/,/# ALPHA-LIMITS-END/d; /^    client_max_body_size /d" "$VHOST"
    NOTICE_DIR="/var/www/alpha-static/${SUB}"
    LINES="    # ALPHA-LIMITS-START\n    client_max_body_size ${UPV}m;"
    [ "$RATE" != 0 ] && LINES="${LINES}\n    limit_rate ${RATE}k;"
    if [ "$UP" != 0 ]; then                                     # popup-style notice for oversized uploads
      mkdir -p "$NOTICE_DIR"
      printf '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>File too large</title></head><body style="margin:0;font-family:system-ui,sans-serif;background:rgba(8,9,13,.94);min-height:100vh;display:grid;place-items:center;padding:20px"><div style="max-width:420px;background:#15171e;border:1px solid #2a2d38;border-radius:16px;padding:30px;text-align:center;color:#e7e9f0;box-shadow:0 24px 60px rgba(0,0,0,.5)"><div style="font-size:42px;line-height:1">&#128230;</div><h2 style="margin:12px 0 6px;font-size:20px">File too large</h2><p style="color:#9aa0b4;font-size:14px;line-height:1.65;margin:0">This site is for demonstration purposes and accepts uploads up to <b style="color:#e7e9f0">%s&nbsp;MB</b>.<br>Please choose a smaller file.</p><a href="javascript:history.back()" style="display:inline-block;margin-top:20px;padding:10px 24px;border-radius:10px;background:#2F6BFF;color:#fff;text-decoration:none;font-size:14px;font-weight:600">Go back</a></div></body></html>' "$UP" > "${NOTICE_DIR}/__alpha_413.html"
      chown -R www-data:www-data "$NOTICE_DIR"
      LINES="${LINES}\n    error_page 413 /__alpha_413.html;\n    location = /__alpha_413.html { internal; root ${NOTICE_DIR}; }"
    fi
    LINES="${LINES}\n    # ALPHA-LIMITS-END"
    awk -v inj="$LINES" 'BEGIN{d=0} {print} (!d && $1=="server_name"){printf "%s\n", inj; d=1}' "$VHOST" > "${VHOST}.tmp" && mv "${VHOST}.tmp" "$VHOST"
    if ! nginx -t >/dev/null 2>&1; then cp "$(ls -1t ${VHOST}.bak.* | head -1)" "$VHOST"; echo "NGINX_FAIL"; exit 4; fi
    systemctl reload nginx
    echo "OK"
    ;;

  app)
    APP_ACTION="${2:-}"; SUB="${3:-}"
    valid "$SUB" || { echo "INVALID_NAME"; exit 2; }
    U="$(unit_of "$SUB")"
    [ -f "/etc/systemd/system/${U}.service" ] || { echo "NO_APP"; exit 3; }
    # folder apps are addressed as <sub>--<folder>; their files live in the folder
    if [[ "$SUB" == *--* ]]; then ROOT="${SITES_ROOT}/${SUB%%--*}/${SUB#*--}"; else ROOT="${SITES_ROOT}/${SUB}"; fi
    case "$APP_ACTION" in
      start)   systemctl start "$U"   >/dev/null 2>&1 && echo "OK" || echo "FAIL" ;;
      stop)    systemctl stop "$U"    >/dev/null 2>&1 && echo "OK" || echo "FAIL" ;;
      restart) systemctl restart "$U" >/dev/null 2>&1 && echo "OK" || echo "FAIL" ;;
      status)  systemctl is-active "$U" 2>/dev/null | head -1 || true ;;
      getcmd)  tail -1 "$ROOT/.alpha-run.sh" 2>/dev/null ;;
      logs)    journalctl -u "$U" -n 60 --no-pager 2>/dev/null | tail -60 ;;
      setcmd)
        CMD="${4:-}"; [ -n "$CMD" ] || { echo "EMPTY_CMD"; exit 4; }
        PORT="$(grep -oE 'PORT=[0-9]+' "$ROOT/.alpha-run.sh" 2>/dev/null | head -1 | cut -d= -f2)"; PORT="${PORT:-3000}"
        write_app_unit "$SUB" "$ROOT" "$PORT" "$CMD"
        systemctl restart "$U" >/dev/null 2>&1 || true
        echo "OK"
        ;;
      *) echo "BAD_APP_ACTION"; exit 1 ;;
    esac
    ;;

  wpguard)
    # wpguard <sub> [folder] — add the wp_guard locations (no PHP in uploads, plugin private stores never served)
    # to a vhost made before they existed. Idempotent: ALREADY when present.
    SUB="${2:-}"
    valid "$SUB" || { echo "INVALID_NAME"; exit 2; }
    VHOST="/etc/nginx/sites-available/sub-${SUB}"
    [ -e "$VHOST" ] || { echo "NO_SITE"; exit 3; }
    F="${3:-}"; P=""
    if [ -n "$F" ]; then
      FRE='^[A-Za-z0-9._ -]+(/[A-Za-z0-9._ -]+)?$'
      { [[ "$F" =~ $FRE ]] && [[ "$F" != *..* ]] && [[ "$F" != .* ]] && [[ "$F" != */.* ]]; } || { echo "BAD_FOLDER"; exit 9; }
      P="/${F}"
    fi
    grep -qF "location ~* ^${P}/wp-content/uploads/.+[.](php" "$VHOST" && { echo "ALREADY"; exit 0; }
    G="$(wp_guard "$P" | sed 's/\\n    /\n/')"
    cp "$VHOST" "${VHOST}.bak.$(date +%s)"
    ls -1t "${VHOST}".bak.* 2>/dev/null | tail -n +4 | xargs -r rm -f
    if [ -n "$F" ]; then   # right after the folder's own location, inside its ALPHA-ATTACH block (detach removes it too)
      awk -v loc="/${F}/" -v g="$G" '{print} (!d && $1=="location" && $2==loc){n=split(g, a, "\n"); for(i=1;i<=n;i++) printf "    %s\n", a[i]; d=1}' "$VHOST" > "${VHOST}.tmp"
    else                   # before the first PHP handler (regex locations match in order)
      awk -v g="$G" '(!d && $1=="location" && $2=="~" && $3 ~ /php/){n=split(g, a, "\n"); for(i=1;i<=n;i++) printf "    %s\n", a[i]; d=1} {print}' "$VHOST" > "${VHOST}.tmp"
    fi
    grep -qF "/wp-content/uploads/.+[.](php" "${VHOST}.tmp" || { rm -f "${VHOST}.tmp"; echo "NO_ANCHOR"; exit 5; }
    mv "${VHOST}.tmp" "$VHOST"
    if nginx -t >/dev/null 2>&1; then systemctl reload nginx; echo "OK"; else cp "$(ls -1t ${VHOST}.bak.* | head -1)" "$VHOST"; echo "NGINX_FAIL"; exit 4; fi
    ;;

  wppass)
    # wppass <sub> [folder] — folder targets a WordPress attached at /<folder>/ on the subdomain.
    # stdin pass=<b64> sets that password; nothing piped = a generated one.
    SUB="${2:-}"
    valid "$SUB" || { echo "INVALID_NAME"; exit 2; }
    ROOT=$(wp_root_of "$SUB" "${3:-}") || { echo "${ROOT:-NOT_WP}"; exit 3; }
    command -v wp >/dev/null 2>&1 || { echo "NO_WPCLI"; exit 4; }
    wp_opts_read; wp_opts_check
    U=$(wp user list --role=administrator --field=user_login --path="$ROOT" --allow-root 2>/dev/null | head -1)
    [ -n "$U" ] || { echo "NO_ADMIN"; exit 5; }
    NEWPASS="${WPO_PASS:-$(wp_gen_pass)}"
    if wp_set_pass "$ROOT" "$U" "$NEWPASS"; then echo "WPPASS ${U} ${NEWPASS}"; else echo "FAIL"; exit 6; fi
    ;;

  wplogin)
    # wplogin <sub> [folder] — stdin slug=<b64>: the custom sign-in address; empty = back to wp-login.php
    SUB="${2:-}"
    valid "$SUB" || { echo "INVALID_NAME"; exit 2; }
    ROOT=$(wp_root_of "$SUB" "${3:-}") || { echo "${ROOT:-NOT_WP}"; exit 3; }
    command -v wp >/dev/null 2>&1 || { echo "NO_WPCLI"; exit 4; }
    wp_opts_read; wp_opts_check
    if wp_login_apply "$ROOT" "$WPO_SLUG"; then echo "OK ${WPO_SLUG:--}"; else echo "FAIL"; exit 6; fi
    ;;

  wpmailfrom)
    # wpmailfrom <sub> [folder] — (re)install the sender-address guard on an existing WordPress site
    SUB="${2:-}"
    valid "$SUB" || { echo "INVALID_NAME"; exit 2; }
    ROOT=$(wp_root_of "$SUB" "${3:-}") || { echo "${ROOT:-NOT_WP}"; exit 3; }
    if wp_mailfrom_apply "$ROOT"; then echo "OK"; else echo "FAIL"; exit 6; fi
    ;;

  wpinfo)
    # wpinfo <sub> [folder] → WPINFO <first administrator login> <custom sign-in slug | ->
    SUB="${2:-}"
    valid "$SUB" || { echo "INVALID_NAME"; exit 2; }
    ROOT=$(wp_root_of "$SUB" "${3:-}") || { echo "${ROOT:-NOT_WP}"; exit 3; }
    command -v wp >/dev/null 2>&1 || { echo "NO_WPCLI"; exit 4; }
    U=$(wp user list --role=administrator --field=user_login --path="$ROOT" --allow-root 2>/dev/null | head -1)
    S=""
    [ -f "$ROOT/wp-content/mu-plugins/alpha-login-url.php" ] && S=$(wp option get alpha_login_slug --path="$ROOT" --allow-root 2>/dev/null || true)
    wp_slug_ok "${S:-x}" || S=""
    echo "WPINFO ${U:--} ${S:--}"
    ;;

  laravel)
    # laravel <sub> [subpath]
    # Make a Laravel app deployed under a panel site "just work", reproducibly — the same
    # fixes we'd otherwise apply by hand: public/ routing, .env -> production, app key,
    # storage link, and cached config/routes/views (route:cache degrades gracefully on apps
    # with duplicate route names). With a [subpath] it serves a Laravel app from a folder of
    # an existing site (e.g. demo + Evanto/Adixo) including the cached-route root workaround.
    SUB="${2:-}"; SUBPATH="${3:-}"
    valid "$SUB" || { echo "INVALID_NAME"; exit 2; }
    VHOST="/etc/nginx/sites-available/sub-${SUB}"
    [ -e "$VHOST" ] || { echo "NO_SITE"; exit 3; }
    ROOT="${SITES_ROOT}/${SUB}"; APPDIR="$ROOT"
    if [ -n "$SUBPATH" ]; then
      SUBPATH="${SUBPATH#/}"; SUBPATH="${SUBPATH%/}"
      { [[ "$SUBPATH" =~ ^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$ ]] && [[ "$SUBPATH" != *..* ]]; } || { echo "BAD_SUBPATH"; exit 9; }
      APPDIR="$ROOT/$SUBPATH"
    fi
    [ -f "$APPDIR/artisan" ] || { echo "NOT_LARAVEL"; exit 10; }
    [ -d "$APPDIR/public" ] || { echo "NO_PUBLIC"; exit 11; }
    command -v php >/dev/null 2>&1 || { echo "NO_PHP"; exit 12; }

    FQDN="${SUB}.${DOMAIN}"
    BASEURL="https://${FQDN}"; [ -n "$SUBPATH" ] && BASEURL="${BASEURL}/${SUBPATH}"
    PV="$(grep -oE 'php[0-9]+\.[0-9]+-fpm\.sock' "$VHOST" | head -1 | grep -oE '[0-9]+\.[0-9]+')"; PV="${PV:-8.3}"
    FPM="unix:/run/php/php${PV}-fpm.sock"
    RU="runuser -u www-data --"

    # 1. ownership + writable runtime dirs
    chown -R www-data:www-data "$APPDIR"
    chmod -R ug+rwX "$APPDIR/storage" "$APPDIR/bootstrap/cache" 2>/dev/null || true

    # 2. .env -> production (seed from .env.example if missing)
    [ -f "$APPDIR/.env" ] || { [ -f "$APPDIR/.env.example" ] && cp "$APPDIR/.env.example" "$APPDIR/.env" && chown www-data:www-data "$APPDIR/.env"; }
    if [ -f "$APPDIR/.env" ]; then
      grep -q '^APP_ENV='   "$APPDIR/.env" && sed -i -E "s|^APP_ENV=.*|APP_ENV=production|"   "$APPDIR/.env" || echo "APP_ENV=production"   >> "$APPDIR/.env"
      grep -q '^APP_DEBUG=' "$APPDIR/.env" && sed -i -E "s|^APP_DEBUG=.*|APP_DEBUG=false|"    "$APPDIR/.env" || echo "APP_DEBUG=false"      >> "$APPDIR/.env"
      grep -q '^APP_URL='   "$APPDIR/.env" && sed -i -E "s|^APP_URL=.*|APP_URL=${BASEURL}|"   "$APPDIR/.env" || echo "APP_URL=${BASEURL}"   >> "$APPDIR/.env"
    fi

    # 2b. force URL generation from APP_URL so links keep the base path under a subdirectory
    #     (the nginx fix routes requests; this fixes the URLs the app *prints*). Idempotent:
    #     skipped if any forceRootUrl already exists. Rolls back on syntax error.
    PROV="$APPDIR/app/Providers/AppServiceProvider.php"
    if [ -f "$PROV" ] && ! grep -q 'forceRootUrl' "$PROV"; then
      cp "$PROV" "${PROV}.bak.$(date +%s)"
      PP="$(mktemp)"; chmod 0644 "$PP"
      cat > "$PP" <<'PHPEOF'
<?php
$f = $argv[1];
$s = @file_get_contents($f);
if ($s === false || strpos($s, 'forceRootUrl') !== false) exit;
if (!preg_match('/function\s+boot\s*\([^)]*\)\s*(?::\s*\w+)?\s*\{/', $s, $m, PREG_OFFSET_CAPTURE)) exit;
$pos = $m[0][1] + strlen($m[0][0]);
$inj = "\n        // alpha-forceRootUrl: keep generated URLs on APP_URL's base path (subdirectory-safe)\n        if (\$r = config('app.url')) { \\Illuminate\\Support\\Facades\\URL::forceRootUrl(\$r); if (str_starts_with(\$r, 'https://')) \\Illuminate\\Support\\Facades\\URL::forceScheme('https'); }";
file_put_contents($f, substr($s, 0, $pos) . $inj . substr($s, $pos));
PHPEOF
      php "$PP" "$PROV" >/dev/null 2>&1 || true
      rm -f "$PP"
      chown www-data:www-data "$PROV"
      php -l "$PROV" >/dev/null 2>&1 || cp "$(ls -1t ${PROV}.bak.* 2>/dev/null | head -1)" "$PROV"
      ls -1t "${PROV}".bak.* 2>/dev/null | tail -n +4 | xargs -r rm -f
    fi

    cd "$APPDIR" || { echo "NO_APPDIR"; exit 13; }
    # 3. composer deps if vendor is absent
    [ -d "$APPDIR/vendor" ] || ! command -v composer >/dev/null 2>&1 || $RU composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader >/dev/null 2>&1 || true
    # 4. app key + storage symlink
    grep -qE '^APP_KEY=base64:' "$APPDIR/.env" 2>/dev/null || $RU php artisan key:generate --force >/dev/null 2>&1 || true
    [ -e "$APPDIR/public/storage" ] || $RU php artisan storage:link >/dev/null 2>&1 || true
    # 5. caches — route:cache can fail on duplicate route names; degrade to cleared routes
    $RU php artisan config:cache >/dev/null 2>&1 || true
    $RU php artisan view:cache   >/dev/null 2>&1 || true
    ROUTEWARN=0
    $RU php artisan route:cache  >/dev/null 2>&1 || { $RU php artisan route:clear >/dev/null 2>&1 || true; ROUTEWARN=1; }

    # 6. nginx routing
    if [ -n "$SUBPATH" ]; then
      P="/${SUBPATH}"; PUB="${APPDIR}/public"; TAG="lv_$(echo "$SUBPATH" | tr '/.-' '___')"
      cp "$VHOST" "${VHOST}.bak.$(date +%s)"
      ls -1t "${VHOST}".bak.* 2>/dev/null | tail -n +4 | xargs -r rm -f   # keep only the 3 most recent backups
      sed -i "\\#^[[:space:]]*location .*${P}/#d; \\#location @${TAG} #d" "$VHOST"   # drop any prior injection (idempotent)
      # Three blocks per Laravel subdir, all funnelling to public/index.php with an explicit
      # SCRIPT_NAME=${P}/index.php so Laravel's base path is ${P} (the nginx alias bug otherwise
      # corrupts SCRIPT_NAME and breaks non-root routes):
      #   = ${P}/      -> root, with REQUEST_URI faked to .../index.php (cached-route 405 workaround)
      #   ${P}/        -> serve real files from public/, else hand to the named front controller
      #   @${TAG}      -> the front controller (keeps the real REQUEST_URI for correct route matching)
      # Use fastcgi_params (not snippets/fastcgi-php.conf) — the latter's "try_files
      # $fastcgi_script_name =404" guard 404s framework routes that aren't real files.
      # SCRIPT_FILENAME is pinned to public/index.php (always present), so no guard is needed.
      INJ="    location = ${P}/ { include fastcgi_params; fastcgi_pass ${FPM}; fastcgi_param SCRIPT_FILENAME ${PUB}/index.php; fastcgi_param SCRIPT_NAME ${P}/index.php; fastcgi_param REQUEST_URI ${P}/index.php; }\n    location ${P}/ { alias ${PUB}/; try_files \$uri \$uri/ @${TAG}; }\n    location @${TAG} { include fastcgi_params; fastcgi_pass ${FPM}; fastcgi_param SCRIPT_FILENAME ${PUB}/index.php; fastcgi_param SCRIPT_NAME ${P}/index.php; }"
      awk -v inj="$INJ" 'BEGIN{d=0} {print} (!d && $1=="server_name"){printf "%s\n", inj; d=1}' "$VHOST" > "${VHOST}.tmp" && mv "${VHOST}.tmp" "$VHOST"
      if ! nginx -t >/dev/null 2>&1; then cp "$(ls -1t ${VHOST}.bak.* | head -1)" "$VHOST"; echo "NGINX_FAIL"; exit 4; fi
      systemctl reload nginx
    fi
    [ "$ROUTEWARN" = 1 ] && echo "OK_NO_ROUTECACHE" || echo "OK"
    ;;

  *) echo "BAD_ACTION"; exit 1 ;;
esac
