mirror of
https://github.com/myronblair/novacpx
synced 2026-07-29 13:42:45 -05:00
Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be3dbec68a | |||
| a299447c9c | |||
| 25233b9496 | |||
| 35aa202a4f | |||
| 2beffd810d | |||
| 3d2c7c25c2 | |||
| 8adedb9759 | |||
| 827a4e8297 | |||
| 14cb285209 | |||
| d6321c3268 | |||
| bc691466cd | |||
| bd4ac8426a | |||
| 85cbfd554c | |||
| 33c86171d6 | |||
| bc727eeedc | |||
| 7ecab86ef9 | |||
| 7cce8cfef0 | |||
| 3a1746b0c0 | |||
| cba75b3356 | |||
| d24ea40505 | |||
| 18971f7f10 | |||
| e805e55120 | |||
| b29a988129 | |||
| 87281cb923 | |||
| 5d9fc30124 | |||
| a98f08e45a | |||
| c872ee2449 | |||
| 568e0a0891 | |||
| 732b7d16ca | |||
| e209df0dc2 | |||
| 9691cc0853 | |||
| 42055ccdcc | |||
| 2327f1b958 | |||
| 3ca3a1dae6 | |||
| a91b67de10 | |||
| d39559a058 | |||
| 01b099509f | |||
| 844f571231 | |||
| 06e43b1297 | |||
| 960a29f508 | |||
| 63ec5f48b3 | |||
| 6aa96e6265 | |||
| 12e03304af | |||
| 1c4a06d31e | |||
| 55f5fc1da9 | |||
| b00cf10120 | |||
| e88a5e6fdc | |||
| 76726dc47c | |||
| 8405772e01 | |||
| 6b59730bec | |||
| 9157307ae1 | |||
| e0447bc5f5 | |||
| b278effdfb | |||
| 697763f333 | |||
| fcde84d2ad | |||
| 9caaa65b31 | |||
| fe41a97e74 | |||
| 2ecf93a344 | |||
| a5bb5dfddd | |||
| 6f494e96fd | |||
| 5a2e81e754 | |||
| 5d1d47a007 | |||
| 6b945bb0fa | |||
| 7b11439f9c | |||
| 3684f7c6c2 | |||
| 956defc34b | |||
| 21dd846508 | |||
| 60004a29d6 | |||
| b281768685 | |||
| 1a907d18b0 | |||
| c0cc88ac3b | |||
| 65a8690750 | |||
| 29e2744609 | |||
| 91d0e625c4 | |||
| 99829f628d | |||
| e12f569460 | |||
| def6edf4d5 | |||
| 9aa67f7efd | |||
| a8a80bff5e | |||
| eb84504689 | |||
| 2d074ea25e | |||
| 8e623427e3 | |||
| 79857a750a | |||
| 3ad7ee44c2 | |||
| 15bbe955fa | |||
| 3dab4ffe0f | |||
| ee7e488f3d | |||
| b534e7e306 | |||
| 0b6850673a | |||
| 6dd2e3a08d | |||
| 89c1deea5c | |||
| b077226581 | |||
| 7185fcca5f | |||
| 91bf8d965f | |||
| ab7c69d086 | |||
| 39942929a7 | |||
| cbd20c5390 | |||
| 8497aecc8d | |||
| 08844c6f79 | |||
| 5ce5bd1520 | |||
| 87c0f9c651 | |||
| 483ab8a002 | |||
| 7e4b9e18b2 | |||
| 008658e0ec | |||
| db033732b4 | |||
| 9bc427f8a2 | |||
| f17210fd3b | |||
| 7a42be8d01 | |||
| 2e96c2b0b9 | |||
| 61329ff343 | |||
| 81a8ea88f4 | |||
| 2cc219f9fa | |||
| e87e1a14b8 | |||
| 2fa1f10901 | |||
| 5037633f5f | |||
| 658e2f9057 | |||
| 7107d230c8 | |||
| e6550f0a90 | |||
| b0ac6e7aa2 | |||
| b9c37030b6 |
@@ -8,3 +8,6 @@ VERSION
|
||||
node_modules/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
panel/public/adminer.php
|
||||
panel/public/adminer.php
|
||||
*.bak-*
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Migration: replace global email UNIQUE with partial index (hosting accounts only)
|
||||
-- Allows admin/reseller emails to be reused for hosting accounts
|
||||
CREATE TABLE IF NOT EXISTS users_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','reseller','user')),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','suspended','pending')),
|
||||
reseller_id INTEGER DEFAULT NULL,
|
||||
package_id INTEGER DEFAULT NULL,
|
||||
theme TEXT DEFAULT 'nova-dark',
|
||||
language TEXT DEFAULT 'en',
|
||||
contact_name TEXT,
|
||||
contact_phone TEXT,
|
||||
last_login TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT,
|
||||
totp_secret TEXT,
|
||||
totp_enabled INTEGER DEFAULT 0,
|
||||
totp_backup_codes TEXT,
|
||||
FOREIGN KEY (reseller_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO users_new SELECT id,username,password,email,role,status,reseller_id,package_id,theme,language,contact_name,contact_phone,last_login,created_at,updated_at,totp_secret,totp_enabled,totp_backup_codes FROM users;
|
||||
DROP TABLE users;
|
||||
ALTER TABLE users_new RENAME TO users;
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users (role);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_status ON users (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_reseller ON users (reseller_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_user ON users (email) WHERE role = 'user';
|
||||
@@ -79,6 +79,9 @@ while IFS='|' read -r REPO_PATH WEB_ROOT COMMIT QUEUED_BRANCH; do
|
||||
rsync -av "$REPO_PATH/panel/bin/" /opt/novacpx/bin/ >> "$LOG" 2>&1
|
||||
chmod +x /opt/novacpx/bin/*.php 2>/dev/null || true
|
||||
|
||||
# Copy VERSION to web root so /VERSION endpoint stays current
|
||||
[[ -f "$REPO_PATH/VERSION" ]] && cp "$REPO_PATH/VERSION" "$WEB_ROOT/VERSION"
|
||||
|
||||
# Run pending DB migrations (SQLite)
|
||||
MIGR_DIR="$REPO_PATH/db/migrations"
|
||||
if [[ -d "$MIGR_DIR" && -f "$DB_PATH" ]]; then
|
||||
|
||||
+15
-1
@@ -14,6 +14,9 @@ http {
|
||||
sendfile on;
|
||||
gzip on;
|
||||
|
||||
# Large uploads via the admin File Manager (e.g. ISOs, installers) can be multi-GB.
|
||||
client_max_body_size 20g;
|
||||
|
||||
# ── Admin Panel (8882) ─────────────────────────────────────────────────────
|
||||
server {
|
||||
listen 8882 ssl;
|
||||
@@ -43,7 +46,18 @@ http {
|
||||
include /etc/nginx/fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /srv/novacpx/public/api/index.php;
|
||||
fastcgi_param SERVER_PORT 8882;
|
||||
fastcgi_read_timeout 300;
|
||||
fastcgi_read_timeout 1800;
|
||||
fastcgi_send_timeout 1800;
|
||||
client_body_timeout 1800;
|
||||
send_timeout 1800;
|
||||
# Stream the request body straight to PHP-FPM instead of nginx buffering
|
||||
# the whole thing to a temp file first and then reading it back — cuts a
|
||||
# full extra disk read+write pass for large uploads (ISOs, installers).
|
||||
fastcgi_request_buffering off;
|
||||
fastcgi_param PHP_VALUE "upload_max_filesize=20G
|
||||
post_max_size=20G
|
||||
max_execution_time=1800
|
||||
max_input_time=1800";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Executable
+179
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env bash
|
||||
# NovaCPX Post-Restore Script v2
|
||||
# Run after any PBS/backup restore
|
||||
# Usage: /usr/local/bin/novacpx-post-restore [--no-git]
|
||||
|
||||
set -euo pipefail
|
||||
LOG="/var/log/novacpx/post-restore.log"
|
||||
PAT=$(python3 -c "import configparser; c=configparser.ConfigParser(); c.read('/etc/novacpx/config.ini'); print(c.get('deploy','github_pat',fallback=''))" 2>/dev/null || echo "")
|
||||
WEB_DASHBOARD_REPO="https://${PAT}@github.com/myronblair/web-dashboard.git"
|
||||
NOVACPX_REPO="https://${PAT}@github.com/myronblair/novacpx.git"
|
||||
SKIP_GIT="${1:-}"
|
||||
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; }
|
||||
ok() { log " ✓ $*"; }
|
||||
warn() { log " ⚠ $*"; }
|
||||
|
||||
mkdir -p /var/log/novacpx
|
||||
log "=== NovaCPX Post-Restore Started ==="
|
||||
|
||||
# ── 1. Fix config.ini ─────────────────────────────────────────────────────
|
||||
log "1. Fixing config.ini..."
|
||||
sed -i 's/^server = apache/server = nginx/' /etc/novacpx/config.ini
|
||||
ok "server = $(grep '^server' /etc/novacpx/config.ini | cut -d= -f2 | tr -d ' ')"
|
||||
|
||||
# Disable Apache2 if running
|
||||
systemctl stop apache2 2>/dev/null && systemctl disable apache2 2>/dev/null || true
|
||||
|
||||
# ── 2. Fix PHP-FPM ────────────────────────────────────────────────────────
|
||||
log "2. Fixing PHP-FPM..."
|
||||
REMOVED=0
|
||||
for f in /etc/php/8.3/fpm/pool.d/*.conf; do
|
||||
[[ "$f" == *"www.conf"* ]] && continue
|
||||
u=$(basename "$f" .conf)
|
||||
id "$u" &>/dev/null || { rm -f "$f"; ((REMOVED++)) || true; }
|
||||
done
|
||||
[[ $REMOVED -gt 0 ]] && warn "Removed $REMOVED orphaned pools" || ok "No orphaned pools"
|
||||
|
||||
# Fix pm.max_children if still at default 5
|
||||
PM=$(grep "^pm.max_children" /etc/php/8.3/fpm/pool.d/www.conf 2>/dev/null | awk '{print $3}')
|
||||
if [[ "$PM" == "5" ]] || [[ -z "$PM" ]]; then
|
||||
echo -e "\npm = dynamic\npm.max_children = 20\npm.start_servers = 5\npm.min_spare_servers = 3\npm.max_spare_servers = 10" >> /etc/php/8.3/fpm/pool.d/www.conf
|
||||
ok "PHP-FPM max_children bumped to 20"
|
||||
fi
|
||||
|
||||
systemctl start php8.3-fpm 2>/dev/null || systemctl restart php8.3-fpm
|
||||
ok "PHP-FPM $(systemctl is-active php8.3-fpm)"
|
||||
|
||||
# ── 3. Pull latest NovaCPX code ───────────────────────────────────────────
|
||||
if [[ "$SKIP_GIT" != "--no-git" ]] && [[ -n "$PAT" ]]; then
|
||||
log "3. Pulling latest NovaCPX code..."
|
||||
cd /opt/novacpx-src 2>/dev/null || { warn "novacpx-src not found, skipping"; true; }
|
||||
if [[ -d /opt/novacpx-src/.git ]]; then
|
||||
git remote set-url origin "$NOVACPX_REPO" 2>/dev/null
|
||||
# Capture exit code separately — pipeline masks it via while loop
|
||||
GIT_OUT=$(git pull origin main 2>&1) && GIT_OK=1 || GIT_OK=0
|
||||
echo "$GIT_OUT" | tail -3 | while read l; do log " $l"; done
|
||||
[[ "$GIT_OK" == "0" ]] && { warn "git pull failed — deploying existing local code"; }
|
||||
rsync -a --delete --exclude=".git" --exclude="api/config.php" \
|
||||
/opt/novacpx-src/panel/public/ /srv/novacpx/public/ 2>/dev/null
|
||||
rsync -a --delete --exclude="config.php" \
|
||||
/opt/novacpx-src/panel/api/ /srv/novacpx/public/api/ 2>/dev/null
|
||||
rsync -a --delete /opt/novacpx-src/panel/lib/ /srv/novacpx/public/lib/ 2>/dev/null
|
||||
ok "Code deployed"
|
||||
# Run migrations
|
||||
DB=/var/lib/novacpx/panel.db
|
||||
for SQL in /opt/novacpx-src/db/migrations/*.sql; do
|
||||
[[ -f "$SQL" ]] || continue
|
||||
NAME=$(basename "$SQL" .sql)
|
||||
DONE=$(sqlite3 "$DB" "SELECT value FROM settings WHERE key='migration_$NAME'" 2>/dev/null)
|
||||
if [[ -z "$DONE" ]]; then
|
||||
sqlite3 "$DB" < "$SQL" 2>/dev/null && \
|
||||
sqlite3 "$DB" "INSERT OR REPLACE INTO settings (key,value,updated_at) VALUES ('migration_$NAME','$(date)',datetime('now'))" 2>/dev/null
|
||||
ok "Migration: $NAME"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
else
|
||||
log "3. Skipping git pull"
|
||||
fi
|
||||
|
||||
# ── 4. Fix webacct hosting account ────────────────────────────────────────
|
||||
log "4. Fixing webacct hosting account..."
|
||||
DB=/var/lib/novacpx/panel.db
|
||||
|
||||
# Write the PHP helper ONCE here, before any call to it
|
||||
cat > /tmp/_nova_create_webacct.php << 'PHPEOF'
|
||||
<?php
|
||||
define('NOVACPX_ROOT', '/srv/novacpx/public');
|
||||
define('NOVACPX_API', NOVACPX_ROOT.'/api');
|
||||
define('NOVACPX_LIB', NOVACPX_ROOT.'/lib');
|
||||
$_SERVER['SERVER_PORT'] = 8882;
|
||||
require_once NOVACPX_LIB.'/Core.php';
|
||||
require_once NOVACPX_LIB.'/DB.php';
|
||||
require_once NOVACPX_LIB.'/VhostManager.php';
|
||||
require_once NOVACPX_LIB.'/DNSManager.php';
|
||||
require_once NOVACPX_LIB.'/PHPManager.php';
|
||||
require_once NOVACPX_LIB.'/AccountManager.php';
|
||||
$db = DB::getInstance();
|
||||
// Clean orphaned user
|
||||
$db->execute("DELETE FROM users WHERE username='webacct' AND role='user'");
|
||||
$uid = (int)$db->insert(
|
||||
"INSERT INTO users (username,password,email,role,status) VALUES (?,?,?,?,?)",
|
||||
['webacct',password_hash('Joker1974!!!',PASSWORD_BCRYPT),'webacct@web.orbishosting.com','user','active']
|
||||
);
|
||||
$r = AccountManager::create(['username'=>'webacct','domain'=>'web.orbishosting.com','password'=>'Joker1974!!!','user_id'=>$uid,'php_version'=>'8.3']);
|
||||
echo "Created: ".json_encode($r)."\n";
|
||||
PHPEOF
|
||||
|
||||
# Clean orphaned user record (Linux user missing but DB record exists)
|
||||
if id "webacct" &>/dev/null; then
|
||||
ok "webacct Linux user exists"
|
||||
else
|
||||
warn "webacct Linux user missing — cleaning DB and recreating..."
|
||||
sqlite3 "$DB" "DELETE FROM users WHERE username='webacct' AND id NOT IN (SELECT user_id FROM accounts WHERE username='webacct');" 2>/dev/null || true
|
||||
sqlite3 "$DB" "DELETE FROM users WHERE username='webacct' AND role='user';" 2>/dev/null || true
|
||||
php8.3 /tmp/_nova_create_webacct.php >> "$LOG" 2>&1 || true
|
||||
fi
|
||||
|
||||
# Ensure account exists in DB
|
||||
ACCT_EXISTS=$(sqlite3 "$DB" "SELECT COUNT(*) FROM accounts WHERE username='webacct';" 2>/dev/null || echo "0")
|
||||
if [[ "$ACCT_EXISTS" == "0" ]]; then
|
||||
warn "webacct account not in DB — creating..."
|
||||
php8.3 /tmp/_nova_create_webacct.php >> "$LOG" 2>&1 && ok "Account created" || warn "Account creation failed — check log"
|
||||
fi
|
||||
|
||||
# ── 5. Ensure nginx vhost and Basic Auth ──────────────────────────────────
|
||||
log "5. Ensuring nginx vhost..."
|
||||
VHOST="/etc/nginx/sites-available/novacpx-webacct.conf"
|
||||
if [[ ! -f "$VHOST" ]]; then
|
||||
cat > "$VHOST" << 'NGINX'
|
||||
server {
|
||||
listen 80;
|
||||
server_name web.orbishosting.com www.web.orbishosting.com;
|
||||
root /home/webacct/public_html;
|
||||
index index.php index.html index.htm;
|
||||
access_log /home/webacct/logs/access.log;
|
||||
error_log /home/webacct/logs/error.log;
|
||||
auth_basic "Blair HQ";
|
||||
auth_basic_user_file /etc/nginx/htpasswd.webacct;
|
||||
location / { try_files $uri $uri/ /index.php?$query_string; }
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/run/php/php8.3-fpm-webacct.sock;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
}
|
||||
location ~ /\.ht { deny all; }
|
||||
}
|
||||
NGINX
|
||||
ln -sf "$VHOST" /etc/nginx/sites-enabled/novacpx-webacct.conf
|
||||
ok "Vhost created"
|
||||
fi
|
||||
|
||||
[[ -f /etc/nginx/htpasswd.webacct ]] || {
|
||||
htpasswd -cb /etc/nginx/htpasswd.webacct "myronblair@outlook.com" "Joker1974!!!" 2>/dev/null
|
||||
ok "Basic auth created"
|
||||
}
|
||||
|
||||
# ── 6. Deploy Blair HQ dashboard ──────────────────────────────────────────
|
||||
log "6. Deploying Blair HQ dashboard..."
|
||||
if [[ -n "$PAT" ]]; then
|
||||
TMP=$(mktemp -d)
|
||||
git clone --depth=1 "$WEB_DASHBOARD_REPO" "$TMP" 2>/dev/null && {
|
||||
cp "$TMP/index.html" /home/webacct/public_html/index.html 2>/dev/null
|
||||
cp "$TMP/notes.php" /home/webacct/public_html/notes.php 2>/dev/null
|
||||
chown webacct:www-data /home/webacct/public_html/index.html /home/webacct/public_html/notes.php 2>/dev/null
|
||||
[[ -f /home/webacct/notes.json ]] || { echo "[]" > /home/webacct/notes.json; chown webacct:www-data /home/webacct/notes.json; }
|
||||
ok "Dashboard deployed"
|
||||
} || warn "Dashboard deploy failed (check PAT)"
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
|
||||
# ── 7. Reload nginx ───────────────────────────────────────────────────────
|
||||
log "7. Reloading nginx..."
|
||||
nginx -t 2>/dev/null && systemctl reload nginx && ok "nginx reloaded" || warn "nginx config error"
|
||||
systemctl reload php8.3-fpm 2>/dev/null || true
|
||||
|
||||
log "=== Post-Restore Complete ==="
|
||||
echo ""
|
||||
echo "✓ NovaCPX post-restore complete. Log: $LOG"
|
||||
+127
-17
@@ -30,7 +30,7 @@ info() { echo -e "${BLUE}[→]${NC} $*" | tee -a "$LOG"; }
|
||||
step() { echo -e "\n${BOLD}━━━ $* ━━━${NC}" | tee -a "$LOG"; }
|
||||
|
||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||
WEB_SERVER="apache"
|
||||
WEB_SERVER="nginx"
|
||||
INSTALL_MYSQL=true
|
||||
INSTALL_POSTGRES=true
|
||||
|
||||
@@ -133,7 +133,7 @@ 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 \
|
||||
software-properties-common apt-transport-https zip unzip git \
|
||||
sudo cron logrotate ufw fail2ban sshpass sqlite3 >> "$LOG" 2>&1
|
||||
log "System packages updated"
|
||||
|
||||
@@ -225,6 +225,9 @@ server {
|
||||
}
|
||||
NGXCONF
|
||||
ln -sf "$PANEL_WEB_CONF" /etc/nginx/sites-enabled/novacpx
|
||||
# Allow www-data to manage customer vhost configs
|
||||
chown root:www-data /etc/nginx/sites-available /etc/nginx/sites-enabled
|
||||
chmod 775 /etc/nginx/sites-available /etc/nginx/sites-enabled
|
||||
|
||||
else
|
||||
apt-get install -y -qq apache2 libapache2-mod-fcgid >> "$LOG" 2>&1
|
||||
@@ -636,29 +639,113 @@ log "Fail2Ban configured"
|
||||
# ── Sudoers for NovaCPX panel (www-data needs root for firewall/opendkim) ────
|
||||
cat > /etc/sudoers.d/novacpx-firewall <<SUDOERS
|
||||
Defaults:www-data !requiretty
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw *
|
||||
# Firewall / security
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw status
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw status verbose
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw allow *
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw deny *
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw delete *
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw reload
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw enable
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw disable
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw logging *
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client *
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart fail2ban
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload fail2ban
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start fail2ban
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop fail2ban
|
||||
# Web servers
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start apache2
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop apache2
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart apache2
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload apache2
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl enable apache2
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start nginx
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop nginx
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart nginx
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload nginx
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart apache2
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload apache2
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl enable nginx
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/nginx *
|
||||
# DB tool installation privileges
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/gpg *
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/curl *
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/debconf-set-selections *
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/apt/sources.list.d/*
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/tee /usr/share/keyrings/*
|
||||
# Mail servers
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start postfix
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop postfix
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart postfix
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload postfix
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start dovecot
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop dovecot
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart dovecot
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload dovecot
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start rspamd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop rspamd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart rspamd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl enable rspamd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl disable rspamd
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/postqueue -f
|
||||
# FTP servers
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start proftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop proftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart proftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload proftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl enable proftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start vsftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop vsftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart vsftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl enable vsftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start pure-ftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop pure-ftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart pure-ftpd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl enable pure-ftpd
|
||||
# DNS servers
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start named
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop named
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart named
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload named
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start bind9
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop bind9
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart bind9
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start pdns
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop pdns
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart pdns
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start nsd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop nsd
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart nsd
|
||||
# Database servers
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start mysql
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop mysql
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart mysql
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start mariadb
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop mariadb
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart mariadb
|
||||
# Security
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start fail2ban
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop fail2ban
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart fail2ban
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload fail2ban
|
||||
# PHP-FPM
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl reload php*-fpm
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl restart php*-fpm
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl start php*-fpm
|
||||
www-data ALL=(root) NOPASSWD: /bin/systemctl stop php*-fpm
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/php/*/fpm/pool.d/*
|
||||
www-data ALL=(root) NOPASSWD: /bin/rm -f /etc/php/*/fpm/pool.d/*.conf
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/rm -f /etc/php/*/fpm/pool.d/*.conf
|
||||
# Web config file management (scoped paths only)
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/nginx/conf.d/*
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/nginx/sites-available/*
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/nginx/sites-enabled/*
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/apache2/conf-enabled/*
|
||||
www-data ALL=(root) NOPASSWD: /usr/pgadmin4/bin/setup-web.sh *
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/env *
|
||||
www-data ALL=(root) NOPASSWD: /bin/ln -sf /etc/nginx/sites-available/* /etc/nginx/sites-enabled/*
|
||||
www-data ALL=(root) NOPASSWD: /bin/rm /etc/nginx/sites-available/novacpx-*
|
||||
www-data ALL=(root) NOPASSWD: /bin/rm /etc/nginx/sites-enabled/novacpx-*
|
||||
# Account management (user creation and home directories)
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/useradd
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/userdel
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/usermod
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/chpasswd
|
||||
www-data ALL=(root) NOPASSWD: /bin/mkdir
|
||||
www-data ALL=(root) NOPASSWD: /bin/chown
|
||||
www-data ALL=(root) NOPASSWD: /bin/chmod
|
||||
# SSL and DKIM
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/certbot
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/opendkim-genkey
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/rndc reload
|
||||
www-data ALL=(root) NOPASSWD: /usr/sbin/named-checkzone *
|
||||
SUDOERS
|
||||
chmod 440 /etc/sudoers.d/novacpx-firewall
|
||||
log "Sudoers rules installed"
|
||||
@@ -673,9 +760,32 @@ cat > /etc/cron.d/novacpx <<CRON
|
||||
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
|
||||
|
||||
# PHP-FPM pool cleanup + deferred reload (runs every minute as root)
|
||||
# Removes orphaned pool configs for deleted Linux users before reloading,
|
||||
# preventing php-fpm from failing to start due to missing user references.
|
||||
(crontab -l 2>/dev/null | grep -v "novacpx-fpm-reload"; echo '* * * * * for f in /etc/php/*/fpm/pool.d/*.conf; do [[ "$f" == *"www.conf"* ]] && continue; u=$(basename "$f" .conf); id "$u" &>/dev/null || rm -f "$f"; done; for flag in /tmp/novacpx-fpm-reload-*; do [ -f "$flag" ] && ver=$(basename "$flag" | sed s/novacpx-fpm-reload-//) && rm -f "$flag" && systemctl reload php${ver}-fpm 2>/dev/null; done') | crontab -
|
||||
mkdir -p /var/log/novacpx
|
||||
log "Cron jobs installed"
|
||||
|
||||
# ── Disable conflicting web servers ───────────────────────────────────────────
|
||||
step "Disabling Conflicting Web Servers"
|
||||
if [[ "$WEB_SERVER" == "nginx" ]]; then
|
||||
systemctl stop apache2 2>/dev/null || true
|
||||
systemctl disable apache2 2>/dev/null || true
|
||||
# Replace nginx default site with a 444 connection-close so unmatched
|
||||
# vhosts don't accidentally serve Apache's default HTML page
|
||||
cat > /etc/nginx/sites-available/default <<'NGINXDEFAULT'
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
return 444;
|
||||
}
|
||||
NGINXDEFAULT
|
||||
log "Apache2 disabled; nginx default site set to return 444"
|
||||
fi
|
||||
|
||||
# ── Restart services ──────────────────────────────────────────────────────────
|
||||
step "Starting All Services"
|
||||
if [[ "$WEB_SERVER" == "nginx" ]]; then
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
/**
|
||||
* Server-side branding loader — injected into portal <head> before JS loads.
|
||||
* Reads session cookie → looks up user's reseller → returns branding row.
|
||||
*/
|
||||
function novacpx_get_branding(): array {
|
||||
static $cache = null;
|
||||
if ($cache !== null) return $cache;
|
||||
$cfg = @parse_ini_file('/etc/novacpx/config.ini', true);
|
||||
if (!$cfg) return $cache = [];
|
||||
try {
|
||||
$dbPath = $cfg['database']['path'] ?? '/var/lib/novacpx/panel.db';
|
||||
$pdo = new PDO(
|
||||
"sqlite:{$dbPath}", null, null,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||
);
|
||||
$token = $_COOKIE['ncpx_session'] ?? '';
|
||||
if (!$token || strlen($token) < 32) return $cache = [];
|
||||
|
||||
$stmt = $pdo->prepare("SELECT user_id FROM sessions WHERE id = ? AND expires_at > datetime('now') LIMIT 1");
|
||||
$stmt->execute([substr($token, 0, 128)]);
|
||||
$uid = (int)($stmt->fetchColumn() ?: 0);
|
||||
if (!$uid) return $cache = [];
|
||||
|
||||
$stmt = $pdo->prepare("SELECT role, reseller_id FROM users WHERE id = ? LIMIT 1");
|
||||
$stmt->execute([$uid]);
|
||||
$u = $stmt->fetch();
|
||||
if (!$u) return $cache = [];
|
||||
|
||||
$resellerId = ($u['role'] === 'reseller') ? $uid : (int)($u['reseller_id'] ?? 0);
|
||||
if (!$resellerId) return $cache = [];
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM reseller_branding WHERE user_id = ? LIMIT 1");
|
||||
$stmt->execute([$resellerId]);
|
||||
$row = $stmt->fetch();
|
||||
return $cache = ($row ?: []);
|
||||
} catch (Throwable $e) {
|
||||
return $cache = [];
|
||||
}
|
||||
}
|
||||
|
||||
function novacpx_branding_head(): void {
|
||||
$b = novacpx_get_branding();
|
||||
if (!$b) return;
|
||||
$pc = preg_match('/^#[0-9a-fA-F]{3,6}$/', $b['primary_color'] ?? '') ? $b['primary_color'] : null;
|
||||
$ac = preg_match('/^#[0-9a-fA-F]{3,6}$/', $b['accent_color'] ?? '') ? $b['accent_color'] : null;
|
||||
$css = $b['custom_css'] ?? '';
|
||||
echo '<style id="reseller-branding">' . "\n";
|
||||
echo ':root {' . "\n";
|
||||
if ($pc) echo " --primary: $pc;\n --primary-dark: $pc;\n";
|
||||
if ($ac) echo " --accent: $ac;\n";
|
||||
echo '}' . "\n";
|
||||
// Sanitize custom CSS — allow only safe property declarations, strip everything else.
|
||||
// Regex approach (strip </style>) is bypassable; whitelist parsing is the safe alternative.
|
||||
$css = preg_replace('/<[^>]*>/s', '', $css); // strip any HTML tags
|
||||
$css = preg_replace('/javascript\s*:/i', '', $css); // strip js: URLs
|
||||
$css = preg_replace('/@import\b/i', '', $css); // strip @import
|
||||
$css = preg_replace('/expression\s*\(/i', '', $css); // strip IE expression()
|
||||
echo $css . "\n";
|
||||
echo '</style>' . "\n";
|
||||
if ($b['favicon_url'] ?? '') {
|
||||
$fav = htmlspecialchars($b['favicon_url']);
|
||||
echo "<link rel=\"icon\" href=\"$fav\">\n";
|
||||
}
|
||||
}
|
||||
|
||||
function novacpx_panel_name(string $default): string {
|
||||
$b = novacpx_get_branding();
|
||||
return htmlspecialchars($b['panel_name'] ?? $default);
|
||||
}
|
||||
|
||||
function novacpx_logo_html(string $default_svg): string {
|
||||
$b = novacpx_get_branding();
|
||||
if (!empty($b['logo_url'])) {
|
||||
$url = htmlspecialchars($b['logo_url']);
|
||||
$name = htmlspecialchars($b['panel_name'] ?? 'Panel');
|
||||
return "<img src=\"$url\" alt=\"$name\" style=\"max-height:36px;max-width:160px;object-fit:contain\">";
|
||||
}
|
||||
return $default_svg;
|
||||
}
|
||||
|
||||
function novacpx_powered_by(): bool {
|
||||
$b = novacpx_get_branding();
|
||||
return empty($b['hide_powered_by']);
|
||||
}
|
||||
+46
-20
@@ -1,33 +1,34 @@
|
||||
<?php
|
||||
// NovaCPX Admin Panel — Datacenter/Server Manager
|
||||
// Equivalent to WHM (WebHost Manager)
|
||||
if (!defined('NOVACPX_ROOT')) define('NOVACPX_ROOT', dirname(__DIR__));
|
||||
if (!defined('NOVACPX_VERSION')) define('NOVACPX_VERSION', trim(@file_get_contents(NOVACPX_ROOT . '/VERSION') ?: '1.0.0'));
|
||||
$_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
|
||||
require_once dirname(__DIR__) . '/_branding.php';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="NovaCPX Admin Panel — full-featured hosting control panel for managing servers, accounts, domains, email, databases, and services.">
|
||||
<meta name="keywords" content="hosting control panel, server management, web hosting admin, NovaCPX, domain management, email hosting, database management">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>NovaCPX Admin</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/css/nova.css">
|
||||
<link rel="stylesheet" href="/assets/css/nova.css<?= $_v('/assets/css/nova.css') ?>">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="panel-layout" id="app" style="display:none">
|
||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||
|
||||
<!-- 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>
|
||||
<?= novacpx_logo_html('<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 id="panel-version-badge" style="display:block;font-size:.6rem;color:var(--text-muted);font-weight:400;line-height:1;margin-top:2px">v<?= trim(@file_get_contents(NOVACPX_ROOT . '/VERSION') ?: NOVACPX_VERSION) ?></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
@@ -37,10 +38,6 @@
|
||||
<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">
|
||||
@@ -97,10 +94,18 @@
|
||||
<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>
|
||||
<a href="#" class="sidebar-link" data-page="nginx-proxy">
|
||||
<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>
|
||||
Nginx Proxy
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="wordpress">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 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>
|
||||
WordPress
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="docker">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="8" width="4" height="4" rx="1"/><rect x="8" y="8" width="4" height="4" rx="1"/><rect x="14" y="8" width="4" height="4" rx="1"/><rect x="8" y="2" width="4" height="4" rx="1"/><path d="M2 14c0 4 3 6 10 6s10-2 10-6"/><path d="M20 14c1.5 0 2.5-1 2.5-2.5S21.5 9 20 9h-1"/></svg>
|
||||
Docker
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
@@ -111,7 +116,11 @@
|
||||
</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
|
||||
Firewall (UFW)
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="fail2ban">
|
||||
<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"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
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>
|
||||
@@ -121,6 +130,10 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/><circle cx="12" cy="16" r="1" fill="currentColor"/></svg>
|
||||
2FA Manager
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="sessions">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
|
||||
Sessions
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
@@ -133,10 +146,22 @@
|
||||
<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="file-manager">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
|
||||
File Manager
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="cloudflare">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9z"/></svg>
|
||||
Cloudflare
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="server-options">
|
||||
<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 Options
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="notifications">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
Notifications
|
||||
</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
|
||||
@@ -161,7 +186,8 @@
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
<header class="topbar">
|
||||
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" style="display:none">☰</button>
|
||||
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
|
||||
|
||||
<div class="topbar-title" id="page-title">Dashboard</div>
|
||||
<div class="topbar-actions">
|
||||
<span id="server-ip" class="text-muted text-sm"></span>
|
||||
@@ -206,7 +232,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/nova.js"></script>
|
||||
<script src="/assets/js/admin.js"></script>
|
||||
<script src="/assets/js/nova.js<?= $_v('/assets/js/nova.js') ?>"></script>
|
||||
<script src="/assets/js/admin.js<?= $_v('/assets/js/admin.js') ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -69,7 +69,14 @@ match ($action) {
|
||||
$required = ['username','domain','email','password'];
|
||||
foreach ($required as $f) { if (empty($body[$f])) Response::error("$f is required"); }
|
||||
|
||||
// Create user account
|
||||
if (!filter_var($body['email'], FILTER_VALIDATE_EMAIL)) Response::error("Invalid email address");
|
||||
if ($db->fetchOne("SELECT id FROM users WHERE email = ? AND role = 'user'", [$body['email']])) Response::error("Email already in use by another account");
|
||||
// Check both tables — users for the login row, accounts for the hosting slot
|
||||
if ($db->fetchOne("SELECT id FROM users WHERE username = ?", [$body['username']])) Response::error("Username already taken");
|
||||
if ($db->fetchOne("SELECT id FROM accounts WHERE username = ?", [$body['username']])) Response::error("Username already taken");
|
||||
|
||||
// Wrap user insert + provisioning in a transaction so cleanup is atomic
|
||||
$db->beginTransaction();
|
||||
$userId = (int)$db->insert(
|
||||
"INSERT INTO users (username, password, email, role, status, reseller_id) VALUES (?,?,?,?,?,?)",
|
||||
[
|
||||
@@ -83,9 +90,15 @@ match ($action) {
|
||||
);
|
||||
$body['user_id'] = $userId;
|
||||
|
||||
$result = AccountManager::create($body);
|
||||
try {
|
||||
$result = AccountManager::create($body);
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
$db->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
audit('account.create', $body['domain'], $result);
|
||||
// Send welcome email to user + admin notification
|
||||
Notifier::accountCreated(array_merge($body, ['email' => $body['email']]), $body['password']);
|
||||
Response::success($result, 'Account created successfully');
|
||||
})(),
|
||||
|
||||
@@ -29,6 +29,36 @@ match ($action) {
|
||||
Response::success(null, $msg);
|
||||
})(),
|
||||
|
||||
'uninstall-account' => (function() use ($dm, $currentUser, $isAdmin, $_userAccountId, $body) {
|
||||
// Stop and remove all containers and stacks belonging to one account.
|
||||
// Users can only remove their own; admins can specify any account_id.
|
||||
$accountId = $isAdmin ? (int)($body['account_id'] ?? $_userAccountId) : ($_userAccountId ?? 0);
|
||||
if (!$accountId) Response::error('account_id required');
|
||||
|
||||
$db = DB::getInstance();
|
||||
|
||||
// Tear down compose stacks
|
||||
$stacks = $db->fetchAll("SELECT * FROM docker_compose_stacks WHERE account_id = ?", [$accountId]);
|
||||
foreach ($stacks as $stack) {
|
||||
if (is_dir($stack['stack_dir']) && file_exists("{$stack['stack_dir']}/docker-compose.yml")) {
|
||||
shell_exec("sudo docker compose -f " . escapeshellarg("{$stack['stack_dir']}/docker-compose.yml") . " down -v 2>/dev/null");
|
||||
}
|
||||
$db->execute("DELETE FROM docker_compose_stacks WHERE id = ?", [$stack['id']]);
|
||||
}
|
||||
|
||||
// Stop and remove bare containers
|
||||
$containers = $db->fetchAll("SELECT container_id FROM docker_containers WHERE account_id = ?", [$accountId]);
|
||||
foreach ($containers as $c) {
|
||||
if ($c['container_id']) {
|
||||
shell_exec("sudo docker rm -f " . escapeshellarg($c['container_id']) . " 2>/dev/null");
|
||||
}
|
||||
}
|
||||
$db->execute("DELETE FROM docker_containers WHERE account_id = ?", [$accountId]);
|
||||
|
||||
audit("docker.uninstall-account", "account:{$accountId}");
|
||||
Response::success(null, 'All Docker apps and containers removed for this account');
|
||||
})(),
|
||||
|
||||
'prune' => (function() use ($dm, $body, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
$out = $dm->systemPrune((bool)($body['volumes'] ?? false));
|
||||
@@ -163,20 +193,42 @@ match ($action) {
|
||||
Response::success($result, 'Stack created');
|
||||
})(),
|
||||
|
||||
'stack-action' => (function() use ($dm, $body, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
'stack-action' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
|
||||
$id = (int)($body['stack_id'] ?? 0);
|
||||
$act = $body['action'] ?? '';
|
||||
if (!$id || !$act) Response::error('stack_id and action required');
|
||||
// Non-admins can only act on their own stacks
|
||||
if (!$isAdmin) {
|
||||
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
|
||||
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
|
||||
}
|
||||
$out = $dm->composeAction($id, $act);
|
||||
audit("docker.stack.{$act}", "stack:{$id}");
|
||||
Response::success(['output' => $out]);
|
||||
})(),
|
||||
|
||||
'stack-remove' => (function() use ($dm, $body, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
'stack-reinstall' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
|
||||
$id = (int)($body['stack_id'] ?? 0);
|
||||
if (!$id) Response::error('stack_id required');
|
||||
if (!$isAdmin) {
|
||||
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
|
||||
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
|
||||
}
|
||||
// Pull latest images, bring down (removing volumes), then start fresh
|
||||
$dm->composeAction($id, 'pull');
|
||||
$dm->composeAction($id, 'down');
|
||||
$out = $dm->composeAction($id, 'up');
|
||||
audit('docker.stack.reinstall', "stack:{$id}");
|
||||
Response::success(['output' => $out], 'Stack reinstalled');
|
||||
})(),
|
||||
|
||||
'stack-remove' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
|
||||
$id = (int)($body['stack_id'] ?? 0);
|
||||
if (!$id) Response::error('stack_id required');
|
||||
if (!$isAdmin) {
|
||||
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
|
||||
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
|
||||
}
|
||||
$dm->removeStack($id);
|
||||
audit('docker.stack.remove', "stack:{$id}");
|
||||
Response::success(null, 'Stack removed');
|
||||
@@ -213,5 +265,28 @@ match ($action) {
|
||||
Response::success($result, ucfirst($appKey) . ' launched successfully');
|
||||
})(),
|
||||
|
||||
'sync-orphans' => (function() use ($dm, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
$result = shell_exec('docker ps -a --format "{{json .}}" 2>/dev/null') ?? '';
|
||||
$db = \DB::getInstance();
|
||||
$added = 0;
|
||||
foreach (explode("\n", trim($result)) as $line) {
|
||||
if (!$line) continue;
|
||||
$c = json_decode($line, true);
|
||||
if (!$c) continue;
|
||||
$cid = $c['ID'] ?? '';
|
||||
$name = ltrim($c['Names'] ?? '', '/');
|
||||
$img = $c['Image'] ?? '';
|
||||
$st = $c['State'] ?? 'unknown';
|
||||
if (!$cid) continue;
|
||||
$ex = $db->fetchOne('SELECT id FROM docker_containers WHERE container_id=?', [$cid]);
|
||||
if (!$ex) {
|
||||
$db->execute('INSERT INTO docker_containers (container_id,name,image,status,account_id,created_at) VALUES (?,?,?,?,0,datetime("now"))', [$cid,$name,$img,$st]);
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
Response::success(['added' => $added], "Synced $added orphaned containers");
|
||||
})(),
|
||||
|
||||
default => Response::error("Unknown docker action: $action", 404),
|
||||
};
|
||||
|
||||
@@ -98,5 +98,21 @@ match ($action) {
|
||||
Response::success(null, 'Autoresponder deleted');
|
||||
})(),
|
||||
|
||||
|
||||
'domains' => (function() use ($db) {
|
||||
Auth::getInstance()->require('admin', 'reseller');
|
||||
$user = Auth::getInstance()->user();
|
||||
$clause = $user['role'] === 'reseller' ? "AND a.user_id IN (SELECT id FROM users WHERE reseller_id=".(int)$user['uid'].")" : "";
|
||||
$rows = $db->fetchAll(
|
||||
"SELECT d.domain, a.username, a.id as account_id,
|
||||
(SELECT COUNT(*) FROM email_accounts WHERE account_id = a.id) as email_count
|
||||
FROM dns_zones d
|
||||
JOIN accounts a ON a.id = d.account_id
|
||||
WHERE 1=1 $clause
|
||||
ORDER BY d.domain"
|
||||
);
|
||||
Response::success($rows);
|
||||
})(),
|
||||
|
||||
default => Response::error("Unknown email action: $action", 404),
|
||||
};
|
||||
|
||||
+128
-29
@@ -1,7 +1,14 @@
|
||||
<?php
|
||||
/**
|
||||
* Files endpoint — file manager (list, read, write, rename, delete, chmod, upload, archive)
|
||||
* Strictly confined to account home directory via realpath check
|
||||
* Files endpoint — file manager (list, read, write, rename, delete, chmod, chown, upload, download, archive)
|
||||
* Strictly confined to account home directory via realpath check.
|
||||
* Admins with no linked hosting account get baseDir = '/' (full filesystem, bounded by the
|
||||
* OS permissions of the PHP-FPM pool user — see safe_path()).
|
||||
*
|
||||
* Destructive actions (write/delete/rename/chmod/chown/extract/mkdir/upload) targeting a
|
||||
* path under a system/security-critical zone (see is_dangerous_path()) are rejected unless
|
||||
* the request includes confirm_dangerous=true. This is enforced here, not just in the UI,
|
||||
* so it can't be bypassed by calling the API directly.
|
||||
*/
|
||||
$db = DB::getInstance();
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
@@ -11,11 +18,15 @@ $acct = $db->fetchOne("SELECT * FROM accounts WHERE user_id = ?", [$user['uid']]
|
||||
if (!$acct && $user['role'] !== 'admin') Response::error("No hosting account found", 404);
|
||||
|
||||
$baseDir = $acct ? realpath($acct['home_dir']) : '/';
|
||||
// Normalize so the prefix check below doesn't break when baseDir is '/' (rtrim would otherwise
|
||||
// leave a doubled leading slash — '/' . '/' — that no real path can ever start with).
|
||||
$baseDirPrefix = rtrim($baseDir, '/');
|
||||
|
||||
// For existing paths only — throws if path doesn't exist or escapes baseDir
|
||||
function safe_path(string $base, string $rel): string {
|
||||
global $baseDirPrefix;
|
||||
$full = realpath($base . '/' . ltrim($rel, '/'));
|
||||
if ($full === false || !str_starts_with($full . '/', $base . '/')) {
|
||||
if ($full === false || !str_starts_with($full . '/', $baseDirPrefix . '/')) {
|
||||
throw new RuntimeException("Path outside account directory");
|
||||
}
|
||||
return $full;
|
||||
@@ -23,14 +34,39 @@ function safe_path(string $base, string $rel): string {
|
||||
|
||||
// For paths that may not exist yet (write/mkdir) — validates parent dir
|
||||
function safe_path_new(string $base, string $rel): string {
|
||||
global $baseDirPrefix;
|
||||
$candidate = $base . '/' . ltrim(str_replace(['../', '..\\'], '', $rel), '/');
|
||||
$parent = realpath(dirname($candidate));
|
||||
if ($parent === false || !str_starts_with($parent . '/', $base . '/')) {
|
||||
if ($parent === false || !str_starts_with($parent . '/', $baseDirPrefix . '/')) {
|
||||
throw new RuntimeException("Path outside account directory");
|
||||
}
|
||||
return $parent . '/' . basename($candidate);
|
||||
}
|
||||
|
||||
// Only meaningful for true root access (baseDir === '/'); hosting accounts are always
|
||||
// confined under a home directory and never reach these zones.
|
||||
function is_dangerous_path(string $baseDir, string $fullPath): bool {
|
||||
if ($baseDir !== '/') return false;
|
||||
static $zones = ['/etc','/boot','/root','/sys','/proc','/dev','/run',
|
||||
'/lib','/lib64','/usr/lib','/usr/lib64',
|
||||
'/bin','/sbin','/usr/bin','/usr/sbin','/var/lib','/snap'];
|
||||
foreach ($zones as $z) {
|
||||
if ($fullPath === $z || str_starts_with($fullPath, $z . '/')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function require_dangerous_confirm(string $baseDir, string $path, array $body): void {
|
||||
if (is_dangerous_path($baseDir, $path) && !($body['confirm_dangerous'] ?? $_POST['confirm_dangerous'] ?? false)) {
|
||||
Response::error(
|
||||
"This path is in a system/security-critical location. Editing, deleting, or changing " .
|
||||
"permissions here can break services or compromise server security. Confirmation required.",
|
||||
409,
|
||||
['dangerous_path' => true]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function fmt_size(int $bytes): string {
|
||||
if ($bytes >= 1073741824) return round($bytes/1073741824, 1) . 'GB';
|
||||
if ($bytes >= 1048576) return round($bytes/1048576, 1) . 'MB';
|
||||
@@ -39,35 +75,59 @@ function fmt_size(int $bytes): string {
|
||||
}
|
||||
|
||||
match ($action) {
|
||||
'list' => (function() use ($baseDir, $body) {
|
||||
$rel = $body['path'] ?? $_GET['path'] ?? '/public_html';
|
||||
'list' => (function() use ($baseDir, $body, $user) {
|
||||
$rel = $body['path'] ?? $_GET['path'] ?? ($user['role'] === 'admin' ? '/' : '/public_html');
|
||||
$path = safe_path($baseDir, $rel);
|
||||
if (!is_dir($path)) Response::error("Not a directory");
|
||||
if (!is_readable($path)) Response::error("Permission denied reading this directory");
|
||||
|
||||
$hasPosix = function_exists('posix_getpwuid');
|
||||
$items = [];
|
||||
foreach (new DirectoryIterator($path) as $f) {
|
||||
if ($f->isDot()) continue;
|
||||
$uid = fileowner($f->getPathname());
|
||||
$gid = filegroup($f->getPathname());
|
||||
$itemPath = (($p = substr($path . '/' . $f->getFilename(), strlen(rtrim($baseDir, '/')))) === '' ? '/' : $p);
|
||||
$items[] = [
|
||||
'name' => $f->getFilename(),
|
||||
'type' => $f->isDir() ? 'dir' : 'file',
|
||||
'size' => $f->isFile() ? fmt_size($f->getSize()) : null,
|
||||
'size_raw' => $f->isFile() ? $f->getSize() : 0,
|
||||
'perms' => substr(sprintf('%o', $f->getPerms()), -4),
|
||||
'modified' => date('Y-m-d H:i', $f->getMTime()),
|
||||
'path' => str_replace($baseDir, '', $path . '/' . $f->getFilename()),
|
||||
'name' => $f->getFilename(),
|
||||
'type' => $f->isDir() ? 'dir' : 'file',
|
||||
'link' => $f->isLink(),
|
||||
'size' => $f->isFile() ? fmt_size($f->getSize()) : null,
|
||||
'size_raw' => $f->isFile() ? $f->getSize() : 0,
|
||||
'perms' => substr(sprintf('%o', $f->getPerms()), -4),
|
||||
'owner' => $hasPosix ? (@posix_getpwuid($uid)['name'] ?? (string)$uid) : (string)$uid,
|
||||
'group' => $hasPosix ? (@posix_getgrgid($gid)['name'] ?? (string)$gid) : (string)$gid,
|
||||
'modified' => date('Y-m-d H:i', $f->getMTime()),
|
||||
'path' => $itemPath,
|
||||
'writable' => $f->isWritable(),
|
||||
'dangerous' => is_dangerous_path($baseDir, $path . '/' . $f->getFilename()),
|
||||
];
|
||||
}
|
||||
usort($items, fn($a,$b) => ($a['type'] === 'dir' ? -1 : 1) - ($b['type'] === 'dir' ? -1 : 1) ?: strcmp($a['name'], $b['name']));
|
||||
Response::success(['path' => $rel, 'items' => $items]);
|
||||
Response::success(['path' => $rel, 'items' => $items, 'base_writable' => is_writable($path)]);
|
||||
})(),
|
||||
|
||||
'read' => (function() use ($baseDir, $body) {
|
||||
$path = safe_path($baseDir, $body['path'] ?? $_GET['path'] ?? '');
|
||||
if (!is_file($path)) Response::error("Not a file");
|
||||
if (filesize($path) > 2 * 1024 * 1024) Response::error("File too large to edit (>2MB)");
|
||||
if (!is_readable($path)) Response::error("Permission denied reading this file");
|
||||
if (filesize($path) > 2 * 1024 * 1024) Response::error("File too large to edit (>2MB) — use Download instead");
|
||||
Response::success(['content' => file_get_contents($path), 'path' => $body['path'] ?? '']);
|
||||
})(),
|
||||
|
||||
'download' => (function() use ($baseDir, $body) {
|
||||
$path = safe_path($baseDir, $body['path'] ?? $_GET['path'] ?? '');
|
||||
if (!is_file($path)) Response::error("Not a file");
|
||||
if (!is_readable($path)) Response::error("Permission denied reading this file");
|
||||
audit('files.download', $body['path'] ?? $_GET['path'] ?? '');
|
||||
header('Content-Type: application/octet-stream');
|
||||
header('Content-Disposition: attachment; filename="' . basename($path) . '"');
|
||||
header('Content-Length: ' . filesize($path));
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
readfile($path);
|
||||
exit;
|
||||
})(),
|
||||
|
||||
'write' => (function() use ($baseDir, $body) {
|
||||
$rel = $body['path'] ?? '';
|
||||
$content = $body['content'] ?? '';
|
||||
@@ -77,6 +137,8 @@ match ($action) {
|
||||
} catch (RuntimeException) {
|
||||
$path = safe_path_new($baseDir, $rel);
|
||||
}
|
||||
require_dangerous_confirm($baseDir, $path, $body);
|
||||
if (file_exists($path) && !is_writable($path)) Response::error("Permission denied writing this file");
|
||||
// PHP syntax check for .php files
|
||||
if (str_ends_with($path, '.php')) {
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'ncpx_');
|
||||
@@ -85,34 +147,41 @@ match ($action) {
|
||||
unlink($tmp);
|
||||
if (!str_contains($result ?? '', 'No syntax errors')) Response::error("PHP syntax error: $result");
|
||||
}
|
||||
file_put_contents($path, $content);
|
||||
if (file_put_contents($path, $content) === false) Response::error("Write failed (permission denied)");
|
||||
audit('files.write', $rel);
|
||||
Response::success(null, 'File saved');
|
||||
})(),
|
||||
|
||||
'mkdir' => (function() use ($baseDir, $body) {
|
||||
$path = safe_path_new($baseDir, $body['path'] ?? '');
|
||||
require_dangerous_confirm($baseDir, $path, $body);
|
||||
if (is_dir($path)) Response::error("Directory already exists");
|
||||
if (!mkdir($path, 0755, true)) Response::error("Could not create directory");
|
||||
if (!mkdir($path, 0755, true)) Response::error("Could not create directory (permission denied)");
|
||||
audit('files.mkdir', $body['path'] ?? '');
|
||||
Response::success(null, 'Directory created');
|
||||
})(),
|
||||
|
||||
'rename' => (function() use ($baseDir, $body) {
|
||||
$from = safe_path($baseDir, $body['from'] ?? '');
|
||||
$to = safe_path_new($baseDir, $body['to'] ?? '');
|
||||
require_dangerous_confirm($baseDir, $from, $body);
|
||||
require_dangerous_confirm($baseDir, $to, $body);
|
||||
if (file_exists($to)) Response::error("Destination already exists");
|
||||
rename($from, $to);
|
||||
if (!rename($from, $to)) Response::error("Rename failed (permission denied)");
|
||||
audit('files.rename', ($body['from'] ?? '') . ' -> ' . ($body['to'] ?? ''));
|
||||
Response::success(null, 'Renamed');
|
||||
})(),
|
||||
|
||||
'delete' => (function() use ($baseDir, $body) {
|
||||
$path = safe_path($baseDir, $body['path'] ?? '');
|
||||
// Prevent deleting the account root or its direct children (public_html, logs, tmp)
|
||||
if ($path === $baseDir || dirname($path) === $baseDir) Response::error("Cannot delete root account directories");
|
||||
// Prevent deleting the root directory itself or its direct children
|
||||
// (for accounts: public_html/logs/tmp; for admin baseDir='/': /etc, /home, /var, /root, etc.)
|
||||
if ($path === $baseDir || dirname($path) === $baseDir) Response::error("Cannot delete a top-level directory");
|
||||
require_dangerous_confirm($baseDir, $path, $body);
|
||||
if (is_dir($path)) {
|
||||
shell_exec("rm -rf " . escapeshellarg($path));
|
||||
shell_exec("rm -rf " . escapeshellarg($path) . " 2>&1");
|
||||
} else {
|
||||
unlink($path);
|
||||
if (!unlink($path)) Response::error("Delete failed (permission denied)");
|
||||
}
|
||||
audit('files.delete', $body['path'] ?? '');
|
||||
Response::success(null, 'Deleted');
|
||||
@@ -120,42 +189,72 @@ match ($action) {
|
||||
|
||||
'chmod' => (function() use ($baseDir, $body) {
|
||||
$path = safe_path($baseDir, $body['path'] ?? '');
|
||||
require_dangerous_confirm($baseDir, $path, $body);
|
||||
$perms = octdec((string)($body['perms'] ?? '755'));
|
||||
// Clamp to sane range: no setuid/setgid/sticky, no world-write on dirs
|
||||
$perms = $perms & 0777;
|
||||
chmod($path, $perms);
|
||||
if (!chmod($path, $perms)) Response::error("chmod failed (permission denied)");
|
||||
audit('files.chmod', $body['path'] ?? '', ['perms' => decoct($perms)]);
|
||||
Response::success(null, 'Permissions updated');
|
||||
})(),
|
||||
|
||||
'chown' => (function() use ($baseDir, $body, $user) {
|
||||
if ($user['role'] !== 'admin') Response::error("Forbidden", 403);
|
||||
if (!function_exists('posix_getpwnam')) Response::error("posix extension not available on this server");
|
||||
$path = safe_path($baseDir, $body['path'] ?? '');
|
||||
require_dangerous_confirm($baseDir, $path, $body);
|
||||
$owner = trim((string)($body['owner'] ?? ''));
|
||||
$group = trim((string)($body['group'] ?? ''));
|
||||
if ($owner !== '') {
|
||||
$pw = @posix_getpwnam($owner);
|
||||
if (!$pw) Response::error("Unknown user: $owner");
|
||||
if (!@chown($path, $pw['uid'])) Response::error("chown failed — PHP-FPM ({$user['username']}) does not have OS permission to change ownership to '$owner'");
|
||||
}
|
||||
if ($group !== '') {
|
||||
$gr = @posix_getgrnam($group);
|
||||
if (!$gr) Response::error("Unknown group: $group");
|
||||
if (!@chgrp($path, $gr['gid'])) Response::error("chgrp failed — PHP-FPM does not have OS permission to change group to '$group'");
|
||||
}
|
||||
audit('files.chown', $body['path'] ?? '', ['owner' => $owner, 'group' => $group]);
|
||||
Response::success(null, 'Ownership updated');
|
||||
})(),
|
||||
|
||||
'upload' => (function() use ($baseDir, $body) {
|
||||
$dir = safe_path($baseDir, $body['path'] ?? '/public_html');
|
||||
$dir = safe_path($baseDir, $body['path'] ?? $_POST['path'] ?? $_GET['path'] ?? '/public_html');
|
||||
require_dangerous_confirm($baseDir, $dir, $body);
|
||||
if (!is_dir($dir)) Response::error("Target directory not found");
|
||||
if (!is_writable($dir)) Response::error("Permission denied writing to this directory");
|
||||
if (empty($_FILES['file'])) Response::error("No file uploaded");
|
||||
$filename = basename($_FILES['file']['name']);
|
||||
if ($filename === '' || $filename === '.') Response::error("Invalid filename");
|
||||
$dest = $dir . '/' . $filename;
|
||||
if (!str_starts_with($dest, $baseDir . '/')) Response::error("Invalid destination");
|
||||
move_uploaded_file($_FILES['file']['tmp_name'], $dest);
|
||||
if (!str_starts_with($dest, rtrim($baseDir, '/') . '/')) Response::error("Invalid destination");
|
||||
if (!move_uploaded_file($_FILES['file']['tmp_name'], $dest)) Response::error("Upload failed (permission denied)");
|
||||
audit('files.upload', ($body['path'] ?? '') . '/' . $filename);
|
||||
Response::success(['name' => $filename], 'File uploaded');
|
||||
})(),
|
||||
|
||||
'compress' => (function() use ($baseDir, $body) {
|
||||
$paths = array_map(fn($p) => safe_path($baseDir, $p), (array)($body['paths'] ?? []));
|
||||
$dest = safe_path_new($baseDir, $body['dest'] ?? 'archive.zip');
|
||||
require_dangerous_confirm($baseDir, $dest, $body);
|
||||
$files = implode(' ', array_map('escapeshellarg', $paths));
|
||||
shell_exec("cd " . escapeshellarg($baseDir) . " && zip -r " . escapeshellarg($dest) . " $files 2>/dev/null");
|
||||
shell_exec("cd " . escapeshellarg($baseDir) . " && zip -r " . escapeshellarg($dest) . " $files 2>&1");
|
||||
audit('files.compress', $body['dest'] ?? '', ['paths' => $body['paths'] ?? []]);
|
||||
Response::success(null, 'Archive created');
|
||||
})(),
|
||||
|
||||
'extract' => (function() use ($baseDir, $body) {
|
||||
$file = safe_path($baseDir, $body['path'] ?? '');
|
||||
$dest = safe_path($baseDir, $body['dest'] ?? dirname($body['path'] ?? '/'));
|
||||
require_dangerous_confirm($baseDir, $dest, $body);
|
||||
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
||||
match($ext) {
|
||||
'zip' => shell_exec("unzip -o " . escapeshellarg($file) . " -d " . escapeshellarg($dest)),
|
||||
'gz','tgz','tar' => shell_exec("tar xf " . escapeshellarg($file) . " -C " . escapeshellarg($dest)),
|
||||
'zip' => shell_exec("unzip -o " . escapeshellarg($file) . " -d " . escapeshellarg($dest) . " 2>&1"),
|
||||
'gz','tgz','tar' => shell_exec("tar xf " . escapeshellarg($file) . " -C " . escapeshellarg($dest) . " 2>&1"),
|
||||
default => Response::error("Unsupported archive type"),
|
||||
};
|
||||
audit('files.extract', $body['path'] ?? '');
|
||||
Response::success(null, 'Extracted');
|
||||
})(),
|
||||
|
||||
|
||||
@@ -50,9 +50,10 @@ match ($action) {
|
||||
"UPDATE packages SET name=?, disk_mb=?, bandwidth_mb=?, max_domains=?, max_subdomains=?,
|
||||
max_addon_domains=?, max_parked_domains=?, max_email=?, max_ftp=?, max_databases=?, php_version=?, ssl_enabled=? WHERE id=?",
|
||||
[
|
||||
$body['name'], $body['disk_mb'], $body['bandwidth_mb'], $body['max_domains'],
|
||||
$body['max_subdomains'], $body['max_addon_domains'], $body['max_parked_domains'],
|
||||
$body['max_email'], $body['max_ftp'], $body['max_databases'],
|
||||
$body['name'] ?? '', $body['disk_mb'] ?? 0, $body['bandwidth_mb'] ?? 0,
|
||||
$body['max_domains'] ?? 0, $body['max_subdomains'] ?? 0,
|
||||
$body['max_addon_domains'] ?? 0, $body['max_parked_domains'] ?? 0,
|
||||
$body['max_email'] ?? 0, $body['max_ftp'] ?? 0, $body['max_databases'] ?? 0,
|
||||
$body['php_version'] ?? '8.3', $body['ssl_enabled'] ?? 1, $id,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -390,11 +390,13 @@ BASH;
|
||||
if ($active) $services[$svc] = $active;
|
||||
}
|
||||
|
||||
// Persist to DB for history
|
||||
$db->execute(
|
||||
"INSERT INTO server_stats (cpu_usage,ram_usage,disk_usage,load_avg) VALUES (?,?,?,?)",
|
||||
[$cpuPct, $ramPct, $diskPct, $load[0]]
|
||||
);
|
||||
// Persist to DB for history (non-fatal — don't let lock errors kill the stats response)
|
||||
try {
|
||||
$db->execute(
|
||||
"INSERT INTO server_stats (cpu_usage,ram_usage,disk_usage,load_avg) VALUES (?,?,?,?)",
|
||||
[$cpuPct, $ramPct, $diskPct, $load[0]]
|
||||
);
|
||||
} catch (Throwable $_) {}
|
||||
|
||||
Response::success([
|
||||
'cpu' => ['pct' => $cpuPct, 'load' => $load],
|
||||
@@ -922,6 +924,8 @@ BASH;
|
||||
|| is_dir('/usr/share/phpmyadmin');
|
||||
$pgaInstalled = (int)trim(shell_exec("dpkg -l pgadmin4 2>/dev/null | grep -c '^ii'") ?: '0') > 0
|
||||
|| is_file('/usr/pgadmin4/bin/pgadmin4') || is_dir('/usr/pgadmin4');
|
||||
$adminerInstalled = is_file(NOVACPX_ROOT . '/adminer.php');
|
||||
$adminerVer = $adminerInstalled ? 'bundled' : '';
|
||||
$pmaVer = $pmaInstalled
|
||||
? trim(shell_exec("dpkg -l phpmyadmin 2>/dev/null | awk '/^ii/{print $3}' | head -1") ?: '')
|
||||
: '';
|
||||
@@ -929,8 +933,9 @@ BASH;
|
||||
? trim(shell_exec("pgadmin4 --version 2>/dev/null | grep -oP '[0-9]+\\.[0-9]+' | head -1") ?: '')
|
||||
: '';
|
||||
Response::success([
|
||||
'phpmyadmin' => ['installed' => $pmaInstalled, 'version' => $pmaVer],
|
||||
'phpmyadmin' => ['installed' => $pmaInstalled, 'version' => $pmaVer, 'url' => '/phpmyadmin'],
|
||||
'pgadmin' => ['installed' => $pgaInstalled, 'version' => $pgaVer],
|
||||
'adminer' => ['installed' => $adminerInstalled, 'version' => $adminerVer, 'url' => '/adminer.php'],
|
||||
]);
|
||||
})(),
|
||||
|
||||
@@ -938,7 +943,7 @@ BASH;
|
||||
Auth::getInstance()->require('admin');
|
||||
$tool = $body['tool'] ?? '';
|
||||
$action = $body['action'] ?? '';
|
||||
if (!in_array($tool, ['phpmyadmin','pgadmin'])) { echo 'data:'.json_encode(['error'=>'Invalid tool'])."\n\n"; exit; }
|
||||
if (!in_array($tool, ['phpmyadmin','pgadmin','adminer'])) { echo 'data:'.json_encode(['error'=>'Invalid tool'])."\n\n"; exit; }
|
||||
if (!in_array($action, ['install','reinstall','remove'])) { echo 'data:'.json_encode(['error'=>'Invalid action'])."\n\n"; exit; }
|
||||
|
||||
header('Content-Type: text/event-stream');
|
||||
@@ -970,7 +975,22 @@ BASH;
|
||||
|
||||
$env = 'sudo env DEBIAN_FRONTEND=noninteractive';
|
||||
|
||||
if ($tool === 'phpmyadmin') {
|
||||
if ($tool === 'adminer') {
|
||||
$adminerPath = NOVACPX_ROOT . '/adminer.php';
|
||||
if ($action === 'remove') {
|
||||
$sse("▶ Removing Adminer…\n");
|
||||
$run('sudo rm -f ' . escapeshellarg($adminerPath));
|
||||
$sse(" ✓ Removed!\n");
|
||||
} else {
|
||||
$sse("▶ Downloading Adminer…\n");
|
||||
$out = shell_exec('curl -sL https://www.adminer.org/latest.php -o ' . escapeshellarg($adminerPath) . ' 2>&1');
|
||||
if (is_file($adminerPath) && filesize($adminerPath) > 100000) {
|
||||
$sse(" ✓ Adminer installed at /adminer.php (" . round(filesize($adminerPath)/1024) . " KB)\n");
|
||||
} else {
|
||||
$sse(" ✗ Download failed: $out\n");
|
||||
}
|
||||
}
|
||||
} else if ($tool === 'phpmyadmin') {
|
||||
if ($action === 'remove') {
|
||||
$sse("▶ Removing phpMyAdmin…\n");
|
||||
$run("$env apt-get remove -y phpmyadmin 2>&1");
|
||||
@@ -1055,5 +1075,22 @@ BASH;
|
||||
exit;
|
||||
})(),
|
||||
|
||||
default => Response::error("Unknown system action: $action", 404),
|
||||
|
||||
'read-log' => (function() {
|
||||
Auth::getInstance()->require('admin');
|
||||
$log = preg_replace('/[^a-z0-9-]/', '', $_GET['log'] ?? 'panel');
|
||||
$map = [
|
||||
'panel' => '/var/log/novacpx/panel.log',
|
||||
'deploy' => '/var/log/novacpx/deploy.log',
|
||||
'nginx-error' => '/var/log/novacpx/nginx-error.log',
|
||||
'nginx-access' => '/var/log/novacpx/nginx-access.log',
|
||||
'mail' => '/var/log/mail.log',
|
||||
'stats' => '/var/log/novacpx/stats-collector.log',
|
||||
];
|
||||
$path = $map[$log] ?? '/var/log/novacpx/panel.log';
|
||||
$raw = file_exists($path) ? trim(shell_exec('tail -100 ' . escapeshellarg($path)) ?: '') : '';
|
||||
Response::success(['content' => $raw, 'log' => $log]);
|
||||
})(),
|
||||
|
||||
default => Response::error("Unknown system action: $action", 404),
|
||||
};
|
||||
|
||||
@@ -20,5 +20,80 @@ match ($action) {
|
||||
Response::success($rows);
|
||||
})(),
|
||||
|
||||
// Create a panel user (reseller or admin) — no hosting account provisioned
|
||||
'create' => (function() use ($db, $body) {
|
||||
$username = trim($body['username'] ?? '');
|
||||
$password = $body['password'] ?? '';
|
||||
$email = trim($body['email'] ?? '');
|
||||
$role = $body['role'] ?? 'reseller';
|
||||
|
||||
if (!$username || !$password || !$email) Response::error('username, password and email are required');
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) Response::error('Invalid email address');
|
||||
if (!in_array($role, ['reseller', 'admin'], true)) Response::error('role must be reseller or admin');
|
||||
if (strlen($password) < 8) Response::error('Password must be at least 8 characters');
|
||||
if (!preg_match('/^[a-zA-Z0-9_\-\.]+$/', $username)) Response::error('Username may only contain letters, numbers, _ - .');
|
||||
|
||||
$exists = $db->fetchOne("SELECT id FROM users WHERE username=? OR email=?", [$username, $email]);
|
||||
if ($exists) Response::error('Username or email already in use');
|
||||
|
||||
$uid = (int)$db->insert(
|
||||
"INSERT INTO users (username, password, email, role, status, created_at) VALUES (?,?,?,?,?,datetime('now'))",
|
||||
[$username, password_hash($password, PASSWORD_BCRYPT), $email, $role, 'active']
|
||||
);
|
||||
audit("user.create.{$role}", "user:{$username}");
|
||||
Response::success(['id' => $uid, 'username' => $username, 'email' => $email, 'role' => $role], ucfirst($role) . ' created');
|
||||
})(),
|
||||
|
||||
// Suspend a panel user (sets status=suspended on the users row)
|
||||
'suspend' => (function() use ($db, $body) {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$id) Response::error('id required');
|
||||
$u = $db->fetchOne("SELECT id, username, role FROM users WHERE id=?", [$id]);
|
||||
if (!$u) Response::error('User not found', 404);
|
||||
if ($u['role'] === 'admin') Response::error('Cannot suspend admin users');
|
||||
$db->execute("UPDATE users SET status='suspended' WHERE id=?", [$id]);
|
||||
audit('user.suspend', "user:{$u['username']}");
|
||||
Response::success(null, 'User suspended');
|
||||
})(),
|
||||
|
||||
// Unsuspend a panel user
|
||||
'unsuspend' => (function() use ($db, $body) {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$id) Response::error('id required');
|
||||
$u = $db->fetchOne("SELECT id, username FROM users WHERE id=?", [$id]);
|
||||
if (!$u) Response::error('User not found', 404);
|
||||
$db->execute("UPDATE users SET status='active' WHERE id=?", [$id]);
|
||||
audit('user.unsuspend', "user:{$u['username']}");
|
||||
Response::success(null, 'User unsuspended');
|
||||
})(),
|
||||
|
||||
// Change password for a panel user by user id
|
||||
'change-password' => (function() use ($db, $body) {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
$pass = $body['password'] ?? '';
|
||||
if (!$id) Response::error('id required');
|
||||
if (strlen($pass) < 8) Response::error('Password must be at least 8 characters');
|
||||
$u = $db->fetchOne("SELECT id, username FROM users WHERE id=?", [$id]);
|
||||
if (!$u) Response::error('User not found', 404);
|
||||
$db->execute("UPDATE users SET password=? WHERE id=?", [password_hash($pass, PASSWORD_BCRYPT), $id]);
|
||||
audit('user.change-password', "user:{$u['username']}");
|
||||
Response::success(null, 'Password updated');
|
||||
})(),
|
||||
|
||||
// Delete a panel user (admin or reseller — not a hosting account user)
|
||||
'delete' => (function() use ($db, $body) {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$id) Response::error('id required');
|
||||
$user = $db->fetchOne("SELECT id, username, role FROM users WHERE id=?", [$id]);
|
||||
if (!$user) Response::error('User not found', 404);
|
||||
if ($user['role'] === 'admin') Response::error('Cannot delete admin users');
|
||||
// Disown accounts under this reseller rather than deleting them
|
||||
$db->execute("UPDATE users SET reseller_id=NULL WHERE reseller_id=?", [$id]);
|
||||
$db->execute("UPDATE accounts SET reseller_id=NULL WHERE reseller_id=?", [$id]);
|
||||
$db->execute("DELETE FROM users WHERE id=?", [$id]);
|
||||
audit('user.delete', "user:{$user['username']}");
|
||||
Response::success(null, 'User deleted');
|
||||
})(),
|
||||
|
||||
default => Response::error("Unknown users action: $action", 404),
|
||||
};
|
||||
|
||||
+23
-2
@@ -9,14 +9,35 @@ define('NOVACPX_API', __DIR__);
|
||||
define('NOVACPX_LIB', NOVACPX_ROOT . '/lib');
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Global exception handler — prevents uncaught exceptions from crashing PHP-FPM (502)
|
||||
set_exception_handler(function (Throwable $e) {
|
||||
http_response_code(500);
|
||||
// Never expose internal exception messages (may contain SQL, paths, credentials)
|
||||
$safe = ($e instanceof \InvalidArgumentException || $e instanceof \RuntimeException)
|
||||
? $e->getMessage()
|
||||
: 'An internal error occurred.';
|
||||
echo json_encode(['success' => false, 'message' => $safe, 'errors' => []]);
|
||||
exit;
|
||||
});
|
||||
|
||||
$_ver = file_get_contents(NOVACPX_ROOT . '/VERSION')
|
||||
?: file_get_contents('/opt/novacpx-src/VERSION')
|
||||
?: '1.0.0';
|
||||
header('X-NovaCPX-Version: ' . trim($_ver));
|
||||
|
||||
// CORS for same-origin panel requests (ports 8880/8881/8882/8883)
|
||||
// CORS — only allow same-host origins on the panel ports or the known proxy hostnames
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if (preg_match('#^https?://[^/]+:(888[0-3])$#', $origin)) {
|
||||
$_allowedHosts = ['novacpx.orbishosting.com', 'admin.novacpx.orbishosting.com',
|
||||
'reseller.novacpx.orbishosting.com', 'panel.novacpx.orbishosting.com',
|
||||
'web.orbishosting.com'];
|
||||
$_originHost = parse_url($origin, PHP_URL_HOST) ?? '';
|
||||
$_originPort = (int)(parse_url($origin, PHP_URL_PORT) ?? 0);
|
||||
$_panelPorts = [8880, 8881, 8882, 8883]; // hardcoded — Core.php not loaded yet
|
||||
if ($origin && (
|
||||
in_array($_originHost, $_allowedHosts, true) ||
|
||||
(in_array($_originPort, $_panelPorts, true) && filter_var($_originHost, FILTER_VALIDATE_IP))
|
||||
)) {
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
/* 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"], input[type="search"],
|
||||
input[type="date"], input[type="time"], input[type="tel"],
|
||||
input:not([type]), .form-control, 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;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus, .form-control:focus { border-color: var(--primary); }
|
||||
input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1) opacity(.5); }
|
||||
|
||||
.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 {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: .7rem; font-weight: 700; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--text-muted);
|
||||
padding: .25rem 1.25rem .5rem;
|
||||
cursor: pointer; user-select: none;
|
||||
transition: color .15s;
|
||||
}
|
||||
.sidebar-section-label:hover { color: var(--text); }
|
||||
.sidebar-section-label .nav-chevron {
|
||||
font-style: normal; font-size: .65rem; opacity: .5;
|
||||
transition: transform .2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-section.collapsed .nav-chevron { transform: rotate(-90deg); }
|
||||
.sidebar-section.collapsed .sidebar-link { display: none; }
|
||||
.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; }
|
||||
|
||||
/* ── #26 Mobile Responsive Additions ────────────────────────────────────────── */
|
||||
#sidebar-toggle { display: none; }
|
||||
|
||||
.sidebar-overlay {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.5); z-index: 99;
|
||||
}
|
||||
.sidebar-overlay.open { display: block; }
|
||||
|
||||
/* page-header layout */
|
||||
.page-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 1.5rem; flex-wrap: wrap; gap: .75rem;
|
||||
}
|
||||
.page-title { font-size: 1.2rem; font-weight: 700; }
|
||||
.page-actions { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
/* panel utility */
|
||||
.panel {
|
||||
background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); margin-bottom: 1.5rem;
|
||||
}
|
||||
.panel-header {
|
||||
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
flex-wrap: wrap; gap: .5rem;
|
||||
}
|
||||
.panel-title { font-size: .95rem; font-weight: 600; }
|
||||
.panel-body { padding: 1.25rem; }
|
||||
|
||||
/* table alias */
|
||||
.table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
.table 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); white-space: nowrap; }
|
||||
.table td { padding: .75rem 1rem; border-bottom: 1px solid var(--border); }
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
.table tr:hover td { background: var(--bg3); }
|
||||
|
||||
/* btn variants */
|
||||
.btn-success { background: var(--green); color: #fff; }
|
||||
.btn-success:hover { background: #0da271; }
|
||||
.btn-warning { background: var(--yellow); color: #000; }
|
||||
.btn-warning:hover { background: #d97706; }
|
||||
.btn-danger { background: var(--red); color: #fff; }
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
.btn-secondary { background: var(--bg3); border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-secondary:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.btn-xs { padding: .2rem .55rem; font-size: .75rem; border-radius: 6px; }
|
||||
|
||||
/* badge alias */
|
||||
.badge-muted { background: rgba(148,163,184,.15); color: #94a3b8; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#sidebar-toggle { display: flex; }
|
||||
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform .25s ease;
|
||||
z-index: 200;
|
||||
}
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
|
||||
.main-content { margin-left: 0; }
|
||||
|
||||
.page-header { flex-direction: column; align-items: flex-start; }
|
||||
.page-actions { width: 100%; }
|
||||
|
||||
.stats-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||
|
||||
.modal { max-width: calc(100vw - 2rem); margin: 1rem; }
|
||||
|
||||
.panel-header { flex-direction: column; align-items: flex-start; }
|
||||
|
||||
.topbar { padding: .65rem 1rem; }
|
||||
|
||||
/* hide non-essential table columns on mobile */
|
||||
.table th:nth-child(n+4),
|
||||
.table td:nth-child(n+4) { display: none; }
|
||||
|
||||
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#0d0f17"/>
|
||||
<polygon points="16,3 27,9 27,21 16,27 5,21 5,9" fill="none" stroke="#6366f1" stroke-width="2"/>
|
||||
<circle cx="16" cy="15" r="5" fill="none" stroke="#0ea5e9" stroke-width="1.5" stroke-dasharray="2 1.5"/>
|
||||
<circle cx="16" cy="15" r="2.5" fill="#6366f1"/>
|
||||
<circle cx="16" cy="9.5" r="1.2" fill="#6366f1"/>
|
||||
<circle cx="21" cy="18" r="1.2" fill="#0ea5e9"/>
|
||||
<circle cx="11" cy="18" r="1.2" fill="#10b981"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 534 B |
@@ -0,0 +1,329 @@
|
||||
<!-- NovaCPX Custom Icon Sprite — inline <use href="#icon-name"/> -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
|
||||
|
||||
<!-- Dashboard / home -->
|
||||
<symbol id="ni-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="9" height="9" rx="1.5"/>
|
||||
<rect x="13" y="3" width="9" height="5" rx="1.5"/>
|
||||
<rect x="13" y="11" width="9" height="9" rx="1.5"/>
|
||||
<rect x="2" y="15" width="9" height="5" rx="1.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Accounts / users -->
|
||||
<symbol id="ni-accounts" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M2 21c0-4 3-7 7-7h4c4 0 7 3 7 7"/>
|
||||
<circle cx="19" cy="9" r="2.5"/>
|
||||
<path d="M22 19c0-2.5-1.5-4.5-3.5-5.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Resellers -->
|
||||
<symbol id="ni-resellers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12,2 15,8.5 22,9.5 17,14 18.5,21 12,17.5 5.5,21 7,14 2,9.5 9,8.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Packages -->
|
||||
<symbol id="ni-packages" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</symbol>
|
||||
|
||||
<!-- DNS -->
|
||||
<symbol id="ni-dns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20A14.5 14.5 0 0 0 12 2"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<line x1="12" y1="2" x2="12" y2="22"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Email -->
|
||||
<symbol id="ni-email" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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"/>
|
||||
<line x1="12" y1="13" x2="12" y2="20"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Databases -->
|
||||
<symbol id="ni-databases" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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"/>
|
||||
</symbol>
|
||||
|
||||
<!-- FTP -->
|
||||
<symbol id="ni-ftp" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="8" height="6" rx="1"/>
|
||||
<rect x="13" y="3" width="8" height="6" rx="1"/>
|
||||
<rect x="3" y="13" width="8" height="6" rx="1"/>
|
||||
<path d="M17 13v4m-2-2h4"/>
|
||||
</symbol>
|
||||
|
||||
<!-- SSL / Lock -->
|
||||
<symbol id="ni-ssl" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="5" y="11" width="14" height="11" rx="2"/>
|
||||
<path d="M8 11V7a4 4 0 1 1 8 0v4"/>
|
||||
<circle cx="12" cy="16" r="1.5" fill="currentColor" stroke="none"/>
|
||||
<line x1="12" y1="17.5" x2="12" y2="19.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- PHP -->
|
||||
<symbol id="ni-php" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<ellipse cx="12" cy="12" rx="10" ry="6"/>
|
||||
<path d="M9 10v4m0-4h2a1.5 1.5 0 0 1 0 3H9"/>
|
||||
<path d="M14 10v4m0-4h2a1.5 1.5 0 0 1 0 3H14"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Cron / clock -->
|
||||
<symbol id="ni-cron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
<path d="M16 2l2 3M8 2L6 5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- File Manager -->
|
||||
<symbol id="ni-files" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
<line x1="12" y1="11" x2="12" y2="17"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Domains / Web -->
|
||||
<symbol id="ni-domains" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="13" rx="2"/>
|
||||
<line x1="3" y1="7" x2="21" y2="7"/>
|
||||
<line x1="7" y1="21" x2="17" y2="21"/>
|
||||
<line x1="12" y1="16" x2="12" y2="21"/>
|
||||
<circle cx="6" cy="5" r="0.8" fill="currentColor" stroke="none"/>
|
||||
<circle cx="9" cy="5" r="0.8" fill="currentColor" stroke="none"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Server / System -->
|
||||
<symbol id="ni-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="7" rx="1.5"/>
|
||||
<rect x="2" y="13" width="20" height="7" rx="1.5"/>
|
||||
<circle cx="6" cy="6.5" r="1.2" fill="currentColor" stroke="none"/>
|
||||
<circle cx="6" cy="16.5" r="1.2" fill="currentColor" stroke="none"/>
|
||||
<line x1="10" y1="6.5" x2="16" y2="6.5"/>
|
||||
<line x1="10" y1="16.5" x2="16" y2="16.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Features / Plugin -->
|
||||
<symbol id="ni-features" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.5 2H9.5a1 1 0 0 0-.9.5l-4 7a1 1 0 0 0 0 1l4 7a1 1 0 0 0 .9.5h5a1 1 0 0 0 .9-.5l4-7a1 1 0 0 0 0-1l-4-7a1 1 0 0 0-.9-.5z"/>
|
||||
<circle cx="12" cy="12" r="2.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Updates / Git -->
|
||||
<symbol id="ni-updates" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="1 4 1 10 7 10"/>
|
||||
<path d="M3.51 15a9 9 0 1 0 .49-3.51"/>
|
||||
<polyline points="12 7 12 12 15 15"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Firewall / Shield -->
|
||||
<symbol id="ni-firewall" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="15" x2="12.01" y2="15"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Backups -->
|
||||
<symbol id="ni-backups" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="21 15 21 21 15 21"/>
|
||||
<polyline points="3 9 3 3 9 3"/>
|
||||
<path d="M21 3L14 10"/>
|
||||
<path d="M3 21l7-7"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Settings / Gear -->
|
||||
<symbol id="ni-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3.5"/>
|
||||
<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"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Audit Log -->
|
||||
<symbol id="ni-audit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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="8" y1="13" x2="16" y2="13"/>
|
||||
<line x1="8" y1="17" x2="12" y2="17"/>
|
||||
<circle cx="16" cy="17" r="2"/>
|
||||
<line x1="18" y1="19" x2="20" y2="21"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Webmail -->
|
||||
<symbol id="ni-webmail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22c5.52 0 10-4.48 10-10S17.52 2 12 2 2 6.48 2 12s4.48 10 10 10z"/>
|
||||
<path d="M12 8a4 4 0 0 1 0 8 4 4 0 0 1 0-8z"/>
|
||||
<path d="M16 12h6M2 12h6"/>
|
||||
</symbol>
|
||||
|
||||
<!-- WordPress -->
|
||||
<symbol id="ni-wordpress" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M2.48 12s3.22 7 9.52 7 9.52-7 9.52-7"/>
|
||||
<path d="M12 2c2.8 2 5 5.8 5 10s-2.2 8-5 10"/>
|
||||
<path d="M12 2c-2.8 2-5 5.8-5 10s2.2 8 5 10"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Docker -->
|
||||
<symbol id="ni-docker" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="9" width="4" height="4" rx="0.5"/>
|
||||
<rect x="7" y="9" width="4" height="4" rx="0.5"/>
|
||||
<rect x="12" y="9" width="4" height="4" rx="0.5"/>
|
||||
<rect x="7" y="4" width="4" height="4" rx="0.5"/>
|
||||
<path d="M18 11a4 4 0 0 1 4 4c0 3-3 5-7 5H6c-2 0-4-1.5-4-4 0-1.8 1-3.5 3-4.5"/>
|
||||
<path d="M20 7s-1-1-3-1"/>
|
||||
<circle cx="20" cy="6" r="1" fill="currentColor" stroke="none"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Node.js -->
|
||||
<symbol id="ni-nodejs" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12,2 22,7.5 22,16.5 12,22 2,16.5 2,7.5"/>
|
||||
<path d="M12 7v5l4 2.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Redis -->
|
||||
<symbol id="ni-redis" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<ellipse cx="12" cy="8" rx="9" ry="3"/>
|
||||
<path d="M3 8v4c0 1.66 4.03 3 9 3s9-1.34 9-3V8"/>
|
||||
<path d="M3 12v4c0 1.66 4.03 3 9 3s9-1.34 9-3v-4"/>
|
||||
<path d="M8 6l2 1 4-2"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Cloudflare -->
|
||||
<symbol id="ni-cloudflare" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17.5 16c.83-1.5.5-4-2.5-4.5l.3-1.5C18 9.5 21 11 21 14.5c0 1-.2 2-.8 2.5H17.5z"/>
|
||||
<path d="M6.5 16H17m-3-4.5C12.5 8 8 8 6 10.5c-1 1.5-1 3-0.5 4.5"/>
|
||||
<path d="M3 15c0 .55.45 1 1 1h1"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Gitea -->
|
||||
<symbol id="ni-git" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="18" cy="18" r="3"/>
|
||||
<circle cx="6" cy="6" r="3"/>
|
||||
<circle cx="6" cy="18" r="3"/>
|
||||
<path d="M6 9v6"/>
|
||||
<path d="M15.7 5.7l-9.4 12.6"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Suspend / pause -->
|
||||
<symbol id="ni-suspend" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="10" y1="8" x2="10" y2="16"/>
|
||||
<line x1="14" y1="8" x2="14" y2="16"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Terminate / X -->
|
||||
<symbol id="ni-terminate" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Stats / Chart -->
|
||||
<symbol id="ni-stats" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Notifications / bell -->
|
||||
<symbol id="ni-notifications" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Hostname -->
|
||||
<symbol id="ni-hostname" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
<line x1="12" y1="15" x2="12" y2="18"/>
|
||||
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Add / Plus -->
|
||||
<symbol id="ni-add" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="16"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</symbol>
|
||||
|
||||
<!-- User profile -->
|
||||
<symbol id="ni-profile" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="8" r="4"/>
|
||||
<path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Logout -->
|
||||
<symbol id="ni-logout" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Search -->
|
||||
<symbol id="ni-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Copy -->
|
||||
<symbol id="ni-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Key / API -->
|
||||
<symbol id="ni-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Mail server -->
|
||||
<symbol id="ni-mailserver" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="6" width="20" height="14" rx="2"/>
|
||||
<path d="M22 6l-10 7L2 6"/>
|
||||
<path d="M2 6l4-4h12l4 4"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Docs / book -->
|
||||
<symbol id="ni-docs" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
||||
<line x1="8" y1="7" x2="16" y2="7"/>
|
||||
<line x1="8" y1="11" x2="14" y2="11"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Contact / support -->
|
||||
<symbol id="ni-support" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
<line x1="9" y1="10" x2="9.01" y2="10"/>
|
||||
<line x1="12" y1="10" x2="12.01" y2="10"/>
|
||||
<line x1="15" y1="10" x2="15.01" y2="10"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ModSecurity / WAF -->
|
||||
<symbol id="ni-waf" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
<polyline points="9 12 11 14 15 10"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Varnish / cache -->
|
||||
<symbol id="ni-cache" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Network / IP -->
|
||||
<symbol id="ni-network" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="2" width="6" height="4" rx="1"/>
|
||||
<rect x="2" y="18" width="6" height="4" rx="1"/>
|
||||
<rect x="16" y="18" width="6" height="4" rx="1"/>
|
||||
<rect x="9" y="18" width="6" height="4" rx="1"/>
|
||||
<line x1="12" y1="6" x2="12" y2="14"/>
|
||||
<path d="M5 20v-6h14v6"/>
|
||||
<line x1="12" y1="14" x2="5" y2="14"/>
|
||||
<line x1="12" y1="14" x2="19" y2="14"/>
|
||||
</symbol>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 48">
|
||||
<defs>
|
||||
<linearGradient id="ng" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="60%" stop-color="#0ea5e9"/>
|
||||
<stop offset="100%" stop-color="#10b981"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ng2" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1" stop-opacity="1"/>
|
||||
<stop offset="100%" stop-color="#0ea5e9" stop-opacity="0.7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Hexagon core mark -->
|
||||
<polygon points="24,4 40,13 40,31 24,40 8,31 8,13" fill="none" stroke="url(#ng)" stroke-width="2.5"/>
|
||||
<!-- Inner orbit ring -->
|
||||
<circle cx="24" cy="22" r="7" fill="none" stroke="url(#ng)" stroke-width="1.5" stroke-dasharray="3 2"/>
|
||||
<!-- Central dot -->
|
||||
<circle cx="24" cy="22" r="3" fill="url(#ng)"/>
|
||||
<!-- Orbit node dots -->
|
||||
<circle cx="24" cy="14" r="1.5" fill="#6366f1"/>
|
||||
<circle cx="31" cy="26" r="1.5" fill="#0ea5e9"/>
|
||||
<circle cx="17" cy="26" r="1.5" fill="#10b981"/>
|
||||
<!-- Wordmark -->
|
||||
<text x="52" y="32" font-family="'SF Pro Display','Segoe UI',sans-serif" font-size="22" font-weight="700" fill="url(#ng)" letter-spacing="-0.5">Nova</text>
|
||||
<text x="118" y="32" font-family="'SF Pro Display','Segoe UI',sans-serif" font-size="22" font-weight="300" fill="#94a3b8" letter-spacing="2">CPX</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<defs>
|
||||
<linearGradient id="mg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="60%" stop-color="#0ea5e9"/>
|
||||
<stop offset="100%" stop-color="#10b981"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points="24,4 40,13 40,31 24,40 8,31 8,13" fill="none" stroke="url(#mg)" stroke-width="2.5"/>
|
||||
<circle cx="24" cy="22" r="7" fill="none" stroke="url(#mg)" stroke-width="1.5" stroke-dasharray="3 2"/>
|
||||
<circle cx="24" cy="22" r="3" fill="url(#mg)"/>
|
||||
<circle cx="24" cy="14" r="1.5" fill="#6366f1"/>
|
||||
<circle cx="31" cy="26" r="1.5" fill="#0ea5e9"/>
|
||||
<circle cx="17" cy="26" r="1.5" fill="#10b981"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 731 B |
+1814
-171
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* NovaCPX Feature Manager
|
||||
* Loaded by admin panel's features page
|
||||
*/
|
||||
window.FeaturesManager = {
|
||||
async load() {
|
||||
const res = await Nova.api('features', 'list');
|
||||
if (!res?.success) return '<p class="text-muted">Failed to load features.</p>';
|
||||
const grouped = res.data;
|
||||
|
||||
const categoryIcons = {
|
||||
'Web Server':'🌐', 'PHP':'⚙️', 'Database':'🗄️', 'Email':'📧',
|
||||
'DNS':'🔍', 'FTP':'📁', 'SSL':'🔒', 'Security':'🛡️', 'Containers':'🐳',
|
||||
'IP Management':'🌍', 'Monitoring':'📊', 'Backup':'💾', 'CDN & Performance':'⚡',
|
||||
'Development':'👨💻', 'One-Click Apps':'🚀', 'Applications':'📦',
|
||||
'Billing':'💳', 'Reseller':'🏪', 'Notifications':'🔔', 'Compliance':'✅',
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h2 style="font-size:1.1rem;font-weight:700">Feature Manager</h2>
|
||||
<div class="flex gap-1">
|
||||
<input type="text" id="feat-search" placeholder="Search features…" style="width:220px;padding:.45rem .85rem;font-size:.85rem">
|
||||
<select id="feat-cat-filter" style="padding:.45rem .7rem;font-size:.85rem">
|
||||
<option value="">All Categories</option>
|
||||
${Object.keys(grouped).map(c => `<option value="${c}">${c}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="features-container">
|
||||
${Object.entries(grouped).map(([cat, feats]) => `
|
||||
<div class="feat-category" data-cat="${cat}">
|
||||
<div class="flex items-center gap-1 mb-2 mt-3">
|
||||
<span style="font-size:1.1rem">${categoryIcons[cat] || '🔧'}</span>
|
||||
<h3 style="font-size:.9rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted)">${cat}</h3>
|
||||
<span class="badge badge-gray" style="margin-left:.5rem">${feats.length}</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:.75rem">
|
||||
${feats.map(f => `
|
||||
<div class="feat-card card" data-id="${f.id}" data-name="${f.name.toLowerCase()}" data-cat="${cat}">
|
||||
<div class="card-body" style="padding:1rem">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:.88rem">${f.name}</div>
|
||||
<div style="font-size:.75rem;color:var(--text-muted);margin-top:.15rem;line-height:1.4">${f.description}</div>
|
||||
${f.min_ram_mb > 0 ? `<div style="font-size:.72rem;color:var(--text-muted);margin-top:.2rem">Requires ${f.min_ram_mb}MB RAM</div>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:.4rem;margin-left:1rem;flex-shrink:0">
|
||||
${f.installed
|
||||
? `<label class="toggle-switch" title="${f.enabled ? 'Disable' : 'Enable'}">
|
||||
<input type="checkbox" ${f.enabled ? 'checked' : ''} onchange="FeaturesManager.toggle(${f.id}, this.checked)">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>`
|
||||
: `<button class="btn btn-primary btn-sm" onclick="FeaturesManager.install(${f.id})">Install</button>`
|
||||
}
|
||||
${f.installed ? `<span class="badge badge-green" style="font-size:.65rem">Installed</span>` : `<span class="badge badge-gray" style="font-size:.65rem">Not installed</span>`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toggle-switch { position:relative; display:inline-block; width:40px; height:22px; }
|
||||
.toggle-switch input { opacity:0; width:0; height:0; }
|
||||
.toggle-slider {
|
||||
position:absolute; cursor:pointer; inset:0;
|
||||
background:var(--border); border-radius:999px; transition:.2s;
|
||||
}
|
||||
.toggle-slider:before {
|
||||
content:''; position:absolute; width:16px; height:16px;
|
||||
left:3px; bottom:3px; background:#fff; border-radius:50%; transition:.2s;
|
||||
}
|
||||
input:checked + .toggle-slider { background:var(--primary); }
|
||||
input:checked + .toggle-slider:before { transform:translateX(18px); }
|
||||
</style>`;
|
||||
},
|
||||
|
||||
async toggle(id, enable) {
|
||||
const res = await Nova.api('features', 'toggle', { method: 'POST', body: { id, enable } });
|
||||
if (res?.data?.action === 'install_required') {
|
||||
Nova.confirm(`"${res.data.feature.name}" must be installed first. Install now?`, () => this.install(id));
|
||||
return;
|
||||
}
|
||||
Nova.toast(res?.message || 'Updated', res?.success ? 'success' : 'error');
|
||||
},
|
||||
|
||||
async install(id) {
|
||||
const res = await Nova.api('features', 'install', { method: 'POST', body: { id } });
|
||||
if (!res?.success) { Nova.toast(res?.message || 'Install failed', 'error'); return; }
|
||||
|
||||
const logDiv = `<div class="terminal" id="install-log-${id}" style="min-height:100px">Starting installation…</div>`;
|
||||
const ov = Nova.modal('Installing Feature', logDiv);
|
||||
const poll = setInterval(async () => {
|
||||
const logRes = await Nova.api('features', 'install-log', { params: { id } });
|
||||
const logEl = document.getElementById(`install-log-${id}`);
|
||||
if (logEl) logEl.innerHTML = (logRes?.data?.log || '').split('\n').map(l => `<div>${l}</div>`).join('');
|
||||
if (!logRes?.data?.running) {
|
||||
clearInterval(poll);
|
||||
if (logRes?.data?.installed) {
|
||||
Nova.toast('Feature installed successfully', 'success');
|
||||
ov.remove();
|
||||
document.getElementById('page-content').innerHTML = await this.load();
|
||||
this.bindSearch();
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
bindSearch() {
|
||||
const search = document.getElementById('feat-search');
|
||||
const catFilter = document.getElementById('feat-cat-filter');
|
||||
if (!search || !catFilter) return;
|
||||
|
||||
const filter = () => {
|
||||
const q = search.value.toLowerCase();
|
||||
const cat = catFilter.value;
|
||||
document.querySelectorAll('.feat-card').forEach(c => {
|
||||
const match = (!q || c.dataset.name.includes(q)) && (!cat || c.dataset.cat === cat);
|
||||
c.closest('div').style.display = match ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.feat-category').forEach(sec => {
|
||||
const visible = [...sec.querySelectorAll('.feat-card')].some(c => c.closest('div').style.display !== 'none');
|
||||
sec.style.display = visible ? '' : 'none';
|
||||
});
|
||||
};
|
||||
search.addEventListener('input', filter);
|
||||
catFilter.addEventListener('change', filter);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* NovaCPX — Shared JS utilities
|
||||
*/
|
||||
|
||||
window.Nova = (() => {
|
||||
// ── Activity bar (thin top-of-page progress stripe for every API call) ────
|
||||
let _barEl = null, _barPct = 0, _barTimer = null, _barActive = 0;
|
||||
function _barShow() {
|
||||
_barActive++;
|
||||
if (!_barEl) {
|
||||
_barEl = document.createElement('div');
|
||||
_barEl.style.cssText = [
|
||||
'position:fixed;top:0;left:0;z-index:999999',
|
||||
'height:3px;width:0%;background:var(--primary,#6366f1)',
|
||||
'transition:width .2s ease,opacity .3s ease',
|
||||
'box-shadow:0 0 8px var(--primary,#6366f1)',
|
||||
'pointer-events:none',
|
||||
].join(';');
|
||||
document.body.appendChild(_barEl);
|
||||
}
|
||||
_barEl.style.opacity = '1';
|
||||
_barPct = 10;
|
||||
_barEl.style.width = _barPct + '%';
|
||||
clearInterval(_barTimer);
|
||||
_barTimer = setInterval(() => {
|
||||
if (_barEl && _barPct < 85) { _barPct += (_barPct < 50 ? 8 : _barPct < 70 ? 4 : 1); _barEl.style.width = _barPct + '%'; }
|
||||
}, 200);
|
||||
}
|
||||
function _barDone() {
|
||||
_barActive = Math.max(0, _barActive - 1);
|
||||
if (_barActive > 0) return;
|
||||
clearInterval(_barTimer);
|
||||
if (_barEl) {
|
||||
_barEl.style.width = '100%';
|
||||
setTimeout(() => { if (_barEl) { _barEl.style.opacity = '0'; setTimeout(() => { _barEl?.remove(); _barEl = null; }, 300); } }, 200);
|
||||
}
|
||||
}
|
||||
|
||||
// ── API ───────────────────────────────────────────────────────────────────
|
||||
async function api(endpoint, action, opts = {}) {
|
||||
const { method = 'GET', body, params } = opts;
|
||||
let url = `/api/${endpoint}/${action}`;
|
||||
if (params) url += '?' + new URLSearchParams(params);
|
||||
_barShow();
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
_barDone();
|
||||
console.error(`Nova.api network error [${endpoint}/${action}]:`, e);
|
||||
return { success: false, message: 'Network error — check your connection' };
|
||||
}
|
||||
_barDone();
|
||||
if (res.status === 401) {
|
||||
const text401 = await res.text();
|
||||
try {
|
||||
const j = JSON.parse(text401);
|
||||
// Login failures return the real message; other 401s mean the session expired
|
||||
if (endpoint === 'auth' && action === 'login') return j;
|
||||
return { success: false, message: j.message || 'Session expired — please log in again' };
|
||||
} catch { return { success: false, message: 'Session expired — please log in again' }; }
|
||||
}
|
||||
if (res.status === 429) {
|
||||
const reset = res.headers.get('X-RateLimit-Reset');
|
||||
const wait = reset ? Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000)) : 60;
|
||||
return { success: false, message: `Rate limited — try again in ${wait}s` };
|
||||
}
|
||||
const text = await res.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
console.error(`Nova.api non-JSON from [${endpoint}/${action}] (HTTP ${res.status}):`, text.slice(0, 500));
|
||||
return { success: false, message: `Server error (HTTP ${res.status}) — see browser console` };
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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>`;
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
// ── Loading overlay ───────────────────────────────────────────────────────
|
||||
let _loadingEl = null;
|
||||
let _loadingCount = 0;
|
||||
function loading(msg = 'Working…') {
|
||||
_loadingCount++;
|
||||
if (!_loadingEl) {
|
||||
_loadingEl = document.createElement('div');
|
||||
_loadingEl.id = 'nova-loading-overlay';
|
||||
_loadingEl.style.cssText = [
|
||||
'position:fixed;inset:0;z-index:99999',
|
||||
'background:rgba(0,0,0,.55)',
|
||||
'display:flex;flex-direction:column;align-items:center;justify-content:center',
|
||||
'gap:1rem;animation:fadeIn .15s',
|
||||
].join(';');
|
||||
_loadingEl.innerHTML = `
|
||||
<div style="width:48px;height:48px;border:4px solid rgba(255,255,255,.2);border-top-color:#fff;border-radius:50%;animation:ncpxSpin 0.7s linear infinite"></div>
|
||||
<div id="nova-loading-msg" style="color:#fff;font-size:1rem;font-weight:500;text-shadow:0 1px 3px rgba(0,0,0,.6)">${escHtml(msg)}</div>`;
|
||||
document.body.appendChild(_loadingEl);
|
||||
} else {
|
||||
document.getElementById('nova-loading-msg').textContent = msg;
|
||||
}
|
||||
}
|
||||
function loadingDone() {
|
||||
_loadingCount = Math.max(0, _loadingCount - 1);
|
||||
if (_loadingCount === 0 && _loadingEl) {
|
||||
_loadingEl.remove();
|
||||
_loadingEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Inject global CSS animations
|
||||
const style = document.createElement('style');
|
||||
style.textContent = [
|
||||
'@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}',
|
||||
'@keyframes ncpxSpin{to{transform:rotate(360deg)}}',
|
||||
].join('');
|
||||
document.head.appendChild(style);
|
||||
|
||||
return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot, escHtml, loading, loadingDone };
|
||||
})();
|
||||
|
||||
// #26 Mobile sidebar toggle — shared across all panels
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const toggle = document.getElementById('sidebar-toggle');
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('sidebar-overlay');
|
||||
if (!toggle || !sidebar) return;
|
||||
|
||||
const open = () => { sidebar.classList.add('open'); overlay?.classList.add('open'); document.body.style.overflow = 'hidden'; };
|
||||
const close = () => { sidebar.classList.remove('open'); overlay?.classList.remove('open'); document.body.style.overflow = ''; };
|
||||
|
||||
toggle.addEventListener('click', () => sidebar.classList.contains('open') ? close() : open());
|
||||
overlay?.addEventListener('click', close);
|
||||
|
||||
// Close when a nav link is clicked on mobile
|
||||
sidebar.querySelectorAll('.sidebar-link').forEach(link =>
|
||||
link.addEventListener('click', () => { if (window.innerWidth <= 768) close(); })
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,700 @@
|
||||
/**
|
||||
* NovaCPX Reseller Panel JS
|
||||
*/
|
||||
|
||||
let _rUser = null;
|
||||
|
||||
async function initReseller() {
|
||||
const res = await Nova.api('auth', 'me');
|
||||
if (!res?.success || !['admin','reseller'].includes(res.data?.role)) {
|
||||
document.getElementById('auth-check').innerHTML = renderLogin();
|
||||
document.getElementById('main-layout').style.display = 'none';
|
||||
return false;
|
||||
}
|
||||
_rUser = res.data;
|
||||
document.getElementById('user-name').textContent = _rUser.username || 'Reseller';
|
||||
document.getElementById('auth-check').style.display = 'none';
|
||||
document.getElementById('main-layout').style.display = '';
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderLogin() {
|
||||
return `<div class="login-wrap">
|
||||
<div class="login-card">
|
||||
<div style="text-align:center;margin-bottom:2rem">
|
||||
<img src="/assets/img/nova-logo.svg" style="height:42px;margin-bottom:.5rem">
|
||||
<div style="color:var(--muted);font-size:.85rem">Reseller Portal · Port 8881</div>
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label">Username</label><input id="li-user" type="text" class="form-control" autocomplete="username"></div>
|
||||
<div class="form-group"><label class="form-label">Password</label><input id="li-pass" type="password" class="form-control" autocomplete="current-password"></div>
|
||||
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Sign In</button>
|
||||
<div id="li-err" style="color:var(--red);margin-top:.75rem;text-align:center;display:none"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
Nova.loading('Signing in…');
|
||||
const res = await Nova.api('auth', 'login', { method: 'POST', body: { username: document.getElementById('li-user')?.value, password: document.getElementById('li-pass')?.value }});
|
||||
Nova.loadingDone();
|
||||
if (res?.success) {
|
||||
if (res.data?.portal_url && !res.data.portal_url.includes(':8881')) location.href = res.data.portal_url;
|
||||
else location.reload();
|
||||
} else {
|
||||
const err = document.getElementById('li-err');
|
||||
if (err) { err.textContent = res?.message || 'Login failed'; err.style.display = 'block'; }
|
||||
}
|
||||
}
|
||||
window.doLogin = doLogin;
|
||||
|
||||
/* ── Pages ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
async function rDashboard(el) {
|
||||
el.innerHTML = `<div class="page-header"><h2 class="page-title">Reseller Dashboard</h2></div>
|
||||
<div id="r-stats" class="stats-grid"><div class="loading">Loading…</div></div>
|
||||
<div style="margin-top:1.5rem" class="card">
|
||||
<div class="card-header"><span class="card-title">Recent Accounts</span>
|
||||
<button class="btn btn-sm btn-primary" onclick="resellerNav('accounts')">View All</button>
|
||||
</div>
|
||||
<div id="r-recent"><div class="loading">Loading…</div></div>
|
||||
</div>`;
|
||||
|
||||
const res = await Nova.api('accounts', 'list', { params:{ limit:5 }});
|
||||
const accts = res?.data || [];
|
||||
|
||||
document.getElementById('r-stats').innerHTML = [
|
||||
{ label: 'Total Accounts', val: res?.meta?.total || accts.length, icon: 'ni-accounts' },
|
||||
{ label: 'Active', val: accts.filter(a=>a.status==='active').length, icon: 'ni-stats' },
|
||||
{ label: 'Suspended', val: accts.filter(a=>a.status==='suspended').length, icon: 'ni-suspend' },
|
||||
].map(s => `<div class="stat-card" style="display:flex;align-items:center;gap:1rem">
|
||||
<svg width="32" height="32" style="color:var(--primary);flex-shrink:0"><use href="/assets/img/nova-icons.svg#${s.icon}"/></svg>
|
||||
<div><div class="stat-value">${s.val}</div><div class="stat-label">${s.label}</div></div>
|
||||
</div>`).join('');
|
||||
|
||||
document.getElementById('r-recent').innerHTML = accts.length
|
||||
? `<table class="table"><thead><tr><th>Username</th><th>Domain</th><th>Package</th><th>Status</th></tr></thead><tbody>
|
||||
${accts.map(a => `<tr>
|
||||
<td>${a.username}</td><td>${a.domain}</td><td>${a.package_name||'—'}</td>
|
||||
<td>${Nova.badge(a.status, a.status==='active'?'green':'yellow')}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`
|
||||
: '<div class="empty">No accounts yet.</div>';
|
||||
}
|
||||
|
||||
async function rAccounts(el) {
|
||||
el.innerHTML = `<div class="page-header">
|
||||
<h2 class="page-title">Hosting Accounts</h2>
|
||||
<button class="btn btn-primary btn-sm" onclick="resellerNav('createAccount')">+ Create Account</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div style="padding:.75rem;border-bottom:1px solid var(--border)">
|
||||
<input id="r-search" class="form-control" placeholder="Search accounts…" oninput="rSearchAccounts(this.value)" style="max-width:300px">
|
||||
</div>
|
||||
<div id="r-accounts-list"><div class="loading">Loading…</div></div>
|
||||
</div>`;
|
||||
loadRAccounts();
|
||||
}
|
||||
|
||||
async function loadRAccounts(search = '') {
|
||||
const el = document.getElementById('r-accounts-list');
|
||||
if (!el) return;
|
||||
const res = await Nova.api('accounts', 'list', { params: search ? { search } : {}});
|
||||
const acctRows = res?.data || [];
|
||||
if (!res?.success || !acctRows.length) { el.innerHTML = '<div class="empty">No accounts found.</div>'; return; }
|
||||
el.innerHTML = `<table class="table"><thead><tr><th>Username</th><th>Domain</th><th>Package</th><th>Disk</th><th>Status</th><th>Actions</th></tr></thead><tbody>
|
||||
${acctRows.map(a => `<tr>
|
||||
<td><strong>${Nova.escHtml(a.username)}</strong></td>
|
||||
<td>${Nova.escHtml(a.domain)}</td>
|
||||
<td>${a.package_name ? Nova.escHtml(a.package_name) : '—'}</td>
|
||||
<td>${a.disk_usage_mb || 0} MB</td>
|
||||
<td>${Nova.badge(a.status, a.status==='active'?'green':a.status==='suspended'?'yellow':'red')}</td>
|
||||
<td style="display:flex;gap:.25rem;flex-wrap:wrap">
|
||||
<button class="btn btn-xs btn-primary" onclick="rLoginAs(${a.user_id},'${Nova.escHtml(a.username)}')">Login As</button>
|
||||
${a.status === 'active'
|
||||
? `<button class="btn btn-xs btn-warning" onclick="rSuspend(${a.id},'${a.username}')">Suspend</button>`
|
||||
: `<button class="btn btn-xs btn-success" onclick="rUnsuspend(${a.id},'${a.username}')">Unsuspend</button>`}
|
||||
<button class="btn btn-xs" onclick="rChangePass(${a.id},'${a.username}')">Passwd</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="rTerminate(${a.id},'${a.username}')">Terminate</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
window.loadRAccounts = loadRAccounts;
|
||||
window.rSearchAccounts = (v) => loadRAccounts(v);
|
||||
|
||||
window.rLoginAs = async (userId, username) => {
|
||||
Nova.confirm(`Login as ${username}? You'll be taken to their panel. Use the banner to return.`, async () => {
|
||||
Nova.loading(`Switching to ${username}…`);
|
||||
const res = await Nova.api('auth', 'impersonate', { method: 'POST', body: { user_id: userId } });
|
||||
Nova.loadingDone();
|
||||
if (res?.success) {
|
||||
window.location.href = res.data?.portal_url || 'https://' + location.hostname + ':8880/';
|
||||
} else {
|
||||
Nova.toast(res?.message || 'Impersonation failed', 'error');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.rSuspend = async (id, user) => {
|
||||
Nova.confirm(`Suspend account ${user}? Their website will show a suspension page.`, async () => {
|
||||
const res = await Nova.api('accounts', 'suspend', { method:'POST', body:{ account_id: id }});
|
||||
if (res?.success) { Nova.toast('Account suspended','success'); loadRAccounts(); }
|
||||
else Nova.toast(res?.message,'error');
|
||||
});
|
||||
};
|
||||
window.rUnsuspend = async (id, user) => {
|
||||
const res = await Nova.api('accounts', 'unsuspend', { method:'POST', body:{ account_id: id }});
|
||||
if (res?.success) { Nova.toast('Account unsuspended','success'); loadRAccounts(); }
|
||||
else Nova.toast(res?.message,'error');
|
||||
};
|
||||
window.rTerminate = (id, user) => {
|
||||
Nova.confirm(`TERMINATE ${user}? This permanently deletes all files, databases, DNS, and email. THIS CANNOT BE UNDONE.`, async () => {
|
||||
const res = await Nova.api('accounts', 'terminate', { method:'POST', body:{ account_id: id }});
|
||||
if (res?.success) { Nova.toast('Account terminated','success'); loadRAccounts(); }
|
||||
else Nova.toast(res?.message,'error');
|
||||
}, true);
|
||||
};
|
||||
window.rChangePass = (id, user) => {
|
||||
Nova.modal(`Change Password — ${user}`, `<div class="form-group"><label class="form-label">New Password</label><input id="rp-pass" type="password" class="form-control"></div>`,
|
||||
`<button class="btn btn-primary" onclick="Nova.api('accounts','change-password',{method:'POST',body:{account_id:${id},password:document.getElementById('rp-pass').value}}).then(r=>{if(r?.success){Nova.toast('Password updated','success');document.querySelector('.modal-overlay').remove();}else Nova.toast(r?.message,'error');})">Update</button>`);
|
||||
};
|
||||
|
||||
async function rCreateAccount(el) {
|
||||
el.innerHTML = `<div class="page-header"><h2 class="page-title">Create Hosting Account</h2></div>
|
||||
<div class="card" style="max-width:600px">
|
||||
<div style="padding:1.5rem">
|
||||
<div class="form-group"><label class="form-label">Username <span style="color:var(--red)">*</span></label><input id="ca-user" class="form-control" placeholder="lowercase letters, numbers"></div>
|
||||
<div class="form-group"><label class="form-label">Password <span style="color:var(--red)">*</span></label><input id="ca-pass" type="password" class="form-control"></div>
|
||||
<div class="form-group"><label class="form-label">Email</label><input id="ca-email" type="email" class="form-control" placeholder="user@example.com"></div>
|
||||
<div class="form-group"><label class="form-label">Primary Domain <span style="color:var(--red)">*</span></label><input id="ca-domain" class="form-control" placeholder="example.com"></div>
|
||||
<div class="form-group"><label class="form-label">Package</label><select id="ca-pkg" class="form-control"><option value="">Loading…</option></select></div>
|
||||
<div style="margin-top:1.5rem;display:flex;gap:.75rem">
|
||||
<button class="btn btn-primary" onclick="submitCreateAccount()">Create Account</button>
|
||||
<button class="btn" onclick="resellerNav('accounts')">Cancel</button>
|
||||
</div>
|
||||
<div id="ca-result" style="margin-top:1rem"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
Nova.api('packages', 'list').then(res => {
|
||||
const sel = document.getElementById('ca-pkg');
|
||||
if (sel && res?.success) {
|
||||
sel.innerHTML = res.data.map(p => `<option value="${p.id}">${p.name} — ${p.disk_mb}MB disk</option>`).join('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.submitCreateAccount = async () => {
|
||||
const btn = document.querySelector('#ca-result');
|
||||
if (btn) btn.textContent = '';
|
||||
Nova.loading('Creating hosting account…');
|
||||
const res = await Nova.api('accounts', 'create', { method:'POST', body:{
|
||||
username: document.getElementById('ca-user')?.value,
|
||||
password: document.getElementById('ca-pass')?.value,
|
||||
email: document.getElementById('ca-email')?.value,
|
||||
domain: document.getElementById('ca-domain')?.value,
|
||||
package_id: document.getElementById('ca-pkg')?.value,
|
||||
}});
|
||||
Nova.loadingDone();
|
||||
if (res?.success) {
|
||||
Nova.toast('Account created successfully!','success');
|
||||
if (btn) btn.innerHTML = `<div class="alert alert-success">Account created! <a href="#" onclick="resellerNav('accounts')">View accounts →</a></div>`;
|
||||
} else {
|
||||
Nova.toast(res?.message || 'Failed to create account','error');
|
||||
if (btn) btn.innerHTML = `<div class="alert alert-error">${res?.message || 'Error'}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
async function rPackages(el) {
|
||||
el.innerHTML = `<div class="page-header">
|
||||
<h2 class="page-title">Packages</h2>
|
||||
<button class="btn btn-primary btn-sm" onclick="rAddPackage()">+ Add Package</button>
|
||||
</div>
|
||||
<div class="card"><div id="pkg-list"><div class="loading">Loading…</div></div></div>`;
|
||||
|
||||
const res = await Nova.api('packages', 'list');
|
||||
const plist = document.getElementById('pkg-list');
|
||||
if (!res?.success || !res.data.length) { plist.innerHTML = '<div class="empty">No packages yet.</div>'; return; }
|
||||
plist.innerHTML = `<table class="table"><thead><tr><th>Name</th><th>Disk</th><th>BW</th><th>DBs</th><th>Emails</th><th>Domains</th><th>Price</th><th>Actions</th></tr></thead><tbody>
|
||||
${res.data.map(p => `<tr>
|
||||
<td><strong>${p.name}</strong></td>
|
||||
<td>${p.disk_mb > 0 ? p.disk_mb+'MB' : '∞'}</td>
|
||||
<td>${p.bandwidth_mb > 0 ? p.bandwidth_mb+'MB' : '∞'}</td>
|
||||
<td>${p.databases || '∞'}</td>
|
||||
<td>${p.email_accounts || '∞'}</td>
|
||||
<td>${p.addon_domains || '∞'}</td>
|
||||
<td>${p.price ? '$'+p.price : 'Free'}</td>
|
||||
<td style="display:flex;gap:.25rem">
|
||||
<button class="btn btn-xs" onclick="rEditPackage(${p.id})">Edit</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="rDeletePackage(${p.id},'${p.name}')">Del</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
window.rAddPackage = () => showPackageModal();
|
||||
window.rEditPackage = async (id) => {
|
||||
const res = await Nova.api('packages', 'get', { params:{ id }});
|
||||
if (res?.success) showPackageModal(res.data);
|
||||
};
|
||||
function showPackageModal(pkg = null) {
|
||||
const p = pkg || {};
|
||||
Nova.modal(pkg ? 'Edit Package' : 'Add Package', `
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
|
||||
<div class="form-group" style="grid-column:1/-1"><label class="form-label">Package Name</label><input id="pk-name" class="form-control" value="${p.name||''}"></div>
|
||||
<div class="form-group"><label class="form-label">Disk (MB, 0=∞)</label><input id="pk-disk" type="number" class="form-control" value="${p.disk_mb||0}"></div>
|
||||
<div class="form-group"><label class="form-label">Bandwidth (MB)</label><input id="pk-bw" type="number" class="form-control" value="${p.bandwidth_mb||0}"></div>
|
||||
<div class="form-group"><label class="form-label">Databases</label><input id="pk-db" type="number" class="form-control" value="${p.databases||0}"></div>
|
||||
<div class="form-group"><label class="form-label">Email Accounts</label><input id="pk-email" type="number" class="form-control" value="${p.email_accounts||0}"></div>
|
||||
<div class="form-group"><label class="form-label">Addon Domains</label><input id="pk-adom" type="number" class="form-control" value="${p.addon_domains||0}"></div>
|
||||
<div class="form-group"><label class="form-label">Subdomains</label><input id="pk-sub" type="number" class="form-control" value="${p.subdomains||0}"></div>
|
||||
<div class="form-group"><label class="form-label">FTP Accounts</label><input id="pk-ftp" type="number" class="form-control" value="${p.ftp_accounts||0}"></div>
|
||||
<div class="form-group"><label class="form-label">Price ($/mo)</label><input id="pk-price" type="number" step="0.01" class="form-control" value="${p.price||0}"></div>
|
||||
</div>`,
|
||||
`<button class="btn btn-primary" onclick="submitPackage(${p.id||'null'})">Save</button>`);
|
||||
}
|
||||
window.submitPackage = async (id) => {
|
||||
const body = { name:document.getElementById('pk-name')?.value, disk_mb:parseInt(document.getElementById('pk-disk')?.value), bandwidth_mb:parseInt(document.getElementById('pk-bw')?.value), databases:parseInt(document.getElementById('pk-db')?.value), email_accounts:parseInt(document.getElementById('pk-email')?.value), addon_domains:parseInt(document.getElementById('pk-adom')?.value), subdomains:parseInt(document.getElementById('pk-sub')?.value), ftp_accounts:parseInt(document.getElementById('pk-ftp')?.value), price:parseFloat(document.getElementById('pk-price')?.value) };
|
||||
const res = id ? await Nova.api('packages','update',{method:'POST',body:{...body,id}}) : await Nova.api('packages','create',{method:'POST',body});
|
||||
if (res?.success) { Nova.toast(id ? 'Package updated' : 'Package created','success'); document.querySelector('.modal-overlay')?.remove(); rPackages(document.getElementById('page-content')); }
|
||||
else Nova.toast(res?.message,'error');
|
||||
};
|
||||
window.rDeletePackage = (id, name) => {
|
||||
Nova.confirm(`Delete package "${name}"? Cannot delete if accounts are using it.`, async () => {
|
||||
const res = await Nova.api('packages','delete',{method:'POST',body:{id}});
|
||||
if (res?.success) { Nova.toast('Deleted','success'); rPackages(document.getElementById('page-content')); }
|
||||
else Nova.toast(res?.message,'error');
|
||||
}, true);
|
||||
};
|
||||
|
||||
async function rDNS(el) {
|
||||
el.innerHTML = `<div class="page-header"><h2 class="page-title">DNS Zones</h2></div>
|
||||
<div class="card"><div id="r-dns-list"><div class="loading">Loading…</div></div></div>`;
|
||||
const res = await Nova.api('dns', 'zones');
|
||||
const list = document.getElementById('r-dns-list');
|
||||
if (!res?.success || !res.data.length) { list.innerHTML = '<div class="empty">No DNS zones.</div>'; return; }
|
||||
list.innerHTML = `<table class="table"><thead><tr><th>Domain</th><th>Account</th><th>Records</th><th>Actions</th></tr></thead><tbody>
|
||||
${res.data.map(z => `<tr>
|
||||
<td>${z.domain}</td>
|
||||
<td>${z.username||'—'}</td>
|
||||
<td>${z.record_count||0}</td>
|
||||
<td><button class="btn btn-xs" onclick="rViewZone(${z.id},'${z.domain}')">Edit Records</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
window.rViewZone = async (zoneId, domain) => {
|
||||
const res = await Nova.api('dns', 'records', { params:{ zone_id: zoneId }});
|
||||
if (!res?.success) { Nova.toast('Failed to load records','error'); return; }
|
||||
const rows = res.data.map(r => `<tr>
|
||||
<td>${r.name}</td><td>${Nova.badge(r.type,'default')}</td><td><code style="font-size:.8rem">${r.value}</code></td><td>${r.ttl}</td>
|
||||
<td><button class="btn btn-xs btn-danger" onclick="rDeleteRecord(${r.id},${zoneId},'${domain}')">Del</button></td>
|
||||
</tr>`).join('');
|
||||
Nova.modal(`DNS Records — ${domain}`,
|
||||
`<button class="btn btn-sm btn-primary" style="margin-bottom:.75rem" onclick="rAddRecord(${zoneId},'${domain}')">+ Add Record</button>
|
||||
<table class="table"><thead><tr><th>Name</th><th>Type</th><th>Value</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`);
|
||||
};
|
||||
window.rAddRecord = (zoneId, domain) => {
|
||||
Nova.modal('Add DNS Record', `
|
||||
<div class="form-group"><label class="form-label">Name</label><input id="dns-name" class="form-control" placeholder="@ or subdomain"></div>
|
||||
<div class="form-group"><label class="form-label">Type</label><select id="dns-type" class="form-control"><option>A</option><option>AAAA</option><option>CNAME</option><option>MX</option><option>TXT</option><option>NS</option><option>SRV</option></select></div>
|
||||
<div class="form-group"><label class="form-label">Value</label><input id="dns-val" class="form-control"></div>
|
||||
<div class="form-group"><label class="form-label">TTL</label><input id="dns-ttl" type="number" class="form-control" value="3600"></div>
|
||||
<div class="form-group"><label class="form-label">Priority (MX)</label><input id="dns-pri" type="number" class="form-control" value="10"></div>`,
|
||||
`<button class="btn btn-primary" onclick="Nova.api('dns','add-record',{method:'POST',body:{zone_id:${zoneId},name:document.getElementById('dns-name').value,type:document.getElementById('dns-type').value,value:document.getElementById('dns-val').value,ttl:parseInt(document.getElementById('dns-ttl').value),priority:parseInt(document.getElementById('dns-pri').value)}}).then(r=>{if(r?.success){Nova.toast('Record added','success');document.querySelector('.modal-overlay').remove();}else Nova.toast(r?.message,'error');})">Add Record</button>`);
|
||||
};
|
||||
window.rDeleteRecord = async (id, zoneId, domain) => {
|
||||
Nova.confirm('Delete this DNS record?', async () => {
|
||||
const res = await Nova.api('dns', 'delete-record', { method:'POST', body:{id, zone_id: zoneId }});
|
||||
if (res?.success) { Nova.toast('Deleted','success'); document.querySelector('.modal-overlay')?.remove(); rViewZone(zoneId, domain); }
|
||||
else Nova.toast(res?.message,'error');
|
||||
}, true);
|
||||
};
|
||||
|
||||
/* ── Nav ────────────────────────────────────────────────────────────────── */
|
||||
const rNavGroups = [
|
||||
{ label: 'Overview', items: [
|
||||
{ id: 'dashboard', label: 'Dashboard',
|
||||
svg: '<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>' },
|
||||
]},
|
||||
{ label: 'Accounts', items: [
|
||||
{ id: 'accounts', label: 'All Accounts',
|
||||
svg: '<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>' },
|
||||
{ id: 'createAccount', label: 'New Account',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="17" y1="11" x2="23" y2="11"/></svg>' },
|
||||
{ id: 'packages', label: 'Packages',
|
||||
svg: '<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"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>' },
|
||||
]},
|
||||
{ label: 'DNS', items: [
|
||||
{ id: 'dns', label: 'DNS Zones',
|
||||
svg: '<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>' },
|
||||
]},
|
||||
{ label: 'Tools', items: [
|
||||
{ id: 'docker', label: 'Docker',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="9" width="4" height="4"/><rect x="7" y="9" width="4" height="4"/><rect x="12" y="9" width="4" height="4"/><rect x="7" y="4" width="4" height="4"/><path d="M22 11c0 5-3.9 9-10 9-8 0-10-7-10-7"/></svg>' },
|
||||
{ id: 'whitelabel', label: 'White Label',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/></svg>' },
|
||||
]},
|
||||
];
|
||||
const rPages = { dashboard: rDashboard, accounts: rAccounts, createAccount: rCreateAccount, packages: rPackages, dns: rDNS, docker: rDocker, whitelabel: rWhiteLabel };
|
||||
|
||||
let _rActivePage = 'dashboard';
|
||||
|
||||
function renderRNav() {
|
||||
const nav = document.getElementById('sidebar-nav');
|
||||
if (!nav) return;
|
||||
nav.innerHTML = rNavGroups.map(g => `
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">${g.label}</div>
|
||||
${g.items.map(n => `
|
||||
<a href="#" class="sidebar-link${n.id === _rActivePage ? ' active' : ''}" data-page="${n.id}">
|
||||
${n.svg}
|
||||
${n.label}
|
||||
</a>`).join('')}
|
||||
</div>`).join('');
|
||||
|
||||
nav.querySelectorAll('[data-page]').forEach(link => {
|
||||
link.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
if (window.innerWidth <= 768) {
|
||||
document.getElementById('sidebar')?.classList.remove('open');
|
||||
document.getElementById('sidebar-overlay')?.classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
resellerNav(link.dataset.page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.resellerNav = (page) => {
|
||||
_rActivePage = page;
|
||||
renderRNav();
|
||||
const allItems = rNavGroups.flatMap(g => g.items);
|
||||
const item = allItems.find(n => n.id === page);
|
||||
const titleEl = document.getElementById('page-title');
|
||||
if (titleEl && item) titleEl.textContent = item.label;
|
||||
const content = document.getElementById('page-content');
|
||||
if (!content) return;
|
||||
content.innerHTML = '<div style="padding:2rem;color:var(--text-muted);text-align:center">Loading…</div>';
|
||||
if (rPages[page]) rPages[page](content);
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const ok = await initReseller();
|
||||
if (!ok) return;
|
||||
document.getElementById('logout-btn')?.addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
await Nova.api('auth', 'logout', { method: 'POST' });
|
||||
location.href = '/';
|
||||
});
|
||||
renderRNav();
|
||||
window.resellerNav('dashboard');
|
||||
});
|
||||
|
||||
/* ── Docker (Reseller #33) ────────────────────────────────────────────────── */
|
||||
async function rDocker(el) {
|
||||
el.innerHTML = '<div class="loading">Loading…</div>';
|
||||
const [stRes, acctRes] = await Promise.all([
|
||||
Nova.api('docker', 'stacks'),
|
||||
Nova.api('accounts', 'list', { params: { limit: 200 } }),
|
||||
]);
|
||||
const stacks = stRes?.data?.stacks || [];
|
||||
const accts = acctRes?.data || [];
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h2 class="page-title">Docker</h2></div>
|
||||
<p class="text-muted" style="margin-bottom:1.5rem">Manage Docker containers and quotas for your customers. Contact the server admin to change your own Docker allocation.</p>
|
||||
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
|
||||
<button class="btn btn-sm ${_rDockerTab==='containers'?'btn-primary':'btn-ghost'}" onclick="rDockerTab('containers')">Containers</button>
|
||||
<button class="btn btn-sm ${_rDockerTab==='quotas'?'btn-primary':'btn-ghost'}" onclick="rDockerTab('quotas')">Customer Quotas</button>
|
||||
<button class="btn btn-sm ${_rDockerTab==='catalog'?'btn-primary':'btn-ghost'}" onclick="rDockerTab('catalog')">App Catalog</button>
|
||||
</div>
|
||||
<div id="rdocker-content"><div class="loading">Loading…</div></div>`;
|
||||
|
||||
window._rDockerAccts = accts;
|
||||
window._rDockerTab = window._rDockerTab || 'containers';
|
||||
|
||||
window.rDockerTab = async (tab) => {
|
||||
window._rDockerTab = tab;
|
||||
document.querySelectorAll('[onclick^="rDockerTab"]').forEach(b => {
|
||||
const t = b.getAttribute('onclick').match(/'([^']+)'/)?.[1];
|
||||
b.className = 'btn btn-sm ' + (t === tab ? 'btn-primary' : 'btn-ghost');
|
||||
});
|
||||
await rDockerLoadTab(tab);
|
||||
};
|
||||
|
||||
await rDockerLoadTab(window._rDockerTab);
|
||||
}
|
||||
|
||||
window._rDockerTab = 'containers';
|
||||
|
||||
async function rDockerLoadTab(tab) {
|
||||
const tc = document.getElementById('rdocker-content');
|
||||
if (!tc) return;
|
||||
tc.innerHTML = '<div class="loading">Loading…</div>';
|
||||
|
||||
if (tab === 'containers') {
|
||||
const r = await Nova.api('docker', 'containers');
|
||||
const rows = r?.data?.containers || [];
|
||||
tc.innerHTML = rows.length === 0
|
||||
? '<div class="text-muted" style="padding:2rem;text-align:center">No containers for your accounts</div>'
|
||||
: `<div style="overflow-x:auto"><table class="table"><thead><tr><th>Name</th><th>Image</th><th>Status</th><th>Account</th><th>Actions</th></tr></thead><tbody>
|
||||
${rows.map(c=>`<tr>
|
||||
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(c.name)}</td>
|
||||
<td style="font-size:.82rem">${Nova.escHtml(c.image)}</td>
|
||||
<td>${Nova.badge(c.status,c.status==='running'?'green':'red')}</td>
|
||||
<td>${c.account_id||'—'}</td>
|
||||
<td>
|
||||
${c.status==='running'
|
||||
? `<button class="btn btn-xs btn-warning" onclick="rDockerAct('${Nova.escHtml(c.container_id||'')}','stop')">Stop</button>`
|
||||
: `<button class="btn btn-xs btn-success" onclick="rDockerAct('${Nova.escHtml(c.container_id||'')}','start')">Start</button>`}
|
||||
<button class="btn btn-xs btn-ghost" onclick="rDockerLogs('${Nova.escHtml(c.container_id||'')}','${Nova.escHtml(c.name)}')">Logs</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`;
|
||||
|
||||
} else if (tab === 'quotas') {
|
||||
const accts = window._rDockerAccts || [];
|
||||
tc.innerHTML = accts.length === 0
|
||||
? '<div class="text-muted" style="padding:2rem;text-align:center">No accounts</div>'
|
||||
: `<p class="text-muted" style="margin-bottom:1rem">Set Docker limits for each of your customers.</p>
|
||||
<div style="overflow-x:auto"><table class="table"><thead><tr><th>Username</th><th>Max Containers</th><th>Max Memory</th><th>Max CPUs</th><th>Actions</th></tr></thead><tbody>
|
||||
${accts.map(u=>`<tr>
|
||||
<td>${Nova.escHtml(u.username)}</td>
|
||||
<td>2</td><td>512 MB</td><td>1.0</td>
|
||||
<td><button class="btn btn-xs btn-primary" onclick="rDockerQuotaModal(${u.user_id},'${Nova.escHtml(u.username)}')">Edit</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`;
|
||||
|
||||
} else if (tab === 'catalog') {
|
||||
const r = await Nova.api('docker', 'catalog');
|
||||
const catalog = r?.data?.catalog || {};
|
||||
const accts = window._rDockerAccts || [];
|
||||
tc.innerHTML = `
|
||||
<p class="text-muted" style="margin-bottom:1rem">Pre-install app stacks for your customers.</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:1rem">
|
||||
${Object.entries(catalog).map(([key,app])=>`
|
||||
<div class="card" style="cursor:pointer" onclick="rDockerLaunchModal('${key}','${Nova.escHtml(app.name)}')">
|
||||
<div class="card-body" style="text-align:center;padding:1.5rem">
|
||||
<div style="font-size:1.5rem;font-weight:700;margin-bottom:.5rem;color:var(--primary)">${Nova.escHtml(app.icon)}</div>
|
||||
<div style="font-weight:600">${Nova.escHtml(app.name)}</div>
|
||||
<div style="font-size:.8rem;color:var(--text-muted);margin-top:.25rem">${Nova.escHtml(app.description)}</div>
|
||||
<button class="btn btn-sm btn-primary" style="margin-top:1rem">Deploy</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
window.rDockerAct = async (cid, action) => {
|
||||
Nova.loading(`${action.charAt(0).toUpperCase()+action.slice(1)}ing container…`);
|
||||
const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } });
|
||||
Nova.loadingDone();
|
||||
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message||'Failed'), r?.success?'success':'error');
|
||||
if (r?.success) rDockerLoadTab('containers');
|
||||
};
|
||||
|
||||
window.rDockerLogs = async (cid, name) => {
|
||||
const r = await Nova.api('docker', 'container-logs', { params: { container_id: cid, lines: 100 } });
|
||||
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(r?.data?.logs||'')}</pre>`);
|
||||
};
|
||||
|
||||
window.rDockerQuotaModal = (userId, username) => {
|
||||
const ov = Nova.modal(`Docker Quota: ${username}`,
|
||||
`<div class="form-group"><label>Max Containers</label><input id="rdq-cnt" type="number" class="form-control" value="2" min="0"></div>
|
||||
<div class="form-group"><label>Max Memory (MB)</label><input id="rdq-mem" type="number" class="form-control" value="512" min="64"></div>
|
||||
<div class="form-group"><label>Max CPUs</label><input id="rdq-cpus" type="number" step="0.5" class="form-control" value="1.0" min="0.1"></div>`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="rDockerQuotaSubmit(${userId})">Save</button>`
|
||||
);
|
||||
window.rDockerQuotaSubmit = async (uid) => {
|
||||
ov.remove();
|
||||
const r = await Nova.api('docker', 'quota-set', { method:'POST', body:{
|
||||
user_id: uid,
|
||||
max_containers: parseInt(document.getElementById('rdq-cnt').value)||2,
|
||||
max_memory_mb: parseInt(document.getElementById('rdq-mem').value)||512,
|
||||
max_cpus: parseFloat(document.getElementById('rdq-cpus').value)||1.0,
|
||||
}});
|
||||
Nova.toast(r?.success?'Quota saved':(r?.message||'Failed'),r?.success?'success':'error');
|
||||
};
|
||||
};
|
||||
|
||||
window.rDockerLaunchModal = async (appKey, appName) => {
|
||||
const catRes = await Nova.api('docker', 'catalog');
|
||||
const app = catRes?.data?.catalog?.[appKey];
|
||||
if (!app) return;
|
||||
const accts = window._rDockerAccts || [];
|
||||
const acctOpts = accts.map(a=>`<option value="${a.id}">${Nova.escHtml(a.username)}</option>`).join('');
|
||||
const paramFields = (app.params||[]).map(p=>`
|
||||
<div class="form-group"><label>${Nova.escHtml(p.label)}${p.required?' *':''}</label>
|
||||
<input id="rl-${Nova.escHtml(p.key)}" type="${p.type||'text'}" class="form-control" ${p.placeholder?`placeholder="${Nova.escHtml(p.placeholder)}"`:''}></div>`).join('');
|
||||
const ov = Nova.modal(`Deploy ${appName}`,
|
||||
`<div class="form-group"><label>Account</label><select id="rl-acct" class="form-control"><option value="">Select account</option>${acctOpts}</select></div>${paramFields}`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="rDockerLaunchSubmit('${appKey}')">Deploy</button>`
|
||||
);
|
||||
window.rDockerLaunchSubmit = async (key) => {
|
||||
const acctId = parseInt(document.getElementById('rl-acct').value)||0;
|
||||
if (!acctId) { Nova.toast('Select an account','error'); return; }
|
||||
const params = {};
|
||||
(app.params||[]).forEach(p => { params[p.key] = document.getElementById('rl-'+p.key)?.value||''; });
|
||||
ov.remove();
|
||||
Nova.loading(`Deploying ${appName}… this may take a minute`);
|
||||
const r = await Nova.api('docker', 'launch', { method:'POST', body:{ account_id: acctId, app_key: key, params }});
|
||||
Nova.loadingDone();
|
||||
Nova.toast(r?.success?`${appName} deployed!`:(r?.message||'Deploy failed'), r?.success?'success':'error');
|
||||
if (r?.success) rDockerLoadTab('containers');
|
||||
};
|
||||
};
|
||||
|
||||
// ── White Label / Branding (#18) ────────────────────────────────────────────
|
||||
async function rWhiteLabel(el) {
|
||||
el.innerHTML = '<div class="page-loader">Loading…</div>';
|
||||
const res = await Nova.api('branding', 'get');
|
||||
const b = res?.data || {};
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h1 class="page-title">White Label Branding</h1></div>
|
||||
<div class="grid-2" style="gap:1.5rem;align-items:start">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Panel Identity</span></div>
|
||||
<div class="card-body" style="display:flex;flex-direction:column;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label>Panel Name</label>
|
||||
<input id="wl-name" class="form-control" value="${Nova.escHtml(b.panel_name||'NovaCPX')}" placeholder="NovaCPX">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Logo</label>
|
||||
${b.logo_url ? `<div style="margin-bottom:.5rem"><img src="${Nova.escHtml(b.logo_url)}" style="max-height:50px;max-width:200px;border-radius:6px;background:var(--bg2);padding:.5rem"></div>` : ''}
|
||||
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
|
||||
<label class="btn btn-ghost btn-sm" style="cursor:pointer">
|
||||
Upload Logo <input type="file" id="wl-logo-file" accept="image/*" style="display:none" onchange="rWlUploadLogo()">
|
||||
</label>
|
||||
${b.logo_url ? `<button class="btn btn-ghost btn-sm" onclick="rWlDeleteLogo()" style="color:var(--danger)">Remove</button>` : ''}
|
||||
<span class="text-muted text-sm">PNG/SVG/JPG · max 512 KB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Custom CSS <span class="text-muted text-sm">(advanced)</span></label>
|
||||
<textarea id="wl-css" class="form-control" rows="4" style="font-family:monospace;font-size:.8rem" placeholder="/* e.g. .sidebar { background: #1a1a2e; } */">${Nova.escHtml(b.custom_css||'')}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:1.5rem">
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Colors</span></div>
|
||||
<div class="card-body" style="display:flex;flex-direction:column;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label>Primary Color</label>
|
||||
<div style="display:flex;gap:.5rem;align-items:center">
|
||||
<input type="color" id="wl-primary" value="${Nova.escHtml(b.primary_color||'#6366f1')}" style="width:48px;height:36px;padding:2px;border-radius:6px;border:1px solid var(--border);background:var(--bg2);cursor:pointer">
|
||||
<input type="text" id="wl-primary-hex" class="form-control" style="width:110px;font-family:monospace" value="${Nova.escHtml(b.primary_color||'#6366f1')}" maxlength="7">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Accent Color</label>
|
||||
<div style="display:flex;gap:.5rem;align-items:center">
|
||||
<input type="color" id="wl-accent" value="${Nova.escHtml(b.accent_color||'#0ea5e9')}" style="width:48px;height:36px;padding:2px;border-radius:6px;border:1px solid var(--border);background:var(--bg2);cursor:pointer">
|
||||
<input type="text" id="wl-accent-hex" class="form-control" style="width:110px;font-family:monospace" value="${Nova.escHtml(b.accent_color||'#0ea5e9')}" maxlength="7">
|
||||
</div>
|
||||
</div>
|
||||
<div id="wl-color-preview" style="height:40px;border-radius:8px;background:linear-gradient(135deg,${Nova.escHtml(b.primary_color||'#6366f1')},${Nova.escHtml(b.accent_color||'#0ea5e9')});transition:background .3s"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Support</span></div>
|
||||
<div class="card-body" style="display:flex;flex-direction:column;gap:1rem">
|
||||
<div class="form-group">
|
||||
<label>Support Email</label>
|
||||
<input id="wl-email" class="form-control" type="email" value="${Nova.escHtml(b.support_email||'')}" placeholder="support@yourdomain.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Support URL</label>
|
||||
<input id="wl-url" class="form-control" type="url" value="${Nova.escHtml(b.support_url||'')}" placeholder="https://support.yourdomain.com">
|
||||
</div>
|
||||
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer">
|
||||
<input type="checkbox" id="wl-hide-powered" ${b.hide_powered_by ? 'checked' : ''}>
|
||||
Hide "Powered by NovaCPX" in panel footer
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.5rem;justify-content:flex-end">
|
||||
<button class="btn btn-ghost" onclick="rWhiteLabel(document.getElementById('page-content'))">Reset</button>
|
||||
<button class="btn btn-primary" onclick="rWlSave()">Save Branding</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Sync color pickers ↔ hex inputs ↔ preview
|
||||
['primary','accent'].forEach(k => {
|
||||
const picker = document.getElementById('wl-'+k);
|
||||
const hex = document.getElementById('wl-'+k+'-hex');
|
||||
const sync = () => {
|
||||
if (picker) hex.value = picker.value;
|
||||
rWlUpdatePreview();
|
||||
};
|
||||
const syncBack = () => {
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(hex.value)) { picker.value = hex.value; rWlUpdatePreview(); }
|
||||
};
|
||||
picker?.addEventListener('input', sync);
|
||||
hex?.addEventListener('input', syncBack);
|
||||
});
|
||||
}
|
||||
|
||||
function rWlUpdatePreview() {
|
||||
const p = document.getElementById('wl-primary-hex')?.value || '#6366f1';
|
||||
const a = document.getElementById('wl-accent-hex')?.value || '#0ea5e9';
|
||||
const el = document.getElementById('wl-color-preview');
|
||||
if (el) el.style.background = `linear-gradient(135deg,${p},${a})`;
|
||||
// Live-preview CSS vars
|
||||
const style = document.getElementById('reseller-branding') || (() => {
|
||||
const s = document.createElement('style'); s.id = 'reseller-branding'; document.head.appendChild(s); return s;
|
||||
})();
|
||||
style.textContent = `:root { --primary: ${p}; --primary-dark: ${p}; --accent: ${a}; }`;
|
||||
}
|
||||
|
||||
window.rWlUploadLogo = async () => {
|
||||
const file = document.getElementById('wl-logo-file')?.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 512 * 1024) { Nova.toast('Logo must be under 512 KB', 'error'); return; }
|
||||
const fd = new FormData();
|
||||
fd.append('logo', file);
|
||||
Nova.toast('Uploading…', 'info', 5000);
|
||||
try {
|
||||
const res = await fetch('/api/branding/upload-logo', {
|
||||
method: 'POST', credentials: 'include', body: fd
|
||||
});
|
||||
const data = await res.json();
|
||||
Nova.toast(data?.success ? 'Logo uploaded' : (data?.message || 'Upload failed'),
|
||||
data?.success ? 'success' : 'error');
|
||||
if (data?.success) rWhiteLabel(document.getElementById('page-content'));
|
||||
} catch (e) { Nova.toast('Upload failed', 'error'); }
|
||||
};
|
||||
|
||||
window.rWlDeleteLogo = async () => {
|
||||
const r = await Nova.api('branding', 'delete-logo', { method: 'POST' });
|
||||
Nova.toast(r?.success ? 'Logo removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) rWhiteLabel(document.getElementById('page-content'));
|
||||
};
|
||||
|
||||
window.rWlSave = async () => {
|
||||
const body = {
|
||||
panel_name: document.getElementById('wl-name')?.value?.trim() || 'NovaCPX',
|
||||
primary_color: document.getElementById('wl-primary-hex')?.value || '#6366f1',
|
||||
accent_color: document.getElementById('wl-accent-hex')?.value || '#0ea5e9',
|
||||
support_email: document.getElementById('wl-email')?.value?.trim() || '',
|
||||
support_url: document.getElementById('wl-url')?.value?.trim() || '',
|
||||
hide_powered_by: document.getElementById('wl-hide-powered')?.checked ? 1 : 0,
|
||||
custom_css: document.getElementById('wl-css')?.value || '',
|
||||
};
|
||||
Nova.loading('Saving branding…');
|
||||
const r = await Nova.api('branding', 'save', { method: 'POST', body });
|
||||
Nova.loadingDone();
|
||||
Nova.toast(r?.success ? 'Branding saved — reload to see changes' : (r?.message || 'Save failed'),
|
||||
r?.success ? 'success' : 'error');
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
<?php http_response_code(404); ?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>404 — Page Not Found · NovaCPX</title>
|
||||
<style>
|
||||
:root{--bg:#0d0f17;--bg2:#131520;--border:#252840;--text:#e2e4f0;--text-muted:#7c7f9a;--primary:#6366f1;--red:#ef4444}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Inter',system-ui,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
||||
background-image:radial-gradient(ellipse at 30% 20%,rgba(99,102,241,.12) 0%,transparent 60%),radial-gradient(ellipse at 80% 80%,rgba(239,68,68,.07) 0%,transparent 60%)}
|
||||
.wrap{text-align:center;padding:2rem;max-width:480px}
|
||||
.code{font-size:7rem;font-weight:900;line-height:1;background:linear-gradient(135deg,var(--primary),#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.5rem}
|
||||
h1{font-size:1.5rem;font-weight:600;margin-bottom:.75rem}
|
||||
p{color:var(--text-muted);margin-bottom:2rem;line-height:1.6}
|
||||
.btn{display:inline-flex;align-items:center;gap:.5rem;padding:.65rem 1.5rem;background:var(--primary);color:#fff;border-radius:10px;text-decoration:none;font-weight:500;font-size:.9rem}
|
||||
.btn:hover{opacity:.85}
|
||||
.logo{display:flex;align-items:center;justify-content:center;gap:.5rem;margin-bottom:2.5rem;opacity:.6}
|
||||
.logo svg{width:28px;height:28px}
|
||||
.logo-text{font-size:1.1rem;font-weight:300}
|
||||
.logo-text strong{font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#g1)" stroke-width="2"/><path d="M12 28L20 8l8 20" stroke="url(#g2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22h12" stroke="url(#g2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="g1" x1="2" y1="2" x2="38" y2="38"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient><linearGradient id="g2" x1="12" y1="8" x2="28" y2="28"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient></defs></svg>
|
||||
<div class="logo-text">Nova<strong>CPX</strong></div>
|
||||
</div>
|
||||
<div class="code">404</div>
|
||||
<h1>Page Not Found</h1>
|
||||
<p>The page you're looking for doesn't exist or has been moved.</p>
|
||||
<a href="javascript:history.back()" class="btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
|
||||
Go Back
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php http_response_code(500); ?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>500 — Server Error · NovaCPX</title>
|
||||
<style>
|
||||
:root{--bg:#0d0f17;--bg2:#131520;--border:#252840;--text:#e2e4f0;--text-muted:#7c7f9a;--primary:#6366f1;--red:#ef4444}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Inter',system-ui,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
||||
background-image:radial-gradient(ellipse at 30% 20%,rgba(239,68,68,.1) 0%,transparent 60%),radial-gradient(ellipse at 80% 80%,rgba(99,102,241,.07) 0%,transparent 60%)}
|
||||
.wrap{text-align:center;padding:2rem;max-width:480px}
|
||||
.code{font-size:7rem;font-weight:900;line-height:1;background:linear-gradient(135deg,#ef4444,#f59e0b);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.5rem}
|
||||
h1{font-size:1.5rem;font-weight:600;margin-bottom:.75rem}
|
||||
p{color:var(--text-muted);margin-bottom:2rem;line-height:1.6}
|
||||
.btn{display:inline-flex;align-items:center;gap:.5rem;padding:.65rem 1.5rem;background:var(--primary);color:#fff;border-radius:10px;text-decoration:none;font-weight:500;font-size:.9rem}
|
||||
.btn:hover{opacity:.85}
|
||||
.logo{display:flex;align-items:center;justify-content:center;gap:.5rem;margin-bottom:2.5rem;opacity:.6}
|
||||
.logo svg{width:28px;height:28px}
|
||||
.logo-text{font-size:1.1rem;font-weight:300}
|
||||
.logo-text strong{font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#g1)" stroke-width="2"/><path d="M12 28L20 8l8 20" stroke="url(#g2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22h12" stroke="url(#g2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="g1" x1="2" y1="2" x2="38" y2="38"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient><linearGradient id="g2" x1="12" y1="8" x2="28" y2="28"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#0ea5e9"/></linearGradient></defs></svg>
|
||||
<div class="logo-text">Nova<strong>CPX</strong></div>
|
||||
</div>
|
||||
<div class="code">500</div>
|
||||
<h1>Internal Server Error</h1>
|
||||
<p>Something went wrong on our end. The issue has been logged. Please try again in a moment.</p>
|
||||
<a href="javascript:history.back()" class="btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
|
||||
Go Back
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+130
@@ -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');
|
||||
|
||||
// Each role redirects to its dedicated port
|
||||
const dest = REDIRECT || data.data.portal_url || '/';
|
||||
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>
|
||||
@@ -25,41 +25,61 @@ class AccountManager {
|
||||
$docRoot = "{$homeDir}/public_html";
|
||||
$password = $data['password'] ?? bin2hex(random_bytes(8));
|
||||
|
||||
// Create Linux user
|
||||
// Create Linux user and home directory first
|
||||
self::shell("useradd -m -d {$homeDir} -s /sbin/nologin -G www-data " . escapeshellarg($username));
|
||||
self::shell("echo " . escapeshellarg("{$username}:{$password}") . " | chpasswd");
|
||||
self::shell("echo " . escapeshellarg("{$username}:{$password}") . " | sudo chpasswd");
|
||||
self::shell("sudo mkdir -p {$docRoot} {$homeDir}/logs {$homeDir}/tmp");
|
||||
self::shell("sudo chown -R {$username}:www-data {$homeDir}");
|
||||
self::shell("sudo chmod 750 {$homeDir}"); self::shell("sudo chmod 775 {$docRoot}");
|
||||
self::shell("sudo chmod 750 {$homeDir}");
|
||||
self::shell("sudo chmod 775 {$docRoot}");
|
||||
|
||||
// Default index page
|
||||
file_put_contents("{$docRoot}/index.html",
|
||||
"<html><body style='font-family:sans-serif;text-align:center;padding:4rem'><h1>Welcome to {$domain}</h1><p>Hosted by NovaCPX</p></body></html>"
|
||||
);
|
||||
// Default index page — use custom template from settings if set, else built-in
|
||||
$customTpl = null;
|
||||
try {
|
||||
$db2 = DB::getInstance();
|
||||
$tplRow = $db2->fetchOne("SELECT value FROM settings WHERE key='default_index_template'");
|
||||
$customTpl = $tplRow ? trim($tplRow['value']) : null;
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
// Save account to DB
|
||||
$acctId = (int)$db->insert(
|
||||
"INSERT INTO accounts (user_id, username, domain, home_dir, package_id, php_version, web_server) VALUES (?,?,?,?,?,?,?)",
|
||||
[$userId, $username, $domain, $homeDir, $pkgId ?: null, $phpVer, $webSrv]
|
||||
);
|
||||
$html = $customTpl
|
||||
? str_replace(['{domain}', '{username}'], [$domain, $username], $customTpl)
|
||||
: "<!DOCTYPE html>\n<html lang=\"en\">\n<head><meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>Welcome to {$domain}</title>\n<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f1117;color:#e2e4f0;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center}.wrap{padding:3rem 2rem}.domain{font-size:2rem;font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin-bottom:1rem}.sub{color:#8b90a8;font-size:1rem;margin-bottom:2rem}.badge{display:inline-block;padding:.4rem 1rem;border:1px solid #2e3350;border-radius:6px;font-size:.8rem;color:#8b90a8}</style>\n</head>\n<body><div class=\"wrap\">\n<div class=\"domain\">{$domain}</div>\n<p class=\"sub\">Your website is ready. Upload your files to get started.</p>\n<span class=\"badge\">Hosted by NovaCPX</span>\n</div></body></html>";
|
||||
|
||||
// Save domain
|
||||
$db->insert(
|
||||
"INSERT INTO domains (account_id, domain, type, document_root) VALUES (?,?,?,?)",
|
||||
[$acctId, $domain, 'main', $docRoot]
|
||||
);
|
||||
self::shell("sudo tee " . escapeshellarg("{$docRoot}/index.html") . " > /dev/null << 'HTMLEOF'\n{$html}\nHTMLEOF");
|
||||
|
||||
// Create web vhost
|
||||
VhostManager::create($username, $domain, $docRoot, $phpVer);
|
||||
// Wrap all DB writes in a transaction so partial failures leave no orphans
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$acctId = (int)$db->insert(
|
||||
"INSERT INTO accounts (user_id, username, domain, home_dir, package_id, php_version, web_server) VALUES (?,?,?,?,?,?,?)",
|
||||
[$userId, $username, $domain, $homeDir, $pkgId ?: null, $phpVer, $webSrv]
|
||||
);
|
||||
|
||||
// Create DNS zone
|
||||
DNSManager::createZone($acctId, $domain);
|
||||
$db->insert(
|
||||
"INSERT INTO domains (account_id, domain, type, document_root) VALUES (?,?,?,?)",
|
||||
[$acctId, $domain, 'main', $docRoot]
|
||||
);
|
||||
|
||||
// Auto-provision SPF, DKIM, DMARC records
|
||||
self::provisionEmailDNS($acctId, $domain);
|
||||
// Create web vhost
|
||||
VhostManager::create($username, $domain, $docRoot, $phpVer);
|
||||
|
||||
// Create PHP-FPM pool
|
||||
PHPManager::createPool($username, $phpVer);
|
||||
// Create DNS zone
|
||||
DNSManager::createZone($acctId, $domain);
|
||||
|
||||
// Auto-provision SPF, DKIM, DMARC records
|
||||
self::provisionEmailDNS($acctId, $domain);
|
||||
|
||||
// Create PHP-FPM pool
|
||||
PHPManager::createPool($username, $phpVer);
|
||||
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
$db->rollBack();
|
||||
// Clean up Linux user and PHP-FPM pool so orphaned configs can't crash php-fpm
|
||||
self::shell("userdel -r " . escapeshellarg($username) . " 2>/dev/null || true");
|
||||
PHPManager::removePool($username);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
novacpx_log('info', "Account created: $username ($domain)");
|
||||
return ['account_id' => $acctId, 'username' => $username, 'domain' => $domain, 'home_dir' => $homeDir];
|
||||
@@ -87,6 +107,7 @@ class AccountManager {
|
||||
}
|
||||
|
||||
public static function terminate(int $acctId): void {
|
||||
require_once NOVACPX_LIB . '/DatabaseManager.php';
|
||||
$db = DB::getInstance();
|
||||
$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$acctId]);
|
||||
if (!$acct) throw new RuntimeException("Account not found");
|
||||
|
||||
+24
-2
@@ -150,13 +150,35 @@ class Auth {
|
||||
* Used by login redirect so each role lands on the right port
|
||||
*/
|
||||
public static function portalUrl(string $role, string $path = '/'): string {
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
|
||||
// Allowlist of trusted hostnames — prevents open redirect via Host header injection
|
||||
$allowed = [
|
||||
'novacpx.orbishosting.com',
|
||||
'admin.novacpx.orbishosting.com',
|
||||
'reseller.novacpx.orbishosting.com',
|
||||
'panel.novacpx.orbishosting.com',
|
||||
'web.orbishosting.com',
|
||||
];
|
||||
$hostname = preg_replace('/:\d+$/', '', $host);
|
||||
|
||||
// Trusted proxy (no port) — stay on same host
|
||||
if ($host && !preg_match('/:\d+$/', $host) && in_array($hostname, $allowed, true)) {
|
||||
return "https://{$hostname}{$path}";
|
||||
}
|
||||
|
||||
// Direct port access — validate hostname is a known server IP or allowed host
|
||||
$port = match($role) {
|
||||
'admin' => PORT_ADMIN,
|
||||
'reseller' => PORT_RESELLER,
|
||||
default => PORT_USER,
|
||||
};
|
||||
return "https://{$hostname}:{$port}{$path}";
|
||||
// Only redirect to localhost/LAN IPs on direct panel access
|
||||
if ($hostname && (filter_var($hostname, FILTER_VALIDATE_IP) || in_array($hostname, $allowed, true))) {
|
||||
return "https://{$hostname}:{$port}{$path}";
|
||||
}
|
||||
|
||||
// Fallback — safe relative path with no host (works for same-origin redirects)
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
+1671
-5
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,8 @@ php_value[max_execution_time] = 30
|
||||
public static function removePool(string $username): void {
|
||||
foreach (['7.4','8.1','8.2','8.3'] as $ver) {
|
||||
$file = str_replace('{ver}', $ver, self::$poolDir) . "/{$username}.conf";
|
||||
if (file_exists($file)) { shell_exec("sudo rm -f " . escapeshellarg($file)); self::reloadFPM($ver); }
|
||||
shell_exec("sudo /bin/rm -f " . escapeshellarg($file) . " 2>/dev/null");
|
||||
self::reloadFPM($ver);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +63,8 @@ php_value[max_execution_time] = 30
|
||||
|
||||
// Remove old pool, create new one
|
||||
$oldPool = str_replace('{ver}', $oldVer, self::$poolDir) . "/{$acct['username']}.conf";
|
||||
if (file_exists($oldPool)) { unlink($oldPool); self::reloadFPM($oldVer); }
|
||||
shell_exec("sudo rm -f " . escapeshellarg($oldPool) . " 2>/dev/null");
|
||||
self::reloadFPM($oldVer);
|
||||
|
||||
self::createPool($acct['username'], $newVer);
|
||||
|
||||
@@ -97,10 +99,12 @@ php_value[max_execution_time] = 30
|
||||
self::reloadFPM($acct['php_version']);
|
||||
|
||||
$db->execute(
|
||||
"INSERT INTO php_configs (account_id, php_version, memory_limit, max_execution_time, upload_max_filesize, post_max_size)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE memory_limit=VALUES(memory_limit), max_execution_time=VALUES(max_execution_time),
|
||||
upload_max_filesize=VALUES(upload_max_filesize), post_max_size=VALUES(post_max_size), updated_at=NOW()",
|
||||
"INSERT INTO php_configs (account_id, php_version, memory_limit, max_execution_time, upload_max_filesize, post_max_size, updated_at)
|
||||
VALUES (?,?,?,?,?,?,datetime('now'))
|
||||
ON CONFLICT(account_id) DO UPDATE SET php_version=excluded.php_version,
|
||||
memory_limit=excluded.memory_limit, max_execution_time=excluded.max_execution_time,
|
||||
upload_max_filesize=excluded.upload_max_filesize, post_max_size=excluded.post_max_size,
|
||||
updated_at=excluded.updated_at",
|
||||
[$accountId, $acct['php_version'], $cfg['memory_limit'] ?? '256M', $cfg['max_execution_time'] ?? 30,
|
||||
$cfg['upload_max_filesize'] ?? '64M', $cfg['post_max_size'] ?? '64M']
|
||||
);
|
||||
@@ -112,6 +116,8 @@ php_value[max_execution_time] = 30
|
||||
}
|
||||
|
||||
private static function reloadFPM(string $ver): void {
|
||||
shell_exec("systemctl reload php{$ver}-fpm 2>/dev/null || true");
|
||||
// Write a flag file instead of reloading inline — the cron runner picks this up
|
||||
// within 60s and reloads fpm outside of any HTTP request, avoiding 502s.
|
||||
@file_put_contents('/tmp/novacpx-fpm-reload-' . $ver, '1');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ class WordPressManager {
|
||||
$stagingRoot = dirname($docRoot) . rtrim($stagingPath, '/');
|
||||
|
||||
// Copy files
|
||||
$this->exec("cp -r {$docRoot} {$stagingRoot}");
|
||||
$this->exec("cp -r " . escapeshellarg($docRoot) . " " . escapeshellarg($stagingRoot));
|
||||
|
||||
// Clone DB
|
||||
$stagingDb = $install['db_name'] . '_staging';
|
||||
@@ -119,7 +119,7 @@ class WordPressManager {
|
||||
$this->getProvDb()->exec("CREATE DATABASE IF NOT EXISTS `{$stagingDb}`");
|
||||
$this->getProvDb()->exec("CREATE USER IF NOT EXISTS '{$stagingDb}'@'localhost' IDENTIFIED BY '{$stagingDbPw}'");
|
||||
$this->getProvDb()->exec("GRANT ALL ON `{$stagingDb}`.* TO '{$stagingDb}'@'localhost'");
|
||||
$this->exec("mysqldump {$install['db_name']} | mysql {$stagingDb}");
|
||||
$this->exec("mysqldump " . escapeshellarg($install['db_name']) . " | mysql " . escapeshellarg($stagingDb));
|
||||
|
||||
// Update staging wp-config
|
||||
$this->wp($stagingRoot, "config set DB_NAME {$stagingDb}", $sysUser);
|
||||
@@ -141,10 +141,11 @@ class WordPressManager {
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
public function delete(int $id): bool {
|
||||
[$install, $sysUser, $docRoot] = $this->resolve($id);
|
||||
$this->exec("rm -rf {$docRoot}");
|
||||
// Remove DB record first so a failed filesystem cleanup doesn't leave a phantom record
|
||||
$this->db->prepare("DELETE FROM wordpress_installs WHERE id=?")->execute([$id]);
|
||||
$this->exec("rm -rf " . escapeshellarg($docRoot));
|
||||
$this->getProvDb()->exec("DROP DATABASE IF EXISTS `{$install['db_name']}`");
|
||||
$this->getProvDb()->exec("DROP USER IF EXISTS '{$install['db_user']}'@'localhost'");
|
||||
$this->db->prepare("DELETE FROM wordpress_installs WHERE id=?")->execute([$id]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -245,7 +246,12 @@ class WordPressManager {
|
||||
|
||||
private function ensureWpCli(): void {
|
||||
if (!file_exists($this->wpcli)) {
|
||||
file_put_contents('/tmp/wp-cli.phar', file_get_contents('https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar'));
|
||||
$ctx = stream_context_create(['http' => ['timeout' => 30]]);
|
||||
$data = @file_get_contents('https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar', false, $ctx);
|
||||
if (!$data || strlen($data) < 100000) {
|
||||
throw new \RuntimeException("Failed to download WP-CLI (received " . strlen((string)$data) . " bytes)");
|
||||
}
|
||||
file_put_contents('/tmp/wp-cli.phar', $data);
|
||||
rename('/tmp/wp-cli.phar', $this->wpcli);
|
||||
chmod($this->wpcli, 0755);
|
||||
}
|
||||
|
||||
@@ -139,10 +139,21 @@ input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1) opacit
|
||||
|
||||
.sidebar-section { padding: .75rem 0; }
|
||||
.sidebar-section-label {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: .7rem; font-weight: 700; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--text-muted);
|
||||
padding: .25rem 1.25rem .5rem;
|
||||
cursor: pointer; user-select: none;
|
||||
transition: color .15s;
|
||||
}
|
||||
.sidebar-section-label:hover { color: var(--text); }
|
||||
.sidebar-section-label .nav-chevron {
|
||||
font-style: normal; font-size: .65rem; opacity: .5;
|
||||
transition: transform .2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-section.collapsed .nav-chevron { transform: rotate(-90deg); }
|
||||
.sidebar-section.collapsed .sidebar-link { display: none; }
|
||||
.sidebar-link {
|
||||
display: flex; align-items: center; gap: .75rem;
|
||||
padding: .55rem 1.25rem; text-decoration: none;
|
||||
|
||||
+397
-123
@@ -75,7 +75,7 @@
|
||||
// ── Page definitions ───────────────────────────────────────────────────────
|
||||
const pages = {
|
||||
dashboard,
|
||||
'server-status': serverStatus,
|
||||
'server-status': dashboard,
|
||||
accounts,
|
||||
resellers,
|
||||
packages,
|
||||
@@ -100,6 +100,8 @@
|
||||
backups,
|
||||
cloudflare,
|
||||
'server-options': serverOptions,
|
||||
'subdomains': window.adminSubdomains,
|
||||
'parked-domains': window.adminParked,
|
||||
notifications,
|
||||
settings,
|
||||
};
|
||||
@@ -111,16 +113,18 @@
|
||||
|
||||
// ── Dashboard ──────────────────────────────────────────────────────────────
|
||||
async function dashboard() {
|
||||
const [stats, version] = await Promise.all([
|
||||
const [stats, version, histRes] = await Promise.all([
|
||||
Nova.api('system', 'stats'),
|
||||
Nova.api('system', 'version'),
|
||||
Nova.api('stats', 'server'),
|
||||
]);
|
||||
const s = stats?.data || {};
|
||||
const v = version?.data || {};
|
||||
const s = stats?.data || {};
|
||||
const v = version?.data || {};
|
||||
const hist = histRes?.data?.history || [];
|
||||
|
||||
document.getElementById('server-ip').textContent = '';
|
||||
|
||||
return `
|
||||
const html = `
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">CPU Usage</div>
|
||||
@@ -180,53 +184,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:1.5rem">
|
||||
<div class="card-header">
|
||||
<span class="card-title">24-Hour History</span>
|
||||
<span class="text-muted" style="font-size:.8rem;margin-left:.5rem">${hist.length} samples</span>
|
||||
<button class="btn btn-ghost btn-sm" style="margin-left:auto" onclick="adminPage('dashboard')">↻</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
${hist.length === 0
|
||||
? '<p class="text-muted" style="text-align:center;padding:2rem">No history yet — collected every 5 minutes.</p>'
|
||||
: '<canvas id="dash-hist-chart" height="70"></canvas>'}
|
||||
</div>
|
||||
</div>`;
|
||||
// setTimeout BEFORE return so it actually executes (after return is dead code)
|
||||
setTimeout(()=>{const c=document.getElementById('dash-hist-chart');if(!c||!hist.length)return;if(window.Chart){initStatsChart(c,hist);}else{const s2=document.createElement('script');s2.src='https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js';s2.onload=()=>initStatsChart(c,hist);document.head.appendChild(s2);}},150);
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Server Status ──────────────────────────────────────────────────────────
|
||||
async function serverStatus() {
|
||||
const [liveRes, histRes] = await Promise.all([
|
||||
Nova.api('system', 'stats'),
|
||||
Nova.api('stats', 'server'),
|
||||
]);
|
||||
const s = liveRes?.data || {};
|
||||
const hist = histRes?.data?.history || [];
|
||||
|
||||
const html = `
|
||||
<div class="page-header"><h2 class="page-title">Server Status</h2>
|
||||
<button class="btn btn-ghost btn-sm" onclick="adminPage('server-status')">↻ Refresh</button>
|
||||
</div>
|
||||
<div class="stats-grid" style="margin-bottom:1.5rem">
|
||||
<div class="stat-card"><div class="stat-label">CPU</div><div class="stat-value ${(s.cpu?.pct||0)>80?'stat-red':'stat-green'}">${s.cpu?.pct??0}%</div><div class="mt-1">${Nova.progressBar(s.cpu?.pct||0)}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">RAM</div><div class="stat-value ${(s.ram?.pct||0)>80?'stat-red':'stat-blue'}">${s.ram?.pct??0}%</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||0)>85?'stat-red':'stat-yellow'}">${s.disk?.pct??0}%</div><div class="mt-1">${Nova.progressBar(s.disk?.pct||0)}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Load Avg</div><div class="stat-value" style="font-size:1rem;padding-top:.4rem">${(s.cpu?.load||[0]).map(v=>v.toFixed(2)).join(' / ')}</div><div class="stat-sub">Uptime: ${s.uptime||'—'}</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">24-Hour History</span><span class="text-muted" style="font-size:.8rem">${hist.length} samples</span></div>
|
||||
<div class="card-body">
|
||||
${hist.length === 0
|
||||
? '<p class="text-muted" style="text-align:center;padding:2rem">No history yet — stats are collected every 5 minutes.<br>Check that the collector cron is running: <code>*/5 * * * * root /usr/bin/php /opt/novacpx/bin/collect-stats.php</code></p>'
|
||||
: '<canvas id="stats-chart" height="80"></canvas>'}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Can't return html and async render chart — use a trick: render then init chart
|
||||
setTimeout(() => {
|
||||
const canvas = document.getElementById('stats-chart');
|
||||
if (!canvas || !hist.length) return;
|
||||
if (!window.Chart) {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js';
|
||||
s.onload = () => initStatsChart(canvas, hist);
|
||||
document.head.appendChild(s);
|
||||
} else {
|
||||
initStatsChart(canvas, hist);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function initStatsChart(canvas, hist) {
|
||||
const labels = hist.map(r => {
|
||||
@@ -1166,7 +1143,7 @@
|
||||
|
||||
// ── Resellers ──────────────────────────────────────────────────────────────
|
||||
async function resellers() {
|
||||
const res = await Nova.api('accounts', 'list', { params:{ role: 'reseller' }});
|
||||
const res = await Nova.api('users', 'list', { params:{ role: 'reseller' }});
|
||||
const rows = res?.data || [];
|
||||
return `
|
||||
<div class="card">
|
||||
@@ -1175,14 +1152,18 @@
|
||||
<button class="btn btn-primary btn-sm" onclick="adminAddReseller()">+ Add Reseller</button>
|
||||
</div>
|
||||
<div id="reseller-table">
|
||||
${rows.length ? `<table class="table"><thead><tr><th>Username</th><th>Email</th><th>Accounts</th><th>Status</th><th>Actions</th></tr></thead><tbody>
|
||||
${rows.length ? `<table class="table"><thead><tr><th>Username</th><th>Email</th><th>Status</th><th>Created</th><th>Actions</th></tr></thead><tbody>
|
||||
${rows.map(r => `<tr>
|
||||
<td>${r.username}</td><td>${r.email||'—'}</td>
|
||||
<td>${r.account_count||0}</td>
|
||||
<td>${Nova.badge(r.status,r.status==='active'?'green':'red')}</td>
|
||||
<td><strong>${Nova.escHtml(r.username)}</strong></td>
|
||||
<td>${Nova.escHtml(r.email||'—')}</td>
|
||||
<td>${Nova.badge(r.status, r.status==='active'?'green':'red')}</td>
|
||||
<td class="text-sm text-muted">${r.created_at ? r.created_at.slice(0,10) : '—'}</td>
|
||||
<td style="display:flex;gap:.25rem">
|
||||
<button class="btn btn-xs" onclick="adminChangePass(${r.id},'${r.username}')">Passwd</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="adminSuspend(${r.id},'${r.username}')">Suspend</button>
|
||||
<button class="btn btn-xs" onclick="adminResellerPasswd(${r.id},'${Nova.escHtml(r.username)}')">Passwd</button>
|
||||
${r.status === 'active'
|
||||
? `<button class="btn btn-xs btn-warning" onclick="adminResellerSuspend(${r.id},'${Nova.escHtml(r.username)}')">Suspend</button>`
|
||||
: `<button class="btn btn-xs btn-success" onclick="adminResellerUnsuspend(${r.id})">Unsuspend</button>`}
|
||||
<button class="btn btn-xs btn-danger" onclick="adminResellerDelete(${r.id},'${Nova.escHtml(r.username)}')">Delete</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`
|
||||
@@ -1193,10 +1174,51 @@
|
||||
|
||||
window.adminAddReseller = () => {
|
||||
Nova.modal('Create Reseller Account', `
|
||||
<div class="form-group"><label class="form-label">Username</label><input id="ar-user" class="form-control"></div>
|
||||
<div class="form-group"><label class="form-label">Password</label><input id="ar-pass" type="password" class="form-control"></div>
|
||||
<div class="form-group"><label class="form-label">Email</label><input id="ar-email" type="email" class="form-control"></div>`,
|
||||
`<button class="btn btn-primary" onclick="Nova.api('auth','register',{method:'POST',body:{username:document.getElementById('ar-user').value,password:document.getElementById('ar-pass').value,email:document.getElementById('ar-email').value,role:'reseller'}}).then(r=>{if(r?.success){Nova.toast('Reseller created','success');document.querySelector('.modal-overlay').remove();adminPage('resellers');}else Nova.toast(r?.message,'error');})">Create</button>`);
|
||||
<div class="form-group"><label class="form-label">Username</label><input id="ar-user" class="form-control" autocomplete="off"></div>
|
||||
<div class="form-group"><label class="form-label">Email</label><input id="ar-email" type="email" class="form-control"></div>
|
||||
<div class="form-group"><label class="form-label">Password</label><input id="ar-pass" type="password" class="form-control" autocomplete="new-password"></div>`,
|
||||
`<button class="btn btn-primary" onclick="
|
||||
Nova.api('users','create',{method:'POST',body:{
|
||||
username:document.getElementById('ar-user').value,
|
||||
email:document.getElementById('ar-email').value,
|
||||
password:document.getElementById('ar-pass').value,
|
||||
role:'reseller'
|
||||
}}).then(r=>{
|
||||
if(r?.success){Nova.toast('Reseller created','success');document.querySelector('.modal-overlay').remove();adminPage('resellers');}
|
||||
else Nova.toast(r?.message||'Error','error');
|
||||
})">Create</button>`);
|
||||
};
|
||||
|
||||
window.adminResellerPasswd = (id, user) => {
|
||||
Nova.modal(`Change Password — ${user}`,
|
||||
`<div class="form-group"><label class="form-label">New Password</label><input id="arp-pass" type="password" class="form-control" autocomplete="new-password"></div>`,
|
||||
`<button class="btn btn-primary" onclick="
|
||||
Nova.api('users','change-password',{method:'POST',body:{id:${id},password:document.getElementById('arp-pass').value}}).then(r=>{
|
||||
if(r?.success){Nova.toast('Password updated','success');document.querySelector('.modal-overlay').remove();}
|
||||
else Nova.toast(r?.message||'Error','error');
|
||||
})">Update</button>`);
|
||||
};
|
||||
|
||||
window.adminResellerSuspend = (id, user) => {
|
||||
Nova.confirm(`Suspend reseller ${user}?`, async () => {
|
||||
const r = await Nova.api('users','suspend',{method:'POST',body:{id}});
|
||||
if (r?.success) { Nova.toast('Suspended','success'); adminPage('resellers'); }
|
||||
else Nova.toast(r?.message||'Error','error');
|
||||
});
|
||||
};
|
||||
|
||||
window.adminResellerUnsuspend = async (id) => {
|
||||
const r = await Nova.api('users','unsuspend',{method:'POST',body:{id}});
|
||||
if (r?.success) { Nova.toast('Unsuspended','success'); adminPage('resellers'); }
|
||||
else Nova.toast(r?.message||'Error','error');
|
||||
};
|
||||
|
||||
window.adminResellerDelete = (id, user) => {
|
||||
Nova.confirm(`Delete reseller ${user}? Their accounts will be disowned (moved to admin).`, async () => {
|
||||
const r = await Nova.api('users','delete',{method:'POST',body:{id}});
|
||||
if (r?.success) { Nova.toast('Reseller deleted','success'); adminPage('resellers'); }
|
||||
else Nova.toast(r?.message||'Error','error');
|
||||
}, true);
|
||||
};
|
||||
|
||||
// ── Packages ───────────────────────────────────────────────────────────────
|
||||
@@ -1354,26 +1376,68 @@
|
||||
|
||||
// ── Web Server ────────────────────────────────────────────────────────────
|
||||
async function webServer() {
|
||||
const r = await Nova.api('system', 'stats');
|
||||
const svcs = r?.data?.services || {};
|
||||
const webSvc = Object.keys(svcs).find(k => k.includes('apache') || k.includes('nginx')) || 'apache2';
|
||||
const [statsR, phpR] = await Promise.all([
|
||||
Nova.api('system','stats'),
|
||||
Nova.api('php','global-config'),
|
||||
]);
|
||||
const svcs = statsR?.data?.services || {};
|
||||
const uptime = statsR?.data?.uptime || '—';
|
||||
const cpu = statsR?.data?.cpu?.pct ?? '—';
|
||||
const ram = statsR?.data?.ram?.pct ?? '—';
|
||||
const phpCfg = phpR?.data || {};
|
||||
|
||||
window.wsLoadLog = async (log) => {
|
||||
const r = await Nova.api('system','read-log',{params:{log}});
|
||||
const el = document.getElementById('ws-log-out');
|
||||
if (el) { el.textContent = r?.data?.content || '(empty)'; el.scrollTop = el.scrollHeight; }
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Web Server Management</span></div>
|
||||
<div style="padding:1.5rem">
|
||||
<div class="stats-grid" style="margin-bottom:1.5rem">
|
||||
${Object.entries(svcs).map(([s,st]) => `<div class="stat-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem">
|
||||
<strong style="word-break:break-all">${s}</strong><span data-svc-status="${s}">${Nova.badge(st,st==='active'?'green':'red')}</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:.35rem">
|
||||
<button class="btn btn-xs" onclick="adminServiceAction('${s}','restart')">Restart</button>
|
||||
<button class="btn btn-xs" onclick="adminServiceAction('${s}','start')">Start</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="adminServiceAction('${s}','stop')">Stop</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
<div class="page-header mb-2"><h2 class="page-title">Web Server</h2></div>
|
||||
<div class="stats-grid mb-3">
|
||||
<div class="stat-card"><div class="stat-label">CPU</div><div class="stat-value">${cpu}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">RAM</div><div class="stat-value">${ram}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Uptime</div><div class="stat-value stat-sm">${uptime}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">PHP</div><div class="stat-value stat-sm">${phpCfg.version||'8.3'}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Services</span></div>
|
||||
<div class="card-body">
|
||||
${Object.entries(svcs).map(([s,st]) => `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:.5rem 0;border-bottom:1px solid var(--border)">
|
||||
<span style="font-weight:500">${s} ${Nova.badge(st,st==='active'?'green':'red')}</span>
|
||||
<div style="display:flex;gap:.35rem">
|
||||
<button class="btn btn-xs" onclick="adminServiceAction('${s}','restart')">↺ Restart</button>
|
||||
<button class="btn btn-xs" onclick="adminServiceAction('${s}','reload')">⟳ Reload</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="adminServiceAction('${s}','stop')">■ Stop</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">PHP Defaults</span></div>
|
||||
<div class="card-body">
|
||||
${[['Version',phpCfg.version||'8.3'],['Memory Limit',phpCfg.memory_limit||'256M'],
|
||||
['Upload Max',phpCfg.upload_max_filesize||'64M'],['Max Exec Time',(phpCfg.max_execution_time||30)+'s'],
|
||||
['Post Max',phpCfg.post_max_size||'64M']].map(([k,v])=>`
|
||||
<div style="display:flex;justify-content:space-between;padding:.35rem 0;border-bottom:1px solid var(--border);font-size:.85rem">
|
||||
<span style="color:var(--text-muted)">${k}</span><strong>${v}</strong>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Log Viewer</span>
|
||||
<div style="display:flex;gap:.35rem;margin-left:auto">
|
||||
${[['nginx-error','Nginx Error'],['nginx-access','Nginx Access'],['panel','Panel'],['deploy','Deploy']].map(([l,n])=>
|
||||
`<button class="btn btn-xs" onclick="wsLoadLog('${l}')">${n}</button>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<pre id="ws-log-out" style="background:var(--bg);padding:1rem;font-size:.78rem;height:220px;overflow:auto;margin:0;border-radius:0 0 var(--radius) var(--radius);color:var(--text-muted)">← Click a log above to view it</pre>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -2359,7 +2423,12 @@ ${dbs.map(d=>`<tr>
|
||||
</tbody></table>` : '<div class="empty" style="padding:2rem">No databases yet.</div>';
|
||||
|
||||
return `
|
||||
<div class="page-header"><h2 class="page-title">Database Engine Manager</h2></div>
|
||||
<div class="page-header mb-2"><h2 class="page-title">Database Manager</h2></div>
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:1.25rem;flex-wrap:wrap">
|
||||
<a href="/phpmyadmin" target="_blank" class="btn btn-sm">🐬 phpMyAdmin (MySQL)</a>
|
||||
<a href="/adminer.php" target="_blank" class="btn btn-sm">🗄️ Adminer (MySQL)</a>
|
||||
<a href="/adminer.php?pgsql=" target="_blank" class="btn btn-sm">🐘 Adminer (PostgreSQL)</a>
|
||||
</div>
|
||||
|
||||
<div class="grid-3 gap-2" style="margin-bottom:1.5rem">
|
||||
${engineCard('mysql', 'MySQL', '🐬')}
|
||||
@@ -2386,6 +2455,7 @@ ${dbs.map(d=>`<tr>
|
||||
<div class="grid-2 gap-2">
|
||||
${toolCard('phpmyadmin', 'phpMyAdmin', '🛢', `http://${location.hostname}/phpmyadmin`)}
|
||||
${toolCard('pgadmin', 'pgAdmin 4', '🐘', `http://${location.hostname}/pgadmin4`)}
|
||||
${toolCard('adminer', 'Adminer', '🗄️', `http://${location.hostname}/adminer.php`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2562,37 +2632,71 @@ ${dbs.map(d=>`<tr>
|
||||
|
||||
// ── Mail Server ────────────────────────────────────────────────────────────
|
||||
async function mailServer() {
|
||||
const r = await Nova.api('system','stats');
|
||||
const svcs = r?.data?.services || {};
|
||||
const mailStatus = svcs['postfix'] || 'unknown';
|
||||
const doveStatus = svcs['dovecot'] || 'unknown';
|
||||
const [statsR, domainsR] = await Promise.all([
|
||||
Nova.api('system','stats'),
|
||||
Nova.api('email','domains'),
|
||||
]);
|
||||
const svcs = statsR?.data?.services || {};
|
||||
const mailSvcs = ['postfix','dovecot','rspamd','opendkim'].filter(s => svcs[s]);
|
||||
const domains = domainsR?.data || [];
|
||||
|
||||
window.msLoadLog = async () => {
|
||||
const r = await Nova.api('system','read-log',{params:{log:'mail'}});
|
||||
const el = document.getElementById('ms-log-out');
|
||||
if (el) { el.textContent = r?.data?.content || '(empty)'; el.scrollTop = el.scrollHeight; }
|
||||
};
|
||||
|
||||
return `
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem">
|
||||
<div class="page-header mb-2"><h2 class="page-title">Mail Server</h2></div>
|
||||
<div class="grid-2 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Mail Services</span></div>
|
||||
<div style="padding:1.25rem">
|
||||
${[['postfix',mailStatus],['dovecot',doveStatus]].map(([s,st]) => `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:.6rem 0;border-bottom:1px solid var(--border)">
|
||||
<span>${s} <span data-svc-status="${s}">${Nova.badge(st,st==='active'?'green':'red')}</span></span>
|
||||
<div style="display:flex;gap:.5rem">
|
||||
<button class="btn btn-xs" onclick="adminServiceAction('${s}','restart')">Restart</button>
|
||||
<button class="btn btn-xs" onclick="adminServiceAction('${s}','reload')">Reload</button>
|
||||
<div class="card-header"><span class="card-title">Services</span></div>
|
||||
<div class="card-body">
|
||||
${mailSvcs.length ? mailSvcs.map(s => `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:.5rem 0;border-bottom:1px solid var(--border)">
|
||||
<span style="font-weight:500">${s} ${Nova.badge(svcs[s],svcs[s]==='active'?'green':'red')}</span>
|
||||
<div style="display:flex;gap:.35rem">
|
||||
<button class="btn btn-xs" onclick="adminServiceAction('${s}','restart')">↺ Restart</button>
|
||||
<button class="btn btn-xs" onclick="adminServiceAction('${s}','reload')">⟳ Reload</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`).join('') : '<p class="text-muted">No mail services detected</p>'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Mail Queue</span></div>
|
||||
<div style="padding:1.25rem">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Mail Queue</span>
|
||||
<button class="btn btn-xs btn-warning" style="margin-left:auto" onclick="Nova.confirm('Flush all queued mail?',()=>adminServiceAction('postfix','flush'))">Flush</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<button class="btn btn-sm" onclick="adminViewMailQueue()">View Queue</button>
|
||||
<button class="btn btn-sm btn-warning" style="margin-top:.5rem" onclick="Nova.confirm('Flush mail queue?',()=>adminServiceAction('postfix','flush'))">Flush Queue</button>
|
||||
<div id="ms-queue-out" style="margin-top:.75rem;font-size:.82rem;color:var(--text-muted)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Virtual Mail Domains (${domains.length})</span>
|
||||
</div>
|
||||
${domains.length ? `<div style="overflow-x:auto"><table class="table"><thead><tr><th>Domain</th><th>Account</th><th>Email Accounts</th></tr></thead><tbody>
|
||||
${domains.map(d=>`<tr><td><strong>${Nova.escHtml(d.domain)}</strong></td><td>${Nova.escHtml(d.username||'—')}</td><td>${d.email_count||0}</td></tr>`).join('')}
|
||||
</tbody></table></div>` : '<div class="card-body text-muted">No mail domains yet — created automatically when hosting accounts are set up.</div>'}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Mail Log</span>
|
||||
<button class="btn btn-xs" style="margin-left:auto" onclick="msLoadLog()">Load Log</button>
|
||||
</div>
|
||||
<pre id="ms-log-out" style="background:var(--bg);padding:1rem;font-size:.78rem;height:180px;overflow:auto;margin:0;color:var(--text-muted)">← Click Load Log to view recent mail activity</pre>
|
||||
</div>`;
|
||||
}
|
||||
window.adminViewMailQueue = async () => {
|
||||
const r = await Nova.api('system','service',{method:'POST',body:{service:'mailq',command:'status'}});
|
||||
Nova.modal('Mail Queue', `<pre style="background:var(--bg);padding:1rem;font-size:.8rem;overflow:auto;max-height:400px">${r?.data?.output || 'Queue is empty'}</pre>`);
|
||||
const out = r?.data?.output || 'Queue is empty';
|
||||
const el = document.getElementById('ms-queue-out');
|
||||
if (el) el.innerHTML = `<pre style="margin:0">${Nova.escHtml(out)}</pre>`;
|
||||
else Nova.modal('Mail Queue', `<pre style="background:var(--bg);padding:1rem;font-size:.8rem;overflow:auto;max-height:400px">${Nova.escHtml(out)}</pre>`);
|
||||
};
|
||||
|
||||
// ── FTP Server ────────────────────────────────────────────────────────────
|
||||
@@ -2606,23 +2710,36 @@ ${dbs.map(d=>`<tr>
|
||||
const svcName = ftpConf === 'vsftpd' ? 'vsftpd' : (ftpConf === 'pureftpd' ? 'pure-ftpd' : 'proftpd');
|
||||
const label = ftpConf === 'vsftpd' ? 'vsftpd' : (ftpConf === 'pureftpd' ? 'Pure-FTPd' : 'ProFTPD');
|
||||
const status = svcs[svcName] || 'unknown';
|
||||
const ftpR = await Nova.api('ftp','list',{params:{account_id:0}});
|
||||
const ftpAccts = ftpR?.data || [];
|
||||
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="page-header mb-2"><h2 class="page-title">FTP Server</h2></div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<span class="card-title">FTP Server (${label})</span>
|
||||
<span data-svc-status="${svcName}">${Nova.badge(status, status==='active'?'green':'red')}</span>
|
||||
<div style="display:flex;gap:.5rem;margin-left:auto">
|
||||
<button class="btn btn-sm" onclick="adminServiceAction('${svcName}','restart')">Restart</button>
|
||||
<button class="btn btn-sm" onclick="adminServiceAction('${svcName}','reload')">Reload</button>
|
||||
<span class="card-title">${label}</span>
|
||||
${Nova.badge(status, status==='active'?'green':'red')}
|
||||
<div style="display:flex;gap:.35rem;margin-left:auto">
|
||||
<button class="btn btn-sm" onclick="adminServiceAction('${svcName}','restart')">↺ Restart</button>
|
||||
<button class="btn btn-sm" onclick="adminServiceAction('${svcName}','reload')">⟳ Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:1.25rem">
|
||||
<div style="color:var(--muted);font-size:.85rem">
|
||||
<p>Active FTP server: <strong>${label}</strong> — change in <a href="#" onclick="adminPage('server-options')">Server Options</a>.</p>
|
||||
<p style="margin-top:.5rem">FTP connections use SFTP on port 22 or passive FTP on ports 20/21.</p>
|
||||
<p style="margin-top:.5rem">Per-account FTP management is available in each account's FTP page.</p>
|
||||
</div>
|
||||
<div class="card-body" style="font-size:.85rem;color:var(--text-muted)">
|
||||
Active server: <strong>${label}</strong> — change in <a href="#" onclick="adminPage('server-options')">Server Options</a>.
|
||||
Passive FTP ports 20/21 · SFTP on port 22.
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">All FTP Accounts (${ftpAccts.length})</span></div>
|
||||
${ftpAccts.length ? `<div style="overflow-x:auto"><table class="table"><thead><tr><th>Username</th><th>Account</th><th>Directory</th><th>Permissions</th></tr></thead><tbody>
|
||||
${ftpAccts.map(f=>`<tr>
|
||||
<td><strong>${Nova.escHtml(f.username)}</strong></td>
|
||||
<td style="font-size:.82rem">${Nova.escHtml(f.account_domain||String(f.account_id)||'—')}</td>
|
||||
<td style="font-size:.75rem;font-family:monospace">${Nova.escHtml(f.home_dir||'—')}</td>
|
||||
<td>${Nova.badge(f.permissions||'rw','blue')}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`
|
||||
: '<div class="card-body text-muted">No FTP accounts yet — created from each account's FTP page.</div>'}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -4047,16 +4164,21 @@ async function docker() {
|
||||
${(status.disk||[]).map(d=>`<div class="stat-card"><div class="stat-label">${Nova.escHtml(d.Type||d.type||'?')}</div><div class="stat-value" style="font-size:1rem">${Nova.escHtml(d.TotalCount||d.Size||'—')}</div><div class="stat-sub">${Nova.escHtml(d.Reclaimable||d.reclaimable||'')}</div></div>`).join('')}
|
||||
</div>
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:1rem;flex-wrap:wrap">
|
||||
${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('quotas','User Quotas')}
|
||||
<button class="btn btn-sm btn-danger" style="margin-left:auto" onclick="dockerPrune()">System Prune</button>
|
||||
${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('catalog','App Catalog')} ${tab('quotas','User Quotas')}
|
||||
<button class="btn btn-sm btn-warning" style="margin-left:auto" onclick="dockerPrune()">System Prune</button>
|
||||
</div>
|
||||
<div id="docker-tab-content"><div class="loading">Loading…</div></div>`;
|
||||
}
|
||||
|
||||
async function dockerLoadTab(tab) {
|
||||
// Refresh without clearing the list first (keeps current content visible while loading)
|
||||
async function dockerLoadTabKeep(tab) {
|
||||
await dockerLoadTab(tab, true);
|
||||
}
|
||||
|
||||
async function dockerLoadTab(tab, keepContent = false) {
|
||||
const tc = document.getElementById('docker-tab-content');
|
||||
if (!tc) return;
|
||||
tc.innerHTML = '<div class="loading">Loading…</div>';
|
||||
if (!keepContent) tc.innerHTML = '<div class="loading">Loading…</div>';
|
||||
|
||||
if (tab === 'containers') {
|
||||
const r = await Nova.api('docker', 'containers');
|
||||
@@ -4070,7 +4192,7 @@ ${rows.length === 0 ? '<div class="card"><div class="card-body text-muted" style
|
||||
<div style="overflow-x:auto"><table class="table"><thead><tr>
|
||||
<th>Name</th><th>Image</th><th>Status</th><th>Account</th><th>Created</th><th>Actions</th>
|
||||
</tr></thead><tbody>
|
||||
${rows.map(c => `<tr>
|
||||
${rows.map(c => `<tr data-cid="${Nova.escHtml(c.container_id||'')}">
|
||||
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(c.name)}</td>
|
||||
<td style="font-size:.82rem">${Nova.escHtml(c.image)}</td>
|
||||
<td>${Nova.badge(c.status, c.status==='running'?'green':c.status==='stopped'?'red':'yellow')}</td>
|
||||
@@ -4143,11 +4265,29 @@ ${stacks.map(s=>`<tr>
|
||||
<button class="btn btn-xs btn-success" onclick="dockerStackAct(${s.id},'up')">Up</button>
|
||||
<button class="btn btn-xs btn-warning" onclick="dockerStackAct(${s.id},'down')">Down</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick="dockerStackAct(${s.id},'logs')">Logs</button>
|
||||
<button class="btn btn-xs btn-secondary" onclick="dockerStackReinstall(${s.id})">Reinstall</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="dockerStackRemove(${s.id})">Remove</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`}`;
|
||||
|
||||
} else if (tab === 'catalog') {
|
||||
const r = await Nova.api('docker', 'catalog');
|
||||
const catalog = r?.data?.catalog || {};
|
||||
tc.innerHTML = `
|
||||
<p class="text-muted" style="margin-bottom:1rem">One-click app deployment. Each app runs as an isolated Docker Compose stack. Select an account after clicking Launch.</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem">
|
||||
${Object.entries(catalog).map(([key,app])=>`
|
||||
<div class="card" style="transition:var(--transition)" onmouseover="this.style.borderColor='var(--primary)'" onmouseout="this.style.borderColor=''">
|
||||
<div class="card-body" style="text-align:center;padding:1.5rem">
|
||||
<div style="font-size:1.8rem;font-weight:700;margin-bottom:.5rem;color:var(--primary)">${Nova.escHtml(app.icon)}</div>
|
||||
<div style="font-weight:600;margin-bottom:.25rem">${Nova.escHtml(app.name)}</div>
|
||||
<div style="font-size:.78rem;color:var(--text-muted);margin-bottom:.75rem">${Nova.escHtml(app.description)}</div>
|
||||
<button class="btn btn-sm btn-primary" onclick="dockerAdminLaunchApp('${key}')">Launch</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
|
||||
} else if (tab === 'quotas') {
|
||||
const r = await Nova.api('accounts', 'list', { params: { limit: 200 } });
|
||||
const users = r?.data || [];
|
||||
@@ -4165,16 +4305,67 @@ ${users.map(u=>`<tr id="docker-quota-row-${u.user_id}">
|
||||
}
|
||||
}
|
||||
|
||||
window.dockerContainerAct = async (cid, action) => {
|
||||
const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } });
|
||||
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) dockerLoadTab('containers');
|
||||
window.dockerAdminLaunchApp = async (preselect) => {
|
||||
const catRes = await Nova.api('docker', 'catalog');
|
||||
const catalog = catRes?.data?.catalog || {};
|
||||
const acctRes = await Nova.api('accounts', 'list', { params: { limit: 200 } });
|
||||
const accounts = acctRes?.data || [];
|
||||
const appOpts = Object.entries(catalog).map(([k,a])=>`<option value="${k}" ${k===preselect?'selected':''}>${Nova.escHtml(a.name)}</option>`).join('');
|
||||
const acctOpts = accounts.map(a=>`<option value="${a.id}">${Nova.escHtml(a.username)} (${Nova.escHtml(a.domain)})</option>`).join('');
|
||||
|
||||
window.dockerAdminUpdateParams = (key) => {
|
||||
const app = catalog[key]; if (!app) return;
|
||||
const tc = document.getElementById('dal-params'); if (!tc) return;
|
||||
tc.innerHTML = (app.params||[]).map(p=>`
|
||||
<div class="form-group"><label>${Nova.escHtml(p.label)}${p.required?' *':''}</label>
|
||||
<input id="dal-${Nova.escHtml(p.key)}" type="${p.type||'text'}" class="form-control" ${p.placeholder?`placeholder="${Nova.escHtml(p.placeholder)}"`:''}></div>`).join('');
|
||||
};
|
||||
|
||||
const ov = Nova.modal('Launch App (Admin)',
|
||||
`<div class="form-group"><label>Account</label><select id="dal-account" class="form-control">${acctOpts}</select></div>
|
||||
<div class="form-group"><label>Application</label><select id="dal-app" class="form-control" onchange="dockerAdminUpdateParams(this.value)">${appOpts}</select></div>
|
||||
<div id="dal-params"></div>`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="dockerAdminLaunchSubmit()">Launch</button>`
|
||||
);
|
||||
dockerAdminUpdateParams(preselect || Object.keys(catalog)[0] || '');
|
||||
|
||||
window.dockerAdminLaunchSubmit = async () => {
|
||||
const appKey = document.getElementById('dal-app').value;
|
||||
const accountId = parseInt(document.getElementById('dal-account').value);
|
||||
const app = catalog[appKey]; if (!app) return;
|
||||
const params = {};
|
||||
(app.params||[]).forEach(p => { const el = document.getElementById(`dal-${p.key}`); if (el) params[p.key] = el.value.trim(); });
|
||||
const missing = (app.params||[]).filter(p => p.required && !params[p.key]);
|
||||
if (missing.length) { Nova.toast(`Required: ${missing.map(p=>p.label).join(', ')}`, 'error'); return; }
|
||||
ov.remove();
|
||||
Nova.toast(`Launching ${app.name}…`, 'info', 15000);
|
||||
const r = await Nova.api('docker', 'launch', { method: 'POST', body: { app_key: appKey, account_id: accountId, params } });
|
||||
Nova.toast(r?.success ? `${app.name} launched!` : (r?.error || r?.message || 'Launch failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) dockerLoadTab('stacks');
|
||||
};
|
||||
};
|
||||
|
||||
window.dockerRemove = (cid) => Nova.confirm('Remove this container?', async () => {
|
||||
const r = await Nova.api('docker', 'container-remove', { method: 'DELETE', body: { container_id: cid, force: true } });
|
||||
window.dockerContainerAct = async (cid, action) => {
|
||||
// Optimistic UI — update the row immediately so user sees feedback
|
||||
const row = document.querySelector(`tr[data-cid="${cid}"]`);
|
||||
if (row) {
|
||||
const badge = row.querySelector('.badge');
|
||||
if (badge) { badge.textContent = action === 'stop' ? 'stopping…' : action === 'start' ? 'starting…' : 'restarting…'; badge.className = 'badge badge-yellow'; }
|
||||
row.querySelectorAll('button').forEach(b => b.disabled = true);
|
||||
}
|
||||
const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } });
|
||||
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
// Reload tab to show real status (don't clear first — keep current list visible)
|
||||
if (r?.success || r !== null) dockerLoadTabKeep('containers');
|
||||
};
|
||||
|
||||
window.dockerRemove = (cid) => Nova.confirm('Force remove this container?', async () => {
|
||||
const row = document.querySelector(`tr[data-cid="${cid}"]`);
|
||||
if (row) row.style.opacity = '0.4';
|
||||
const r = await Nova.api('docker', 'container-remove', { method: 'POST', body: { container_id: cid, force: true } });
|
||||
Nova.toast(r?.success ? 'Removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) dockerLoadTab('containers');
|
||||
if (r?.success || r !== null) dockerLoadTabKeep('containers');
|
||||
}, true);
|
||||
|
||||
window.dockerLogs = async (cid, name) => {
|
||||
@@ -4183,10 +4374,10 @@ window.dockerLogs = async (cid, name) => {
|
||||
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(logs)}</pre>`);
|
||||
};
|
||||
|
||||
window.dockerImgRemove = (id) => Nova.confirm('Remove this image?', async () => {
|
||||
const r = await Nova.api('docker', 'image-remove', { method: 'DELETE', body: { image_id: id } });
|
||||
window.dockerImgRemove = (id) => Nova.confirm('Remove this image? Stop any containers using it first.', async () => {
|
||||
const r = await Nova.api('docker', 'image-remove', { method: 'POST', body: { image_id: id } });
|
||||
Nova.toast(r?.success ? 'Image removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) dockerLoadTab('images');
|
||||
dockerLoadTabKeep('images');
|
||||
}, true);
|
||||
|
||||
window.dockerPullModal = () => {
|
||||
@@ -4243,6 +4434,13 @@ window.dockerStackAct = async (id, action) => {
|
||||
}
|
||||
};
|
||||
|
||||
window.dockerStackReinstall = (id) => Nova.confirm('Reinstall this stack? Latest images will be pulled and containers restarted. Data volumes are preserved.', async () => {
|
||||
Nova.toast('Reinstalling stack…', 'info', 15000);
|
||||
const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: id } });
|
||||
Nova.toast(r?.success ? 'Stack reinstalled' : (r?.message||'Reinstall failed'), r?.success?'success':'error');
|
||||
if (r?.success) dockerLoadTab('stacks');
|
||||
}, true);
|
||||
|
||||
window.dockerStackRemove = (id) => Nova.confirm('Remove this stack? Docker Compose down will be run first.', async () => {
|
||||
const r = await Nova.api('docker', 'stack-remove', { method: 'DELETE', body: { stack_id: id } });
|
||||
Nova.toast(r?.success ? 'Stack removed' : (r?.message||'Failed'), r?.success?'success':'error');
|
||||
@@ -4421,7 +4619,21 @@ async function serverOptions() {
|
||||
<button class="btn btn-primary btn-sm" onclick="soSaveNS()">Save Nameservers</button>
|
||||
<div id="so-ns-results" style="margin-top:1rem"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
</div>
|
||||
|
||||
<!-- Default Index Page Template (#39) -->
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<div class="card-header"><span class="card-title">Default Index Page Template</span></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted" style="font-size:.85rem;margin-bottom:.75rem">
|
||||
HTML shown when a new hosting account is created. Use <code>{domain}</code> and <code>{username}</code> as placeholders. Leave blank to use the built-in styled template.
|
||||
</p>
|
||||
<textarea id="so-index-tpl" class="form-control" rows="6" style="font-family:monospace;font-size:.82rem;margin-bottom:.75rem"
|
||||
placeholder="Leave blank for built-in template...">${Nova.escHtml(opts.default_index_template||'')}</textarea>
|
||||
<button class="btn btn-primary btn-sm" onclick="soSaveIndexTemplate()">Save Template</button>
|
||||
<button class="btn btn-ghost btn-sm" style="margin-left:.5rem" onclick="document.getElementById('so-index-tpl').value=''">Reset to Default</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
window.soSave = (key, inputId, label) => {
|
||||
@@ -4492,6 +4704,12 @@ window.soSaveNS = async () => {
|
||||
Nova.toast('Nameservers saved', 'success');
|
||||
};
|
||||
|
||||
window.soSaveIndexTemplate = async () => {
|
||||
const tpl = document.getElementById('so-index-tpl')?.value || '';
|
||||
const res = await Nova.api('system','save-option',{method:'POST',body:{key:'default_index_template',value:tpl}});
|
||||
Nova.toast(res?.success ? 'Default template saved' : 'Save failed', res?.success?'success':'error');
|
||||
};
|
||||
|
||||
window.soCheckNS = async () => {
|
||||
const tc = document.getElementById('so-ns-results');
|
||||
if (!tc) return;
|
||||
@@ -4508,3 +4726,59 @@ ${results.map(z=>`<tr>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`;
|
||||
};
|
||||
|
||||
// ══ ADMIN SUBDOMAINS PAGE ═════════════════════════════════════════════════
|
||||
window.adminSubdomains = async function() {
|
||||
document.querySelectorAll('.sidebar-link').forEach(l=>l.classList.remove('active'));
|
||||
document.querySelector('[data-page="subdomains"]')?.classList.add('active');
|
||||
document.getElementById('page-title').textContent = 'All Subdomains';
|
||||
document.getElementById('page-content').innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<p class="text-muted" style="margin:0">All subdomains across all hosting accounts.</p>
|
||||
</div>
|
||||
<div class="card"><div id="admin-sub-list"><div class="loading">Loading…</div></div></div>`;
|
||||
const el = document.getElementById('admin-sub-list');
|
||||
const res = await Nova.api('accounts','list',{params:{per_page:200}});
|
||||
if (!res?.success || !res.data?.length) { el.innerHTML='<div class="empty">No accounts</div>'; return; }
|
||||
let rows = [];
|
||||
for (const acct of res.data) {
|
||||
const dr = await Nova.api('domains','list',{params:{account_id:acct.id}});
|
||||
if (!dr?.success) continue;
|
||||
dr.data.filter(d=>d.type==='subdomain').forEach(d=>rows.push({...d,acct_username:acct.username}));
|
||||
}
|
||||
if (!rows.length) { el.innerHTML='<div class="empty">No subdomains found.</div>'; return; }
|
||||
el.innerHTML = `<table class="table"><thead><tr><th>Account</th><th>Subdomain</th><th>SSL</th><th>Created</th></tr></thead><tbody>
|
||||
${rows.map(d=>`<tr><td>${d.acct_username}</td><td><strong>${d.domain}</strong></td>
|
||||
<td>${d.ssl_enabled?Nova.badge('SSL','green'):'—'}</td>
|
||||
<td style="font-size:.78rem">${(d.created_at||'').split('T')[0]}</td></tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
};
|
||||
|
||||
// ══ ADMIN PARKED DOMAINS PAGE ═════════════════════════════════════════════
|
||||
window.adminParked = async function() {
|
||||
document.querySelectorAll('.sidebar-link').forEach(l=>l.classList.remove('active'));
|
||||
document.querySelector('[data-page="parked-domains"]')?.classList.add('active');
|
||||
document.getElementById('page-title').textContent = 'Parked Domains';
|
||||
document.getElementById('page-content').innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<p class="text-muted" style="margin:0">All parked/alias domains across all accounts.</p>
|
||||
</div>
|
||||
<div class="card"><div id="admin-park-list"><div class="loading">Loading…</div></div></div>`;
|
||||
const el = document.getElementById('admin-park-list');
|
||||
const res = await Nova.api('accounts','list',{params:{per_page:200}});
|
||||
if (!res?.success || !res.data?.length) { el.innerHTML='<div class="empty">No accounts</div>'; return; }
|
||||
let rows = [];
|
||||
for (const acct of res.data) {
|
||||
const dr = await Nova.api('domains','list',{params:{account_id:acct.id}});
|
||||
if (!dr?.success) continue;
|
||||
const main = dr.data.find(d=>d.type==='main');
|
||||
dr.data.filter(d=>d.type==='parked'||d.type==='alias').forEach(d=>rows.push({...d,acct_username:acct.username,main_domain:main?.domain||acct.domain}));
|
||||
}
|
||||
if (!rows.length) { el.innerHTML='<div class="empty">No parked domains found.</div>'; return; }
|
||||
el.innerHTML = `<table class="table"><thead><tr><th>Account</th><th>Parked Domain</th><th>Points To</th><th>Created</th></tr></thead><tbody>
|
||||
${rows.map(d=>`<tr><td>${d.acct_username}</td><td><strong>${d.domain}</strong></td>
|
||||
<td style="color:var(--text-muted);font-size:.82rem">${d.main_domain}</td>
|
||||
<td style="font-size:.78rem">${(d.created_at||'').split('T')[0]}</td></tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
};
|
||||
|
||||
|
||||
@@ -56,7 +56,14 @@ window.Nova = (() => {
|
||||
return { success: false, message: 'Network error — check your connection' };
|
||||
}
|
||||
_barDone();
|
||||
if (res.status === 401) { return { success: false, message: 'Session expired — please log in again' }; }
|
||||
if (res.status === 401) {
|
||||
const text401 = await res.text();
|
||||
try {
|
||||
const j = JSON.parse(text401);
|
||||
if (endpoint === 'auth' && action === 'login') return j;
|
||||
return { success: false, message: j.message || 'Session expired — please log in again' };
|
||||
} catch { return { success: false, message: 'Session expired — please log in again' }; }
|
||||
}
|
||||
if (res.status === 429) {
|
||||
const reset = res.headers.get('X-RateLimit-Reset');
|
||||
const wait = reset ? Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000)) : 60;
|
||||
@@ -214,6 +221,40 @@ window.Nova = (() => {
|
||||
return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot, escHtml, loading, loadingDone };
|
||||
})();
|
||||
|
||||
// #48 Collapsible sidebar nav — shared across all panels
|
||||
// Exported as window.Nova.initCollapsibleNav so panel JS can call it after dynamic nav render
|
||||
function _initCollapsibleNav() {
|
||||
const STORE = 'ncpx_nav_collapsed';
|
||||
const state = JSON.parse(localStorage.getItem(STORE) || '{}');
|
||||
|
||||
document.querySelectorAll('.sidebar-section').forEach(section => {
|
||||
const label = section.querySelector('.sidebar-section-label');
|
||||
if (!label || label.querySelector('.nav-chevron')) return; // already initialized
|
||||
|
||||
const chevron = document.createElement('i');
|
||||
chevron.className = 'nav-chevron';
|
||||
chevron.textContent = '▼';
|
||||
label.appendChild(chevron);
|
||||
|
||||
const key = label.textContent.replace('▼','').trim();
|
||||
|
||||
if (state[key]) section.classList.add('collapsed');
|
||||
|
||||
const hasActive = section.querySelector('.sidebar-link.active');
|
||||
if (hasActive) section.classList.remove('collapsed');
|
||||
|
||||
label.addEventListener('click', () => {
|
||||
if (section.querySelector('.sidebar-link.active')) return;
|
||||
section.classList.toggle('collapsed');
|
||||
state[key] = section.classList.contains('collapsed');
|
||||
localStorage.setItem(STORE, JSON.stringify(state));
|
||||
});
|
||||
});
|
||||
}
|
||||
// Run on DOMContentLoaded for admin panel (static nav), expose globally for dynamic panels
|
||||
document.addEventListener('DOMContentLoaded', _initCollapsibleNav);
|
||||
window._initCollapsibleNav = _initCollapsibleNav;
|
||||
|
||||
// #26 Mobile sidebar toggle — shared across all panels
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const toggle = document.getElementById('sidebar-toggle');
|
||||
|
||||
@@ -325,6 +325,11 @@ const rNavGroups = [
|
||||
{ id: 'packages', label: 'Packages',
|
||||
svg: '<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"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>' },
|
||||
]},
|
||||
{ id: 'subdomains', label: 'Subdomains',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h10M4 18h13"/><path d="M18 15l3 3-3 3"/></svg>' },
|
||||
{ id: 'parked-domains', label: 'Parked Domains',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>' },
|
||||
]},
|
||||
{ label: 'DNS', items: [
|
||||
{ id: 'dns', label: 'DNS Zones',
|
||||
svg: '<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>' },
|
||||
@@ -361,9 +366,12 @@ function renderRNav() {
|
||||
document.getElementById('sidebar-overlay')?.classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
const extras = {'subdomains': window.resellerSubdomains, 'parked-domains': window.resellerParked};
|
||||
if (extras[link.dataset.page]) { extras[link.dataset.page](); _rActivePage = link.dataset.page; return; }
|
||||
resellerNav(link.dataset.page);
|
||||
});
|
||||
});
|
||||
if (typeof _initCollapsibleNav === 'function') _initCollapsibleNav();
|
||||
}
|
||||
|
||||
window.resellerNav = (page) => {
|
||||
@@ -698,3 +706,54 @@ window.rWlSave = async () => {
|
||||
Nova.toast(r?.success ? 'Branding saved — reload to see changes' : (r?.message || 'Save failed'),
|
||||
r?.success ? 'success' : 'error');
|
||||
};
|
||||
|
||||
// ══ RESELLER SUBDOMAINS PAGE ══════════════════════════════════════════════
|
||||
window.resellerSubdomains = async function() {
|
||||
document.getElementById('page-title').textContent = 'Subdomains';
|
||||
document.getElementById('page-content').innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<p class="text-muted" style="margin:0">Subdomains across your customers' accounts.</p>
|
||||
</div>
|
||||
<div class="card"><div id="rsub-list"><div class="loading">Loading…</div></div></div>`;
|
||||
const el = document.getElementById('rsub-list');
|
||||
const res = await Nova.api('accounts','list',{params:{per_page:200}});
|
||||
if (!res?.success || !res.data?.length) { el.innerHTML='<div class="empty">No accounts</div>'; return; }
|
||||
let rows = [];
|
||||
for (const acct of res.data) {
|
||||
const dr = await Nova.api('domains','list',{params:{account_id:acct.id}});
|
||||
if (!dr?.success) continue;
|
||||
dr.data.filter(d=>d.type==='subdomain').forEach(d=>rows.push({...d,acct_username:acct.username}));
|
||||
}
|
||||
if (!rows.length) { el.innerHTML='<div class="empty">No subdomains found.</div>'; return; }
|
||||
el.innerHTML = `<table class="table"><thead><tr><th>Account</th><th>Subdomain</th><th>SSL</th><th>Created</th></tr></thead><tbody>
|
||||
${rows.map(d=>`<tr><td>${d.acct_username}</td><td><strong>${d.domain}</strong></td>
|
||||
<td>${d.ssl_enabled?'<span class="badge badge-green">SSL</span>':'—'}</td>
|
||||
<td style="font-size:.78rem">${(d.created_at||'').split('T')[0]}</td></tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
};
|
||||
|
||||
// ══ RESELLER PARKED DOMAINS PAGE ══════════════════════════════════════════
|
||||
window.resellerParked = async function() {
|
||||
document.getElementById('page-title').textContent = 'Parked Domains';
|
||||
document.getElementById('page-content').innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<p class="text-muted" style="margin:0">Parked domains across your customers' accounts.</p>
|
||||
</div>
|
||||
<div class="card"><div id="rpark-list"><div class="loading">Loading…</div></div></div>`;
|
||||
const el = document.getElementById('rpark-list');
|
||||
const res = await Nova.api('accounts','list',{params:{per_page:200}});
|
||||
if (!res?.success || !res.data?.length) { el.innerHTML='<div class="empty">No accounts</div>'; return; }
|
||||
let rows = [];
|
||||
for (const acct of res.data) {
|
||||
const dr = await Nova.api('domains','list',{params:{account_id:acct.id}});
|
||||
if (!dr?.success) continue;
|
||||
const main = dr.data.find(d=>d.type==='main');
|
||||
dr.data.filter(d=>d.type==='parked'||d.type==='alias').forEach(d=>rows.push({...d,acct_username:acct.username,main_domain:main?.domain||acct.domain}));
|
||||
}
|
||||
if (!rows.length) { el.innerHTML='<div class="empty">No parked domains found.</div>'; return; }
|
||||
el.innerHTML = `<table class="table"><thead><tr><th>Account</th><th>Parked Domain</th><th>Points To</th><th>Created</th></tr></thead><tbody>
|
||||
${rows.map(d=>`<tr><td>${d.acct_username}</td><td><strong>${d.domain}</strong></td>
|
||||
<td style="color:var(--text-muted);font-size:.82rem">${d.main_domain}</td>
|
||||
<td style="font-size:.78rem">${(d.created_at||'').split('T')[0]}</td></tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
};
|
||||
|
||||
@@ -937,8 +937,13 @@ const navGroups = [
|
||||
svg: '<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>' },
|
||||
]},
|
||||
{ label: 'Hosting', items: [
|
||||
{ id: 'domains', label: 'Domains',
|
||||
{ id: 'domains', label: 'Domains',
|
||||
|
||||
svg: '<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>' },
|
||||
{ id: 'subdomains', label: 'Subdomains',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h10M4 18h13"/><path d="M18 15l3 3-3 3"/></svg>' },
|
||||
{ id: 'parked-domains', label: 'Parked Domains',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>' },
|
||||
{ id: 'email', label: 'Email',
|
||||
svg: '<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>' },
|
||||
{ id: 'databases', label: 'Databases',
|
||||
@@ -965,6 +970,8 @@ const navGroups = [
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="9" width="4" height="4"/><rect x="7" y="9" width="4" height="4"/><rect x="12" y="9" width="4" height="4"/><rect x="7" y="4" width="4" height="4"/><path d="M22 11c0 5-3.9 9-10 9-8 0-10-7-10-7"/></svg>' },
|
||||
]},
|
||||
{ label: 'Account', items: [
|
||||
{ id: 'account-settings', label: 'Settings',
|
||||
svg: '<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>' },
|
||||
{ id: 'change-password', label: 'Change Password',
|
||||
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' },
|
||||
]},
|
||||
@@ -985,7 +992,7 @@ function renderNav() {
|
||||
</a>`).join('')}
|
||||
</div>`).join('');
|
||||
|
||||
nav.querySelectorAll('[data-page]').forEach(link => {
|
||||
nav.querySelectorAll("[data-page]").forEach(link => {
|
||||
link.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
if (window.innerWidth <= 768) {
|
||||
@@ -996,12 +1003,19 @@ function renderNav() {
|
||||
userNav(link.dataset.page);
|
||||
});
|
||||
});
|
||||
if (typeof _initCollapsibleNav === 'function') _initCollapsibleNav();
|
||||
}
|
||||
|
||||
window.userNav = (page) => {
|
||||
_activePage = page;
|
||||
renderNav();
|
||||
const allItems = navGroups.flatMap(g => g.items);
|
||||
// Extra pages not in nav groups
|
||||
const extraPages = {
|
||||
'account-settings': accountSettings,
|
||||
'subdomains': userSubdomains,
|
||||
'parked-domains': userParked,
|
||||
};
|
||||
const item = allItems.find(n => n.id === page);
|
||||
const titleEl = document.getElementById('page-title');
|
||||
if (titleEl && item) titleEl.textContent = item.label;
|
||||
@@ -1076,9 +1090,10 @@ async function dockerPage(el) {
|
||||
<div class="stat-card"><div class="stat-label">Max CPUs / App</div><div class="stat-value stat-green">${quota.max_cpus}</div></div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:1rem;flex-wrap:wrap">
|
||||
<button class="btn btn-sm ${_uDockerTab==='my-apps'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('my-apps')">My Apps</button>
|
||||
<button class="btn btn-sm ${_uDockerTab==='catalog'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('catalog')">App Catalog</button>
|
||||
<button class="btn btn-sm btn-danger" style="margin-left:auto" onclick="uDockerUninstallAll()">Remove All My Apps</button>
|
||||
</div>
|
||||
<div id="udocker-content"><div class="loading">Loading…</div></div>`;
|
||||
|
||||
@@ -1103,6 +1118,18 @@ async function dockerPage(el) {
|
||||
|
||||
window._uDockerTab = 'my-apps';
|
||||
|
||||
window.uDockerUninstallAll = () => Nova.confirm(
|
||||
'Remove ALL your Docker apps? This will stop and delete every container and stack you own. Your hosting account and websites are not affected.',
|
||||
async () => {
|
||||
Nova.loading('Removing all your Docker apps…');
|
||||
const r = await Nova.api('docker', 'uninstall-account', { method: 'POST', body: {} });
|
||||
Nova.loadingDone();
|
||||
Nova.toast(r?.success ? 'All Docker apps removed' : (r?.error || r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) await uDockerReloadStacks();
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
async function uDockerReloadStacks() {
|
||||
const r = await Nova.api('docker', 'stacks');
|
||||
window._uDockerStacks = r?.data?.stacks || [];
|
||||
@@ -1142,6 +1169,7 @@ ${stacks.map(s=>`<tr>
|
||||
? `<button class="btn btn-xs btn-warning" onclick="uStackAct(${s.id},'down')">Stop</button>`
|
||||
: `<button class="btn btn-xs btn-success" onclick="uStackAct(${s.id},'up')">Start</button>`}
|
||||
<button class="btn btn-xs btn-ghost" onclick="uStackLogs(${s.id},'${Nova.escHtml(s.name)}')">Logs</button>
|
||||
<button class="btn btn-xs btn-secondary" onclick="uStackReinstall(${s.id},'${Nova.escHtml(s.name)}')">Reinstall</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="uStackRemove(${s.id},'${Nova.escHtml(s.name)}')">Remove</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
@@ -1180,10 +1208,22 @@ window.uStackLogs = async (stackId, name) => {
|
||||
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(r?.data?.output||'No logs available')}</pre>`);
|
||||
};
|
||||
|
||||
window.uStackReinstall = (stackId, name) => Nova.confirm(
|
||||
`Reinstall "${name}"? This will pull the latest images, restart all containers, and reset to a fresh state. Your data volumes will be preserved.`,
|
||||
async () => {
|
||||
Nova.loading(`Reinstalling ${name}…`);
|
||||
const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: stackId } });
|
||||
Nova.loadingDone();
|
||||
Nova.toast(r?.success ? `${name} reinstalled` : (r?.message || 'Reinstall failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) await uDockerReloadStacks();
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
window.uStackRemove = async (stackId, name) => {
|
||||
if (!confirm(`Remove app "${name}"? This will stop and delete its containers and data.`)) return;
|
||||
Nova.loading('Removing app…');
|
||||
const r = await Nova.api('docker', 'remove-stack', { method: 'POST', body: { stack_id: stackId } });
|
||||
const r = await Nova.api('docker', 'stack-remove', { method: 'POST', body: { stack_id: stackId } });
|
||||
Nova.loadingDone();
|
||||
Nova.toast(r?.success ? 'App removed' : (r?.message||'Failed'), r?.success?'success':'error');
|
||||
if (r?.success) await uDockerReloadStacks();
|
||||
@@ -1248,3 +1288,231 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
renderNav();
|
||||
window.userNav('dashboard');
|
||||
});
|
||||
|
||||
// ══ SUBDOMAINS PAGE ════════════════════════════════════════════════════════
|
||||
async function userSubdomains() {
|
||||
Nova.loadPage('subdomains', 'Subdomains', `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<p class="text-muted" style="margin:0">Create subdomains on your hosted domains.</p>
|
||||
<button class="btn btn-primary btn-sm" onclick="addSubdomain()">+ Add Subdomain</button>
|
||||
</div>
|
||||
<div class="card"><div id="subdomains-list"><div class="loading">Loading…</div></div></div>`);
|
||||
loadSubdomainsList();
|
||||
}
|
||||
|
||||
async function loadSubdomainsList() {
|
||||
const el = document.getElementById('subdomains-list');
|
||||
if (!el) return;
|
||||
const res = await Nova.api('domains', 'list');
|
||||
if (!res?.success) { el.innerHTML = '<div class="empty">No domains found</div>'; return; }
|
||||
const rows = res.data.filter(d => d.type === 'subdomain');
|
||||
if (!rows.length) {
|
||||
el.innerHTML = '<div class="empty">No subdomains yet. Click <strong>+ Add Subdomain</strong> to create one.</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<table class="table"><thead><tr>
|
||||
<th>Subdomain</th><th>Document Root</th><th>SSL</th><th>Created</th><th>Actions</th>
|
||||
</tr></thead><tbody>${rows.map(d => `<tr>
|
||||
<td><strong>${d.domain}</strong></td>
|
||||
<td style="font-size:.78rem;color:var(--text-muted)">${d.document_root||'—'}</td>
|
||||
<td>${d.ssl_enabled ? Nova.badge('SSL','green') : '<span class="text-muted" style="font-size:.78rem">None</span>'}</td>
|
||||
<td style="font-size:.78rem">${d.created_at?.split('T')[0]||d.created_at||''}</td>
|
||||
<td><button class="btn btn-xs btn-danger" onclick="removeDomainEntry(${d.id},'${d.domain}')">Remove</button></td>
|
||||
</tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
window.userSubdomains = userSubdomains;
|
||||
|
||||
window.addSubdomain = () => {
|
||||
Nova.modal('Add Subdomain', `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Subdomain prefix</label>
|
||||
<input id="sd-prefix" class="form-control" placeholder="e.g. blog">
|
||||
<small class="text-muted">Creates <em>blog.yourdomain.com</em></small>
|
||||
</div>`, `<button class="btn btn-primary" onclick="submitSubdomain()">Create</button>`);
|
||||
};
|
||||
|
||||
window.submitSubdomain = async () => {
|
||||
const sub = document.getElementById('sd-prefix')?.value?.trim();
|
||||
if (!sub) { Nova.toast('Enter a subdomain prefix','error'); return; }
|
||||
const res = await Nova.api('domains', 'add-subdomain', { method: 'POST', body: { subdomain: sub } });
|
||||
if (res?.success) { Nova.toast(res.message,'success'); document.querySelector('.modal-overlay')?.remove(); loadSubdomainsList(); }
|
||||
else Nova.toast(res?.message||'Failed','error');
|
||||
};
|
||||
|
||||
window.removeDomainEntry = (id, domain) => {
|
||||
Nova.confirm(`Remove ${domain}? This will delete its vhost and directory.`, async () => {
|
||||
const res = await Nova.api('domains','remove',{method:'POST',body:{id}});
|
||||
if (res?.success) { Nova.toast('Removed','success'); loadSubdomainsList(); loadParkedList(); }
|
||||
else Nova.toast(res?.message||'Failed','error');
|
||||
}, true);
|
||||
};
|
||||
|
||||
// ══ PARKED DOMAINS PAGE ════════════════════════════════════════════════════
|
||||
async function userParked() {
|
||||
Nova.loadPage('parked-domains', 'Parked Domains', `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<p class="text-muted" style="margin:0">Parked domains point additional domains to your account's main content.</p>
|
||||
<button class="btn btn-primary btn-sm" onclick="addParked()">+ Park Domain</button>
|
||||
</div>
|
||||
<div class="card"><div id="parked-list"><div class="loading">Loading…</div></div></div>`);
|
||||
loadParkedList();
|
||||
}
|
||||
|
||||
async function loadParkedList() {
|
||||
const el = document.getElementById('parked-list');
|
||||
if (!el) return;
|
||||
const res = await Nova.api('domains','list');
|
||||
if (!res?.success) { el.innerHTML = '<div class="empty">No domains</div>'; return; }
|
||||
const rows = res.data.filter(d => d.type === 'parked' || d.type === 'alias');
|
||||
const main = res.data.find(d => d.type === 'main');
|
||||
if (!rows.length) {
|
||||
el.innerHTML = `<div class="empty">No parked domains yet. Click <strong>+ Park Domain</strong> to add one.</div>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<table class="table"><thead><tr>
|
||||
<th>Parked Domain</th><th>Points To</th><th>Created</th><th>Actions</th>
|
||||
</tr></thead><tbody>${rows.map(d=>`<tr>
|
||||
<td><strong>${d.domain}</strong></td>
|
||||
<td style="font-size:.78rem;color:var(--text-muted)">${main?.domain||'—'}</td>
|
||||
<td style="font-size:.78rem">${d.created_at?.split('T')[0]||d.created_at||''}</td>
|
||||
<td><button class="btn btn-xs btn-danger" onclick="removeDomainEntry(${d.id},'${d.domain}')">Remove</button></td>
|
||||
</tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
window.userParked = userParked;
|
||||
|
||||
window.addParked = () => {
|
||||
Nova.modal('Park a Domain', `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Domain name</label>
|
||||
<input id="pd-domain" class="form-control" placeholder="e.g. example.com">
|
||||
<small class="text-muted">This domain will serve the same content as your primary domain.</small>
|
||||
</div>`, `<button class="btn btn-primary" onclick="submitParked()">Park Domain</button>`);
|
||||
};
|
||||
|
||||
window.submitParked = async () => {
|
||||
const domain = document.getElementById('pd-domain')?.value?.trim();
|
||||
if (!domain) { Nova.toast('Enter a domain','error'); return; }
|
||||
const res = await Nova.api('domains','add-alias',{method:'POST',body:{domain}});
|
||||
if (res?.success) { Nova.toast(res.message,'success'); document.querySelector('.modal-overlay')?.remove(); loadParkedList(); }
|
||||
else Nova.toast(res?.message||'Failed','error');
|
||||
};
|
||||
|
||||
// ══ #38 ACCOUNT SETTINGS PAGE ═════════════════════════════════════════════
|
||||
async function accountSettings(el) {
|
||||
Nova.loadPage('account-settings', 'Account Settings', `
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.25rem" id="acct-settings-grid">
|
||||
<div class="loading" style="grid-column:1/-1">Loading…</div>
|
||||
</div>`);
|
||||
|
||||
const [usageRes, phpRes] = await Promise.all([
|
||||
Nova.api('stats', 'account'),
|
||||
Nova.api('php', 'config'),
|
||||
]);
|
||||
|
||||
const u = usageRes?.data || {};
|
||||
const p = {};
|
||||
const a = { username: u.username || '—', domain: u.domain || '—',
|
||||
php_version: phpRes?.data?.php_version || '8.3',
|
||||
status: 'active', created_at: '' };
|
||||
const el2 = document.getElementById('acct-settings-grid');
|
||||
if (!el2) return;
|
||||
|
||||
el2.innerHTML = `
|
||||
<!-- Account Info -->
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<div class="card-header"><h3 class="card-title">Account Information</h3></div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.75rem;padding:.75rem 0">
|
||||
${[
|
||||
['Username', a.username||'—'],
|
||||
['Primary Domain', a.domain||'—'],
|
||||
['Package', p.name||'Default'],
|
||||
['Status', a.status ? Nova.badge(a.status, a.status==='active'?'green':'yellow') : '—'],
|
||||
['PHP Version', a.php_version||'8.3'],
|
||||
['Created', (a.created_at||'').split('T')[0]||'—'],
|
||||
].map(([k,v])=>`<div style="background:var(--bg3);border-radius:6px;padding:.75rem">
|
||||
<div style="font-size:.7rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:.25rem">${k}</div>
|
||||
<div style="font-weight:600;font-size:.9rem">${v}</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resource Usage -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Resource Usage</h3></div>
|
||||
${[
|
||||
['Disk', u.disk_mb||0, u.disk_limit, 'MB'],
|
||||
['Email', u.emails||0, u.email_limit, 'accounts'],
|
||||
['Databases', u.databases||0, u.db_limit, 'databases'],
|
||||
['FTP', u.ftp||0, u.ftp_limit, 'accounts'],
|
||||
['Domains', u.domains||0, u.domain_limit, 'domains'],
|
||||
].map(([name,used,limit,unit])=>{
|
||||
const pct = limit>0 ? Math.min(100,Math.round(used/limit*100)) : 0;
|
||||
const col = pct>90?'var(--red)':pct>70?'var(--yellow)':'var(--primary)';
|
||||
return `<div style="margin-bottom:.75rem">
|
||||
<div style="display:flex;justify-content:space-between;font-size:.8rem;margin-bottom:.3rem">
|
||||
<span>${name}</span>
|
||||
<span style="color:var(--text-muted)">${used}${limit>0?` / ${limit} ${unit}`:` ${unit}`}</span>
|
||||
</div>
|
||||
${limit>0?`<div style="height:5px;background:var(--bg3);border-radius:3px">
|
||||
<div style="height:100%;width:${pct}%;background:${col};border-radius:3px;transition:width .3s"></div>
|
||||
</div>`:''}
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
|
||||
<!-- PHP Settings -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">PHP Configuration</h3></div>
|
||||
<div style="display:grid;gap:.75rem;padding:.25rem 0">
|
||||
<div>
|
||||
<label class="form-label">PHP Version</label>
|
||||
<select id="as-php-ver" class="form-control form-control-sm">
|
||||
${['8.3','8.2','8.1','8.0','7.4'].map(v=>`<option value="${v}"${(a.php_version||'8.3')===v?' selected':''}>${v}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div><label class="form-label">Memory Limit</label>
|
||||
<select id="as-mem" class="form-control form-control-sm">
|
||||
${['128M','256M','512M','1G'].map(v=>`<option value="${v}"${v==='256M'?' selected':''}>${ v}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div><label class="form-label">Max Upload Size</label>
|
||||
<select id="as-upload" class="form-control form-control-sm">
|
||||
${['32M','64M','128M','256M'].map(v=>`<option value="${v}"${v==='64M'?' selected':''}>${ v}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div><label class="form-label">Max Execution Time (sec)</label>
|
||||
<select id="as-exec" class="form-control form-control-sm">
|
||||
${['30','60','120','300'].map(v=>`<option value="${v}"${v==='30'?' selected':''}>${ v}s</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" onclick="saveAccountPHP()">Save PHP Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security & Access -->
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<div class="card-header"><h3 class="card-title">Security & Access</h3></div>
|
||||
<div style="display:flex;gap:1rem;flex-wrap:wrap">
|
||||
<button class="btn btn-sm" onclick="userNav('change-password')">🔑 Change Password</button>
|
||||
<button class="btn btn-sm" onclick="userNav('ssl')">🔒 SSL / TLS Certificates</button>
|
||||
<button class="btn btn-sm" onclick="userNav('twofa')">📱 Two-Factor Auth</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
window.accountSettings = accountSettings;
|
||||
|
||||
window.saveAccountPHP = async () => {
|
||||
const body = {
|
||||
php_version: document.getElementById('as-php-ver')?.value,
|
||||
memory_limit: document.getElementById('as-mem')?.value,
|
||||
upload_max_filesize: document.getElementById('as-upload')?.value,
|
||||
post_max_size: document.getElementById('as-upload')?.value,
|
||||
max_execution_time: parseInt(document.getElementById('as-exec')?.value),
|
||||
};
|
||||
Nova.loading('Saving…');
|
||||
const res = await Nova.api('php', 'update-config', { method: 'POST', body });
|
||||
Nova.loadingDone();
|
||||
if (res?.success) Nova.toast('PHP settings saved', 'success');
|
||||
else Nova.toast(res?.message || 'Failed', 'error');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
// NovaCPX Reseller Panel — port 8881
|
||||
if (!defined('NOVACPX_ROOT')) define('NOVACPX_ROOT', dirname(__DIR__));
|
||||
if (!defined('NOVACPX_VERSION')) define('NOVACPX_VERSION', trim(@file_get_contents(NOVACPX_ROOT . '/VERSION') ?: '1.0.0'));
|
||||
$_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
|
||||
require_once dirname(__DIR__) . '/_branding.php';
|
||||
$_pname = novacpx_panel_name('NovaCPX');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="NovaCPX Reseller Panel — manage your reseller hosting accounts, packages, clients, and branding from one dashboard.">
|
||||
<meta name="keywords" content="reseller hosting panel, reseller control panel, manage hosting clients, white label hosting, NovaCPX reseller">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title><?= $_pname ?> — Reseller</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/nova-favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/css/nova.css<?= $_v('/assets/css/nova.css') ?>">
|
||||
<?php novacpx_branding_head() ?>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="panel-layout" id="main-layout" style="display:none">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<?= novacpx_logo_html('<svg class="logo-icon" viewBox="0 0 40 40" fill="none"><circle cx="20" cy="20" r="18" stroke="url(#rlg1)" stroke-width="2"/><path d="M12 28 L20 8 L28 28" stroke="url(#rlg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 22 H26" stroke="url(#rlg2)" stroke-width="2" stroke-linecap="round"/><defs><linearGradient id="rlg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient><linearGradient id="rlg2" 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"><?= $_pname ?> <small style="font-size:.65rem;color:var(--text-muted)">Reseller</small>
|
||||
<span style="display:block;font-size:.6rem;color:var(--text-muted);font-weight:400;line-height:1;margin-top:2px">v<?= NOVACPX_VERSION ?></span>
|
||||
</span>
|
||||
</div>
|
||||
<nav id="sidebar-nav"></nav>
|
||||
<div class="sidebar-user">
|
||||
<div class="sidebar-user-info">
|
||||
<div class="avatar" id="user-avatar">R</div>
|
||||
<div><div class="user-name" id="user-name">Reseller</div><div class="user-role">Reseller Account</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">
|
||||
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
|
||||
<div class="topbar-title" id="page-title">Reseller Dashboard</div>
|
||||
</header>
|
||||
<div class="page-content" id="page-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth guard / Inline login -->
|
||||
<div id="auth-check" style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg)">
|
||||
<div style="width:100%;max-width:400px;padding:1.5rem">
|
||||
<div style="text-align:center;margin-bottom:1.5rem">
|
||||
<svg viewBox="0 0 40 40" fill="none" style="width:40px;height:40px;margin:0 auto 1rem">
|
||||
<circle cx="20" cy="20" r="18" stroke="url(#rlga)" stroke-width="2"/>
|
||||
<path d="M12 28 L20 8 L28 28" stroke="url(#rlgb)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 22 H26" stroke="url(#rlgb)" stroke-width="2" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="rlga" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
<linearGradient id="rlgb" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div style="font-size:1.4rem;font-weight:300"><?= htmlspecialchars($_pname, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">Reseller Panel</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div id="li-err" class="alert alert-error" style="display:none"></div>
|
||||
<form id="login-form" onsubmit="event.preventDefault();doLogin()">
|
||||
<div class="form-group"><label>Username or Email</label><input type="text" id="li-user" autofocus autocomplete="username"></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="li-pass" autocomplete="current-password"></div>
|
||||
<button type="submit" class="btn btn-primary btn-full">Sign In to Reseller Panel</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.NOVACPX_BRANDING = <?= json_encode([
|
||||
'panel_name' => novacpx_panel_name('NovaCPX'),
|
||||
'support_email' => novacpx_get_branding()['support_email'] ?? null,
|
||||
'support_url' => novacpx_get_branding()['support_url'] ?? null,
|
||||
'hide_powered_by' => !novacpx_powered_by(),
|
||||
]) ?>;
|
||||
</script>
|
||||
<script src="/assets/js/nova.js<?= $_v('/assets/js/nova.js') ?>"></script>
|
||||
<script src="/assets/js/reseller.js<?= $_v('/assets/js/reseller.js') ?>"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
// NovaCPX User Panel — End-user hosting dashboard
|
||||
if (!defined('NOVACPX_ROOT')) define('NOVACPX_ROOT', dirname(__DIR__));
|
||||
if (!defined('NOVACPX_VERSION')) define('NOVACPX_VERSION', trim(@file_get_contents(NOVACPX_ROOT . '/VERSION') ?: '1.0.0'));
|
||||
$_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
|
||||
require_once dirname(__DIR__) . '/_branding.php';
|
||||
$_pname = novacpx_panel_name('NovaCPX');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Your hosting control panel — manage your website, domains, email accounts, databases, FTP, SSL certificates, and files in one place.">
|
||||
<meta name="keywords" content="hosting control panel, manage website, email hosting, domain management, database management, SSL certificate, FTP, web hosting dashboard">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title><?= $_pname ?> — My Hosting</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/nova-favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/css/nova.css<?= $_v('/assets/css/nova.css') ?>">
|
||||
<?php novacpx_branding_head() ?>
|
||||
<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="main-layout" style="display:none">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<?= novacpx_logo_html('<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"><?= $_pname ?> <small style="font-size:.65rem;color:var(--text-muted)">My Hosting</small>
|
||||
<span style="display:block;font-size:.6rem;color:var(--text-muted);font-weight:400;line-height:1;margin-top:2px">v<?= NOVACPX_VERSION ?></span>
|
||||
</span>
|
||||
</div>
|
||||
<nav id="sidebar-nav"></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">
|
||||
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
|
||||
<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;background:var(--bg)">
|
||||
<div style="width:100%;max-width:400px;padding:1.5rem">
|
||||
<div style="text-align:center;margin-bottom:1.5rem">
|
||||
<svg viewBox="0 0 40 40" fill="none" style="width:40px;height:40px;margin:0 auto 1rem">
|
||||
<circle cx="20" cy="20" r="18" stroke="url(#ulg3)" stroke-width="2"/>
|
||||
<path d="M12 28 L20 8 L28 28" stroke="url(#ulg4)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 22 H26" stroke="url(#ulg4)" stroke-width="2" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="ulg3" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
<linearGradient id="ulg4" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div style="font-size:1.4rem;font-weight:300"><?= htmlspecialchars($_pname, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">My Hosting</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div id="li-err" class="alert alert-error" style="display:none"></div>
|
||||
<form id="login-form" onsubmit="event.preventDefault();doLogin()">
|
||||
<div class="form-group"><label>Username or Email</label><input type="text" id="li-user" autofocus autocomplete="username"></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="li-pass" autocomplete="current-password"></div>
|
||||
<button type="submit" class="btn btn-primary btn-full">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.NOVACPX_BRANDING = <?= json_encode([
|
||||
'panel_name' => novacpx_panel_name('NovaCPX'),
|
||||
'support_email' => novacpx_get_branding()['support_email'] ?? null,
|
||||
'support_url' => novacpx_get_branding()['support_url'] ?? null,
|
||||
'hide_powered_by' => !novacpx_powered_by(),
|
||||
]) ?>;
|
||||
</script>
|
||||
<script src="/assets/js/nova.js<?= $_v('/assets/js/nova.js') ?>"></script>
|
||||
<script src="/assets/js/user.js<?= $_v('/assets/js/user.js') ?>"></script>
|
||||
<!-- user.js boots via DOMContentLoaded and handles all auth/init -->
|
||||
</body>
|
||||
</html>
|
||||
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env bash
|
||||
# NovaCPX Uninstaller
|
||||
# Backs up everything, then cleanly removes all NovaCPX components.
|
||||
# Usage: bash uninstall.sh [--yes]
|
||||
|
||||
set -euo pipefail
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BOLD='\033[1m'; NC='\033[0m'
|
||||
log() { echo -e "${GREEN}[✓]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
fail() { echo -e "${RED}[✗]${NC} $*"; exit 1; }
|
||||
step() { echo -e "\n${BOLD}━━━ $* ━━━${NC}"; }
|
||||
|
||||
[[ $EUID -ne 0 ]] && fail "Run as root"
|
||||
|
||||
SKIP_CONFIRM="${1:-}"
|
||||
DB_PATH=$(python3 -c "import configparser; c=configparser.ConfigParser(); c.read('/etc/novacpx/config.ini'); print(c.get('database','path',fallback='/var/lib/novacpx/panel.db'))" 2>/dev/null || echo "/var/lib/novacpx/panel.db")
|
||||
BACKUP_DIR="/tmp/novacpx-uninstall-backup-$(date +%Y%m%d-%H%M%S)"
|
||||
BACKUP_ARCHIVE="${BACKUP_DIR}.tar.gz"
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}NovaCPX Uninstaller${NC}"
|
||||
echo "This will completely remove NovaCPX and all hosted accounts."
|
||||
echo ""
|
||||
|
||||
# ── STEP 1: Create full backup ─────────────────────────────────────────────
|
||||
step "Creating full backup before removal"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"/{db,configs,accounts,logs,certs,nginx,systemd,cron}
|
||||
|
||||
# Database
|
||||
[[ -f "$DB_PATH" ]] && {
|
||||
cp "$DB_PATH" "$BACKUP_DIR/db/panel.db"
|
||||
log "Database backed up"
|
||||
}
|
||||
|
||||
# All account home directories
|
||||
if [[ -f "$DB_PATH" ]]; then
|
||||
while IFS='|' read -r user home; do
|
||||
[[ -d "$home" ]] && cp -a "$home" "$BACKUP_DIR/accounts/" 2>/dev/null && log "Account: $user ($home)"
|
||||
done < <(sqlite3 "$DB_PATH" "SELECT username, home_dir FROM accounts;" 2>/dev/null)
|
||||
fi
|
||||
|
||||
# Config files
|
||||
[[ -d /etc/novacpx ]] && cp -a /etc/novacpx "$BACKUP_DIR/configs/" && log "NovaCPX configs"
|
||||
[[ -d /var/lib/novacpx ]] && cp -a /var/lib/novacpx "$BACKUP_DIR/db/var-lib-novacpx" 2>/dev/null
|
||||
|
||||
# Nginx vhosts
|
||||
cp /etc/nginx/sites-available/novacpx-*.conf "$BACKUP_DIR/nginx/" 2>/dev/null || true
|
||||
log "Nginx vhosts"
|
||||
|
||||
# SSL certs per account
|
||||
[[ -d /etc/novacpx/ssl/accounts ]] && cp -a /etc/novacpx/ssl/accounts "$BACKUP_DIR/certs/" && log "SSL certs"
|
||||
|
||||
# Systemd units
|
||||
cp /etc/systemd/system/novacpx*.service "$BACKUP_DIR/systemd/" 2>/dev/null || true
|
||||
log "Systemd units"
|
||||
|
||||
# Cron
|
||||
cp /etc/cron.d/novacpx "$BACKUP_DIR/cron/" 2>/dev/null || true
|
||||
log "Cron jobs"
|
||||
|
||||
# Logs
|
||||
[[ -d /var/log/novacpx ]] && cp -a /var/log/novacpx "$BACKUP_DIR/logs/" && log "Logs"
|
||||
|
||||
# DNS zones
|
||||
[[ -d /var/cache/bind ]] && cp -a /var/cache/bind "$BACKUP_DIR/configs/bind-cache" 2>/dev/null || true
|
||||
[[ -f /etc/bind/named.conf.local ]] && cp /etc/bind/named.conf.local "$BACKUP_DIR/configs/" 2>/dev/null || true
|
||||
|
||||
# Mail config
|
||||
[[ -d /etc/postfix ]] && cp -a /etc/postfix "$BACKUP_DIR/configs/postfix" 2>/dev/null || true
|
||||
[[ -d /etc/dovecot ]] && cp -a /etc/dovecot "$BACKUP_DIR/configs/dovecot" 2>/dev/null || true
|
||||
|
||||
# Compress backup — abort if tar fails rather than silently delete unbackedup files
|
||||
tar -czf "$BACKUP_ARCHIVE" -C "$(dirname $BACKUP_DIR)" "$(basename $BACKUP_DIR)" || {
|
||||
echo -e "${RED}[✗]${NC} Backup archive creation FAILED. Uninstall aborted to protect your data."
|
||||
echo " Staging dir preserved at: $BACKUP_DIR"
|
||||
exit 1
|
||||
}
|
||||
rm -rf "$BACKUP_DIR"
|
||||
BACKUP_SIZE=$(du -sh "$BACKUP_ARCHIVE" | cut -f1)
|
||||
log "Backup archive: $BACKUP_ARCHIVE ($BACKUP_SIZE)"
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}Backup complete.${NC}"
|
||||
echo ""
|
||||
echo "To download the backup before continuing:"
|
||||
echo " scp root@$(hostname -I | awk '{print $1}'):${BACKUP_ARCHIVE} ./"
|
||||
echo ""
|
||||
echo "Or serve temporarily:"
|
||||
echo " cd $(dirname $BACKUP_ARCHIVE) && python3 -m http.server 9999 &"
|
||||
echo " # Download: http://$(hostname -I | awk '{print $1}'):9999/$(basename $BACKUP_ARCHIVE)"
|
||||
echo ""
|
||||
|
||||
if [[ "$SKIP_CONFIRM" != "--yes" ]]; then
|
||||
read -r -p "Continue with uninstall? This CANNOT be undone. [yes/no]: " CONFIRM
|
||||
[[ "$CONFIRM" != "yes" ]] && { warn "Aborted."; exit 0; }
|
||||
fi
|
||||
|
||||
# ── STEP 2: Stop all NovaCPX services ────────────────────────────────────
|
||||
step "Stopping services"
|
||||
systemctl stop novacpx-web 2>/dev/null && log "Stopped novacpx-web" || true
|
||||
systemctl disable novacpx-web 2>/dev/null || true
|
||||
|
||||
# ── STEP 3: Remove hosting accounts ──────────────────────────────────────
|
||||
step "Removing hosting accounts"
|
||||
if [[ -f "$DB_PATH" ]]; then
|
||||
while IFS='|' read -r username home_dir; do
|
||||
[[ -z "$username" ]] && continue
|
||||
# Remove Linux user and home dir
|
||||
id "$username" &>/dev/null && userdel -r "$username" 2>/dev/null && log "Removed user: $username" || true
|
||||
# Remove PHP-FPM pool
|
||||
rm -f "/etc/php/8.3/fpm/pool.d/${username}.conf" 2>/dev/null || true
|
||||
# Remove nginx vhost
|
||||
rm -f "/etc/nginx/sites-available/novacpx-${username}.conf" \
|
||||
"/etc/nginx/sites-enabled/novacpx-${username}.conf" 2>/dev/null || true
|
||||
done < <(sqlite3 "$DB_PATH" "SELECT username, home_dir FROM accounts;" 2>/dev/null)
|
||||
fi
|
||||
|
||||
# Also clean any remaining novacpx vhosts
|
||||
rm -f /etc/nginx/sites-available/novacpx-*.conf \
|
||||
/etc/nginx/sites-enabled/novacpx-*.conf 2>/dev/null || true
|
||||
log "Nginx vhosts removed"
|
||||
|
||||
# Remove webacct if exists
|
||||
id webacct &>/dev/null && userdel -r webacct 2>/dev/null || true
|
||||
|
||||
# ── STEP 4: Remove PHP-FPM pools ─────────────────────────────────────────
|
||||
step "Removing PHP-FPM pools"
|
||||
for f in /etc/php/*/fpm/pool.d/novacpx-*.conf /etc/php/*/fpm/pool.d/webacct.conf; do
|
||||
[[ -f "$f" ]] && rm -f "$f" && log "Removed pool: $f" || true
|
||||
done
|
||||
systemctl reload php8.3-fpm 2>/dev/null || true
|
||||
|
||||
# ── STEP 5: Remove systemd units ─────────────────────────────────────────
|
||||
step "Removing systemd units"
|
||||
for svc in novacpx-web; do
|
||||
systemctl stop "$svc" 2>/dev/null; systemctl disable "$svc" 2>/dev/null
|
||||
rm -f "/etc/systemd/system/${svc}.service"
|
||||
rm -rf "/etc/systemd/system/${svc}.service.d"
|
||||
log "Removed: $svc"
|
||||
done
|
||||
systemctl daemon-reload
|
||||
|
||||
# ── STEP 6: Remove sudoers ────────────────────────────────────────────────
|
||||
step "Removing sudoers rules"
|
||||
for f in novacpx novacpx-firewall novacpx-panel novacpx-services; do
|
||||
rm -f "/etc/sudoers.d/$f" && log "Removed sudoers: $f" || true
|
||||
done
|
||||
# Remove any novacpx entries added to main sudoers
|
||||
sed -i '/Defaults:www-data !requiretty/d' /etc/sudoers 2>/dev/null || true
|
||||
|
||||
# ── STEP 7: Remove cron jobs ──────────────────────────────────────────────
|
||||
step "Removing cron jobs"
|
||||
rm -f /etc/cron.d/novacpx && log "Removed /etc/cron.d/novacpx" || true
|
||||
# Remove root crontab entries added by NovaCPX
|
||||
crontab -l 2>/dev/null | grep -v "novacpx" | crontab - 2>/dev/null || true
|
||||
log "Root crontab cleaned"
|
||||
|
||||
# ── STEP 8: Remove nginx default override ────────────────────────────────
|
||||
step "Restoring nginx defaults"
|
||||
# Restore default site (remove 444 return we added)
|
||||
cat > /etc/nginx/sites-available/default << 'NGINX'
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
root /var/www/html;
|
||||
index index.html index.htm;
|
||||
server_name _;
|
||||
location / { try_files $uri $uri/ =404; }
|
||||
}
|
||||
NGINX
|
||||
nginx -t 2>/dev/null && systemctl reload nginx && log "nginx restored"
|
||||
|
||||
# ── STEP 9: Remove mail/DNS config added by NovaCPX ──────────────────────
|
||||
step "Removing mail/DNS config"
|
||||
# Remove opendkim signing entries for hosted domains
|
||||
[[ -f /etc/opendkim/signing.table ]] && > /etc/opendkim/signing.table || true
|
||||
[[ -f /etc/opendkim/key.table ]] && > /etc/opendkim/key.table || true
|
||||
rm -rf /etc/opendkim/keys/* 2>/dev/null || true
|
||||
systemctl reload opendkim 2>/dev/null || true
|
||||
log "DKIM config cleared"
|
||||
|
||||
# Remove DNS zones for hosted domains
|
||||
rm -f /etc/bind/zones/db.novacpx.* 2>/dev/null || true
|
||||
# Remove novacpx entries from named.conf.local
|
||||
[[ -f /etc/bind/named.conf.local ]] && \
|
||||
sed -i '/novacpx/d' /etc/bind/named.conf.local 2>/dev/null || true
|
||||
systemctl reload named 2>/dev/null || true
|
||||
log "DNS zones cleared"
|
||||
|
||||
# Postfix virtual mailbox maps
|
||||
[[ -f /etc/postfix/novacpx_virtual_domains ]] && {
|
||||
rm -f /etc/postfix/novacpx_virtual_domains /etc/postfix/novacpx_virtual_mailbox \
|
||||
/etc/postfix/novacpx_virtual_aliases 2>/dev/null || true
|
||||
postmap /etc/postfix/novacpx_* 2>/dev/null || true
|
||||
systemctl reload postfix 2>/dev/null || true
|
||||
log "Postfix virtual tables cleared"
|
||||
}
|
||||
|
||||
# ── STEP 10: Remove all NovaCPX files ────────────────────────────────────
|
||||
step "Removing NovaCPX files"
|
||||
rm -rf /srv/novacpx && log "Removed /srv/novacpx"
|
||||
rm -rf /opt/novacpx-src && log "Removed /opt/novacpx-src"
|
||||
rm -rf /opt/novacpx && log "Removed /opt/novacpx"
|
||||
rm -rf /var/lib/novacpx && log "Removed /var/lib/novacpx"
|
||||
rm -rf /var/log/novacpx && log "Removed /var/log/novacpx"
|
||||
rm -rf /etc/novacpx && log "Removed /etc/novacpx"
|
||||
rm -f /etc/nginx/htpasswd.webacct 2>/dev/null || true
|
||||
rm -f /usr/local/bin/novacpx* /usr/local/bin/novacpx-* 2>/dev/null || true
|
||||
# Remove fail2ban filters
|
||||
rm -f /etc/fail2ban/filter.d/novacpx-*.conf \
|
||||
/etc/fail2ban/jail.d/novacpx*.conf 2>/dev/null || true
|
||||
systemctl reload fail2ban 2>/dev/null || true
|
||||
log "fail2ban filters removed"
|
||||
|
||||
# ── DONE ──────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${GREEN}${BOLD}NovaCPX has been fully uninstalled.${NC}"
|
||||
echo ""
|
||||
echo "Backup archive preserved at: $BACKUP_ARCHIVE"
|
||||
echo ""
|
||||
echo "Services still running (not removed, NovaCPX didn't own these):"
|
||||
echo " nginx, php8.3-fpm, postfix, dovecot, bind9, fail2ban"
|
||||
echo " (stop/remove them separately if no longer needed)"
|
||||
echo ""
|
||||
Reference in New Issue
Block a user