- CORS: replace open regex with explicit hostname allowlist + port whitelist
- Exception handler: only expose RuntimeException/InvalidArgumentException
messages; PDOException and others return generic 'internal error'
- Auth::portalUrl(): allowlist-validate HTTP_HOST before using it in
redirect URL — prevents open redirect via Host header injection
- _branding.php custom_css: strip HTML tags, js: URLs, @import, expression()
instead of just </style> which was trivially bypassable
- accounts create: check accounts table as well as users for username
uniqueness (TOCTOU fix); wrap user INSERT + provisioning in single
transaction so rollback is atomic on failure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
- accounts.php: remove outer beginTransaction() — AccountManager already wraps in its own transaction; nested transactions fail in SQLite with 'already an active transaction'
- accounts.php: on AccountManager failure, manually delete the inserted user row instead
- admin/reseller/user index.php: fix favicon href from /assets/img/favicon.svg to nova-favicon.svg
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
- admin.js: 1292 lines of features were on server but not in repo — recovered and committed
- admin.js: impersonation redirect now uses location.origin instead of hardcoded :8880 port
- accounts.php: pre-validate email uniqueness and username before INSERT to prevent SQLSTATE 23000
- accounts.php: wrap user INSERT + AccountManager::create() in single transaction for full rollback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
When HTTP_HOST has no port (NPM on 443), return URL without appending panel port.
Direct access (HTTP_HOST includes :8882 etc.) still redirects to correct port.
Prevents browser being sent to :8882 directly after login via novacpx.orbishosting.com.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
- set_exception_handler in api/index.php prevents uncaught exceptions from crashing PHP-FPM
- AccountManager::create() wrapped in DB transaction with rollback + Linux user cleanup on failure
- CORS origin regex updated to allow requests from port 443 (NPM reverse proxy)
- index.html written via sudo tee instead of file_put_contents (www-data permission fix)
- chpasswd now called with sudo prefix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
- 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>
- 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>
- 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>
- 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>
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>
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>
- Core.php: add DB_HOST constant (was undefined, causing fatal error on any
WordPress manager page load in PHP 8)
- WordPressManager: make provDb lazy (only connects to MySQL when actually
needed for install/clone/delete — not on list/info which only use SQLite)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Settings page now loads current values from DB and saves via save-option API
- check-novacpx-update reads update_channel setting, checks origin/main or origin/beta
- apply-novacpx-update pulls from channel branch, fixes backup dir (/tmp), fixes SQLite migration syntax, records new version in novacpx_version table + settings.panel_version
- deploy-runner.sh reads update_channel from DB, pulls correct branch, records version after deploy
- webhook.php accepts pushes to both main and beta branches
- Updates page shows channel badge and latest remote version
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- date -u +%H:%M:%S UTC → ts() helper with date -u +"%H:%M:%S UTC"
(UTC as a separate word was being treated as an extra date argument)
- Backup dir changed from /var/novacpx/backups/ (root-owned, doesn't exist)
to /tmp/novacpx-backup-TIMESTAMP/ (always writable by www-data)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- check-novacpx-update and check-os-update return cached data (12h TTL)
immediately instead of running slow git fetch / apt-get update on page load
- Cache stored in settings table (update_cache_novacpx, update_cache_os)
- Updates page shows "Cached · last checked X ago" when serving cache
- "Refresh now" button forces a live re-check and updates cache
- bin/cache-update-check.php: standalone cron script that warms cache nightly
- Cron registered at 2am daily on panel server
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
launchFromCatalog creates compose stacks, not docker_containers entries.
Replace My Containers tab with My Apps tab backed by docker/stacks endpoint.
Add Refresh, Start/Stop, Logs, Remove actions per stack row.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Docker app launch now runs docker compose up -d in background (nohup &)
so the API returns immediately instead of timing out during image pulls
- EmailManager syncPostfix: replace MySQL SUBSTRING_INDEX with SQLite SUBSTR/INSTR
- EmailManager syncPostfix: write postfix files via sudo tee (www-data permission fix)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dots/dashes in names were failing validateName; now stripped to underscores.
Empty db_user field sent as "" (not null) so ?? fallback never fired; fixed
to check for empty string explicitly. Wrap createMySQL/Postgres in try/catch
so validation errors return 400 JSON instead of 500. Also pass db_type from
JS (was being sent as db_type not type).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- domains: VhostManager::create() called with array instead of 4 params
- PHPManager: VhostManager not required; pool writes use sudo tee (permission);
updateConfig creates pool if missing instead of throwing
- DatabaseManager: MySQL ops used SQLite panel PDO; add dedicated mysqlPdo()
using MariaDB socket auth
- BackupManager: column name is size_mb not size; diskUsage returns float
- DB.php: add LAST_INSERT_ID() → last_insert_rowid() translation
- user.js: SSL issue/submit used Nova.api (JSON) but endpoint streams SSE;
add _sslStream() helper matching admin panel behavior
- schema/migration: add enc_password column to email_accounts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Constants were only defined in api/index.php, so direct requests to
admin/reseller/user index.php got a fatal error before rendering any HTML.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- .github/workflows/version-bump.yml: auto-increment patch version on push to main/beta
- admin/index.php: show version under logo from VERSION file
- system.php: service-versions endpoint (catalog of 22 services with version/description/status)
- admin.js: updates page shows Installed Services table with current/latest/status/description
- admin.js: loadServiceVersions() lazy-loaded after page render via setTimeout
- admin/index.php: separate Fail2Ban sidebar entry (was merged into Firewall label)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Panel no longer depends on the user-managed MariaDB service.
SQLite at /var/lib/novacpx/panel.db runs independently so the
control panel stays up even when MariaDB is stopped.
- DB.php: switch to sqlite: DSN, add SQL translator (ON DUPLICATE KEY,
DATE_ADD/DATE_SUB INTERVAL, NOW(), UNIX_TIMESTAMP(), IFNULL)
- Core.php: replace DB_HOST/NAME/USER/PASS with DB_PATH constant
- schema.sql: full SQLite syntax, add TOTP columns to users table
- _branding.php: use sqlite: PDO, datetime('now') for session check
- install.sh: apt install sqlite3, create SQLite DB instead of MySQL DB
- tools/migrate-to-sqlite.sh: one-shot migration script for existing installs
- proxy.php: always set proxy_mode=disabled and clear remote_host/backend_ip
on any uninstall, not just when nginx binary is removed
- admin.js: show setup cards when mode==='disabled' regardless of whether
nginx binary still exists on the remote VM
- Status card shows 'Disabled' instead of 'Stopped' when mode is disabled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NOVACPX_ROOT (/srv/novacpx/public) is a deployed file copy, not a git
repo — hence 'fatal: not a git repository'. The actual git clone lives
at /opt/novacpx-src (installed by the installer).
check-update and apply-update now use /opt/novacpx-src for all git
operations. apply-update also deploys the pulled files back to the web
root with cp -a (public/, api/, lib/, bin/) and re-sets ownership.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Nova.modal(title, body, footerHtml) expects an HTML string for the third
parameter. proxyAddHost, proxyEditHost, proxySwitchLocal, and proxyUninstall
were all passing async functions instead, which got stringified as garbage
text with no actual buttons rendered.
Each modal now gets proper Cancel + action button footer HTML, with the
save/action logic wired via addEventListener after modal creation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
proxyRunSetup() used EventSource which only sends GET. The setup-remote
endpoint requires POST, so the request hit the 404 default and the SSE
connection immediately errored with 'Connection lost'.
Replace EventSource with fetch+ReadableStream (same pattern already used
by proxySwitchLocal). Also remove the dead EventSource+close() pair that
was left in proxySwitchLocal from an earlier draft.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>