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 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 05:05:30 +00:00
commit e802443d4a
18 changed files with 2622 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# NovaCPX .gitignore
/etc/novacpx/config.ini
panel/api/config.php
*.log
*.tmp
/var/
node_modules/
.DS_Store
Thumbs.db
+1
View File
@@ -0,0 +1 @@
1.0.0
+372
View File
@@ -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;
+422
View File
@@ -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 <<CREDS
NovaCPX Installation Credentials — $(date)
==========================================
Panel URL: https://$(hostname -I | awk '{print $1}'):2083
Admin User: admin
Admin Pass: $ADMIN_PASS
DB Name: $DB_NAME
DB User: $DB_USER
DB Pass: $DB_PASS
==========================================
SAVE THIS FILE. It will not be shown again.
CREDS
chmod 600 /root/.novacpx/credentials.txt
log "Credentials saved to /root/.novacpx/credentials.txt"
# ── System update ─────────────────────────────────────────────────────────────
step "Updating System Packages"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >> "$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" <<NGXCONF
server {
listen 2083 ssl http2;
server_name _;
root $WEB_ROOT;
index index.php;
ssl_certificate /etc/novacpx/ssl/novacpx.crt;
ssl_certificate_key /etc/novacpx/ssl/novacpx.key;
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php$ {
fastcgi_pass unix:/run/php/php${PHP_DEFAULT}-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
}
location ~ /\.ht { deny all; }
}
NGXCONF
ln -sf "$PANEL_WEB_CONF" /etc/nginx/sites-enabled/novacpx
else
apt-get install -y -qq apache2 libapache2-mod-fcgid >> "$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" <<APCONF
<VirtualHost *:2083>
DocumentRoot $WEB_ROOT
SSLEngine on
SSLCertificateFile /etc/novacpx/ssl/novacpx.crt
SSLCertificateKeyFile /etc/novacpx/ssl/novacpx.key
<Directory $WEB_ROOT>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
<FilesMatch "\.php$">
SetHandler "proxy:unix:/run/php/php${PHP_DEFAULT}-fpm.sock|fcgi://localhost/"
</FilesMatch>
</VirtualHost>
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 <<BINDCONF
options {
directory "/var/cache/bind";
recursion yes;
allow-recursion { localhost; };
listen-on { any; };
forwarders { 8.8.8.8; 1.1.1.1; };
dnssec-validation auto;
auth-nxdomain no;
};
BINDCONF
systemctl restart named >> "$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 <<CONFIG
[database]
host = localhost
name = ${DB_NAME}
user = ${DB_USER}
pass = ${DB_PASS}
[panel]
secret = ${SECRET_KEY}
port = 2083
webroot = ${WEB_ROOT}
version = ${NOVACPX_VERSION}
[web]
server = ${WEB_SERVER}
php_default = ${PHP_DEFAULT}
CONFIG
chmod 600 /etc/novacpx/config.ini
# Import database schema
if [[ -f /opt/novacpx-src/db/schema.sql ]]; then
mysql "$DB_NAME" < /opt/novacpx-src/db/schema.sql >> "$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 <<F2B
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 5
[sshd]
enabled = true
[novacpx-panel]
enabled = true
port = 2083
logpath = /var/log/novacpx/access.log
maxretry = 10
F2B
systemctl enable fail2ban >> "$LOG" 2>&1
systemctl restart fail2ban >> "$LOG" 2>&1
log "Fail2Ban configured"
# ── Cron jobs ─────────────────────────────────────────────────────────────────
step "Setting Up Cron Jobs"
cat > /etc/cron.d/novacpx <<CRON
# NovaCPX system cron jobs
*/5 * * * * www-data /usr/local/bin/php${PHP_DEFAULT} ${WEB_ROOT}/api/cron/monitor.php >> /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 <<DONE
╔══════════════════════════════════════════════════════════════╗
║ NovaCPX Installation Complete! ║
╠══════════════════════════════════════════════════════════════╣
║ Panel URL: https://${SERVER_IP}:2083
║ Username: admin
║ Password: ${ADMIN_PASS}
╠══════════════════════════════════════════════════════════════╣
║ Credentials saved to: /root/.novacpx/credentials.txt ║
║ Install log: ${LOG}
╚══════════════════════════════════════════════════════════════╝
DONE
+44
View File
@@ -0,0 +1,44 @@
<?php
$method = $_SERVER['REQUEST_METHOD'];
$body = json_decode(file_get_contents('php://input'), true) ?? [];
match ($action) {
'login' => (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),
};
+158
View File
@@ -0,0 +1,158 @@
<?php
/**
* System endpoint — version info, updates, server stats, services
* Admin-only actions gated with Auth::require('admin')
*/
Auth::getInstance()->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),
};
+56
View File
@@ -0,0 +1,56 @@
<?php
/**
* NovaCPX API Router
* All requests: /api/{endpoint}/{action}
*/
define('NOVACPX_ROOT', dirname(__DIR__, 2));
define('NOVACPX_API', __DIR__);
define('NOVACPX_LIB', NOVACPX_ROOT . '/panel/lib');
header('Content-Type: application/json');
header('X-NovaCPX-Version: ' . (file_get_contents(NOVACPX_ROOT . '/VERSION') ?: '1.0.0'));
// CORS for same-origin panel requests
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (preg_match('#^https?://[^/]+:2083$#', $origin)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
require_once NOVACPX_LIB . '/Core.php';
require_once NOVACPX_LIB . '/Auth.php';
require_once NOVACPX_LIB . '/DB.php';
require_once NOVACPX_LIB . '/Response.php';
// Parse route: /api/endpoint/action
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$parts = array_values(array_filter(explode('/', $uri)));
$apiIdx = array_search('api', $parts);
$endpoint = $parts[$apiIdx + 1] ?? null;
$action = $parts[$apiIdx + 2] ?? null;
if (!$endpoint) {
Response::json(['status' => '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;
+100
View File
@@ -0,0 +1,100 @@
<?php
class Auth {
private static ?Auth $instance = null;
private ?array $user = null;
private function __construct() {}
public static function getInstance(): self {
if (!self::$instance) self::$instance = new self();
return self::$instance;
}
public function check(): bool {
// Bearer token (API)
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (str_starts_with($header, 'Bearer ')) {
$token = substr($header, 7);
return $this->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);
}
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
/**
* NovaCPX Core — constants, config loader, helpers
*/
define('NOVACPX_VERSION', trim(file_get_contents(NOVACPX_ROOT . '/VERSION') ?: '1.0.0'));
define('NOVACPX_CONFIG', '/etc/novacpx/config.ini');
// Load config
$_cfg = parse_ini_file(NOVACPX_CONFIG, true);
if (!$_cfg) {
http_response_code(503);
die(json_encode(['error' => '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());
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
class DB {
private static ?DB $instance = null;
private PDO $pdo;
private function __construct() {
$this->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; }
}
+29
View File
@@ -0,0 +1,29 @@
<?php
class Response {
public static function json(array $data, int $code = 200): never {
http_response_code($code);
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
public static function success(mixed $data = null, string $message = 'OK'): never {
self::json(['success' => 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),
],
]);
}
}
+17
View File
@@ -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=()"
+176
View File
@@ -0,0 +1,176 @@
<?php
// NovaCPX Admin Panel — Datacenter/Server Manager
// Equivalent to WHM (WebHost Manager)
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NovaCPX Admin</title>
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
<link rel="stylesheet" href="/assets/css/nova.css">
</head>
<body>
<div class="panel-layout" id="app" style="display:none">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">
<svg class="logo-icon" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="18" stroke="url(#lg1)" stroke-width="2"/>
<path d="M12 28 L20 8 L28 28" stroke="url(#lg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 22 H26" stroke="url(#lg2)" stroke-width="2" stroke-linecap="round"/>
<defs>
<linearGradient id="lg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
<linearGradient id="lg2" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
</defs>
</svg>
<span class="logo-text">Nova<strong>CPX</strong> <small style="font-size:.65rem;color:var(--text-muted)">Admin</small></span>
</div>
<nav>
<div class="sidebar-section">
<div class="sidebar-section-label">Overview</div>
<a href="#" class="sidebar-link active" data-page="dashboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Dashboard
</a>
<a href="#" class="sidebar-link" data-page="server-status">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
Server Status
</a>
</div>
<div class="sidebar-section">
<div class="sidebar-section-label">Accounts</div>
<a href="#" class="sidebar-link" data-page="accounts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
All Accounts
</a>
<a href="#" class="sidebar-link" data-page="resellers">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M2 20c0-4 4-7 10-7s10 3 10 7"/></svg>
Resellers
</a>
<a href="#" class="sidebar-link" data-page="packages">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
Packages
</a>
<a href="#" class="sidebar-link" data-page="create-account">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
Create Account
</a>
</div>
<div class="sidebar-section">
<div class="sidebar-section-label">DNS</div>
<a href="#" class="sidebar-link" data-page="dns-zones">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
DNS Zones
</a>
<a href="#" class="sidebar-link" data-page="nameservers">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
Nameservers
</a>
</div>
<div class="sidebar-section">
<div class="sidebar-section-label">Services</div>
<a href="#" class="sidebar-link" data-page="web-server">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
Web Server
</a>
<a href="#" class="sidebar-link" data-page="php-manager">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
PHP Manager
</a>
<a href="#" class="sidebar-link" data-page="mysql-manager">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
MySQL / PgSQL
</a>
<a href="#" class="sidebar-link" data-page="mail-server">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
Mail Server
</a>
<a href="#" class="sidebar-link" data-page="ftp-server">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
FTP Server
</a>
</div>
<div class="sidebar-section">
<div class="sidebar-section-label">Security</div>
<a href="#" class="sidebar-link" data-page="ssl-manager">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
SSL Manager
</a>
<a href="#" class="sidebar-link" data-page="firewall">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Firewall / Fail2Ban
</a>
<a href="#" class="sidebar-link" data-page="audit-log">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
Audit Log
</a>
</div>
<div class="sidebar-section">
<div class="sidebar-section-label">System</div>
<a href="#" class="sidebar-link" data-page="updates">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
Updates <span id="update-badge" class="badge badge-yellow" style="display:none"></span>
</a>
<a href="#" class="sidebar-link" data-page="backups">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Backups
</a>
<a href="#" class="sidebar-link" data-page="settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
</a>
</div>
</nav>
<div class="sidebar-user">
<div class="sidebar-user-info">
<div class="avatar" id="user-avatar">A</div>
<div>
<div class="user-name" id="user-name">Admin</div>
<div class="user-role">Administrator</div>
</div>
<a href="#" id="logout-btn" class="btn btn-ghost btn-sm btn-icon" title="Logout" style="margin-left:auto">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</a>
</div>
</div>
</aside>
<!-- Main Content -->
<div class="main-content">
<header class="topbar">
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" style="display:none">☰</button>
<div class="topbar-title" id="page-title">Dashboard</div>
<div class="topbar-actions">
<span id="server-ip" class="text-muted text-sm"></span>
<div id="alert-indicator" style="display:none">
<span class="badge badge-red" id="alert-count"></span>
</div>
</div>
</header>
<div class="page-content" id="page-content">
<!-- Loaded by JS -->
</div>
</div>
</div>
<!-- Auth guard -->
<div id="auth-check" style="display:flex;align-items:center;justify-content:center;min-height:100vh">
<div style="text-align:center;color:var(--text-muted)">Verifying session…</div>
</div>
<script src="/assets/js/nova.js"></script>
<script src="/assets/js/admin.js"></script>
</body>
</html>
+302
View File
@@ -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; }
+315
View File
@@ -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 `
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">CPU Usage</div>
<div class="stat-value ${s.cpu?.pct > 80 ? 'stat-red' : 'stat-green'}">${s.cpu?.pct ?? 0}%</div>
<div class="stat-sub">Load: ${(s.cpu?.load || [0,0,0]).join(' / ')}</div>
<div class="mt-1">${Nova.progressBar(s.cpu?.pct || 0)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Memory</div>
<div class="stat-value ${s.ram?.pct > 80 ? 'stat-red' : 'stat-blue'}">${s.ram?.pct ?? 0}%</div>
<div class="stat-sub">${Nova.bytes((s.ram?.used_kb||0)*1024)} / ${Nova.bytes((s.ram?.total_kb||0)*1024)}</div>
<div class="mt-1">${Nova.progressBar(s.ram?.pct || 0)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Disk</div>
<div class="stat-value ${s.disk?.pct > 85 ? 'stat-red' : 'stat-yellow'}">${s.disk?.pct ?? 0}%</div>
<div class="stat-sub">${Nova.bytes(s.disk?.total - s.disk?.free || 0)} used</div>
<div class="mt-1">${Nova.progressBar(s.disk?.pct || 0)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Uptime</div>
<div class="stat-value stat-green" style="font-size:1rem;padding-top:.4rem">${s.uptime || '—'}</div>
<div class="stat-sub">PHP ${v.php_version || '—'}</div>
</div>
</div>
<div class="grid-2 gap-2">
<div class="card">
<div class="card-header"><span class="card-title">Services</span></div>
<div class="card-body">
<table><tbody>
${Object.entries(s.services || {}).map(([svc, status]) => `
<tr>
<td>${Nova.serviceDot(status)} ${svc}</td>
<td>${Nova.badge(status, status === 'active' ? 'green' : 'red')}</td>
<td class="text-right">
<button class="btn btn-ghost btn-sm" onclick="adminServiceAction('${svc}','restart')">Restart</button>
<button class="btn btn-ghost btn-sm" onclick="adminServiceAction('${svc}','stop')">Stop</button>
</td>
</tr>`).join('')}
</tbody></table>
</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">NovaCPX Version</span></div>
<div class="card-body">
<table><tbody>
<tr><td class="text-muted">Installed</td><td><strong>${v.installed_version || ''}</strong></td></tr>
<tr><td class="text-muted">Branch</td><td><code>${v.git_branch || 'main'}</code></td></tr>
<tr><td class="text-muted">Commit</td><td><code>${v.git_commit || ''}</code>${v.git_dirty ? ' <span class="badge badge-yellow">dirty</span>' : ''}</td></tr>
<tr><td class="text-muted">PHP</td><td>${v.php_version || ''}</td></tr>
<tr><td class="text-muted">OS</td><td>${v.os || ''}</td></tr>
</tbody></table>
<div class="mt-2">
<button class="btn btn-primary btn-sm" onclick="adminPage('updates')">Check for Updates</button>
</div>
</div>
</div>
</div>`;
}
// ── Server Status ──────────────────────────────────────────────────────────
async function serverStatus() {
const res = await Nova.api('system', 'stats');
const s = res?.data || {};
return `
<div class="card">
<div class="card-header"><span class="card-title">Real-Time Server Status</span>
<button class="btn btn-ghost btn-sm" onclick="adminPage('server-status')">&#x21bb; Refresh</button>
</div>
<div class="card-body">
<div class="grid-3">
<div><p class="text-muted text-sm mb-1">CPU</p><h2>${s.cpu?.pct}%</h2>${Nova.progressBar(s.cpu?.pct||0)}</div>
<div><p class="text-muted text-sm mb-1">RAM</p><h2>${s.ram?.pct}%</h2>${Nova.progressBar(s.ram?.pct||0)}</div>
<div><p class="text-muted text-sm mb-1">Disk</p><h2>${s.disk?.pct}%</h2>${Nova.progressBar(s.disk?.pct||0)}</div>
</div>
<div class="mt-3">
<p class="text-muted text-sm mb-1">Load Average</p>
<p>${(s.cpu?.load||[]).join(' / ')}</p>
</div>
<div class="mt-3">
<p class="text-muted text-sm mb-1">Uptime</p>
<p>${s.uptime}</p>
</div>
</div>
</div>`;
}
// ── 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 `
<div class="card">
<div class="card-header">
<span class="card-title">NovaCPX Updates</span>
${count > 0 ? Nova.badge(count + ' update' + (count > 1 ? 's' : '') + ' available', 'yellow') : Nova.badge('Up to date', 'green')}
</div>
<div class="card-body">
<div class="grid-2 mb-3">
<div><p class="text-muted text-sm">Installed Version</p><p class="font-bold">${v.installed_version}</p></div>
<div><p class="text-muted text-sm">Git Commit</p><code>${v.git_commit || ''}</code></div>
<div><p class="text-muted text-sm">Branch</p><code>${v.git_branch || 'main'}</code></div>
<div><p class="text-muted text-sm">Dirty Working Tree</p><p>${v.git_dirty ? Nova.badge('Yes','yellow') : Nova.badge('No','green')}</p></div>
</div>
${count > 0 ? `
<div class="card mb-2" style="background:var(--bg3)">
<div class="card-header"><span class="card-title">Pending Commits</span></div>
<div class="card-body terminal">
${upd.commits?.map(c => `<div>${c}</div>`).join('') || 'None'}
</div>
</div>
<button class="btn btn-primary" onclick="applyUpdate()">Apply Update</button>
` : `<p class="text-muted">NovaCPX is up to date.</p>`}
</div>
</div>`;
}
// ── Audit Log ──────────────────────────────────────────────────────────────
async function auditLog() {
const res = await Nova.api('system', 'audit-log', { params: { per_page: 50 } });
const rows = res?.data || [];
return `
<div class="card">
<div class="card-header"><span class="card-title">Audit Log</span></div>
<div class="table-wrap">
<table>
<thead><tr><th>Time</th><th>User</th><th>Action</th><th>Resource</th><th>IP</th></tr></thead>
<tbody>
${rows.map(r => `
<tr>
<td class="text-muted text-sm">${Nova.relTime(r.created_at)}</td>
<td>${r.username || '—'}</td>
<td><code>${r.action}</code></td>
<td>${r.resource || '—'}</td>
<td class="text-muted text-sm">${r.ip_address || '—'}</td>
</tr>`).join('')}
</tbody>
</table>
</div>
</div>`;
}
// ── PHP Manager ────────────────────────────────────────────────────────────
async function phpManager() {
return `
<div class="card">
<div class="card-header"><span class="card-title">PHP Version Manager</span></div>
<div class="card-body">
<p class="text-muted mb-2">Manage installed PHP versions and global extensions.</p>
<div class="grid-4">
${['7.4','8.1','8.2','8.3'].map(v => `
<div class="stat-card">
<div class="stat-label">PHP ${v}</div>
<div class="stat-value" style="font-size:1rem">${Nova.badge('Active','green')}</div>
<div class="mt-2 flex gap-1">
<button class="btn btn-ghost btn-sm" onclick="phpAction('${v}','fpm-restart')">Restart FPM</button>
</div>
</div>`).join('')}
</div>
<div class="mt-3">
<h4 class="mb-1">Global PHP Extensions</h4>
<p class="text-muted text-sm">Extensions installed across all PHP versions: mbstring, curl, gd, xml, zip, opcache, redis, imagick, pdo, pdo_mysql, pdo_pgsql</p>
</div>
</div>
</div>`;
}
// ── Settings ───────────────────────────────────────────────────────────────
async function settings() {
return `
<div class="card">
<div class="card-header"><span class="card-title">Panel Settings</span></div>
<div class="card-body">
<form id="settings-form">
<div class="grid-2">
<div class="form-group"><label>Panel Name</label><input type="text" name="panel_name" value="NovaCPX"></div>
<div class="form-group"><label>Default PHP Version</label>
<select name="default_php">
${['7.4','8.1','8.2','8.3'].map(v => `<option value="${v}" ${v==='8.3'?'selected':''}>${v}</option>`).join('')}
</select>
</div>
<div class="form-group"><label>Primary Nameserver</label><input type="text" name="default_nameserver1" value="ns1.example.com"></div>
<div class="form-group"><label>Secondary Nameserver</label><input type="text" name="default_nameserver2" value="ns2.example.com"></div>
<div class="form-group"><label>Update Channel</label>
<select name="update_channel"><option value="stable">Stable</option><option value="beta">Beta</option></select>
</div>
<div class="form-group"><label>Git Remote</label><input type="url" name="git_remote" value="https://github.com/myronblair/novacpx.git"></div>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
</div>
</div>`;
}
// ── Stub pages ─────────────────────────────────────────────────────────────
function stubPage(title, desc) {
return `<div class="card"><div class="card-header"><span class="card-title">${title}</span></div>
<div class="card-body"><p class="text-muted">${desc}</p>
<div class="mt-2">${Nova.badge('Coming Soon','yellow')}</div></div></div>`;
}
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 = ''; }
}
})();
+125
View File
@@ -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 = `<div class="modal">
<div class="modal-header">
<span class="modal-title">${title}</span>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">&times;</button>
</div>
<div class="modal-body">${bodyHtml}</div>
${footerHtml ? `<div class="modal-footer">${footerHtml}</div>` : ''}
</div>`;
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', `<p>${msg}</p>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-${danger ? 'red' : 'primary'}" id="confirm-yes">Confirm</button>`
);
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 = '<div style="padding:2rem;color:var(--text-muted);text-align:center">Loading…</div>';
Promise.resolve(fn()).then(html => { if (html) content.innerHTML = html; });
} else {
content.innerHTML = `<div class="card"><div class="card-body"><p class="text-muted">Page "${page}" coming soon.</p></div></div>`;
}
}
// ── Progress bar helper ───────────────────────────────────────────────────
function progressBar(pct) {
const color = pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green';
return `<div class="progress"><div class="progress-bar ${color}" style="width:${pct}%"></div></div>`;
}
// ── 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 `<span class="badge badge-${type}">${text}</span>`;
}
function serviceDot(status) {
const cls = status === 'active' ? 'active' : status === 'inactive' ? 'inactive' : 'unknown';
return `<span class="service-dot ${cls}"></span>`;
}
// 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 };
})();
+130
View File
@@ -0,0 +1,130 @@
<?php
// NovaCPX entry point — redirect based on role or show login
session_start();
$redirect = $_GET['redirect'] ?? '';
$safeRedirect = preg_match('#^/(user|reseller|admin)#', $redirect) ? $redirect : '';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NovaCPX Login</title>
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
<link rel="stylesheet" href="/assets/css/nova.css">
</head>
<body class="login-page">
<div class="login-wrap">
<div class="login-brand">
<svg class="logo-icon" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="20" r="18" stroke="url(#lg1)" stroke-width="2"/>
<path d="M12 28 L20 8 L28 28" stroke="url(#lg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 22 H26" stroke="url(#lg2)" stroke-width="2" stroke-linecap="round"/>
<defs>
<linearGradient id="lg1" x1="2" y1="2" x2="38" y2="38">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#0ea5e9"/>
</linearGradient>
<linearGradient id="lg2" x1="12" y1="8" x2="28" y2="28">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#0ea5e9"/>
</linearGradient>
</defs>
</svg>
<span class="logo-text">Nova<strong>CPX</strong></span>
</div>
<div class="login-card">
<h1>Sign In</h1>
<p class="login-sub">Linux Web Hosting Control Panel</p>
<div id="login-error" class="alert alert-error" style="display:none"></div>
<form id="login-form">
<div class="form-group">
<label for="username">Username or Email</label>
<input type="text" id="username" name="username" autocomplete="username" autofocus required>
</div>
<div class="form-group">
<label for="password">Password</label>
<div class="input-with-icon">
<input type="password" id="password" name="password" autocomplete="current-password" required>
<button type="button" class="eye-toggle" data-target="password">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary btn-full" id="login-btn">
<span class="btn-text">Sign In</span>
<span class="btn-spinner" style="display:none">Signing in…</span>
</button>
</form>
</div>
<div class="login-footer">
NovaCPX v<span id="panel-version">1.0.0</span> &nbsp;|&nbsp;
<a href="/api/system/version" target="_blank">System Info</a>
</div>
</div>
<script>
const REDIRECT = <?= json_encode($safeRedirect) ?>;
document.getElementById('login-form').addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('login-btn');
const err = document.getElementById('login-error');
btn.querySelector('.btn-text').style.display = 'none';
btn.querySelector('.btn-spinner').style.display = '';
btn.disabled = true;
err.style.display = 'none';
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type':'application/json'},
credentials: 'include',
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
});
const data = await res.json();
if (!data.success) throw new Error(data.message || 'Login failed');
const role = data.data.user.role;
const dest = REDIRECT || (role === 'admin' ? '/admin/' : role === 'reseller' ? '/reseller/' : '/user/');
location.href = dest;
} catch (ex) {
err.textContent = ex.message;
err.style.display = '';
btn.querySelector('.btn-text').style.display = '';
btn.querySelector('.btn-spinner').style.display = 'none';
btn.disabled = false;
}
});
// Password toggle
document.querySelectorAll('.eye-toggle').forEach(btn => {
btn.addEventListener('click', () => {
const inp = document.getElementById(btn.dataset.target);
inp.type = inp.type === 'password' ? 'text' : 'password';
});
});
// Fetch version
fetch('/api/auth/me', {credentials:'include'}).then(r => r.json()).then(d => {
if (d.success) {
const role = d.data.role;
location.href = role === 'admin' ? '/admin/' : role === 'reseller' ? '/reseller/' : '/user/';
}
});
fetch('/api/system/version', {credentials:'include'})
.then(r=>r.json()).then(d=>{ if(d.data?.installed_version) document.getElementById('panel-version').textContent=d.data.installed_version; });
</script>
</body>
</html>
+269
View File
@@ -0,0 +1,269 @@
<?php
// NovaCPX User Panel — End-user hosting dashboard
// Design: Horizontal feature cards with usage rings, NOT cPanel icon grid
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NovaCPX My Hosting</title>
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
<link rel="stylesheet" href="/assets/css/nova.css">
<style>
/* ── User panel specific ─────────────────────────────── */
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.feature-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.25rem;
text-decoration: none;
color: var(--text);
display: flex;
align-items: flex-start;
gap: 1rem;
transition: border-color .15s, transform .1s;
cursor: pointer;
}
.feature-card:hover {
border-color: var(--primary);
transform: translateY(-1px);
}
.feature-icon {
width: 44px; height: 44px; flex-shrink: 0;
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
}
.fi-purple { background: rgba(99,102,241,.15); color: var(--primary); }
.fi-sky { background: rgba(14,165,233,.15); color: var(--sky); }
.fi-green { background: rgba(16,185,129,.15); color: var(--green); }
.fi-yellow { background: rgba(245,158,11,.15); color: var(--yellow); }
.fi-red { background: rgba(239,68,68,.15); color: var(--red); }
.fi-pink { background: rgba(236,72,153,.15); color: #f472b6; }
.fi-teal { background: rgba(20,184,166,.15); color: #2dd4bf; }
.fi-orange { background: rgba(249,115,22,.15); color: #fb923c; }
.feature-icon svg { width: 22px; height: 22px; }
.feature-info { flex: 1; min-width: 0; }
.feature-name { font-weight: 600; font-size: .9rem; margin-bottom: .2rem; }
.feature-desc { font-size: .78rem; color: var(--text-muted); line-height: 1.4; }
.feature-meta { font-size: .75rem; color: var(--primary); margin-top: .3rem; }
/* Usage ring */
.usage-rings {
display: flex; gap: 2rem; align-items: center;
background: var(--bg2); border: 1px solid var(--border);
border-radius: 12px; padding: 1.25rem 1.5rem; margin-bottom: 1.5rem;
}
.ring-item { text-align: center; }
.ring-label { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin-top: .5rem; }
.ring-val { font-size: .85rem; font-weight: 600; margin-top: .15rem; }
svg.ring { transform: rotate(-90deg); }
svg.ring circle { transition: stroke-dashoffset .5s; }
/* Breadcrumb / section tabs */
.section-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.25rem; }
.section-header h2 { font-size: 1rem; font-weight: 700; flex: 1; }
</style>
</head>
<body>
<div class="panel-layout" id="app" style="display:none">
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">
<svg class="logo-icon" viewBox="0 0 40 40" fill="none">
<circle cx="20" cy="20" r="18" stroke="url(#ulg1)" stroke-width="2"/>
<path d="M12 28 L20 8 L28 28" stroke="url(#ulg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 22 H26" stroke="url(#ulg2)" stroke-width="2" stroke-linecap="round"/>
<defs>
<linearGradient id="ulg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
<linearGradient id="ulg2" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
</defs>
</svg>
<span class="logo-text">Nova<strong>CPX</strong></span>
</div>
<nav>
<div class="sidebar-section">
<div class="sidebar-section-label">My Account</div>
<a href="#" class="sidebar-link active" data-page="home">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg> Home
</a>
<a href="#" class="sidebar-link" data-page="domains">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg> Domains
</a>
<a href="#" class="sidebar-link" data-page="files">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> File Manager
</a>
</div>
<div class="sidebar-section">
<div class="sidebar-section-label">Email</div>
<a href="#" class="sidebar-link" data-page="email-accounts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg> Email Accounts
</a>
<a href="#" class="sidebar-link" data-page="forwarders">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg> Forwarders
</a>
<a href="#" class="sidebar-link" data-page="autoresponders">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> Autoresponders
</a>
</div>
<div class="sidebar-section">
<div class="sidebar-section-label">Databases</div>
<a href="#" class="sidebar-link" data-page="mysql">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg> MySQL
</a>
<a href="#" class="sidebar-link" data-page="postgres">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg> PostgreSQL
</a>
</div>
<div class="sidebar-section">
<div class="sidebar-section-label">Advanced</div>
<a href="#" class="sidebar-link" data-page="ftp">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg> FTP Accounts
</a>
<a href="#" class="sidebar-link" data-page="dns">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg> DNS Editor
</a>
<a href="#" class="sidebar-link" data-page="ssl">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> SSL / TLS
</a>
<a href="#" class="sidebar-link" data-page="php-config">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> PHP Config
</a>
<a href="#" class="sidebar-link" data-page="cron">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> Cron Jobs
</a>
<a href="#" class="sidebar-link" data-page="backups">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> Backups
</a>
<a href="#" class="sidebar-link" data-page="logs">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/></svg> Error Logs
</a>
</div>
</nav>
<div class="sidebar-user">
<div class="sidebar-user-info">
<div class="avatar" id="user-avatar">U</div>
<div><div class="user-name" id="user-name">User</div><div class="user-role" id="user-domain">example.com</div></div>
<a href="#" id="logout-btn" class="btn btn-ghost btn-sm btn-icon" title="Logout" style="margin-left:auto">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</a>
</div>
</div>
</aside>
<div class="main-content">
<header class="topbar">
<div class="topbar-title" id="page-title">My Hosting</div>
<div class="topbar-actions">
<span id="account-domain" class="text-muted text-sm"></span>
</div>
</header>
<div class="page-content" id="page-content"></div>
</div>
</div>
<div id="auth-check" style="display:flex;align-items:center;justify-content:center;min-height:100vh">
<div style="text-align:center;color:var(--text-muted)">Loading…</div>
</div>
<script src="/assets/js/nova.js"></script>
<script>
(async () => {
const me = await Nova.api('auth', 'me');
if (!me?.success) { location.href = '/?redirect=/user/'; 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();
document.getElementById('logout-btn').addEventListener('click', async e => {
e.preventDefault();
await Nova.api('auth', 'logout', { method: 'POST' });
location.href = '/';
});
function ring(pct, color, r = 28) {
const circ = 2 * Math.PI * r;
const offset = circ * (1 - pct / 100);
return `<svg class="ring" width="${r*2+8}" height="${r*2+8}" viewBox="0 0 ${r*2+8} ${r*2+8}">
<circle cx="${r+4}" cy="${r+4}" r="${r}" fill="none" stroke="var(--border)" stroke-width="5"/>
<circle cx="${r+4}" cy="${r+4}" r="${r}" fill="none" stroke="${color}" stroke-width="5"
stroke-dasharray="${circ}" stroke-dashoffset="${offset}" stroke-linecap="round"/>
</svg>`;
}
const homePage = () => `
<div class="usage-rings">
<div>
<h2 style="font-size:1.1rem;font-weight:700">My Hosting</h2>
<p class="text-muted text-sm">example.com &nbsp;·&nbsp; Active</p>
</div>
<div style="margin-left:auto;display:flex;gap:2rem">
<div class="ring-item">${ring(45,'#6366f1')}<div class="ring-label">Disk</div><div class="ring-val">2.3 GB / 5 GB</div></div>
<div class="ring-item">${ring(22,'#0ea5e9')}<div class="ring-label">Bandwidth</div><div class="ring-val">2.2 GB / 10 GB</div></div>
<div class="ring-item">${ring(60,'#10b981')}<div class="ring-label">Email</div><div class="ring-val">6 / 10</div></div>
<div class="ring-item">${ring(30,'#f59e0b')}<div class="ring-label">Databases</div><div class="ring-val">3 / 10</div></div>
</div>
</div>
<div class="section-header"><h2>Quick Access</h2></div>
<div class="feature-grid">
${[
{ page:'files', icon:'folder', color:'fi-yellow', name:'File Manager', desc:'Upload, edit, and manage your website files and directories.' },
{ page:'email-accounts',icon:'mail', color:'fi-sky', name:'Email Accounts', desc:'Create and manage mailboxes for your domains.' },
{ page:'mysql', icon:'db', color:'fi-green', name:'MySQL Databases', desc:'Create databases and users for your PHP applications.' },
{ page:'postgres', icon:'db', color:'fi-teal', name:'PostgreSQL', desc:'Manage PostgreSQL databases and connections.' },
{ page:'domains', icon:'globe', color:'fi-purple', name:'Domains', desc:'Add subdomains, addon domains, and domain aliases.' },
{ page:'dns', icon:'dns', color:'fi-orange', name:'DNS Editor', desc:'Manage A, CNAME, MX, TXT, and SRV records.' },
{ page:'ssl', icon:'lock', color:'fi-green', name:'SSL / TLS', desc:'Issue free Let\'s Encrypt certificates for your domains.' },
{ page:'ftp', icon:'ftp', color:'fi-pink', name:'FTP Accounts', desc:'Create FTP users with directory access controls.' },
{ page:'php-config', icon:'code', color:'fi-purple', name:'PHP Config', desc:'Switch PHP version and configure php.ini settings per domain.' },
{ page:'cron', icon:'clock', color:'fi-yellow', name:'Cron Jobs', desc:'Schedule automated tasks on any interval.' },
{ page:'forwarders', icon:'forward', color:'fi-sky', name:'Email Forwarders', desc:'Forward emails from one address to another.' },
{ page:'backups', icon:'backup', color:'fi-red', name:'Backups', desc:'Create full or partial backups and restore files.' },
].map(f => `
<div class="feature-card" onclick="loadUserPage('${f.page}')">
<div class="feature-icon ${f.color}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
${svgPath(f.icon)}
</svg>
</div>
<div class="feature-info">
<div class="feature-name">${f.name}</div>
<div class="feature-desc">${f.desc}</div>
</div>
</div>`).join('')}
</div>`;
function svgPath(icon) {
const p = {
folder:'<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
mail:'<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>',
db:'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>',
globe:'<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
dns:'<line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>',
lock:'<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
ftp:'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
code:'<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
clock:'<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
forward:'<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>',
backup:'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
};
return p[icon] || '';
}
const pages = { home: homePage };
Nova.initNav(pages);
document.getElementById('page-content').innerHTML = homePage();
window.loadUserPage = (page) => Nova.loadPage(page, pages);
})();
</script>
</body>
</html>