Using PORT_USER ?? 8880 threw Error in PHP 8 since the constant isn't defined
until Core.php is require_once'd later in the file. Every API request was
hitting the exception handler and returning 'An internal error occurred.',
breaking all logins and API calls.
#38 — User panel Account > Settings page: account info, resource usage
gauges, PHP config (version/memory/upload/exec), quick links to SSL/2FA/password.
#39 — AccountManager: dark-themed modern default index.html on account
creation; supports custom HTML template from admin Server Options
(saved as default_index_template setting, {domain}/{username} placeholders).
Admin Server Options: new card to set/reset the custom template.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
Admin: global view of all subdomains/parked across accounts; nav items added
Reseller: filtered view scoped to their customers' accounts
User: create/remove subdomains and parked domains for own account
Backend already existed in api/endpoints/domains.php (add-subdomain,
add-alias, list, remove actions).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
- 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>