feat: items #9-13 — password change, webmail SSO, DKIM live, file manager security, cache busting

#9  auth.php: add self-service change-password action (current+new+confirm)
    accounts.php: fix admin change-password — accept account_id, fetch username
    for chpasswd (was using int ID), add Auth::require('admin') guard
    user.js: add Change Password page + navItem + submitChangePassword()

#10 EmailManager: store AES-256-CBC enc_password alongside SHA512-CRYPT hash
    webmail.php: rewrite login-url to use webmail_sso_tokens table
    novacpx-sso.php: Roundcube SSO bridge (validate token, decrypt, autosubmit)
    Migration 005: add enc_password column + webmail_sso_tokens table

#11 opendkim: installed, configured (/etc/opendkim.conf, signing.table,
    key.table, trusted.hosts), socket at /var/spool/postfix/opendkim/,
    Postfix milter wired, service enabled+running, key generation verified

#12 files.php: fix safe_path() for non-existent paths (write/mkdir),
    add safe_path_new() helper using parent-dir realpath check,
    fix delete guard (block deleting account root dirs),
    fix rename destination, clamp chmod to 0777

#13 nova.js: api() handles network errors, 429 rate-limit with retry-after,
    non-JSON responses (PHP fatal pages) — graceful error instead of throw
    admin/user/reseller index.php: filemtime-based cache-busting on all assets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 01:19:33 +00:00
