Compare commits

15 Commits

Author SHA1 Message Date
github-actions[bot] 2d6592e0e2 chore: bump version to 1.0.21-beta.8 [skip ci] 2026-06-10 14:45:31 +00:00
myron a5b2a67708 fix: reseller creation and management in admin panel
- admin.js was calling auth/register (action does not exist) — changed
  to users/create
- Reseller list was fetching from accounts/list which is for hosting
  accounts; fixed to users/list?role=reseller
- Replaced shared adminSuspend/adminChangePass (account-scoped) with
  dedicated adminResellerSuspend/Unsuspend/Passwd/Delete functions that
  operate on the users table
- Added users endpoint actions: create, suspend, unsuspend,
  change-password, delete — all admin-only, operating on user rows
  rather than hosting account rows
- Reseller delete disowns their accounts (sets reseller_id=NULL) rather
  than cascading delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:45:19 +00:00
github-actions[bot] 233a2105ed chore: bump version to 1.0.21-beta.7 [skip ci] 2026-06-10 14:38:01 +00:00
myron 321a88fa28 fix: copy VERSION to web root on every deploy
deploy-runner.sh was rsyncing panel/public/ but VERSION lives at repo
root — web root /srv/novacpx/public/VERSION was perpetually stale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:37:40 +00:00
github-actions[bot] ffb0683d4c chore: bump version to 1.0.21-beta.6 [skip ci] 2026-06-10 13:35:50 +00:00
myron 80e964ee8f feat: expand Docker app catalog from 60 to 140 apps
Added 80 new apps across 13 categories: AI/LLM (Ollama, Open-WebUI,
Flowise, Langfuse, AnythingLLM, LocalAI, ComfyUI), Dev Tools
(code-server, Jenkins, SonarQube, Vault, MailHog, Dozzle, Yacht,
Semaphore), Databases (Redis, MongoDB, PostgreSQL, MariaDB,
Elasticsearch, InfluxDB, Neo4j, Qdrant), Monitoring (Loki+Grafana,
Jaeger, VictoriaMetrics, changedetection.io, Metabase), Networking
(Pi-hole, AdGuard Home, NPM, Traefik, wg-easy, Cloudflared, CrowdSec,
Authelia), CMS/Commerce (Drupal, Joomla, Grav, PrestaShop, OpenCart,
Medusa, Answer), Project Mgmt (Plane, OpenProject, Leantime, WeKan,
Focalboard), Communication (Matrix Synapse, Gotify, ntfy, Grist,
Mailpit), File/Storage (Syncthing, SFTPGo, ownCloud, Duplicati,
Calibre-Web), ERP/Business (Odoo, Akaunting, Monica, Twenty CRM,
Dolibarr), Media (Audiobookshelf, Komga, Grocy, TubeArchivist, AFFiNE),
Smart Home (Home Assistant, Node-RED, Mosquitto, Zigbee2MQTT),
Dashboards (Dashy, Homarr, Homer, IT-Tools, CloudBeaver, pgAdmin,
phpMyAdmin, Infisical).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 13:35:38 +00:00
github-actions[bot] bebf01658d chore: bump version to 1.0.21-beta.5 [skip ci] 2026-06-10 13:14:06 +00:00
myron bc06cb1f22 feat: per-stack Reinstall + fix stack ownership enforcement
- API: stack-action/stack-remove now verify ownership for non-admin users
- API: add stack-reinstall action (pull latest images → down → up)
- User panel: add Reinstall button per stack; fix bug where remove-stack was called instead of stack-remove
- Admin panel: add Reinstall button per stack + dockerStackReinstall() handler
- User panel: Remove All My Apps now only removes the calling user's own containers/stacks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 13:13:53 +00:00
github-actions[bot] 1f179f1732 chore: bump version to 1.0.21-beta.4 [skip ci] 2026-06-10 12:59:14 +00:00
myron f4726c2b2e feat: Docker catalog in admin panel + per-account app removal
- Admin Docker page: add App Catalog tab (60 apps, account-picker modal)
- Admin Docker page: add dockerAdminLaunchApp() for launching apps on behalf of any account
- User panel: add 'Remove All My Apps' button — stops/removes only that user's own containers and stacks
- API: add uninstall-account action (user-scoped; admin can specify account_id, users limited to own account)
- Admin panel: no global Docker uninstall (would affect all users on the server)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 12:59:04 +00:00
github-actions[bot] 89c1ffd184 chore: bump version to 1.0.21-beta.3 [skip ci] 2026-06-10 12:55:56 +00:00
myron 4e371e8f2f feat: expand Docker app catalog from 21 to 60 apps
Adds 39 new apps across 8 categories:
- Media & Files: Jellyfin, Navidrome, Kavita, Paperless-ngx, File Browser, Seafile, Immich
- Monitoring & DevOps: Adminer, Grafana, Prometheus, Netdata, Glances, Healthchecks, Docker Registry, Verdaccio, Watchtower
- Git & CI/CD: Forgejo, Woodpecker CI
- Knowledge: BookStack, HedgeDoc, FreshRSS, Wallabag, Homepage
- Auth & Security: Keycloak, Authentik, Passbolt CE
- Analytics: Plausible (Postgres + ClickHouse)
- Low-code / No-code: Baserow, Appsmith CE, NocoDB
- Communication: Rocket.Chat, Chatwoot, Zammad
- Business: Invoice Ninja, Linkding, Mealie
- Design: Penpot, Excalidraw, Stirling PDF

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 12:55:44 +00:00
github-actions[bot] bc17356249 chore: bump version to 1.0.21-beta.2 [skip ci] 2026-06-10 12:46:04 +00:00
myron 5d1f3aa241 feat: add 12 Docker app catalog entries
Adds Uptime Kuma, Portainer CE, MinIO, n8n, Directus, Listmonk, Umami,
PhotoPrism, Meilisearch, Wiki.js, Vikunja, and Mattermost — each with
catalog metadata and a complete docker-compose template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 12:45:54 +00:00
github-actions[bot] 32e374a2d0 chore: bump version to 1.0.21-beta.1 [skip ci] 2026-06-10 12:33:01 +00:00
40 changed files with 386 additions and 6997 deletions
-3
View File
@@ -8,6 +8,3 @@ VERSION
node_modules/ node_modules/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
panel/public/adminer.php
panel/public/adminer.php
*.bak-*
+1 -1
View File
@@ -1 +1 @@
1.0.78 1.0.21-beta.8
@@ -1,30 +0,0 @@
-- 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';
+1 -15
View File
@@ -14,9 +14,6 @@ http {
sendfile on; sendfile on;
gzip 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) ───────────────────────────────────────────────────── # ── Admin Panel (8882) ─────────────────────────────────────────────────────
server { server {
listen 8882 ssl; listen 8882 ssl;
@@ -46,18 +43,7 @@ http {
include /etc/nginx/fastcgi_params; include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME /srv/novacpx/public/api/index.php; fastcgi_param SCRIPT_FILENAME /srv/novacpx/public/api/index.php;
fastcgi_param SERVER_PORT 8882; fastcgi_param SERVER_PORT 8882;
fastcgi_read_timeout 1800; fastcgi_read_timeout 300;
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";
} }
} }
-179
View File
@@ -1,179 +0,0 @@
#!/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"
+2 -49
View File
@@ -30,7 +30,7 @@ info() { echo -e "${BLUE}[→]${NC} $*" | tee -a "$LOG"; }
step() { echo -e "\n${BOLD}━━━ $* ━━━${NC}" | tee -a "$LOG"; } step() { echo -e "\n${BOLD}━━━ $* ━━━${NC}" | tee -a "$LOG"; }
# ── Argument parsing ────────────────────────────────────────────────────────── # ── Argument parsing ──────────────────────────────────────────────────────────
WEB_SERVER="nginx" WEB_SERVER="apache"
INSTALL_MYSQL=true INSTALL_MYSQL=true
INSTALL_POSTGRES=true INSTALL_POSTGRES=true
@@ -133,7 +133,7 @@ export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >> "$LOG" 2>&1 apt-get update -qq >> "$LOG" 2>&1
apt-get upgrade -y -qq >> "$LOG" 2>&1 apt-get upgrade -y -qq >> "$LOG" 2>&1
apt-get install -y -qq curl wget gnupg2 lsb-release ca-certificates \ apt-get install -y -qq curl wget gnupg2 lsb-release ca-certificates \
software-properties-common apt-transport-https zip unzip git \ software-properties-common apt-transport-https unzip git \
sudo cron logrotate ufw fail2ban sshpass sqlite3 >> "$LOG" 2>&1 sudo cron logrotate ufw fail2ban sshpass sqlite3 >> "$LOG" 2>&1
log "System packages updated" log "System packages updated"
@@ -225,9 +225,6 @@ server {
} }
NGXCONF NGXCONF
ln -sf "$PANEL_WEB_CONF" /etc/nginx/sites-enabled/novacpx 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 else
apt-get install -y -qq apache2 libapache2-mod-fcgid >> "$LOG" 2>&1 apt-get install -y -qq apache2 libapache2-mod-fcgid >> "$LOG" 2>&1
@@ -722,30 +719,9 @@ 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 restart php*-fpm
www-data ALL=(root) NOPASSWD: /bin/systemctl start 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: /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) # 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/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/bin/tee /etc/apache2/conf-enabled/*
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 SUDOERS
chmod 440 /etc/sudoers.d/novacpx-firewall chmod 440 /etc/sudoers.d/novacpx-firewall
log "Sudoers rules installed" log "Sudoers rules installed"
@@ -760,32 +736,9 @@ cat > /etc/cron.d/novacpx <<CRON
0 2 * * * root /usr/local/bin/novacpx-backup >> /var/log/novacpx/backup.log 2>&1 0 2 * * * root /usr/local/bin/novacpx-backup >> /var/log/novacpx/backup.log 2>&1
*/1 * * * * root /usr/local/bin/novacpx-dns-sync >> /var/log/novacpx/dns.log 2>&1 */1 * * * * root /usr/local/bin/novacpx-dns-sync >> /var/log/novacpx/dns.log 2>&1
CRON 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 mkdir -p /var/log/novacpx
log "Cron jobs installed" 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 ────────────────────────────────────────────────────────── # ── Restart services ──────────────────────────────────────────────────────────
step "Starting All Services" step "Starting All Services"
if [[ "$WEB_SERVER" == "nginx" ]]; then if [[ "$WEB_SERVER" == "nginx" ]]; then
-85
View File
@@ -1,85 +0,0 @@
<?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']);
}
+20 -46
View File
@@ -1,34 +1,33 @@
<?php <?php
// NovaCPX Admin Panel — Datacenter/Server Manager // NovaCPX Admin Panel — Datacenter/Server Manager
if (!defined('NOVACPX_ROOT')) define('NOVACPX_ROOT', dirname(__DIR__)); // Equivalent to WHM (WebHost Manager)
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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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> <title>NovaCPX Admin</title>
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
<link rel="stylesheet" href="/assets/css/nova.css<?= $_v('/assets/css/nova.css') ?>"> <link rel="stylesheet" href="/assets/css/nova.css">
</head> </head>
<body> <body>
<div class="panel-layout" id="app" style="display:none"> <div class="panel-layout" id="app" style="display:none">
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<!-- Sidebar --> <!-- Sidebar -->
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
<div class="sidebar-brand"> <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(#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>') ?> <svg class="logo-icon" viewBox="0 0 40 40" fill="none">
<span class="logo-text">Nova<strong>CPX</strong> <small style="font-size:.65rem;color:var(--text-muted)">Admin</small> <circle cx="20" cy="20" r="18" stroke="url(#lg1)" stroke-width="2"/>
<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> <path d="M12 28 L20 8 L28 28" stroke="url(#lg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</span> <path d="M14 22 H26" stroke="url(#lg2)" stroke-width="2" stroke-linecap="round"/>
<defs>
<linearGradient id="lg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
<linearGradient id="lg2" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
</defs>
</svg>
<span class="logo-text">Nova<strong>CPX</strong> <small style="font-size:.65rem;color:var(--text-muted)">Admin</small></span>
</div> </div>
<nav> <nav>
@@ -38,6 +37,10 @@ require_once dirname(__DIR__) . '/_branding.php';
<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> <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 Dashboard
</a> </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>
<div class="sidebar-section"> <div class="sidebar-section">
@@ -94,18 +97,10 @@ require_once dirname(__DIR__) . '/_branding.php';
<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> <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 FTP Server
</a> </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"> <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> <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 WordPress
</a> </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>
<div class="sidebar-section"> <div class="sidebar-section">
@@ -116,11 +111,7 @@ require_once dirname(__DIR__) . '/_branding.php';
</a> </a>
<a href="#" class="sidebar-link" data-page="firewall"> <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> <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 (UFW) Firewall / Fail2Ban
</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>
<a href="#" class="sidebar-link" data-page="audit-log"> <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> <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>
@@ -130,10 +121,6 @@ require_once dirname(__DIR__) . '/_branding.php';
<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> <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 2FA Manager
</a> </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>
<div class="sidebar-section"> <div class="sidebar-section">
@@ -146,22 +133,10 @@ require_once dirname(__DIR__) . '/_branding.php';
<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> <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 Backups
</a> </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"> <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> <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 Cloudflare
</a> </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"> <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> <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 Settings
@@ -186,8 +161,7 @@ require_once dirname(__DIR__) . '/_branding.php';
<!-- Main Content --> <!-- Main Content -->
<div class="main-content"> <div class="main-content">
<header class="topbar"> <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> <button class="btn btn-ghost btn-icon" id="sidebar-toggle" style="display:none">☰</button>
<div class="topbar-title" id="page-title">Dashboard</div> <div class="topbar-title" id="page-title">Dashboard</div>
<div class="topbar-actions"> <div class="topbar-actions">
<span id="server-ip" class="text-muted text-sm"></span> <span id="server-ip" class="text-muted text-sm"></span>
@@ -232,7 +206,7 @@ require_once dirname(__DIR__) . '/_branding.php';
</div> </div>
</div> </div>
<script src="/assets/js/nova.js<?= $_v('/assets/js/nova.js') ?>"></script> <script src="/assets/js/nova.js"></script>
<script src="/assets/js/admin.js<?= $_v('/assets/js/admin.js') ?>"></script> <script src="/assets/js/admin.js"></script>
</body> </body>
</html> </html>
+3 -16
View File
@@ -69,14 +69,7 @@ match ($action) {
$required = ['username','domain','email','password']; $required = ['username','domain','email','password'];
foreach ($required as $f) { if (empty($body[$f])) Response::error("$f is required"); } foreach ($required as $f) { if (empty($body[$f])) Response::error("$f is required"); }
if (!filter_var($body['email'], FILTER_VALIDATE_EMAIL)) Response::error("Invalid email address"); // Create user account
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( $userId = (int)$db->insert(
"INSERT INTO users (username, password, email, role, status, reseller_id) VALUES (?,?,?,?,?,?)", "INSERT INTO users (username, password, email, role, status, reseller_id) VALUES (?,?,?,?,?,?)",
[ [
@@ -90,15 +83,9 @@ match ($action) {
); );
$body['user_id'] = $userId; $body['user_id'] = $userId;
try { $result = AccountManager::create($body);
$result = AccountManager::create($body);
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
audit('account.create', $body['domain'], $result); audit('account.create', $body['domain'], $result);
// Send welcome email to user + admin notification
Notifier::accountCreated(array_merge($body, ['email' => $body['email']]), $body['password']); Notifier::accountCreated(array_merge($body, ['email' => $body['email']]), $body['password']);
Response::success($result, 'Account created successfully'); Response::success($result, 'Account created successfully');
})(), })(),
-23
View File
@@ -265,28 +265,5 @@ match ($action) {
Response::success($result, ucfirst($appKey) . ' launched successfully'); 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), default => Response::error("Unknown docker action: $action", 404),
}; };
-16
View File
@@ -98,21 +98,5 @@ match ($action) {
Response::success(null, 'Autoresponder deleted'); 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), default => Response::error("Unknown email action: $action", 404),
}; };
+29 -128
View File
@@ -1,14 +1,7 @@
<?php <?php
/** /**
* Files endpoint — file manager (list, read, write, rename, delete, chmod, chown, upload, download, archive) * Files endpoint — file manager (list, read, write, rename, delete, chmod, upload, archive)
* Strictly confined to account home directory via realpath check. * 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(); $db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? []; $body = json_decode(file_get_contents('php://input'), true) ?? [];
@@ -18,15 +11,11 @@ $acct = $db->fetchOne("SELECT * FROM accounts WHERE user_id = ?", [$user['uid']]
if (!$acct && $user['role'] !== 'admin') Response::error("No hosting account found", 404); if (!$acct && $user['role'] !== 'admin') Response::error("No hosting account found", 404);
$baseDir = $acct ? realpath($acct['home_dir']) : '/'; $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 // For existing paths only — throws if path doesn't exist or escapes baseDir
function safe_path(string $base, string $rel): string { function safe_path(string $base, string $rel): string {
global $baseDirPrefix;
$full = realpath($base . '/' . ltrim($rel, '/')); $full = realpath($base . '/' . ltrim($rel, '/'));
if ($full === false || !str_starts_with($full . '/', $baseDirPrefix . '/')) { if ($full === false || !str_starts_with($full . '/', $base . '/')) {
throw new RuntimeException("Path outside account directory"); throw new RuntimeException("Path outside account directory");
} }
return $full; return $full;
@@ -34,39 +23,14 @@ function safe_path(string $base, string $rel): string {
// For paths that may not exist yet (write/mkdir) — validates parent dir // For paths that may not exist yet (write/mkdir) — validates parent dir
function safe_path_new(string $base, string $rel): string { function safe_path_new(string $base, string $rel): string {
global $baseDirPrefix;
$candidate = $base . '/' . ltrim(str_replace(['../', '..\\'], '', $rel), '/'); $candidate = $base . '/' . ltrim(str_replace(['../', '..\\'], '', $rel), '/');
$parent = realpath(dirname($candidate)); $parent = realpath(dirname($candidate));
if ($parent === false || !str_starts_with($parent . '/', $baseDirPrefix . '/')) { if ($parent === false || !str_starts_with($parent . '/', $base . '/')) {
throw new RuntimeException("Path outside account directory"); throw new RuntimeException("Path outside account directory");
} }
return $parent . '/' . basename($candidate); 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 { function fmt_size(int $bytes): string {
if ($bytes >= 1073741824) return round($bytes/1073741824, 1) . 'GB'; if ($bytes >= 1073741824) return round($bytes/1073741824, 1) . 'GB';
if ($bytes >= 1048576) return round($bytes/1048576, 1) . 'MB'; if ($bytes >= 1048576) return round($bytes/1048576, 1) . 'MB';
@@ -75,59 +39,35 @@ function fmt_size(int $bytes): string {
} }
match ($action) { match ($action) {
'list' => (function() use ($baseDir, $body, $user) { 'list' => (function() use ($baseDir, $body) {
$rel = $body['path'] ?? $_GET['path'] ?? ($user['role'] === 'admin' ? '/' : '/public_html'); $rel = $body['path'] ?? $_GET['path'] ?? '/public_html';
$path = safe_path($baseDir, $rel); $path = safe_path($baseDir, $rel);
if (!is_dir($path)) Response::error("Not a directory"); 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 = []; $items = [];
foreach (new DirectoryIterator($path) as $f) { foreach (new DirectoryIterator($path) as $f) {
if ($f->isDot()) continue; if ($f->isDot()) continue;
$uid = fileowner($f->getPathname());
$gid = filegroup($f->getPathname());
$itemPath = (($p = substr($path . '/' . $f->getFilename(), strlen(rtrim($baseDir, '/')))) === '' ? '/' : $p);
$items[] = [ $items[] = [
'name' => $f->getFilename(), 'name' => $f->getFilename(),
'type' => $f->isDir() ? 'dir' : 'file', 'type' => $f->isDir() ? 'dir' : 'file',
'link' => $f->isLink(), 'size' => $f->isFile() ? fmt_size($f->getSize()) : null,
'size' => $f->isFile() ? fmt_size($f->getSize()) : null, 'size_raw' => $f->isFile() ? $f->getSize() : 0,
'size_raw' => $f->isFile() ? $f->getSize() : 0, 'perms' => substr(sprintf('%o', $f->getPerms()), -4),
'perms' => substr(sprintf('%o', $f->getPerms()), -4), 'modified' => date('Y-m-d H:i', $f->getMTime()),
'owner' => $hasPosix ? (@posix_getpwuid($uid)['name'] ?? (string)$uid) : (string)$uid, 'path' => str_replace($baseDir, '', $path . '/' . $f->getFilename()),
'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'])); 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, 'base_writable' => is_writable($path)]); Response::success(['path' => $rel, 'items' => $items]);
})(), })(),
'read' => (function() use ($baseDir, $body) { 'read' => (function() use ($baseDir, $body) {
$path = safe_path($baseDir, $body['path'] ?? $_GET['path'] ?? ''); $path = safe_path($baseDir, $body['path'] ?? $_GET['path'] ?? '');
if (!is_file($path)) Response::error("Not a file"); if (!is_file($path)) Response::error("Not a file");
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)");
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'] ?? '']); 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) { 'write' => (function() use ($baseDir, $body) {
$rel = $body['path'] ?? ''; $rel = $body['path'] ?? '';
$content = $body['content'] ?? ''; $content = $body['content'] ?? '';
@@ -137,8 +77,6 @@ match ($action) {
} catch (RuntimeException) { } catch (RuntimeException) {
$path = safe_path_new($baseDir, $rel); $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 // PHP syntax check for .php files
if (str_ends_with($path, '.php')) { if (str_ends_with($path, '.php')) {
$tmp = tempnam(sys_get_temp_dir(), 'ncpx_'); $tmp = tempnam(sys_get_temp_dir(), 'ncpx_');
@@ -147,41 +85,34 @@ match ($action) {
unlink($tmp); unlink($tmp);
if (!str_contains($result ?? '', 'No syntax errors')) Response::error("PHP syntax error: $result"); if (!str_contains($result ?? '', 'No syntax errors')) Response::error("PHP syntax error: $result");
} }
if (file_put_contents($path, $content) === false) Response::error("Write failed (permission denied)"); file_put_contents($path, $content);
audit('files.write', $rel); audit('files.write', $rel);
Response::success(null, 'File saved'); Response::success(null, 'File saved');
})(), })(),
'mkdir' => (function() use ($baseDir, $body) { 'mkdir' => (function() use ($baseDir, $body) {
$path = safe_path_new($baseDir, $body['path'] ?? ''); $path = safe_path_new($baseDir, $body['path'] ?? '');
require_dangerous_confirm($baseDir, $path, $body);
if (is_dir($path)) Response::error("Directory already exists"); if (is_dir($path)) Response::error("Directory already exists");
if (!mkdir($path, 0755, true)) Response::error("Could not create directory (permission denied)"); if (!mkdir($path, 0755, true)) Response::error("Could not create directory");
audit('files.mkdir', $body['path'] ?? '');
Response::success(null, 'Directory created'); Response::success(null, 'Directory created');
})(), })(),
'rename' => (function() use ($baseDir, $body) { 'rename' => (function() use ($baseDir, $body) {
$from = safe_path($baseDir, $body['from'] ?? ''); $from = safe_path($baseDir, $body['from'] ?? '');
$to = safe_path_new($baseDir, $body['to'] ?? ''); $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"); if (file_exists($to)) Response::error("Destination already exists");
if (!rename($from, $to)) Response::error("Rename failed (permission denied)"); rename($from, $to);
audit('files.rename', ($body['from'] ?? '') . ' -> ' . ($body['to'] ?? ''));
Response::success(null, 'Renamed'); Response::success(null, 'Renamed');
})(), })(),
'delete' => (function() use ($baseDir, $body) { 'delete' => (function() use ($baseDir, $body) {
$path = safe_path($baseDir, $body['path'] ?? ''); $path = safe_path($baseDir, $body['path'] ?? '');
// Prevent deleting the root directory itself or its direct children // Prevent deleting the account root or its direct children (public_html, logs, tmp)
// (for accounts: public_html/logs/tmp; for admin baseDir='/': /etc, /home, /var, /root, etc.) if ($path === $baseDir || dirname($path) === $baseDir) Response::error("Cannot delete root account directories");
if ($path === $baseDir || dirname($path) === $baseDir) Response::error("Cannot delete a top-level directory");
require_dangerous_confirm($baseDir, $path, $body);
if (is_dir($path)) { if (is_dir($path)) {
shell_exec("rm -rf " . escapeshellarg($path) . " 2>&1"); shell_exec("rm -rf " . escapeshellarg($path));
} else { } else {
if (!unlink($path)) Response::error("Delete failed (permission denied)"); unlink($path);
} }
audit('files.delete', $body['path'] ?? ''); audit('files.delete', $body['path'] ?? '');
Response::success(null, 'Deleted'); Response::success(null, 'Deleted');
@@ -189,72 +120,42 @@ match ($action) {
'chmod' => (function() use ($baseDir, $body) { 'chmod' => (function() use ($baseDir, $body) {
$path = safe_path($baseDir, $body['path'] ?? ''); $path = safe_path($baseDir, $body['path'] ?? '');
require_dangerous_confirm($baseDir, $path, $body);
$perms = octdec((string)($body['perms'] ?? '755')); $perms = octdec((string)($body['perms'] ?? '755'));
// Clamp to sane range: no setuid/setgid/sticky, no world-write on dirs // Clamp to sane range: no setuid/setgid/sticky, no world-write on dirs
$perms = $perms & 0777; $perms = $perms & 0777;
if (!chmod($path, $perms)) Response::error("chmod failed (permission denied)"); chmod($path, $perms);
audit('files.chmod', $body['path'] ?? '', ['perms' => decoct($perms)]);
Response::success(null, 'Permissions updated'); 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) { 'upload' => (function() use ($baseDir, $body) {
$dir = safe_path($baseDir, $body['path'] ?? $_POST['path'] ?? $_GET['path'] ?? '/public_html'); $dir = safe_path($baseDir, $body['path'] ?? '/public_html');
require_dangerous_confirm($baseDir, $dir, $body);
if (!is_dir($dir)) Response::error("Target directory not found"); 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"); if (empty($_FILES['file'])) Response::error("No file uploaded");
$filename = basename($_FILES['file']['name']); $filename = basename($_FILES['file']['name']);
if ($filename === '' || $filename === '.') Response::error("Invalid filename"); if ($filename === '' || $filename === '.') Response::error("Invalid filename");
$dest = $dir . '/' . $filename; $dest = $dir . '/' . $filename;
if (!str_starts_with($dest, rtrim($baseDir, '/') . '/')) Response::error("Invalid destination"); if (!str_starts_with($dest, $baseDir . '/')) Response::error("Invalid destination");
if (!move_uploaded_file($_FILES['file']['tmp_name'], $dest)) Response::error("Upload failed (permission denied)"); move_uploaded_file($_FILES['file']['tmp_name'], $dest);
audit('files.upload', ($body['path'] ?? '') . '/' . $filename);
Response::success(['name' => $filename], 'File uploaded'); Response::success(['name' => $filename], 'File uploaded');
})(), })(),
'compress' => (function() use ($baseDir, $body) { 'compress' => (function() use ($baseDir, $body) {
$paths = array_map(fn($p) => safe_path($baseDir, $p), (array)($body['paths'] ?? [])); $paths = array_map(fn($p) => safe_path($baseDir, $p), (array)($body['paths'] ?? []));
$dest = safe_path_new($baseDir, $body['dest'] ?? 'archive.zip'); $dest = safe_path_new($baseDir, $body['dest'] ?? 'archive.zip');
require_dangerous_confirm($baseDir, $dest, $body);
$files = implode(' ', array_map('escapeshellarg', $paths)); $files = implode(' ', array_map('escapeshellarg', $paths));
shell_exec("cd " . escapeshellarg($baseDir) . " && zip -r " . escapeshellarg($dest) . " $files 2>&1"); shell_exec("cd " . escapeshellarg($baseDir) . " && zip -r " . escapeshellarg($dest) . " $files 2>/dev/null");
audit('files.compress', $body['dest'] ?? '', ['paths' => $body['paths'] ?? []]);
Response::success(null, 'Archive created'); Response::success(null, 'Archive created');
})(), })(),
'extract' => (function() use ($baseDir, $body) { 'extract' => (function() use ($baseDir, $body) {
$file = safe_path($baseDir, $body['path'] ?? ''); $file = safe_path($baseDir, $body['path'] ?? '');
$dest = safe_path($baseDir, $body['dest'] ?? dirname($body['path'] ?? '/')); $dest = safe_path($baseDir, $body['dest'] ?? dirname($body['path'] ?? '/'));
require_dangerous_confirm($baseDir, $dest, $body);
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
match($ext) { match($ext) {
'zip' => shell_exec("unzip -o " . escapeshellarg($file) . " -d " . escapeshellarg($dest) . " 2>&1"), 'zip' => shell_exec("unzip -o " . escapeshellarg($file) . " -d " . escapeshellarg($dest)),
'gz','tgz','tar' => shell_exec("tar xf " . escapeshellarg($file) . " -C " . escapeshellarg($dest) . " 2>&1"), 'gz','tgz','tar' => shell_exec("tar xf " . escapeshellarg($file) . " -C " . escapeshellarg($dest)),
default => Response::error("Unsupported archive type"), default => Response::error("Unsupported archive type"),
}; };
audit('files.extract', $body['path'] ?? '');
Response::success(null, 'Extracted'); Response::success(null, 'Extracted');
})(), })(),
+3 -4
View File
@@ -50,10 +50,9 @@ match ($action) {
"UPDATE packages SET name=?, disk_mb=?, bandwidth_mb=?, max_domains=?, max_subdomains=?, "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=?", max_addon_domains=?, max_parked_domains=?, max_email=?, max_ftp=?, max_databases=?, php_version=?, ssl_enabled=? WHERE id=?",
[ [
$body['name'] ?? '', $body['disk_mb'] ?? 0, $body['bandwidth_mb'] ?? 0, $body['name'], $body['disk_mb'], $body['bandwidth_mb'], $body['max_domains'],
$body['max_domains'] ?? 0, $body['max_subdomains'] ?? 0, $body['max_subdomains'], $body['max_addon_domains'], $body['max_parked_domains'],
$body['max_addon_domains'] ?? 0, $body['max_parked_domains'] ?? 0, $body['max_email'], $body['max_ftp'], $body['max_databases'],
$body['max_email'] ?? 0, $body['max_ftp'] ?? 0, $body['max_databases'] ?? 0,
$body['php_version'] ?? '8.3', $body['ssl_enabled'] ?? 1, $id, $body['php_version'] ?? '8.3', $body['ssl_enabled'] ?? 1, $id,
] ]
); );
+9 -46
View File
@@ -390,13 +390,11 @@ BASH;
if ($active) $services[$svc] = $active; if ($active) $services[$svc] = $active;
} }
// Persist to DB for history (non-fatal — don't let lock errors kill the stats response) // Persist to DB for history
try { $db->execute(
$db->execute( "INSERT INTO server_stats (cpu_usage,ram_usage,disk_usage,load_avg) VALUES (?,?,?,?)",
"INSERT INTO server_stats (cpu_usage,ram_usage,disk_usage,load_avg) VALUES (?,?,?,?)", [$cpuPct, $ramPct, $diskPct, $load[0]]
[$cpuPct, $ramPct, $diskPct, $load[0]] );
);
} catch (Throwable $_) {}
Response::success([ Response::success([
'cpu' => ['pct' => $cpuPct, 'load' => $load], 'cpu' => ['pct' => $cpuPct, 'load' => $load],
@@ -924,8 +922,6 @@ BASH;
|| is_dir('/usr/share/phpmyadmin'); || is_dir('/usr/share/phpmyadmin');
$pgaInstalled = (int)trim(shell_exec("dpkg -l pgadmin4 2>/dev/null | grep -c '^ii'") ?: '0') > 0 $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'); || is_file('/usr/pgadmin4/bin/pgadmin4') || is_dir('/usr/pgadmin4');
$adminerInstalled = is_file(NOVACPX_ROOT . '/adminer.php');
$adminerVer = $adminerInstalled ? 'bundled' : '';
$pmaVer = $pmaInstalled $pmaVer = $pmaInstalled
? trim(shell_exec("dpkg -l phpmyadmin 2>/dev/null | awk '/^ii/{print $3}' | head -1") ?: '') ? trim(shell_exec("dpkg -l phpmyadmin 2>/dev/null | awk '/^ii/{print $3}' | head -1") ?: '')
: ''; : '';
@@ -933,9 +929,8 @@ BASH;
? trim(shell_exec("pgadmin4 --version 2>/dev/null | grep -oP '[0-9]+\\.[0-9]+' | head -1") ?: '') ? trim(shell_exec("pgadmin4 --version 2>/dev/null | grep -oP '[0-9]+\\.[0-9]+' | head -1") ?: '')
: ''; : '';
Response::success([ Response::success([
'phpmyadmin' => ['installed' => $pmaInstalled, 'version' => $pmaVer, 'url' => '/phpmyadmin'], 'phpmyadmin' => ['installed' => $pmaInstalled, 'version' => $pmaVer],
'pgadmin' => ['installed' => $pgaInstalled, 'version' => $pgaVer], 'pgadmin' => ['installed' => $pgaInstalled, 'version' => $pgaVer],
'adminer' => ['installed' => $adminerInstalled, 'version' => $adminerVer, 'url' => '/adminer.php'],
]); ]);
})(), })(),
@@ -943,7 +938,7 @@ BASH;
Auth::getInstance()->require('admin'); Auth::getInstance()->require('admin');
$tool = $body['tool'] ?? ''; $tool = $body['tool'] ?? '';
$action = $body['action'] ?? ''; $action = $body['action'] ?? '';
if (!in_array($tool, ['phpmyadmin','pgadmin','adminer'])) { echo 'data:'.json_encode(['error'=>'Invalid tool'])."\n\n"; exit; } if (!in_array($tool, ['phpmyadmin','pgadmin'])) { 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; } if (!in_array($action, ['install','reinstall','remove'])) { echo 'data:'.json_encode(['error'=>'Invalid action'])."\n\n"; exit; }
header('Content-Type: text/event-stream'); header('Content-Type: text/event-stream');
@@ -975,22 +970,7 @@ BASH;
$env = 'sudo env DEBIAN_FRONTEND=noninteractive'; $env = 'sudo env DEBIAN_FRONTEND=noninteractive';
if ($tool === 'adminer') { if ($tool === 'phpmyadmin') {
$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') { if ($action === 'remove') {
$sse("▶ Removing phpMyAdmin…\n"); $sse("▶ Removing phpMyAdmin…\n");
$run("$env apt-get remove -y phpmyadmin 2>&1"); $run("$env apt-get remove -y phpmyadmin 2>&1");
@@ -1075,22 +1055,5 @@ BASH;
exit; 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),
}; };
+2 -23
View File
@@ -9,35 +9,14 @@ define('NOVACPX_API', __DIR__);
define('NOVACPX_LIB', NOVACPX_ROOT . '/lib'); define('NOVACPX_LIB', NOVACPX_ROOT . '/lib');
header('Content-Type: application/json'); 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') $_ver = file_get_contents(NOVACPX_ROOT . '/VERSION')
?: file_get_contents('/opt/novacpx-src/VERSION') ?: file_get_contents('/opt/novacpx-src/VERSION')
?: '1.0.0'; ?: '1.0.0';
header('X-NovaCPX-Version: ' . trim($_ver)); header('X-NovaCPX-Version: ' . trim($_ver));
// CORS — only allow same-host origins on the panel ports or the known proxy hostnames // CORS for same-origin panel requests (ports 8880/8881/8882/8883)
$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$_allowedHosts = ['novacpx.orbishosting.com', 'admin.novacpx.orbishosting.com', if (preg_match('#^https?://[^/]+:(888[0-3])$#', $origin)) {
'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-Origin: $origin");
header('Access-Control-Allow-Credentials: true'); header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
-398
View File
@@ -1,398 +0,0 @@
/* 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; }
}
-9
View File
@@ -1,9 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 534 B

-329
View File
@@ -1,329 +0,0 @@
<!-- 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>

Before

Width:  |  Height:  |  Size: 16 KiB

-26
View File
@@ -1,26 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-15
View File
@@ -1,15 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 731 B

+171 -1814
View File
File diff suppressed because it is too large Load Diff
-133
View File
@@ -1,133 +0,0 @@
/**
* 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);
},
};
-242
View File
@@ -1,242 +0,0 @@
/**
* 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()">&times;</button>
</div>
<div class="modal-body">${bodyHtml}</div>
${footerHtml ? `<div class="modal-footer">${footerHtml}</div>` : ''}
</div>`;
ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
document.body.appendChild(ov);
return ov;
}
// ── Confirm dialog ────────────────────────────────────────────────────────
function confirm(msg, onYes, danger = false) {
const ov = modal('Confirm', `<p>${msg}</p>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-${danger ? 'red' : 'primary'}" id="confirm-yes">Confirm</button>`
);
ov.querySelector('#confirm-yes').onclick = () => { ov.remove(); onYes(); };
}
// ── Sidebar navigation ────────────────────────────────────────────────────
function initNav(pages) {
document.querySelectorAll('[data-page]').forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
const page = link.dataset.page;
document.querySelectorAll('[data-page]').forEach(l => l.classList.remove('active'));
link.classList.add('active');
const titleEl = document.getElementById('page-title');
if (titleEl) titleEl.textContent = link.textContent.trim();
loadPage(page, pages);
});
});
}
function loadPage(page, pages) {
const content = document.getElementById('page-content');
if (!content) return;
const fn = pages[page];
if (fn) {
content.innerHTML = '<div style="padding:2rem;color:var(--text-muted);text-align:center">Loading…</div>';
Promise.resolve(fn()).then(html => { if (html) content.innerHTML = html; });
} else {
content.innerHTML = `<div class="card"><div class="card-body"><p class="text-muted">Page "${page}" coming soon.</p></div></div>`;
}
}
// ── Progress bar helper ───────────────────────────────────────────────────
function progressBar(pct) {
const color = pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green';
return `<div class="progress"><div class="progress-bar ${color}" style="width:${pct}%"></div></div>`;
}
// ── Format helpers ────────────────────────────────────────────────────────
function bytes(n) {
if (n >= 1073741824) return (n / 1073741824).toFixed(1) + ' GB';
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB';
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
return n + ' B';
}
function relTime(dateStr) {
const diff = (Date.now() - new Date(dateStr)) / 1000;
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
}
function badge(text, type = 'blue') {
return `<span class="badge badge-${type}">${text}</span>`;
}
function serviceDot(status) {
const cls = status === 'active' ? 'active' : status === 'inactive' ? 'inactive' : 'unknown';
return `<span class="service-dot ${cls}"></span>`;
}
function escHtml(str) {
return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// ── 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(); })
);
});
-700
View File
@@ -1,700 +0,0 @@
/**
* 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
-39
View File
@@ -1,39 +0,0 @@
<?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>
-39
View File
@@ -1,39 +0,0 @@
<?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
View File
@@ -1,130 +0,0 @@
<?php
// NovaCPX entry point — redirect based on role or show login
session_start();
$redirect = $_GET['redirect'] ?? '';
$safeRedirect = preg_match('#^/(user|reseller|admin)#', $redirect) ? $redirect : '';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NovaCPX Login</title>
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
<link rel="stylesheet" href="/assets/css/nova.css">
</head>
<body class="login-page">
<div class="login-wrap">
<div class="login-brand">
<svg class="logo-icon" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="20" r="18" stroke="url(#lg1)" stroke-width="2"/>
<path d="M12 28 L20 8 L28 28" stroke="url(#lg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 22 H26" stroke="url(#lg2)" stroke-width="2" stroke-linecap="round"/>
<defs>
<linearGradient id="lg1" x1="2" y1="2" x2="38" y2="38">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#0ea5e9"/>
</linearGradient>
<linearGradient id="lg2" x1="12" y1="8" x2="28" y2="28">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#0ea5e9"/>
</linearGradient>
</defs>
</svg>
<span class="logo-text">Nova<strong>CPX</strong></span>
</div>
<div class="login-card">
<h1>Sign In</h1>
<p class="login-sub">Linux Web Hosting Control Panel</p>
<div id="login-error" class="alert alert-error" style="display:none"></div>
<form id="login-form">
<div class="form-group">
<label for="username">Username or Email</label>
<input type="text" id="username" name="username" autocomplete="username" autofocus required>
</div>
<div class="form-group">
<label for="password">Password</label>
<div class="input-with-icon">
<input type="password" id="password" name="password" autocomplete="current-password" required>
<button type="button" class="eye-toggle" data-target="password">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary btn-full" id="login-btn">
<span class="btn-text">Sign In</span>
<span class="btn-spinner" style="display:none">Signing in…</span>
</button>
</form>
</div>
<div class="login-footer">
NovaCPX v<span id="panel-version">1.0.0</span> &nbsp;|&nbsp;
<a href="/api/system/version" target="_blank">System Info</a>
</div>
</div>
<script>
const REDIRECT = <?= json_encode($safeRedirect) ?>;
document.getElementById('login-form').addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('login-btn');
const err = document.getElementById('login-error');
btn.querySelector('.btn-text').style.display = 'none';
btn.querySelector('.btn-spinner').style.display = '';
btn.disabled = true;
err.style.display = 'none';
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type':'application/json'},
credentials: 'include',
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
});
const data = await res.json();
if (!data.success) throw new Error(data.message || 'Login failed');
// 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 -45
View File
@@ -25,61 +25,41 @@ class AccountManager {
$docRoot = "{$homeDir}/public_html"; $docRoot = "{$homeDir}/public_html";
$password = $data['password'] ?? bin2hex(random_bytes(8)); $password = $data['password'] ?? bin2hex(random_bytes(8));
// Create Linux user and home directory first // Create Linux user
self::shell("useradd -m -d {$homeDir} -s /sbin/nologin -G www-data " . escapeshellarg($username)); self::shell("useradd -m -d {$homeDir} -s /sbin/nologin -G www-data " . escapeshellarg($username));
self::shell("echo " . escapeshellarg("{$username}:{$password}") . " | sudo chpasswd"); self::shell("echo " . escapeshellarg("{$username}:{$password}") . " | chpasswd");
self::shell("sudo mkdir -p {$docRoot} {$homeDir}/logs {$homeDir}/tmp"); self::shell("sudo mkdir -p {$docRoot} {$homeDir}/logs {$homeDir}/tmp");
self::shell("sudo chown -R {$username}:www-data {$homeDir}"); self::shell("sudo chown -R {$username}:www-data {$homeDir}");
self::shell("sudo chmod 750 {$homeDir}"); self::shell("sudo chmod 750 {$homeDir}"); self::shell("sudo chmod 775 {$docRoot}");
self::shell("sudo chmod 775 {$docRoot}");
// Default index page — use custom template from settings if set, else built-in // Default index page
$customTpl = null; file_put_contents("{$docRoot}/index.html",
try { "<html><body style='font-family:sans-serif;text-align:center;padding:4rem'><h1>Welcome to {$domain}</h1><p>Hosted by NovaCPX</p></body></html>"
$db2 = DB::getInstance(); );
$tplRow = $db2->fetchOne("SELECT value FROM settings WHERE key='default_index_template'");
$customTpl = $tplRow ? trim($tplRow['value']) : null;
} catch (Throwable $e) {}
$html = $customTpl // Save account to DB
? str_replace(['{domain}', '{username}'], [$domain, $username], $customTpl) $acctId = (int)$db->insert(
: "<!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>"; "INSERT INTO accounts (user_id, username, domain, home_dir, package_id, php_version, web_server) VALUES (?,?,?,?,?,?,?)",
[$userId, $username, $domain, $homeDir, $pkgId ?: null, $phpVer, $webSrv]
);
self::shell("sudo tee " . escapeshellarg("{$docRoot}/index.html") . " > /dev/null << 'HTMLEOF'\n{$html}\nHTMLEOF"); // Save domain
$db->insert(
"INSERT INTO domains (account_id, domain, type, document_root) VALUES (?,?,?,?)",
[$acctId, $domain, 'main', $docRoot]
);
// Wrap all DB writes in a transaction so partial failures leave no orphans // Create web vhost
$db->beginTransaction(); VhostManager::create($username, $domain, $docRoot, $phpVer);
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]
);
$db->insert( // Create DNS zone
"INSERT INTO domains (account_id, domain, type, document_root) VALUES (?,?,?,?)", DNSManager::createZone($acctId, $domain);
[$acctId, $domain, 'main', $docRoot]
);
// Create web vhost // Auto-provision SPF, DKIM, DMARC records
VhostManager::create($username, $domain, $docRoot, $phpVer); self::provisionEmailDNS($acctId, $domain);
// Create DNS zone // Create PHP-FPM pool
DNSManager::createZone($acctId, $domain); PHPManager::createPool($username, $phpVer);
// 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)"); novacpx_log('info', "Account created: $username ($domain)");
return ['account_id' => $acctId, 'username' => $username, 'domain' => $domain, 'home_dir' => $homeDir]; return ['account_id' => $acctId, 'username' => $username, 'domain' => $domain, 'home_dir' => $homeDir];
+2 -24
View File
@@ -150,35 +150,13 @@ class Auth {
* Used by login redirect so each role lands on the right port * Used by login redirect so each role lands on the right port
*/ */
public static function portalUrl(string $role, string $path = '/'): string { public static function portalUrl(string $role, string $path = '/'): string {
$host = $_SERVER['HTTP_HOST'] ?? ''; $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
// 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); $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) { $port = match($role) {
'admin' => PORT_ADMIN, 'admin' => PORT_ADMIN,
'reseller' => PORT_RESELLER, 'reseller' => PORT_RESELLER,
default => PORT_USER, default => PORT_USER,
}; };
// Only redirect to localhost/LAN IPs on direct panel access return "https://{$hostname}:{$port}{$path}";
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;
} }
} }
+5 -11
View File
@@ -190,11 +190,7 @@ SH;
public function removeImage(string $imageId): string { public function removeImage(string $imageId): string {
if (!preg_match('/^[a-zA-Z0-9:._\-\/]+$/', $imageId)) throw new RuntimeException("Invalid image ID"); if (!preg_match('/^[a-zA-Z0-9:._\-\/]+$/', $imageId)) throw new RuntimeException("Invalid image ID");
$out = trim(shell_exec("sudo docker rmi " . escapeshellarg($imageId) . " 2>&1") ?? ''); return trim(shell_exec("sudo docker rmi " . escapeshellarg($imageId) . " 2>&1") ?? '');
if (stripos($out, "Error") !== false || stripos($out, "conflict") !== false) {
throw new \RuntimeException($out);
}
return $out;
} }
// ── Volumes & Networks ──────────────────────────────────────────────────── // ── Volumes & Networks ────────────────────────────────────────────────────
@@ -252,10 +248,11 @@ SH;
} }
if (!is_dir($dir)) throw new RuntimeException("Failed to create stack directory: {$dir}"); if (!is_dir($dir)) throw new RuntimeException("Failed to create stack directory: {$dir}");
file_put_contents("{$dir}/docker-compose.yml", $composeYaml); file_put_contents("{$dir}/docker-compose.yml", $composeYaml);
$id = (int)$this->db->insert( $this->db->execute(
"INSERT INTO docker_compose_stacks (account_id, name, stack_dir, compose_file, status) VALUES (?,?,?,?,'pending')", "INSERT INTO docker_compose_stacks (account_id, name, stack_dir, compose_file, status) VALUES (?,?,?,?,'pending')",
[$accountId, $safeName, $dir, $composeYaml] [$accountId, $safeName, $dir, $composeYaml]
); );
$id = $this->db->fetchOne("SELECT LAST_INSERT_ID() as id")['id'];
novacpx_log('info', "DockerManager: created stack {$safeName}"); novacpx_log('info', "DockerManager: created stack {$safeName}");
return ['id' => $id, 'dir' => $dir]; return ['id' => $id, 'dir' => $dir];
} }
@@ -290,10 +287,7 @@ SH;
$this->db->execute( $this->db->execute(
"INSERT INTO docker_quotas (user_id, max_containers, max_memory_mb, max_cpus) "INSERT INTO docker_quotas (user_id, max_containers, max_memory_mb, max_cpus)
VALUES (?,?,?,?) VALUES (?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET ON DUPLICATE KEY UPDATE max_containers=VALUES(max_containers), max_memory_mb=VALUES(max_memory_mb), max_cpus=VALUES(max_cpus)",
max_containers=excluded.max_containers,
max_memory_mb=excluded.max_memory_mb,
max_cpus=excluded.max_cpus",
[$userId, $maxContainers, $maxMemoryMb, $maxCpus] [$userId, $maxContainers, $maxMemoryMb, $maxCpus]
); );
} }
@@ -1611,7 +1605,7 @@ SH;
$logFile = escapeshellarg("/tmp/novacpx-stack-{$stackId}.log"); $logFile = escapeshellarg("/tmp/novacpx-stack-{$stackId}.log");
$compose = escapeshellarg("{$dir}/docker-compose.yml"); $compose = escapeshellarg("{$dir}/docker-compose.yml");
shell_exec("nohup sudo docker compose -f {$compose} up -d > {$logFile} 2>&1 &"); shell_exec("nohup sudo docker compose -f {$compose} up -d > {$logFile} 2>&1 &");
$this->db->execute("UPDATE docker_compose_stacks SET status='pending' WHERE id=?", [$stackId]); $this->db->execute("UPDATE docker_compose_stacks SET status='starting' WHERE id=?", [$stackId]);
novacpx_log('info', "DockerManager: launching {$appKey} for account {$accountId} on {$domain} (async)"); novacpx_log('info', "DockerManager: launching {$appKey} for account {$accountId} on {$domain} (async)");
return ['stack_id' => $stackId, 'dir' => $dir, 'output' => 'Launching in background — refresh in a moment to see status']; return ['stack_id' => $stackId, 'dir' => $dir, 'output' => 'Launching in background — refresh in a moment to see status'];
} }
+4 -4
View File
@@ -48,7 +48,9 @@ php_value[max_execution_time] = 30
public static function removePool(string $username): void { public static function removePool(string $username): void {
foreach (['7.4','8.1','8.2','8.3'] as $ver) { foreach (['7.4','8.1','8.2','8.3'] as $ver) {
$file = str_replace('{ver}', $ver, self::$poolDir) . "/{$username}.conf"; $file = str_replace('{ver}', $ver, self::$poolDir) . "/{$username}.conf";
shell_exec("sudo /bin/rm -f " . escapeshellarg($file) . " 2>/dev/null"); // Always attempt removal — don't rely on file_exists() which fails when
// www-data can't read the pool.d directory; rm -f is a no-op if missing
shell_exec("sudo rm -f " . escapeshellarg($file) . " 2>/dev/null");
self::reloadFPM($ver); self::reloadFPM($ver);
} }
} }
@@ -116,8 +118,6 @@ php_value[max_execution_time] = 30
} }
private static function reloadFPM(string $ver): void { private static function reloadFPM(string $ver): void {
// Write a flag file instead of reloading inline — the cron runner picks this up shell_exec("sudo systemctl reload php{$ver}-fpm 2>/dev/null || true");
// within 60s and reloads fpm outside of any HTTP request, avoiding 502s.
@file_put_contents('/tmp/novacpx-fpm-reload-' . $ver, '1');
} }
} }
-11
View File
@@ -139,21 +139,10 @@ input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1) opacit
.sidebar-section { padding: .75rem 0; } .sidebar-section { padding: .75rem 0; }
.sidebar-section-label { .sidebar-section-label {
display: flex; align-items: center; justify-content: space-between;
font-size: .7rem; font-weight: 700; letter-spacing: .08em; font-size: .7rem; font-weight: 700; letter-spacing: .08em;
text-transform: uppercase; color: var(--text-muted); text-transform: uppercase; color: var(--text-muted);
padding: .25rem 1.25rem .5rem; 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 { .sidebar-link {
display: flex; align-items: center; gap: .75rem; display: flex; align-items: center; gap: .75rem;
padding: .55rem 1.25rem; text-decoration: none; padding: .55rem 1.25rem; text-decoration: none;
+106 -269
View File
@@ -75,7 +75,7 @@
// ── Page definitions ─────────────────────────────────────────────────────── // ── Page definitions ───────────────────────────────────────────────────────
const pages = { const pages = {
dashboard, dashboard,
'server-status': dashboard, 'server-status': serverStatus,
accounts, accounts,
resellers, resellers,
packages, packages,
@@ -100,8 +100,6 @@
backups, backups,
cloudflare, cloudflare,
'server-options': serverOptions, 'server-options': serverOptions,
'subdomains': window.adminSubdomains,
'parked-domains': window.adminParked,
notifications, notifications,
settings, settings,
}; };
@@ -113,18 +111,16 @@
// ── Dashboard ────────────────────────────────────────────────────────────── // ── Dashboard ──────────────────────────────────────────────────────────────
async function dashboard() { async function dashboard() {
const [stats, version, histRes] = await Promise.all([ const [stats, version] = await Promise.all([
Nova.api('system', 'stats'), Nova.api('system', 'stats'),
Nova.api('system', 'version'), Nova.api('system', 'version'),
Nova.api('stats', 'server'),
]); ]);
const s = stats?.data || {}; const s = stats?.data || {};
const v = version?.data || {}; const v = version?.data || {};
const hist = histRes?.data?.history || [];
document.getElementById('server-ip').textContent = ''; document.getElementById('server-ip').textContent = '';
const html = ` return `
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">CPU Usage</div> <div class="stat-label">CPU Usage</div>
@@ -184,26 +180,53 @@
</div> </div>
</div> </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')">&#x21BB;</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>`; </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 ────────────────────────────────────────────────────────── // ── 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) { function initStatsChart(canvas, hist) {
const labels = hist.map(r => { const labels = hist.map(r => {
@@ -1376,68 +1399,26 @@
// ── Web Server ──────────────────────────────────────────────────────────── // ── Web Server ────────────────────────────────────────────────────────────
async function webServer() { async function webServer() {
const [statsR, phpR] = await Promise.all([ const r = await Nova.api('system', 'stats');
Nova.api('system','stats'), const svcs = r?.data?.services || {};
Nova.api('php','global-config'), const webSvc = Object.keys(svcs).find(k => k.includes('apache') || k.includes('nginx')) || 'apache2';
]);
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 ` return `
<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">
<div class="card-header"> <div class="card-header"><span class="card-title">Web Server Management</span></div>
<span class="card-title">Log Viewer</span> <div style="padding:1.5rem">
<div style="display:flex;gap:.35rem;margin-left:auto"> <div class="stats-grid" style="margin-bottom:1.5rem">
${[['nginx-error','Nginx Error'],['nginx-access','Nginx Access'],['panel','Panel'],['deploy','Deploy']].map(([l,n])=> ${Object.entries(svcs).map(([s,st]) => `<div class="stat-card">
`<button class="btn btn-xs" onclick="wsLoadLog('${l}')">${n}</button>`).join('')} <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> </div>
</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>`; </div>`;
} }
@@ -2423,12 +2404,7 @@ ${dbs.map(d=>`<tr>
</tbody></table>` : '<div class="empty" style="padding:2rem">No databases yet.</div>'; </tbody></table>` : '<div class="empty" style="padding:2rem">No databases yet.</div>';
return ` return `
<div class="page-header mb-2"><h2 class="page-title">Database Manager</h2></div> <div class="page-header"><h2 class="page-title">Database Engine 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"> <div class="grid-3 gap-2" style="margin-bottom:1.5rem">
${engineCard('mysql', 'MySQL', '🐬')} ${engineCard('mysql', 'MySQL', '🐬')}
@@ -2455,7 +2431,6 @@ ${dbs.map(d=>`<tr>
<div class="grid-2 gap-2"> <div class="grid-2 gap-2">
${toolCard('phpmyadmin', 'phpMyAdmin', '🛢', `http://${location.hostname}/phpmyadmin`)} ${toolCard('phpmyadmin', 'phpMyAdmin', '🛢', `http://${location.hostname}/phpmyadmin`)}
${toolCard('pgadmin', 'pgAdmin 4', '🐘', `http://${location.hostname}/pgadmin4`)} ${toolCard('pgadmin', 'pgAdmin 4', '🐘', `http://${location.hostname}/pgadmin4`)}
${toolCard('adminer', 'Adminer', '🗄️', `http://${location.hostname}/adminer.php`)}
</div> </div>
</div> </div>
</div> </div>
@@ -2632,71 +2607,37 @@ ${dbs.map(d=>`<tr>
// ── Mail Server ──────────────────────────────────────────────────────────── // ── Mail Server ────────────────────────────────────────────────────────────
async function mailServer() { async function mailServer() {
const [statsR, domainsR] = await Promise.all([ const r = await Nova.api('system','stats');
Nova.api('system','stats'), const svcs = r?.data?.services || {};
Nova.api('email','domains'), const mailStatus = svcs['postfix'] || 'unknown';
]); const doveStatus = svcs['dovecot'] || 'unknown';
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 ` return `
<div class="page-header mb-2"><h2 class="page-title">Mail Server</h2></div> <div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem">
<div class="grid-2 mb-3">
<div class="card"> <div class="card">
<div class="card-header"><span class="card-title">Services</span></div> <div class="card-header"><span class="card-title">Mail Services</span></div>
<div class="card-body"> <div style="padding:1.25rem">
${mailSvcs.length ? mailSvcs.map(s => ` ${[['postfix',mailStatus],['dovecot',doveStatus]].map(([s,st]) => `
<div style="display:flex;align-items:center;justify-content:space-between;padding:.5rem 0;border-bottom:1px solid var(--border)"> <div style="display:flex;align-items:center;justify-content:space-between;padding:.6rem 0;border-bottom:1px solid var(--border)">
<span style="font-weight:500">${s} ${Nova.badge(svcs[s],svcs[s]==='active'?'green':'red')}</span> <span>${s} <span data-svc-status="${s}">${Nova.badge(st,st==='active'?'green':'red')}</span></span>
<div style="display:flex;gap:.35rem"> <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}','restart')">Restart</button>
<button class="btn btn-xs" onclick="adminServiceAction('${s}','reload')"> Reload</button> <button class="btn btn-xs" onclick="adminServiceAction('${s}','reload')">Reload</button>
</div> </div>
</div>`).join('') : '<p class="text-muted">No mail services detected</p>'} </div>`).join('')}
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header"><span class="card-title">Mail Queue</span></div>
<span class="card-title">Mail Queue</span> <div style="padding:1.25rem">
<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" onclick="adminViewMailQueue()">View Queue</button>
<div id="ms-queue-out" style="margin-top:.75rem;font-size:.82rem;color:var(--text-muted)"></div> <button class="btn btn-sm btn-warning" style="margin-top:.5rem" onclick="Nova.confirm('Flush mail queue?',()=>adminServiceAction('postfix','flush'))">Flush Queue</button>
</div> </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>`; </div>`;
} }
window.adminViewMailQueue = async () => { window.adminViewMailQueue = async () => {
const r = await Nova.api('system','service',{method:'POST',body:{service:'mailq',command:'status'}}); const r = await Nova.api('system','service',{method:'POST',body:{service:'mailq',command:'status'}});
const out = r?.data?.output || 'Queue is empty'; 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 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 ──────────────────────────────────────────────────────────── // ── FTP Server ────────────────────────────────────────────────────────────
@@ -2710,36 +2651,23 @@ ${dbs.map(d=>`<tr>
const svcName = ftpConf === 'vsftpd' ? 'vsftpd' : (ftpConf === 'pureftpd' ? 'pure-ftpd' : 'proftpd'); const svcName = ftpConf === 'vsftpd' ? 'vsftpd' : (ftpConf === 'pureftpd' ? 'pure-ftpd' : 'proftpd');
const label = ftpConf === 'vsftpd' ? 'vsftpd' : (ftpConf === 'pureftpd' ? 'Pure-FTPd' : 'ProFTPD'); const label = ftpConf === 'vsftpd' ? 'vsftpd' : (ftpConf === 'pureftpd' ? 'Pure-FTPd' : 'ProFTPD');
const status = svcs[svcName] || 'unknown'; const status = svcs[svcName] || 'unknown';
const ftpR = await Nova.api('ftp','list',{params:{account_id:0}});
const ftpAccts = ftpR?.data || [];
return ` return `
<div class="page-header mb-2"><h2 class="page-title">FTP Server</h2></div> <div class="card">
<div class="card mb-3">
<div class="card-header"> <div class="card-header">
<span class="card-title">${label}</span> <span class="card-title">FTP Server (${label})</span>
${Nova.badge(status, status==='active'?'green':'red')} <span data-svc-status="${svcName}">${Nova.badge(status, status==='active'?'green':'red')}</span>
<div style="display:flex;gap:.35rem;margin-left:auto"> <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}','restart')">Restart</button>
<button class="btn btn-sm" onclick="adminServiceAction('${svcName}','reload')"> Reload</button> <button class="btn btn-sm" onclick="adminServiceAction('${svcName}','reload')">Reload</button>
</div> </div>
</div> </div>
<div class="card-body" style="font-size:.85rem;color:var(--text-muted)"> <div style="padding:1.25rem">
Active server: <strong>${label}</strong> change in <a href="#" onclick="adminPage('server-options')">Server Options</a>. <div style="color:var(--muted);font-size:.85rem">
Passive FTP ports 20/21 · SFTP on port 22. <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> </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&#39;s FTP page.</div>'}
</div>`; </div>`;
} }
@@ -4170,15 +4098,10 @@ async function docker() {
<div id="docker-tab-content"><div class="loading">Loading</div></div>`; <div id="docker-tab-content"><div class="loading">Loading</div></div>`;
} }
// Refresh without clearing the list first (keeps current content visible while loading) async function dockerLoadTab(tab) {
async function dockerLoadTabKeep(tab) {
await dockerLoadTab(tab, true);
}
async function dockerLoadTab(tab, keepContent = false) {
const tc = document.getElementById('docker-tab-content'); const tc = document.getElementById('docker-tab-content');
if (!tc) return; if (!tc) return;
if (!keepContent) tc.innerHTML = '<div class="loading">Loading…</div>'; tc.innerHTML = '<div class="loading">Loading…</div>';
if (tab === 'containers') { if (tab === 'containers') {
const r = await Nova.api('docker', 'containers'); const r = await Nova.api('docker', 'containers');
@@ -4192,7 +4115,7 @@ ${rows.length === 0 ? '<div class="card"><div class="card-body text-muted" style
<div style="overflow-x:auto"><table class="table"><thead><tr> <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> <th>Name</th><th>Image</th><th>Status</th><th>Account</th><th>Created</th><th>Actions</th>
</tr></thead><tbody> </tr></thead><tbody>
${rows.map(c => `<tr data-cid="${Nova.escHtml(c.container_id||'')}"> ${rows.map(c => `<tr>
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(c.name)}</td> <td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(c.name)}</td>
<td style="font-size:.82rem">${Nova.escHtml(c.image)}</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> <td>${Nova.badge(c.status, c.status==='running'?'green':c.status==='stopped'?'red':'yellow')}</td>
@@ -4347,25 +4270,15 @@ window.dockerAdminLaunchApp = async (preselect) => {
}; };
window.dockerContainerAct = async (cid, action) => { 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 } }); 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'); 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) dockerLoadTab('containers');
if (r?.success || r !== null) dockerLoadTabKeep('containers');
}; };
window.dockerRemove = (cid) => Nova.confirm('Force remove this container?', async () => { window.dockerRemove = (cid) => Nova.confirm('Remove this container?', async () => {
const row = document.querySelector(`tr[data-cid="${cid}"]`); const r = await Nova.api('docker', 'container-remove', { method: 'DELETE', body: { container_id: cid, force: true } });
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'); Nova.toast(r?.success ? 'Removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success || r !== null) dockerLoadTabKeep('containers'); if (r?.success) dockerLoadTab('containers');
}, true); }, true);
window.dockerLogs = async (cid, name) => { window.dockerLogs = async (cid, name) => {
@@ -4374,10 +4287,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>`); 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? Stop any containers using it first.', async () => { window.dockerImgRemove = (id) => Nova.confirm('Remove this image?', async () => {
const r = await Nova.api('docker', 'image-remove', { method: 'POST', body: { image_id: id } }); const r = await Nova.api('docker', 'image-remove', { method: 'DELETE', body: { image_id: id } });
Nova.toast(r?.success ? 'Image removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error'); Nova.toast(r?.success ? 'Image removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
dockerLoadTabKeep('images'); if (r?.success) dockerLoadTab('images');
}, true); }, true);
window.dockerPullModal = () => { window.dockerPullModal = () => {
@@ -4619,21 +4532,7 @@ async function serverOptions() {
<button class="btn btn-primary btn-sm" onclick="soSaveNS()">Save Nameservers</button> <button class="btn btn-primary btn-sm" onclick="soSaveNS()">Save Nameservers</button>
<div id="so-ns-results" style="margin-top:1rem"></div> <div id="so-ns-results" style="margin-top:1rem"></div>
</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) => { window.soSave = (key, inputId, label) => {
@@ -4704,12 +4603,6 @@ window.soSaveNS = async () => {
Nova.toast('Nameservers saved', 'success'); 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 () => { window.soCheckNS = async () => {
const tc = document.getElementById('so-ns-results'); const tc = document.getElementById('so-ns-results');
if (!tc) return; if (!tc) return;
@@ -4726,59 +4619,3 @@ ${results.map(z=>`<tr>
</tr>`).join('')} </tr>`).join('')}
</tbody></table></div>`; </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'):'&mdash;'}</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>`;
};
+1 -42
View File
@@ -56,14 +56,7 @@ window.Nova = (() => {
return { success: false, message: 'Network error — check your connection' }; return { success: false, message: 'Network error — check your connection' };
} }
_barDone(); _barDone();
if (res.status === 401) { if (res.status === 401) { return { success: false, message: 'Session expired — please log in again' }; }
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) { if (res.status === 429) {
const reset = res.headers.get('X-RateLimit-Reset'); const reset = res.headers.get('X-RateLimit-Reset');
const wait = reset ? Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000)) : 60; const wait = reset ? Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000)) : 60;
@@ -221,40 +214,6 @@ window.Nova = (() => {
return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot, escHtml, loading, loadingDone }; 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 // #26 Mobile sidebar toggle — shared across all panels
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const toggle = document.getElementById('sidebar-toggle'); const toggle = document.getElementById('sidebar-toggle');
-59
View File
@@ -325,11 +325,6 @@ const rNavGroups = [
{ id: 'packages', label: 'Packages', { 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>' }, 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: [ { label: 'DNS', items: [
{ id: 'dns', label: 'DNS Zones', { 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>' }, 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>' },
@@ -366,12 +361,9 @@ function renderRNav() {
document.getElementById('sidebar-overlay')?.classList.remove('open'); document.getElementById('sidebar-overlay')?.classList.remove('open');
document.body.style.overflow = ''; 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); resellerNav(link.dataset.page);
}); });
}); });
if (typeof _initCollapsibleNav === 'function') _initCollapsibleNav();
} }
window.resellerNav = (page) => { window.resellerNav = (page) => {
@@ -706,54 +698,3 @@ window.rWlSave = async () => {
Nova.toast(r?.success ? 'Branding saved — reload to see changes' : (r?.message || 'Save failed'), Nova.toast(r?.success ? 'Branding saved — reload to see changes' : (r?.message || 'Save failed'),
r?.success ? 'success' : 'error'); 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>`;
};
+2 -244
View File
@@ -937,13 +937,8 @@ 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>' }, 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: [ { 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>' }, 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', { 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>' }, 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', { id: 'databases', label: 'Databases',
@@ -970,8 +965,6 @@ 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>' }, 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: [ { 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', { 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>' }, 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>' },
]}, ]},
@@ -992,7 +985,7 @@ function renderNav() {
</a>`).join('')} </a>`).join('')}
</div>`).join(''); </div>`).join('');
nav.querySelectorAll("[data-page]").forEach(link => { nav.querySelectorAll('[data-page]').forEach(link => {
link.addEventListener('click', e => { link.addEventListener('click', e => {
e.preventDefault(); e.preventDefault();
if (window.innerWidth <= 768) { if (window.innerWidth <= 768) {
@@ -1003,19 +996,12 @@ function renderNav() {
userNav(link.dataset.page); userNav(link.dataset.page);
}); });
}); });
if (typeof _initCollapsibleNav === 'function') _initCollapsibleNav();
} }
window.userNav = (page) => { window.userNav = (page) => {
_activePage = page; _activePage = page;
renderNav(); renderNav();
const allItems = navGroups.flatMap(g => g.items); 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 item = allItems.find(n => n.id === page);
const titleEl = document.getElementById('page-title'); const titleEl = document.getElementById('page-title');
if (titleEl && item) titleEl.textContent = item.label; if (titleEl && item) titleEl.textContent = item.label;
@@ -1288,231 +1274,3 @@ document.addEventListener('DOMContentLoaded', async () => {
renderNav(); renderNav();
window.userNav('dashboard'); 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');
};
-94
View File
@@ -1,94 +0,0 @@
<?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>
-155
View File
@@ -1,155 +0,0 @@
<?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>
-225
View File
@@ -1,225 +0,0 @@
#!/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 ""