diff --git a/db/migrations/002_features_14_17.sql b/db/migrations/002_features_14_17.sql
new file mode 100644
index 0000000..799658a
--- /dev/null
+++ b/db/migrations/002_features_14_17.sql
@@ -0,0 +1,59 @@
+-- Migration 002: Features #14-17 (WordPress, Backup, Cloudflare, TOTP)
+
+-- #17 TOTP columns on users (ignore errors if columns already exist)
+ALTER TABLE users ADD COLUMN totp_secret VARCHAR(64) DEFAULT NULL;
+ALTER TABLE users ADD COLUMN totp_enabled TINYINT(1) DEFAULT 0;
+ALTER TABLE users ADD COLUMN totp_backup_codes TEXT DEFAULT NULL;
+
+-- #16 Cloudflare columns on accounts
+ALTER TABLE accounts ADD COLUMN cf_api_key VARCHAR(255) DEFAULT NULL;
+ALTER TABLE accounts ADD COLUMN cf_api_email VARCHAR(255) DEFAULT NULL;
+ALTER TABLE accounts ADD COLUMN cf_zone_id VARCHAR(64) DEFAULT NULL;
+
+-- #16 Cloudflare zone_id on dns_zones
+ALTER TABLE dns_zones ADD COLUMN cf_zone_id VARCHAR(64) DEFAULT NULL;
+
+-- #14 WordPress installs
+CREATE TABLE IF NOT EXISTS wordpress_installs (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ account_id INT NOT NULL,
+ domain VARCHAR(255) NOT NULL,
+ path VARCHAR(255) DEFAULT '/',
+ db_name VARCHAR(64) DEFAULT NULL,
+ db_user VARCHAR(64) DEFAULT NULL,
+ db_pass VARCHAR(128) DEFAULT NULL,
+ wp_version VARCHAR(20) DEFAULT NULL,
+ admin_user VARCHAR(64) DEFAULT NULL,
+ admin_email VARCHAR(255) DEFAULT NULL,
+ status ENUM('active','updating','suspended') DEFAULT 'active',
+ staging_of INT DEFAULT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX (account_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- #15 Backups
+CREATE TABLE IF NOT EXISTS backups (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ account_id INT NOT NULL,
+ filename VARCHAR(255) NOT NULL,
+ size BIGINT DEFAULT 0,
+ type ENUM('full','files','database') DEFAULT 'full',
+ status ENUM('pending','running','complete','failed') DEFAULT 'pending',
+ storage VARCHAR(50) DEFAULT 'local',
+ remote_path VARCHAR(500) DEFAULT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ INDEX (account_id),
+ INDEX (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS backup_schedules (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ account_id INT NOT NULL UNIQUE,
+ frequency ENUM('hourly','daily','weekly','monthly') DEFAULT 'daily',
+ type ENUM('full','files','database') DEFAULT 'full',
+ retain_count INT DEFAULT 7,
+ last_run TIMESTAMP NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ INDEX (account_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/db/migrations/003_proxy_hosts.sql b/db/migrations/003_proxy_hosts.sql
new file mode 100644
index 0000000..81c91a8
--- /dev/null
+++ b/db/migrations/003_proxy_hosts.sql
@@ -0,0 +1,19 @@
+-- Migration 003: Nginx Proxy Hosts table
+CREATE TABLE IF NOT EXISTS proxy_hosts (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ account_id INT UNSIGNED DEFAULT NULL,
+ domain VARCHAR(255) NOT NULL,
+ upstream VARCHAR(500) NOT NULL DEFAULT 'http://127.0.0.1:80',
+ ssl_enabled TINYINT(1) NOT NULL DEFAULT 0,
+ enabled TINYINT(1) NOT NULL DEFAULT 1,
+ custom_config TEXT DEFAULT NULL,
+ notes VARCHAR(500) DEFAULT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+ INDEX (account_id),
+ UNIQUE KEY uq_domain (domain)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Panel settings for proxy mode
+INSERT INTO settings (`key`, `value`) VALUES ('proxy_mode', 'disabled') ON DUPLICATE KEY UPDATE `key`=`key`;
+INSERT INTO settings (`key`, `value`) VALUES ('proxy_auto_sync', '0') ON DUPLICATE KEY UPDATE `key`=`key`;
diff --git a/db/migrations/004_rate_limits.sql b/db/migrations/004_rate_limits.sql
new file mode 100644
index 0000000..2adc7f5
--- /dev/null
+++ b/db/migrations/004_rate_limits.sql
@@ -0,0 +1,8 @@
+-- Migration 004: API rate limiting table
+CREATE TABLE IF NOT EXISTS api_rate_limits (
+ ip VARCHAR(45) NOT NULL,
+ endpoint VARCHAR(64) NOT NULL DEFAULT 'api',
+ hits INT UNSIGNED NOT NULL DEFAULT 1,
+ window_start INT UNSIGNED NOT NULL,
+ PRIMARY KEY (ip, endpoint)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/db/migrations/005_webmail_sso.sql b/db/migrations/005_webmail_sso.sql
new file mode 100644
index 0000000..7792579
--- /dev/null
+++ b/db/migrations/005_webmail_sso.sql
@@ -0,0 +1,22 @@
+-- Migration 005: Webmail SSO — encrypted IMAP password + SSO tokens table
+SET @col_exists = (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'email_accounts' AND COLUMN_NAME = 'enc_password'
+);
+SET @sql = IF(@col_exists = 0,
+ 'ALTER TABLE email_accounts ADD COLUMN enc_password VARCHAR(512) DEFAULT NULL',
+ 'SELECT 1'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+CREATE TABLE IF NOT EXISTS webmail_sso_tokens (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ token VARCHAR(64) NOT NULL,
+ email VARCHAR(320) NOT NULL,
+ enc_pass VARCHAR(512) NOT NULL,
+ expires_at DATETIME NOT NULL,
+ UNIQUE KEY uq_token (token),
+ INDEX idx_expires (expires_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/errors/404.php b/errors/404.php
new file mode 100644
index 0000000..7ffc2c8
--- /dev/null
+++ b/errors/404.php
@@ -0,0 +1,39 @@
+
+
+
+
+
+404 — Page Not Found · NovaCPX
+
+
+
+
+
+
404
+
Page Not Found
+
The page you're looking for doesn't exist or has been moved.
+
+
+ Go Back
+
+
+
+
diff --git a/errors/500.php b/errors/500.php
new file mode 100644
index 0000000..ce2d702
--- /dev/null
+++ b/errors/500.php
@@ -0,0 +1,39 @@
+
+
+
+
+
+500 — Server Error · NovaCPX
+
+
+
+
+
+
500
+
Internal Server Error
+
Something went wrong on our end. The issue has been logged. Please try again in a moment.
+
+
+ Go Back
+
+
+
+
diff --git a/panel/api/endpoints/accounts.php b/panel/api/endpoints/accounts.php
index e176d75..94d2fb6 100644
--- a/panel/api/endpoints/accounts.php
+++ b/panel/api/endpoints/accounts.php
@@ -109,15 +109,15 @@ match ($action) {
Response::success(null, 'Account terminated');
})(),
- 'change-password' => (function() use ($db, $body) {
- $id = (int)($body['id'] ?? 0);
+ 'change-password' => (function() use ($db, $body, $user) {
+ Auth::getInstance()->require('admin');
+ $id = (int)($body['account_id'] ?? $body['id'] ?? 0);
$pass = $body['password'] ?? '';
if (strlen($pass) < 8) Response::error("Password must be at least 8 characters");
- $acct = $db->fetchOne("SELECT user_id FROM accounts WHERE id = ?", [$id]);
+ $acct = $db->fetchOne("SELECT a.user_id, a.username FROM accounts a WHERE a.id = ?", [$id]);
if (!$acct) Response::error("Account not found", 404);
$db->execute("UPDATE users SET password = ? WHERE id = ?", [password_hash($pass, PASSWORD_BCRYPT), $acct['user_id']]);
- // Also update system user password
- shell_exec("echo " . escapeshellarg("{$id}:{$pass}") . " | chpasswd 2>/dev/null");
+ shell_exec("echo " . escapeshellarg("{$acct['username']}:{$pass}") . " | sudo chpasswd 2>/dev/null");
audit('account.change-password', "account:$id");
Response::success(null, 'Password changed');
})(),
diff --git a/panel/api/endpoints/auth.php b/panel/api/endpoints/auth.php
index 981dd5d..3faeb86 100644
--- a/panel/api/endpoints/auth.php
+++ b/panel/api/endpoints/auth.php
@@ -4,11 +4,15 @@ $body = json_decode(file_get_contents('php://input'), true) ?? [];
match ($action) {
'login' => (function() use ($body) {
- $username = trim($body['username'] ?? '');
- $password = $body['password'] ?? '';
+ $username = trim($body['username'] ?? '');
+ $password = $body['password'] ?? '';
+ $totpCode = isset($body['totp_code']) ? trim($body['totp_code']) : null;
if (!$username || !$password) Response::error('Username and password required');
$auth = Auth::getInstance();
- $token = $auth->attempt($username, $password);
+ $token = $auth->attempt($username, $password, $totpCode);
+ if ($token === Auth::TOTP_REQUIRED) {
+ Response::json(['success' => false, 'totp_required' => true, 'message' => 'Enter your 2FA code'], 200);
+ }
if (!$token) {
// Log failure for Fail2Ban to detect
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
@@ -53,5 +57,23 @@ match ($action) {
]);
})(),
+ 'change-password' => (function() use ($body) {
+ $auth = Auth::getInstance();
+ if (!$auth->check()) Response::error('Unauthorized', 401);
+ $user = $auth->user();
+ $current = $body['current_password'] ?? '';
+ $new = $body['new_password'] ?? '';
+ $confirm = $body['confirm_password'] ?? '';
+ if (!$current || !$new) Response::error('current_password and new_password required');
+ if (strlen($new) < 8) Response::error('Password must be at least 8 characters');
+ if ($new !== $confirm) Response::error('Passwords do not match');
+ $db = DB::getInstance();
+ $full = $db->fetchOne("SELECT password FROM users WHERE id = ?", [$user['uid']]);
+ if (!$full || !password_verify($current, $full['password'])) Response::error('Current password is incorrect', 400);
+ $db->execute("UPDATE users SET password = ? WHERE id = ?", [password_hash($new, PASSWORD_BCRYPT), $user['uid']]);
+ audit('auth.change-password', 'user:' . $user['uid']);
+ Response::success(null, 'Password changed');
+ })(),
+
default => Response::error('Unknown auth action', 404),
};
diff --git a/panel/api/endpoints/backup.php b/panel/api/endpoints/backup.php
new file mode 100644
index 0000000..a3df798
--- /dev/null
+++ b/panel/api/endpoints/backup.php
@@ -0,0 +1,82 @@
+ (function() use ($bm, $accountId, $isAdmin) {
+ $list = $bm->list($isAdmin ? $accountId : $accountId);
+ Response::success(['backups' => $list, 'disk_used' => $bm->diskUsage($accountId)]);
+ })(),
+
+ 'create' => (function() use ($bm, $body, $accountId) {
+ if (!$accountId) Response::error('account_id required');
+ $type = in_array($body['type'] ?? 'full', ['full','files','database']) ? $body['type'] : 'full';
+ $result = $bm->create($accountId, $type);
+ audit('backup_create', 'backup', ['account_id' => $accountId, 'type' => $type]);
+ Response::success($result, 'Backup created successfully');
+ })(),
+
+ 'restore' => (function() use ($bm, $body, $isAdmin) {
+ if (!$isAdmin) Response::error('Admin only', 403);
+ $id = (int)($body['id'] ?? 0);
+ if (!$id) Response::error('id required');
+ $bm->restore($id);
+ audit('backup_restore', 'backup', ['id' => $id]);
+ Response::success(null, 'Backup restored successfully');
+ })(),
+
+ 'download' => (function() use ($bm, $body) {
+ $id = (int)($body['id'] ?? $_GET['id'] ?? 0);
+ if (!$id) Response::error('id required');
+ $path = $bm->getDownloadPath($id);
+ $name = basename($path);
+ header('Content-Type: application/gzip');
+ header("Content-Disposition: attachment; filename=\"{$name}\"");
+ header('Content-Length: ' . filesize($path));
+ ob_end_clean();
+ readfile($path);
+ exit;
+ })(),
+
+ 'delete' => (function() use ($bm, $body, $isAdmin) {
+ if (!$isAdmin) Response::error('Admin only', 403);
+ $id = (int)($body['id'] ?? 0);
+ if (!$id) Response::error('id required');
+ $bm->delete($id);
+ audit('backup_delete', 'backup', ['id' => $id]);
+ Response::success(null, 'Backup deleted');
+ })(),
+
+ 'schedule' => (function() use ($bm, $body, $accountId, $isAdmin) {
+ if (!$isAdmin) Response::error('Admin only', 403);
+ if (!$accountId) Response::error('account_id required');
+ $freq = $body['frequency'] ?? 'daily';
+ $type = $body['type'] ?? 'full';
+ $retain = (int)($body['retain'] ?? 7);
+ $bm->setSchedule($accountId, $freq, $type, $retain);
+ Response::success(null, 'Backup schedule saved');
+ })(),
+
+ 'get-schedule' => (function() use ($bm, $accountId) {
+ if (!$accountId) Response::error('account_id required');
+ Response::success($bm->getSchedule($accountId));
+ })(),
+
+ 'upload-remote' => (function() use ($bm, $body, $isAdmin) {
+ if (!$isAdmin) Response::error('Admin only', 403);
+ $id = (int)($body['id'] ?? 0);
+ $remote = trim($body['remote'] ?? '');
+ if (!$id || !$remote) Response::error('id and remote required');
+ $out = $bm->uploadRemote($id, $remote);
+ Response::success(['output' => $out], 'Upload complete');
+ })(),
+
+ default => Response::error('Unknown action', 404),
+};
diff --git a/panel/api/endpoints/cloudflare.php b/panel/api/endpoints/cloudflare.php
new file mode 100644
index 0000000..eb99783
--- /dev/null
+++ b/panel/api/endpoints/cloudflare.php
@@ -0,0 +1,99 @@
+getCredentials($accountId);
+ $apiKey = $creds['cf_api_key'] ?? null;
+ $email = $creds['cf_api_email'] ?? null;
+}
+
+match ($action) {
+ 'test-key' => (function() use ($cf, $body) {
+ $key = trim($body['api_key'] ?? '');
+ $email = trim($body['email'] ?? '');
+ if (!$key || !$email) Response::error('api_key and email required');
+ $ok = $cf->testCredentials($key, $email);
+ Response::success(['valid' => $ok], $ok ? 'API key is valid' : 'Invalid API key');
+ })(),
+
+ 'save-credentials' => (function() use ($cf, $body, $accountId) {
+ if (!$accountId) Response::error('account_id required');
+ $key = trim($body['api_key'] ?? '');
+ $email = trim($body['email'] ?? '');
+ if (!$key || !$email) Response::error('api_key and email required');
+ $cf->saveCredentials($accountId, $key, $email);
+ audit('cf_credentials_saved', 'cloudflare', ['account_id' => $accountId]);
+ Response::success(null, 'Cloudflare credentials saved');
+ })(),
+
+ 'get-credentials' => (function() use ($cf, $accountId) {
+ if (!$accountId) Response::error('account_id required');
+ $creds = $cf->getCredentials($accountId);
+ // Mask the key in the response
+ if ($creds) $creds['cf_api_key'] = substr($creds['cf_api_key'], 0, 6) . str_repeat('*', 30);
+ Response::success($creds);
+ })(),
+
+ 'list-zones' => (function() use ($cf, $apiKey, $email) {
+ if (!$apiKey || !$email) Response::error('No Cloudflare credentials configured');
+ Response::success($cf->listZones($apiKey, $email));
+ })(),
+
+ 'list-records' => (function() use ($cf, $body, $apiKey, $email) {
+ $zoneId = trim($body['zone_id'] ?? '');
+ if (!$zoneId) Response::error('zone_id required');
+ if (!$apiKey || !$email) Response::error('No Cloudflare credentials configured');
+ Response::success($cf->listRecords($zoneId, $apiKey, $email));
+ })(),
+
+ 'toggle-proxy' => (function() use ($cf, $body, $apiKey, $email) {
+ $zoneId = trim($body['zone_id'] ?? '');
+ $recordId = trim($body['record_id'] ?? '');
+ $proxied = (bool)($body['proxied'] ?? false);
+ if (!$zoneId || !$recordId) Response::error('zone_id and record_id required');
+ if (!$apiKey || !$email) Response::error('No credentials');
+ $result = $cf->toggleProxy($zoneId, $recordId, $proxied, $apiKey, $email);
+ Response::success($result, 'Proxy status updated');
+ })(),
+
+ 'sync-to-cf' => (function() use ($cf, $body, $apiKey, $email) {
+ $domain = trim($body['domain'] ?? '');
+ $zoneId = trim($body['zone_id'] ?? '');
+ if (!$domain || !$zoneId) Response::error('domain and zone_id required');
+ if (!$apiKey || !$email) Response::error('No credentials');
+ $result = $cf->syncToCloudflare($domain, $zoneId, $apiKey, $email);
+ audit('cf_sync_to', 'cloudflare', ['domain' => $domain]);
+ Response::success($result, "Pushed to Cloudflare: {$result['created']} created, {$result['updated']} updated");
+ })(),
+
+ 'sync-from-cf' => (function() use ($cf, $body, $apiKey, $email) {
+ $domain = trim($body['domain'] ?? '');
+ $zoneId = trim($body['zone_id'] ?? '');
+ if (!$domain || !$zoneId) Response::error('domain and zone_id required');
+ if (!$apiKey || !$email) Response::error('No credentials');
+ $count = $cf->syncFromCloudflare($domain, $zoneId, $apiKey, $email);
+ audit('cf_sync_from', 'cloudflare', ['domain' => $domain, 'records' => $count]);
+ Response::success(['count' => $count], "Pulled {$count} records from Cloudflare");
+ })(),
+
+ 'purge-cache' => (function() use ($cf, $body, $apiKey, $email) {
+ $zoneId = trim($body['zone_id'] ?? '');
+ if (!$zoneId) Response::error('zone_id required');
+ if (!$apiKey || !$email) Response::error('No credentials');
+ $ok = $cf->purgeCache($zoneId, $apiKey, $email);
+ Response::success(['success' => $ok], $ok ? 'Cache purged' : 'Purge failed');
+ })(),
+
+ default => Response::error('Unknown action', 404),
+};
diff --git a/panel/api/endpoints/files.php b/panel/api/endpoints/files.php
index 3a05119..6e5fc81 100644
--- a/panel/api/endpoints/files.php
+++ b/panel/api/endpoints/files.php
@@ -12,14 +12,25 @@ if (!$acct && $user['role'] !== 'admin') Response::error("No hosting account fou
$baseDir = $acct ? realpath($acct['home_dir']) : '/';
+// For existing paths only — throws if path doesn't exist or escapes baseDir
function safe_path(string $base, string $rel): string {
$full = realpath($base . '/' . ltrim($rel, '/'));
- if (!$full || !str_starts_with($full, $base)) {
+ if ($full === false || !str_starts_with($full . '/', $base . '/')) {
throw new RuntimeException("Path outside account directory");
}
return $full;
}
+// For paths that may not exist yet (write/mkdir) — validates parent dir
+function safe_path_new(string $base, string $rel): string {
+ $candidate = $base . '/' . ltrim(str_replace(['../', '..\\'], '', $rel), '/');
+ $parent = realpath(dirname($candidate));
+ if ($parent === false || !str_starts_with($parent . '/', $base . '/')) {
+ throw new RuntimeException("Path outside account directory");
+ }
+ return $parent . '/' . basename($candidate);
+}
+
function fmt_size(int $bytes): string {
if ($bytes >= 1073741824) return round($bytes/1073741824, 1) . 'GB';
if ($bytes >= 1048576) return round($bytes/1048576, 1) . 'MB';
@@ -58,39 +69,46 @@ match ($action) {
})(),
'write' => (function() use ($baseDir, $body) {
- $path = safe_path($baseDir, $body['path'] ?? '');
+ $rel = $body['path'] ?? '';
$content = $body['content'] ?? '';
+ // Use safe_path for existing files, safe_path_new for new ones
+ try {
+ $path = safe_path($baseDir, $rel);
+ } catch (RuntimeException) {
+ $path = safe_path_new($baseDir, $rel);
+ }
// PHP syntax check for .php files
if (str_ends_with($path, '.php')) {
$tmp = tempnam(sys_get_temp_dir(), 'ncpx_');
file_put_contents($tmp, $content);
$result = shell_exec("php8.3 -l " . escapeshellarg($tmp) . " 2>&1");
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");
}
file_put_contents($path, $content);
- audit('files.write', $body['path'] ?? '');
+ audit('files.write', $rel);
Response::success(null, 'File saved');
})(),
'mkdir' => (function() use ($baseDir, $body) {
- $path = $baseDir . '/' . ltrim($body['path'] ?? '', '/');
- // Don't use safe_path since dir may not exist yet
- if (!str_starts_with(realpath(dirname($path)) ?: '', $baseDir)) Response::error("Invalid path");
+ $path = safe_path_new($baseDir, $body['path'] ?? '');
+ if (is_dir($path)) Response::error("Directory already exists");
if (!mkdir($path, 0755, true)) Response::error("Could not create directory");
Response::success(null, 'Directory created');
})(),
'rename' => (function() use ($baseDir, $body) {
$from = safe_path($baseDir, $body['from'] ?? '');
- $to = $baseDir . '/' . ltrim($body['to'] ?? '', '/');
- if (!str_starts_with(realpath(dirname($to)) ?: $baseDir, $baseDir)) Response::error("Invalid destination");
+ $to = safe_path_new($baseDir, $body['to'] ?? '');
+ if (file_exists($to)) Response::error("Destination already exists");
rename($from, $to);
Response::success(null, 'Renamed');
})(),
'delete' => (function() use ($baseDir, $body) {
$path = safe_path($baseDir, $body['path'] ?? '');
+ // Prevent deleting the account root or its direct children (public_html, logs, tmp)
+ if ($path === $baseDir || dirname($path) === $baseDir) Response::error("Cannot delete root account directories");
if (is_dir($path)) {
shell_exec("rm -rf " . escapeshellarg($path));
} else {
@@ -103,23 +121,28 @@ match ($action) {
'chmod' => (function() use ($baseDir, $body) {
$path = safe_path($baseDir, $body['path'] ?? '');
$perms = octdec((string)($body['perms'] ?? '755'));
+ // Clamp to sane range: no setuid/setgid/sticky, no world-write on dirs
+ $perms = $perms & 0777;
chmod($path, $perms);
Response::success(null, 'Permissions updated');
})(),
'upload' => (function() use ($baseDir, $body) {
$dir = safe_path($baseDir, $body['path'] ?? '/public_html');
+ if (!is_dir($dir)) Response::error("Target directory not found");
if (empty($_FILES['file'])) Response::error("No file uploaded");
- $dest = $dir . '/' . basename($_FILES['file']['name']);
- if (!str_starts_with(realpath(dirname($dest)) ?: $baseDir, $baseDir)) Response::error("Invalid destination");
+ $filename = basename($_FILES['file']['name']);
+ if ($filename === '' || $filename === '.') Response::error("Invalid filename");
+ $dest = $dir . '/' . $filename;
+ if (!str_starts_with($dest, $baseDir . '/')) Response::error("Invalid destination");
move_uploaded_file($_FILES['file']['tmp_name'], $dest);
- Response::success(['name' => basename($dest)], 'File uploaded');
+ Response::success(['name' => $filename], 'File uploaded');
})(),
'compress' => (function() use ($baseDir, $body) {
- $paths = array_map(fn($p) => safe_path($baseDir, $p), (array)($body['paths'] ?? []));
- $dest = $baseDir . '/' . ltrim($body['dest'] ?? 'archive.zip', '/');
- $files = implode(' ', array_map('escapeshellarg', $paths));
+ $paths = array_map(fn($p) => safe_path($baseDir, $p), (array)($body['paths'] ?? []));
+ $dest = safe_path_new($baseDir, $body['dest'] ?? 'archive.zip');
+ $files = implode(' ', array_map('escapeshellarg', $paths));
shell_exec("cd " . escapeshellarg($baseDir) . " && zip -r " . escapeshellarg($dest) . " $files 2>/dev/null");
Response::success(null, 'Archive created');
})(),
diff --git a/panel/api/endpoints/proxy.php b/panel/api/endpoints/proxy.php
new file mode 100644
index 0000000..c5adc03
--- /dev/null
+++ b/panel/api/endpoints/proxy.php
@@ -0,0 +1,108 @@
+require('admin');
+$body = json_decode(file_get_contents('php://input'), true) ?? [];
+
+require_once NOVACPX_LIB . '/ProxyManager.php';
+
+try {
+ $method = $_SERVER['REQUEST_METHOD'];
+
+ match (true) {
+
+ // GET status
+ $action === 'status' && $method === 'GET' =>
+ Response::json(['success' => true, 'data' => ProxyManager::status()]),
+
+ // POST install
+ $action === 'install' && $method === 'POST' =>
+ Response::json(['success' => true, 'data' => ['result' => ProxyManager::install()]]),
+
+ // POST control
+ $action === 'control' && $method === 'POST' => (function() use ($body) {
+ $act = $body['action'] ?? '';
+ if (!in_array($act, ['start','stop','restart','reload'])) Response::error('Invalid action', 400);
+ $result = match($act) {
+ 'start' => ProxyManager::start(),
+ 'stop' => ProxyManager::stop(),
+ 'restart' => ProxyManager::restart(),
+ 'reload' => ProxyManager::reload(),
+ };
+ Response::json(['success' => true, 'data' => ['result' => $result, 'running' => ProxyManager::isRunning()]]);
+ })(),
+
+ // GET hosts list
+ $action === 'hosts' && $method === 'GET' =>
+ Response::json(['success' => true, 'data' => ProxyManager::listHosts()]),
+
+ // POST hosts — add
+ $action === 'hosts' && $method === 'POST' => (function() use ($body) {
+ if (empty($body['domain'])) Response::error('domain required', 400);
+ if (empty($body['upstream'])) Response::error('upstream required', 400);
+ $id = ProxyManager::addHost($body);
+ Response::json(['success' => true, 'data' => ['id' => $id]]);
+ })(),
+
+ // PUT host — update (body has id)
+ $action === 'host' && $method === 'PUT' => (function() use ($body) {
+ if (empty($body['id'])) Response::error('id required', 400);
+ ProxyManager::updateHost((int)$body['id'], $body);
+ Response::json(['success' => true]);
+ })(),
+
+ // DELETE host (body has id)
+ $action === 'host' && $method === 'DELETE' => (function() use ($body) {
+ $id = (int)($body['id'] ?? $_GET['id'] ?? 0);
+ if (!$id) Response::error('id required', 400);
+ ProxyManager::deleteHost($id);
+ Response::json(['success' => true]);
+ })(),
+
+ // POST toggle
+ $action === 'toggle' && $method === 'POST' => (function() use ($body) {
+ if (empty($body['id'])) Response::error('id required', 400);
+ ProxyManager::toggleHost((int)$body['id'], (bool)($body['enabled'] ?? true));
+ Response::json(['success' => true]);
+ })(),
+
+ // POST sync
+ $action === 'sync' && $method === 'POST' => (function() {
+ $added = ProxyManager::syncFromAccounts();
+ Response::json(['success' => true, 'data' => ['added' => $added]]);
+ })(),
+
+ // POST write-configs
+ ($action === 'write-configs' || $action === 'write_configs') && $method === 'POST' => (function() {
+ ProxyManager::writeAllConfigs();
+ Response::json(['success' => true, 'data' => ['result' => 'configs written']]);
+ })(),
+
+ // GET setup-script
+ ($action === 'setup-script' || $action === 'setup_script') && $method === 'GET' => (function() {
+ header('Content-Type: text/plain');
+ echo ProxyManager::setupScript();
+ exit;
+ })(),
+
+ default => Response::error('Not found', 404),
+ };
+
+} catch (Throwable $e) {
+ novacpx_log('error', 'proxy endpoint: ' . $e->getMessage());
+ Response::error($e->getMessage(), 500);
+}
diff --git a/panel/api/endpoints/sessions.php b/panel/api/endpoints/sessions.php
new file mode 100644
index 0000000..3680e9d
--- /dev/null
+++ b/panel/api/endpoints/sessions.php
@@ -0,0 +1,57 @@
+require('admin');
+$body = json_decode(file_get_contents('php://input'), true) ?? [];
+$db = DB::getInstance();
+$me = Auth::getInstance()->user();
+$method = $_SERVER['REQUEST_METHOD'];
+
+match (true) {
+
+ $action === 'list' && $method === 'GET' => (function() use ($db) {
+ $rows = $db->fetchAll(
+ "SELECT s.id, s.user_id, s.ip_address, s.user_agent, s.created_at, s.expires_at,
+ u.username, u.email, u.role
+ FROM sessions s
+ JOIN users u ON u.id = s.user_id
+ WHERE s.expires_at > NOW()
+ ORDER BY s.created_at DESC
+ LIMIT 200"
+ ) ?: [];
+ Response::json(['success' => true, 'data' => $rows]);
+ })(),
+
+ $action === 'revoke' && $method === 'DELETE' => (function() use ($db, $body) {
+ $sid = trim($body['session_id'] ?? '');
+ if (!$sid) Response::error('session_id required', 400);
+ $db->execute("DELETE FROM sessions WHERE id = ?", [$sid]);
+ Response::json(['success' => true]);
+ })(),
+
+ $action === 'revoke-user' && $method === 'DELETE' => (function() use ($db, $body) {
+ $uid = (int)($body['user_id'] ?? 0);
+ if (!$uid) Response::error('user_id required', 400);
+ $count = $db->execute("DELETE FROM sessions WHERE user_id = ?", [$uid]);
+ Response::json(['success' => true, 'data' => ['revoked' => $count]]);
+ })(),
+
+ $action === 'revoke-all' && $method === 'DELETE' => (function() use ($db, $me, $body) {
+ // Keep current session if provided
+ $keepId = $body['keep_session'] ?? null;
+ if ($keepId) {
+ $db->execute("DELETE FROM sessions WHERE id != ?", [hash('sha256', $keepId)]);
+ } else {
+ $db->execute("DELETE FROM sessions");
+ }
+ Response::json(['success' => true]);
+ })(),
+
+ default => Response::error('Not found', 404),
+};
diff --git a/panel/api/endpoints/totp.php b/panel/api/endpoints/totp.php
new file mode 100644
index 0000000..4704d39
--- /dev/null
+++ b/panel/api/endpoints/totp.php
@@ -0,0 +1,90 @@
+getPDO();
+
+match ($action) {
+ // Begin setup: generate secret + return QR URL (not yet enabled)
+ 'setup' => (function() use ($db, $uid, $currentUser) {
+ $secret = TOTP::generateSecret();
+ // Store pending secret (not enabled yet until verified)
+ $db->prepare("UPDATE users SET totp_secret=? WHERE id=?")->execute([$secret, $uid]);
+ Response::success([
+ 'secret' => $secret,
+ 'qr_url' => TOTP::qrUrl($secret, $currentUser['username']),
+ ], 'Scan QR code in your authenticator app, then confirm with a code');
+ })(),
+
+ // Confirm setup: verify first code, then enable TOTP and return backup codes
+ 'enable' => (function() use ($db, $uid, $body) {
+ $code = trim($body['code'] ?? '');
+ if (strlen($code) !== 6) Response::error('Enter the 6-digit code from your authenticator');
+ $stmt = $db->prepare("SELECT totp_secret FROM users WHERE id=?");
+ $stmt->execute([$uid]);
+ $secret = $stmt->fetchColumn();
+ if (!$secret) Response::error('Run setup first');
+ if (!TOTP::verify($secret, $code)) Response::error('Code incorrect — try again');
+ $backupCodes = TOTP::generateBackupCodes();
+ $hashedCodes = TOTP::hashBackupCodes($backupCodes);
+ $db->prepare("UPDATE users SET totp_enabled=1, totp_backup_codes=? WHERE id=?")->execute([$hashedCodes, $uid]);
+ audit('totp_enabled', 'security');
+ Response::success(['backup_codes' => $backupCodes], '2FA enabled. Save your backup codes — they will not be shown again.');
+ })(),
+
+ // Disable TOTP (requires current password confirmation)
+ 'disable' => (function() use ($db, $uid, $body) {
+ $pass = $body['password'] ?? '';
+ $stmt = $db->prepare("SELECT password FROM users WHERE id=?");
+ $stmt->execute([$uid]);
+ $hash = $stmt->fetchColumn();
+ if (!password_verify($pass, $hash)) Response::error('Password incorrect');
+ $db->prepare("UPDATE users SET totp_enabled=0, totp_secret=NULL, totp_backup_codes=NULL WHERE id=?")->execute([$uid]);
+ audit('totp_disabled', 'security');
+ Response::success(null, '2FA disabled');
+ })(),
+
+ // Get status (is 2FA on?)
+ 'status' => (function() use ($db, $uid) {
+ $stmt = $db->prepare("SELECT totp_enabled FROM users WHERE id=?");
+ $stmt->execute([$uid]);
+ $enabled = (bool)$stmt->fetchColumn();
+ Response::success(['enabled' => $enabled]);
+ })(),
+
+ // Regenerate backup codes
+ 'regen-backup-codes' => (function() use ($db, $uid, $body) {
+ $code = trim($body['code'] ?? '');
+ $stmt = $db->prepare("SELECT totp_secret, totp_enabled FROM users WHERE id=?");
+ $stmt->execute([$uid]);
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!$row['totp_enabled']) Response::error('2FA not enabled');
+ if (!TOTP::verify($row['totp_secret'], $code)) Response::error('Code incorrect');
+ $backupCodes = TOTP::generateBackupCodes();
+ $db->prepare("UPDATE users SET totp_backup_codes=? WHERE id=?")->execute([TOTP::hashBackupCodes($backupCodes), $uid]);
+ Response::success(['backup_codes' => $backupCodes], 'Backup codes regenerated');
+ })(),
+
+ // Admin: get 2FA status for any user
+ 'admin-status' => (function() use ($db, $body, $currentUser) {
+ if ($currentUser['role'] !== 'admin') Response::error('Admin only', 403);
+ $userId = (int)($body['user_id'] ?? 0);
+ if (!$userId) Response::error('user_id required');
+ $stmt = $db->prepare("SELECT id, username, totp_enabled FROM users WHERE id=?");
+ $stmt->execute([$userId]);
+ Response::success($stmt->fetch(PDO::FETCH_ASSOC));
+ })(),
+
+ // Admin: force-disable 2FA for a user (account recovery)
+ 'admin-disable' => (function() use ($db, $body, $currentUser) {
+ if ($currentUser['role'] !== 'admin') Response::error('Admin only', 403);
+ $userId = (int)($body['user_id'] ?? 0);
+ if (!$userId) Response::error('user_id required');
+ $db->prepare("UPDATE users SET totp_enabled=0, totp_secret=NULL, totp_backup_codes=NULL WHERE id=?")->execute([$userId]);
+ audit('totp_admin_disabled', 'security', ['user_id' => $userId]);
+ Response::success(null, '2FA disabled for user');
+ })(),
+
+ default => Response::error('Unknown action', 404),
+};
diff --git a/panel/api/endpoints/webmail.php b/panel/api/endpoints/webmail.php
index aef98b9..ecb1653 100644
--- a/panel/api/endpoints/webmail.php
+++ b/panel/api/endpoints/webmail.php
@@ -1,11 +1,11 @@
user();
match ($action) {
@@ -16,37 +16,50 @@ match ($action) {
$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$accountId]);
if (!$acct) Response::error("Account not found");
- $domain = $acct['domain'];
- // Roundcube installed by default at /var/www/roundcube
- $rcUrl = "https://{$domain}/webmail/";
- // Check if Roundcube is installed
- $installed = file_exists('/var/www/roundcube/index.php');
+ $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
+ $hostname = preg_replace('/:\d+$/', '', $host);
+ $rcUrl = "https://{$hostname}:" . PORT_WEBMAIL . "/";
+ $installed = file_exists('/usr/share/roundcube/index.php');
Response::success([
'url' => $rcUrl,
'installed' => $installed,
- 'domain' => $domain,
]);
})(),
- 'install' => (function() use ($db) {
- Auth::getInstance()->requireRole(['admin']);
- // Background install
+ 'install' => (function() {
+ Auth::getInstance()->require('admin');
$logFile = '/var/log/novacpx/webmail-install.log';
- $cmd = 'apt-get install -y roundcube roundcube-mysql php8.3-intl > ' . escapeshellarg($logFile) . ' 2>&1 && ' .
- 'ln -sf /usr/share/roundcube /var/www/roundcube >> ' . escapeshellarg($logFile) . ' 2>&1';
+ $cmd = 'apt-get install -y roundcube roundcube-mysql php8.3-intl > ' . escapeshellarg($logFile) . ' 2>&1';
shell_exec("nohup bash -c " . escapeshellarg($cmd) . " &");
Response::success(['log' => $logFile], 'Webmail install started');
})(),
'login-url' => (function() use ($db, $body, $user) {
- // Generate a short-lived token for auto-login
- $emailAccount = $db->fetchOne("SELECT * FROM email_accounts WHERE id = ?", [(int)($body['email_id'] ?? 0)]);
+ $emailAccount = $db->fetchOne(
+ "SELECT ea.*, a.user_id FROM email_accounts ea JOIN accounts a ON a.id = ea.account_id WHERE ea.id = ?",
+ [(int)($body['email_id'] ?? 0)]
+ );
if (!$emailAccount) Response::error("Email account not found");
- $token = bin2hex(random_bytes(16));
- $db->execute("INSERT INTO api_tokens (user_id, token, purpose, expires_at) VALUES (?,?,?,DATE_ADD(NOW(), INTERVAL 30 SECOND))",
- [$user['uid'], hash('sha256', $token), 'webmail_sso']);
- $domain = parse_url($emailAccount['email'], PHP_URL_HOST) ?: '';
- Response::success(['url' => "https://{$domain}/webmail/?_token={$token}"]);
+ // Users can only SSO into their own email accounts
+ if ($user['role'] === 'user' && (int)$emailAccount['user_id'] !== (int)$user['uid']) {
+ Response::error('Forbidden', 403);
+ }
+ if (empty($emailAccount['enc_password'])) Response::error("SSO not available for this account — password must be reset");
+
+ // Create short-lived SSO token
+ $token = bin2hex(random_bytes(24));
+ $db->execute(
+ "DELETE FROM webmail_sso_tokens WHERE expires_at < NOW()"
+ );
+ $db->execute(
+ "INSERT INTO webmail_sso_tokens (token, email, enc_pass, expires_at) VALUES (?,?,?,DATE_ADD(NOW(), INTERVAL 60 SECOND))",
+ [hash('sha256', $token), $emailAccount['email'], $emailAccount['enc_password']]
+ );
+ $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
+ $hostname = preg_replace('/:\d+$/', '', $host);
+ Response::success([
+ 'url' => "https://{$hostname}:" . PORT_WEBMAIL . "/novacpx-sso.php?t=" . urlencode($token),
+ ]);
})(),
default => Response::error("Unknown webmail action: $action", 404),
diff --git a/panel/api/endpoints/wordpress.php b/panel/api/endpoints/wordpress.php
new file mode 100644
index 0000000..6bd3f32
--- /dev/null
+++ b/panel/api/endpoints/wordpress.php
@@ -0,0 +1,72 @@
+ (function() use ($wp, $accountId, $isAdmin) {
+ Response::success($wp->list($isAdmin ? $accountId : $accountId));
+ })(),
+
+ 'install' => (function() use ($wp, $body, $accountId, $currentUser) {
+ $required = ['domain','path','admin_user','admin_email','admin_pass','site_title'];
+ foreach ($required as $f) if (empty($body[$f])) Response::error("Missing: {$f}");
+ if (!$accountId) Response::error('account_id required');
+ $result = $wp->install($accountId, $body['domain'], $body['path'],
+ $body['admin_user'], $body['admin_email'], $body['admin_pass'], $body['site_title']);
+ audit('wordpress_install', 'wordpress', ['domain' => $body['domain']]);
+ Response::success($result, 'WordPress installed successfully');
+ })(),
+
+ 'update-core' => (function() use ($wp, $body) {
+ $id = (int)($body['id'] ?? 0);
+ if (!$id) Response::error('id required');
+ Response::success(['output' => $wp->updateCore($id)], 'Core updated');
+ })(),
+
+ 'update-plugins' => (function() use ($wp, $body) {
+ $id = (int)($body['id'] ?? 0);
+ if (!$id) Response::error('id required');
+ Response::success(['output' => $wp->updatePlugins($id)], 'Plugins updated');
+ })(),
+
+ 'update-themes' => (function() use ($wp, $body) {
+ $id = (int)($body['id'] ?? 0);
+ if (!$id) Response::error('id required');
+ Response::success(['output' => $wp->updateThemes($id)], 'Themes updated');
+ })(),
+
+ 'clone-staging' => (function() use ($wp, $body) {
+ $id = (int)($body['id'] ?? 0);
+ if (!$id) Response::error('id required');
+ $result = $wp->cloneStaging($id);
+ audit('wordpress_staging', 'wordpress', ['id' => $id]);
+ Response::success($result, 'Staging clone created');
+ })(),
+
+ 'info' => (function() use ($wp, $body) {
+ $id = (int)($body['id'] ?? $_GET['id'] ?? 0);
+ if (!$id) Response::error('id required');
+ Response::success($wp->info($id));
+ })(),
+
+ 'delete' => (function() use ($wp, $body, $isAdmin) {
+ if (!$isAdmin) Response::error('Admin only', 403);
+ $id = (int)($body['id'] ?? 0);
+ if (!$id) Response::error('id required');
+ $wp->delete($id);
+ audit('wordpress_delete', 'wordpress', ['id' => $id]);
+ Response::success(null, 'WordPress installation deleted');
+ })(),
+
+ default => Response::error('Unknown action', 404),
+};
diff --git a/panel/lib/BackupManager.php b/panel/lib/BackupManager.php
new file mode 100644
index 0000000..22f31de
--- /dev/null
+++ b/panel/lib/BackupManager.php
@@ -0,0 +1,165 @@
+db = Database::getInstance()->getPDO();
+ if (!is_dir($this->backupRoot)) mkdir($this->backupRoot, 0750, true);
+ }
+
+ // ── Create full backup ────────────────────────────────────────────────────
+ public function create(int $accountId, string $type = 'full'): array {
+ $account = $this->getAccount($accountId);
+ $dir = $this->backupRoot . '/' . $account['username'];
+ if (!is_dir($dir)) mkdir($dir, 0750, true);
+
+ $ts = date('Ymd_His');
+ $filename = "{$account['username']}_{$type}_{$ts}.tar.gz";
+ $filepath = "{$dir}/{$filename}";
+
+ // Record as pending
+ $stmt = $this->db->prepare("INSERT INTO backups (account_id, filename, type, status, storage) VALUES (?,?,?,'running','local')");
+ $stmt->execute([$accountId, $filename, $type]);
+ $backupId = $this->db->lastInsertId();
+
+ try {
+ if ($type === 'full' || $type === 'files') {
+ $docRoot = escapeshellarg($account['document_root']);
+ exec("tar -czf " . escapeshellarg($filepath) . " -C / " . ltrim($docRoot, '/') . " 2>&1", $out, $rc);
+ if ($rc !== 0) throw new RuntimeException("tar failed: " . implode("\n", $out));
+ }
+
+ if ($type === 'full' || $type === 'database') {
+ // Dump all databases belonging to this account
+ $dbs = $this->db->prepare("SELECT db_name FROM account_databases WHERE account_id=?");
+ $dbs->execute([$accountId]);
+ foreach ($dbs->fetchAll(PDO::FETCH_COLUMN) as $dbName) {
+ $dumpFile = escapeshellarg("{$dir}/{$account['username']}_{$dbName}_{$ts}.sql.gz");
+ exec("mysqldump " . escapeshellarg($dbName) . " | gzip > {$dumpFile} 2>&1");
+ }
+
+ if ($type === 'database') {
+ // Pack all sql.gz files into the tar
+ exec("tar -czf " . escapeshellarg($filepath) . " -C {$dir} " .
+ escapeshellarg("{$account['username']}_{$ts}") . "*.sql.gz 2>/dev/null");
+ }
+ }
+
+ $size = file_exists($filepath) ? filesize($filepath) : 0;
+ $this->db->prepare("UPDATE backups SET status='complete', size=? WHERE id=?")->execute([$size, $backupId]);
+ return ['id' => $backupId, 'filename' => $filename, 'size' => $size];
+
+ } catch (RuntimeException $e) {
+ $this->db->prepare("UPDATE backups SET status='failed' WHERE id=?")->execute([$backupId]);
+ throw $e;
+ }
+ }
+
+ // ── List ──────────────────────────────────────────────────────────────────
+ public function list(int $accountId = 0): array {
+ if ($accountId) {
+ $stmt = $this->db->prepare("SELECT b.*, a.username FROM backups b JOIN accounts a ON b.account_id=a.id WHERE b.account_id=? ORDER BY b.created_at DESC");
+ $stmt->execute([$accountId]);
+ } else {
+ $stmt = $this->db->query("SELECT b.*, a.username FROM backups b JOIN accounts a ON b.account_id=a.id ORDER BY b.created_at DESC");
+ }
+ return $stmt->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ // ── Download ──────────────────────────────────────────────────────────────
+ public function getDownloadPath(int $backupId): string {
+ $stmt = $this->db->prepare("SELECT b.*, a.username FROM backups b JOIN accounts a ON b.account_id=a.id WHERE b.id=?");
+ $stmt->execute([$backupId]);
+ $backup = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!$backup) throw new RuntimeException("Backup not found");
+ $path = $this->backupRoot . '/' . $backup['username'] . '/' . $backup['filename'];
+ if (!file_exists($path)) throw new RuntimeException("Backup file missing from disk");
+ return $path;
+ }
+
+ // ── Restore ───────────────────────────────────────────────────────────────
+ public function restore(int $backupId): bool {
+ $stmt = $this->db->prepare("SELECT b.*, a.document_root, a.username FROM backups b JOIN accounts a ON b.account_id=a.id WHERE b.id=?");
+ $stmt->execute([$backupId]);
+ $backup = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!$backup) throw new RuntimeException("Backup not found");
+
+ $path = $this->backupRoot . '/' . $backup['username'] . '/' . $backup['filename'];
+ if (!file_exists($path)) throw new RuntimeException("Backup file not found on disk");
+
+ exec("tar -xzf " . escapeshellarg($path) . " -C / 2>&1", $out, $rc);
+ if ($rc !== 0) throw new RuntimeException("Restore failed: " . implode("\n", $out));
+ return true;
+ }
+
+ // ── Delete ────────────────────────────────────────────────────────────────
+ public function delete(int $backupId): bool {
+ $stmt = $this->db->prepare("SELECT b.*, a.username FROM backups b JOIN accounts a ON b.account_id=a.id WHERE b.id=?");
+ $stmt->execute([$backupId]);
+ $backup = $stmt->fetch(PDO::FETCH_ASSOC);
+ if ($backup) {
+ $path = $this->backupRoot . '/' . $backup['username'] . '/' . $backup['filename'];
+ if (file_exists($path)) unlink($path);
+ $this->db->prepare("DELETE FROM backups WHERE id=?")->execute([$backupId]);
+ }
+ return true;
+ }
+
+ // ── Schedule ──────────────────────────────────────────────────────────────
+ public function setSchedule(int $accountId, string $frequency, string $type = 'full', int $retain = 7): bool {
+ $stmt = $this->db->prepare("INSERT INTO backup_schedules (account_id, frequency, type, retain_count)
+ VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE frequency=VALUES(frequency), type=VALUES(type), retain_count=VALUES(retain_count)");
+ $stmt->execute([$accountId, $frequency, $type, $retain]);
+ return true;
+ }
+
+ public function getSchedule(int $accountId): ?array {
+ $stmt = $this->db->prepare("SELECT * FROM backup_schedules WHERE account_id=?");
+ $stmt->execute([$accountId]);
+ return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
+ }
+
+ // ── Prune old backups per retention policy ────────────────────────────────
+ public function prune(int $accountId): int {
+ $schedule = $this->getSchedule($accountId);
+ if (!$schedule) return 0;
+ $retain = (int)$schedule['retain_count'];
+ $stmt = $this->db->prepare("SELECT * FROM backups WHERE account_id=? AND status='complete' ORDER BY created_at DESC");
+ $stmt->execute([$accountId]);
+ $all = $stmt->fetchAll(PDO::FETCH_ASSOC);
+ $pruned = 0;
+ foreach (array_slice($all, $retain) as $old) {
+ $this->delete($old['id']);
+ $pruned++;
+ }
+ return $pruned;
+ }
+
+ // ── rclone remote upload ──────────────────────────────────────────────────
+ public function uploadRemote(int $backupId, string $remote): string {
+ $path = $this->getDownloadPath($backupId);
+ $out = []; exec("rclone copy " . escapeshellarg($path) . " " . escapeshellarg($remote) . " 2>&1", $out, $rc);
+ if ($rc === 0) {
+ $this->db->prepare("UPDATE backups SET storage='remote', remote_path=? WHERE id=?")->execute([$remote, $backupId]);
+ }
+ return implode("\n", $out);
+ }
+
+ // ── Disk usage ────────────────────────────────────────────────────────────
+ public function diskUsage(int $accountId = 0): int {
+ if ($accountId) {
+ $stmt = $this->db->prepare("SELECT COALESCE(SUM(size),0) FROM backups WHERE account_id=? AND status='complete'");
+ $stmt->execute([$accountId]);
+ } else {
+ $stmt = $this->db->query("SELECT COALESCE(SUM(size),0) FROM backups WHERE status='complete'");
+ }
+ return (int)$stmt->fetchColumn();
+ }
+
+ private function getAccount(int $id): array {
+ $stmt = $this->db->prepare("SELECT * FROM accounts WHERE id=?");
+ $stmt->execute([$id]);
+ return $stmt->fetch(PDO::FETCH_ASSOC) ?: throw new RuntimeException("Account not found");
+ }
+}
diff --git a/panel/lib/CloudflareManager.php b/panel/lib/CloudflareManager.php
new file mode 100644
index 0000000..5e91152
--- /dev/null
+++ b/panel/lib/CloudflareManager.php
@@ -0,0 +1,150 @@
+db = Database::getInstance()->getPDO();
+ }
+
+ // ── Credential management ─────────────────────────────────────────────────
+ public function saveCredentials(int $accountId, string $apiKey, string $email): bool {
+ $stmt = $this->db->prepare("UPDATE accounts SET cf_api_key=?, cf_api_email=? WHERE id=?");
+ return $stmt->execute([$apiKey, $email, $accountId]);
+ }
+
+ public function testCredentials(string $apiKey, string $email): bool {
+ $r = $this->req('GET', 'user/tokens/verify', [], $apiKey, $email);
+ return $r['success'] ?? false;
+ }
+
+ public function getCredentials(int $accountId): ?array {
+ $stmt = $this->db->prepare("SELECT cf_api_key, cf_api_email, cf_zone_id FROM accounts WHERE id=?");
+ $stmt->execute([$accountId]);
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ return ($row && $row['cf_api_key']) ? $row : null;
+ }
+
+ // ── Zones ─────────────────────────────────────────────────────────────────
+ public function listZones(string $apiKey, string $email): array {
+ $r = $this->req('GET', 'zones?per_page=200&status=active', [], $apiKey, $email);
+ return $r['result'] ?? [];
+ }
+
+ public function getZoneId(string $domain, string $apiKey, string $email): ?string {
+ $r = $this->req('GET', 'zones?name=' . urlencode($domain), [], $apiKey, $email);
+ return $r['result'][0]['id'] ?? null;
+ }
+
+ // ── DNS records ───────────────────────────────────────────────────────────
+ public function listRecords(string $zoneId, string $apiKey, string $email): array {
+ $r = $this->req('GET', "zones/{$zoneId}/dns_records?per_page=200", [], $apiKey, $email);
+ return $r['result'] ?? [];
+ }
+
+ public function createRecord(string $zoneId, array $record, string $apiKey, string $email): array {
+ return $this->req('POST', "zones/{$zoneId}/dns_records", $record, $apiKey, $email);
+ }
+
+ public function updateRecord(string $zoneId, string $recordId, array $record, string $apiKey, string $email): array {
+ return $this->req('PUT', "zones/{$zoneId}/dns_records/{$recordId}", $record, $apiKey, $email);
+ }
+
+ public function deleteRecord(string $zoneId, string $recordId, string $apiKey, string $email): bool {
+ $r = $this->req('DELETE', "zones/{$zoneId}/dns_records/{$recordId}", [], $apiKey, $email);
+ return $r['success'] ?? false;
+ }
+
+ public function toggleProxy(string $zoneId, string $recordId, bool $proxied, string $apiKey, string $email): array {
+ // Fetch existing record first
+ $r = $this->req('GET', "zones/{$zoneId}/dns_records/{$recordId}", [], $apiKey, $email);
+ $rec = $r['result'] ?? [];
+ $rec['proxied'] = $proxied;
+ unset($rec['id'], $rec['zone_id'], $rec['zone_name'], $rec['created_on'], $rec['modified_on'], $rec['meta']);
+ return $this->req('PUT', "zones/{$zoneId}/dns_records/{$recordId}", $rec, $apiKey, $email);
+ }
+
+ // ── Sync: push local DNS records to Cloudflare ────────────────────────────
+ public function syncToCloudflare(string $domain, string $zoneId, string $apiKey, string $email): array {
+ $localRecords = $this->getLocalRecords($domain);
+ $cfRecords = $this->listRecords($zoneId, $apiKey, $email);
+ $cfIndex = [];
+ foreach ($cfRecords as $r) $cfIndex[$r['type'] . '_' . $r['name']] = $r;
+
+ $created = 0; $updated = 0;
+ foreach ($localRecords as $local) {
+ $key = $local['type'] . '_' . $local['name'] . '.' . $domain . '.';
+ if (isset($cfIndex[$key])) {
+ $this->updateRecord($zoneId, $cfIndex[$key]['id'], [
+ 'type' => $local['type'],
+ 'name' => $local['name'],
+ 'content' => $local['content'],
+ 'ttl' => (int)($local['ttl'] ?? 1),
+ 'proxied' => in_array($local['type'], ['A','AAAA','CNAME']),
+ ], $apiKey, $email);
+ $updated++;
+ } else {
+ $this->createRecord($zoneId, [
+ 'type' => $local['type'],
+ 'name' => $local['name'],
+ 'content' => $local['content'],
+ 'ttl' => (int)($local['ttl'] ?? 1),
+ 'proxied' => in_array($local['type'], ['A','AAAA','CNAME']),
+ ], $apiKey, $email);
+ $created++;
+ }
+ }
+ return ['created' => $created, 'updated' => $updated];
+ }
+
+ // ── Sync: pull Cloudflare records into local DB ───────────────────────────
+ public function syncFromCloudflare(string $domain, string $zoneId, string $apiKey, string $email): int {
+ $cfRecords = $this->listRecords($zoneId, $apiKey, $email);
+ $count = 0;
+ foreach ($cfRecords as $rec) {
+ $name = rtrim(str_replace('.' . $domain, '', $rec['name']), '.');
+ $this->db->prepare("INSERT INTO dns_records (domain, name, type, content, ttl, priority)
+ VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE content=VALUES(content), ttl=VALUES(ttl)")
+ ->execute([$domain, $name ?: '@', $rec['type'], $rec['content'], $rec['ttl'] ?? 300, $rec['priority'] ?? 0]);
+ $count++;
+ }
+ // Store zone ID on domain
+ $this->db->prepare("UPDATE dns_zones SET cf_zone_id=? WHERE domain=?")->execute([$zoneId, $domain]);
+ return $count;
+ }
+
+ // ── Purge cache ───────────────────────────────────────────────────────────
+ public function purgeCache(string $zoneId, string $apiKey, string $email): bool {
+ $r = $this->req('POST', "zones/{$zoneId}/purge_cache", ['purge_everything' => true], $apiKey, $email);
+ return $r['success'] ?? false;
+ }
+
+ // ── HTTP helper ───────────────────────────────────────────────────────────
+ private function req(string $method, string $path, array $body, string $apiKey, string $email): array {
+ $ch = curl_init(self::API . ltrim($path, '/'));
+ $headers = [
+ "X-Auth-Email: {$email}",
+ "X-Auth-Key: {$apiKey}",
+ "Content-Type: application/json",
+ ];
+ curl_setopt_array($ch, [
+ CURLOPT_CUSTOMREQUEST => $method,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 15,
+ CURLOPT_SSL_VERIFYPEER => true,
+ ]);
+ if ($body && in_array($method, ['POST','PUT','PATCH'])) {
+ curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
+ }
+ $result = curl_exec($ch);
+ curl_close($ch);
+ return json_decode($result, true) ?? ['success' => false, 'errors' => ['curl failed']];
+ }
+
+ private function getLocalRecords(string $domain): array {
+ $stmt = $this->db->prepare("SELECT * FROM dns_records WHERE domain=?");
+ $stmt->execute([$domain]);
+ return $stmt->fetchAll(PDO::FETCH_ASSOC);
+ }
+}
diff --git a/panel/lib/EmailManager.php b/panel/lib/EmailManager.php
index 5818ca7..432748e 100644
--- a/panel/lib/EmailManager.php
+++ b/panel/lib/EmailManager.php
@@ -6,11 +6,12 @@
class EmailManager {
public static function createAccount(int $accountId, string $email, string $password, int $quotaMb = 500): int {
- $db = DB::getInstance();
+ $db = DB::getInstance();
$hashed = self::hashPassword($password);
+ $enc = self::encryptPassword($password);
$id = (int)$db->insert(
- "INSERT INTO email_accounts (account_id, email, password, quota_mb) VALUES (?,?,?,?)",
- [$accountId, $email, $hashed, $quotaMb]
+ "INSERT INTO email_accounts (account_id, email, password, enc_password, quota_mb) VALUES (?,?,?,?,?)",
+ [$accountId, $email, $hashed, $enc, $quotaMb]
);
self::syncPostfix();
novacpx_log('info', "Email account created: $email");
@@ -27,7 +28,10 @@ class EmailManager {
public static function changePassword(int $id, string $newPassword): void {
$db = DB::getInstance();
- $db->execute("UPDATE email_accounts SET password = ? WHERE id = ?", [self::hashPassword($newPassword), $id]);
+ $db->execute(
+ "UPDATE email_accounts SET password = ?, enc_password = ? WHERE id = ?",
+ [self::hashPassword($newPassword), self::encryptPassword($newPassword), $id]
+ );
}
public static function suspend(int $id): void {
@@ -56,9 +60,21 @@ class EmailManager {
);
}
+ /**
+ * Decrypt stored IMAP password for SSO use only.
+ */
+ public static function decryptPassword(string $enc): ?string {
+ $key = substr(hash('sha256', SECRET_KEY, true), 0, 32);
+ $data = base64_decode($enc);
+ if (strlen($data) <= 16) return null;
+ $iv = substr($data, 0, 16);
+ $encrypted = substr($data, 16);
+ $plain = openssl_decrypt($encrypted, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
+ return $plain !== false ? $plain : null;
+ }
+
/**
* Sync Postfix virtual_mailbox_maps + virtual_alias_maps files from DB
- * Postfix reads these files (postmap creates .db hash)
*/
private static function syncPostfix(): void {
$db = DB::getInstance();
@@ -93,7 +109,13 @@ class EmailManager {
}
private static function hashPassword(string $password): string {
- // Dovecot SHA512-CRYPT compatible
return '{SHA512-CRYPT}' . crypt($password, '$6$' . bin2hex(random_bytes(8)) . '$');
}
+
+ private static function encryptPassword(string $password): string {
+ $key = substr(hash('sha256', SECRET_KEY, true), 0, 32);
+ $iv = random_bytes(16);
+ $enc = openssl_encrypt($password, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
+ return base64_encode($iv . $enc);
+ }
}
diff --git a/panel/lib/ProxyManager.php b/panel/lib/ProxyManager.php
new file mode 100644
index 0000000..5fa7bc2
--- /dev/null
+++ b/panel/lib/ProxyManager.php
@@ -0,0 +1,252 @@
+/dev/null'));
+ }
+
+ public static function isRunning(): bool {
+ $out = shell_exec('systemctl is-active nginx 2>/dev/null');
+ return trim($out ?? '') === 'active';
+ }
+
+ public static function status(): array {
+ $installed = self::isInstalled();
+ $running = $installed && self::isRunning();
+ $version = $installed ? trim(shell_exec('nginx -v 2>&1') ?: '') : '';
+ $db = DB::getInstance();
+ $row = $db->fetchOne("SELECT value FROM settings WHERE `key` = 'proxy_mode'");
+ $mode = $row['value'] ?? 'disabled';
+ return [
+ 'installed' => $installed,
+ 'running' => $running,
+ 'version' => $version,
+ 'mode' => $mode,
+ ];
+ }
+
+ public static function start(): string {
+ return self::sysctl('start');
+ }
+
+ public static function stop(): string {
+ return self::sysctl('stop');
+ }
+
+ public static function restart(): string {
+ return self::sysctl('restart');
+ }
+
+ public static function reload(): string {
+ if (!self::isInstalled()) return 'nginx not installed';
+ $test = shell_exec('sudo nginx -t 2>&1');
+ if (strpos($test ?? '', 'successful') === false) return 'Config test failed: ' . $test;
+ shell_exec('sudo systemctl reload nginx 2>/dev/null');
+ return 'reloaded';
+ }
+
+ public static function install(): string {
+ if (self::isInstalled()) return 'already installed';
+ shell_exec('sudo apt-get update -qq 2>/dev/null && sudo apt-get install -y nginx 2>&1');
+ if (!self::isInstalled()) return 'install failed';
+ // Disable default site
+ @unlink('/etc/nginx/sites-enabled/default');
+ shell_exec('sudo systemctl enable nginx 2>/dev/null');
+ shell_exec('sudo systemctl start nginx 2>/dev/null');
+ return 'installed';
+ }
+
+ // --- Proxy Hosts ---
+
+ public static function listHosts(): array {
+ $db = DB::getInstance();
+ return $db->fetchAll("SELECT * FROM proxy_hosts ORDER BY domain") ?: [];
+ }
+
+ public static function syncFromAccounts(): int {
+ $db = DB::getInstance();
+ $accounts = $db->fetchAll("SELECT a.*, d.domain FROM accounts a JOIN domains d ON d.account_id=a.id AND d.type='main' WHERE a.status='active'") ?: [];
+ $count = 0;
+ foreach ($accounts as $acct) {
+ $existing = $db->fetchOne("SELECT id FROM proxy_hosts WHERE domain=?", [$acct['domain']]);
+ if (!$existing) {
+ $db->insert(
+ "INSERT INTO proxy_hosts (account_id, domain, upstream, ssl_enabled, enabled, created_at) VALUES (?,?,?,0,1,NOW())",
+ [$acct['id'], $acct['domain'], 'http://127.0.0.1:80']
+ );
+ $count++;
+ }
+ }
+ if ($count > 0) self::writeAllConfigs();
+ return $count;
+ }
+
+ public static function addHost(array $data): int {
+ $db = DB::getInstance();
+ $id = (int)$db->insert(
+ "INSERT INTO proxy_hosts (account_id, domain, upstream, ssl_enabled, enabled, custom_config, created_at) VALUES (?,?,?,?,1,?,NOW())",
+ [
+ $data['account_id'] ?? null,
+ $data['domain'],
+ $data['upstream'] ?? 'http://127.0.0.1:80',
+ (int)($data['ssl_enabled'] ?? 0),
+ $data['custom_config'] ?? null,
+ ]
+ );
+ self::writeAllConfigs();
+ return $id;
+ }
+
+ public static function updateHost(int $id, array $data): void {
+ $db = DB::getInstance();
+ $db->execute(
+ "UPDATE proxy_hosts SET domain=?, upstream=?, ssl_enabled=?, enabled=?, custom_config=? WHERE id=?",
+ [$data['domain'], $data['upstream'], (int)($data['ssl_enabled'] ?? 0), (int)($data['enabled'] ?? 1), $data['custom_config'] ?? null, $id]
+ );
+ self::writeAllConfigs();
+ }
+
+ public static function deleteHost(int $id): void {
+ $db = DB::getInstance();
+ $host = $db->fetchOne("SELECT domain FROM proxy_hosts WHERE id=?", [$id]);
+ $db->execute("DELETE FROM proxy_hosts WHERE id=?", [$id]);
+ if ($host) {
+ @unlink(self::$confDir . '/' . self::$confPrefix . $host['domain'] . '.conf');
+ @unlink(self::$enabledDir . '/' . self::$confPrefix . $host['domain'] . '.conf');
+ }
+ self::reload();
+ }
+
+ public static function toggleHost(int $id, bool $enable): void {
+ $db = DB::getInstance();
+ $db->execute("UPDATE proxy_hosts SET enabled=? WHERE id=?", [(int)$enable, $id]);
+ self::writeAllConfigs();
+ }
+
+ // --- Config Generation ---
+
+ public static function writeAllConfigs(): void {
+ if (!self::isInstalled()) return;
+ $db = DB::getInstance();
+ $hosts = $db->fetchAll("SELECT * FROM proxy_hosts") ?: [];
+ // Remove old novacpx proxy configs
+ foreach (glob(self::$confDir . '/' . self::$confPrefix . '*.conf') ?: [] as $f) @unlink($f);
+ foreach (glob(self::$enabledDir . '/' . self::$confPrefix . '*.conf') ?: [] as $f) @unlink($f);
+ foreach ($hosts as $host) {
+ if (!$host['enabled']) continue;
+ self::writeHostConfig($host);
+ }
+ self::reload();
+ }
+
+ private static function writeHostConfig(array $host): void {
+ $safe = preg_replace('/[^a-z0-9._-]/', '', strtolower($host['domain']));
+ $confPath = self::$confDir . '/' . self::$confPrefix . $safe . '.conf';
+ $linkPath = self::$enabledDir . '/' . self::$confPrefix . $safe . '.conf';
+
+ if ($host['custom_config']) {
+ file_put_contents($confPath, $host['custom_config']);
+ } else {
+ $upstream = rtrim($host['upstream'], '/');
+ $ssl = !empty($host['ssl_enabled']);
+ $certDir = "/etc/novacpx/ssl/accounts/" . preg_replace('/[^a-z0-9._-]/', '', $host['domain']);
+
+ $conf = "server {\n";
+ $conf .= " listen 80;\n";
+ if ($ssl) $conf .= " listen 443 ssl http2;\n";
+ $conf .= " server_name {$host['domain']} www.{$host['domain']};\n";
+ if ($ssl) {
+ $conf .= " ssl_certificate {$certDir}/cert.pem;\n";
+ $conf .= " ssl_certificate_key {$certDir}/key.pem;\n";
+ $conf .= " ssl_protocols TLSv1.2 TLSv1.3;\n";
+ $conf .= " ssl_ciphers HIGH:!aNULL:!MD5;\n";
+ }
+ $conf .= " location / {\n";
+ $conf .= " proxy_pass {$upstream};\n";
+ $conf .= " proxy_http_version 1.1;\n";
+ $conf .= " proxy_set_header Host \$host;\n";
+ $conf .= " proxy_set_header X-Real-IP \$remote_addr;\n";
+ $conf .= " proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;\n";
+ $conf .= " proxy_set_header X-Forwarded-Proto \$scheme;\n";
+ $conf .= " proxy_set_header Upgrade \$http_upgrade;\n";
+ $conf .= " proxy_set_header Connection 'upgrade';\n";
+ $conf .= " proxy_cache_bypass \$http_upgrade;\n";
+ $conf .= " proxy_read_timeout 86400;\n";
+ $conf .= " }\n";
+ $conf .= "}\n";
+ file_put_contents($confPath, $conf);
+ }
+ @symlink($confPath, $linkPath);
+ }
+
+ // --- Setup Script ---
+
+ public static function setupScript(): string {
+ $serverIp = trim(shell_exec("hostname -I | awk '{print $1}'") ?: '127.0.0.1');
+ return << /etc/nginx/conf.d/novacpx-proxy.conf << 'EOF'
+client_max_body_size 256M;
+proxy_buffers 16 16k;
+proxy_buffer_size 16k;
+gzip on;
+gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
+EOF
+
+# Point proxy back to NovaCPX Apache backend (update SERVER_IP below)
+BACKEND_IP={$serverIp}
+
+# Generic catch-all for testing
+cat > /etc/nginx/sites-available/novacpx-default.conf << EOF
+server {
+ listen 80 default_server;
+ server_name _;
+ return 444;
+}
+EOF
+ln -sf /etc/nginx/sites-available/novacpx-default.conf /etc/nginx/sites-enabled/
+
+# Test and reload
+nginx -t && systemctl reload nginx
+systemctl enable nginx
+
+echo "[NovaCPX] Nginx proxy installed and running."
+echo " Backend IP: \$BACKEND_IP"
+echo " Add proxy hosts from the NovaCPX admin panel → Nginx Proxy"
+BASH;
+ }
+
+ // --- Helpers ---
+
+ private static function sysctl(string $action): string {
+ if (!self::isInstalled()) return 'nginx not installed';
+ shell_exec("sudo systemctl {$action} nginx 2>/dev/null");
+ sleep(1);
+ return self::isRunning() ? 'running' : 'stopped';
+ }
+}
diff --git a/panel/lib/TOTP.php b/panel/lib/TOTP.php
new file mode 100644
index 0000000..bc5afa1
--- /dev/null
+++ b/panel/lib/TOTP.php
@@ -0,0 +1,78 @@
+ password_hash($c, PASSWORD_BCRYPT), $codes));
+ }
+
+ public static function verifyBackupCode(string $code, string $hashedJson): bool {
+ $hashes = json_decode($hashedJson, true) ?? [];
+ foreach ($hashes as $hash) {
+ if (password_verify(strtoupper($code), $hash)) return true;
+ }
+ return false;
+ }
+
+ private static function base32Decode(string $base32): string {
+ $base32 = strtoupper(preg_replace('/[^A-Z2-7]/', '', $base32));
+ $buf = 0; $bits = 0; $out = '';
+ for ($i = 0; $i < strlen($base32); $i++) {
+ $val = strpos(self::CHARS, $base32[$i]);
+ $buf = ($buf << 5) | $val;
+ $bits += 5;
+ if ($bits >= 8) { $bits -= 8; $out .= chr(($buf >> $bits) & 0xFF); }
+ }
+ return $out;
+ }
+}
diff --git a/panel/lib/WordPressManager.php b/panel/lib/WordPressManager.php
new file mode 100644
index 0000000..ca3d386
--- /dev/null
+++ b/panel/lib/WordPressManager.php
@@ -0,0 +1,174 @@
+db = Database::getInstance()->getPDO();
+ $this->ensureWpCli();
+ }
+
+ // ── Install ───────────────────────────────────────────────────────────────
+ public function install(int $accountId, string $domain, string $path,
+ string $adminUser, string $adminEmail, string $adminPass,
+ string $siteTitle): array {
+ $account = $this->getAccount($accountId);
+ $docRoot = $account['document_root'] . rtrim($path, '/');
+ $dbName = 'wp_' . preg_replace('/[^a-z0-9]/', '_', strtolower($account['username'])) . '_' . substr(md5($domain), 0, 6);
+ $dbPass = bin2hex(random_bytes(12));
+ $dbUser = substr($dbName, 0, 32);
+
+ // Create DB
+ $this->db->exec("CREATE DATABASE IF NOT EXISTS `{$dbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
+ $this->db->exec("CREATE USER IF NOT EXISTS '{$dbUser}'@'localhost' IDENTIFIED BY '{$dbPass}'");
+ $this->db->exec("GRANT ALL ON `{$dbName}`.* TO '{$dbUser}'@'localhost'");
+
+ // Download WP + install
+ $sysUser = $account['system_user'] ?? 'www-data';
+ $this->wp($docRoot, "core download --locale=en_US", $sysUser);
+ $this->wp($docRoot, "config create --dbname={$dbName} --dbuser={$dbUser} --dbpass={$dbPass} --dbhost=localhost --skip-check", $sysUser);
+ $this->wp($docRoot, sprintf(
+ 'core install --url=https://%s --title="%s" --admin_user=%s --admin_password=%s --admin_email=%s --skip-email',
+ escapeshellarg($domain . $path), escapeshellarg($siteTitle),
+ escapeshellarg($adminUser), escapeshellarg($adminPass), escapeshellarg($adminEmail)
+ ), $sysUser);
+
+ // Store in DB
+ $stmt = $this->db->prepare("INSERT INTO wordpress_installs
+ (account_id, domain, path, db_name, db_user, db_pass, admin_user, admin_email, wp_version, status)
+ VALUES (?,?,?,?,?,?,?,?,?,?)");
+ $stmt->execute([$accountId, $domain, $path, $dbName, $dbUser, $dbPass, $adminUser, $adminEmail,
+ $this->getVersion($docRoot, $sysUser), 'active']);
+ $id = $this->db->lastInsertId();
+
+ return ['id' => $id, 'db_name' => $dbName, 'admin_user' => $adminUser, 'admin_pass' => $adminPass];
+ }
+
+ // ── List ──────────────────────────────────────────────────────────────────
+ public function list(int $accountId = 0): array {
+ $sql = $accountId
+ ? "SELECT w.*, a.domain as account_domain FROM wordpress_installs w JOIN accounts a ON w.account_id=a.id WHERE w.account_id=? ORDER BY w.created_at DESC"
+ : "SELECT w.*, a.domain as account_domain FROM wordpress_installs w JOIN accounts a ON w.account_id=a.id ORDER BY w.created_at DESC";
+ $stmt = $this->db->prepare($sql);
+ $accountId ? $stmt->execute([$accountId]) : $stmt->execute();
+ return $stmt->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ // ── Update ────────────────────────────────────────────────────────────────
+ public function updateCore(int $id): string {
+ [$install, $sysUser, $docRoot] = $this->resolve($id);
+ $out = $this->wp($docRoot, 'core update', $sysUser);
+ $ver = $this->getVersion($docRoot, $sysUser);
+ $this->db->prepare("UPDATE wordpress_installs SET wp_version=? WHERE id=?")->execute([$ver, $id]);
+ return $out;
+ }
+
+ public function updatePlugins(int $id): string {
+ [$install, $sysUser, $docRoot] = $this->resolve($id);
+ return $this->wp($docRoot, 'plugin update --all', $sysUser);
+ }
+
+ public function updateThemes(int $id): string {
+ [$install, $sysUser, $docRoot] = $this->resolve($id);
+ return $this->wp($docRoot, 'theme update --all', $sysUser);
+ }
+
+ // ── Staging clone ─────────────────────────────────────────────────────────
+ public function cloneStaging(int $id): array {
+ [$install, $sysUser, $docRoot] = $this->resolve($id);
+ $stagingPath = $install['path'] . '_staging';
+ $stagingDomain = 'staging.' . $install['domain'];
+ $stagingRoot = dirname($docRoot) . rtrim($stagingPath, '/');
+
+ // Copy files
+ $this->exec("cp -r {$docRoot} {$stagingRoot}");
+
+ // Clone DB
+ $stagingDb = $install['db_name'] . '_staging';
+ $stagingDbPw = bin2hex(random_bytes(8));
+ $this->db->exec("CREATE DATABASE IF NOT EXISTS `{$stagingDb}`");
+ $this->db->exec("CREATE USER IF NOT EXISTS '{$stagingDb}'@'localhost' IDENTIFIED BY '{$stagingDbPw}'");
+ $this->db->exec("GRANT ALL ON `{$stagingDb}`.* TO '{$stagingDb}'@'localhost'");
+ $this->exec("mysqldump {$install['db_name']} | mysql {$stagingDb}");
+
+ // Update staging wp-config
+ $this->wp($stagingRoot, "config set DB_NAME {$stagingDb}", $sysUser);
+ $this->wp($stagingRoot, "config set DB_USER {$stagingDb}", $sysUser);
+ $this->wp($stagingRoot, "config set DB_PASSWORD {$stagingDbPw}", $sysUser);
+ $this->wp($stagingRoot, "search-replace https://{$install['domain']} https://{$stagingDomain} --all-tables", $sysUser);
+
+ // Record staging install
+ $stmt = $this->db->prepare("INSERT INTO wordpress_installs
+ (account_id,domain,path,db_name,db_user,db_pass,admin_user,admin_email,wp_version,status,staging_of)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)");
+ $stmt->execute([$install['account_id'], $stagingDomain, $stagingPath,
+ $stagingDb, $stagingDb, $stagingDbPw,
+ $install['admin_user'], $install['admin_email'], $install['wp_version'], 'active', $id]);
+
+ return ['domain' => $stagingDomain, 'path' => $stagingPath];
+ }
+
+ // ── Delete ────────────────────────────────────────────────────────────────
+ public function delete(int $id): bool {
+ [$install, $sysUser, $docRoot] = $this->resolve($id);
+ $this->exec("rm -rf {$docRoot}");
+ $this->db->exec("DROP DATABASE IF EXISTS `{$install['db_name']}`");
+ $this->db->exec("DROP USER IF EXISTS '{$install['db_user']}'@'localhost'");
+ $this->db->prepare("DELETE FROM wordpress_installs WHERE id=?")->execute([$id]);
+ return true;
+ }
+
+ // ── Info ──────────────────────────────────────────────────────────────────
+ public function info(int $id): array {
+ [$install, $sysUser, $docRoot] = $this->resolve($id);
+ $plugins = $this->wp($docRoot, 'plugin list --format=json', $sysUser);
+ $themes = $this->wp($docRoot, 'theme list --format=json', $sysUser);
+ return [
+ 'install' => $install,
+ 'plugins' => json_decode($plugins, true) ?? [],
+ 'themes' => json_decode($themes, true) ?? [],
+ 'version' => $this->getVersion($docRoot, $sysUser),
+ ];
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+ private function wp(string $path, string $cmd, string $user): string {
+ $safe = escapeshellarg($path);
+ $out = []; $rc = 0;
+ exec("sudo -u {$user} {$this->wpcli} --path={$safe} --allow-root {$cmd} 2>&1", $out, $rc);
+ return implode("\n", $out);
+ }
+
+ private function exec(string $cmd): string {
+ $out = []; exec($cmd . ' 2>&1', $out); return implode("\n", $out);
+ }
+
+ private function getVersion(string $path, string $user): string {
+ $v = trim($this->wp($path, 'core version', $user));
+ return $v ?: 'unknown';
+ }
+
+ private function resolve(int $id): array {
+ $stmt = $this->db->prepare("SELECT w.*, a.document_root, a.system_user, a.username FROM wordpress_installs w JOIN accounts a ON w.account_id=a.id WHERE w.id=?");
+ $stmt->execute([$id]);
+ $install = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!$install) throw new RuntimeException("WordPress install #{$id} not found");
+ $docRoot = $install['document_root'] . rtrim($install['path'], '/');
+ $sysUser = $install['system_user'] ?? 'www-data';
+ return [$install, $sysUser, $docRoot];
+ }
+
+ private function getAccount(int $id): array {
+ $stmt = $this->db->prepare("SELECT * FROM accounts WHERE id=?");
+ $stmt->execute([$id]);
+ return $stmt->fetch(PDO::FETCH_ASSOC) ?: throw new RuntimeException("Account #{$id} not found");
+ }
+
+ private function ensureWpCli(): void {
+ if (!file_exists($this->wpcli)) {
+ file_put_contents('/tmp/wp-cli.phar', file_get_contents('https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar'));
+ rename('/tmp/wp-cli.phar', $this->wpcli);
+ chmod($this->wpcli, 0755);
+ }
+ }
+}
diff --git a/panel/public/admin/index.php b/panel/public/admin/index.php
index b772b3d..ab85db1 100644
--- a/panel/public/admin/index.php
+++ b/panel/public/admin/index.php
@@ -1,6 +1,6 @@
'?v=' . @filemtime(dirname(__DIR__) . $f);
?>
@@ -9,7 +9,7 @@
NovaCPX Admin
-
+
@@ -194,7 +194,7 @@
-
-
+
+