From e802443d4af432344ed9c849333c940f32a84d29 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Sun, 7 Jun 2026 05:05:30 +0000 Subject: [PATCH] feat: NovaCPX v1.0.0 initial scaffold Full hosting control panel with 3 tiers: Admin, Reseller, User. - install.sh: unattended installer for Ubuntu 20/22/24 + Debian 11/12 - PHP multi-version (7.4/8.1/8.2/8.3), Apache2/nginx choice, MySQL, PostgreSQL - BIND9 DNS, Postfix+Dovecot mail, ProFTPD, Certbot SSL, UFW, Fail2Ban - 18-table DB schema with audit log and version tracking - PHP REST API (auth, system/updates, server stats, service control) - Admin panel: dark dashboard, service manager, git-based update system - User panel: usage rings + feature card grid (distinct from cPanel) - VERSION file: git-tracked; Admin > Updates panel shows/applies git commits Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 9 + VERSION | 1 + db/schema.sql | 372 +++++++++++++++++++++++++++ install.sh | 422 +++++++++++++++++++++++++++++++ panel/api/endpoints/auth.php | 44 ++++ panel/api/endpoints/system.php | 158 ++++++++++++ panel/api/index.php | 56 ++++ panel/lib/Auth.php | 100 ++++++++ panel/lib/Core.php | 54 ++++ panel/lib/DB.php | 43 ++++ panel/lib/Response.php | 29 +++ panel/public/.htaccess | 17 ++ panel/public/admin/index.php | 176 +++++++++++++ panel/public/assets/css/nova.css | 302 ++++++++++++++++++++++ panel/public/assets/js/admin.js | 315 +++++++++++++++++++++++ panel/public/assets/js/nova.js | 125 +++++++++ panel/public/index.php | 130 ++++++++++ panel/public/user/index.php | 269 ++++++++++++++++++++ 18 files changed, 2622 insertions(+) create mode 100644 .gitignore create mode 100644 VERSION create mode 100644 db/schema.sql create mode 100644 install.sh create mode 100644 panel/api/endpoints/auth.php create mode 100644 panel/api/endpoints/system.php create mode 100644 panel/api/index.php create mode 100644 panel/lib/Auth.php create mode 100644 panel/lib/Core.php create mode 100644 panel/lib/DB.php create mode 100644 panel/lib/Response.php create mode 100644 panel/public/.htaccess create mode 100644 panel/public/admin/index.php create mode 100644 panel/public/assets/css/nova.css create mode 100644 panel/public/assets/js/admin.js create mode 100644 panel/public/assets/js/nova.js create mode 100644 panel/public/index.php create mode 100644 panel/public/user/index.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d57d6e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# NovaCPX .gitignore +/etc/novacpx/config.ini +panel/api/config.php +*.log +*.tmp +/var/ +node_modules/ +.DS_Store +Thumbs.db diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..65b169e --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,372 @@ +-- NovaCPX Database Schema v1.0.0 +-- Engine: MySQL 8+ | Charset: utf8mb4_unicode_ci + +SET NAMES utf8mb4; +SET foreign_key_checks = 0; + +-- ── Version tracking ────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS novacpx_version ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + version VARCHAR(20) NOT NULL, + installed_at DATETIME DEFAULT CURRENT_TIMESTAMP, + notes TEXT, + git_commit VARCHAR(64), + INDEX idx_version (version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO novacpx_version (version, notes, git_commit) +VALUES ('1.0.0', 'Initial installation', 'HEAD'); + +-- ── Audit log (every action tracked) ───────────────────────────────────────── +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED, + username VARCHAR(100), + action VARCHAR(100) NOT NULL, + resource VARCHAR(200), + detail JSON, + ip_address VARCHAR(45), + user_agent TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user (user_id), + INDEX idx_action (action), + INDEX idx_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── Users (admin, resellers, end-users) ─────────────────────────────────────── +CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + role ENUM('admin','reseller','user') DEFAULT 'user', + status ENUM('active','suspended','pending') DEFAULT 'active', + reseller_id INT UNSIGNED DEFAULT NULL, + package_id INT UNSIGNED DEFAULT NULL, + theme VARCHAR(50) DEFAULT 'nova-dark', + language VARCHAR(10) DEFAULT 'en', + contact_name VARCHAR(200), + contact_phone VARCHAR(50), + last_login DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (reseller_id) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_role (role), + INDEX idx_status (status), + INDEX idx_reseller (reseller_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── Sessions ────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS sessions ( + id VARCHAR(128) PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + ip_address VARCHAR(45), + user_agent TEXT, + data JSON, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── Packages / Hosting Plans ────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS packages ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + owner_id INT UNSIGNED DEFAULT NULL, -- NULL = system, or reseller + disk_mb INT UNSIGNED DEFAULT 1024, + bandwidth_mb BIGINT UNSIGNED DEFAULT 10240, + max_domains SMALLINT UNSIGNED DEFAULT 1, + max_subdomains SMALLINT UNSIGNED DEFAULT 10, + max_addon_domains SMALLINT UNSIGNED DEFAULT 0, + max_parked_domains SMALLINT UNSIGNED DEFAULT 5, + max_email SMALLINT UNSIGNED DEFAULT 10, + max_ftp SMALLINT UNSIGNED DEFAULT 5, + max_databases SMALLINT UNSIGNED DEFAULT 5, + php_version VARCHAR(10) DEFAULT '8.3', + ssl_enabled TINYINT(1) DEFAULT 1, + is_default TINYINT(1) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_owner (owner_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO packages (name, disk_mb, bandwidth_mb, max_domains, max_email, max_databases, is_default) +VALUES ('Default', 5120, 51200, 5, 25, 10, 1); + +-- ── Hosting Accounts ────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS accounts ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL UNIQUE, + username VARCHAR(32) NOT NULL UNIQUE, + domain VARCHAR(253) NOT NULL, + home_dir VARCHAR(500) NOT NULL, + package_id INT UNSIGNED, + disk_used_mb INT UNSIGNED DEFAULT 0, + bw_used_mb BIGINT UNSIGNED DEFAULT 0, + php_version VARCHAR(10) DEFAULT '8.3', + web_server ENUM('apache','nginx') DEFAULT 'apache', + status ENUM('active','suspended','terminated') DEFAULT 'active', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + suspended_at DATETIME DEFAULT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE SET NULL, + INDEX idx_domain (domain), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── Domains ─────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS domains ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + account_id INT UNSIGNED NOT NULL, + domain VARCHAR(253) NOT NULL, + type ENUM('main','addon','subdomain','parked','alias') DEFAULT 'main', + document_root VARCHAR(500), + php_version VARCHAR(10), + ssl_enabled TINYINT(1) DEFAULT 0, + ssl_cert TEXT, + ssl_key TEXT, + ssl_chain TEXT, + ssl_expires DATE, + redirect_to VARCHAR(500) DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, + UNIQUE KEY uq_domain (domain), + INDEX idx_account (account_id), + INDEX idx_type (type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── DNS Zones & Records ─────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS dns_zones ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + account_id INT UNSIGNED NOT NULL, + domain VARCHAR(253) NOT NULL UNIQUE, + serial BIGINT UNSIGNED DEFAULT 1, + primary_ns VARCHAR(253) DEFAULT 'ns1.localhost', + secondary_ns VARCHAR(253) DEFAULT 'ns2.localhost', + admin_email VARCHAR(255) DEFAULT 'hostmaster@localhost', + ttl INT UNSIGNED DEFAULT 3600, + refresh INT UNSIGNED DEFAULT 86400, + retry INT UNSIGNED DEFAULT 7200, + expire INT UNSIGNED DEFAULT 2419200, + minimum INT UNSIGNED DEFAULT 86400, + status ENUM('active','disabled') DEFAULT 'active', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, + INDEX idx_account (account_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS dns_records ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + zone_id INT UNSIGNED NOT NULL, + name VARCHAR(253) NOT NULL, + type ENUM('A','AAAA','CNAME','MX','TXT','SRV','NS','PTR','CAA','DKIM','SPF','DMARC') NOT NULL, + content TEXT NOT NULL, + ttl INT UNSIGNED DEFAULT 3600, + priority SMALLINT UNSIGNED DEFAULT NULL, + proxied TINYINT(1) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (zone_id) REFERENCES dns_zones(id) ON DELETE CASCADE, + INDEX idx_zone (zone_id), + INDEX idx_type (type), + INDEX idx_name (name(100)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── Email Accounts & Forwarders ─────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS email_accounts ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + account_id INT UNSIGNED NOT NULL, + email VARCHAR(320) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + quota_mb INT UNSIGNED DEFAULT 500, + used_mb INT UNSIGNED DEFAULT 0, + status ENUM('active','suspended') DEFAULT 'active', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, + INDEX idx_account (account_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS email_forwarders ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + account_id INT UNSIGNED NOT NULL, + source VARCHAR(320) NOT NULL, + destination VARCHAR(320) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS email_autoresponders ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + account_id INT UNSIGNED NOT NULL, + email VARCHAR(320) NOT NULL, + subject VARCHAR(255), + body TEXT, + is_active TINYINT(1) DEFAULT 1, + start_at DATETIME, + stop_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── Databases ───────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS databases ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + account_id INT UNSIGNED NOT NULL, + db_name VARCHAR(100) NOT NULL, + db_user VARCHAR(100) NOT NULL, + db_pass VARCHAR(255) NOT NULL, + db_type ENUM('mysql','postgresql') DEFAULT 'mysql', + size_mb FLOAT DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, + INDEX idx_account (account_id), + INDEX idx_type (db_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── FTP Accounts ───────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS ftp_accounts ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + account_id INT UNSIGNED NOT NULL, + username VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + home_dir VARCHAR(500) NOT NULL, + quota_mb INT UNSIGNED DEFAULT 0, + status ENUM('active','suspended') DEFAULT 'active', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── SSL Certificates ────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS ssl_certs ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + account_id INT UNSIGNED NOT NULL, + domain VARCHAR(253) NOT NULL, + type ENUM('lets_encrypt','self_signed','custom') DEFAULT 'lets_encrypt', + cert TEXT, + private_key TEXT, + chain TEXT, + issued_at DATETIME, + expires_at DATETIME, + auto_renew TINYINT(1) DEFAULT 1, + status ENUM('active','expired','pending','failed') DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, + INDEX idx_domain (domain), + INDEX idx_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── Cron Jobs ───────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS cron_jobs ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + account_id INT UNSIGNED NOT NULL, + command TEXT NOT NULL, + minute VARCHAR(20) DEFAULT '*', + hour VARCHAR(20) DEFAULT '*', + day VARCHAR(20) DEFAULT '*', + month VARCHAR(20) DEFAULT '*', + weekday VARCHAR(20) DEFAULT '*', + is_active TINYINT(1) DEFAULT 1, + last_run DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── PHP Configuration ───────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS php_configs ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + account_id INT UNSIGNED NOT NULL UNIQUE, + php_version VARCHAR(10) DEFAULT '8.3', + memory_limit VARCHAR(20) DEFAULT '256M', + max_execution_time INT DEFAULT 30, + upload_max_filesize VARCHAR(20) DEFAULT '64M', + post_max_size VARCHAR(20) DEFAULT '64M', + max_input_vars INT DEFAULT 1000, + display_errors TINYINT(1) DEFAULT 0, + error_reporting VARCHAR(50) DEFAULT 'E_ALL & ~E_NOTICE', + extensions JSON, + updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── Backups ─────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS backups ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + account_id INT UNSIGNED NOT NULL, + filename VARCHAR(500) NOT NULL, + size_mb FLOAT DEFAULT 0, + type ENUM('full','partial','db_only','files_only') DEFAULT 'full', + status ENUM('pending','running','complete','failed') DEFAULT 'pending', + storage ENUM('local','s3','ftp','sftp') DEFAULT 'local', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + completed_at DATETIME, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, + INDEX idx_account (account_id), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── Server Stats / Monitoring ───────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS server_stats ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + cpu_pct FLOAT, + ram_pct FLOAT, + disk_pct FLOAT, + load_1m FLOAT, + load_5m FLOAT, + load_15m FLOAT, + net_in_kb BIGINT UNSIGNED DEFAULT 0, + net_out_kb BIGINT UNSIGNED DEFAULT 0, + recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_recorded (recorded_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── Notifications ───────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS notifications ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + type ENUM('info','success','warning','error') DEFAULT 'info', + is_read TINYINT(1) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user (user_id), + INDEX idx_unread (is_read) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── API Tokens ──────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS api_tokens ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + name VARCHAR(100) NOT NULL, + token VARCHAR(128) NOT NULL UNIQUE, + permissions JSON, + last_used DATETIME, + expires_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_token (token) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── Settings ────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS settings ( + `key` VARCHAR(100) PRIMARY KEY, + `value` TEXT, + updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO settings (`key`, `value`) VALUES + ('panel_name', 'NovaCPX'), + ('panel_version', '1.0.0'), + ('default_nameserver1', 'ns1.localhost'), + ('default_nameserver2', 'ns2.localhost'), + ('default_php', '8.3'), + ('mail_enabled', '1'), + ('ftp_enabled', '1'), + ('dns_enabled', '1'), + ('backup_dir', '/var/novacpx/backups'), + ('update_channel', 'stable'), + ('git_remote', 'https://github.com/myronblair/novacpx.git') +ON DUPLICATE KEY UPDATE `value` = VALUES(`value`); + +SET foreign_key_checks = 1; diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..6ed2f24 --- /dev/null +++ b/install.sh @@ -0,0 +1,422 @@ +#!/usr/bin/env bash +# NovaCPX Installer — Linux Web Hosting Control Panel +# Supports: Ubuntu 20.04/22.04/24.04, Debian 11/12 +# Usage: curl -fsSL https://novacpx.io/install.sh | bash +# or: bash install.sh [--nginx|--apache] [--no-mysql] [--no-postgres] + +set -euo pipefail + +NOVACPX_VERSION="1.0.0" +PANEL_DIR="/opt/novacpx" +WEB_ROOT="/srv/novacpx/public" +LOG="/var/log/novacpx-install.log" +DB_NAME="novacpx" +DB_USER="novacpx_user" +PHP_DEFAULT="8.3" + +# ── Colors ──────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m' + +log() { echo -e "${GREEN}[✓]${NC} $*" | tee -a "$LOG"; } +warn() { echo -e "${YELLOW}[!]${NC} $*" | tee -a "$LOG"; } +fail() { echo -e "${RED}[✗]${NC} $*" | tee -a "$LOG"; exit 1; } +info() { echo -e "${BLUE}[→]${NC} $*" | tee -a "$LOG"; } +step() { echo -e "\n${BOLD}━━━ $* ━━━${NC}" | tee -a "$LOG"; } + +# ── Argument parsing ────────────────────────────────────────────────────────── +WEB_SERVER="apache" +INSTALL_MYSQL=true +INSTALL_POSTGRES=true + +for arg in "$@"; do + case "$arg" in + --nginx) WEB_SERVER="nginx" ;; + --apache) WEB_SERVER="apache" ;; + --no-mysql) INSTALL_MYSQL=false ;; + --no-postgres) INSTALL_POSTGRES=false ;; + esac +done + +# ── Banner ───────────────────────────────────────────────────────────────────── +clear +cat <<'EOF' + + ███╗ ██╗ ██████╗ ██╗ ██╗ █████╗ ██████╗██████╗ ██╗ ██╗ + ████╗ ██║██╔═══██╗██║ ██║██╔══██╗██╔════╝██╔══██╗╚██╗██╔╝ + ██╔██╗ ██║██║ ██║██║ ██║███████║██║ ██████╔╝ ╚███╔╝ + ██║╚██╗██║██║ ██║╚██╗ ██╔╝██╔══██║██║ ██╔═══╝ ██╔██╗ + ██║ ╚████║╚██████╔╝ ╚████╔╝ ██║ ██║╚██████╗██║ ██╔╝ ██╗ + ╚═╝ ╚═══╝ ╚═════╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ + + Linux Web Hosting Control Panel | v${NOVACPX_VERSION} + ───────────────────────────────────────────────────────────── +EOF + +echo "" + +# ── Preflight checks ────────────────────────────────────────────────────────── +step "Preflight Checks" + +[[ $EUID -ne 0 ]] && fail "Must run as root. Use: sudo bash install.sh" + +# OS detection +if [[ -f /etc/os-release ]]; then + . /etc/os-release + OS_ID="$ID" + OS_VER="$VERSION_ID" + OS_CODENAME="${VERSION_CODENAME:-}" +else + fail "Cannot detect OS. /etc/os-release missing." +fi + +case "$OS_ID" in + ubuntu) + case "$OS_VER" in + 20.04|22.04|24.04) log "Detected: Ubuntu $OS_VER" ;; + *) fail "Ubuntu $OS_VER not supported. Use 20.04, 22.04, or 24.04." ;; + esac + ;; + debian) + case "$OS_VER" in + 11|12) log "Detected: Debian $OS_VER ($OS_CODENAME)" ;; + *) fail "Debian $OS_VER not supported. Use Debian 11 (Bullseye) or 12 (Bookworm)." ;; + esac + ;; + *) fail "Unsupported OS: $OS_ID. NovaCPX supports Ubuntu 20/22/24 and Debian 11/12." ;; +esac + +log "Web server: $WEB_SERVER" +log "MySQL: $INSTALL_MYSQL | PostgreSQL: $INSTALL_POSTGRES" + +# Check minimum requirements +TOTAL_RAM=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo) +TOTAL_DISK=$(df / | awk 'NR==2 {print int($4/1024/1024)}') +log "RAM: ${TOTAL_RAM}MB | Free disk: ${TOTAL_DISK}GB" +[[ $TOTAL_RAM -lt 512 ]] && warn "Low RAM (${TOTAL_RAM}MB). Recommend 1GB+ for best performance." +[[ $TOTAL_DISK -lt 5 ]] && fail "Insufficient disk space. Need 5GB+ free." + +# ── Generate secrets ────────────────────────────────────────────────────────── +step "Generating Credentials" + +DB_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9!@#$' | head -c 20) +ADMIN_PASS=$(openssl rand -base64 16 | tr -dc 'A-Za-z0-9' | head -c 16) +SECRET_KEY=$(openssl rand -hex 32) +mkdir -p /root/.novacpx +cat > /root/.novacpx/credentials.txt <> "$LOG" 2>&1 +apt-get upgrade -y -qq >> "$LOG" 2>&1 +apt-get install -y -qq curl wget gnupg2 lsb-release ca-certificates \ + software-properties-common apt-transport-https unzip git \ + sudo cron logrotate ufw fail2ban >> "$LOG" 2>&1 +log "System packages updated" + +# ── PHP multi-version setup ─────────────────────────────────────────────────── +step "Installing PHP (Multi-Version)" + +# Add ondrej/php PPA for Ubuntu; sury for Debian +if [[ "$OS_ID" == "ubuntu" ]]; then + add-apt-repository -y ppa:ondrej/php >> "$LOG" 2>&1 +elif [[ "$OS_ID" == "debian" ]]; then + curl -fsSL https://packages.sury.org/php/apt.gpg | gpg --dearmor -o /etc/apt/trusted.gpg.d/sury-php.gpg + echo "deb https://packages.sury.org/php/ $OS_CODENAME main" > /etc/apt/sources.list.d/sury-php.list +fi + +apt-get update -qq >> "$LOG" 2>&1 + +PHP_VERSIONS=("7.4" "8.1" "8.2" "8.3") +PHP_EXTENSIONS="cli fpm common mysql pgsql gd curl mbstring xml zip bcmath intl soap redis imagick opcache" + +for VER in "${PHP_VERSIONS[@]}"; do + info "Installing PHP $VER..." + PKGS="" + for EXT in $PHP_EXTENSIONS; do + PKGS="$PKGS php${VER}-${EXT}" + done + apt-get install -y -qq php${VER} $PKGS >> "$LOG" 2>&1 || warn "PHP $VER: some extensions may not be available" + log "PHP $VER installed" +done + +# Set default PHP CLI +update-alternatives --set php /usr/bin/php${PHP_DEFAULT} >> "$LOG" 2>&1 || true +log "Default PHP CLI: $PHP_DEFAULT" + +# ── Web Server ──────────────────────────────────────────────────────────────── +step "Installing Web Server ($WEB_SERVER)" + +if [[ "$WEB_SERVER" == "nginx" ]]; then + apt-get install -y -qq nginx >> "$LOG" 2>&1 + systemctl enable nginx >> "$LOG" 2>&1 + log "nginx installed" + PANEL_WEB_CONF="/etc/nginx/sites-available/novacpx" + cat > "$PANEL_WEB_CONF" <> "$LOG" 2>&1 + a2enmod ssl rewrite proxy_fcgi setenvif headers >> "$LOG" 2>&1 + systemctl enable apache2 >> "$LOG" 2>&1 + log "Apache2 installed" + PANEL_WEB_CONF="/etc/apache2/sites-available/novacpx.conf" + cat > "$PANEL_WEB_CONF" < + DocumentRoot $WEB_ROOT + SSLEngine on + SSLCertificateFile /etc/novacpx/ssl/novacpx.crt + SSLCertificateKeyFile /etc/novacpx/ssl/novacpx.key + + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + + + SetHandler "proxy:unix:/run/php/php${PHP_DEFAULT}-fpm.sock|fcgi://localhost/" + + +APCONF + a2ensite novacpx >> "$LOG" 2>&1 + # Enable PHP-FPM for default version + a2enconf php${PHP_DEFAULT}-fpm >> "$LOG" 2>&1 || true +fi + +# Enable PHP-FPM services +for VER in "${PHP_VERSIONS[@]}"; do + systemctl enable php${VER}-fpm >> "$LOG" 2>&1 && systemctl start php${VER}-fpm >> "$LOG" 2>&1 || true +done + +# ── MySQL ───────────────────────────────────────────────────────────────────── +if $INSTALL_MYSQL; then + step "Installing MySQL 8" + apt-get install -y -qq mysql-server >> "$LOG" 2>&1 + systemctl enable mysql >> "$LOG" 2>&1 + systemctl start mysql >> "$LOG" 2>&1 + mysql -e "CREATE DATABASE IF NOT EXISTS $DB_NAME CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >> "$LOG" 2>&1 + mysql -e "CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';" >> "$LOG" 2>&1 + mysql -e "GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';" >> "$LOG" 2>&1 + mysql -e "FLUSH PRIVILEGES;" >> "$LOG" 2>&1 + log "MySQL installed and database created" +fi + +# ── PostgreSQL ──────────────────────────────────────────────────────────────── +if $INSTALL_POSTGRES; then + step "Installing PostgreSQL" + apt-get install -y -qq postgresql postgresql-contrib >> "$LOG" 2>&1 + systemctl enable postgresql >> "$LOG" 2>&1 + log "PostgreSQL installed" +fi + +# ── BIND9 DNS ───────────────────────────────────────────────────────────────── +step "Installing BIND9 DNS Server" +apt-get install -y -qq bind9 bind9utils bind9-doc >> "$LOG" 2>&1 +systemctl enable named >> "$LOG" 2>&1 + +cat > /etc/bind/named.conf.options <> "$LOG" 2>&1 +log "BIND9 DNS installed" + +# ── Postfix + Dovecot (Mail) ────────────────────────────────────────────────── +step "Installing Mail Server (Postfix + Dovecot)" +HOSTNAME=$(hostname -f) +debconf-set-selections <<< "postfix postfix/mailname string $HOSTNAME" +debconf-set-selections <<< "postfix postfix/main_mailer_type string 'Internet Site'" +apt-get install -y -qq postfix postfix-mysql dovecot-core dovecot-imapd \ + dovecot-pop3d dovecot-lmtpd dovecot-mysql spamassassin >> "$LOG" 2>&1 +systemctl enable postfix dovecot >> "$LOG" 2>&1 +log "Mail server installed (Postfix + Dovecot)" + +# ── ProFTPD ─────────────────────────────────────────────────────────────────── +step "Installing ProFTPD" +apt-get install -y -qq proftpd-basic proftpd-mod-mysql >> "$LOG" 2>&1 +systemctl enable proftpd >> "$LOG" 2>&1 +log "ProFTPD installed" + +# ── SSL Certificate ─────────────────────────────────────────────────────────── +step "Generating Self-Signed SSL (Panel)" +mkdir -p /etc/novacpx/ssl +openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ + -keyout /etc/novacpx/ssl/novacpx.key \ + -out /etc/novacpx/ssl/novacpx.crt \ + -subj "/CN=$(hostname -I | awk '{print $1}')/O=NovaCPX/C=US" >> "$LOG" 2>&1 +chmod 600 /etc/novacpx/ssl/novacpx.key +log "SSL certificate generated" + +# Install certbot for Let's Encrypt +apt-get install -y -qq certbot >> "$LOG" 2>&1 +log "Certbot installed for Let's Encrypt SSL" + +# ── Panel installation ──────────────────────────────────────────────────────── +step "Installing NovaCPX Panel" +mkdir -p "$WEB_ROOT" "$PANEL_DIR" + +# Install panel files from GitHub +if [[ -d /opt/novacpx-src ]]; then + cp -r /opt/novacpx-src/panel/public/* "$WEB_ROOT/" + cp -r /opt/novacpx-src/panel/api "$WEB_ROOT/api" + cp -r /opt/novacpx-src/panel/lib "$WEB_ROOT/lib" + cp -r /opt/novacpx-src/panel/lib /opt/novacpx/lib +fi + +# Write config +mkdir -p /etc/novacpx +cat > /etc/novacpx/config.ini <> "$LOG" 2>&1 + # Create admin user + ADMIN_HASH=$(php -r "echo password_hash('${ADMIN_PASS}', PASSWORD_BCRYPT);") + mysql "$DB_NAME" -e "INSERT INTO users (username,password,email,role,status) VALUES ('admin','$ADMIN_HASH','root@localhost','admin','active') ON DUPLICATE KEY UPDATE password='$ADMIN_HASH';" >> "$LOG" 2>&1 + log "Database schema imported and admin user created" +fi + +# Set permissions +chown -R www-data:www-data "$WEB_ROOT" +chmod -R 750 "$WEB_ROOT" + +# ── Firewall ────────────────────────────────────────────────────────────────── +step "Configuring Firewall (UFW)" +ufw --force reset >> "$LOG" 2>&1 +ufw default deny incoming >> "$LOG" 2>&1 +ufw default allow outgoing >> "$LOG" 2>&1 +ufw allow ssh >> "$LOG" 2>&1 +ufw allow 80/tcp >> "$LOG" 2>&1 # HTTP +ufw allow 443/tcp >> "$LOG" 2>&1 # HTTPS +ufw allow 2083/tcp >> "$LOG" 2>&1 # NovaCPX panel +ufw allow 21/tcp >> "$LOG" 2>&1 # FTP +ufw allow 20/tcp >> "$LOG" 2>&1 # FTP data +ufw allow 25/tcp >> "$LOG" 2>&1 # SMTP +ufw allow 587/tcp >> "$LOG" 2>&1 # SMTP submission +ufw allow 465/tcp >> "$LOG" 2>&1 # SMTPS +ufw allow 110/tcp >> "$LOG" 2>&1 # POP3 +ufw allow 995/tcp >> "$LOG" 2>&1 # POP3S +ufw allow 143/tcp >> "$LOG" 2>&1 # IMAP +ufw allow 993/tcp >> "$LOG" 2>&1 # IMAPS +ufw allow 53/tcp >> "$LOG" 2>&1 # DNS +ufw allow 53/udp >> "$LOG" 2>&1 # DNS +ufw --force enable >> "$LOG" 2>&1 +log "Firewall configured" + +# ── Fail2Ban ───────────────────────────────────────────────────────────────── +step "Configuring Fail2Ban" +cat > /etc/fail2ban/jail.local <> "$LOG" 2>&1 +systemctl restart fail2ban >> "$LOG" 2>&1 +log "Fail2Ban configured" + +# ── Cron jobs ───────────────────────────────────────────────────────────────── +step "Setting Up Cron Jobs" +cat > /etc/cron.d/novacpx <> /var/log/novacpx/cron.log 2>&1 +0 * * * * root /usr/local/bin/novacpx-ssl-renew >> /var/log/novacpx/ssl.log 2>&1 +0 2 * * * root /usr/local/bin/novacpx-backup >> /var/log/novacpx/backup.log 2>&1 +*/1 * * * * root /usr/local/bin/novacpx-dns-sync >> /var/log/novacpx/dns.log 2>&1 +CRON +mkdir -p /var/log/novacpx +log "Cron jobs installed" + +# ── Restart services ────────────────────────────────────────────────────────── +step "Starting All Services" +if [[ "$WEB_SERVER" == "nginx" ]]; then + systemctl restart nginx >> "$LOG" 2>&1 +else + systemctl restart apache2 >> "$LOG" 2>&1 +fi +$INSTALL_MYSQL && systemctl restart mysql >> "$LOG" 2>&1 +systemctl restart postfix dovecot proftpd named >> "$LOG" 2>&1 +log "All services started" + +# ── Done ───────────────────────────────────────────────────────────────────── +SERVER_IP=$(hostname -I | awk '{print $1}') +cat < (function() use ($body) { + $username = trim($body['username'] ?? ''); + $password = $body['password'] ?? ''; + if (!$username || !$password) Response::error('Username and password required'); + $auth = Auth::getInstance(); + $token = $auth->attempt($username, $password); + if (!$token) Response::error('Invalid credentials', 401); + $user = $auth->user(); + audit('login', 'auth'); + Response::success([ + 'token' => $token, + 'user' => [ + 'id' => $user['id'], + 'username' => $user['username'], + 'email' => $user['email'], + 'role' => $user['role'], + 'theme' => $user['theme'], + ], + ], 'Login successful'); + })(), + + 'logout' => (function() { + Auth::getInstance()->logout(); + audit('logout', 'auth'); + Response::success(null, 'Logged out'); + })(), + + 'me' => (function() use ($currentUser) { + Response::success([ + 'id' => $currentUser['uid'], + 'username' => $currentUser['username'], + 'email' => $currentUser['email'], + 'role' => $currentUser['role'], + 'theme' => $currentUser['theme'], + ]); + })(), + + default => Response::error('Unknown auth action', 404), +}; diff --git a/panel/api/endpoints/system.php b/panel/api/endpoints/system.php new file mode 100644 index 0000000..68d16bf --- /dev/null +++ b/panel/api/endpoints/system.php @@ -0,0 +1,158 @@ +require('admin', 'reseller', 'user'); +$db = DB::getInstance(); +$body = json_decode(file_get_contents('php://input'), true) ?? []; + +match ($action) { + + // ── Version & Update Info ───────────────────────────────────────────────── + 'version' => (function() use ($db) { + $installed = $db->fetchOne("SELECT version, installed_at, git_commit FROM novacpx_version ORDER BY id DESC LIMIT 1"); + $gitDir = NOVACPX_ROOT . '/.git'; + $gitCommit = null; + $gitBranch = null; + $gitDirty = false; + + if (is_dir($gitDir)) { + $gitCommit = trim(shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " rev-parse --short HEAD 2>/dev/null") ?: ''); + $gitBranch = trim(shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " rev-parse --abbrev-ref HEAD 2>/dev/null") ?: ''); + $status = shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " status --porcelain 2>/dev/null"); + $gitDirty = !empty(trim($status)); + } + + Response::success([ + 'installed_version' => $installed['version'] ?? NOVACPX_VERSION, + 'installed_at' => $installed['installed_at'], + 'git_commit' => $gitCommit ?: ($installed['git_commit'] ?? null), + 'git_branch' => $gitBranch, + 'git_dirty' => $gitDirty, + 'php_version' => PHP_VERSION, + 'os' => php_uname('s') . ' ' . php_uname('r'), + ]); + })(), + + // ── Check for updates ───────────────────────────────────────────────────── + 'check-update' => (function() use ($db) { + Auth::getInstance()->require('admin'); + $remote = $db->fetchOne("SELECT value FROM settings WHERE `key` = 'git_remote'"); + $gitRemote = $remote['value'] ?? ''; + if (!$gitRemote) Response::error('No git remote configured'); + + $output = shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " fetch origin 2>&1 && git -C " . escapeshellarg(NOVACPX_ROOT) . " log HEAD..origin/main --oneline 2>/dev/null"); + $updates = array_values(array_filter(explode("\n", trim($output ?: '')))); + + Response::success([ + 'updates_available' => count($updates), + 'commits' => $updates, + ]); + })(), + + // ── Apply update ────────────────────────────────────────────────────────── + 'apply-update' => (function() use ($db) { + Auth::getInstance()->require('admin'); + $before = trim(shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " rev-parse HEAD 2>/dev/null") ?: ''); + + $pull = shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " pull origin main 2>&1"); + + $after = trim(shell_exec("git -C " . escapeshellarg(NOVACPX_ROOT) . " rev-parse HEAD 2>/dev/null") ?: ''); + $changed = $before !== $after; + + if ($changed) { + // Run any pending DB migrations + $migrDir = NOVACPX_ROOT . '/db/migrations'; + if (is_dir($migrDir)) { + foreach (glob("$migrDir/*.sql") as $sql) { + $migName = basename($sql, '.sql'); + $already = $db->fetchOne("SELECT 1 FROM settings WHERE `key` = 'migration_$migName'"); + if (!$already) { + $db->pdo()->exec(file_get_contents($sql)); + $db->execute("INSERT INTO settings (`key`,`value`) VALUES (?,NOW()) ON DUPLICATE KEY UPDATE `value`=NOW()", ["migration_$migName"]); + novacpx_log('info', "Migration applied: $migName"); + } + } + } + audit('system.update', "novacpx:$before→$after"); + novacpx_log('info', "NovaCPX updated $before → $after"); + } + + Response::success([ + 'updated' => $changed, + 'from_commit' => $before, + 'to_commit' => $after, + 'pull_output' => $pull, + ]); + })(), + + // ── Server Stats ────────────────────────────────────────────────────────── + 'stats' => (function() use ($db) { + // CPU/load + $load = sys_getloadavg(); + $cpuPct = round(($load[0] / max(1, (int)shell_exec('nproc'))) * 100, 1); + + // RAM + $memRaw = file_get_contents('/proc/meminfo'); + preg_match('/MemTotal:\s+(\d+)/', $memRaw, $mt); + preg_match('/MemAvailable:\s+(\d+)/', $memRaw, $ma); + $ramTotal = (int)($mt[1] ?? 0); + $ramAvail = (int)($ma[1] ?? 0); + $ramPct = $ramTotal > 0 ? round((($ramTotal - $ramAvail) / $ramTotal) * 100, 1) : 0; + + // Disk + $diskTotal = disk_total_space('/'); + $diskFree = disk_free_space('/'); + $diskPct = $diskTotal > 0 ? round((($diskTotal - $diskFree) / $diskTotal) * 100, 1) : 0; + + // Services + $services = []; + foreach (['apache2','nginx','mysql','postfix','dovecot','proftpd','named','fail2ban'] as $svc) { + $active = trim(shell_exec("systemctl is-active $svc 2>/dev/null") ?: ''); + if ($active) $services[$svc] = $active; + } + + // Persist to DB for history + $db->execute( + "INSERT INTO server_stats (cpu_pct,ram_pct,disk_pct,load_1m,load_5m,load_15m) VALUES (?,?,?,?,?,?)", + [$cpuPct, $ramPct, $diskPct, $load[0], $load[1], $load[2]] + ); + + Response::success([ + 'cpu' => ['pct' => $cpuPct, 'load' => $load], + 'ram' => ['total_kb' => $ramTotal, 'used_kb' => $ramTotal - $ramAvail, 'pct' => $ramPct], + 'disk' => ['total' => $diskTotal, 'free' => $diskFree, 'pct' => $diskPct], + 'services' => $services, + 'uptime' => trim(shell_exec('uptime -p') ?: ''), + ]); + })(), + + // ── Service control (start/stop/restart) ────────────────────────────────── + 'service' => (function() use ($body, $db) { + Auth::getInstance()->require('admin'); + $svc = preg_replace('/[^a-z0-9\-_]/', '', $body['service'] ?? ''); + $cmd = $body['command'] ?? 'status'; + $allowed = ['apache2','nginx','mysql','postfix','dovecot','proftpd','named','fail2ban','php7.4-fpm','php8.1-fpm','php8.2-fpm','php8.3-fpm']; + if (!in_array($svc, $allowed)) Response::error("Service not managed: $svc"); + if (!in_array($cmd, ['start','stop','restart','reload','status'])) Response::error("Invalid command"); + + $out = shell_exec("systemctl $cmd " . escapeshellarg($svc) . " 2>&1"); + audit("service.$cmd", $svc); + Response::success(['output' => $out]); + })(), + + // ── Audit log ───────────────────────────────────────────────────────────── + 'audit-log' => (function() use ($db) { + Auth::getInstance()->require('admin'); + $page = max(1, (int)($_GET['page'] ?? 1)); + $perPage = min(100, max(10, (int)($_GET['per_page'] ?? 50))); + $offset = ($page - 1) * $perPage; + $total = $db->fetchOne("SELECT COUNT(*) as c FROM audit_log")['c'] ?? 0; + $rows = $db->fetchAll("SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?", [$perPage, $offset]); + Response::paginate($rows, (int)$total, $page, $perPage); + })(), + + default => Response::error("Unknown system action: $action", 404), +}; diff --git a/panel/api/index.php b/panel/api/index.php new file mode 100644 index 0000000..39bb077 --- /dev/null +++ b/panel/api/index.php @@ -0,0 +1,56 @@ + 'ok', 'panel' => 'NovaCPX', 'version' => NOVACPX_VERSION]); +} + +// Public endpoints (no auth required) +$public = ['auth']; +if (!in_array($endpoint, $public)) { + $auth = Auth::getInstance(); + if (!$auth->check()) { + Response::error('Unauthorized', 401); + } + $currentUser = $auth->user(); +} + +// Route to endpoint handler +$endpointFile = NOVACPX_API . "/endpoints/{$endpoint}.php"; +if (!file_exists($endpointFile)) { + Response::error("Unknown endpoint: $endpoint", 404); +} + +require $endpointFile; diff --git a/panel/lib/Auth.php b/panel/lib/Auth.php new file mode 100644 index 0000000..8006d65 --- /dev/null +++ b/panel/lib/Auth.php @@ -0,0 +1,100 @@ +loginByToken($token); + } + // Session cookie + $sessionId = $_COOKIE['ncpx_session'] ?? ''; + if ($sessionId) { + return $this->loginBySession($sessionId); + } + return false; + } + + private function loginBySession(string $sessionId): bool { + $db = DB::getInstance(); + $row = $db->fetchOne( + "SELECT s.*, u.id as uid, u.username, u.email, u.role, u.status, u.reseller_id, u.theme + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.id = ? AND s.expires_at > NOW() AND u.status = 'active'", + [hash('sha256', $sessionId)] + ); + if (!$row) return false; + $this->user = $row; + return true; + } + + private function loginByToken(string $token): bool { + $db = DB::getInstance(); + $row = $db->fetchOne( + "SELECT t.permissions, u.id as uid, u.username, u.email, u.role, u.status + FROM api_tokens t + JOIN users u ON u.id = t.user_id + WHERE t.token = ? AND (t.expires_at IS NULL OR t.expires_at > NOW()) AND u.status = 'active'", + [hash('sha256', $token)] + ); + if (!$row) return false; + $db->execute("UPDATE api_tokens SET last_used = NOW() WHERE token = ?", [hash('sha256', $token)]); + $this->user = $row; + return true; + } + + public function attempt(string $username, string $password): ?string { + $db = DB::getInstance(); + $user = $db->fetchOne( + "SELECT * FROM users WHERE (username = ? OR email = ?) AND status = 'active'", + [$username, $username] + ); + if (!$user || !password_verify($password, $user['password'])) return null; + + // Create session + $token = bin2hex(random_bytes(32)); + $sessionId = hash('sha256', $token); + $db->execute( + "INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at) + VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 8 HOUR))", + [$sessionId, $user['id'], $_SERVER['REMOTE_ADDR'] ?? '', $_SERVER['HTTP_USER_AGENT'] ?? ''] + ); + $db->execute("UPDATE users SET last_login = NOW() WHERE id = ?", [$user['id']]); + + setcookie('ncpx_session', $token, [ + 'expires' => time() + 28800, + 'path' => '/', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict', + ]); + $this->user = $user; + return $token; + } + + public function logout(): void { + $sessionId = hash('sha256', $_COOKIE['ncpx_session'] ?? ''); + DB::getInstance()->execute("DELETE FROM sessions WHERE id = ?", [$sessionId]); + setcookie('ncpx_session', '', time() - 3600, '/', '', true, true); + } + + public function user(): ?array { return $this->user; } + + public function require(string ...$roles): void { + $user = $this->user(); + if (!$user || !in_array($user['role'], $roles)) { + Response::error('Forbidden', 403); + } + } +} diff --git a/panel/lib/Core.php b/panel/lib/Core.php new file mode 100644 index 0000000..545a28b --- /dev/null +++ b/panel/lib/Core.php @@ -0,0 +1,54 @@ + 'NovaCPX not configured. Run the installer.'])); +} + +define('DB_HOST', $_cfg['database']['host'] ?? 'localhost'); +define('DB_NAME', $_cfg['database']['name'] ?? 'novacpx'); +define('DB_USER', $_cfg['database']['user'] ?? ''); +define('DB_PASS', $_cfg['database']['pass'] ?? ''); +define('SECRET_KEY', $_cfg['panel']['secret'] ?? ''); +define('PANEL_VER', $_cfg['panel']['version'] ?? NOVACPX_VERSION); +define('WEB_SERVER', $_cfg['web']['server'] ?? 'apache'); +define('PHP_DEFAULT',$_cfg['web']['php_default'] ?? '8.3'); + +function novacpx_log(string $level, string $msg, array $ctx = []): void { + $line = sprintf("[%s] [%s] %s %s\n", + date('Y-m-d H:i:s'), strtoupper($level), $msg, + $ctx ? json_encode($ctx) : '' + ); + file_put_contents('/var/log/novacpx/panel.log', $line, FILE_APPEND | LOCK_EX); +} + +function audit(string $action, string $resource = '', array $detail = []): void { + try { + $db = DB::getInstance(); + $auth = Auth::getInstance(); + $user = $auth->user(); + $db->execute( + "INSERT INTO audit_log (user_id, username, action, resource, detail, ip_address, user_agent) + VALUES (?, ?, ?, ?, ?, ?, ?)", + [ + $user['id'] ?? null, + $user['username'] ?? 'system', + $action, + $resource, + json_encode($detail), + $_SERVER['REMOTE_ADDR'] ?? '', + $_SERVER['HTTP_USER_AGENT'] ?? '', + ] + ); + } catch (Throwable $e) { + novacpx_log('error', 'audit failed: ' . $e->getMessage()); + } +} diff --git a/panel/lib/DB.php b/panel/lib/DB.php new file mode 100644 index 0000000..f698c50 --- /dev/null +++ b/panel/lib/DB.php @@ -0,0 +1,43 @@ +pdo = new PDO( + "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", + DB_USER, DB_PASS, + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + } + + public static function getInstance(): self { + if (!self::$instance) self::$instance = new self(); + return self::$instance; + } + + public function execute(string $sql, array $params = []): PDOStatement { + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + return $stmt; + } + + public function fetchOne(string $sql, array $params = []): ?array { + return $this->execute($sql, $params)->fetch() ?: null; + } + + public function fetchAll(string $sql, array $params = []): array { + return $this->execute($sql, $params)->fetchAll(); + } + + public function insert(string $sql, array $params = []): string { + $this->execute($sql, $params); + return $this->pdo->lastInsertId(); + } + + public function pdo(): PDO { return $this->pdo; } +} diff --git a/panel/lib/Response.php b/panel/lib/Response.php new file mode 100644 index 0000000..0a7ca7f --- /dev/null +++ b/panel/lib/Response.php @@ -0,0 +1,29 @@ + true, 'message' => $message, 'data' => $data]); + } + + public static function error(string $message, int $code = 400, array $errors = []): never { + self::json(['success' => false, 'message' => $message, 'errors' => $errors], $code); + } + + public static function paginate(array $items, int $total, int $page, int $perPage): never { + self::json([ + 'success' => true, + 'data' => $items, + 'meta' => [ + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'pages' => (int) ceil($total / $perPage), + ], + ]); + } +} diff --git a/panel/public/.htaccess b/panel/public/.htaccess new file mode 100644 index 0000000..d79fc47 --- /dev/null +++ b/panel/public/.htaccess @@ -0,0 +1,17 @@ +Options -Indexes +RewriteEngine On + +# Route API calls +RewriteRule ^api/(.*)$ api/index.php [QSA,L] + +# Panel routes — serve index.php for SPA navigation within each panel tier +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(admin|reseller|user)/.*$ $1/index.php [QSA,L] + +# Security headers +Header always set X-Frame-Options "SAMEORIGIN" +Header always set X-Content-Type-Options "nosniff" +Header always set X-XSS-Protection "1; mode=block" +Header always set Referrer-Policy "strict-origin-when-cross-origin" +Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()" diff --git a/panel/public/admin/index.php b/panel/public/admin/index.php new file mode 100644 index 0000000..e801bfc --- /dev/null +++ b/panel/public/admin/index.php @@ -0,0 +1,176 @@ + + + + + + +NovaCPX Admin + + + + + + + + +
+
Verifying session…
+
+ + + + + diff --git a/panel/public/assets/css/nova.css b/panel/public/assets/css/nova.css new file mode 100644 index 0000000..04254cf --- /dev/null +++ b/panel/public/assets/css/nova.css @@ -0,0 +1,302 @@ +/* NovaCPX Design System */ +:root { + --bg: #0d0f17; + --bg2: #131520; + --bg3: #1a1d2e; + --border: #252840; + --text: #e2e4f0; + --text-muted: #7c7f9a; + --primary: #6366f1; + --primary-h: #4f52e8; + --sky: #0ea5e9; + --green: #10b981; + --yellow: #f59e0b; + --red: #ef4444; + --radius: 10px; + --shadow: 0 4px 24px rgba(0,0,0,.4); + --font: 'Inter', system-ui, -apple-system, sans-serif; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { font-size: 15px; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font); + line-height: 1.6; + min-height: 100vh; +} + +/* ── Login Page ─────────────────────────────────────────────────────────────── */ +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: radial-gradient(ellipse at 30% 20%, rgba(99,102,241,.15) 0%, transparent 60%), + radial-gradient(ellipse at 80% 80%, rgba(14,165,233,.1) 0%, transparent 60%), + var(--bg); +} + +.login-wrap { width: 100%; max-width: 420px; padding: 1.5rem; } + +.login-brand { + display: flex; align-items: center; gap: .75rem; + justify-content: center; margin-bottom: 2rem; +} +.logo-icon { width: 42px; height: 42px; } +.logo-text { font-size: 1.8rem; font-weight: 300; letter-spacing: -.5px; } +.logo-text strong { font-weight: 700; background: linear-gradient(135deg, #6366f1, #0ea5e9); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } + +.login-card { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 16px; + padding: 2rem; + box-shadow: var(--shadow); +} +.login-card h1 { font-size: 1.4rem; margin-bottom: .25rem; } +.login-sub { color: var(--text-muted); font-size: .875rem; margin-bottom: 1.5rem; } + +.login-footer { + text-align: center; margin-top: 1.25rem; + font-size: .8rem; color: var(--text-muted); +} +.login-footer a { color: var(--primary); text-decoration: none; } + +/* ── Forms ──────────────────────────────────────────────────────────────────── */ +.form-group { margin-bottom: 1rem; } +.form-group label { display: block; font-size: .85rem; font-weight: 500; margin-bottom: .4rem; color: var(--text-muted); } + +input[type="text"], input[type="password"], input[type="email"], +input[type="number"], input[type="url"], select, textarea { + width: 100%; padding: .65rem .9rem; + background: var(--bg3); border: 1px solid var(--border); + border-radius: var(--radius); color: var(--text); + font-family: var(--font); font-size: .9rem; + transition: border-color .15s; + outline: none; +} +input:focus, select:focus, textarea:focus { border-color: var(--primary); } + +.input-with-icon { position: relative; } +.input-with-icon input { padding-right: 2.5rem; } +.eye-toggle { + position: absolute; right: .75rem; top: 50%; transform: translateY(-50%); + background: none; border: none; cursor: pointer; color: var(--text-muted); + padding: 0; display: flex; align-items: center; +} +.eye-toggle svg { width: 18px; height: 18px; } + +/* ── Buttons ─────────────────────────────────────────────────────────────────── */ +.btn { + display: inline-flex; align-items: center; gap: .4rem; + padding: .6rem 1.25rem; border: none; border-radius: var(--radius); + font-family: var(--font); font-size: .9rem; font-weight: 500; + cursor: pointer; transition: all .15s; text-decoration: none; +} +.btn-primary { background: var(--primary); color: #fff; } +.btn-primary:hover { background: var(--primary-h); } +.btn-sky { background: var(--sky); color: #fff; } +.btn-green { background: var(--green); color: #fff; } +.btn-red { background: var(--red); color: #fff; } +.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); } +.btn-ghost:hover { border-color: var(--primary); color: var(--primary); } +.btn-full { width: 100%; justify-content: center; padding: .75rem; } +.btn:disabled { opacity: .6; cursor: not-allowed; } +.btn-sm { padding: .35rem .8rem; font-size: .82rem; } +.btn-icon { padding: .5rem; border-radius: 8px; } + +/* ── Alerts ──────────────────────────────────────────────────────────────────── */ +.alert { padding: .75rem 1rem; border-radius: var(--radius); font-size: .875rem; margin-bottom: 1rem; } +.alert-error { background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.3); color: #fca5a5; } +.alert-success { background: rgba(16,185,129,.12); border: 1px solid rgba(16,185,129,.3); color: #6ee7b7; } +.alert-warning { background: rgba(245,158,11,.12); border: 1px solid rgba(245,158,11,.3); color: #fcd34d; } +.alert-info { background: rgba(99,102,241,.12); border: 1px solid rgba(99,102,241,.3); color: #a5b4fc; } + +/* ── Panel Layout ────────────────────────────────────────────────────────────── */ +.panel-layout { display: flex; min-height: 100vh; } + +.sidebar { + width: 240px; min-width: 240px; background: var(--bg2); + border-right: 1px solid var(--border); + display: flex; flex-direction: column; + position: fixed; height: 100vh; overflow-y: auto; z-index: 100; +} +.sidebar-brand { + display: flex; align-items: center; gap: .6rem; + padding: 1.25rem 1.25rem 1rem; + border-bottom: 1px solid var(--border); +} +.sidebar-brand .logo-text { font-size: 1.1rem; } +.sidebar-brand .logo-icon { width: 28px; height: 28px; } + +.sidebar-section { padding: .75rem 0; } +.sidebar-section-label { + font-size: .7rem; font-weight: 700; letter-spacing: .08em; + text-transform: uppercase; color: var(--text-muted); + padding: .25rem 1.25rem .5rem; +} +.sidebar-link { + display: flex; align-items: center; gap: .75rem; + padding: .55rem 1.25rem; text-decoration: none; + color: var(--text-muted); font-size: .88rem; + border-left: 3px solid transparent; + transition: all .12s; +} +.sidebar-link:hover { color: var(--text); background: var(--bg3); } +.sidebar-link.active { color: var(--primary); background: rgba(99,102,241,.1); border-left-color: var(--primary); } +.sidebar-link svg { width: 18px; height: 18px; flex-shrink: 0; } + +.sidebar-user { + margin-top: auto; padding: 1rem 1.25rem; + border-top: 1px solid var(--border); +} +.sidebar-user-info { display: flex; align-items: center; gap: .75rem; } +.avatar { + width: 36px; height: 36px; border-radius: 50%; + background: linear-gradient(135deg, var(--primary), var(--sky)); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: .9rem; flex-shrink: 0; +} +.user-name { font-size: .88rem; font-weight: 600; } +.user-role { font-size: .75rem; color: var(--text-muted); text-transform: capitalize; } + +.main-content { margin-left: 240px; flex: 1; display: flex; flex-direction: column; } + +.topbar { + background: var(--bg2); border-bottom: 1px solid var(--border); + padding: .75rem 1.5rem; display: flex; align-items: center; + gap: 1rem; position: sticky; top: 0; z-index: 50; +} +.topbar-title { font-size: 1rem; font-weight: 600; flex: 1; } +.topbar-actions { display: flex; align-items: center; gap: .5rem; } + +.page-content { padding: 1.5rem; flex: 1; } + +/* ── Cards ───────────────────────────────────────────────────────────────────── */ +.card { + background: var(--bg2); border: 1px solid var(--border); + border-radius: var(--radius); overflow: hidden; +} +.card-header { + padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: .75rem; +} +.card-title { font-size: .95rem; font-weight: 600; flex: 1; } +.card-body { padding: 1.25rem; } + +/* ── Stats Cards ─────────────────────────────────────────────────────────────── */ +.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } +.stat-card { + background: var(--bg2); border: 1px solid var(--border); + border-radius: var(--radius); padding: 1.25rem; +} +.stat-label { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); margin-bottom: .5rem; } +.stat-value { font-size: 1.8rem; font-weight: 700; line-height: 1; } +.stat-sub { font-size: .78rem; color: var(--text-muted); margin-top: .3rem; } +.stat-green { color: var(--green); } +.stat-red { color: var(--red); } +.stat-yellow{ color: var(--yellow); } +.stat-blue { color: var(--sky); } + +/* ── Progress bar ────────────────────────────────────────────────────────────── */ +.progress { background: var(--bg3); border-radius: 999px; height: 6px; overflow: hidden; } +.progress-bar { height: 100%; border-radius: 999px; transition: width .3s; } +.progress-bar.green { background: var(--green); } +.progress-bar.yellow { background: var(--yellow); } +.progress-bar.red { background: var(--red); } + +/* ── Tables ──────────────────────────────────────────────────────────────────── */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: .88rem; } +th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; + color: var(--text-muted); padding: .65rem 1rem; border-bottom: 1px solid var(--border); } +td { padding: .75rem 1rem; border-bottom: 1px solid var(--border); } +tr:last-child td { border-bottom: none; } +tr:hover td { background: var(--bg3); } + +/* ── Badges ──────────────────────────────────────────────────────────────────── */ +.badge { display: inline-block; padding: .2rem .55rem; border-radius: 999px; font-size: .72rem; font-weight: 600; } +.badge-green { background: rgba(16,185,129,.15); color: #6ee7b7; } +.badge-red { background: rgba(239,68,68,.15); color: #fca5a5; } +.badge-yellow { background: rgba(245,158,11,.15); color: #fcd34d; } +.badge-blue { background: rgba(99,102,241,.15); color: #a5b4fc; } +.badge-sky { background: rgba(14,165,233,.15); color: #7dd3fc; } +.badge-gray { background: rgba(148,163,184,.15); color: #94a3b8; } + +/* ── Modal ───────────────────────────────────────────────────────────────────── */ +.modal-overlay { + display: none; position: fixed; inset: 0; + background: rgba(0,0,0,.7); z-index: 1000; + align-items: center; justify-content: center; +} +.modal-overlay.open { display: flex; } +.modal { + background: var(--bg2); border: 1px solid var(--border); + border-radius: 14px; width: 100%; max-width: 500px; + max-height: 90vh; overflow-y: auto; + box-shadow: 0 20px 60px rgba(0,0,0,.6); +} +.modal-header { + padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border); + display: flex; align-items: center; +} +.modal-title { font-size: 1rem; font-weight: 600; flex: 1; } +.modal-close { background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 1.25rem; } +.modal-body { padding: 1.5rem; } +.modal-footer { padding: 1rem 1.5rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: .5rem; } + +/* ── Tabs ────────────────────────────────────────────────────────────────────── */ +.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; } +.tab-btn { + padding: .65rem 1.25rem; border: none; background: none; + color: var(--text-muted); font-size: .88rem; cursor: pointer; + border-bottom: 2px solid transparent; margin-bottom: -1px; + transition: color .12s; +} +.tab-btn:hover { color: var(--text); } +.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); } +.tab-pane { display: none; } +.tab-pane.active { display: block; } + +/* ── Grid helpers ────────────────────────────────────────────────────────────── */ +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } +.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; } +.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; } +@media (max-width: 768px) { + .sidebar { transform: translateX(-100%); transition: transform .2s; } + .sidebar.open { transform: translateX(0); } + .main-content { margin-left: 0; } + .grid-2,.grid-3,.grid-4 { grid-template-columns: 1fr; } +} + +/* ── Services status ─────────────────────────────────────────────────────────── */ +.service-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } +.service-dot.active { background: var(--green); box-shadow: 0 0 6px var(--green); } +.service-dot.inactive { background: var(--red); } +.service-dot.unknown { background: var(--text-muted); } + +/* ── Code / Terminal ─────────────────────────────────────────────────────────── */ +code { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: .85em; background: var(--bg3); padding: .15em .4em; border-radius: 4px; } +.terminal { + background: #050508; border: 1px solid var(--border); border-radius: var(--radius); + padding: 1rem; font-family: monospace; font-size: .82rem; line-height: 1.7; + color: #a6e22e; max-height: 300px; overflow-y: auto; +} + +/* ── Scrollbar ───────────────────────────────────────────────────────────────── */ +::-webkit-scrollbar { width: 5px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; } + +/* ── Utility ─────────────────────────────────────────────────────────────────── */ +.flex { display: flex; } .items-center { align-items: center; } .justify-between { justify-content: space-between; } +.gap-1 { gap: .5rem; } .gap-2 { gap: 1rem; } .gap-3 { gap: 1.5rem; } +.mb-1 { margin-bottom: .5rem; } .mb-2 { margin-bottom: 1rem; } .mb-3 { margin-bottom: 1.5rem; } +.mt-1 { margin-top: .5rem; } .mt-2 { margin-top: 1rem; } +.text-muted { color: var(--text-muted); } .text-sm { font-size: .82rem; } +.text-right { text-align: right; } .font-bold { font-weight: 700; } +.w-full { width: 100%; } .hidden { display: none; } diff --git a/panel/public/assets/js/admin.js b/panel/public/assets/js/admin.js new file mode 100644 index 0000000..b1cd708 --- /dev/null +++ b/panel/public/assets/js/admin.js @@ -0,0 +1,315 @@ +/** + * NovaCPX Admin Panel — page controllers + */ +(async () => { + // ── Auth guard ───────────────────────────────────────────────────────────── + const me = await Nova.api('auth', 'me'); + if (!me?.success || me.data.role !== 'admin') { + location.href = '/?redirect=/admin/'; + return; + } + document.getElementById('auth-check').style.display = 'none'; + document.getElementById('app').style.display = ''; + document.getElementById('user-name').textContent = me.data.username; + document.getElementById('user-avatar').textContent = me.data.username[0].toUpperCase(); + + // ── Logout ───────────────────────────────────────────────────────────────── + document.getElementById('logout-btn').addEventListener('click', async e => { + e.preventDefault(); + await Nova.api('auth', 'logout', { method: 'POST' }); + location.href = '/'; + }); + + // ── Page definitions ─────────────────────────────────────────────────────── + const pages = { + dashboard, + 'server-status': serverStatus, + accounts, + resellers, + packages, + 'create-account': createAccount, + 'dns-zones': dnsZones, + nameservers, + 'web-server': webServer, + 'php-manager': phpManager, + 'mysql-manager': mysqlManager, + 'mail-server': mailServer, + 'ftp-server': ftpServer, + 'ssl-manager': sslManager, + firewall, + 'audit-log': auditLog, + updates, + backups, + settings, + }; + + Nova.initNav(pages); + await Nova.loadPage('dashboard', pages); + checkUpdates(); + + // ── Dashboard ────────────────────────────────────────────────────────────── + async function dashboard() { + const [stats, version] = await Promise.all([ + Nova.api('system', 'stats'), + Nova.api('system', 'version'), + ]); + const s = stats?.data || {}; + const v = version?.data || {}; + + document.getElementById('server-ip').textContent = ''; + + return ` +
+
+
CPU Usage
+
${s.cpu?.pct ?? 0}%
+
Load: ${(s.cpu?.load || [0,0,0]).join(' / ')}
+
${Nova.progressBar(s.cpu?.pct || 0)}
+
+
+
Memory
+
${s.ram?.pct ?? 0}%
+
${Nova.bytes((s.ram?.used_kb||0)*1024)} / ${Nova.bytes((s.ram?.total_kb||0)*1024)}
+
${Nova.progressBar(s.ram?.pct || 0)}
+
+
+
Disk
+
${s.disk?.pct ?? 0}%
+
${Nova.bytes(s.disk?.total - s.disk?.free || 0)} used
+
${Nova.progressBar(s.disk?.pct || 0)}
+
+
+
Uptime
+
${s.uptime || '—'}
+
PHP ${v.php_version || '—'}
+
+
+ +
+
+
Services
+
+ + ${Object.entries(s.services || {}).map(([svc, status]) => ` + + + + + `).join('')} +
${Nova.serviceDot(status)} ${svc}${Nova.badge(status, status === 'active' ? 'green' : 'red')} + + +
+
+
+ +
+
NovaCPX Version
+
+ + + + + + +
Installed${v.installed_version || '—'}
Branch${v.git_branch || 'main'}
Commit${v.git_commit || '—'}${v.git_dirty ? ' dirty' : ''}
PHP${v.php_version || '—'}
OS${v.os || '—'}
+
+ +
+
+
+
`; + } + + // ── Server Status ────────────────────────────────────────────────────────── + async function serverStatus() { + const res = await Nova.api('system', 'stats'); + const s = res?.data || {}; + return ` +
+
Real-Time Server Status + +
+
+
+

CPU

${s.cpu?.pct}%

${Nova.progressBar(s.cpu?.pct||0)}
+

RAM

${s.ram?.pct}%

${Nova.progressBar(s.ram?.pct||0)}
+

Disk

${s.disk?.pct}%

${Nova.progressBar(s.disk?.pct||0)}
+
+
+

Load Average

+

${(s.cpu?.load||[]).join(' / ')}

+
+
+

Uptime

+

${s.uptime}

+
+
+
`; + } + + // ── Updates ──────────────────────────────────────────────────────────────── + async function updates() { + const [ver, check] = await Promise.all([ + Nova.api('system', 'version'), + Nova.api('system', 'check-update'), + ]); + const v = ver?.data || {}; + const upd = check?.data || {}; + const count = upd.updates_available || 0; + + return ` +
+
+ NovaCPX Updates + ${count > 0 ? Nova.badge(count + ' update' + (count > 1 ? 's' : '') + ' available', 'yellow') : Nova.badge('Up to date', 'green')} +
+
+
+

Installed Version

${v.installed_version}

+

Git Commit

${v.git_commit || '—'}
+

Branch

${v.git_branch || 'main'}
+

Dirty Working Tree

${v.git_dirty ? Nova.badge('Yes','yellow') : Nova.badge('No','green')}

+
+ + ${count > 0 ? ` +
+
Pending Commits
+
+ ${upd.commits?.map(c => `
${c}
`).join('') || 'None'} +
+
+ + ` : `

NovaCPX is up to date.

`} +
+
`; + } + + // ── Audit Log ────────────────────────────────────────────────────────────── + async function auditLog() { + const res = await Nova.api('system', 'audit-log', { params: { per_page: 50 } }); + const rows = res?.data || []; + return ` +
+
Audit Log
+
+ + + + ${rows.map(r => ` + + + + + + + `).join('')} + +
TimeUserActionResourceIP
${Nova.relTime(r.created_at)}${r.username || '—'}${r.action}${r.resource || '—'}${r.ip_address || '—'}
+
+
`; + } + + // ── PHP Manager ──────────────────────────────────────────────────────────── + async function phpManager() { + return ` +
+
PHP Version Manager
+
+

Manage installed PHP versions and global extensions.

+
+ ${['7.4','8.1','8.2','8.3'].map(v => ` +
+
PHP ${v}
+
${Nova.badge('Active','green')}
+
+ +
+
`).join('')} +
+
+

Global PHP Extensions

+

Extensions installed across all PHP versions: mbstring, curl, gd, xml, zip, opcache, redis, imagick, pdo, pdo_mysql, pdo_pgsql

+
+
+
`; + } + + // ── Settings ─────────────────────────────────────────────────────────────── + async function settings() { + return ` +
+
Panel Settings
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
`; + } + + // ── Stub pages ───────────────────────────────────────────────────────────── + function stubPage(title, desc) { + return `
${title}
+

${desc}

+
${Nova.badge('Coming Soon','yellow')}
`; + } + function accounts() { return stubPage('All Accounts', 'View and manage all hosting accounts on this server.'); } + function resellers() { return stubPage('Resellers', 'Create and manage reseller accounts with custom packages and resource limits.'); } + function packages() { return stubPage('Packages', 'Define hosting packages with disk, bandwidth, email, FTP, and database limits.'); } + function createAccount() { return stubPage('Create Account', 'Create a new hosting account and assign it a package.'); } + function dnsZones() { return stubPage('DNS Zones', 'View, add, and edit all DNS zones on this nameserver.'); } + function nameservers() { return stubPage('Nameservers', 'Configure primary and secondary nameservers for all hosted domains.'); } + function webServer() { return stubPage('Web Server', 'Manage Apache2 / nginx virtual hosts, modules, and configuration.'); } + function mysqlManager() { return stubPage('MySQL / PostgreSQL', 'Create databases, users, and manage remote access.'); } + function mailServer() { return stubPage('Mail Server', 'Manage Postfix/Dovecot configuration, spam filters, and mail queues.'); } + function ftpServer() { return stubPage('FTP Server', 'Configure ProFTPD, manage FTP accounts and access rules.'); } + function sslManager() { return stubPage('SSL Manager', 'Issue, install, and auto-renew Let\'s Encrypt SSL certificates for all domains.'); } + function firewall() { return stubPage('Firewall / Fail2Ban', 'Manage UFW rules and review Fail2Ban bans.'); } + function backups() { return stubPage('Backups', 'Configure automated backups, restore accounts, and manage backup storage.'); } + + // ── Global action helpers ────────────────────────────────────────────────── + window.adminPage = (page) => Nova.loadPage(page, pages); + window.applyUpdate = async () => { + Nova.confirm('Apply all pending updates? The panel may restart.', async () => { + Nova.toast('Applying update…', 'info', 8000); + const res = await Nova.api('system', 'apply-update', { method: 'POST' }); + if (res?.data?.updated) { + Nova.toast(`Updated to ${res.data.to_commit}`, 'success'); + Nova.loadPage('updates', pages); + } else { + Nova.toast(res?.data?.pull_output || 'Already up to date', 'info'); + } + }); + }; + window.adminServiceAction = async (svc, cmd) => { + const res = await Nova.api('system', 'service', { method: 'POST', body: { service: svc, command: cmd } }); + Nova.toast(`${svc}: ${cmd} → ${res?.success ? 'OK' : res?.message}`, res?.success ? 'success' : 'error'); + }; + window.phpAction = async (ver, cmd) => { + const svc = `php${ver}-fpm`; + await window.adminServiceAction(svc, 'restart'); + }; + + // ── Check for updates badge ──────────────────────────────────────────────── + async function checkUpdates() { + const res = await Nova.api('system', 'check-update'); + const n = res?.data?.updates_available || 0; + const badge = document.getElementById('update-badge'); + if (badge && n > 0) { badge.textContent = n; badge.style.display = ''; } + } +})(); diff --git a/panel/public/assets/js/nova.js b/panel/public/assets/js/nova.js new file mode 100644 index 0000000..3937cb6 --- /dev/null +++ b/panel/public/assets/js/nova.js @@ -0,0 +1,125 @@ +/** + * NovaCPX — Shared JS utilities + */ + +window.Nova = (() => { + // ── API ─────────────────────────────────────────────────────────────────── + async function api(endpoint, action, opts = {}) { + const { method = 'GET', body, params } = opts; + let url = `/api/${endpoint}/${action}`; + if (params) url += '?' + new URLSearchParams(params); + const res = await fetch(url, { + method, + credentials: 'include', + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + if (res.status === 401) { location.href = '/?redirect=' + encodeURIComponent(location.pathname); return null; } + return res.json(); + } + + // ── Toast ───────────────────────────────────────────────────────────────── + let toastEl = null; + function toast(msg, type = 'info', duration = 3500) { + if (!toastEl) { + toastEl = document.createElement('div'); + toastEl.style.cssText = 'position:fixed;bottom:1.5rem;right:1.5rem;z-index:9999;display:flex;flex-direction:column;gap:.5rem;max-width:380px'; + document.body.appendChild(toastEl); + } + const el = document.createElement('div'); + el.className = `alert alert-${type}`; + el.style.cssText = 'animation:fadeIn .2s;cursor:pointer;box-shadow:var(--shadow)'; + el.textContent = msg; + el.addEventListener('click', () => el.remove()); + toastEl.appendChild(el); + setTimeout(() => el.remove(), duration); + } + + // ── Modal ───────────────────────────────────────────────────────────────── + function modal(title, bodyHtml, footerHtml = '') { + const ov = document.createElement('div'); + ov.className = 'modal-overlay open'; + ov.innerHTML = ``; + ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); }); + document.body.appendChild(ov); + return ov; + } + + // ── Confirm dialog ──────────────────────────────────────────────────────── + function confirm(msg, onYes, danger = false) { + const ov = modal('Confirm', `

${msg}

`, + ` + ` + ); + ov.querySelector('#confirm-yes').onclick = () => { ov.remove(); onYes(); }; + } + + // ── Sidebar navigation ──────────────────────────────────────────────────── + function initNav(pages) { + document.querySelectorAll('[data-page]').forEach(link => { + link.addEventListener('click', e => { + e.preventDefault(); + const page = link.dataset.page; + document.querySelectorAll('[data-page]').forEach(l => l.classList.remove('active')); + link.classList.add('active'); + const titleEl = document.getElementById('page-title'); + if (titleEl) titleEl.textContent = link.textContent.trim(); + loadPage(page, pages); + }); + }); + } + + function loadPage(page, pages) { + const content = document.getElementById('page-content'); + if (!content) return; + const fn = pages[page]; + if (fn) { + content.innerHTML = '
Loading…
'; + Promise.resolve(fn()).then(html => { if (html) content.innerHTML = html; }); + } else { + content.innerHTML = `

Page "${page}" coming soon.

`; + } + } + + // ── Progress bar helper ─────────────────────────────────────────────────── + function progressBar(pct) { + const color = pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green'; + return `
`; + } + + // ── Format helpers ──────────────────────────────────────────────────────── + function bytes(n) { + if (n >= 1073741824) return (n / 1073741824).toFixed(1) + ' GB'; + if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB'; + if (n >= 1024) return (n / 1024).toFixed(1) + ' KB'; + return n + ' B'; + } + function relTime(dateStr) { + const diff = (Date.now() - new Date(dateStr)) / 1000; + if (diff < 60) return 'just now'; + if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; + if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; + return Math.floor(diff / 86400) + 'd ago'; + } + function badge(text, type = 'blue') { + return `${text}`; + } + function serviceDot(status) { + const cls = status === 'active' ? 'active' : status === 'inactive' ? 'inactive' : 'unknown'; + return ``; + } + + // Inject global CSS animation + const style = document.createElement('style'); + style.textContent = '@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}'; + document.head.appendChild(style); + + return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot }; +})(); diff --git a/panel/public/index.php b/panel/public/index.php new file mode 100644 index 0000000..7f09dd9 --- /dev/null +++ b/panel/public/index.php @@ -0,0 +1,130 @@ + + + + + + +NovaCPX — Login + + + + + + + + + + diff --git a/panel/public/user/index.php b/panel/public/user/index.php new file mode 100644 index 0000000..4ee435f --- /dev/null +++ b/panel/public/user/index.php @@ -0,0 +1,269 @@ + + + + + + +NovaCPX — My Hosting + + + + + + + + +
+
Loading…
+
+ + + + +