dragonflight/services/web-ui/public/projects.html
ZGaetano 1e8cde81be fix(projects): prevent JS injection via bin names in onclick handlers
binCard() was building onclick="renameBinPrompt('id', 'NAME')" by
calling esc() then .replace(/'/g, "\\'").  The problem: esc() converts
' to ', so the replace never fires on raw single quotes.  When the
HTML parser evaluates the attribute it decodes ' back to ', breaking
the JS string — and for injected payloads like `'; alert(1)//` this is
stored XSS.

Fix: use JSON.stringify(b.name) to produce a properly-escaped double-
quoted JS string literal, then esc() to HTML-encode the surrounding
double-quotes for safe embedding in the HTML attribute.
2026-05-19 00:09:49 -04:00

570 lines
24 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<title>Projects — Z-AMPP</title>
<link rel="stylesheet" href="css/common.css">
<style>
.proj-shell { display: flex; flex: 1; overflow: hidden; }
.proj-list-panel {
width: 340px;
flex-shrink: 0;
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
background: oklch(11% 0.018 250 / 0.6);
}
.proj-list-header {
padding: 16px 18px 12px;
display: flex; align-items: center; justify-content: space-between;
gap: 8px;
border-bottom: 1px solid var(--border);
}
.proj-list-title {
font-size: 11px; font-weight: 600;
letter-spacing: 0.16em; text-transform: uppercase;
color: var(--text-tertiary);
}
.proj-list-count {
font-size: 11px; font-family: var(--font-mono);
color: var(--text-tertiary);
}
.proj-list-search {
padding: 8px 14px 0;
}
.proj-list-search input {
width: 100%;
padding: 8px 12px;
background: oklch(15% 0.020 250);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-primary);
font-size: 13px;
}
.proj-list { flex: 1; overflow: auto; padding: 8px; }
.proj-list-empty {
padding: 32px 18px;
text-align: center;
color: var(--text-tertiary);
font-size: 13px;
}
.proj-row {
display: flex; flex-direction: column; gap: 6px;
padding: 12px 14px;
border-radius: var(--r-sm);
border: 1px solid transparent;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.proj-row:hover { background: oklch(15% 0.020 250 / 0.5); }
.proj-row.active {
background: oklch(18% 0.030 260 / 0.7);
border-color: oklch(45% 0.20 266 / 0.45);
}
.proj-row-name {
font-size: 14px; font-weight: 500;
color: var(--text-primary);
display: flex; align-items: center; gap: 8px;
}
.proj-row-meta {
display: flex; gap: 12px; align-items: center;
font-size: 11px; color: var(--text-tertiary);
font-family: var(--font-mono);
}
.proj-row-meta b { color: var(--text-secondary); font-weight: 600; }
.proj-detail {
flex: 1; display: flex; flex-direction: column;
overflow: hidden;
background: var(--bg-base);
}
.proj-detail-empty {
flex: 1; display: flex; align-items: center; justify-content: center;
color: var(--text-tertiary); font-size: 13px;
flex-direction: column; gap: 12px;
}
.proj-detail-header {
padding: 24px 32px 16px;
border-bottom: 1px solid var(--border);
}
.proj-detail-eyebrow {
font-size: 11px; font-weight: 600;
letter-spacing: 0.16em; text-transform: uppercase;
color: var(--text-tertiary);
margin-bottom: 8px;
}
.proj-detail-title {
display: flex; align-items: center; gap: 12px;
font-size: 22px; font-weight: 600;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.proj-detail-title input {
background: transparent;
border: 1px solid transparent;
border-radius: var(--r-sm);
padding: 4px 8px;
color: var(--text-primary);
font: inherit;
min-width: 240px;
}
.proj-detail-title input:focus,
.proj-detail-title input:hover {
border-color: var(--border);
background: oklch(13% 0.018 250);
}
.proj-detail-desc {
margin-top: 8px;
font-size: 13px; color: var(--text-secondary);
line-height: 1.55;
}
.proj-detail-desc textarea {
width: 100%; min-height: 56px; resize: vertical;
background: transparent;
border: 1px solid transparent;
border-radius: var(--r-sm);
padding: 6px 8px;
color: var(--text-secondary); font: inherit;
}
.proj-detail-desc textarea:focus,
.proj-detail-desc textarea:hover {
border-color: var(--border);
background: oklch(13% 0.018 250);
}
.proj-detail-stats {
display: flex; gap: 24px;
margin-top: 14px;
font-size: 12px; font-family: var(--font-mono);
color: var(--text-tertiary);
}
.proj-detail-stats b { color: var(--text-primary); font-weight: 600; }
.proj-detail-actions {
margin-top: 16px;
display: flex; gap: 8px;
}
.proj-bins {
flex: 1; overflow: auto;
padding: 24px 32px 40px;
}
.proj-bins-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px;
}
.proj-bins-title {
font-size: 11px; font-weight: 600;
letter-spacing: 0.16em; text-transform: uppercase;
color: var(--text-tertiary);
}
.proj-bin-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.proj-bin-card {
position: relative;
padding: 16px;
background: oklch(13% 0.018 250 / 0.6);
border: 1px solid oklch(28% 0.04 260 / 0.4);
border-radius: var(--r-sm);
display: flex; flex-direction: column; gap: 6px;
}
.proj-bin-card-name {
font-size: 14px; font-weight: 500;
color: var(--text-primary);
display: flex; align-items: center; gap: 8px;
}
.proj-bin-card-meta {
font-size: 11px; color: var(--text-tertiary);
font-family: var(--font-mono);
}
.proj-bin-card-actions {
position: absolute; top: 10px; right: 10px;
display: flex; gap: 4px;
opacity: 0; transition: opacity 120ms ease;
}
.proj-bin-card:hover .proj-bin-card-actions { opacity: 1; }
.proj-bin-empty {
grid-column: 1 / -1;
padding: 32px;
text-align: center;
color: var(--text-tertiary);
font-size: 13px;
border: 1px dashed var(--border);
border-radius: var(--r-sm);
}
/* Modal */
.modal-overlay {
position: fixed; inset: 0;
background: oklch(0% 0 0 / 0.6);
backdrop-filter: blur(4px);
display: none; align-items: center; justify-content: center;
z-index: 100;
}
.modal-overlay.open { display: flex; }
.modal {
width: min(420px, 90vw);
background: oklch(15% 0.025 250);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
box-shadow: 0 30px 60px -20px oklch(0% 0 0 / 0.5);
}
.modal h3 {
font-size: 16px; font-weight: 600;
margin-bottom: 12px;
color: var(--text-primary);
}
.modal label {
display: block;
font-size: 11px; font-weight: 600;
letter-spacing: 0.06em; text-transform: uppercase;
color: var(--text-tertiary);
margin-bottom: 6px; margin-top: 12px;
}
.modal input, .modal textarea {
width: 100%;
padding: 10px 12px;
background: oklch(11% 0.015 250);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-primary);
font: inherit; font-size: 14px;
}
.modal input:focus, .modal textarea:focus {
outline: none; border-color: oklch(45% 0.20 266 / 0.6);
}
.modal-actions {
display: flex; gap: 8px; justify-content: flex-end;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="shell">
<nav class="sidebar" aria-label="Main navigation">
<div class="sidebar-brand">
<img src="img/dragon-logo.png?v=1" alt="Z-AMPP" class="sidebar-logo">
<span class="sidebar-brand-name">Z-AMPP</span>
</div>
<nav class="sidebar-nav">
<a href="home.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
Home
</a>
<a href="index.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
Library
</a>
<a href="projects.html" class="nav-item active">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
Projects
</a>
<a href="upload.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
Ingest
</a>
<a href="recorders.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
Recorders
</a>
<a href="capture.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
Capture
</a>
<a href="jobs.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
Jobs
</a>
<a href="editor.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
Editor
</a>
<div class="sidebar-section-label">Admin</div>
<a href="users.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
Users
</a>
<a href="tokens.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
Tokens
</a>
</nav>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="sidebar-user-avatar" id="userAvatar">?</div>
<div class="sidebar-user-info">
<div class="sidebar-user-name" id="userName"></div>
<div class="sidebar-user-role" id="userRole"></div>
</div>
<button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
</button>
</div>
</div>
</nav>
<div class="main">
<header class="topbar">
<div class="topbar-left">
<span class="page-title">Projects</span>
</div>
<div class="topbar-right">
<button class="btn btn-primary btn-sm" onclick="openNewProject()">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M8 3v10M3 8h10"/></svg>
New project
</button>
</div>
</header>
<div class="proj-shell">
<!-- Left: project list -->
<aside class="proj-list-panel">
<div class="proj-list-header">
<span class="proj-list-title">All Projects</span>
<span class="proj-list-count" id="projCount">--</span>
</div>
<div class="proj-list-search">
<input type="text" id="projSearch" placeholder="Search projects…" />
</div>
<div class="proj-list" id="projList">
<div class="proj-list-empty">Loading…</div>
</div>
</aside>
<!-- Right: detail / bins -->
<section class="proj-detail" id="projDetail">
<div class="proj-detail-empty">
<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1" width="48" height="48" style="opacity:0.4">
<path d="M3 9a1 1 0 0 1 1-1h9l3 3h12a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/>
</svg>
<div>Select a project on the left, or create a new one.</div>
</div>
</section>
</div>
</div>
</div>
<!-- New project modal -->
<div class="modal-overlay" id="newProjModal">
<div class="modal">
<h3>New project</h3>
<label>Name</label>
<input type="text" id="newProjName" placeholder="e.g. 2026 Sunday Service" autocomplete="off" />
<label>Description (optional)</label>
<textarea id="newProjDesc" rows="2" placeholder="A short note about the project"></textarea>
<div class="modal-actions">
<button class="btn btn-ghost btn-sm" onclick="closeModal('newProjModal')">Cancel</button>
<button class="btn btn-primary btn-sm" onclick="createProject()">Create</button>
</div>
</div>
</div>
<!-- New bin modal -->
<div class="modal-overlay" id="newBinModal">
<div class="modal">
<h3>New bin</h3>
<label>Name</label>
<input type="text" id="newBinName" placeholder="e.g. Cameras, B-Roll, Interviews" autocomplete="off" />
<div class="modal-actions">
<button class="btn btn-ghost btn-sm" onclick="closeModal('newBinModal')">Cancel</button>
<button class="btn btn-primary btn-sm" onclick="createBin()">Create</button>
</div>
</div>
</div>
<script src="js/api.js"></script>
<script src="js/topbar-strip.js"></script>
<script>
const state = { projects: [], filtered: [], selectedId: null, bins: [], assetsByProject: {} };
// ── Modal helpers ─────────────────────────
function openModal(id) { document.getElementById(id).classList.add('open'); setTimeout(() => { const i = document.querySelector('#' + id + ' input'); if (i) i.focus(); }, 50); }
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
function openNewProject() { document.getElementById('newProjName').value = ''; document.getElementById('newProjDesc').value = ''; openModal('newProjModal'); }
function openNewBin() { if (!state.selectedId) return; document.getElementById('newBinName').value = ''; openModal('newBinModal'); }
// ── API ────────────────────────────────────
async function api(path, opts = {}) {
const r = await fetch('/api/v1' + path, Object.assign({ credentials: 'include', headers: { 'Content-Type': 'application/json' } }, opts));
if (!r.ok) throw new Error('HTTP ' + r.status + ' on ' + path);
return r.json();
}
async function loadAll() {
try {
const projs = await api('/projects');
state.projects = Array.isArray(projs) ? projs : [];
// Hydrate asset counts per project
const counts = await Promise.all(state.projects.map(async (p) => {
try {
const r = await api('/assets?project_id=' + encodeURIComponent(p.id) + '&limit=1');
return { id: p.id, count: r.total ?? 0 };
} catch { return { id: p.id, count: 0 }; }
}));
counts.forEach(c => { state.assetsByProject[c.id] = c.count; });
filterAndRender();
if (state.selectedId) await loadBins(state.selectedId);
} catch (e) {
document.getElementById('projList').innerHTML = '<div class="proj-list-empty" style="color:var(--status-red)">Failed to load: ' + e.message + '</div>';
}
}
function filterAndRender() {
const q = (document.getElementById('projSearch').value || '').trim().toLowerCase();
state.filtered = state.projects.filter(p => !q || p.name.toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q));
document.getElementById('projCount').textContent = state.filtered.length + (state.filtered.length === state.projects.length ? '' : ' / ' + state.projects.length);
const list = document.getElementById('projList');
if (state.filtered.length === 0) {
list.innerHTML = '<div class="proj-list-empty">' + (q ? 'No matches.' : 'No projects yet. Create one with the button above.') + '</div>';
return;
}
list.innerHTML = state.filtered.map(p => {
const cnt = state.assetsByProject[p.id] ?? 0;
const active = p.id === state.selectedId ? ' active' : '';
const created = new Date(p.created_at).toLocaleDateString();
return '<div class="proj-row' + active + '" onclick="selectProject(\'' + p.id + '\')"><div class="proj-row-name">' + esc(p.name) + '</div><div class="proj-row-meta"><span><b>' + cnt + '</b> assets</span><span>' + created + '</span></div></div>';
}).join('');
}
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
// ── Selection / detail ─────────────────────
async function selectProject(id) {
state.selectedId = id;
filterAndRender();
await loadBins(id);
}
async function loadBins(projectId) {
const p = state.projects.find(x => x.id === projectId);
if (!p) return;
try {
state.bins = await api('/bins?project_id=' + encodeURIComponent(projectId));
} catch { state.bins = []; }
renderDetail(p);
}
function renderDetail(p) {
const host = document.getElementById('projDetail');
const cnt = state.assetsByProject[p.id] ?? 0;
const created = new Date(p.created_at).toLocaleString();
host.innerHTML =
'<div class="proj-detail-header">' +
'<div class="proj-detail-eyebrow">Project</div>' +
'<div class="proj-detail-title"><input id="detailName" value="' + esc(p.name) + '" onblur="renameProject(\'' + p.id + '\', this.value)"></div>' +
'<div class="proj-detail-desc"><textarea id="detailDesc" placeholder="Description…" onblur="updateDesc(\'' + p.id + '\', this.value)">' + esc(p.description || '') + '</textarea></div>' +
'<div class="proj-detail-stats">' +
'<span><b>' + cnt + '</b> assets</span>' +
'<span><b>' + state.bins.length + '</b> bins</span>' +
'<span>Created ' + created + '</span>' +
'</div>' +
'<div class="proj-detail-actions">' +
'<button class="btn btn-primary btn-sm" onclick="openNewBin()"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M8 3v10M3 8h10"/></svg>New bin</button>' +
'<button class="btn btn-ghost btn-sm" onclick="location.href=\'index.html?project=' + p.id + '\'">Open in Library</button>' +
'<button class="btn btn-danger btn-sm" style="margin-left:auto" onclick="deleteProject(\'' + p.id + '\')">Delete project</button>' +
'</div>' +
'</div>' +
'<div class="proj-bins">' +
'<div class="proj-bins-header"><span class="proj-bins-title">Bins</span></div>' +
'<div class="proj-bin-grid">' +
(state.bins.length === 0
? '<div class="proj-bin-empty">No bins yet. Use <b>New bin</b> above to make your first one.</div>'
: state.bins.map(b => binCard(b)).join('')) +
'</div>' +
'</div>';
}
function binCard(b) {
// Use JSON.stringify + esc so the bin name is safe in an onclick JS string
// regardless of quotes, backslashes, or other special characters it may contain.
const nameJs = esc(JSON.stringify(b.name));
return '<div class="proj-bin-card">' +
'<div class="proj-bin-card-name">' +
'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>' +
esc(b.name) +
'</div>' +
'<div class="proj-bin-card-meta">Created ' + new Date(b.created_at).toLocaleDateString() + '</div>' +
'<div class="proj-bin-card-actions">' +
'<button class="btn btn-ghost btn-sm" style="padding:4px 8px" onclick="renameBinPrompt(\'' + b.id + '\', ' + nameJs + ')">Rename</button>' +
'<button class="btn btn-ghost btn-sm" style="padding:4px 8px;color:var(--status-red)" onclick="deleteBin(\'' + b.id + '\')">Delete</button>' +
'</div>' +
'</div>';
}
// ── Mutations ──────────────────────────────
async function createProject() {
const name = document.getElementById('newProjName').value.trim();
const description = document.getElementById('newProjDesc').value.trim();
if (!name) return alert('Name is required');
try {
const p = await api('/projects', { method: 'POST', body: JSON.stringify({ name, description }) });
closeModal('newProjModal');
state.selectedId = p.id;
await loadAll();
} catch (e) { alert('Create failed: ' + e.message); }
}
async function renameProject(id, name) {
name = (name || '').trim();
if (!name) return loadAll();
const cur = state.projects.find(p => p.id === id);
if (cur && cur.name === name) return;
try { await api('/projects/' + id, { method: 'PATCH', body: JSON.stringify({ name }) }); await loadAll(); }
catch (e) { alert('Rename failed: ' + e.message); }
}
async function updateDesc(id, description) {
const cur = state.projects.find(p => p.id === id);
if (cur && (cur.description || '') === description) return;
try { await api('/projects/' + id, { method: 'PATCH', body: JSON.stringify({ description }) }); await loadAll(); }
catch (e) { alert('Save failed: ' + e.message); }
}
async function deleteProject(id) {
const p = state.projects.find(x => x.id === id);
const cnt = state.assetsByProject[id] ?? 0;
if (!confirm('Delete project "' + p.name + '"?\n\nThis will also delete its ' + cnt + ' asset(s) and any bins. This cannot be undone.')) return;
try {
await api('/projects/' + id, { method: 'DELETE' });
state.selectedId = null;
document.getElementById('projDetail').innerHTML = '<div class="proj-detail-empty"><svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1" width="48" height="48" style="opacity:0.4"><path d="M3 9a1 1 0 0 1 1-1h9l3 3h12a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/></svg><div>Select a project on the left, or create a new one.</div></div>';
await loadAll();
} catch (e) { alert('Delete failed: ' + e.message); }
}
async function createBin() {
const name = document.getElementById('newBinName').value.trim();
if (!name || !state.selectedId) return;
try {
await api('/bins', { method: 'POST', body: JSON.stringify({ project_id: state.selectedId, name }) });
closeModal('newBinModal');
await loadBins(state.selectedId);
} catch (e) { alert('Create bin failed: ' + e.message); }
}
async function renameBinPrompt(id, current) {
const name = prompt('Rename bin', current);
if (!name || name === current) return;
try { await api('/bins/' + id, { method: 'PATCH', body: JSON.stringify({ name }) }); await loadBins(state.selectedId); }
catch (e) { alert('Rename failed: ' + e.message); }
}
async function deleteBin(id) {
if (!confirm('Delete this bin? Assets inside the bin will become un-binned (still in the project).')) return;
try { await api('/bins/' + id, { method: 'DELETE' }); await loadBins(state.selectedId); }
catch (e) { alert('Delete failed: ' + e.message); }
}
// ── Init ────────────────────────────────────
document.getElementById('projSearch').addEventListener('input', filterAndRender);
document.querySelectorAll('.modal-overlay').forEach(el => el.addEventListener('click', e => { if (e.target === el) el.classList.remove('open'); }));
document.addEventListener('keydown', e => { if (e.key === 'Escape') document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open')); });
loadAll();
</script>
</body>
</html>