22 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
myron 2fa1f10901 Security: fix 8 code-review findings
- install.sh: replace /usr/sbin/ufw * with scoped subcommands
- install.sh: remove /usr/bin/curl * and /usr/bin/env * NOPASSWD (trivial root escalation)
- PHPManager: switchVersion() uses sudo rm -f instead of unlink() for old pool
- PHPManager: updateConfig() SQLite syntax (ON CONFLICT / datetime('now'))
- WordPressManager: cloneStaging() escapeshellarg() on all shell-interpolated paths
- WordPressManager: delete() removes DB record before filesystem to avoid phantom records
- WordPressManager: ensureWpCli() validates download size and enforces 30s timeout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 12:32:06 +00:00
github-actions[bot] 5037633f5f chore: bump version to 1.0.21 [skip ci] 2026-06-10 05:53:34 +00:00
myron 658e2f9057 Fix PHP-FPM pool not removed on account termination
Two bugs that together left stale pool files behind after termination,
crashing php-fpm on next startup (exit-code 78, user not found):

1. removePool() used file_exists() to guard the rm — fails silently when
   www-data can't read /etc/php/*/fpm/pool.d/; now always attempts sudo rm -f
2. reloadFPM() called systemctl without sudo — silently failed as www-data,
   leaving the old pool loaded even when the file was successfully removed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 05:53:25 +00:00