parent 62707d62ce
commit 6fdccc6dbd
27 changed files with 1736 additions and 79 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
<?php
// NovaCPX Admin Panel — Datacenter/Server Manager
// Equivalent to WHM (WebHost Manager)
$_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
?>
<!DOCTYPE html>
<html lang="en">
@@ -9,7 +9,7 @@
<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">
<link rel="stylesheet" href="/assets/css/nova.css<?= $_v('/assets/css/nova.css') ?>">
</head>
<body>
@@ -194,7 +194,7 @@
</div>
</div>
<script src="/assets/js/nova.js"></script>
<script src="/assets/js/admin.js"></script>
<script src="/assets/js/nova.js<?= $_v('/assets/js/nova.js') ?>"></script>
<script src="/assets/js/admin.js<?= $_v('/assets/js/admin.js') ?>"></script>
</body>
</html>
+24 -7
View File
@@ -8,14 +8,31 @@ window.Nova = (() => {
const { method = 'GET', body, params } = opts;
let url = `/api/${endpoint}/${action}`;
if (params) url += '?' + new URLSearchParams(params);
const res = await fetch(url, {
method,
credentials: 'include',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
});
let res;
try {
res = await fetch(url, {
method,
credentials: 'include',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
});
} catch (e) {
console.error(`Nova.api network error [${endpoint}/${action}]:`, e);
return { success: false, message: 'Network error — check your connection' };
}
if (res.status === 401) { location.href = '/?redirect=' + encodeURIComponent(location.pathname); return null; }
return res.json();
if (res.status === 429) {
const reset = res.headers.get('X-RateLimit-Reset');
const wait = reset ? Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000)) : 60;
return { success: false, message: `Rate limited — try again in ${wait}s` };
}
const text = await res.text();
try {
return JSON.parse(text);
} catch {
console.error(`Nova.api non-JSON from [${endpoint}/${action}] (HTTP ${res.status}):`, text.slice(0, 500));
return { success: false, message: `Server error (HTTP ${res.status}) — see browser console` };
}
}
// ── Toast ─────────────────────────────────────────────────────────────────
+57 -11
View File
@@ -69,6 +69,7 @@ const userPages = {
files,
stats: statsPage,
backups,
'change-password': changePasswordPage,
};
/* ── Dashboard ───────────────────────────────────────────────────────────── */
@@ -761,17 +762,18 @@ window.createBackup = () => Nova.toast('Backup request submitted — you will be
/* ── Navigation ─────────────────────────────────────────────────────────── */
const navItems = [
{ id: 'dashboard', label: 'Dashboard', icon: 'ni-dashboard' },
{ id: 'domains', label: 'Domains', icon: 'ni-domains' },
{ id: 'email', label: 'Email', icon: 'ni-email' },
{ id: 'databases', label: 'Databases', icon: 'ni-databases' },
{ id: 'ftp', label: 'FTP', icon: 'ni-ftp' },
{ id: 'ssl', label: 'SSL / TLS', icon: 'ni-ssl' },
{ id: 'php', label: 'PHP', icon: 'ni-php' },
{ id: 'cron', label: 'Cron Jobs', icon: 'ni-cron' },
{ id: 'files', label: 'File Manager', icon: 'ni-files' },
{ id: 'stats', label: 'Statistics', icon: 'ni-stats' },
{ id: 'backups', label: 'Backups', icon: 'ni-backups' },
{ id: 'dashboard', label: 'Dashboard', icon: 'ni-dashboard' },
{ id: 'domains', label: 'Domains', icon: 'ni-domains' },
{ id: 'email', label: 'Email', icon: 'ni-email' },
{ id: 'databases', label: 'Databases', icon: 'ni-databases' },
{ id: 'ftp', label: 'FTP', icon: 'ni-ftp' },
{ id: 'ssl', label: 'SSL / TLS', icon: 'ni-ssl' },
{ id: 'php', label: 'PHP', icon: 'ni-php' },
{ id: 'cron', label: 'Cron Jobs', icon: 'ni-cron' },
{ id: 'files', label: 'File Manager', icon: 'ni-files' },
{ id: 'stats', label: 'Statistics', icon: 'ni-stats' },
{ id: 'backups', label: 'Backups', icon: 'ni-backups' },
{ id: 'change-password', label: 'Change Password', icon: 'ni-lock' },
];
let _activePage = 'dashboard';
@@ -795,6 +797,50 @@ window.userNav = (page) => {
if (userPages[page]) userPages[page](content);
};
/* ── Change Password ─────────────────────────────────────────────────────── */
async function changePasswordPage(el) {
el.innerHTML = `
<div class="page-header"><h2 class="page-title">Change Password</h2></div>
<div class="card" style="max-width:480px">
<div class="card-header"><span class="card-title">Update Your Password</span></div>
<div class="card-body">
<div class="form-group">
<label class="form-label">Current Password</label>
<input type="password" id="cp-current" class="form-control" autocomplete="current-password">
</div>
<div class="form-group">
<label class="form-label">New Password <span style="color:var(--muted);font-size:.8rem">(min 8 chars)</span></label>
<input type="password" id="cp-new" class="form-control" autocomplete="new-password">
</div>
<div class="form-group">
<label class="form-label">Confirm New Password</label>
<input type="password" id="cp-confirm" class="form-control" autocomplete="new-password">
</div>
<button class="btn btn-primary" onclick="submitChangePassword()">Update Password</button>
</div>
</div>`;
}
window.submitChangePassword = async () => {
const current = document.getElementById('cp-current')?.value;
const newPass = document.getElementById('cp-new')?.value;
const confirm = document.getElementById('cp-confirm')?.value;
if (!current || !newPass || !confirm) { Nova.toast('All fields required', 'error'); return; }
if (newPass !== confirm) { Nova.toast('New passwords do not match', 'error'); return; }
const res = await Nova.api('auth', 'change-password', {
method: 'POST',
body: { current_password: current, new_password: newPass, confirm_password: confirm },
});
if (res?.success) {
Nova.toast('Password updated successfully', 'success');
document.getElementById('cp-current').value = '';
document.getElementById('cp-new').value = '';
document.getElementById('cp-confirm').value = '';
} else {
Nova.toast(res?.message || 'Failed to update password', 'error');
}
};
/* ── Boot ────────────────────────────────────────────────────────────────── */
document.addEventListener('DOMContentLoaded', async () => {
const ok = await initUser();
+4 -3
View File
@@ -1,5 +1,6 @@
<?php
// NovaCPX Reseller Panel — port 8881
$_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
?>
<!DOCTYPE html>
<html lang="en">
@@ -8,7 +9,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NovaCPX Reseller</title>
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
<link rel="stylesheet" href="/assets/css/nova.css">
<link rel="stylesheet" href="/assets/css/nova.css<?= $_v('/assets/css/nova.css') ?>">
</head>
<body>
@@ -75,8 +76,8 @@
</div>
</div>
<script src="/assets/js/nova.js"></script>
<script src="/assets/js/reseller.js"></script>
<script src="/assets/js/nova.js<?= $_v('/assets/js/nova.js') ?>"></script>
<script src="/assets/js/reseller.js<?= $_v('/assets/js/reseller.js') ?>"></script>
</body>
</html>
+4 -4
View File
@@ -1,6 +1,6 @@
<?php
// NovaCPX User Panel — End-user hosting dashboard
// Design: Horizontal feature cards with usage rings, NOT cPanel icon grid
$_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
?>
<!DOCTYPE html>
<html lang="en">
@@ -9,7 +9,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NovaCPX — My Hosting</title>
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
<link rel="stylesheet" href="/assets/css/nova.css">
<link rel="stylesheet" href="/assets/css/nova.css<?= $_v('/assets/css/nova.css') ?>">
<style>
/* ── User panel specific ─────────────────────────────── */
.feature-grid {
@@ -195,8 +195,8 @@ svg.ring circle { transition: stroke-dashoffset .5s; }
</div>
</div>
<script src="/assets/js/nova.js"></script>
<script src="/assets/js/user.js"></script>
<script src="/assets/js/nova.js<?= $_v('/assets/js/nova.js') ?>"></script>
<script src="/assets/js/user.js<?= $_v('/assets/js/user.js') ?>"></script>
<script>
(async () => {
// Legacy inline login form fallback (user.js handles auth-check div)