mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
feat: items #9-13 — password change, webmail SSO, DKIM live, file manager security, cache busting
#9 auth.php: add self-service change-password action (current+new+confirm) accounts.php: fix admin change-password — accept account_id, fetch username for chpasswd (was using int ID), add Auth::require('admin') guard user.js: add Change Password page + navItem + submitChangePassword() #10 EmailManager: store AES-256-CBC enc_password alongside SHA512-CRYPT hash webmail.php: rewrite login-url to use webmail_sso_tokens table novacpx-sso.php: Roundcube SSO bridge (validate token, decrypt, autosubmit) Migration 005: add enc_password column + webmail_sso_tokens table #11 opendkim: installed, configured (/etc/opendkim.conf, signing.table, key.table, trusted.hosts), socket at /var/spool/postfix/opendkim/, Postfix milter wired, service enabled+running, key generation verified #12 files.php: fix safe_path() for non-existent paths (write/mkdir), add safe_path_new() helper using parent-dir realpath check, fix delete guard (block deleting account root dirs), fix rename destination, clamp chmod to 0777 #13 nova.js: api() handles network errors, 429 rate-limit with retry-after, non-JSON responses (PHP fatal pages) — graceful error instead of throw admin/user/reseller index.php: filemtime-based cache-busting on all assets Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
})(),
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
require_once NOVACPX_LIB . '/BackupManager.php';
|
||||
if (!in_array($currentUser['role'], ['admin','reseller','user'])) Response::error('Forbidden', 403);
|
||||
|
||||
$bm = new BackupManager();
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$isAdmin = $currentUser['role'] === 'admin';
|
||||
|
||||
$accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
|
||||
if ($currentUser['role'] === 'user') $accountId = $currentUser['account_id'] ?? 0;
|
||||
|
||||
match ($action) {
|
||||
'list' => (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),
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
require_once NOVACPX_LIB . '/CloudflareManager.php';
|
||||
if (!in_array($currentUser['role'], ['admin','reseller','user'])) Response::error('Forbidden', 403);
|
||||
|
||||
$cf = new CloudflareManager();
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$isAdmin = $currentUser['role'] === 'admin';
|
||||
|
||||
$accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
|
||||
if ($currentUser['role'] === 'user') $accountId = $currentUser['account_id'] ?? 0;
|
||||
|
||||
// Resolve credentials — from body (for test) or from stored account creds
|
||||
$apiKey = $body['api_key'] ?? null;
|
||||
$email = $body['email'] ?? null;
|
||||
if (!$apiKey && $accountId) {
|
||||
$creds = $cf->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),
|
||||
};
|
||||
@@ -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');
|
||||
})(),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
/**
|
||||
* Proxy endpoint — manage Nginx reverse proxy
|
||||
* Routes:
|
||||
* GET /api/proxy/status — nginx status
|
||||
* POST /api/proxy/install — install nginx
|
||||
* POST /api/proxy/control — {action: start|stop|restart|reload}
|
||||
* GET /api/proxy/hosts — list proxy hosts
|
||||
* POST /api/proxy/hosts — add proxy host
|
||||
* PUT /api/proxy/host — {id, ...fields} update host
|
||||
* DELETE /api/proxy/host — {id} delete host
|
||||
* POST /api/proxy/toggle — {id, enabled} toggle host
|
||||
* POST /api/proxy/sync — sync hosts from accounts
|
||||
* POST /api/proxy/write-configs — regenerate all nginx configs
|
||||
* GET /api/proxy/setup-script — return bash install script
|
||||
*/
|
||||
|
||||
Auth::getInstance()->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);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
/**
|
||||
* Sessions endpoint — admin session management
|
||||
* GET sessions/list — all active sessions with user info
|
||||
* DELETE sessions/revoke — {session_id} revoke one session
|
||||
* DELETE sessions/revoke-user — {user_id} revoke all sessions for a user
|
||||
* DELETE sessions/revoke-all — revoke all sessions except current
|
||||
*/
|
||||
|
||||
Auth::getInstance()->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),
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
require_once NOVACPX_LIB . '/TOTP.php';
|
||||
// TOTP / 2FA management — all actions require authentication
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$uid = $currentUser['id'] ?? $currentUser['uid'] ?? 0;
|
||||
$db = Database::getInstance()->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),
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
<?php
|
||||
/**
|
||||
* Webmail endpoint — Roundcube integration proxy
|
||||
* Redirects authenticated users to Roundcube with SSO token
|
||||
* Webmail endpoint — Roundcube integration + SSO
|
||||
*/
|
||||
require_once NOVACPX_LIB . '/EmailManager.php';
|
||||
|
||||
$db = DB::getInstance();
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
|
||||
$user = Auth::getInstance()->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),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
require_once NOVACPX_LIB . '/WordPressManager.php';
|
||||
if (!in_array($currentUser['role'], ['admin','reseller','user'])) Response::error('Forbidden', 403);
|
||||
|
||||
$wp = new WordPressManager();
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$isAdmin = $currentUser['role'] === 'admin';
|
||||
|
||||
// Scope account_id: users can only manage their own
|
||||
$accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
|
||||
if ($currentUser['role'] === 'user') {
|
||||
$accountId = $currentUser['account_id'] ?? 0;
|
||||
}
|
||||
|
||||
match ($action) {
|
||||
'list' => (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),
|
||||
};
|
||||
Reference in New Issue
Block a user