mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
Fix #4-#8: mail virtual domains, DNS verified, reseller isolation, missing DB tables
#4: Postfix virtual mailbox config (virtual_mailbox_domains/maps, vmail user, maildir at /var/mail/vhosts/%d/%n). Dovecot SQL backend pointed at novacpx.email_accounts with SHA512-CRYPT passdb and per-domain Maildir userdb. #5: BIND9 confirmed working — dig @localhost resolves testdomain1.com correctly. #6: Certbot 2.9.0 confirmed installed; domains.document_root wired; infrastructure ready for live domain issuance (testdomain1.com not publicly resolvable so dry-run expected to fail). #7: Fixed all broken user-panel API queries — missing tables (databases, ftp_accounts, ssl_certs, cron_jobs, php_configs, notifications) created; `databases` reserved-word backtick-quoted across DatabaseManager+endpoints; domains.php is_primary→type=main, doc_root→document_root column fixes; DNSManager::createZone call signature fixed; stats/account auto-resolves account_id for user role. #8: assert_account_access() helper added to api/index.php; reseller ownership check wired into email, ftp, databases, domains, dns, ssl endpoints. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
// NovaCPX Admin Panel — Datacenter/Server Manager
|
||||
// Equivalent to WHM (WebHost Manager)
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NovaCPX Admin</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/css/nova.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="panel-layout" id="app" style="display:none">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<svg class="logo-icon" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="18" stroke="url(#lg1)" stroke-width="2"/>
|
||||
<path d="M12 28 L20 8 L28 28" stroke="url(#lg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 22 H26" stroke="url(#lg2)" stroke-width="2" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="lg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
<linearGradient id="lg2" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<span class="logo-text">Nova<strong>CPX</strong> <small style="font-size:.65rem;color:var(--text-muted)">Admin</small></span>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Overview</div>
|
||||
<a href="#" class="sidebar-link active" data-page="dashboard">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="server-status">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||
Server Status
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Accounts</div>
|
||||
<a href="#" class="sidebar-link" data-page="accounts">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
All Accounts
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="resellers">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M2 20c0-4 4-7 10-7s10 3 10 7"/></svg>
|
||||
Resellers
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="packages">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
||||
Packages
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="create-account">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
|
||||
Create Account
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">DNS</div>
|
||||
<a href="#" class="sidebar-link" data-page="dns-zones">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 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>
|
||||
DNS Zones
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="nameservers">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
Nameservers
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">Services</div>
|
||||
<a href="#" class="sidebar-link" data-page="web-server">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
Web Server
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="php-manager">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
PHP Manager
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="mysql-manager">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
|
||||
MySQL / PgSQL
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="mail-server">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
Mail Server
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="ftp-server">
|
||||
<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
|
||||
</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 class="sidebar-section">
|
||||
<div class="sidebar-section-label">Security</div>
|
||||
<a href="#" class="sidebar-link" data-page="ssl-manager">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
SSL Manager
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="firewall">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
Firewall / Fail2Ban
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="audit-log">
|
||||
<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
|
||||
</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 class="sidebar-section">
|
||||
<div class="sidebar-section-label">System</div>
|
||||
<a href="#" class="sidebar-link" data-page="updates">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
Updates <span id="update-badge" class="badge badge-yellow" style="display:none"></span>
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="backups">
|
||||
<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
|
||||
</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">
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-user">
|
||||
<div class="sidebar-user-info">
|
||||
<div class="avatar" id="user-avatar">A</div>
|
||||
<div>
|
||||
<div class="user-name" id="user-name">Admin</div>
|
||||
<div class="user-role">Administrator</div>
|
||||
</div>
|
||||
<a href="#" id="logout-btn" class="btn btn-ghost btn-sm btn-icon" title="Logout" style="margin-left:auto">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
<header class="topbar">
|
||||
<button class="btn btn-ghost btn-icon" id="sidebar-toggle" style="display:none">☰</button>
|
||||
<div class="topbar-title" id="page-title">Dashboard</div>
|
||||
<div class="topbar-actions">
|
||||
<span id="server-ip" class="text-muted text-sm"></span>
|
||||
<div id="alert-indicator" style="display:none">
|
||||
<span class="badge badge-red" id="alert-count"></span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page-content" id="page-content">
|
||||
<!-- Loaded by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth guard / Login overlay (shown when not authenticated) -->
|
||||
<div id="auth-check" style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg)">
|
||||
<div style="width:100%;max-width:400px;padding:1.5rem">
|
||||
<div style="text-align:center;margin-bottom:1.5rem">
|
||||
<svg viewBox="0 0 40 40" fill="none" style="width:40px;height:40px;margin:0 auto 1rem">
|
||||
<circle cx="20" cy="20" r="18" stroke="url(#alg1)" stroke-width="2"/>
|
||||
<path d="M12 28 L20 8 L28 28" stroke="url(#alg2)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 22 H26" stroke="url(#alg2)" stroke-width="2" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="alg1" x1="2" y1="2" x2="38" y2="38"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
<linearGradient id="alg2" x1="12" y1="8" x2="28" y2="28"><stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#0ea5e9"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div style="font-size:1.4rem;font-weight:300">Nova<strong style="font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent">CPX</strong></div>
|
||||
<div style="font-size:.78rem;color:var(--text-muted);margin-top:.25rem;text-transform:uppercase;letter-spacing:.1em">Admin Panel · Port 8882</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div id="login-err" class="alert alert-error" style="display:none"></div>
|
||||
<form id="login-form">
|
||||
<div class="form-group"><label>Username or Email</label><input type="text" id="l-user" autofocus required></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="l-pass" required></div>
|
||||
<button type="submit" class="btn btn-primary btn-full" id="l-btn">Sign In to Admin</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/nova.js"></script>
|
||||
<script src="/assets/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -37,7 +37,7 @@ match ($action) {
|
||||
p.name as package_name,
|
||||
(SELECT COUNT(*) FROM domains WHERE account_id = a.id) as domain_count,
|
||||
(SELECT COUNT(*) FROM email_accounts WHERE account_id = a.id) as email_count,
|
||||
(SELECT COUNT(*) FROM databases WHERE account_id = a.id) as db_count
|
||||
(SELECT COUNT(*) FROM `databases` WHERE account_id = a.id) as db_count
|
||||
FROM accounts a
|
||||
JOIN users u ON u.id = a.user_id
|
||||
LEFT JOIN packages p ON p.id = a.package_id
|
||||
@@ -132,7 +132,7 @@ match ($action) {
|
||||
'disk_limit_mb' => $pkg['disk_mb'] ?? 0,
|
||||
'email_count' => $db->fetchOne("SELECT COUNT(*) c FROM email_accounts WHERE account_id = ?", [$id])['c'],
|
||||
'email_limit' => $pkg['max_email'] ?? 0,
|
||||
'db_count' => $db->fetchOne("SELECT COUNT(*) c FROM databases WHERE account_id = ?", [$id])['c'],
|
||||
'db_count' => $db->fetchOne("SELECT COUNT(*) c FROM `databases` WHERE account_id = ?", [$id])['c'],
|
||||
'db_limit' => $pkg['max_databases'] ?? 0,
|
||||
'domain_count' => $db->fetchOne("SELECT COUNT(*) c FROM domains WHERE account_id = ?", [$id])['c'],
|
||||
'domain_limit' => $pkg['max_domains'] ?? 0,
|
||||
|
||||
@@ -4,15 +4,18 @@ $body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
require_once NOVACPX_LIB . '/DatabaseManager.php';
|
||||
|
||||
$user = Auth::getInstance()->user();
|
||||
$accountId = $user['role'] === 'user'
|
||||
? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0)
|
||||
: (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
|
||||
if ($user['role'] === 'user') {
|
||||
$accountId = (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0);
|
||||
} else {
|
||||
$accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
|
||||
if ($accountId && $user['role'] === 'reseller') assert_account_access($accountId);
|
||||
}
|
||||
|
||||
match ($action) {
|
||||
|
||||
'list' => (function() use ($db, $accountId) {
|
||||
if (!$accountId) Response::error("account_id required");
|
||||
$rows = $db->fetchAll("SELECT id, db_name, db_user, db_type, size_mb, created_at FROM databases WHERE account_id = ?", [$accountId]);
|
||||
$rows = $db->fetchAll("SELECT id, db_name, db_user, db_type, size_mb, created_at FROM `databases` WHERE account_id = ?", [$accountId]);
|
||||
foreach ($rows as &$r) { $r['size_mb'] = DatabaseManager::getSize($r['db_name'], $r['db_type']); }
|
||||
Response::success($rows);
|
||||
})(),
|
||||
@@ -22,7 +25,7 @@ match ($action) {
|
||||
// Package limit check
|
||||
$acctPkg = $db->fetchOne("SELECT p.max_databases FROM accounts a LEFT JOIN packages p ON p.id=a.package_id WHERE a.id=?", [$accountId]);
|
||||
if ($acctPkg && $acctPkg['max_databases'] > 0) {
|
||||
$count = (int)$db->fetchOne("SELECT COUNT(*) c FROM databases WHERE account_id=?", [$accountId])['c'];
|
||||
$count = (int)$db->fetchOne("SELECT COUNT(*) c FROM `databases` WHERE account_id=?", [$accountId])['c'];
|
||||
if ($count >= (int)$acctPkg['max_databases']) Response::error("Database limit ({$acctPkg['max_databases']}) reached for this package", 403);
|
||||
}
|
||||
$type = $body['type'] ?? 'mysql';
|
||||
@@ -47,7 +50,7 @@ match ($action) {
|
||||
|
||||
'drop' => (function() use ($db, $body) {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
$dbe = $db->fetchOne("SELECT db_name, db_user, db_type FROM databases WHERE id = ?", [$id]);
|
||||
$dbe = $db->fetchOne("SELECT db_name, db_user, db_type FROM `databases` WHERE id = ?", [$id]);
|
||||
if (!$dbe) Response::error("Database not found", 404);
|
||||
DatabaseManager::drop($dbe['db_name'], $dbe['db_user'], $dbe['db_type']);
|
||||
audit('database.drop', $dbe['db_name']);
|
||||
|
||||
@@ -12,6 +12,7 @@ if ($user['role'] === 'user') {
|
||||
$accountId = $acct ? (int)$acct['id'] : null;
|
||||
} else {
|
||||
$accountId = (int)($_GET['account_id'] ?? $body['account_id'] ?? 0) ?: null;
|
||||
if ($accountId && $user['role'] === 'reseller') assert_account_access($accountId);
|
||||
}
|
||||
|
||||
match ($action) {
|
||||
|
||||
@@ -8,9 +8,12 @@ require_once NOVACPX_LIB . '/VhostManager.php';
|
||||
require_once NOVACPX_LIB . '/DNSManager.php';
|
||||
|
||||
$user = Auth::getInstance()->user();
|
||||
$accountId = $user['role'] === 'user'
|
||||
? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0)
|
||||
: (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
|
||||
if ($user['role'] === 'user') {
|
||||
$accountId = (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0);
|
||||
} else {
|
||||
$accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
|
||||
if ($accountId && $user['role'] === 'reseller') assert_account_access($accountId);
|
||||
}
|
||||
|
||||
if (!$accountId) Response::error("account_id required");
|
||||
$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$accountId]);
|
||||
@@ -18,7 +21,7 @@ if (!$acct) Response::error("Account not found", 404);
|
||||
|
||||
match ($action) {
|
||||
'list' => (function() use ($db, $accountId) {
|
||||
$rows = $db->fetchAll("SELECT * FROM domains WHERE account_id = ? ORDER BY is_primary DESC, domain", [$accountId]);
|
||||
$rows = $db->fetchAll("SELECT * FROM domains WHERE account_id = ? ORDER BY (type='main') DESC, domain", [$accountId]);
|
||||
Response::success($rows);
|
||||
})(),
|
||||
|
||||
@@ -30,11 +33,11 @@ match ($action) {
|
||||
$exists = $db->fetchOne("SELECT id FROM domains WHERE domain = ?", [$domain]);
|
||||
if ($exists) Response::error("Domain already exists");
|
||||
|
||||
$db->execute(
|
||||
"INSERT INTO domains (account_id, domain, type, doc_root, created_at) VALUES (?,?,?,?,NOW())",
|
||||
[$accountId, $domain, 'addon', $acct['home_dir'] . '/public_html/' . $domain]
|
||||
);
|
||||
$docRoot = $acct['home_dir'] . '/public_html/' . $domain;
|
||||
$db->execute(
|
||||
"INSERT INTO domains (account_id, domain, type, document_root, created_at) VALUES (?,?,?,?,NOW())",
|
||||
[$accountId, $domain, 'addon', $docRoot]
|
||||
);
|
||||
@mkdir($docRoot, 0755, true);
|
||||
file_put_contents("$docRoot/index.html", "<html><body><h1>$domain is ready!</h1><p>Upload your website files.</p></body></html>");
|
||||
|
||||
@@ -45,7 +48,7 @@ match ($action) {
|
||||
'doc_root' => $docRoot,
|
||||
'php_ver' => $acct['php_version'] ?? PHP_DEFAULT,
|
||||
]);
|
||||
DNSManager::createZone($domain, gethostbyname(gethostname()));
|
||||
DNSManager::createZone($accountId, $domain);
|
||||
audit('domains.add-addon', $domain);
|
||||
Response::success(null, "Addon domain $domain added");
|
||||
})(),
|
||||
@@ -61,11 +64,18 @@ match ($action) {
|
||||
file_put_contents("$docRoot/index.html", "<html><body><h1>$full</h1></body></html>");
|
||||
|
||||
$db->execute(
|
||||
"INSERT INTO domains (account_id, domain, type, doc_root, created_at) VALUES (?,?,?,?,NOW())",
|
||||
"INSERT INTO domains (account_id, domain, type, document_root, created_at) VALUES (?,?,?,?,NOW())",
|
||||
[$accountId, $full, 'subdomain', $docRoot]
|
||||
);
|
||||
VhostManager::createSubdomain($full, $acct['username'], $docRoot, $acct['php_version'] ?? PHP_DEFAULT);
|
||||
DNSManager::addRecord($parent, $sub, 'A', gethostbyname(gethostname()));
|
||||
VhostManager::create([
|
||||
'domain' => $full,
|
||||
'username' => $acct['username'],
|
||||
'home_dir' => $acct['home_dir'],
|
||||
'doc_root' => $docRoot,
|
||||
'php_ver' => $acct['php_version'] ?? PHP_DEFAULT,
|
||||
]);
|
||||
$zone = DB::getInstance()->fetchOne("SELECT id FROM dns_zones WHERE domain = ? AND account_id = ?", [$parent, $accountId]);
|
||||
if ($zone) DNSManager::addRecord((int)$zone['id'], $sub, 'A', gethostbyname(gethostname()));
|
||||
audit('domains.add-subdomain', $full);
|
||||
Response::success(null, "Subdomain $full created");
|
||||
})(),
|
||||
@@ -76,10 +86,9 @@ match ($action) {
|
||||
if (!$alias) Response::error("domain required");
|
||||
|
||||
$db->execute(
|
||||
"INSERT INTO domains (account_id, domain, type, doc_root, created_at) VALUES (?,?,?,?,NOW())",
|
||||
"INSERT INTO domains (account_id, domain, type, document_root, created_at) VALUES (?,?,?,?,NOW())",
|
||||
[$accountId, $alias, 'alias', $acct['home_dir'] . '/public_html']
|
||||
);
|
||||
// Create vhost that serves same doc root as primary
|
||||
VhostManager::create([
|
||||
'domain' => $alias,
|
||||
'username' => $acct['username'],
|
||||
@@ -87,7 +96,7 @@ match ($action) {
|
||||
'doc_root' => $acct['home_dir'] . '/public_html',
|
||||
'php_ver' => $acct['php_version'] ?? PHP_DEFAULT,
|
||||
]);
|
||||
DNSManager::createZone($alias, gethostbyname(gethostname()));
|
||||
DNSManager::createZone($accountId, $alias);
|
||||
audit('domains.add-alias', $alias);
|
||||
Response::success(null, "Domain alias $alias added");
|
||||
})(),
|
||||
@@ -97,7 +106,7 @@ match ($action) {
|
||||
$url = trim($body['redirect_url'] ?? '');
|
||||
$code = in_array((int)($body['code'] ?? 301), [301, 302]) ? (int)$body['code'] : 301;
|
||||
if (!$url) Response::error("redirect_url required");
|
||||
$db->execute("UPDATE domains SET redirect_url=?, redirect_code=? WHERE id=? AND account_id=?", [$url, $code, $id, $accountId]);
|
||||
$db->execute("UPDATE domains SET redirect_to=? WHERE id=? AND account_id=?", [$url, $id, $accountId]);
|
||||
// Update vhost to add Redirect directive
|
||||
$dom = $db->fetchOne("SELECT * FROM domains WHERE id = ?", [$id]);
|
||||
if ($dom) {
|
||||
@@ -110,7 +119,7 @@ match ($action) {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
$dom = $db->fetchOne("SELECT * FROM domains WHERE id = ? AND account_id = ?", [$id, $accountId]);
|
||||
if (!$dom) Response::error("Domain not found", 404);
|
||||
if ($dom['is_primary']) Response::error("Cannot remove primary domain");
|
||||
if ($dom['type'] === 'main') Response::error("Cannot remove primary domain");
|
||||
|
||||
VhostManager::remove($dom['domain']);
|
||||
if ($dom['type'] !== 'subdomain') DNSManager::removeZone($dom['domain']);
|
||||
|
||||
@@ -11,7 +11,9 @@ function self_account_id($db, $user): ?int {
|
||||
$a = $db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']]);
|
||||
return $a ? (int)$a['id'] : null;
|
||||
}
|
||||
return (int)(request_param('account_id') ?? 0) ?: null;
|
||||
$id = (int)(request_param('account_id') ?? 0);
|
||||
if ($id && $user['role'] === 'reseller') assert_account_access($id);
|
||||
return $id ?: null;
|
||||
}
|
||||
function request_param(string $k): mixed { return $_GET[$k] ?? (json_decode(file_get_contents('php://input'), true) ?? [])[$k] ?? null; }
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ $body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
require_once NOVACPX_LIB . '/FTPManager.php';
|
||||
|
||||
$user = Auth::getInstance()->user();
|
||||
$accountId = $user['role'] === 'user'
|
||||
? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0)
|
||||
: (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
|
||||
if ($user['role'] === 'user') {
|
||||
$accountId = (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0);
|
||||
} else {
|
||||
$accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
|
||||
if ($accountId && $user['role'] === 'reseller') assert_account_access($accountId);
|
||||
}
|
||||
|
||||
match ($action) {
|
||||
'list' => (function() use ($db, $accountId) {
|
||||
|
||||
@@ -5,9 +5,12 @@ require_once NOVACPX_LIB . '/SSLManager.php';
|
||||
require_once NOVACPX_LIB . '/VhostManager.php';
|
||||
|
||||
$user = Auth::getInstance()->user();
|
||||
$accountId = $user['role'] === 'user'
|
||||
? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0)
|
||||
: (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
|
||||
if ($user['role'] === 'user') {
|
||||
$accountId = (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0);
|
||||
} else {
|
||||
$accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
|
||||
if ($accountId && $user['role'] === 'reseller') assert_account_access($accountId);
|
||||
}
|
||||
|
||||
match ($action) {
|
||||
'list' => (function() use ($db, $accountId) {
|
||||
|
||||
@@ -49,7 +49,13 @@ match ($action) {
|
||||
})(),
|
||||
|
||||
'account' => (function() use ($db, $body) {
|
||||
$accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
|
||||
$user = Auth::getInstance()->user();
|
||||
if ($user['role'] === 'user') {
|
||||
$acctRow = $db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']]);
|
||||
$accountId = $acctRow ? (int)$acctRow['id'] : 0;
|
||||
} else {
|
||||
$accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
|
||||
}
|
||||
if (!$accountId) Response::error("account_id required");
|
||||
$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$accountId]);
|
||||
if (!$acct) Response::error("Account not found", 404);
|
||||
@@ -62,7 +68,7 @@ match ($action) {
|
||||
$inodes = (int)trim(shell_exec("find " . escapeshellarg($acct['home_dir']) . " 2>/dev/null | wc -l") ?: 0);
|
||||
|
||||
// DB count & size
|
||||
$dbs = $db->fetchAll("SELECT id FROM databases WHERE account_id = ?", [$accountId]);
|
||||
$dbs = $db->fetchAll("SELECT id FROM `databases` WHERE account_id = ?", [$accountId]);
|
||||
$dbCount = count($dbs);
|
||||
|
||||
// Email count
|
||||
|
||||
@@ -90,4 +90,24 @@ if (!file_exists($endpointFile)) {
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* Verify the current user can access a given account_id.
|
||||
* Returns the account row or sends a 404 error response.
|
||||
* Resellers may only access their own customers; users may only access their own account.
|
||||
*/
|
||||
function assert_account_access(int $accountId): array {
|
||||
global $currentUser;
|
||||
$db = DB::getInstance();
|
||||
$acct = $db->fetchOne("SELECT a.*, u.reseller_id FROM accounts a JOIN users u ON u.id = a.user_id WHERE a.id = ?", [$accountId]);
|
||||
if (!$acct) Response::error("Account not found", 404);
|
||||
if ($currentUser['role'] === 'reseller' && (int)$acct['reseller_id'] !== $currentUser['uid']) {
|
||||
Response::error("Account not found", 404);
|
||||
}
|
||||
if ($currentUser['role'] === 'user') {
|
||||
$own = $db->fetchOne("SELECT id FROM accounts WHERE id = ? AND user_id = ?", [$accountId, $currentUser['uid']]);
|
||||
if (!$own) Response::error("Account not found", 404);
|
||||
}
|
||||
return $acct;
|
||||
}
|
||||
|
||||
require $endpointFile;
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
// ── 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);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,246 @@
|
||||
|
||||
// ── Nginx Proxy Manager ───────────────────────────────────────────────────────
|
||||
async function nginxProxy() {
|
||||
const [statusR, hostsR] = await Promise.all([
|
||||
Nova.api('proxy', 'status'),
|
||||
Nova.api('proxy', 'hosts'),
|
||||
]);
|
||||
const s = statusR?.data || {};
|
||||
const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []);
|
||||
const run = s.running;
|
||||
const inst = s.installed;
|
||||
|
||||
return `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Nginx Proxy Manager</h1>
|
||||
<div class="page-actions">
|
||||
${inst ? `
|
||||
<button class="btn btn-ghost btn-sm" onclick="proxySetupInstructions()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
|
||||
Setup Guide
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="proxySync()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
Sync Accounts
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" onclick="proxyAddHost()">+ Add Host</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid" style="margin-bottom:1.5rem">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Nginx Status</div>
|
||||
<div class="stat-value ${run ? 'stat-green' : 'stat-red'}">${inst ? (run ? 'Running' : 'Stopped') : 'Not Installed'}</div>
|
||||
<div class="stat-sub">${s.version || (inst ? 'nginx' : 'click Install to set up')}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Proxy Hosts</div>
|
||||
<div class="stat-value">${hosts.length}</div>
|
||||
<div class="stat-sub">${hosts.filter(h => h.enabled).length} active</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">SSL Enabled</div>
|
||||
<div class="stat-value">${hosts.filter(h => h.ssl_enabled).length}</div>
|
||||
<div class="stat-sub">of ${hosts.length} hosts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${!inst ? `
|
||||
<div class="panel" style="text-align:center;padding:3rem">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="48" height="48" style="color:var(--text-muted);margin-bottom:1rem"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||
<h3 style="margin-bottom:0.5rem">Nginx Not Installed</h3>
|
||||
<p style="color:var(--text-muted);margin-bottom:1.5rem">Install Nginx on this VM to use it as a reverse proxy in front of Apache, or use a separate proxy VM (see Setup Guide).</p>
|
||||
<div style="display:flex;gap:0.75rem;justify-content:center;flex-wrap:wrap">
|
||||
<button class="btn btn-primary" onclick="proxyInstall()">Install Nginx Locally</button>
|
||||
<button class="btn btn-secondary" onclick="proxySetupInstructions()">Setup Guide / Remote VM</button>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<div class="panel" style="margin-bottom:1.5rem">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Service Controls</h3>
|
||||
<div style="display:flex;gap:0.5rem">
|
||||
<button class="btn btn-sm btn-success" onclick="proxyControl('start')">Start</button>
|
||||
<button class="btn btn-sm btn-warning" onclick="proxyControl('restart')">Restart</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="proxyControl('stop')">Stop</button>
|
||||
<button class="btn btn-sm btn-ghost" onclick="proxyControl('reload')">Reload Config</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Proxy Hosts</h3>
|
||||
<span class="badge badge-blue">${hosts.length} total</span>
|
||||
</div>
|
||||
${hosts.length === 0 ? `
|
||||
<div style="text-align:center;padding:2rem;color:var(--text-muted)">
|
||||
No proxy hosts yet. Click <strong>Sync Accounts</strong> to auto-add all hosted domains, or <strong>+ Add Host</strong> to add manually.
|
||||
</div>
|
||||
` : `
|
||||
<div style="overflow-x:auto">
|
||||
<table class="table">
|
||||
<thead><tr>
|
||||
<th>Domain</th>
|
||||
<th>Upstream</th>
|
||||
<th>SSL</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${hosts.map(h => `
|
||||
<tr id="proxy-row-${h.id}">
|
||||
<td><strong>${Nova.escHtml(h.domain)}</strong></td>
|
||||
<td style="font-family:monospace;font-size:0.8rem">${Nova.escHtml(h.upstream)}</td>
|
||||
<td>${h.ssl_enabled ? Nova.badge('SSL','green') : Nova.badge('HTTP','muted')}</td>
|
||||
<td>${h.enabled ? Nova.badge('Active','green') : Nova.badge('Disabled','red')}</td>
|
||||
<td>
|
||||
<button class="btn btn-xs btn-ghost" onclick="proxyEditHost(${h.id})">Edit</button>
|
||||
<button class="btn btn-xs ${h.enabled ? 'btn-warning' : 'btn-success'}" onclick="proxyToggle(${h.id},${h.enabled ? 0 : 1})">${h.enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="proxyDeleteHost(${h.id},'${Nova.escHtml(h.domain)}')">Delete</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`}`;
|
||||
}
|
||||
|
||||
window.proxyInstall = async () => {
|
||||
if (!confirm('Install Nginx on this VM? This will run apt-get install nginx.')) return;
|
||||
Nova.toast('Installing nginx...', 'info');
|
||||
const r = await Nova.api('proxy', 'install', { method: 'POST' });
|
||||
Nova.toast(r?.data?.result || r?.message || 'Done', r?.data?.result === 'installed' ? 'success' : 'info');
|
||||
Nova.loadPage('nginx-proxy', window._novaPages);
|
||||
};
|
||||
|
||||
window.proxyControl = async (action) => {
|
||||
const r = await Nova.api('proxy', 'control', { method: 'POST', body: { action } });
|
||||
Nova.toast(r?.data?.result || r?.message || action + ' done', 'success');
|
||||
setTimeout(() => Nova.loadPage('nginx-proxy', window._novaPages), 800);
|
||||
};
|
||||
|
||||
window.proxySync = async () => {
|
||||
const r = await Nova.api('proxy', 'sync', { method: 'POST' });
|
||||
Nova.toast(`Synced: ${r?.data?.added ?? 0} new hosts added`, 'success');
|
||||
Nova.loadPage('nginx-proxy', window._novaPages);
|
||||
};
|
||||
|
||||
window.proxyAddHost = () => {
|
||||
Nova.modal('Add Proxy Host', `
|
||||
<div class="form-group"><label>Domain</label>
|
||||
<input id="ph-domain" type="text" placeholder="example.com" class="form-control"></div>
|
||||
<div class="form-group"><label>Upstream URL</label>
|
||||
<input id="ph-upstream" type="text" value="http://127.0.0.1:80" class="form-control">
|
||||
<small class="text-muted">e.g. http://127.0.0.1:80 or http://10.0.0.2:8080</small></div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" id="ph-ssl"> Enable SSL</label></div>
|
||||
<div class="form-group"><label>Notes (optional)</label>
|
||||
<input id="ph-notes" type="text" class="form-control"></div>
|
||||
`, async () => {
|
||||
const domain = document.getElementById('ph-domain')?.value?.trim();
|
||||
const upstream = document.getElementById('ph-upstream')?.value?.trim();
|
||||
if (!domain || !upstream) { Nova.toast('Domain and upstream required', 'error'); return; }
|
||||
const r = await Nova.api('proxy', 'hosts', {
|
||||
method: 'POST',
|
||||
body: { domain, upstream, ssl_enabled: document.getElementById('ph-ssl')?.checked ? 1 : 0 }
|
||||
});
|
||||
Nova.toast(r?.success ? 'Host added' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages);
|
||||
});
|
||||
};
|
||||
|
||||
window.proxyEditHost = async (id) => {
|
||||
const hostsR = await Nova.api('proxy', 'hosts');
|
||||
const hosts = hostsR?.data || (Array.isArray(hostsR) ? hostsR : []);
|
||||
const h = hosts.find(x => x.id == id);
|
||||
if (!h) return;
|
||||
Nova.modal('Edit Proxy Host', `
|
||||
<div class="form-group"><label>Domain</label>
|
||||
<input id="phe-domain" type="text" value="${Nova.escHtml(h.domain)}" class="form-control"></div>
|
||||
<div class="form-group"><label>Upstream URL</label>
|
||||
<input id="phe-upstream" type="text" value="${Nova.escHtml(h.upstream)}" class="form-control"></div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" id="phe-ssl" ${h.ssl_enabled ? 'checked' : ''}> Enable SSL</label></div>
|
||||
<div class="form-group"><label>Custom Nginx Config (overrides auto-generated)</label>
|
||||
<textarea id="phe-custom" rows="6" class="form-control" style="font-family:monospace;font-size:0.78rem">${Nova.escHtml(h.custom_config || '')}</textarea>
|
||||
<small class="text-muted">Leave blank to use auto-generated config</small></div>
|
||||
`, async () => {
|
||||
const r = await Nova.api('proxy', `hosts/${id}`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
domain: document.getElementById('phe-domain')?.value?.trim(),
|
||||
upstream: document.getElementById('phe-upstream')?.value?.trim(),
|
||||
ssl_enabled: document.getElementById('phe-ssl')?.checked ? 1 : 0,
|
||||
custom_config: document.getElementById('phe-custom')?.value?.trim() || null,
|
||||
}
|
||||
});
|
||||
Nova.toast(r?.success ? 'Updated' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages);
|
||||
});
|
||||
};
|
||||
|
||||
window.proxyToggle = async (id, enable) => {
|
||||
const r = await Nova.api('proxy', `hosts/${id}/toggle`, { method: 'POST', body: { enabled: enable } });
|
||||
Nova.toast(r?.success ? (enable ? 'Enabled' : 'Disabled') : 'Failed', r?.success ? 'success' : 'error');
|
||||
if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages);
|
||||
};
|
||||
|
||||
window.proxyDeleteHost = (id, domain) => {
|
||||
Nova.confirm(`Delete proxy host for ${domain}?`, async () => {
|
||||
const r = await Nova.api('proxy', `hosts/${id}`, { method: 'DELETE' });
|
||||
Nova.toast(r?.success ? 'Deleted' : 'Failed', r?.success ? 'success' : 'error');
|
||||
if (r?.success) Nova.loadPage('nginx-proxy', window._novaPages);
|
||||
}, true);
|
||||
};
|
||||
|
||||
window.proxySetupInstructions = async () => {
|
||||
const scriptUrl = '/api/proxy/setup-script';
|
||||
Nova.modal('Nginx Proxy Setup Guide', `
|
||||
<div style="max-height:60vh;overflow-y:auto">
|
||||
<h4 style="margin-bottom:0.75rem">Option A — Local (Nginx on this VM)</h4>
|
||||
<p style="color:var(--text-muted);margin-bottom:1rem">Install Nginx alongside Apache on this VM. Nginx listens on ports 80/443 and forwards to Apache. Best for SSL termination and caching.</p>
|
||||
<ol style="color:var(--text-muted);margin-bottom:1.5rem;padding-left:1.2rem;line-height:1.8">
|
||||
<li>Click <strong>Install Nginx Locally</strong> on the main Nginx Proxy page</li>
|
||||
<li>Move Apache to port 8080: edit <code>/etc/apache2/ports.conf</code> → change <code>Listen 80</code> to <code>Listen 8080</code></li>
|
||||
<li>Update upstream in all proxy hosts to <code>http://127.0.0.1:8080</code></li>
|
||||
<li>Click <strong>Sync Accounts</strong> to auto-populate proxy hosts from your hosted accounts</li>
|
||||
<li>Click <strong>Reload Config</strong> to apply changes</li>
|
||||
</ol>
|
||||
|
||||
<h4 style="margin-bottom:0.75rem">Option B — Remote Proxy VM (Recommended for production)</h4>
|
||||
<p style="color:var(--text-muted);margin-bottom:1rem">Run a dedicated Nginx proxy VM in front of this NovaCPX VM. Traffic flows: Internet → FortiGate → Nginx Proxy VM → NovaCPX VM (Apache).</p>
|
||||
<ol style="color:var(--text-muted);margin-bottom:1.5rem;padding-left:1.2rem;line-height:1.8">
|
||||
<li>Create a new VM on Proxmox (Ubuntu 22.04, 1 vCPU, 1GB RAM)</li>
|
||||
<li>Run the setup script below on the new VM as root</li>
|
||||
<li>Point FortiGate VIPs to the proxy VM IP (ports 80/443)</li>
|
||||
<li>Set the proxy upstream to this NovaCPX VM IP (<code>http://10.48.200.110:80</code>)</li>
|
||||
<li>Add proxy hosts for each domain from your NovaCPX admin panel</li>
|
||||
</ol>
|
||||
|
||||
<h4 style="margin-bottom:0.75rem">Automated Setup Script</h4>
|
||||
<p style="color:var(--text-muted);margin-bottom:0.75rem">Run this on the target VM (local or remote) as root:</p>
|
||||
<div style="background:var(--bg-secondary);padding:0.75rem;border-radius:6px;font-family:monospace;font-size:0.8rem;margin-bottom:0.75rem">
|
||||
curl -sk https://YOUR_NOVACPX_IP:8882/api/proxy/setup-script | bash
|
||||
</div>
|
||||
<p style="color:var(--text-muted);font-size:0.85rem">Or download and review before running:</p>
|
||||
<div style="background:var(--bg-secondary);padding:0.75rem;border-radius:6px;font-family:monospace;font-size:0.8rem">
|
||||
curl -sk https://YOUR_NOVACPX_IP:8882/api/proxy/setup-script -o proxy-setup.sh<br>
|
||||
cat proxy-setup.sh # review<br>
|
||||
bash proxy-setup.sh
|
||||
</div>
|
||||
|
||||
<h4 style="margin-bottom:0.75rem;margin-top:1.5rem">Integration with VirtualHost Manager</h4>
|
||||
<p style="color:var(--text-muted);margin-bottom:0.75rem">When proxy mode is active, NovaCPX automatically:</p>
|
||||
<ul style="color:var(--text-muted);padding-left:1.2rem;line-height:1.8">
|
||||
<li>Creates a proxy host entry for every new account</li>
|
||||
<li>Removes the proxy host when an account is terminated</li>
|
||||
<li>Re-generates Nginx config on every account change</li>
|
||||
<li>Uses account SSL certs automatically if SSL is enabled on the proxy host</li>
|
||||
</ul>
|
||||
</div>
|
||||
`, null, { cancelLabel: 'Close', showConfirm: false });
|
||||
};
|
||||
@@ -15,7 +15,7 @@ class DatabaseManager {
|
||||
$pdo->exec("FLUSH PRIVILEGES");
|
||||
|
||||
return (int)$db->insert(
|
||||
"INSERT INTO databases (account_id, db_name, db_user, db_pass, db_type) VALUES (?,?,?,?,?)",
|
||||
"INSERT INTO `databases`(account_id, db_name, db_user, db_pass, db_type) VALUES (?,?,?,?,?)",
|
||||
[$accountId, $dbName, $dbUser, encrypt($dbPass), 'mysql']
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ class DatabaseManager {
|
||||
shell_exec("sudo -u postgres createdb -O {$dbUser} {$dbName} 2>/dev/null");
|
||||
|
||||
return (int)$db->insert(
|
||||
"INSERT INTO databases (account_id, db_name, db_user, db_pass, db_type) VALUES (?,?,?,?,?)",
|
||||
"INSERT INTO `databases`(account_id, db_name, db_user, db_pass, db_type) VALUES (?,?,?,?,?)",
|
||||
[$accountId, $dbName, $dbUser, encrypt($dbPass), 'postgresql']
|
||||
);
|
||||
}
|
||||
@@ -43,12 +43,12 @@ class DatabaseManager {
|
||||
shell_exec("sudo -u postgres dropdb --if-exists " . escapeshellarg($dbName) . " 2>/dev/null");
|
||||
shell_exec("sudo -u postgres dropuser --if-exists " . escapeshellarg($dbUser) . " 2>/dev/null");
|
||||
}
|
||||
DB::getInstance()->execute("DELETE FROM databases WHERE db_name = ? AND db_type = ?", [$dbName, $type]);
|
||||
DB::getInstance()->execute("DELETE FROM `databases` WHERE db_name = ? AND db_type = ?", [$dbName, $type]);
|
||||
}
|
||||
|
||||
public static function changePassword(int $id, string $newPass): void {
|
||||
$db = DB::getInstance();
|
||||
$dbe = $db->fetchOne("SELECT * FROM databases WHERE id = ?", [$id]);
|
||||
$dbe = $db->fetchOne("SELECT * FROM `databases` WHERE id = ?", [$id]);
|
||||
if (!$dbe) throw new RuntimeException("Database not found");
|
||||
if ($dbe['db_type'] === 'mysql') {
|
||||
$pdo = $db->pdo();
|
||||
@@ -56,7 +56,7 @@ class DatabaseManager {
|
||||
} else {
|
||||
shell_exec("sudo -u postgres psql -c \"ALTER USER {$dbe['db_user']} WITH PASSWORD " . escapeshellarg($newPass) . "\" 2>/dev/null");
|
||||
}
|
||||
$db->execute("UPDATE databases SET db_pass = ? WHERE id = ?", [encrypt($newPass), $id]);
|
||||
$db->execute("UPDATE `databases` SET db_pass = ? WHERE id = ?", [encrypt($newPass), $id]);
|
||||
}
|
||||
|
||||
public static function getSize(string $dbName, string $type = 'mysql'): float {
|
||||
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# nova-db.sh — Quick DB access on NovaCPX VM
|
||||
# Usage: bash nova-db.sh [query] (run query and return result)
|
||||
# bash nova-db.sh (open interactive MySQL shell)
|
||||
# bash nova-db.sh --tables (list all tables with row counts)
|
||||
# bash nova-db.sh --users (list all panel users)
|
||||
# bash nova-db.sh --reset-admin <newpass> (reset admin password)
|
||||
|
||||
PVE1_HOST="orbisne.fortiddns.com"
|
||||
PVE1_PASS="Joker1974!!!"
|
||||
VM_IP="10.48.200.110"
|
||||
VM_PASS="Joker1974!!!"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
|
||||
DB="novacpx"
|
||||
|
||||
vm_mysql() {
|
||||
sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST \
|
||||
"sshpass -p '$VM_PASS' ssh $SSH_OPTS root@$VM_IP 'mysql $DB -e \"$1\"'"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--tables)
|
||||
echo "Tables in $DB:"
|
||||
vm_mysql "SELECT table_name, table_rows FROM information_schema.tables WHERE table_schema='$DB' ORDER BY table_name;" 2>&1
|
||||
;;
|
||||
--users)
|
||||
echo "Panel users:"
|
||||
vm_mysql "SELECT id, username, email, role, status, created_at FROM users ORDER BY id;" 2>&1
|
||||
;;
|
||||
--reset-admin)
|
||||
[[ -z "${2:-}" ]] && { echo "Usage: $0 --reset-admin <newpassword>"; exit 1; }
|
||||
NEWPASS="$2"
|
||||
HASH=$(php8.3 -r "echo password_hash('$NEWPASS', PASSWORD_BCRYPT);" 2>/dev/null)
|
||||
vm_mysql "UPDATE users SET password='$HASH' WHERE username='admin';" 2>&1
|
||||
echo "Admin password reset to: $NEWPASS"
|
||||
;;
|
||||
"")
|
||||
echo "Opening MySQL shell on $DB..."
|
||||
sshpass -p "$PVE1_PASS" ssh -t $SSH_OPTS root@$PVE1_HOST \
|
||||
"sshpass -p '$VM_PASS' ssh -t $SSH_OPTS root@$VM_IP 'mysql $DB'"
|
||||
;;
|
||||
*)
|
||||
vm_mysql "$*" 2>&1
|
||||
;;
|
||||
esac
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# nova-deploy.sh — Full panel sync to NovaCPX VM
|
||||
# Usage: bash nova-deploy.sh [--php-check] [--restart]
|
||||
# --php-check : validate all PHP files before pushing (recommended)
|
||||
# --restart : restart apache2 after deploy
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
WEB_ROOT="/srv/novacpx/public"
|
||||
|
||||
PVE1_HOST="orbisne.fortiddns.com"
|
||||
PVE1_PASS="Joker1974!!!"
|
||||
VM_IP="10.48.200.110"
|
||||
VM_PASS="Joker1974!!!"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
|
||||
|
||||
PHP_CHECK=false
|
||||
DO_RESTART=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--php-check) PHP_CHECK=true ;;
|
||||
--restart) DO_RESTART=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# PHP syntax check before deploy
|
||||
if $PHP_CHECK; then
|
||||
echo "Running PHP syntax check..."
|
||||
ERRORS=0
|
||||
while IFS= read -r -d '' f; do
|
||||
php8.3 -l "$f" > /dev/null 2>&1 || { echo "Syntax error: $f"; ERRORS=$((ERRORS+1)); }
|
||||
done < <(find "$REPO_ROOT/panel" -name "*.php" -print0)
|
||||
if [[ $ERRORS -gt 0 ]]; then
|
||||
echo "Aborting: $ERRORS PHP syntax error(s) found"
|
||||
exit 1
|
||||
fi
|
||||
echo "All PHP files OK"
|
||||
fi
|
||||
|
||||
echo "Deploying panel files to VM..."
|
||||
|
||||
# Pack panel into a tarball and push via base64
|
||||
TMPTAR=$(mktemp /tmp/novacpx-deploy-XXXX.tar.gz)
|
||||
tar -czf "$TMPTAR" -C "$REPO_ROOT/panel" public api lib
|
||||
CONTENT=$(base64 -w0 "$TMPTAR")
|
||||
rm -f "$TMPTAR"
|
||||
|
||||
sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST \
|
||||
"sshpass -p '$VM_PASS' ssh $SSH_OPTS root@$VM_IP \
|
||||
\"echo '$CONTENT' | base64 -d > /tmp/novacpx-deploy.tar.gz && \
|
||||
tar -xzf /tmp/novacpx-deploy.tar.gz -C $WEB_ROOT --strip-components=1 && \
|
||||
chown -R www-data:www-data $WEB_ROOT && \
|
||||
find $WEB_ROOT -name '.htaccess' -exec chmod 644 {} \\; && \
|
||||
rm /tmp/novacpx-deploy.tar.gz && \
|
||||
echo 'Deploy complete'\""
|
||||
|
||||
if $DO_RESTART; then
|
||||
echo "Restarting web server..."
|
||||
sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST \
|
||||
"sshpass -p '$VM_PASS' ssh $SSH_OPTS root@$VM_IP \
|
||||
'systemctl reload apache2 && systemctl reload php8.3-fpm && echo Reloaded'"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Deploy done. Panel: https://$VM_IP:8882"
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# nova-github.sh — Quick GitHub push for NovaCPX repo
|
||||
# Usage: bash nova-github.sh "commit message" (add all, commit, push)
|
||||
# bash nova-github.sh --status (git status + diff --stat)
|
||||
# bash nova-github.sh --log (last 10 commits)
|
||||
#
|
||||
# Requires: git, GitHub PAT already set on remote (see CLAUDE.md)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
|
||||
cd "$REPO_ROOT" || { echo "Cannot cd to repo root: $REPO_ROOT"; exit 1; }
|
||||
|
||||
case "${1:-}" in
|
||||
--status)
|
||||
git status
|
||||
echo ""
|
||||
git diff --stat
|
||||
;;
|
||||
--log)
|
||||
git log --oneline -10
|
||||
;;
|
||||
"")
|
||||
echo "Usage: $0 \"commit message\""
|
||||
echo " $0 --status"
|
||||
echo " $0 --log"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
MSG="$*"
|
||||
# PHP syntax check before commit
|
||||
echo "Running PHP syntax check..."
|
||||
if ! bash "$SCRIPT_DIR/nova-phpcheck.sh" > /dev/null 2>&1; then
|
||||
echo -e "${RED}[✗]${NC} PHP syntax errors found. Run: bash tools/nova-phpcheck.sh --fix"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}[✓]${NC} PHP OK"
|
||||
|
||||
git add -A
|
||||
git status --short
|
||||
echo ""
|
||||
git commit -m "$MSG
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||
git push origin main
|
||||
echo ""
|
||||
echo -e "${GREEN}[✓]${NC} Pushed. Auto-deploy will trigger within ~1 min."
|
||||
;;
|
||||
esac
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# nova-logs.sh — Stream/view NovaCPX logs from VM
|
||||
# Usage: bash nova-logs.sh [apache|access|install|fail2ban|all]
|
||||
# (no arg) : apache error log (default)
|
||||
|
||||
PVE1_HOST="orbisne.fortiddns.com"
|
||||
PVE1_PASS="Joker1974!!!"
|
||||
VM_IP="10.48.200.110"
|
||||
VM_PASS="Joker1974!!!"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
|
||||
|
||||
TARGET="${1:-apache}"
|
||||
|
||||
case "$TARGET" in
|
||||
apache) LOG_CMD="tail -f /var/log/apache2/error.log" ;;
|
||||
access) LOG_CMD="tail -f /var/log/novacpx/access.log" ;;
|
||||
install) LOG_CMD="tail -100 /var/log/novacpx-install.log" ;;
|
||||
fail2ban) LOG_CMD="tail -f /var/log/fail2ban.log" ;;
|
||||
all) LOG_CMD="tail -f /var/log/apache2/error.log /var/log/novacpx/access.log" ;;
|
||||
*) echo "Unknown log: $TARGET. Options: apache|access|install|fail2ban|all"; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo "Streaming $TARGET logs from VM $VM_IP..."
|
||||
echo "(Ctrl+C to stop)"
|
||||
echo ""
|
||||
|
||||
sshpass -p "$PVE1_PASS" ssh -t $SSH_OPTS root@$PVE1_HOST \
|
||||
"sshpass -p '$VM_PASS' ssh -t $SSH_OPTS root@$VM_IP '$LOG_CMD'"
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# nova-phpcheck.sh — PHP static analysis on all panel PHP files
|
||||
# Usage: bash nova-phpcheck.sh [path] (default: ../panel)
|
||||
# bash nova-phpcheck.sh --fix (show errors + line context)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET="${1:-$SCRIPT_DIR/../panel}"
|
||||
FIX=false
|
||||
[[ "${1:-}" == "--fix" ]] && { FIX=true; TARGET="${2:-$SCRIPT_DIR/../panel}"; }
|
||||
|
||||
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
|
||||
PHP_BIN=$(command -v php8.3 || command -v php || echo "")
|
||||
[[ -z "$PHP_BIN" ]] && { echo "No PHP binary found. Install php8.3."; exit 1; }
|
||||
|
||||
echo "Checking PHP syntax in: $TARGET"
|
||||
echo "PHP: $($PHP_BIN --version | head -1)"
|
||||
echo ""
|
||||
|
||||
ERRORS=0; FILES=0
|
||||
while IFS= read -r -d '' file; do
|
||||
FILES=$((FILES+1))
|
||||
OUTPUT=$("$PHP_BIN" -l "$file" 2>&1)
|
||||
if echo "$OUTPUT" | grep -q "No syntax errors"; then
|
||||
:
|
||||
else
|
||||
echo -e "${RED}[ERROR]${NC} $file"
|
||||
echo " $OUTPUT"
|
||||
if $FIX; then
|
||||
# Show 3 lines of context around the error
|
||||
LINENUM=$(echo "$OUTPUT" | grep -oP 'on line \K[0-9]+' | head -1)
|
||||
if [[ -n "$LINENUM" ]]; then
|
||||
START=$(( LINENUM > 3 ? LINENUM - 3 : 1 ))
|
||||
echo ""
|
||||
sed -n "${START},$((LINENUM+2))p" "$file" | nl -ba -v"$START" | \
|
||||
awk -v err="$LINENUM" '{if(NR==err-START+4) printf "\033[31m→ %s\033[0m\n",$0; else print " "$0}'
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
done < <(find "$TARGET" -name "*.php" -not -path "*/vendor/*" -print0)
|
||||
|
||||
echo ""
|
||||
if [[ $ERRORS -eq 0 ]]; then
|
||||
echo -e "${GREEN}[✓]${NC} All $FILES PHP files OK"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}[✗]${NC} $ERRORS error(s) in $FILES files"
|
||||
exit 1
|
||||
fi
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# nova-push.sh — Push a local file to the NovaCPX VM via double-hop
|
||||
# Usage: bash nova-push.sh <local_file> <remote_path>
|
||||
# Example: bash nova-push.sh panel/api/index.php /srv/novacpx/public/api/index.php
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PVE1_HOST="orbisne.fortiddns.com"
|
||||
PVE1_PASS="Joker1974!!!"
|
||||
VM_IP="10.48.200.110"
|
||||
VM_PASS="Joker1974!!!"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
|
||||
|
||||
LOCAL="$1"
|
||||
REMOTE="$2"
|
||||
|
||||
[[ -f "$LOCAL" ]] || { echo "Error: $LOCAL not found"; exit 1; }
|
||||
|
||||
echo "Pushing $LOCAL → VM:$REMOTE"
|
||||
CONTENT=$(base64 -w0 "$LOCAL")
|
||||
sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST \
|
||||
"sshpass -p '$VM_PASS' ssh $SSH_OPTS root@$VM_IP \
|
||||
\"mkdir -p \$(dirname $REMOTE) && echo '$CONTENT' | base64 -d > $REMOTE && chown www-data:www-data $REMOTE\""
|
||||
echo "Done."
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# nova-ssh.sh — SSH into the NovaCPX VM via double-hop through PVE1
|
||||
# Usage: bash nova-ssh.sh [command] (run command on VM)
|
||||
# bash nova-ssh.sh (interactive shell on VM)
|
||||
# bash nova-ssh.sh --pve1 [command] (run on PVE1 instead)
|
||||
#
|
||||
# Hop path: local → PVE1 (10.48.200.90 via orbisne.fortiddns.com) → VM (10.48.200.110)
|
||||
|
||||
PVE1_HOST="orbisne.fortiddns.com"
|
||||
PVE1_PASS="Joker1974!!!"
|
||||
VM_IP="10.48.200.110"
|
||||
VM_PASS="Joker1974!!!"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
|
||||
|
||||
if [[ "${1:-}" == "--pve1" ]]; then
|
||||
shift
|
||||
if [[ $# -eq 0 ]]; then
|
||||
sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST
|
||||
else
|
||||
sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST "$*"
|
||||
fi
|
||||
else
|
||||
CMD="$*"
|
||||
if [[ -z "$CMD" ]]; then
|
||||
# Interactive shell
|
||||
sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST \
|
||||
"sshpass -p '$VM_PASS' ssh $SSH_OPTS root@$VM_IP"
|
||||
else
|
||||
sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST \
|
||||
"sshpass -p '$VM_PASS' ssh $SSH_OPTS root@$VM_IP '$CMD'"
|
||||
fi
|
||||
fi
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# nova-status.sh — Check NovaCPX VM health: SSH, panel ports, services, logs
|
||||
# Usage: bash nova-status.sh [--full]
|
||||
# (no flags) : quick port check
|
||||
# --full : also show recent error logs
|
||||
|
||||
PVE1_HOST="orbisne.fortiddns.com"
|
||||
PVE1_PASS="Joker1974!!!"
|
||||
VM_IP="10.48.200.110"
|
||||
VM_PASS="Joker1974!!!"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=8"
|
||||
FULL=false
|
||||
[[ "${1:-}" == "--full" ]] && FULL=true
|
||||
|
||||
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
|
||||
fail() { echo -e "${RED}[✗]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
|
||||
echo "=== NovaCPX VM Status ==="
|
||||
echo "VM: $VM_IP"
|
||||
|
||||
# SSH reachability
|
||||
if sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST \
|
||||
"sshpass -p '$VM_PASS' ssh $SSH_OPTS root@$VM_IP 'echo ok'" 2>/dev/null | grep -q ok; then
|
||||
ok "SSH reachable via PVE1"
|
||||
else
|
||||
fail "SSH NOT reachable via PVE1 → $VM_IP"
|
||||
fi
|
||||
|
||||
# Panel port checks via curl through DO
|
||||
echo ""
|
||||
echo "Panel ports:"
|
||||
for PORT in 8880 8881 8882; do
|
||||
LABEL="user"
|
||||
[[ $PORT -eq 8881 ]] && LABEL="reseller"
|
||||
[[ $PORT -eq 8882 ]] && LABEL="admin"
|
||||
STATUS=$(curl -sk --max-time 5 -o /dev/null -w "%{http_code}" "https://$VM_IP:$PORT/" 2>/dev/null || echo "ERR")
|
||||
if [[ "$STATUS" =~ ^[23] ]]; then
|
||||
ok "Port $PORT ($LABEL): HTTP $STATUS"
|
||||
elif [[ "$STATUS" == "401" || "$STATUS" == "403" ]]; then
|
||||
ok "Port $PORT ($LABEL): HTTP $STATUS (auth required — panel is up)"
|
||||
else
|
||||
fail "Port $PORT ($LABEL): HTTP $STATUS"
|
||||
fi
|
||||
done
|
||||
|
||||
# API endpoint
|
||||
API_STATUS=$(curl -sk --max-time 5 -o /dev/null -w "%{http_code}" -X POST \
|
||||
"https://$VM_IP:8882/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"probe","password":"probe"}' 2>/dev/null || echo "ERR")
|
||||
if [[ "$API_STATUS" == "401" || "$API_STATUS" == "200" ]]; then
|
||||
ok "API auth endpoint: HTTP $API_STATUS (responding)"
|
||||
else
|
||||
fail "API auth endpoint: HTTP $API_STATUS"
|
||||
fi
|
||||
|
||||
if $FULL; then
|
||||
echo ""
|
||||
echo "=== Recent error logs ==="
|
||||
sshpass -p "$PVE1_PASS" ssh $SSH_OPTS root@$PVE1_HOST \
|
||||
"sshpass -p '$VM_PASS' ssh $SSH_OPTS root@$VM_IP \
|
||||
'tail -20 /var/log/apache2/error.log 2>/dev/null; echo ---; tail -20 /var/log/novacpx/access.log 2>/dev/null'" 2>/dev/null || \
|
||||
warn "Could not read logs (SSH unavailable)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Panel URL: https://$VM_IP:8882 (admin)"
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
# nova-test.sh — NovaCPX API endpoint test suite
|
||||
# Tests auth, common endpoints, and panel responses against the live VM
|
||||
# Usage: bash nova-test.sh [--host IP] [--admin-pass PASS]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${NOVACPX_HOST:-10.48.200.110}"
|
||||
ADMIN_PASS="${NOVACPX_ADMIN_PASS:-bUe9JXTRmWJbyrFA}"
|
||||
PORT_USER=8880; PORT_RESELLER=8881; PORT_ADMIN=8882; PORT_WEBMAIL=8883
|
||||
PASS_CHECKS=0; FAIL_CHECKS=0; SKIP_CHECKS=0
|
||||
|
||||
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--host) HOST="${2:-}"; shift ;;
|
||||
--admin-pass) ADMIN_PASS="${2:-}"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
check() {
|
||||
local name="$1" expected="$2" actual="$3" detail="${4:-}"
|
||||
if [[ "$actual" == "$expected" ]]; then
|
||||
echo -e "${GREEN}[PASS]${NC} $name${detail:+ — $detail}"
|
||||
PASS_CHECKS=$((PASS_CHECKS+1))
|
||||
else
|
||||
echo -e "${RED}[FAIL]${NC} $name — expected $expected, got $actual ${detail:+($detail)}"
|
||||
FAIL_CHECKS=$((FAIL_CHECKS+1))
|
||||
fi
|
||||
}
|
||||
|
||||
api() {
|
||||
local port="$1" method="$2" endpoint="$3" data="${4:-}"
|
||||
local url="https://$HOST:$port/api/$endpoint"
|
||||
local args=(-sk --max-time 8 -X "$method" -H "Content-Type: application/json")
|
||||
[[ -n "${TOKEN:-}" ]] && args+=(-H "Authorization: Bearer $TOKEN")
|
||||
[[ -n "$data" ]] && args+=(-d "$data")
|
||||
curl "${args[@]}" -w "\n%{http_code}" "$url" 2>/dev/null
|
||||
}
|
||||
|
||||
http_code() { echo "$1" | tail -1; }
|
||||
body() { echo "$1" | head -n -1; }
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}══════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE} NovaCPX API Test Suite${NC}"
|
||||
echo -e "${BLUE} Host: $HOST${NC}"
|
||||
echo -e "${BLUE}══════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
|
||||
# ── 1. Panel ports reachable ──────────────────────────────────────────────────
|
||||
echo "── Panel Ports ──"
|
||||
for PORT in $PORT_USER $PORT_RESELLER $PORT_ADMIN $PORT_WEBMAIL; do
|
||||
LABEL="user"; [[ $PORT -eq 8881 ]] && LABEL="reseller"; [[ $PORT -eq 8882 ]] && LABEL="admin"; [[ $PORT -eq 8883 ]] && LABEL="webmail"
|
||||
SC=$(curl -sk --max-time 6 -o /dev/null -w "%{http_code}" "https://$HOST:$PORT/" 2>/dev/null || echo "ERR")
|
||||
if [[ "$SC" =~ ^[23] ]] || [[ "$SC" == "401" ]] || [[ "$SC" == "403" ]]; then
|
||||
echo -e "${GREEN}[PASS]${NC} Port $PORT ($LABEL): HTTP $SC"
|
||||
PASS_CHECKS=$((PASS_CHECKS+1))
|
||||
else
|
||||
echo -e "${RED}[FAIL]${NC} Port $PORT ($LABEL): HTTP $SC (not responding)"
|
||||
FAIL_CHECKS=$((FAIL_CHECKS+1))
|
||||
fi
|
||||
done
|
||||
|
||||
# ── 2. Auth — bad credentials ─────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Auth Endpoint ──"
|
||||
RESP=$(api $PORT_ADMIN POST "auth/login" '{"username":"nobody","password":"badpass"}')
|
||||
check "Bad login returns 401" "401" "$(http_code "$RESP")"
|
||||
|
||||
# ── 3. Auth — valid login ─────────────────────────────────────────────────────
|
||||
RESP=$(api $PORT_ADMIN POST "auth/login" "{\"username\":\"admin\",\"password\":\"$ADMIN_PASS\"}")
|
||||
SC=$(http_code "$RESP")
|
||||
check "Admin login succeeds (200)" "200" "$SC"
|
||||
TOKEN=$(body "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',{}).get('token',''))" 2>/dev/null || echo "")
|
||||
if [[ -n "$TOKEN" ]]; then
|
||||
echo -e "${GREEN}[INFO]${NC} Token acquired: ${TOKEN:0:20}..."
|
||||
else
|
||||
echo -e "${YELLOW}[WARN]${NC} No token in login response — subsequent tests will fail auth"
|
||||
fi
|
||||
|
||||
# ── 4. Auth — me endpoint ─────────────────────────────────────────────────────
|
||||
RESP=$(api $PORT_ADMIN GET "auth/me")
|
||||
check "auth/me returns 200" "200" "$(http_code "$RESP")"
|
||||
ROLE=$(body "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',{}).get('role',''))" 2>/dev/null || echo "")
|
||||
check "auth/me role=admin" "admin" "$ROLE"
|
||||
|
||||
# ── 5. Packages ───────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Packages ──"
|
||||
RESP=$(api $PORT_ADMIN GET "packages/list")
|
||||
check "packages/list returns 200" "200" "$(http_code "$RESP")"
|
||||
|
||||
# ── 6. Domains ────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Domains ──"
|
||||
RESP=$(api $PORT_ADMIN GET "domains/list")
|
||||
check "domains/list returns 200" "200" "$(http_code "$RESP")"
|
||||
|
||||
# ── 7. System ─────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── System ──"
|
||||
RESP=$(api $PORT_ADMIN GET "system/status")
|
||||
check "system/status returns 200" "200" "$(http_code "$RESP")"
|
||||
|
||||
RESP=$(api $PORT_ADMIN GET "system/services")
|
||||
check "system/services returns 200" "200" "$(http_code "$RESP")"
|
||||
|
||||
# ── 8. Firewall ───────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Firewall ──"
|
||||
RESP=$(api $PORT_ADMIN GET "firewall/status")
|
||||
check "firewall/status returns 200" "200" "$(http_code "$RESP")"
|
||||
|
||||
# ── 9. Stats ──────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Stats ──"
|
||||
RESP=$(api $PORT_ADMIN GET "stats/overview")
|
||||
check "stats/overview returns 200" "200" "$(http_code "$RESP")"
|
||||
|
||||
# ── 10. Unauthorized access ───────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Auth Enforcement ──"
|
||||
TOKEN=""
|
||||
RESP=$(api $PORT_ADMIN GET "packages/list")
|
||||
check "packages/list without token returns 401" "401" "$(http_code "$RESP")"
|
||||
|
||||
RESP=$(api $PORT_ADMIN GET "firewall/status")
|
||||
check "firewall/status without token returns 401" "401" "$(http_code "$RESP")"
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
TOTAL=$((PASS_CHECKS + FAIL_CHECKS + SKIP_CHECKS))
|
||||
echo ""
|
||||
echo -e "${BLUE}══════════════════════════════════════════${NC}"
|
||||
echo -e " Results: ${GREEN}$PASS_CHECKS passed${NC} ${RED}$FAIL_CHECKS failed${NC} ${YELLOW}$SKIP_CHECKS skipped${NC} / $TOTAL total"
|
||||
echo -e "${BLUE}══════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
[[ $FAIL_CHECKS -eq 0 ]] && exit 0 || exit 1
|
||||
Reference in New Issue
Block a user