Features #14-17: WordPress Manager, Backup, Cloudflare, TOTP 2FA

- WordPressManager.php: wp-cli wrapper for install/update/clone/delete
- BackupManager.php: tar+mysqldump, schedules, retention, rclone
- CloudflareManager.php: zone/record management, sync, cache purge
- TOTP.php: RFC 6238 pure-PHP with backup codes
- Auth.php: TOTP_REQUIRED two-step login flow
- 4 new API endpoints: wordpress, backup, cloudflare, totp
- DB migration 002: TOTP cols, CF cols, wordpress_installs, backups tables
- admin.js: full UI for all 4 features + TOTP login step
- admin/index.php: sidebar nav for WordPress, 2FA Manager, Cloudflare

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 21:13:59 +00:00
parent 62707d62ce
commit 135bbcb0b3
13 changed files with 1542 additions and 47 deletions
+7 -3
View File
@@ -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';
+82
View File
@@ -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),
};
+99
View File
@@ -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),
};
+90
View File
@@ -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),
};
+72
View File
@@ -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),
};