Full firewall management — UFW rules + Fail2Ban + IP lists

- New firewall.php endpoint: status, enable/disable, add-rule (full UFW
  syntax: action/direction/port/proto/from/to/comment), delete-rule by
  number, quick allow-port/deny-port, allow-ip/block-ip with DB storage,
  ip-lists, reset to defaults, default-policy, set-logging, f2b-status
  (all jails with banned counts), f2b-jail detail, f2b-ban, f2b-unban
  (single jail or all), f2b-reload, f2b-restart, raw ufw command (whitelisted)
- admin.js: full firewall page — UFW status badge + enable/disable toggle,
  default policy dropdowns, numbered rules table with delete, quick rule
  inline form, full add-rule modal, trusted IP chip list, blocked IP chip
  list, Fail2Ban jails table with banned counts, per-jail banned IP modal
  with individual unban buttons, manual ban modal, logging level control
- nova.js: add Nova.escHtml() used across all new pages
- admin.js: remove git_remote field from admin settings panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 16:03:35 +00:00
parent 3af01ab614
commit 910427c46c
3 changed files with 753 additions and 51 deletions
+406 -50
View File
@@ -328,7 +328,6 @@
<div class="form-group"><label>Update Channel</label>
<select name="update_channel"><option value="stable">Stable</option><option value="beta">Beta</option></select>
</div>
<div class="form-group"><label>Git Remote</label><input type="url" name="git_remote" value="https://github.com/myronblair/novacpx.git"></div>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
@@ -723,74 +722,431 @@
}, true);
};
// ── Firewall ───────────────────────────────────────────────────────────────
// ── Firewall ───────────────────────────────────────────────────────────────
async function firewall() {
const ufwRes = await Nova.api('system','service',{method:'POST',body:{service:'ufw',command:'status'}}).catch(()=>null);
const [fwRes, f2bRes, ipRes] = await Promise.all([
Nova.api('firewall','status'),
Nova.api('firewall','f2b-status'),
Nova.api('firewall','ip-lists'),
]);
const fw = fwRes?.data || {};
const jails = f2bRes?.data?.jails || [];
const trusted = ipRes?.data?.trusted || [];
const blocked = ipRes?.data?.blocked || [];
const rules = fw.rules || [];
const active = fw.active;
const totalBanned = jails.reduce((s,j) => s + (j.currently_banned||0), 0);
return `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem">
<div class="page-header mb-3">
<h2 class="page-title">Firewall</h2>
<div style="display:flex;gap:.5rem;align-items:center">
${active ? Nova.badge('UFW Active','green') : Nova.badge('UFW Disabled','red')}
${active
? `<button class="btn btn-sm btn-ghost" onclick="fwToggle(false)">Disable UFW</button>`
: `<button class="btn btn-sm btn-primary" onclick="fwToggle(true)">Enable UFW</button>`}
<button class="btn btn-sm btn-ghost" onclick="adminPage('firewall')">
<svg class="icon-xs"><use href="/assets/img/nova-icons.svg#ni-search"/></svg> Refresh
</button>
</div>
</div>
<div class="grid-2 mb-3" style="gap:1.5rem">
<!-- Default Policies -->
<div class="card">
<div class="card-header"><span class="card-title">UFW Firewall</span></div>
<div style="padding:1.25rem">
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
<button class="btn btn-sm btn-primary" onclick="adminServiceAction('ufw','start')">Enable UFW</button>
<button class="btn btn-sm btn-danger" onclick="adminServiceAction('ufw','stop')">Disable UFW</button>
</div>
<div class="form-group"><label class="form-label">Allow Port</label>
<div style="display:flex;gap:.5rem">
<input id="fw-port" class="form-control" placeholder="e.g. 3306/tcp">
<button class="btn btn-sm btn-primary" onclick="adminAllowPort()">Allow</button>
<div class="card-header">
<span class="card-title">Default Policies</span>
</div>
<div class="card-body">
<div class="grid-2 mb-3">
<div>
<p class="text-muted text-sm">Incoming</p>
<select id="pol-incoming" class="form-control form-control-sm" style="width:auto">
${['deny','allow','reject'].map(p=>`<option value="${p}" ${fw.default_incoming===p?'selected':''}>${p.charAt(0).toUpperCase()+p.slice(1)}</option>`).join('')}
</select>
</div>
<div>
<p class="text-muted text-sm">Outgoing</p>
<select id="pol-outgoing" class="form-control form-control-sm" style="width:auto">
${['allow','deny','reject'].map(p=>`<option value="${p}" ${fw.default_outgoing===p?'selected':''}>${p.charAt(0).toUpperCase()+p.slice(1)}</option>`).join('')}
</select>
</div>
</div>
<div class="form-group"><label class="form-label">Block Port</label>
<div style="display:flex;gap:.5rem">
<input id="fw-block" class="form-control" placeholder="e.g. 3306/tcp">
<button class="btn btn-sm btn-danger" onclick="adminBlockPort()">Block</button>
</div>
<button class="btn btn-sm btn-primary" onclick="fwSavePolicies()">Save Policies</button>
</div>
</div>
<!-- Fail2Ban summary -->
<div class="card">
<div class="card-header">
<span class="card-title">Fail2Ban</span>
${totalBanned > 0 ? Nova.badge(totalBanned + ' banned', 'red') : Nova.badge('0 banned','green')}
</div>
<div class="card-body">
<div class="grid-2 mb-2">
<div><p class="text-muted text-sm">Active Jails</p><p class="font-bold">${jails.length}</p></div>
<div><p class="text-muted text-sm">Currently Banned</p><p class="font-bold">${totalBanned}</p></div>
</div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
<button class="btn btn-sm btn-ghost" onclick="fwF2bReload()">Reload Config</button>
<button class="btn btn-sm btn-ghost" onclick="fwF2bRestart()">Restart</button>
</div>
</div>
</div>
</div>
<!-- UFW Rules -->
<div class="card mb-3">
<div class="card-header">
<span class="card-title">
<svg class="icon-sm mr-1"><use href="/assets/img/nova-icons.svg#ni-firewall"/></svg>
UFW Rules
</span>
<span class="text-muted text-sm ml-2">${rules.length} rule${rules.length!==1?'s':''}</span>
<button class="btn btn-sm btn-primary ml-auto" onclick="fwAddRuleModal()">+ Add Rule</button>
<button class="btn btn-sm btn-ghost" onclick="fwResetModal()">Reset to Defaults</button>
</div>
${rules.length ? `
<div class="table-wrap">
<table>
<thead><tr><th>#</th><th>To / Port</th><th>Action</th><th>From</th><th></th></tr></thead>
<tbody>
${rules.map(r => `<tr>
<td class="text-muted text-sm">${r.num}</td>
<td><code>${Nova.escHtml(r.to)}</code></td>
<td>${fwActionBadge(r.action)}</td>
<td class="text-sm">${Nova.escHtml(r.from)}</td>
<td>
<button class="btn btn-xs btn-ghost" style="color:var(--red)" onclick="fwDeleteRule(${r.num})">Delete</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : `<div class="card-body"><p class="text-muted">No rules defined.</p></div>`}
</div>
<!-- Add Quick Rule (inline) -->
<div class="card mb-3">
<div class="card-header"><span class="card-title">Quick Rule</span></div>
<div class="card-body">
<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">Action</label>
<select id="qr-action" class="form-control form-control-sm">
<option value="allow">Allow</option>
<option value="deny">Deny</option>
<option value="reject">Reject</option>
<option value="limit">Limit</option>
</select>
</div>
<div class="form-group mb-0">
<label class="form-label text-sm">Direction</label>
<select id="qr-dir" class="form-control form-control-sm">
<option value="in">In</option>
<option value="out">Out</option>
</select>
</div>
<div class="form-group mb-0">
<label class="form-label text-sm">Port / Service</label>
<input id="qr-port" class="form-control form-control-sm" placeholder="80, 8000:9000, ssh…" style="width:160px">
</div>
<div class="form-group mb-0">
<label class="form-label text-sm">Protocol</label>
<select id="qr-proto" class="form-control form-control-sm">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
<option value="any">Any</option>
</select>
</div>
<div class="form-group mb-0">
<label class="form-label text-sm">From IP (optional)</label>
<input id="qr-from" class="form-control form-control-sm" placeholder="any" style="width:150px">
</div>
<div class="form-group mb-0">
<label class="form-label text-sm">Comment</label>
<input id="qr-comment" class="form-control form-control-sm" placeholder="optional" style="width:140px">
</div>
<button class="btn btn-primary btn-sm mb-0" onclick="fwQuickRule()">Add Rule</button>
</div>
</div>
</div>
<div class="grid-2 mb-3" style="gap:1.5rem">
<!-- Trusted IPs -->
<div class="card">
<div class="card-header"><span class="card-title">Fail2Ban</span></div>
<div style="padding:1.25rem">
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
<button class="btn btn-sm" onclick="adminServiceAction('fail2ban','restart')">Restart Fail2Ban</button>
<button class="btn btn-sm" onclick="adminF2bStatus()">View Jails</button>
<div class="card-header">
<span class="card-title">Trusted IPs</span>
<span class="text-muted text-sm ml-2">${trusted.length} IPs</span>
</div>
<div class="card-body">
<div style="display:flex;gap:.5rem;margin-bottom:.75rem">
<input id="fw-trust-ip" class="form-control form-control-sm" placeholder="IP or CIDR e.g. 192.168.1.0/24" style="flex:1">
<button class="btn btn-sm btn-primary" onclick="fwAllowIp()">Allow</button>
</div>
<div class="form-group"><label class="form-label">Unban IP</label>
<div style="display:flex;gap:.5rem">
<input id="fw-unban" class="form-control" placeholder="192.168.1.100">
<button class="btn btn-sm" onclick="adminUnban()">Unban</button>
</div>
${trusted.length ? `<div style="display:flex;flex-wrap:wrap;gap:.35rem">
${trusted.map(ip => `<span class="badge badge-green" style="cursor:pointer" onclick="fwRemoveIp('${ip}','allow')" title="Click to remove">${Nova.escHtml(ip)} ×</span>`).join('')}
</div>` : `<p class="text-muted text-sm">No trusted IPs.</p>`}
</div>
</div>
<!-- Blocked IPs -->
<div class="card">
<div class="card-header">
<span class="card-title">Blocked IPs</span>
<span class="text-muted text-sm ml-2">${blocked.length} IPs</span>
</div>
<div class="card-body">
<div style="display:flex;gap:.5rem;margin-bottom:.75rem">
<input id="fw-block-ip" class="form-control form-control-sm" placeholder="IP or CIDR e.g. 1.2.3.4" style="flex:1">
<button class="btn btn-sm" style="background:var(--red);color:#fff" onclick="fwBlockIp()">Block</button>
</div>
${blocked.length ? `<div style="display:flex;flex-wrap:wrap;gap:.35rem">
${blocked.map(ip => `<span class="badge badge-red" style="cursor:pointer" onclick="fwRemoveIp('${ip}','deny')" title="Click to remove">${Nova.escHtml(ip)} ×</span>`).join('')}
</div>` : `<p class="text-muted text-sm">No blocked IPs.</p>`}
</div>
</div>
</div>
<!-- Fail2Ban Jails -->
<div class="card mb-3">
<div class="card-header">
<span class="card-title">Fail2Ban Jails</span>
</div>
${jails.length ? `
<div class="table-wrap">
<table>
<thead><tr><th>Jail</th><th>Currently Banned</th><th>Total Banned</th><th>Failed</th><th>Actions</th></tr></thead>
<tbody>
${jails.map(j => `<tr>
<td><strong>${Nova.escHtml(j.jail)}</strong></td>
<td>${j.currently_banned > 0 ? `<span style="color:var(--red);font-weight:600">${j.currently_banned}</span>` : '0'}</td>
<td class="text-muted">${j.total_banned}</td>
<td class="text-muted">${j.currently_failed}</td>
<td style="display:flex;gap:.35rem;flex-wrap:wrap">
${j.currently_banned > 0 ? `<button class="btn btn-xs btn-ghost" onclick="fwJailDetail('${j.jail}')">View Banned</button>` : ''}
<button class="btn btn-xs btn-ghost" onclick="fwManualBanModal('${j.jail}')">Ban IP</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : `<div class="card-body"><p class="text-muted">Fail2Ban not running or no jails configured.</p></div>`}
</div>
<!-- UFW Logging -->
<div class="card">
<div class="card-header"><span class="card-title">UFW Logging</span></div>
<div class="card-body">
<div style="display:flex;gap:.75rem;align-items:flex-end;flex-wrap:wrap">
<div class="form-group mb-0">
<label class="form-label text-sm">Log Level</label>
<select id="fw-log-level" class="form-control form-control-sm">
${['off','on','low','medium','high','full'].map(l=>`<option value="${l}">${l.charAt(0).toUpperCase()+l.slice(1)}</option>`).join('')}
</select>
</div>
<button class="btn btn-sm btn-primary" onclick="fwSetLogging()">Apply</button>
<span class="text-muted text-sm">Logs at <code>/var/log/ufw.log</code></span>
</div>
</div>
</div>`;
}
window.adminAllowPort = async () => {
const port = document.getElementById('fw-port')?.value;
if (!port) return;
const r = await Nova.api('system','service',{method:'POST',body:{service:'ufw',command:`allow ${port}`}});
Nova.toast(r?.success ? `Allowed ${port}` : r?.message,'success');
function fwActionBadge(action) {
const a = (action||'').toLowerCase();
if (a.includes('allow')) return Nova.badge('ALLOW','green');
if (a.includes('deny')) return Nova.badge('DENY','red');
if (a.includes('reject'))return Nova.badge('REJECT','red');
if (a.includes('limit')) return Nova.badge('LIMIT','yellow');
return `<span class="text-sm">${Nova.escHtml(action)}</span>`;
}
window.fwToggle = async (enable) => {
const label = enable ? 'Enable' : 'Disable';
Nova.confirm(`${label} UFW firewall?`, async () => {
const r = await Nova.api('firewall', enable ? 'enable' : 'disable', {method:'POST'});
Nova.toast(r?.message || label + 'd', r?.success ? 'success' : 'error');
adminPage('firewall');
}, !enable);
};
window.adminBlockPort = async () => {
const port = document.getElementById('fw-block')?.value;
if (!port) return;
const r = await Nova.api('system','service',{method:'POST',body:{service:'ufw',command:`deny ${port}`}});
Nova.toast(r?.success ? `Blocked ${port}` : r?.message,'success');
window.fwSavePolicies = async () => {
const inc = document.getElementById('pol-incoming')?.value;
const out = document.getElementById('pol-outgoing')?.value;
await Nova.api('firewall','default-policy',{method:'POST',body:{direction:'incoming',policy:inc}});
const r2 = await Nova.api('firewall','default-policy',{method:'POST',body:{direction:'outgoing',policy:out}});
Nova.toast(r2?.success ? 'Policies saved' : r2?.message, r2?.success ? 'success' : 'error');
adminPage('firewall');
};
window.adminF2bStatus = async () => {
const r = await Nova.api('system','service',{method:'POST',body:{service:'fail2ban-client',command:'status'}});
Nova.modal('Fail2Ban Jails', `<pre style="background:var(--bg);padding:1rem;border-radius:6px;font-size:.8rem;overflow:auto">${r?.data?.output || 'No output'}</pre>`);
window.fwDeleteRule = (num) => {
Nova.confirm(`Delete rule #${num}? This cannot be undone.`, async () => {
const r = await Nova.api('firewall','delete-rule',{method:'POST',body:{num}});
Nova.toast(r?.message || 'Deleted', r?.success ? 'success' : 'error');
adminPage('firewall');
}, true);
};
window.adminUnban = async () => {
const ip = document.getElementById('fw-unban')?.value;
if (!ip) return;
Nova.toast(`Unbanning ${ip}`,'info');
// Unban from all jails
for (const jail of ['sshd','novacpx-user','novacpx-admin','novacpx-reseller','novacpx-webmail']) {
await Nova.api('system','service',{method:'POST',body:{service:`fail2ban-client set ${jail} unbanip`,command:ip}}).catch(()=>{});
}
Nova.toast('Unban commands sent','success');
window.fwResetModal = () => {
Nova.confirm('Reset ALL firewall rules to NovaCPX defaults? SSH, HTTP, HTTPS, and panel ports will be re-allowed automatically.', async () => {
Nova.toast('Resetting firewall…','info',5000);
const r = await Nova.api('firewall','reset',{method:'POST'});
Nova.toast(r?.message || 'Reset complete','success');
adminPage('firewall');
}, true);
};
window.fwQuickRule = async () => {
const body = {
action: document.getElementById('qr-action')?.value,
direction: document.getElementById('qr-dir')?.value,
port: document.getElementById('qr-port')?.value,
proto: document.getElementById('qr-proto')?.value,
from_ip: document.getElementById('qr-from')?.value || 'any',
comment: document.getElementById('qr-comment')?.value,
};
if (!body.port) { Nova.toast('Port/service is required','error'); return; }
const r = await Nova.api('firewall','add-rule',{method:'POST',body});
Nova.toast(r?.message || (r?.success ? 'Rule added' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
};
window.fwAddRuleModal = () => {
Nova.modal('Add Firewall Rule',`
<div class="form-group"><label>Action</label>
<select id="m-action" class="form-control">
<option value="allow">Allow</option><option value="deny">Deny</option>
<option value="reject">Reject</option><option value="limit">Limit (rate-limit)</option>
</select></div>
<div class="form-group"><label>Direction</label>
<select id="m-dir" class="form-control">
<option value="in">Incoming</option><option value="out">Outgoing</option>
</select></div>
<div class="form-group"><label>Port / Service</label>
<input id="m-port" class="form-control" placeholder="e.g. 443, 8000:9000, ssh, smtp"></div>
<div class="form-group"><label>Protocol</label>
<select id="m-proto" class="form-control">
<option value="tcp">TCP</option><option value="udp">UDP</option><option value="any">Any</option>
</select></div>
<div class="form-group"><label>From IP / CIDR (leave blank for any)</label>
<input id="m-from" class="form-control" placeholder="any or 192.168.1.0/24"></div>
<div class="form-group"><label>To IP / CIDR (leave blank for any)</label>
<input id="m-to" class="form-control" placeholder="any"></div>
<div class="form-group"><label>Comment</label>
<input id="m-comment" class="form-control" placeholder="optional description"></div>
`,`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="fwSubmitAddRule()">Add Rule</button>`);
};
window.fwSubmitAddRule = async () => {
const body = {
action: document.getElementById('m-action')?.value,
direction: document.getElementById('m-dir')?.value,
port: document.getElementById('m-port')?.value,
proto: document.getElementById('m-proto')?.value,
from_ip: document.getElementById('m-from')?.value || 'any',
to_ip: document.getElementById('m-to')?.value || 'any',
comment: document.getElementById('m-comment')?.value,
};
if (!body.port) { Nova.toast('Port is required','error'); return; }
document.querySelector('.modal-overlay')?.remove();
const r = await Nova.api('firewall','add-rule',{method:'POST',body});
Nova.toast(r?.message || (r?.success ? 'Rule added' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
};
window.fwAllowIp = async () => {
const ip = document.getElementById('fw-trust-ip')?.value?.trim();
if (!ip) { Nova.toast('Enter an IP or CIDR','error'); return; }
const r = await Nova.api('firewall','allow-ip',{method:'POST',body:{ip}});
Nova.toast(r?.message || (r?.success ? 'IP allowed' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
};
window.fwBlockIp = async () => {
const ip = document.getElementById('fw-block-ip')?.value?.trim();
if (!ip) { Nova.toast('Enter an IP or CIDR','error'); return; }
Nova.confirm(`Block ${ip}? This will deny all incoming traffic from this address.`, async () => {
const r = await Nova.api('firewall','block-ip',{method:'POST',body:{ip}});
Nova.toast(r?.message || (r?.success ? 'IP blocked' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
}, true);
};
window.fwRemoveIp = (ip, action) => {
Nova.confirm(`Remove ${action} rule for ${ip}?`, async () => {
const r = await Nova.api('firewall','remove-ip',{method:'POST',body:{ip,action}});
Nova.toast(r?.message || 'Removed', r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
}, true);
};
window.fwJailDetail = async (jail) => {
const r = await Nova.api('firewall','f2b-jail',{method:'POST',body:{jail}});
const d = r?.data || {};
const ips = d.banned_ips || [];
Nova.modal(`Fail2Ban: ${jail}`,`
<div class="grid-2 mb-2" style="gap:.75rem">
<div><p class="text-muted text-sm">Currently Banned</p><p class="font-bold">${d.currently_banned}</p></div>
<div><p class="text-muted text-sm">Total Banned</p><p class="font-bold">${d.total_banned}</p></div>
</div>
${ips.length ? `
<table style="width:100%;font-size:.85rem">
<thead><tr><th style="text-align:left;padding:.35rem .5rem">Banned IP</th><th></th></tr></thead>
<tbody>
${ips.map(ip=>`<tr>
<td style="padding:.3rem .5rem"><code>${Nova.escHtml(ip)}</code></td>
<td style="padding:.3rem .5rem;text-align:right">
<button class="btn btn-xs btn-primary" onclick="fwUnbanIp('${Nova.escHtml(ip)}','${jail}',this)">Unban</button>
</td>
</tr>`).join('')}
</tbody>
</table>` : '<p class="text-muted">No IPs currently banned in this jail.</p>'}`);
};
window.fwUnbanIp = async (ip, jail, btn) => {
if (btn) btn.disabled = true;
const r = await Nova.api('firewall','f2b-unban',{method:'POST',body:{ip,jail}});
Nova.toast(r?.message || 'Unbanned', r?.success ? 'success' : 'error');
if (r?.success && btn) btn.closest('tr')?.remove();
};
window.fwManualBanModal = (jail) => {
Nova.modal(`Manual Ban — ${jail}`,`
<div class="form-group">
<label>IP Address to Ban</label>
<input id="mb-ip" class="form-control" placeholder="1.2.3.4" autofocus>
</div>`,`
<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn" style="background:var(--red);color:#fff" onclick="fwSubmitManualBan('${jail}')">Ban IP</button>`);
};
window.fwSubmitManualBan = async (jail) => {
const ip = document.getElementById('mb-ip')?.value?.trim();
if (!ip) { Nova.toast('Enter an IP','error'); return; }
document.querySelector('.modal-overlay')?.remove();
const r = await Nova.api('firewall','f2b-ban',{method:'POST',body:{ip,jail}});
Nova.toast(r?.message || (r?.success ? 'Banned' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
};
window.fwF2bReload = async () => {
const r = await Nova.api('firewall','f2b-reload',{method:'POST'});
Nova.toast(r?.message || 'Reloaded', r?.success ? 'success' : 'error');
};
window.fwF2bRestart = async () => {
Nova.confirm('Restart Fail2Ban? Active bans will be preserved.', async () => {
const r = await Nova.api('firewall','f2b-restart',{method:'POST'});
Nova.toast(r?.message || 'Restarted', r?.success ? 'success' : 'error');
adminPage('firewall');
});
};
window.fwSetLogging = async () => {
const level = document.getElementById('fw-log-level')?.value;
const r = await Nova.api('firewall','set-logging',{method:'POST',body:{level}});
Nova.toast(r?.message || 'Logging updated', r?.success ? 'success' : 'error');
};
// ── MySQL/DB Manager ───────────────────────────────────────────────────────
+5 -1
View File
@@ -116,10 +116,14 @@ window.Nova = (() => {
return `<span class="service-dot ${cls}"></span>`;
}
function escHtml(str) {
return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Inject global CSS animation
const style = document.createElement('style');
style.textContent = '@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}';
document.head.appendChild(style);
return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot };
return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot, escHtml };
})();