fix(web-ui): security hardening, SPA nav, and modularization
Ship the in-progress ES-module refactor of the web-ui (new static/modules/ layout, Usage/Settings pages, uplot-based dashboard) alongside a round of security and UX fixes: - main.go: add CSP + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Referrer-Policy middleware on every response; WS CheckOrigin now requires Origin host to match Host (blocks cross-site WebSocket hijacking); upgrade client before dialing upstream so origin check runs first; fatal on unparseable AGENTMON_QUERY_BASE. - app.js: delegated click handler intercepts same-origin <a> clicks for SPA navigation (prev. every nav link caused a full page reload, dropping WS + in-memory state); delegated .copy-btn[data-copy] handler replaces inline onclick=; removed window.navigate / window.copyToClipboard globals and the duplicated handleGlobalSearch. - modules/nav-signal.js: per-route AbortController so in-flight fetches are cancelled when the user navigates away, preventing stale toasts and wasted renders. - modules/api.js: honours the nav signal by default; AbortError is silent. - modules/router.js: resets the nav controller on every route; dropped the fixed 80ms transition delay; breadcrumbs no longer emit inline onclick= (delegated handler picks them up). - modules/utils.js: renderCopyButton emits data-copy=\"...\" instead of nesting a JS string inside an HTML attribute — fixes an XSS where values containing ' broke out via ' decoding. Verified: go build clean; `node --check` clean on all modified modules; manual curl probes confirm security headers present on every response and WS upgrade returns 403 for cross-origin/missing Origin while 101 for same-origin. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
// ── api.js — fetch wrapper, toast, clipboard ─────────────
|
||||
|
||||
import { getNavSignal } from './nav-signal.js';
|
||||
|
||||
export async function api(path, opts) {
|
||||
opts = opts || {};
|
||||
const signal = opts.signal !== undefined ? opts.signal : getNavSignal();
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch('/api' + path, { signal });
|
||||
} catch (err) {
|
||||
// Route changed or caller aborted — swallow silently, no toast.
|
||||
if (err && err.name === 'AbortError') throw err;
|
||||
showToast('Network error: ' + (err && err.message ? err.message : 'fetch failed'), 'error');
|
||||
throw err;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
const msg = body.error || 'Request failed (' + resp.status + ')';
|
||||
showToast(msg, 'error');
|
||||
throw new Error(msg);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export function showToast(message, type) {
|
||||
// Limit to 3 stacked toasts
|
||||
const existing = document.querySelectorAll('.toast');
|
||||
if (existing.length >= 3) existing[0].remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast toast-' + (type || 'info');
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Stack: offset each toast by its position
|
||||
const stackToasts = () => {
|
||||
document.querySelectorAll('.toast').forEach((t, i) => {
|
||||
t.style.bottom = (2 + i * 3.5) + 'rem';
|
||||
});
|
||||
};
|
||||
stackToasts();
|
||||
|
||||
requestAnimationFrame(() => toast.classList.add('visible'));
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('visible');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
stackToasts();
|
||||
}, 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
export function copyToClipboard(text, el) {
|
||||
if (!text) return;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast('Copied to clipboard', 'success');
|
||||
if (el) {
|
||||
const originalText = el.textContent;
|
||||
el.textContent = 'Copied!';
|
||||
setTimeout(() => { el.textContent = originalText; }, 1500);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
showToast('Copy failed', 'error');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user