github-actions[bot] 7107d230c8 chore: bump version to 1.0.20 [skip ci] 2026-06-10 05:51:17 +00:00
myron e6550f0a90 Add full service sudoers rules to installer
Previous installer only granted www-data access to nginx/apache2/fail2ban.
Added NOPASSWD rules for all panel-managed services:
postfix, dovecot, rspamd, proftpd, vsftpd, pure-ftpd,
named/bind9/pdns/nsd, mysql, mariadb, php*-fpm.
Without these, service restart/stop/start buttons returned 502
(shell_exec hung waiting for sudo password → Apache timeout).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 05:51:08 +00:00
github-actions[bot] b0ac6e7aa2 chore: bump version to 1.0.19 [skip ci] 2026-06-10 05:45:42 +00:00
myron b9c37030b6 Fix terminate 500: require DatabaseManager before calling drop
AccountManager::terminate() called DatabaseManager::drop() without
requiring the class first — fatal class not found error on every
account termination.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 05:45:33 +00:00
11 changed files with 2050 additions and 47 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.18 1.0.21-beta.8
+3
View File
@@ -79,6 +79,9 @@ while IFS='|' read -r REPO_PATH WEB_ROOT COMMIT QUEUED_BRANCH; do
rsync -av "$REPO_PATH/panel/bin/" /opt/novacpx/bin/ >> "$LOG" 2>&1 rsync -av "$REPO_PATH/panel/bin/" /opt/novacpx/bin/ >> "$LOG" 2>&1
chmod +x /opt/novacpx/bin/*.php 2>/dev/null || true chmod +x /opt/novacpx/bin/*.php 2>/dev/null || true
# Copy VERSION to web root so /VERSION endpoint stays current
[[ -f "$REPO_PATH/VERSION" ]] && cp "$REPO_PATH/VERSION" "$WEB_ROOT/VERSION"
# Run pending DB migrations (SQLite) # Run pending DB migrations (SQLite)
MIGR_DIR="$REPO_PATH/db/migrations" MIGR_DIR="$REPO_PATH/db/migrations"
if [[ -d "$MIGR_DIR" && -f "$DB_PATH" ]]; then if [[ -d "$MIGR_DIR" && -f "$DB_PATH" ]]; then
+78 -15
View File
@@ -636,29 +636,92 @@ log "Fail2Ban configured"
# ── Sudoers for NovaCPX panel (www-data needs root for firewall/opendkim) ──── # ── Sudoers for NovaCPX panel (www-data needs root for firewall/opendkim) ────
cat > /etc/sudoers.d/novacpx-firewall <<SUDOERS cat > /etc/sudoers.d/novacpx-firewall <<SUDOERS
Defaults:www-data !requiretty Defaults:www-data !requiretty
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw * # Firewall / security
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw status
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw status verbose
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw allow *
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw deny *
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw delete *
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw reload
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw enable
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw disable
www-data ALL=(root) NOPASSWD: /usr/sbin/ufw logging *
www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client * www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client *
www-data ALL=(root) NOPASSWD: /bin/systemctl restart fail2ban # Web servers
www-data ALL=(root) NOPASSWD: /bin/systemctl reload fail2ban www-data ALL=(root) NOPASSWD: /bin/systemctl start apache2
www-data ALL=(root) NOPASSWD: /bin/systemctl start fail2ban www-data ALL=(root) NOPASSWD: /bin/systemctl stop apache2
www-data ALL=(root) NOPASSWD: /bin/systemctl stop fail2ban www-data ALL=(root) NOPASSWD: /bin/systemctl restart apache2
www-data ALL=(root) NOPASSWD: /bin/systemctl reload apache2
www-data ALL=(root) NOPASSWD: /bin/systemctl enable apache2
www-data ALL=(root) NOPASSWD: /bin/systemctl start nginx www-data ALL=(root) NOPASSWD: /bin/systemctl start nginx
www-data ALL=(root) NOPASSWD: /bin/systemctl stop nginx www-data ALL=(root) NOPASSWD: /bin/systemctl stop nginx
www-data ALL=(root) NOPASSWD: /bin/systemctl restart nginx www-data ALL=(root) NOPASSWD: /bin/systemctl restart nginx
www-data ALL=(root) NOPASSWD: /bin/systemctl reload nginx www-data ALL=(root) NOPASSWD: /bin/systemctl reload nginx
www-data ALL=(root) NOPASSWD: /bin/systemctl restart apache2 www-data ALL=(root) NOPASSWD: /bin/systemctl enable nginx
www-data ALL=(root) NOPASSWD: /bin/systemctl reload apache2
www-data ALL=(root) NOPASSWD: /usr/sbin/nginx * www-data ALL=(root) NOPASSWD: /usr/sbin/nginx *
# DB tool installation privileges # Mail servers
www-data ALL=(root) NOPASSWD: /usr/bin/gpg * www-data ALL=(root) NOPASSWD: /bin/systemctl start postfix
www-data ALL=(root) NOPASSWD: /usr/bin/curl * www-data ALL=(root) NOPASSWD: /bin/systemctl stop postfix
www-data ALL=(root) NOPASSWD: /usr/sbin/debconf-set-selections * www-data ALL=(root) NOPASSWD: /bin/systemctl restart postfix
www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/apt/sources.list.d/* www-data ALL=(root) NOPASSWD: /bin/systemctl reload postfix
www-data ALL=(root) NOPASSWD: /usr/bin/tee /usr/share/keyrings/* www-data ALL=(root) NOPASSWD: /bin/systemctl start dovecot
www-data ALL=(root) NOPASSWD: /bin/systemctl stop dovecot
www-data ALL=(root) NOPASSWD: /bin/systemctl restart dovecot
www-data ALL=(root) NOPASSWD: /bin/systemctl reload dovecot
www-data ALL=(root) NOPASSWD: /bin/systemctl start rspamd
www-data ALL=(root) NOPASSWD: /bin/systemctl stop rspamd
www-data ALL=(root) NOPASSWD: /bin/systemctl restart rspamd
www-data ALL=(root) NOPASSWD: /bin/systemctl enable rspamd
www-data ALL=(root) NOPASSWD: /bin/systemctl disable rspamd
www-data ALL=(root) NOPASSWD: /usr/sbin/postqueue -f
# FTP servers
www-data ALL=(root) NOPASSWD: /bin/systemctl start proftpd
www-data ALL=(root) NOPASSWD: /bin/systemctl stop proftpd
www-data ALL=(root) NOPASSWD: /bin/systemctl restart proftpd
www-data ALL=(root) NOPASSWD: /bin/systemctl reload proftpd
www-data ALL=(root) NOPASSWD: /bin/systemctl enable proftpd
www-data ALL=(root) NOPASSWD: /bin/systemctl start vsftpd
www-data ALL=(root) NOPASSWD: /bin/systemctl stop vsftpd
www-data ALL=(root) NOPASSWD: /bin/systemctl restart vsftpd
www-data ALL=(root) NOPASSWD: /bin/systemctl enable vsftpd
www-data ALL=(root) NOPASSWD: /bin/systemctl start pure-ftpd
www-data ALL=(root) NOPASSWD: /bin/systemctl stop pure-ftpd
www-data ALL=(root) NOPASSWD: /bin/systemctl restart pure-ftpd
www-data ALL=(root) NOPASSWD: /bin/systemctl enable pure-ftpd
# DNS servers
www-data ALL=(root) NOPASSWD: /bin/systemctl start named
www-data ALL=(root) NOPASSWD: /bin/systemctl stop named
www-data ALL=(root) NOPASSWD: /bin/systemctl restart named
www-data ALL=(root) NOPASSWD: /bin/systemctl reload named
www-data ALL=(root) NOPASSWD: /bin/systemctl start bind9
www-data ALL=(root) NOPASSWD: /bin/systemctl stop bind9
www-data ALL=(root) NOPASSWD: /bin/systemctl restart bind9
www-data ALL=(root) NOPASSWD: /bin/systemctl start pdns
www-data ALL=(root) NOPASSWD: /bin/systemctl stop pdns
www-data ALL=(root) NOPASSWD: /bin/systemctl restart pdns
www-data ALL=(root) NOPASSWD: /bin/systemctl start nsd
www-data ALL=(root) NOPASSWD: /bin/systemctl stop nsd
www-data ALL=(root) NOPASSWD: /bin/systemctl restart nsd
# Database servers
www-data ALL=(root) NOPASSWD: /bin/systemctl start mysql
www-data ALL=(root) NOPASSWD: /bin/systemctl stop mysql
www-data ALL=(root) NOPASSWD: /bin/systemctl restart mysql
www-data ALL=(root) NOPASSWD: /bin/systemctl start mariadb
www-data ALL=(root) NOPASSWD: /bin/systemctl stop mariadb
www-data ALL=(root) NOPASSWD: /bin/systemctl restart mariadb
# Security
www-data ALL=(root) NOPASSWD: /bin/systemctl start fail2ban
www-data ALL=(root) NOPASSWD: /bin/systemctl stop fail2ban
www-data ALL=(root) NOPASSWD: /bin/systemctl restart fail2ban
www-data ALL=(root) NOPASSWD: /bin/systemctl reload fail2ban
# PHP-FPM
www-data ALL=(root) NOPASSWD: /bin/systemctl reload php*-fpm
www-data ALL=(root) NOPASSWD: /bin/systemctl restart php*-fpm
www-data ALL=(root) NOPASSWD: /bin/systemctl start php*-fpm
www-data ALL=(root) NOPASSWD: /bin/systemctl stop php*-fpm
# 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/apache2/conf-enabled/* www-data ALL=(root) NOPASSWD: /usr/bin/tee /etc/apache2/conf-enabled/*
www-data ALL=(root) NOPASSWD: /usr/pgadmin4/bin/setup-web.sh *
www-data ALL=(root) NOPASSWD: /usr/bin/env *
SUDOERS SUDOERS
chmod 440 /etc/sudoers.d/novacpx-firewall chmod 440 /etc/sudoers.d/novacpx-firewall
log "Sudoers rules installed" log "Sudoers rules installed"
+56 -4
View File
@@ -29,6 +29,36 @@ match ($action) {
Response::success(null, $msg); Response::success(null, $msg);
})(), })(),
'uninstall-account' => (function() use ($dm, $currentUser, $isAdmin, $_userAccountId, $body) {
// Stop and remove all containers and stacks belonging to one account.
// Users can only remove their own; admins can specify any account_id.
$accountId = $isAdmin ? (int)($body['account_id'] ?? $_userAccountId) : ($_userAccountId ?? 0);
if (!$accountId) Response::error('account_id required');
$db = DB::getInstance();
// Tear down compose stacks
$stacks = $db->fetchAll("SELECT * FROM docker_compose_stacks WHERE account_id = ?", [$accountId]);
foreach ($stacks as $stack) {
if (is_dir($stack['stack_dir']) && file_exists("{$stack['stack_dir']}/docker-compose.yml")) {
shell_exec("sudo docker compose -f " . escapeshellarg("{$stack['stack_dir']}/docker-compose.yml") . " down -v 2>/dev/null");
}
$db->execute("DELETE FROM docker_compose_stacks WHERE id = ?", [$stack['id']]);
}
// Stop and remove bare containers
$containers = $db->fetchAll("SELECT container_id FROM docker_containers WHERE account_id = ?", [$accountId]);
foreach ($containers as $c) {
if ($c['container_id']) {
shell_exec("sudo docker rm -f " . escapeshellarg($c['container_id']) . " 2>/dev/null");
}
}
$db->execute("DELETE FROM docker_containers WHERE account_id = ?", [$accountId]);
audit("docker.uninstall-account", "account:{$accountId}");
Response::success(null, 'All Docker apps and containers removed for this account');
})(),
'prune' => (function() use ($dm, $body, $isAdmin) { 'prune' => (function() use ($dm, $body, $isAdmin) {
if (!$isAdmin) Response::error('Admin only', 403); if (!$isAdmin) Response::error('Admin only', 403);
$out = $dm->systemPrune((bool)($body['volumes'] ?? false)); $out = $dm->systemPrune((bool)($body['volumes'] ?? false));
@@ -163,20 +193,42 @@ match ($action) {
Response::success($result, 'Stack created'); Response::success($result, 'Stack created');
})(), })(),
'stack-action' => (function() use ($dm, $body, $isAdmin) { 'stack-action' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
if (!$isAdmin) Response::error('Admin only', 403);
$id = (int)($body['stack_id'] ?? 0); $id = (int)($body['stack_id'] ?? 0);
$act = $body['action'] ?? ''; $act = $body['action'] ?? '';
if (!$id || !$act) Response::error('stack_id and action required'); if (!$id || !$act) Response::error('stack_id and action required');
// Non-admins can only act on their own stacks
if (!$isAdmin) {
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
}
$out = $dm->composeAction($id, $act); $out = $dm->composeAction($id, $act);
audit("docker.stack.{$act}", "stack:{$id}"); audit("docker.stack.{$act}", "stack:{$id}");
Response::success(['output' => $out]); Response::success(['output' => $out]);
})(), })(),
'stack-remove' => (function() use ($dm, $body, $isAdmin) { 'stack-reinstall' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
if (!$isAdmin) Response::error('Admin only', 403);
$id = (int)($body['stack_id'] ?? 0); $id = (int)($body['stack_id'] ?? 0);
if (!$id) Response::error('stack_id required'); if (!$id) Response::error('stack_id required');
if (!$isAdmin) {
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
}
// Pull latest images, bring down (removing volumes), then start fresh
$dm->composeAction($id, 'pull');
$dm->composeAction($id, 'down');
$out = $dm->composeAction($id, 'up');
audit('docker.stack.reinstall', "stack:{$id}");
Response::success(['output' => $out], 'Stack reinstalled');
})(),
'stack-remove' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
$id = (int)($body['stack_id'] ?? 0);
if (!$id) Response::error('stack_id required');
if (!$isAdmin) {
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
}
$dm->removeStack($id); $dm->removeStack($id);
audit('docker.stack.remove', "stack:{$id}"); audit('docker.stack.remove', "stack:{$id}");
Response::success(null, 'Stack removed'); Response::success(null, 'Stack removed');
+75
View File
@@ -20,5 +20,80 @@ match ($action) {
Response::success($rows); Response::success($rows);
})(), })(),
// Create a panel user (reseller or admin) — no hosting account provisioned
'create' => (function() use ($db, $body) {
$username = trim($body['username'] ?? '');
$password = $body['password'] ?? '';
$email = trim($body['email'] ?? '');
$role = $body['role'] ?? 'reseller';
if (!$username || !$password || !$email) Response::error('username, password and email are required');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) Response::error('Invalid email address');
if (!in_array($role, ['reseller', 'admin'], true)) Response::error('role must be reseller or admin');
if (strlen($password) < 8) Response::error('Password must be at least 8 characters');
if (!preg_match('/^[a-zA-Z0-9_\-\.]+$/', $username)) Response::error('Username may only contain letters, numbers, _ - .');
$exists = $db->fetchOne("SELECT id FROM users WHERE username=? OR email=?", [$username, $email]);
if ($exists) Response::error('Username or email already in use');
$uid = (int)$db->insert(
"INSERT INTO users (username, password, email, role, status, created_at) VALUES (?,?,?,?,?,datetime('now'))",
[$username, password_hash($password, PASSWORD_BCRYPT), $email, $role, 'active']
);
audit("user.create.{$role}", "user:{$username}");
Response::success(['id' => $uid, 'username' => $username, 'email' => $email, 'role' => $role], ucfirst($role) . ' created');
})(),
// Suspend a panel user (sets status=suspended on the users row)
'suspend' => (function() use ($db, $body) {
$id = (int)($body['id'] ?? 0);
if (!$id) Response::error('id required');
$u = $db->fetchOne("SELECT id, username, role FROM users WHERE id=?", [$id]);
if (!$u) Response::error('User not found', 404);
if ($u['role'] === 'admin') Response::error('Cannot suspend admin users');
$db->execute("UPDATE users SET status='suspended' WHERE id=?", [$id]);
audit('user.suspend', "user:{$u['username']}");
Response::success(null, 'User suspended');
})(),
// Unsuspend a panel user
'unsuspend' => (function() use ($db, $body) {
$id = (int)($body['id'] ?? 0);
if (!$id) Response::error('id required');
$u = $db->fetchOne("SELECT id, username FROM users WHERE id=?", [$id]);
if (!$u) Response::error('User not found', 404);
$db->execute("UPDATE users SET status='active' WHERE id=?", [$id]);
audit('user.unsuspend', "user:{$u['username']}");
Response::success(null, 'User unsuspended');
})(),
// Change password for a panel user by user id
'change-password' => (function() use ($db, $body) {
$id = (int)($body['id'] ?? 0);
$pass = $body['password'] ?? '';
if (!$id) Response::error('id required');
if (strlen($pass) < 8) Response::error('Password must be at least 8 characters');
$u = $db->fetchOne("SELECT id, username FROM users WHERE id=?", [$id]);
if (!$u) Response::error('User not found', 404);
$db->execute("UPDATE users SET password=? WHERE id=?", [password_hash($pass, PASSWORD_BCRYPT), $id]);
audit('user.change-password', "user:{$u['username']}");
Response::success(null, 'Password updated');
})(),
// Delete a panel user (admin or reseller — not a hosting account user)
'delete' => (function() use ($db, $body) {
$id = (int)($body['id'] ?? 0);
if (!$id) Response::error('id required');
$user = $db->fetchOne("SELECT id, username, role FROM users WHERE id=?", [$id]);
if (!$user) Response::error('User not found', 404);
if ($user['role'] === 'admin') Response::error('Cannot delete admin users');
// Disown accounts under this reseller rather than deleting them
$db->execute("UPDATE users SET reseller_id=NULL WHERE reseller_id=?", [$id]);
$db->execute("UPDATE accounts SET reseller_id=NULL WHERE reseller_id=?", [$id]);
$db->execute("DELETE FROM users WHERE id=?", [$id]);
audit('user.delete', "user:{$user['username']}");
Response::success(null, 'User deleted');
})(),
default => Response::error("Unknown users action: $action", 404), default => Response::error("Unknown users action: $action", 404),
}; };
+1
View File
@@ -87,6 +87,7 @@ class AccountManager {
} }
public static function terminate(int $acctId): void { public static function terminate(int $acctId): void {
require_once NOVACPX_LIB . '/DatabaseManager.php';
$db = DB::getInstance(); $db = DB::getInstance();
$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$acctId]); $acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$acctId]);
if (!$acct) throw new RuntimeException("Account not found"); if (!$acct) throw new RuntimeException("Account not found");
File diff suppressed because it is too large Load Diff
+13 -7
View File
@@ -48,7 +48,10 @@ 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";
if (file_exists($file)) { shell_exec("sudo rm -f " . escapeshellarg($file)); self::reloadFPM($ver); } // 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);
} }
} }
@@ -62,7 +65,8 @@ php_value[max_execution_time] = 30
// Remove old pool, create new one // Remove old pool, create new one
$oldPool = str_replace('{ver}', $oldVer, self::$poolDir) . "/{$acct['username']}.conf"; $oldPool = str_replace('{ver}', $oldVer, self::$poolDir) . "/{$acct['username']}.conf";
if (file_exists($oldPool)) { unlink($oldPool); self::reloadFPM($oldVer); } shell_exec("sudo rm -f " . escapeshellarg($oldPool) . " 2>/dev/null");
self::reloadFPM($oldVer);
self::createPool($acct['username'], $newVer); self::createPool($acct['username'], $newVer);
@@ -97,10 +101,12 @@ php_value[max_execution_time] = 30
self::reloadFPM($acct['php_version']); self::reloadFPM($acct['php_version']);
$db->execute( $db->execute(
"INSERT INTO php_configs (account_id, php_version, memory_limit, max_execution_time, upload_max_filesize, post_max_size) "INSERT INTO php_configs (account_id, php_version, memory_limit, max_execution_time, upload_max_filesize, post_max_size, updated_at)
VALUES (?,?,?,?,?,?) VALUES (?,?,?,?,?,?,datetime('now'))
ON DUPLICATE KEY UPDATE memory_limit=VALUES(memory_limit), max_execution_time=VALUES(max_execution_time), ON CONFLICT(account_id) DO UPDATE SET php_version=excluded.php_version,
upload_max_filesize=VALUES(upload_max_filesize), post_max_size=VALUES(post_max_size), updated_at=NOW()", memory_limit=excluded.memory_limit, max_execution_time=excluded.max_execution_time,
upload_max_filesize=excluded.upload_max_filesize, post_max_size=excluded.post_max_size,
updated_at=excluded.updated_at",
[$accountId, $acct['php_version'], $cfg['memory_limit'] ?? '256M', $cfg['max_execution_time'] ?? 30, [$accountId, $acct['php_version'], $cfg['memory_limit'] ?? '256M', $cfg['max_execution_time'] ?? 30,
$cfg['upload_max_filesize'] ?? '64M', $cfg['post_max_size'] ?? '64M'] $cfg['upload_max_filesize'] ?? '64M', $cfg['post_max_size'] ?? '64M']
); );
@@ -112,6 +118,6 @@ php_value[max_execution_time] = 30
} }
private static function reloadFPM(string $ver): void { private static function reloadFPM(string $ver): void {
shell_exec("systemctl reload php{$ver}-fpm 2>/dev/null || true"); shell_exec("sudo systemctl reload php{$ver}-fpm 2>/dev/null || true");
} }
} }
+11 -5
View File
@@ -111,7 +111,7 @@ class WordPressManager {
$stagingRoot = dirname($docRoot) . rtrim($stagingPath, '/'); $stagingRoot = dirname($docRoot) . rtrim($stagingPath, '/');
// Copy files // Copy files
$this->exec("cp -r {$docRoot} {$stagingRoot}"); $this->exec("cp -r " . escapeshellarg($docRoot) . " " . escapeshellarg($stagingRoot));
// Clone DB // Clone DB
$stagingDb = $install['db_name'] . '_staging'; $stagingDb = $install['db_name'] . '_staging';
@@ -119,7 +119,7 @@ class WordPressManager {
$this->getProvDb()->exec("CREATE DATABASE IF NOT EXISTS `{$stagingDb}`"); $this->getProvDb()->exec("CREATE DATABASE IF NOT EXISTS `{$stagingDb}`");
$this->getProvDb()->exec("CREATE USER IF NOT EXISTS '{$stagingDb}'@'localhost' IDENTIFIED BY '{$stagingDbPw}'"); $this->getProvDb()->exec("CREATE USER IF NOT EXISTS '{$stagingDb}'@'localhost' IDENTIFIED BY '{$stagingDbPw}'");
$this->getProvDb()->exec("GRANT ALL ON `{$stagingDb}`.* TO '{$stagingDb}'@'localhost'"); $this->getProvDb()->exec("GRANT ALL ON `{$stagingDb}`.* TO '{$stagingDb}'@'localhost'");
$this->exec("mysqldump {$install['db_name']} | mysql {$stagingDb}"); $this->exec("mysqldump " . escapeshellarg($install['db_name']) . " | mysql " . escapeshellarg($stagingDb));
// Update staging wp-config // Update staging wp-config
$this->wp($stagingRoot, "config set DB_NAME {$stagingDb}", $sysUser); $this->wp($stagingRoot, "config set DB_NAME {$stagingDb}", $sysUser);
@@ -141,10 +141,11 @@ class WordPressManager {
// ── Delete ──────────────────────────────────────────────────────────────── // ── Delete ────────────────────────────────────────────────────────────────
public function delete(int $id): bool { public function delete(int $id): bool {
[$install, $sysUser, $docRoot] = $this->resolve($id); [$install, $sysUser, $docRoot] = $this->resolve($id);
$this->exec("rm -rf {$docRoot}"); // Remove DB record first so a failed filesystem cleanup doesn't leave a phantom record
$this->db->prepare("DELETE FROM wordpress_installs WHERE id=?")->execute([$id]);
$this->exec("rm -rf " . escapeshellarg($docRoot));
$this->getProvDb()->exec("DROP DATABASE IF EXISTS `{$install['db_name']}`"); $this->getProvDb()->exec("DROP DATABASE IF EXISTS `{$install['db_name']}`");
$this->getProvDb()->exec("DROP USER IF EXISTS '{$install['db_user']}'@'localhost'"); $this->getProvDb()->exec("DROP USER IF EXISTS '{$install['db_user']}'@'localhost'");
$this->db->prepare("DELETE FROM wordpress_installs WHERE id=?")->execute([$id]);
return true; return true;
} }
@@ -245,7 +246,12 @@ class WordPressManager {
private function ensureWpCli(): void { private function ensureWpCli(): void {
if (!file_exists($this->wpcli)) { if (!file_exists($this->wpcli)) {
file_put_contents('/tmp/wp-cli.phar', file_get_contents('https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar')); $ctx = stream_context_create(['http' => ['timeout' => 30]]);
$data = @file_get_contents('https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar', false, $ctx);
if (!$data || strlen($data) < 100000) {
throw new \RuntimeException("Failed to download WP-CLI (received " . strlen((string)$data) . " bytes)");
}
file_put_contents('/tmp/wp-cli.phar', $data);
rename('/tmp/wp-cli.phar', $this->wpcli); rename('/tmp/wp-cli.phar', $this->wpcli);
chmod($this->wpcli, 0755); chmod($this->wpcli, 0755);
} }
+124 -13
View File
@@ -1166,7 +1166,7 @@
// ── Resellers ────────────────────────────────────────────────────────────── // ── Resellers ──────────────────────────────────────────────────────────────
async function resellers() { async function resellers() {
const res = await Nova.api('accounts', 'list', { params:{ role: 'reseller' }}); const res = await Nova.api('users', 'list', { params:{ role: 'reseller' }});
const rows = res?.data || []; const rows = res?.data || [];
return ` return `
<div class="card"> <div class="card">
@@ -1175,14 +1175,18 @@
<button class="btn btn-primary btn-sm" onclick="adminAddReseller()">+ Add Reseller</button> <button class="btn btn-primary btn-sm" onclick="adminAddReseller()">+ Add Reseller</button>
</div> </div>
<div id="reseller-table"> <div id="reseller-table">
${rows.length ? `<table class="table"><thead><tr><th>Username</th><th>Email</th><th>Accounts</th><th>Status</th><th>Actions</th></tr></thead><tbody> ${rows.length ? `<table class="table"><thead><tr><th>Username</th><th>Email</th><th>Status</th><th>Created</th><th>Actions</th></tr></thead><tbody>
${rows.map(r => `<tr> ${rows.map(r => `<tr>
<td>${r.username}</td><td>${r.email||''}</td> <td><strong>${Nova.escHtml(r.username)}</strong></td>
<td>${r.account_count||0}</td> <td>${Nova.escHtml(r.email||'—')}</td>
<td>${Nova.badge(r.status,r.status==='active'?'green':'red')}</td> <td>${Nova.badge(r.status, r.status==='active'?'green':'red')}</td>
<td class="text-sm text-muted">${r.created_at ? r.created_at.slice(0,10) : '—'}</td>
<td style="display:flex;gap:.25rem"> <td style="display:flex;gap:.25rem">
<button class="btn btn-xs" onclick="adminChangePass(${r.id},'${r.username}')">Passwd</button> <button class="btn btn-xs" onclick="adminResellerPasswd(${r.id},'${Nova.escHtml(r.username)}')">Passwd</button>
<button class="btn btn-xs btn-danger" onclick="adminSuspend(${r.id},'${r.username}')">Suspend</button> ${r.status === 'active'
? `<button class="btn btn-xs btn-warning" onclick="adminResellerSuspend(${r.id},'${Nova.escHtml(r.username)}')">Suspend</button>`
: `<button class="btn btn-xs btn-success" onclick="adminResellerUnsuspend(${r.id})">Unsuspend</button>`}
<button class="btn btn-xs btn-danger" onclick="adminResellerDelete(${r.id},'${Nova.escHtml(r.username)}')">Delete</button>
</td> </td>
</tr>`).join('')} </tr>`).join('')}
</tbody></table>` </tbody></table>`
@@ -1193,10 +1197,51 @@
window.adminAddReseller = () => { window.adminAddReseller = () => {
Nova.modal('Create Reseller Account', ` Nova.modal('Create Reseller Account', `
<div class="form-group"><label class="form-label">Username</label><input id="ar-user" class="form-control"></div> <div class="form-group"><label class="form-label">Username</label><input id="ar-user" class="form-control" autocomplete="off"></div>
<div class="form-group"><label class="form-label">Password</label><input id="ar-pass" type="password" class="form-control"></div> <div class="form-group"><label class="form-label">Email</label><input id="ar-email" type="email" class="form-control"></div>
<div class="form-group"><label class="form-label">Email</label><input id="ar-email" type="email" class="form-control"></div>`, <div class="form-group"><label class="form-label">Password</label><input id="ar-pass" type="password" class="form-control" autocomplete="new-password"></div>`,
`<button class="btn btn-primary" onclick="Nova.api('auth','register',{method:'POST',body:{username:document.getElementById('ar-user').value,password:document.getElementById('ar-pass').value,email:document.getElementById('ar-email').value,role:'reseller'}}).then(r=>{if(r?.success){Nova.toast('Reseller created','success');document.querySelector('.modal-overlay').remove();adminPage('resellers');}else Nova.toast(r?.message,'error');})">Create</button>`); `<button class="btn btn-primary" onclick="
Nova.api('users','create',{method:'POST',body:{
username:document.getElementById('ar-user').value,
email:document.getElementById('ar-email').value,
password:document.getElementById('ar-pass').value,
role:'reseller'
}}).then(r=>{
if(r?.success){Nova.toast('Reseller created','success');document.querySelector('.modal-overlay').remove();adminPage('resellers');}
else Nova.toast(r?.message||'Error','error');
})">Create</button>`);
};
window.adminResellerPasswd = (id, user) => {
Nova.modal(`Change Password — ${user}`,
`<div class="form-group"><label class="form-label">New Password</label><input id="arp-pass" type="password" class="form-control" autocomplete="new-password"></div>`,
`<button class="btn btn-primary" onclick="
Nova.api('users','change-password',{method:'POST',body:{id:${id},password:document.getElementById('arp-pass').value}}).then(r=>{
if(r?.success){Nova.toast('Password updated','success');document.querySelector('.modal-overlay').remove();}
else Nova.toast(r?.message||'Error','error');
})">Update</button>`);
};
window.adminResellerSuspend = (id, user) => {
Nova.confirm(`Suspend reseller ${user}?`, async () => {
const r = await Nova.api('users','suspend',{method:'POST',body:{id}});
if (r?.success) { Nova.toast('Suspended','success'); adminPage('resellers'); }
else Nova.toast(r?.message||'Error','error');
});
};
window.adminResellerUnsuspend = async (id) => {
const r = await Nova.api('users','unsuspend',{method:'POST',body:{id}});
if (r?.success) { Nova.toast('Unsuspended','success'); adminPage('resellers'); }
else Nova.toast(r?.message||'Error','error');
};
window.adminResellerDelete = (id, user) => {
Nova.confirm(`Delete reseller ${user}? Their accounts will be disowned (moved to admin).`, async () => {
const r = await Nova.api('users','delete',{method:'POST',body:{id}});
if (r?.success) { Nova.toast('Reseller deleted','success'); adminPage('resellers'); }
else Nova.toast(r?.message||'Error','error');
}, true);
}; };
// ── Packages ─────────────────────────────────────────────────────────────── // ── Packages ───────────────────────────────────────────────────────────────
@@ -4047,8 +4092,8 @@ async function docker() {
${(status.disk||[]).map(d=>`<div class="stat-card"><div class="stat-label">${Nova.escHtml(d.Type||d.type||'?')}</div><div class="stat-value" style="font-size:1rem">${Nova.escHtml(d.TotalCount||d.Size||'—')}</div><div class="stat-sub">${Nova.escHtml(d.Reclaimable||d.reclaimable||'')}</div></div>`).join('')} ${(status.disk||[]).map(d=>`<div class="stat-card"><div class="stat-label">${Nova.escHtml(d.Type||d.type||'?')}</div><div class="stat-value" style="font-size:1rem">${Nova.escHtml(d.TotalCount||d.Size||'—')}</div><div class="stat-sub">${Nova.escHtml(d.Reclaimable||d.reclaimable||'')}</div></div>`).join('')}
</div> </div>
<div style="display:flex;gap:.5rem;margin-bottom:1rem;flex-wrap:wrap"> <div style="display:flex;gap:.5rem;margin-bottom:1rem;flex-wrap:wrap">
${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('quotas','User Quotas')} ${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('catalog','App Catalog')} ${tab('quotas','User Quotas')}
<button class="btn btn-sm btn-danger" style="margin-left:auto" onclick="dockerPrune()">System Prune</button> <button class="btn btn-sm btn-warning" style="margin-left:auto" onclick="dockerPrune()">System Prune</button>
</div> </div>
<div id="docker-tab-content"><div class="loading">Loading</div></div>`; <div id="docker-tab-content"><div class="loading">Loading</div></div>`;
} }
@@ -4143,11 +4188,29 @@ ${stacks.map(s=>`<tr>
<button class="btn btn-xs btn-success" onclick="dockerStackAct(${s.id},'up')">Up</button> <button class="btn btn-xs btn-success" onclick="dockerStackAct(${s.id},'up')">Up</button>
<button class="btn btn-xs btn-warning" onclick="dockerStackAct(${s.id},'down')">Down</button> <button class="btn btn-xs btn-warning" onclick="dockerStackAct(${s.id},'down')">Down</button>
<button class="btn btn-xs btn-ghost" onclick="dockerStackAct(${s.id},'logs')">Logs</button> <button class="btn btn-xs btn-ghost" onclick="dockerStackAct(${s.id},'logs')">Logs</button>
<button class="btn btn-xs btn-secondary" onclick="dockerStackReinstall(${s.id})">Reinstall</button>
<button class="btn btn-xs btn-danger" onclick="dockerStackRemove(${s.id})">Remove</button> <button class="btn btn-xs btn-danger" onclick="dockerStackRemove(${s.id})">Remove</button>
</td> </td>
</tr>`).join('')} </tr>`).join('')}
</tbody></table></div>`}`; </tbody></table></div>`}`;
} else if (tab === 'catalog') {
const r = await Nova.api('docker', 'catalog');
const catalog = r?.data?.catalog || {};
tc.innerHTML = `
<p class="text-muted" style="margin-bottom:1rem">One-click app deployment. Each app runs as an isolated Docker Compose stack. Select an account after clicking Launch.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem">
${Object.entries(catalog).map(([key,app])=>`
<div class="card" style="transition:var(--transition)" onmouseover="this.style.borderColor='var(--primary)'" onmouseout="this.style.borderColor=''">
<div class="card-body" style="text-align:center;padding:1.5rem">
<div style="font-size:1.8rem;font-weight:700;margin-bottom:.5rem;color:var(--primary)">${Nova.escHtml(app.icon)}</div>
<div style="font-weight:600;margin-bottom:.25rem">${Nova.escHtml(app.name)}</div>
<div style="font-size:.78rem;color:var(--text-muted);margin-bottom:.75rem">${Nova.escHtml(app.description)}</div>
<button class="btn btn-sm btn-primary" onclick="dockerAdminLaunchApp('${key}')">Launch</button>
</div>
</div>`).join('')}
</div>`;
} else if (tab === 'quotas') { } else if (tab === 'quotas') {
const r = await Nova.api('accounts', 'list', { params: { limit: 200 } }); const r = await Nova.api('accounts', 'list', { params: { limit: 200 } });
const users = r?.data || []; const users = r?.data || [];
@@ -4165,6 +4228,47 @@ ${users.map(u=>`<tr id="docker-quota-row-${u.user_id}">
} }
} }
window.dockerAdminLaunchApp = async (preselect) => {
const catRes = await Nova.api('docker', 'catalog');
const catalog = catRes?.data?.catalog || {};
const acctRes = await Nova.api('accounts', 'list', { params: { limit: 200 } });
const accounts = acctRes?.data || [];
const appOpts = Object.entries(catalog).map(([k,a])=>`<option value="${k}" ${k===preselect?'selected':''}>${Nova.escHtml(a.name)}</option>`).join('');
const acctOpts = accounts.map(a=>`<option value="${a.id}">${Nova.escHtml(a.username)} (${Nova.escHtml(a.domain)})</option>`).join('');
window.dockerAdminUpdateParams = (key) => {
const app = catalog[key]; if (!app) return;
const tc = document.getElementById('dal-params'); if (!tc) return;
tc.innerHTML = (app.params||[]).map(p=>`
<div class="form-group"><label>${Nova.escHtml(p.label)}${p.required?' *':''}</label>
<input id="dal-${Nova.escHtml(p.key)}" type="${p.type||'text'}" class="form-control" ${p.placeholder?`placeholder="${Nova.escHtml(p.placeholder)}"`:''}></div>`).join('');
};
const ov = Nova.modal('Launch App (Admin)',
`<div class="form-group"><label>Account</label><select id="dal-account" class="form-control">${acctOpts}</select></div>
<div class="form-group"><label>Application</label><select id="dal-app" class="form-control" onchange="dockerAdminUpdateParams(this.value)">${appOpts}</select></div>
<div id="dal-params"></div>`,
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="dockerAdminLaunchSubmit()">Launch</button>`
);
dockerAdminUpdateParams(preselect || Object.keys(catalog)[0] || '');
window.dockerAdminLaunchSubmit = async () => {
const appKey = document.getElementById('dal-app').value;
const accountId = parseInt(document.getElementById('dal-account').value);
const app = catalog[appKey]; if (!app) return;
const params = {};
(app.params||[]).forEach(p => { const el = document.getElementById(`dal-${p.key}`); if (el) params[p.key] = el.value.trim(); });
const missing = (app.params||[]).filter(p => p.required && !params[p.key]);
if (missing.length) { Nova.toast(`Required: ${missing.map(p=>p.label).join(', ')}`, 'error'); return; }
ov.remove();
Nova.toast(`Launching ${app.name}`, 'info', 15000);
const r = await Nova.api('docker', 'launch', { method: 'POST', body: { app_key: appKey, account_id: accountId, params } });
Nova.toast(r?.success ? `${app.name} launched!` : (r?.error || r?.message || 'Launch failed'), r?.success ? 'success' : 'error');
if (r?.success) dockerLoadTab('stacks');
};
};
window.dockerContainerAct = async (cid, action) => { window.dockerContainerAct = async (cid, action) => {
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');
@@ -4243,6 +4347,13 @@ window.dockerStackAct = async (id, action) => {
} }
}; };
window.dockerStackReinstall = (id) => Nova.confirm('Reinstall this stack? Latest images will be pulled and containers restarted. Data volumes are preserved.', async () => {
Nova.toast('Reinstalling stack…', 'info', 15000);
const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: id } });
Nova.toast(r?.success ? 'Stack reinstalled' : (r?.message||'Reinstall failed'), r?.success?'success':'error');
if (r?.success) dockerLoadTab('stacks');
}, true);
window.dockerStackRemove = (id) => Nova.confirm('Remove this stack? Docker Compose down will be run first.', async () => { window.dockerStackRemove = (id) => Nova.confirm('Remove this stack? Docker Compose down will be run first.', async () => {
const r = await Nova.api('docker', 'stack-remove', { method: 'DELETE', body: { stack_id: id } }); const r = await Nova.api('docker', 'stack-remove', { method: 'DELETE', body: { stack_id: id } });
Nova.toast(r?.success ? 'Stack removed' : (r?.message||'Failed'), r?.success?'success':'error'); Nova.toast(r?.success ? 'Stack removed' : (r?.message||'Failed'), r?.success?'success':'error');
+28 -2
View File
@@ -1076,9 +1076,10 @@ async function dockerPage(el) {
<div class="stat-card"><div class="stat-label">Max CPUs / App</div><div class="stat-value stat-green">${quota.max_cpus}</div></div> <div class="stat-card"><div class="stat-label">Max CPUs / App</div><div class="stat-value stat-green">${quota.max_cpus}</div></div>
</div> </div>
<div style="display:flex;gap:.5rem;margin-bottom:1rem"> <div style="display:flex;gap:.5rem;margin-bottom:1rem;flex-wrap:wrap">
<button class="btn btn-sm ${_uDockerTab==='my-apps'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('my-apps')">My Apps</button> <button class="btn btn-sm ${_uDockerTab==='my-apps'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('my-apps')">My Apps</button>
<button class="btn btn-sm ${_uDockerTab==='catalog'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('catalog')">App Catalog</button> <button class="btn btn-sm ${_uDockerTab==='catalog'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('catalog')">App Catalog</button>
<button class="btn btn-sm btn-danger" style="margin-left:auto" onclick="uDockerUninstallAll()">Remove All My Apps</button>
</div> </div>
<div id="udocker-content"><div class="loading">Loading</div></div>`; <div id="udocker-content"><div class="loading">Loading</div></div>`;
@@ -1103,6 +1104,18 @@ async function dockerPage(el) {
window._uDockerTab = 'my-apps'; window._uDockerTab = 'my-apps';
window.uDockerUninstallAll = () => Nova.confirm(
'Remove ALL your Docker apps? This will stop and delete every container and stack you own. Your hosting account and websites are not affected.',
async () => {
Nova.loading('Removing all your Docker apps…');
const r = await Nova.api('docker', 'uninstall-account', { method: 'POST', body: {} });
Nova.loadingDone();
Nova.toast(r?.success ? 'All Docker apps removed' : (r?.error || r?.message || 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) await uDockerReloadStacks();
},
true
);
async function uDockerReloadStacks() { async function uDockerReloadStacks() {
const r = await Nova.api('docker', 'stacks'); const r = await Nova.api('docker', 'stacks');
window._uDockerStacks = r?.data?.stacks || []; window._uDockerStacks = r?.data?.stacks || [];
@@ -1142,6 +1155,7 @@ ${stacks.map(s=>`<tr>
? `<button class="btn btn-xs btn-warning" onclick="uStackAct(${s.id},'down')">Stop</button>` ? `<button class="btn btn-xs btn-warning" onclick="uStackAct(${s.id},'down')">Stop</button>`
: `<button class="btn btn-xs btn-success" onclick="uStackAct(${s.id},'up')">Start</button>`} : `<button class="btn btn-xs btn-success" onclick="uStackAct(${s.id},'up')">Start</button>`}
<button class="btn btn-xs btn-ghost" onclick="uStackLogs(${s.id},'${Nova.escHtml(s.name)}')">Logs</button> <button class="btn btn-xs btn-ghost" onclick="uStackLogs(${s.id},'${Nova.escHtml(s.name)}')">Logs</button>
<button class="btn btn-xs btn-secondary" onclick="uStackReinstall(${s.id},'${Nova.escHtml(s.name)}')">Reinstall</button>
<button class="btn btn-xs btn-danger" onclick="uStackRemove(${s.id},'${Nova.escHtml(s.name)}')">Remove</button> <button class="btn btn-xs btn-danger" onclick="uStackRemove(${s.id},'${Nova.escHtml(s.name)}')">Remove</button>
</td> </td>
</tr>`).join('')} </tr>`).join('')}
@@ -1180,10 +1194,22 @@ window.uStackLogs = async (stackId, name) => {
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(r?.data?.output||'No logs available')}</pre>`); Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(r?.data?.output||'No logs available')}</pre>`);
}; };
window.uStackReinstall = (stackId, name) => Nova.confirm(
`Reinstall "${name}"? This will pull the latest images, restart all containers, and reset to a fresh state. Your data volumes will be preserved.`,
async () => {
Nova.loading(`Reinstalling ${name}`);
const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: stackId } });
Nova.loadingDone();
Nova.toast(r?.success ? `${name} reinstalled` : (r?.message || 'Reinstall failed'), r?.success ? 'success' : 'error');
if (r?.success) await uDockerReloadStacks();
},
true
);
window.uStackRemove = async (stackId, name) => { window.uStackRemove = async (stackId, name) => {
if (!confirm(`Remove app "${name}"? This will stop and delete its containers and data.`)) return; if (!confirm(`Remove app "${name}"? This will stop and delete its containers and data.`)) return;
Nova.loading('Removing app…'); Nova.loading('Removing app…');
const r = await Nova.api('docker', 'remove-stack', { method: 'POST', body: { stack_id: stackId } }); const r = await Nova.api('docker', 'stack-remove', { method: 'POST', body: { stack_id: stackId } });
Nova.loadingDone(); Nova.loadingDone();
Nova.toast(r?.success ? 'App removed' : (r?.message||'Failed'), r?.success?'success':'error'); Nova.toast(r?.success ? 'App removed' : (r?.message||'Failed'), r?.success?'success':'error');
if (r?.success) await uDockerReloadStacks(); if (r?.success) await uDockerReloadStacks();