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
+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();