mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
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:
@@ -0,0 +1,29 @@
|
|||||||
|
-- Migration 002: Features #14-17 (WordPress, Backup, Cloudflare, TOTP)
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
ALTER TABLE dns_zones ADD COLUMN cf_zone_id VARCHAR(64) DEFAULT NULL;
|
||||||
|
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;
|
||||||
|
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;
|
||||||
@@ -6,9 +6,13 @@ match ($action) {
|
|||||||
'login' => (function() use ($body) {
|
'login' => (function() use ($body) {
|
||||||
$username = trim($body['username'] ?? '');
|
$username = trim($body['username'] ?? '');
|
||||||
$password = $body['password'] ?? '';
|
$password = $body['password'] ?? '';
|
||||||
|
$totpCode = isset($body['totp_code']) ? trim($body['totp_code']) : null;
|
||||||
if (!$username || !$password) Response::error('Username and password required');
|
if (!$username || !$password) Response::error('Username and password required');
|
||||||
$auth = Auth::getInstance();
|
$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) {
|
if (!$token) {
|
||||||
// Log failure for Fail2Ban to detect
|
// Log failure for Fail2Ban to detect
|
||||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
+28
-1
@@ -1,8 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
if (!class_exists('TOTP')) require_once __DIR__ . '/TOTP.php';
|
||||||
|
|
||||||
class Auth {
|
class Auth {
|
||||||
private static ?Auth $instance = null;
|
private static ?Auth $instance = null;
|
||||||
private ?array $user = null;
|
private ?array $user = null;
|
||||||
|
|
||||||
|
// Returned by attempt() when password is correct but TOTP code still needed
|
||||||
|
public const TOTP_REQUIRED = 'TOTP_REQUIRED';
|
||||||
|
|
||||||
private function __construct() {}
|
private function __construct() {}
|
||||||
|
|
||||||
public static function getInstance(): self {
|
public static function getInstance(): self {
|
||||||
@@ -54,7 +59,10 @@ class Auth {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function attempt(string $username, string $password): ?string {
|
/**
|
||||||
|
* Returns null (bad credentials), self::TOTP_REQUIRED (need 2FA code), or session token string.
|
||||||
|
*/
|
||||||
|
public function attempt(string $username, string $password, ?string $totpCode = null): ?string {
|
||||||
$db = DB::getInstance();
|
$db = DB::getInstance();
|
||||||
$user = $db->fetchOne(
|
$user = $db->fetchOne(
|
||||||
"SELECT * FROM users WHERE (username = ? OR email = ?) AND status = 'active'",
|
"SELECT * FROM users WHERE (username = ? OR email = ?) AND status = 'active'",
|
||||||
@@ -62,6 +70,25 @@ class Auth {
|
|||||||
);
|
);
|
||||||
if (!$user || !password_verify($password, $user['password'])) return null;
|
if (!$user || !password_verify($password, $user['password'])) return null;
|
||||||
|
|
||||||
|
// TOTP check
|
||||||
|
if (!empty($user['totp_enabled'])) {
|
||||||
|
if ($totpCode === null) {
|
||||||
|
$this->user = $user;
|
||||||
|
return self::TOTP_REQUIRED;
|
||||||
|
}
|
||||||
|
$verified = TOTP::verify($user['totp_secret'] ?? '', $totpCode);
|
||||||
|
if (!$verified && !empty($user['totp_backup_codes'])) {
|
||||||
|
$verified = TOTP::verifyBackupCode($totpCode, $user['totp_backup_codes']);
|
||||||
|
if ($verified) {
|
||||||
|
// Consume used backup code
|
||||||
|
$hashes = json_decode($user['totp_backup_codes'], true) ?? [];
|
||||||
|
$hashes = array_values(array_filter($hashes, fn($h) => !password_verify(strtoupper($totpCode), $h)));
|
||||||
|
$db->execute("UPDATE users SET totp_backup_codes=? WHERE id=?", [json_encode($hashes), $user['id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$verified) return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
$token = bin2hex(random_bytes(32));
|
$token = bin2hex(random_bytes(32));
|
||||||
$sessionId = hash('sha256', $token);
|
$sessionId = hash('sha256', $token);
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
class BackupManager {
|
||||||
|
private PDO $db;
|
||||||
|
private string $backupRoot = '/home/novacpx-backups';
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
class CloudflareManager {
|
||||||
|
private const API = 'https://api.cloudflare.com/client/v4/';
|
||||||
|
private PDO $db;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
class TOTP {
|
||||||
|
private const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
private const PERIOD = 30;
|
||||||
|
private const DIGITS = 6;
|
||||||
|
private const WINDOW = 1;
|
||||||
|
|
||||||
|
public static function generateSecret(int $length = 32): string {
|
||||||
|
$secret = '';
|
||||||
|
$bytes = random_bytes($length);
|
||||||
|
for ($i = 0; $i < $length; $i++) {
|
||||||
|
$secret .= self::CHARS[ord($bytes[$i]) & 31];
|
||||||
|
}
|
||||||
|
return $secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generateCode(string $secret, ?int $timestamp = null): string {
|
||||||
|
$counter = intdiv($timestamp ?? time(), self::PERIOD);
|
||||||
|
$key = self::base32Decode($secret);
|
||||||
|
$msg = pack('J', $counter);
|
||||||
|
$hash = hash_hmac('sha1', $msg, $key, true);
|
||||||
|
$offset = ord($hash[19]) & 0x0F;
|
||||||
|
$code = ((ord($hash[$offset]) & 0x7F) << 24)
|
||||||
|
| ((ord($hash[$offset+1]) & 0xFF) << 16)
|
||||||
|
| ((ord($hash[$offset+2]) & 0xFF) << 8)
|
||||||
|
| (ord($hash[$offset+3]) & 0xFF);
|
||||||
|
return str_pad($code % (10 ** self::DIGITS), self::DIGITS, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verify(string $secret, string $code): bool {
|
||||||
|
$time = time();
|
||||||
|
for ($i = -self::WINDOW; $i <= self::WINDOW; $i++) {
|
||||||
|
if (hash_equals(self::generateCode($secret, $time + $i * self::PERIOD), $code)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function qrUrl(string $secret, string $username, string $issuer = 'NovaCPX'): string {
|
||||||
|
$label = rawurlencode("{$issuer}:{$username}");
|
||||||
|
$otpauth = "otpauth://totp/{$label}?secret={$secret}&issuer=" . rawurlencode($issuer) . "&algorithm=SHA1&digits=6&period=30";
|
||||||
|
return "https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=" . rawurlencode($otpauth);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generateBackupCodes(int $count = 8): array {
|
||||||
|
$codes = [];
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$raw = bin2hex(random_bytes(4));
|
||||||
|
$codes[] = strtoupper(substr($raw, 0, 4) . '-' . substr($raw, 4, 4));
|
||||||
|
}
|
||||||
|
return $codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hashBackupCodes(array $codes): string {
|
||||||
|
return json_encode(array_map(fn($c) => 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
class WordPressManager {
|
||||||
|
private PDO $db;
|
||||||
|
private string $wpcli = '/usr/local/bin/wp';
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,10 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||||
FTP Server
|
FTP Server
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#" class="sidebar-link" data-page="wordpress">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||||
|
WordPress
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
@@ -113,6 +117,10 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||||
Audit Log
|
Audit Log
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#" class="sidebar-link" data-page="twofa">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/><circle cx="12" cy="16" r="1" fill="currentColor"/></svg>
|
||||||
|
2FA Manager
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
@@ -125,6 +133,10 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
Backups
|
Backups
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#" class="sidebar-link" data-page="cloudflare">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9z"/></svg>
|
||||||
|
Cloudflare
|
||||||
|
</a>
|
||||||
<a href="#" class="sidebar-link" data-page="settings">
|
<a href="#" class="sidebar-link" data-page="settings">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
Settings
|
Settings
|
||||||
|
|||||||
+552
-39
@@ -4,19 +4,49 @@
|
|||||||
(async () => {
|
(async () => {
|
||||||
// ── Auth guard ─────────────────────────────────────────────────────────────
|
// ── Auth guard ─────────────────────────────────────────────────────────────
|
||||||
// Inline login handler on port 8882
|
// Inline login handler on port 8882
|
||||||
|
let _loginCredentials = null;
|
||||||
const loginForm = document.getElementById('login-form');
|
const loginForm = document.getElementById('login-form');
|
||||||
if (loginForm) {
|
if (loginForm) {
|
||||||
loginForm.addEventListener('submit', async e => {
|
loginForm.addEventListener('submit', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const btn = document.getElementById('l-btn');
|
const btn = document.getElementById('l-btn');
|
||||||
const err = document.getElementById('login-err');
|
const err = document.getElementById('login-err');
|
||||||
btn.disabled = true; btn.textContent = 'Signing in…'; err.style.display = 'none';
|
btn.disabled = true; err.style.display = 'none';
|
||||||
|
|
||||||
|
// Step 2: TOTP code entry
|
||||||
|
const totpInput = document.getElementById('l-totp');
|
||||||
|
if (totpInput && _loginCredentials) {
|
||||||
|
btn.textContent = 'Verifying…';
|
||||||
const res = await Nova.api('auth', 'login', {
|
const res = await Nova.api('auth', 'login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value }
|
body: { ..._loginCredentials, totp_code: totpInput.value.trim() }
|
||||||
});
|
});
|
||||||
if (res?.success && res.data?.user?.role === 'admin') {
|
if (res?.success && res.data?.user?.role === 'admin') {
|
||||||
location.reload();
|
location.reload();
|
||||||
|
} else {
|
||||||
|
err.textContent = res?.message || 'Invalid 2FA code';
|
||||||
|
err.style.display = '';
|
||||||
|
btn.disabled = false; btn.textContent = 'Verify';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: username + password
|
||||||
|
btn.textContent = 'Signing in…';
|
||||||
|
const creds = { username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value };
|
||||||
|
const res = await Nova.api('auth', 'login', { method: 'POST', body: creds });
|
||||||
|
if (res?.success && res.data?.user?.role === 'admin') {
|
||||||
|
location.reload();
|
||||||
|
} else if (res?.totp_required) {
|
||||||
|
// Show TOTP step
|
||||||
|
_loginCredentials = creds;
|
||||||
|
document.getElementById('l-user').closest('.form-group').style.display = 'none';
|
||||||
|
document.getElementById('l-pass').closest('.form-group').style.display = 'none';
|
||||||
|
const totpGroup = document.createElement('div');
|
||||||
|
totpGroup.className = 'form-group';
|
||||||
|
totpGroup.innerHTML = '<label>2FA Code</label><input id="l-totp" type="text" inputmode="numeric" maxlength="6" autocomplete="one-time-code" placeholder="6-digit code" autofocus>';
|
||||||
|
loginForm.insertBefore(totpGroup, btn.parentNode || btn);
|
||||||
|
btn.textContent = 'Verify'; btn.disabled = false;
|
||||||
} else {
|
} else {
|
||||||
err.textContent = res?.message || 'Invalid credentials or insufficient role';
|
err.textContent = res?.message || 'Invalid credentials or insufficient role';
|
||||||
err.style.display = '';
|
err.style.display = '';
|
||||||
@@ -57,11 +87,14 @@
|
|||||||
'mysql-manager': mysqlManager,
|
'mysql-manager': mysqlManager,
|
||||||
'mail-server': mailServer,
|
'mail-server': mailServer,
|
||||||
'ftp-server': ftpServer,
|
'ftp-server': ftpServer,
|
||||||
|
wordpress,
|
||||||
'ssl-manager': sslManager,
|
'ssl-manager': sslManager,
|
||||||
firewall,
|
firewall,
|
||||||
'audit-log': auditLog,
|
'audit-log': auditLog,
|
||||||
|
twofa,
|
||||||
updates,
|
updates,
|
||||||
backups,
|
backups,
|
||||||
|
cloudflare,
|
||||||
settings,
|
settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1301,43 +1334,13 @@ ${ips.length ? `
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Backups ────────────────────────────────────────────────────────────────
|
// ── Backups — delegates to backupsFull() defined in additions ─────────────
|
||||||
async function backups() {
|
async function backups() { return backupsFull(); }
|
||||||
const res = await Nova.api('accounts','list',{params:{limit:1000}});
|
|
||||||
const accts = res?.data?.accounts || [];
|
// ── Stubs for new pages — implementations in additions block below ─────────
|
||||||
return `
|
async function wordpress() { return `<p class="text-muted" style="padding:2rem">Loading…</p>`; }
|
||||||
<div class="card">
|
async function cloudflare() { return `<p class="text-muted" style="padding:2rem">Loading…</p>`; }
|
||||||
<div class="card-header">
|
async function twofa() { return `<p class="text-muted" style="padding:2rem">Loading…</p>`; }
|
||||||
<span class="card-title">Backup Manager</span>
|
|
||||||
<button class="btn btn-primary btn-sm" onclick="adminBackupAll()">Backup All Accounts</button>
|
|
||||||
</div>
|
|
||||||
<div style="padding:1.25rem">
|
|
||||||
<div style="margin-bottom:1.5rem;padding:1rem;background:var(--bg3);border-radius:8px;display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
|
|
||||||
<div class="form-group"><label class="form-label">Backup Storage</label>
|
|
||||||
<select class="form-control">
|
|
||||||
<option>Local (/var/backups/novacpx)</option>
|
|
||||||
<option>rclone (configured)</option>
|
|
||||||
<option>S3 (configure in settings)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group"><label class="form-label">Retention (days)</label>
|
|
||||||
<input type="number" class="form-control" value="7">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<table class="table"><thead><tr><th>Account</th><th>Domain</th><th>Actions</th></tr></thead><tbody>
|
|
||||||
${accts.slice(0,20).map(a => `<tr>
|
|
||||||
<td>${a.username}</td>
|
|
||||||
<td>${a.domain}</td>
|
|
||||||
<td style="display:flex;gap:.25rem">
|
|
||||||
<button class="btn btn-xs btn-primary" onclick="adminBackupAccount(${a.id},'${a.username}')">Backup Now</button>
|
|
||||||
</td>
|
|
||||||
</tr>`).join('')}
|
|
||||||
</tbody></table>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
window.adminBackupAll = () => Nova.toast('Full backup queued — this may take several minutes.','info',6000);
|
|
||||||
window.adminBackupAccount = (id, user) => Nova.toast(`Backup queued for ${user}…`,'info');
|
|
||||||
|
|
||||||
// ── Global action helpers ──────────────────────────────────────────────────
|
// ── Global action helpers ──────────────────────────────────────────────────
|
||||||
window.adminPage = (page) => Nova.loadPage(page, pages);
|
window.adminPage = (page) => Nova.loadPage(page, pages);
|
||||||
@@ -1406,3 +1409,513 @@ ${ips.length ? `
|
|||||||
if (badge && total > 0) { badge.textContent = total; badge.style.display = ''; }
|
if (badge && total > 0) { badge.textContent = total; badge.style.display = ''; }
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
// ── ADDITIONS: appended by features #14-17 ────────────────────────────────
|
||||||
|
|
||||||
|
// ── WordPress Manager (#14) ────────────────────────────────────────────────
|
||||||
|
async function wordpress() {
|
||||||
|
const [acctRes, wpRes] = await Promise.all([
|
||||||
|
Nova.api('accounts','list',{params:{limit:500}}),
|
||||||
|
Nova.api('wordpress','list'),
|
||||||
|
]);
|
||||||
|
const accts = acctRes?.data?.accounts || [];
|
||||||
|
const installs = wpRes?.data?.installs || [];
|
||||||
|
window._adminAcctsWP = accts;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="page-header mb-3">
|
||||||
|
<h2 class="page-title">WordPress Manager</h2>
|
||||||
|
<button class="btn btn-primary" onclick="wpInstallModal()">+ Install WordPress</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">WordPress Installs</span>
|
||||||
|
<span class="text-muted text-sm ml-2">${installs.length} install${installs.length!==1?'s':''}</span>
|
||||||
|
<button class="btn btn-ghost btn-sm ml-auto" onclick="adminPage('wordpress')">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
${installs.length ? `
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Domain</th><th>Path</th><th>Account</th><th>Version</th><th>Status</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${installs.map(w => `<tr>
|
||||||
|
<td><strong>${Nova.escHtml(w.domain)}</strong></td>
|
||||||
|
<td><code>${Nova.escHtml(w.path||'/')}</code></td>
|
||||||
|
<td>${Nova.escHtml(w.username||'—')}</td>
|
||||||
|
<td>${w.wp_version ? `<code>${Nova.escHtml(w.wp_version)}</code>` : '—'}</td>
|
||||||
|
<td>${Nova.badge(w.status||'active', w.status==='active'?'green':w.status==='updating'?'yellow':'red')}</td>
|
||||||
|
<td style="display:flex;gap:.25rem;flex-wrap:wrap">
|
||||||
|
<button class="btn btn-xs" onclick="wpInfo(${w.id},'${Nova.escHtml(w.domain)}')">Info</button>
|
||||||
|
<button class="btn btn-xs btn-primary" onclick="wpUpdate(${w.id},'core')">Update Core</button>
|
||||||
|
<button class="btn btn-xs" onclick="wpUpdate(${w.id},'plugins')">Plugins</button>
|
||||||
|
<button class="btn btn-xs" onclick="wpUpdate(${w.id},'themes')">Themes</button>
|
||||||
|
${!w.staging_of ? `<button class="btn btn-xs" onclick="wpCloneStaging(${w.id},'${Nova.escHtml(w.domain)}')">Clone Staging</button>` : `<span class="badge badge-yellow">staging</span>`}
|
||||||
|
<button class="btn btn-xs btn-danger" onclick="wpDelete(${w.id},'${Nova.escHtml(w.domain)}')">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>` : `<div class="empty" style="padding:2rem">No WordPress installs yet. Click "Install WordPress" to get started.</div>`}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.wpInstallModal = () => {
|
||||||
|
const accts = window._adminAcctsWP || [];
|
||||||
|
const opts = accts.map(a => `<option value="${a.id}">${a.username} — ${a.domain}</option>`).join('');
|
||||||
|
Nova.modal('Install WordPress', `
|
||||||
|
<div class="form-group"><label>Account</label><select id="wp-acct" class="form-control">${opts}</select></div>
|
||||||
|
<div class="form-group"><label>Domain</label><input id="wp-domain" class="form-control" placeholder="example.com"></div>
|
||||||
|
<div class="form-group"><label>Path (leave / for root)</label><input id="wp-path" class="form-control" value="/"></div>
|
||||||
|
<div class="form-group"><label>Site Title</label><input id="wp-title" class="form-control" placeholder="My WordPress Site"></div>
|
||||||
|
<div class="form-group"><label>WP Admin Username</label><input id="wp-admin" class="form-control" value="admin"></div>
|
||||||
|
<div class="form-group"><label>WP Admin Password</label><input id="wp-adminpass" type="password" class="form-control"></div>
|
||||||
|
<div class="form-group"><label>WP Admin Email</label><input id="wp-email" type="email" class="form-control"></div>
|
||||||
|
<p class="text-muted text-sm">wp-cli will be downloaded automatically if not installed. This may take 1-2 minutes.</p>`,
|
||||||
|
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="wp-install-btn" onclick="wpSubmitInstall()">Install</button>`);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.wpSubmitInstall = async () => {
|
||||||
|
const btn = document.getElementById('wp-install-btn');
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Installing…'; }
|
||||||
|
Nova.toast('Installing WordPress — this may take 1-2 minutes…', 'info', 90000);
|
||||||
|
const res = await Nova.api('wordpress','install',{method:'POST',body:{
|
||||||
|
account_id: +document.getElementById('wp-acct')?.value,
|
||||||
|
domain: document.getElementById('wp-domain')?.value,
|
||||||
|
path: document.getElementById('wp-path')?.value || '/',
|
||||||
|
site_title: document.getElementById('wp-title')?.value,
|
||||||
|
admin_user: document.getElementById('wp-admin')?.value,
|
||||||
|
admin_pass: document.getElementById('wp-adminpass')?.value,
|
||||||
|
admin_email:document.getElementById('wp-email')?.value,
|
||||||
|
}});
|
||||||
|
document.querySelector('.modal-overlay')?.remove();
|
||||||
|
if (res?.success) { Nova.toast('WordPress installed!','success'); adminPage('wordpress'); }
|
||||||
|
else Nova.toast(res?.message || 'Install failed','error');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.wpUpdate = async (id, type) => {
|
||||||
|
const action = type === 'core' ? 'update-core' : type === 'plugins' ? 'update-plugins' : 'update-themes';
|
||||||
|
Nova.toast(`Updating ${type}…`,'info',15000);
|
||||||
|
const r = await Nova.api('wordpress', action, {method:'POST',body:{install_id:id}});
|
||||||
|
Nova.toast(r?.message || (r?.success ? 'Updated' : 'Failed'), r?.success ? 'success' : 'error');
|
||||||
|
if (r?.success) adminPage('wordpress');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.wpInfo = async (id, domain) => {
|
||||||
|
Nova.toast('Loading info…','info',5000);
|
||||||
|
const r = await Nova.api('wordpress','info',{params:{install_id:id}});
|
||||||
|
if (!r?.success) { Nova.toast(r?.message,'error'); return; }
|
||||||
|
const d = r.data || {};
|
||||||
|
const plugins = (d.plugins||[]).map(p => `<tr><td>${Nova.escHtml(p.name)}</td><td>${Nova.escHtml(p.version||'')}</td><td>${Nova.badge(p.status||'inactive',p.status==='active'?'green':'muted')}</td></tr>`).join('');
|
||||||
|
const themes = (d.themes||[]).map(t => `<tr><td>${Nova.escHtml(t.name)}</td><td>${Nova.escHtml(t.version||'')}</td><td>${Nova.badge(t.status||'inactive',t.status==='active'?'green':'muted')}</td></tr>`).join('');
|
||||||
|
Nova.modal(`WordPress: ${domain}`,`
|
||||||
|
<div class="grid-2 mb-2" style="gap:.75rem">
|
||||||
|
<div><p class="text-muted text-sm">Core Version</p><p class="font-bold">${Nova.escHtml(d.version||'—')}</p></div>
|
||||||
|
<div><p class="text-muted text-sm">Site URL</p><p>${Nova.escHtml(d.siteurl||'—')}</p></div>
|
||||||
|
</div>
|
||||||
|
<h4 class="mb-1">Plugins (${(d.plugins||[]).length})</h4>
|
||||||
|
${plugins ? `<table class="table" style="font-size:.82rem"><thead><tr><th>Plugin</th><th>Version</th><th>Status</th></tr></thead><tbody>${plugins}</tbody></table>` : '<p class="text-muted text-sm">None</p>'}
|
||||||
|
<h4 class="mb-1 mt-2">Themes (${(d.themes||[]).length})</h4>
|
||||||
|
${themes ? `<table class="table" style="font-size:.82rem"><thead><tr><th>Theme</th><th>Version</th><th>Status</th></tr></thead><tbody>${themes}</tbody></table>` : '<p class="text-muted text-sm">None</p>'}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.wpCloneStaging = (id, domain) => {
|
||||||
|
Nova.confirm(`Clone ${domain} to a staging environment? This copies all files and the database.`, async () => {
|
||||||
|
Nova.toast('Cloning to staging…','info',30000);
|
||||||
|
const r = await Nova.api('wordpress','clone-staging',{method:'POST',body:{install_id:id}});
|
||||||
|
Nova.toast(r?.message || (r?.success ? 'Staging created' : 'Failed'), r?.success ? 'success' : 'error');
|
||||||
|
if (r?.success) adminPage('wordpress');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.wpDelete = (id, domain) => {
|
||||||
|
Nova.confirm(`DELETE WordPress install on ${domain}? This removes all files AND drops the database. IRREVERSIBLE.`, async () => {
|
||||||
|
const r = await Nova.api('wordpress','delete',{method:'POST',body:{install_id:id}});
|
||||||
|
Nova.toast(r?.message || (r?.success ? 'Deleted' : 'Failed'), r?.success ? 'success' : 'error');
|
||||||
|
if (r?.success) adminPage('wordpress');
|
||||||
|
}, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Backup Manager — full implementation (#15) ─────────────────────────────
|
||||||
|
async function backupsFull() {
|
||||||
|
const [acctRes, bkRes] = await Promise.all([
|
||||||
|
Nova.api('accounts','list',{params:{limit:500}}),
|
||||||
|
Nova.api('backup','list'),
|
||||||
|
]);
|
||||||
|
const accts = acctRes?.data?.accounts || [];
|
||||||
|
const backupList = bkRes?.data?.backups || [];
|
||||||
|
const diskUsed = bkRes?.data?.disk_used || 0;
|
||||||
|
window._adminAcctsBK = accts;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="page-header mb-3">
|
||||||
|
<h2 class="page-title">Backup Manager</h2>
|
||||||
|
<div style="display:flex;gap:.5rem">
|
||||||
|
<button class="btn btn-primary" onclick="bkCreateModal()">+ New Backup</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="adminPage('backups')">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid mb-3" style="grid-template-columns:repeat(3,1fr)">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Total Backups</div>
|
||||||
|
<div class="stat-value stat-blue">${backupList.length}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Disk Used</div>
|
||||||
|
<div class="stat-value">${Nova.bytes(diskUsed)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Accounts</div>
|
||||||
|
<div class="stat-value">${accts.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">Backup Schedules</span>
|
||||||
|
<button class="btn btn-sm" onclick="bkScheduleModal()">Configure Schedule</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted text-sm">Set per-account backup schedules. Cron runs backups automatically based on the configured frequency.</p>
|
||||||
|
<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.75rem">
|
||||||
|
${accts.slice(0,8).map(a => `<button class="btn btn-xs" onclick="bkScheduleForAccount(${a.id},'${Nova.escHtml(a.username)}')">${Nova.escHtml(a.username)}</button>`).join('')}
|
||||||
|
${accts.length>8?`<span class="text-muted text-sm">+${accts.length-8} more</span>`:''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span class="card-title">All Backups</span></div>
|
||||||
|
${backupList.length ? `
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Account</th><th>Type</th><th>Size</th><th>Status</th><th>Storage</th><th>Created</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${backupList.map(b => `<tr>
|
||||||
|
<td>${Nova.escHtml(b.username||b.account_id||'—')}</td>
|
||||||
|
<td>${Nova.badge(b.type,'default')}</td>
|
||||||
|
<td>${Nova.bytes(b.size||0)}</td>
|
||||||
|
<td>${Nova.badge(b.status, b.status==='complete'?'green':b.status==='failed'?'red':'yellow')}</td>
|
||||||
|
<td>${b.remote_path ? Nova.badge('remote','blue') : Nova.badge('local','muted')}</td>
|
||||||
|
<td class="text-muted text-sm">${Nova.relTime(b.created_at)}</td>
|
||||||
|
<td style="display:flex;gap:.25rem">
|
||||||
|
${b.status==='complete'?`<a class="btn btn-xs" href="/api/backup/download?id=${b.id}" target="_blank">Download</a>`:''}
|
||||||
|
<button class="btn btn-xs btn-warning" onclick="bkRestore(${b.id})">Restore</button>
|
||||||
|
<button class="btn btn-xs btn-danger" onclick="bkDelete(${b.id})">Del</button>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>` : `<div class="empty" style="padding:2rem">No backups yet.</div>`}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.bkCreateModal = () => {
|
||||||
|
const accts = window._adminAcctsBK || [];
|
||||||
|
const opts = accts.map(a => `<option value="${a.id}">${a.username} — ${a.domain}</option>`).join('');
|
||||||
|
Nova.modal('Create Backup', `
|
||||||
|
<div class="form-group"><label>Account</label><select id="bk-acct" class="form-control">${opts}</select></div>
|
||||||
|
<div class="form-group"><label>Type</label>
|
||||||
|
<select id="bk-type" class="form-control">
|
||||||
|
<option value="full">Full (files + database)</option>
|
||||||
|
<option value="files">Files only</option>
|
||||||
|
<option value="database">Database only</option>
|
||||||
|
</select>
|
||||||
|
</div>`,
|
||||||
|
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="bkSubmitCreate()">Create Backup</button>`);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.bkSubmitCreate = async () => {
|
||||||
|
const id = +document.getElementById('bk-acct')?.value;
|
||||||
|
const type = document.getElementById('bk-type')?.value;
|
||||||
|
document.querySelector('.modal-overlay')?.remove();
|
||||||
|
Nova.toast('Creating backup…','info',30000);
|
||||||
|
const r = await Nova.api('backup','create',{method:'POST',body:{account_id:id,type}});
|
||||||
|
Nova.toast(r?.message||(r?.success?'Backup complete':'Failed'), r?.success?'success':'error');
|
||||||
|
if (r?.success) adminPage('backups');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.bkRestore = (id) => {
|
||||||
|
Nova.confirm('Restore this backup? Current files and databases will be overwritten. IRREVERSIBLE.', async () => {
|
||||||
|
Nova.toast('Restoring…','info',30000);
|
||||||
|
const r = await Nova.api('backup','restore',{method:'POST',body:{id}});
|
||||||
|
Nova.toast(r?.message||(r?.success?'Restored':'Failed'), r?.success?'success':'error');
|
||||||
|
}, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.bkDelete = (id) => {
|
||||||
|
Nova.confirm('Delete this backup?', async () => {
|
||||||
|
const r = await Nova.api('backup','delete',{method:'POST',body:{id}});
|
||||||
|
Nova.toast(r?.message||(r?.success?'Deleted':'Failed'), r?.success?'success':'error');
|
||||||
|
if (r?.success) adminPage('backups');
|
||||||
|
}, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.bkScheduleModal = () => {
|
||||||
|
const accts = window._adminAcctsBK || [];
|
||||||
|
const opts = accts.map(a => `<option value="${a.id}">${a.username}</option>`).join('');
|
||||||
|
Nova.modal('Configure Backup Schedule', `
|
||||||
|
<div class="form-group"><label>Account</label><select id="bks-acct" class="form-control">${opts}</select></div>
|
||||||
|
<div class="form-group"><label>Frequency</label>
|
||||||
|
<select id="bks-freq" class="form-control">
|
||||||
|
<option value="hourly">Hourly</option>
|
||||||
|
<option value="daily" selected>Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Type</label>
|
||||||
|
<select id="bks-type" class="form-control">
|
||||||
|
<option value="full">Full</option>
|
||||||
|
<option value="files">Files only</option>
|
||||||
|
<option value="database">Database only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Keep (# backups)</label><input id="bks-retain" type="number" class="form-control" value="7"></div>`,
|
||||||
|
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="bkSaveSchedule()">Save Schedule</button>`);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.bkScheduleForAccount = async (id, user) => {
|
||||||
|
const r = await Nova.api('backup','get-schedule',{params:{account_id:id}});
|
||||||
|
const s = r?.data || {};
|
||||||
|
Nova.modal(`Schedule: ${user}`, `
|
||||||
|
<div class="form-group"><label>Frequency</label>
|
||||||
|
<select id="bks-freq" class="form-control">
|
||||||
|
${['hourly','daily','weekly','monthly'].map(f=>`<option value="${f}"${s.frequency===f?' selected':''}>${f.charAt(0).toUpperCase()+f.slice(1)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Type</label>
|
||||||
|
<select id="bks-type" class="form-control">
|
||||||
|
${['full','files','database'].map(t=>`<option value="${t}"${s.type===t?' selected':''}>${t.charAt(0).toUpperCase()+t.slice(1)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Keep (# backups)</label><input id="bks-retain" type="number" class="form-control" value="${s.retain_count||7}"></div>`,
|
||||||
|
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="bkSaveScheduleFor(${id})">Save</button>`);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.bkSaveSchedule = async () => {
|
||||||
|
const id = +document.getElementById('bks-acct')?.value;
|
||||||
|
await bkSaveScheduleFor(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.bkSaveScheduleFor = async (id) => {
|
||||||
|
const r = await Nova.api('backup','schedule',{method:'POST',body:{
|
||||||
|
account_id: id,
|
||||||
|
frequency: document.getElementById('bks-freq')?.value,
|
||||||
|
type: document.getElementById('bks-type')?.value,
|
||||||
|
retain: +document.getElementById('bks-retain')?.value,
|
||||||
|
}});
|
||||||
|
document.querySelector('.modal-overlay')?.remove();
|
||||||
|
Nova.toast(r?.message||(r?.success?'Schedule saved':'Failed'), r?.success?'success':'error');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Cloudflare Integration (#16) ──────────────────────────────────────────
|
||||||
|
async function cloudflare() {
|
||||||
|
const acctRes = await Nova.api('accounts','list',{params:{limit:500}});
|
||||||
|
const accts = acctRes?.data?.accounts || [];
|
||||||
|
window._adminAcctsCF = accts;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="page-header mb-3">
|
||||||
|
<h2 class="page-title">Cloudflare Integration</h2>
|
||||||
|
<p class="text-muted text-sm">Manage Cloudflare API credentials and DNS sync per account.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><span class="card-title">Account Credentials</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted text-sm mb-2">Select an account to configure or view its Cloudflare API key.</p>
|
||||||
|
<div style="display:flex;gap:.75rem;flex-wrap:wrap;align-items:flex-end">
|
||||||
|
<div class="form-group mb-0">
|
||||||
|
<label class="form-label text-sm">Account</label>
|
||||||
|
<select id="cf-acct-sel" class="form-control form-control-sm" onchange="cfLoadAccount(this.value)">
|
||||||
|
<option value="">— Select Account —</option>
|
||||||
|
${accts.map(a=>`<option value="${a.id}">${a.username} — ${a.domain}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="cf-acct-panel" style="margin-top:1rem"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="cf-zones-panel" style="display:none">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">Cloudflare Zones</span>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="cfRefreshZones()">↻ Refresh Zones</button>
|
||||||
|
</div>
|
||||||
|
<div id="cf-zones-body" class="card-body">
|
||||||
|
<p class="text-muted text-sm">Save credentials first, then click Refresh Zones.</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.cfLoadAccount = async (id) => {
|
||||||
|
if (!id) { document.getElementById('cf-acct-panel').innerHTML=''; return; }
|
||||||
|
const r = await Nova.api('cloudflare','get-credentials',{params:{account_id:id}});
|
||||||
|
const c = r?.data || {};
|
||||||
|
document.getElementById('cf-acct-panel').innerHTML = `
|
||||||
|
<div class="grid-2" style="gap:.75rem;max-width:600px">
|
||||||
|
<div class="form-group"><label class="form-label">API Email</label>
|
||||||
|
<input id="cf-email" class="form-control" type="email" value="${Nova.escHtml(c.cf_api_email||'')}" placeholder="you@example.com"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Global API Key</label>
|
||||||
|
<input id="cf-apikey" class="form-control" type="text" value="${Nova.escHtml(c.cf_api_key||'')}" placeholder="API key from Cloudflare dashboard"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:.5rem;margin-top:.5rem">
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="cfSaveCredentials(${id})">Save Credentials</button>
|
||||||
|
<button class="btn btn-sm btn-ghost" onclick="cfTestKey(${id})">Test API Key</button>
|
||||||
|
</div>
|
||||||
|
${c.cf_api_key ? `<p class="text-muted text-sm mt-1">Key on file: <code>${Nova.escHtml(c.cf_api_key)}</code></p>` : ''}`;
|
||||||
|
document.getElementById('cf-zones-panel').style.display = '';
|
||||||
|
window._cfCurrentAcct = id;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.cfSaveCredentials = async (id) => {
|
||||||
|
const r = await Nova.api('cloudflare','save-credentials',{method:'POST',body:{
|
||||||
|
account_id: id,
|
||||||
|
api_key: document.getElementById('cf-apikey')?.value,
|
||||||
|
email: document.getElementById('cf-email')?.value,
|
||||||
|
}});
|
||||||
|
Nova.toast(r?.message||(r?.success?'Saved':'Failed'), r?.success?'success':'error');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.cfTestKey = async (id) => {
|
||||||
|
const r = await Nova.api('cloudflare','test-key',{method:'POST',body:{
|
||||||
|
account_id: id,
|
||||||
|
api_key: document.getElementById('cf-apikey')?.value,
|
||||||
|
email: document.getElementById('cf-email')?.value,
|
||||||
|
}});
|
||||||
|
Nova.toast(r?.message||(r?.data?.valid?'API key is valid':'Invalid key'), r?.data?.valid?'success':'error');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.cfRefreshZones = async () => {
|
||||||
|
const id = window._cfCurrentAcct;
|
||||||
|
if (!id) { Nova.toast('Select an account first','error'); return; }
|
||||||
|
const r = await Nova.api('cloudflare','list-zones',{params:{account_id:id}});
|
||||||
|
const zones = r?.data?.zones || r?.data || [];
|
||||||
|
const body = document.getElementById('cf-zones-body');
|
||||||
|
if (!body) return;
|
||||||
|
if (!r?.success) { body.innerHTML=`<p class="text-muted">${Nova.escHtml(r?.message||'Failed to load zones')}</p>`; return; }
|
||||||
|
if (!zones.length) { body.innerHTML='<p class="text-muted text-sm">No zones found for these credentials.</p>'; return; }
|
||||||
|
body.innerHTML = `
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>Zone</th><th>Status</th><th>Plan</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${zones.map(z=>`<tr>
|
||||||
|
<td><strong>${Nova.escHtml(z.name)}</strong><br><code style="font-size:.75rem">${Nova.escHtml(z.id)}</code></td>
|
||||||
|
<td>${Nova.badge(z.status,z.status==='active'?'green':'yellow')}</td>
|
||||||
|
<td class="text-muted text-sm">${Nova.escHtml(z.plan?.name||'—')}</td>
|
||||||
|
<td style="display:flex;gap:.25rem">
|
||||||
|
<button class="btn btn-xs" onclick="cfViewRecords('${Nova.escHtml(z.id)}','${Nova.escHtml(z.name)}',${id})">DNS Records</button>
|
||||||
|
<button class="btn btn-xs btn-primary" onclick="cfSync('${Nova.escHtml(z.id)}','${Nova.escHtml(z.name)}','to',${id})">Push to CF</button>
|
||||||
|
<button class="btn btn-xs" onclick="cfSync('${Nova.escHtml(z.id)}','${Nova.escHtml(z.name)}','from',${id})">Pull from CF</button>
|
||||||
|
<button class="btn btn-xs btn-warning" onclick="cfPurge('${Nova.escHtml(z.id)}',${id})">Purge Cache</button>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.cfViewRecords = async (zoneId, domain, acctId) => {
|
||||||
|
const r = await Nova.api('cloudflare','list-records',{method:'POST',body:{zone_id:zoneId,account_id:acctId}});
|
||||||
|
const records = r?.data?.records || r?.data || [];
|
||||||
|
Nova.modal(`CF DNS: ${domain}`, !records.length ? '<p class="text-muted">No records.</p>' : `
|
||||||
|
<table class="table" style="font-size:.82rem">
|
||||||
|
<thead><tr><th>Name</th><th>Type</th><th>Value</th><th>Proxy</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${records.map(rec=>`<tr>
|
||||||
|
<td>${Nova.escHtml(rec.name)}</td>
|
||||||
|
<td>${Nova.badge(rec.type,'default')}</td>
|
||||||
|
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis"><code>${Nova.escHtml(rec.content)}</code></td>
|
||||||
|
<td>
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer">
|
||||||
|
<input type="checkbox" ${rec.proxiable&&rec.proxied?'checked':''} ${!rec.proxiable?'disabled':''}
|
||||||
|
onchange="cfToggleProxy('${zoneId}','${rec.id}',this.checked,${acctId})">
|
||||||
|
${rec.proxied?Nova.badge('proxied','orange'):Nova.badge('DNS only','muted')}
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.cfToggleProxy = async (zoneId, recordId, proxied, acctId) => {
|
||||||
|
const r = await Nova.api('cloudflare','toggle-proxy',{method:'POST',body:{zone_id:zoneId,record_id:recordId,proxied,account_id:acctId}});
|
||||||
|
Nova.toast(r?.message||(r?.success?'Updated':'Failed'), r?.success?'success':'error');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.cfSync = async (zoneId, domain, dir, acctId) => {
|
||||||
|
const action = dir==='to' ? 'sync-to-cf' : 'sync-from-cf';
|
||||||
|
const label = dir==='to' ? 'Pushing to Cloudflare' : 'Pulling from Cloudflare';
|
||||||
|
Nova.toast(`${label}…`,'info',10000);
|
||||||
|
const r = await Nova.api('cloudflare',action,{method:'POST',body:{zone_id:zoneId,domain,account_id:acctId}});
|
||||||
|
Nova.toast(r?.message||(r?.success?'Done':'Failed'), r?.success?'success':'error');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.cfPurge = async (zoneId, acctId) => {
|
||||||
|
Nova.confirm('Purge all Cloudflare cache for this zone?', async () => {
|
||||||
|
const r = await Nova.api('cloudflare','purge-cache',{method:'POST',body:{zone_id:zoneId,account_id:acctId}});
|
||||||
|
Nova.toast(r?.message||(r?.success?'Cache purged':'Failed'), r?.success?'success':'error');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── TOTP / 2FA Admin (#17) ────────────────────────────────────────────────
|
||||||
|
async function twofa() {
|
||||||
|
const res = await Nova.api('accounts','list',{params:{limit:500}});
|
||||||
|
const users = res?.data?.accounts || [];
|
||||||
|
return `
|
||||||
|
<div class="page-header mb-3">
|
||||||
|
<h2 class="page-title">Two-Factor Authentication</h2>
|
||||||
|
<p class="text-muted text-sm">View 2FA status for all users. Force-disable for account recovery.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">User 2FA Status</span>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="adminPage('twofa')">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>2FA Status</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody id="totp-user-rows">
|
||||||
|
${users.map(u=>`<tr>
|
||||||
|
<td><strong>${Nova.escHtml(u.username)}</strong></td>
|
||||||
|
<td class="text-muted text-sm">${Nova.escHtml(u.email||'—')}</td>
|
||||||
|
<td>${Nova.badge(u.role||'user','default')}</td>
|
||||||
|
<td id="totp-status-${u.id}">
|
||||||
|
<span class="text-muted text-sm">—</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-xs btn-ghost" onclick="totpCheckStatus(${u.id})">Check</button>
|
||||||
|
<button class="btn btn-xs btn-warning" onclick="totpAdminDisable(${u.id},'${Nova.escHtml(u.username)}')">Force Disable</button>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.totpCheckStatus = async (userId) => {
|
||||||
|
const r = await Nova.api('totp','admin-status',{method:'POST',body:{user_id:userId}});
|
||||||
|
const el = document.getElementById(`totp-status-${userId}`);
|
||||||
|
if (!el) return;
|
||||||
|
const enabled = r?.data?.totp_enabled;
|
||||||
|
el.innerHTML = enabled
|
||||||
|
? Nova.badge('Enabled','green')
|
||||||
|
: Nova.badge('Disabled','muted');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.totpAdminDisable = (userId, username) => {
|
||||||
|
Nova.confirm(`Force-disable 2FA for ${username}? Use only for account recovery when user cannot log in.`, async () => {
|
||||||
|
const r = await Nova.api('totp','admin-disable',{method:'POST',body:{user_id:userId}});
|
||||||
|
Nova.toast(r?.message||(r?.success?'2FA disabled':'Failed'), r?.success?'success':'error');
|
||||||
|
if (r?.success) {
|
||||||
|
const el = document.getElementById(`totp-status-${userId}`);
|
||||||
|
if (el) el.innerHTML = Nova.badge('Disabled','muted');
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user