#!/usr/bin/env bash set -Eeuo pipefail SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" APP_SOURCE="$SCRIPT_DIR/app" TARGET="/var/www/html/pnpaas" DB_NAME="pnpaas" DB_USER="pnpaas" DB_HOST="localhost" ADMIN_USER="admin" ADMIN_EMAIL="" APP_URL="" PORTAINER_URL="" DRY_RUN=0 usage() { printf '%s\n' \ 'Usage: sudo ./install.sh [options]' \ ' --target PATH Application target (default: /var/www/html/pnpaas)' \ ' --db-name NAME MariaDB database (default: pnpaas)' \ ' --db-user NAME MariaDB application user (default: pnpaas)' \ ' --db-host HOST MariaDB host (default: localhost)' \ ' --admin-user NAME Initial administrator username (default: admin)' \ ' --admin-email EMAIL Initial administrator email' \ ' --app-url URL Public HTTPS URL, e.g. https://example/pnpaas' \ ' --portainer-url URL Backend Portainer URL' \ ' --check Validate prerequisites without changes' \ ' --help Show this help' } while (($#)); do case "$1" in --target) TARGET="$2"; shift 2;; --db-name) DB_NAME="$2"; shift 2;; --db-user) DB_USER="$2"; shift 2;; --db-host) DB_HOST="$2"; shift 2;; --admin-user) ADMIN_USER="$2"; shift 2;; --admin-email) ADMIN_EMAIL="$2"; shift 2;; --app-url) APP_URL="$2"; shift 2;; --portainer-url) PORTAINER_URL="$2"; shift 2;; --check) DRY_RUN=1; shift;; --help) usage; exit 0;; *) printf 'Unknown option: %s\n' "$1" >&2; usage >&2; exit 2;; esac done fail() { printf 'ERROR: %s\n' "$1" >&2; exit 1; } require_command() { command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1"; } sql_escape() { local value="$1"; printf "%s" "${value//\'/\'\'}"; } [[ "$(id -u)" -eq 0 ]] || fail 'Run this installer as root, for example: sudo ./install.sh' [[ -d "$APP_SOURCE" ]] || fail "Application source not found: $APP_SOURCE" require_command php require_command mariadb require_command openssl require_command apache2ctl php -m | grep -qi '^pdo_mysql$' || fail 'PHP extension pdo_mysql is required.' php -m | grep -qi '^curl$' || fail 'PHP extension curl is required.' php -m | grep -qi '^mbstring$' || fail 'PHP extension mbstring is required.' [[ "$DB_NAME" =~ ^[A-Za-z0-9_]{1,64}$ ]] || fail 'Database name must contain only letters, numbers, and underscores.' [[ "$DB_USER" =~ ^[A-Za-z0-9_]{1,32}$ ]] || fail 'Database user must contain only letters, numbers, and underscores.' [[ "$DB_HOST" =~ ^[A-Za-z0-9_.:-]{1,253}$ ]] || fail 'Database host contains invalid characters.' [[ "$ADMIN_USER" =~ ^[A-Za-z0-9_.-]{3,80}$ ]] || fail 'Admin username must contain 3-80 ASCII letters, numbers, dot, dash, or underscore.' [[ -z "$APP_URL" || "$APP_URL" == https://* ]] || fail '--app-url must use HTTPS.' [[ -z "$PORTAINER_URL" || "$PORTAINER_URL" == https://* ]] || fail '--portainer-url must use HTTPS.' if [[ -n "$ADMIN_EMAIL" ]]; then php -r 'exit(filter_var($argv[1], FILTER_VALIDATE_EMAIL) ? 0 : 1);' "$ADMIN_EMAIL" || fail 'Invalid administrator email address.' fi if ((DRY_RUN)); then printf 'Prerequisite check passed. No files, services, or database objects were changed.\n' exit 0 fi [[ -n "$ADMIN_EMAIL" ]] || read -r -p 'Administrator email: ' ADMIN_EMAIL php -r 'exit(filter_var($argv[1], FILTER_VALIDATE_EMAIL) ? 0 : 1);' "$ADMIN_EMAIL" || fail 'Invalid administrator email address.' read -r -s -p 'Initial administrator password (empty = generate one): ' ADMIN_PASSWORD; printf '\n' if [[ -z "$ADMIN_PASSWORD" ]]; then ADMIN_PASSWORD="$(openssl rand -hex 24)" GENERATED_ADMIN_PASSWORD=1 else GENERATED_ADMIN_PASSWORD=0 fi [[ ${#ADMIN_PASSWORD} -ge 12 ]] || fail 'Administrator password must be at least 12 characters.' DB_PASSWORD="$(openssl rand -hex 32)" ADMIN_HASH="$(php -r 'echo password_hash($argv[1], PASSWORD_DEFAULT);' "$ADMIN_PASSWORD")" install -d -m 0755 "$TARGET" cp -a "$APP_SOURCE/." "$TARGET/" rm -f "$TARGET/.env" SQL_TMP="$(mktemp)" cleanup() { rm -f "$SQL_TMP"; } trap cleanup EXIT { printf 'CREATE DATABASE IF NOT EXISTS `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\n' "$(sql_escape "$DB_NAME")" printf "CREATE USER IF NOT EXISTS '%s'@'%s' IDENTIFIED BY '%s';\n" "$(sql_escape "$DB_USER")" "$(sql_escape "$DB_HOST")" "$(sql_escape "$DB_PASSWORD")" printf "ALTER USER '%s'@'%s' IDENTIFIED BY '%s';\n" "$(sql_escape "$DB_USER")" "$(sql_escape "$DB_HOST")" "$(sql_escape "$DB_PASSWORD")" printf 'GRANT ALL PRIVILEGES ON `%s`.* TO '\''%s'\''@'\''%s'\'';\n' "$(sql_escape "$DB_NAME")" "$(sql_escape "$DB_USER")" "$(sql_escape "$DB_HOST")" printf 'FLUSH PRIVILEGES;\n' } > "$SQL_TMP" mariadb < "$SQL_TMP" mariadb "$DB_NAME" < "$TARGET/database/schema.sql" for migration in "$TARGET"/database/migrations/*.sql; do mariadb "$DB_NAME" < "$migration" done ADMIN_SQL_TMP="$(mktemp)" trap 'rm -f "$SQL_TMP" "$ADMIN_SQL_TMP"' EXIT printf "INSERT INTO users (username, email, password_hash, role, status) VALUES ('%s','%s','%s','admin','active') ON DUPLICATE KEY UPDATE email=VALUES(email), password_hash=VALUES(password_hash), role='admin', status='active';\n" \ "$(sql_escape "$ADMIN_USER")" "$(sql_escape "$ADMIN_EMAIL")" "$(sql_escape "$ADMIN_HASH")" > "$ADMIN_SQL_TMP" mariadb "$DB_NAME" < "$ADMIN_SQL_TMP" umask 027 cat > "$TARGET/.env" < "$APACHE_CONF" < Options -Indexes AllowOverride None Require all granted Require all denied Require all denied EOF if command -v a2enconf >/dev/null 2>&1; then a2enconf pnpaas >/dev/null; fi apache2ctl configtest >/dev/null systemctl reload apache2 printf '\nPnPaaS installation completed.\nTarget: %s\nDatabase: %s\nAdministrator: %s\n' "$TARGET" "$DB_NAME" "$ADMIN_USER" if ((GENERATED_ADMIN_PASSWORD)); then printf 'Initial administrator password (displayed once): %s\n' "$ADMIN_PASSWORD" fi printf 'Next steps: configure Portainer and legal-document URLs in %s/.env, then configure HTTPS.\n' "$TARGET"