Skip to content

Commit 4bbb456

Browse files
feat: DOMPurify XSS hardening + ES module split for static JS (#36)
* feat(claude-code-chat-browser): add DOMPurify XSS hardening and split app.js into modules Sanitize all session markdown before innerHTML assignment via a single renderMarkdown() wrapper in shared/markdown.js that applies DOMPurify.sanitize(marked.parse(...)). DOMPurify 3.2.7 loaded from CDN with SRI and crossorigin="anonymous" (issue #295). Split the 955-line app.js monolith into six route/view modules: sessions.js, projects.js, search.js, export.js, four shared modules (state, utils, markdown, theme), and a thin app.js bootstrap with window registrations for inline handlers. Entry switched to <script type="module"> (no build step). Add tests/test_xss_sanitization.py (8 source-level regression tests) and update test_hljs_theme_consistency.py for the HLJS_THEME_SHEETS move to shared/theme.js. All 229 tests pass. * Fix: wrong import path * fix: harden routing, encoding, error handling, and XSS in frontend modules * fix(search): ignore stale async search responses * Fix claude css line issue
1 parent 5b64643 commit 4bbb456

13 files changed

Lines changed: 1188 additions & 923 deletions

static/css/style.css

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,37 @@ h3 { font-size: 1.15rem; font-weight: 600; }
279279
flex-wrap: wrap;
280280
}
281281

282+
/* Projects home: title + actions on one row; intro + last export on the next row (same baseline). */
283+
.page-header--projects {
284+
flex-direction: column;
285+
align-items: stretch;
286+
}
287+
.page-header--projects .page-header__top {
288+
display: flex;
289+
align-items: center;
290+
justify-content: space-between;
291+
gap: 1rem;
292+
flex-wrap: wrap;
293+
}
294+
.page-header--projects .page-header__subrow {
295+
display: flex;
296+
align-items: baseline;
297+
justify-content: space-between;
298+
gap: 1rem;
299+
flex-wrap: wrap;
300+
}
301+
.page-header--projects .page-header__intro {
302+
margin: 0;
303+
flex: 1 1 12rem;
304+
}
305+
.page-header--projects .page-header__export-meta {
306+
flex: 0 1 auto;
307+
text-align: right;
308+
}
309+
.page-header--projects .page-header__export-meta p {
310+
margin: 0;
311+
}
312+
282313
/* ---------- Utility ---------- */
283314
.flex-between { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
284315

static/index.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@
2323
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"
2424
integrity="sha512-pSeTnZAQF/RHxb0ysMoYQI/BRZsa5XuklcrgFfU3YZIdnD3LvkkqzrIeHxzFi6gKtI8Cpq2DEWdZjMTcNVhUYA=="
2525
crossorigin="anonymous"></script>
26+
<!-- DOMPurify sanitizes marked.parse() output before innerHTML assignment (issue #295).
27+
SRI hash verified against cdnjs SRI API:
28+
curl https://api.cdnjs.com/libraries/dompurify/3.2.7?fields=sri -->
29+
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.7/purify.min.js"
30+
integrity="sha512-78KH17QLT5e55GJqP76vutp1D2iAoy06WcYBXB6iBCsmO6wWzx0Qdg8EDpm8mKXv68BcvHOyeeP4wxAL0twJGQ=="
31+
crossorigin="anonymous"></script>
2632
</head>
2733
<body>
2834
<!-- Navbar -->
@@ -62,6 +68,6 @@
6268
</div>
6369
</footer>
6470

65-
<script src="/static/js/app.js"></script>
71+
<script type="module" src="/static/js/app.js"></script>
6672
</body>
6773
</html>

static/js/app.js

Lines changed: 57 additions & 921 deletions
Large diffs are not rendered by default.

static/js/export.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Export and download — bulk export, per-session download, file handle helpers.
2+
3+
import { showToast, showConfirm } from './shared/utils.js';
4+
import { showProjects } from './projects.js';
5+
6+
// ==================== Export ====================
7+
8+
export function bulkExport(since = 'all') {
9+
const label = since === 'incremental' ? 'Export new sessions since last export?' : 'Export all sessions as a zip file?';
10+
showConfirm(label, async () => {
11+
const suffix = since === 'incremental' ? '-incremental' : '';
12+
const fname = `claude-code-export${suffix}-${new Date().toISOString().slice(0, 10)}.zip`;
13+
const handle = await getFileHandle(fname, [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }]);
14+
if (!handle) return;
15+
const btnId = since === 'incremental' ? '#btn-export-since' : '#btn-export-all';
16+
const btn = document.querySelector(btnId);
17+
const origText = btn ? btn.textContent.trim() : '';
18+
if (btn) { btn.disabled = true; btn.textContent = 'Exporting...'; }
19+
try {
20+
const res = await fetch('/api/export', {
21+
method: 'POST',
22+
headers: { 'Content-Type': 'application/json' },
23+
body: JSON.stringify({ since }),
24+
});
25+
const ct = res.headers.get('Content-Type') || '';
26+
if (!res.ok) {
27+
let msg = `Export failed: ${res.status}`;
28+
if (ct.includes('application/json')) {
29+
try {
30+
const errBody = await res.json();
31+
if (errBody.error) msg = errBody.error;
32+
} catch (_) { /* ignore */ }
33+
}
34+
throw new Error(msg);
35+
}
36+
const blob = await res.blob();
37+
await writeToHandle(handle, blob, fname);
38+
showProjects();
39+
} catch (e) {
40+
showToast('Export failed: ' + e.message, 'error');
41+
} finally {
42+
if (btn) { btn.disabled = false; btn.textContent = origText; }
43+
}
44+
});
45+
}
46+
47+
export async function downloadSession(project, sessionId) {
48+
const fname = `session-${sessionId.slice(0, 8)}.md`;
49+
const handle = await getFileHandle(fname, [{ description: 'Markdown', accept: { 'text/markdown': ['.md'] } }]);
50+
if (!handle) return;
51+
try {
52+
const res = await fetch(`/api/export/session/${encodeURIComponent(project)}/${encodeURIComponent(sessionId)}`);
53+
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
54+
const blob = await res.blob();
55+
await writeToHandle(handle, blob, fname);
56+
} catch (e) {
57+
showToast('Download failed: ' + e.message, 'error');
58+
}
59+
}
60+
61+
async function getFileHandle(suggestedName, fileTypes) {
62+
if (window.showSaveFilePicker) {
63+
try {
64+
return await window.showSaveFilePicker({ suggestedName, types: fileTypes });
65+
} catch (e) {
66+
if (e.name === 'AbortError') return null;
67+
}
68+
}
69+
return 'fallback';
70+
}
71+
72+
async function writeToHandle(handle, blob, fallbackName) {
73+
if (handle !== 'fallback') {
74+
const writable = await handle.createWritable();
75+
try {
76+
await writable.write(blob);
77+
await writable.close();
78+
} catch (e) {
79+
try { await writable.abort(); } catch { /* ignore abort errors */ }
80+
throw e;
81+
}
82+
} else {
83+
const url = URL.createObjectURL(blob);
84+
const a = document.createElement('a');
85+
a.href = url;
86+
a.download = fallbackName;
87+
document.body.appendChild(a);
88+
a.click();
89+
document.body.removeChild(a);
90+
setTimeout(() => URL.revokeObjectURL(url), 1000);
91+
}
92+
}

static/js/projects.js

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Projects home page — project list rendering.
2+
3+
import { state } from './shared/state.js';
4+
import { esc, formatDate, smoothSet, loadingBar, setHamburgerVisible } from './shared/utils.js';
5+
import { setWorkspaceMode } from './shared/theme.js';
6+
7+
// ==================== Projects (home) ====================
8+
9+
export async function showProjects() {
10+
state.currentProject = null;
11+
setHamburgerVisible(false);
12+
setWorkspaceMode(false);
13+
if (window.location.hash && window.location.hash !== '#') {
14+
state.navInProgress = true;
15+
window.location.hash = '';
16+
setTimeout(() => { state.navInProgress = false; }, 0);
17+
}
18+
const content = document.getElementById('content');
19+
content.innerHTML = '<div class="loading"><div class="spinner"></div><p>Loading projects...</p></div>';
20+
loadingBar.start();
21+
22+
try {
23+
// Load projects first. Do not Promise.all with /api/export/state: that endpoint
24+
// takes a file lock; if it blocks (stale lock, slow disk), the UI would hang here
25+
// forever even though /api/projects already succeeded.
26+
const projRes = await fetch('/api/projects');
27+
if (!projRes.ok) {
28+
let msg = `Failed to load projects (${projRes.status})`;
29+
try { const body = await projRes.json(); if (body.error) msg = body.error; } catch { try { msg = await projRes.text() || msg; } catch { /* ignore */ } }
30+
loadingBar.done();
31+
smoothSet(content, `<div class="loading"><p class="text-danger">${esc(msg)}</p></div>`);
32+
return;
33+
}
34+
const projects = await projRes.json();
35+
36+
if (!projects.length) {
37+
loadingBar.done();
38+
smoothSet(content, '<div class="empty-state">No Claude Code projects found.<br>Make sure Claude Code has been used on this machine.</div>');
39+
return;
40+
}
41+
42+
for (const p of projects) state.projectDisplayNames[p.name] = p.display_name || p.name;
43+
projects.sort((a, b) => (b.last_modified || '').localeCompare(a.last_modified || ''));
44+
45+
let lastExportHtml = '';
46+
let hasPreviousExport = false;
47+
let stateRes = null;
48+
const exportCtrl = new AbortController();
49+
const exportTid = setTimeout(() => exportCtrl.abort(), 5000);
50+
try {
51+
stateRes = await fetch('/api/export/state', { signal: exportCtrl.signal });
52+
} catch {
53+
/* timeout or network — still render project list */
54+
} finally {
55+
clearTimeout(exportTid);
56+
}
57+
if (stateRes && stateRes.ok) {
58+
try {
59+
const exportState = await stateRes.json();
60+
if (exportState.last_export_time) {
61+
const d = new Date(exportState.last_export_time);
62+
if (!isNaN(d.getTime())) {
63+
hasPreviousExport = true;
64+
const sessionCount = Math.max(0, parseInt(exportState.last_export_session_count ?? exportState.export_count ?? 0, 10) || 0);
65+
lastExportHtml = `<p class="text-muted text-sm">Last export: ${d.toLocaleString()} (${sessionCount} sessions in last export)</p>`;
66+
}
67+
}
68+
} catch(e) {}
69+
}
70+
71+
const sinceBtnHtml = hasPreviousExport
72+
? `<button class="btn btn-primary btn-sm" id="btn-export-since" onclick="bulkExport('incremental')">
73+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
74+
Export new since last
75+
</button>`
76+
: '';
77+
78+
let html = `<div class="page-header page-header--projects">
79+
<div class="page-header__top">
80+
<h1>Projects</h1>
81+
<div class="btn-group">
82+
${sinceBtnHtml}
83+
<button class="btn btn-outline btn-sm" id="btn-export-all" onclick="bulkExport('all')">
84+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
85+
Export all
86+
</button>
87+
</div>
88+
</div>
89+
<div class="page-header__subrow">
90+
<p class="text-muted page-header__intro">Browse your Claude Code conversations by project. Click on a project to view its sessions.</p>
91+
${lastExportHtml ? `<div class="page-header__export-meta">${lastExportHtml}</div>` : ''}
92+
</div>
93+
</div>`;
94+
95+
const withSessions = projects.filter(p => (p.session_count || 0) > 0);
96+
const noSessions = projects.filter(p => (p.session_count || 0) === 0);
97+
98+
if (withSessions.length) {
99+
html += `<div class="card">
100+
<div class="card-header">
101+
<h2 class="card-title">Projects with Sessions</h2>
102+
<p class="text-muted text-sm">${withSessions.length} project${withSessions.length !== 1 ? 's' : ''} with chat history</p>
103+
</div>
104+
<div class="card-body" style="padding:0">
105+
<table class="table">
106+
<thead><tr><th>Project</th><th>Sessions</th><th>Last Modified</th></tr></thead>
107+
<tbody>`;
108+
for (const p of withSessions) {
109+
const displayName = p.display_name || p.name;
110+
const count = p.session_count || 0;
111+
html += `<tr>
112+
<td><a href="#project/${encodeURIComponent(p.name)}" class="link">${esc(displayName)}</a></td>
113+
<td><span class="text-success">${count} session${count !== 1 ? 's' : ''}</span></td>
114+
<td>${esc(p.last_modified ? formatDate(p.last_modified) : '')}</td>
115+
</tr>`;
116+
}
117+
html += `</tbody></table></div></div>`;
118+
}
119+
120+
if (noSessions.length) {
121+
html += `<div class="card" style="margin-top:1.5rem">
122+
<div class="card-header">
123+
<h2 class="card-title">Projects without Sessions</h2>
124+
<p class="text-muted text-sm">${noSessions.length} project${noSessions.length !== 1 ? 's' : ''} with no sessions found</p>
125+
</div>
126+
<div class="card-body">
127+
<div class="alert alert-info">These projects may have no recorded sessions yet.</div>
128+
<table class="table">
129+
<thead><tr><th>Project</th><th>Sessions</th><th>Last Modified</th></tr></thead>
130+
<tbody>`;
131+
for (const p of noSessions) {
132+
html += `<tr>
133+
<td><span class="text-muted">${esc(p.display_name || p.name)}</span></td>
134+
<td><span class="text-muted">0</span></td>
135+
<td>${esc(p.last_modified ? formatDate(p.last_modified) : '')}</td>
136+
</tr>`;
137+
}
138+
html += `</tbody></table></div></div>`;
139+
}
140+
141+
loadingBar.done();
142+
smoothSet(content, html);
143+
} catch (e) {
144+
loadingBar.done();
145+
smoothSet(content, `<div class="loading"><p class="text-danger">Failed to load projects.</p></div>`);
146+
}
147+
}

static/js/search.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Search page — search UI and result rendering.
2+
3+
import { esc, smoothSet, setHamburgerVisible } from './shared/utils.js';
4+
import { setWorkspaceMode } from './shared/theme.js';
5+
6+
// ==================== Search ====================
7+
8+
let lastSearchRequestId = 0;
9+
10+
export function showSearchPage() {
11+
setHamburgerVisible(false);
12+
setWorkspaceMode(false);
13+
window.location.hash = '#search';
14+
const content = document.getElementById('content');
15+
content.innerHTML = `
16+
<div class="search-page">
17+
<a class="back-link" href="#" onclick="showProjects();return false;">
18+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
19+
Back to Projects
20+
</a>
21+
<br><br>
22+
<h1>Search</h1>
23+
<div class="search-bar">
24+
<input class="input" type="text" id="search-input" placeholder="Search conversations..." autofocus
25+
onkeydown="if(event.key==='Enter') doSearch()">
26+
<button class="btn btn-primary" onclick="doSearch()">Search</button>
27+
</div>
28+
<div id="search-results"></div>
29+
</div>`;
30+
document.getElementById('search-input').focus();
31+
}
32+
33+
export async function doSearch() {
34+
const localRequestId = ++lastSearchRequestId;
35+
const input = document.getElementById('search-input');
36+
if (!input) { showSearchPage(); return; }
37+
const query = input.value.trim();
38+
if (!query) return;
39+
40+
const container = document.getElementById('search-results');
41+
container.innerHTML = '<div class="loading">Searching...</div>';
42+
43+
try {
44+
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=50`);
45+
if (localRequestId !== lastSearchRequestId) return;
46+
if (!res.ok) {
47+
let msg = `Search failed (${res.status})`;
48+
try { msg = await res.text() || msg; } catch { /* ignore */ }
49+
if (localRequestId !== lastSearchRequestId) return;
50+
throw new Error(msg);
51+
}
52+
const results = await res.json();
53+
if (localRequestId !== lastSearchRequestId) return;
54+
55+
let html = `<p class="text-muted text-sm">${results.length} result${results.length !== 1 ? 's' : ''}</p><br>`;
56+
html += '<div class="search-results">';
57+
58+
for (const r of results) {
59+
html += `<div class="search-result" onclick="window.location.hash='#project/${encodeURIComponent(r.project)}/${encodeURIComponent(r.session_id)}'">
60+
<div><strong>${esc(r.title)}</strong> <span class="text-muted text-sm">${esc(r.project)} &bull; ${esc(r.role)}</span></div>
61+
<div class="snippet">...${esc(r.snippet)}...</div>
62+
</div>`;
63+
}
64+
65+
if (!results.length) html += '<div class="empty-state">No results found.</div>';
66+
html += '</div>';
67+
smoothSet(container, html);
68+
} catch (e) {
69+
if (localRequestId !== lastSearchRequestId) return;
70+
container.innerHTML = `<div class="loading">Error: ${esc(e.message)}</div>`;
71+
}
72+
}

0 commit comments

Comments
 (0)