mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
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:
@@ -0,0 +1,9 @@
|
|||||||
|
# NovaCPX .gitignore
|
||||||
|
/etc/novacpx/config.ini
|
||||||
|
panel/api/config.php
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
/var/
|
||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
+372
@@ -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
@@ -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
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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=()"
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
@@ -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')">↻ 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 = ''; }
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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()">×</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 };
|
||||||
|
})();
|
||||||
@@ -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> |
|
||||||
|
<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>
|
||||||
@@ -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 · 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>
|
||||||
Reference in New Issue
Block a user