Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,25 @@
from api.sessions import sessions_bp
from utils.exclusion_rules import load_rules, resolve_exclusion_rules_path

# Content-Security-Policy for all Flask responses. 'unsafe-inline' in style-src is
# required because highlight.js themes apply inline styles; can be tightened with
# nonces later. script-src lists cdnjs only — keep in sync with SRI <script>/<link>
# sources in static/index.html.
CSP_POLICY = "; ".join(
[
"default-src 'self'",
"script-src 'self' https://cdnjs.cloudflare.com",
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com",
Comment thread
clean6378-max-it marked this conversation as resolved.
"img-src 'self' data:",
"connect-src 'self'",
"font-src 'self'",
"object-src 'none'",
"form-action 'self'",
"base-uri 'self'",
"frame-ancestors 'none'",
]
)


def _normalize_bind_host(host: str) -> str:
"""Lowercase host for checks; strip optional IPv6 brackets (e.g. ``[::1]`` → ``::1``)."""
Expand Down Expand Up @@ -83,6 +102,12 @@ def create_app(
app.register_blueprint(search_bp)
app.register_blueprint(export_bp)

@app.after_request
def set_security_headers(response):
# Always set — do not use setdefault; a blueprint must not weaken CSP.
response.headers["Content-Security-Policy"] = CSP_POLICY
return response

@app.route("/")
def index():
return app.send_static_file("index.html")
Expand Down
19 changes: 19 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,25 @@ The UI is a **hash-routed** SPA with ES modules under `static/js/`:

No bundler step — modern browsers load modules directly. Frontend unit tests use **vitest** + **jsdom** (`npm test`), including `static/js/render/registry.test.js` for registry wiring and renderer escaping.

## Content-Security-Policy

`create_app()` registers an `@app.after_request` hook that sets a `Content-Security-Policy` header on every Flask response. The policy is defined as `CSP_POLICY` in `app.py`:

| Directive | Sources | Notes |
|-----------|---------|-------|
| `default-src` | `'self'` | Fallback for unspecified fetch types |
| `script-src` | `'self'`, `https://cdnjs.cloudflare.com` | Self-hosted JS (e.g. `theme-init.js`, ES modules) plus SRI-pinned CDN scripts in `index.html` |
| `style-src` | `'self'`, `'unsafe-inline'`, `https://cdnjs.cloudflare.com` | `'unsafe-inline'` required for highlight.js theme inline styles **and** the app's own inline `style` attributes (e.g. hamburger `display:none` in `index.html`, layout tweaks in JS templates). Dropping highlight.js alone does not remove this need; nonces are the future tightening path |
| `img-src` | `'self'`, `data:` | Session images and data URLs |
| `connect-src` | `'self'` | API `fetch` calls to same origin |
| `font-src` | `'self'` | Local fonts only |
| `object-src` | `'none'` | Block plugins / `<object>` embeds (no plugin use in this app) |
| `form-action` | `'self'` | Restrict form submissions to same origin |
| `base-uri` | `'self'` | Restrict `<base>` tag injection |
| `frame-ancestors` | `'none'` | Prevent clickjacking via iframes |

**Keeping CDN sources in sync:** when adding or bumping a CDN asset in `static/index.html`, update both the SRI `integrity` hash and `CSP_POLICY` if the origin changes (today all CDN assets use `cdnjs.cloudflare.com`). Recompute SRI hashes against the live CDN payload when bumping highlight.js — `tests/test_hljs_theme_consistency.py` cross-checks `index.html`, `hljs-theme-init.js`, and `theme.js` stay in sync with each other (not the live CDN, which would be flaky in CI). Theme-init scripts were externalized to `static/js/theme-init.js` and `static/js/hljs-theme-init.js` so `script-src` does not require `'unsafe-inline'`. Navbar and route UI handlers use `addEventListener` instead of inline `onclick` attributes for the same reason.

## Continuous integration

[`.github/workflows/ci.yml`](../.github/workflows/ci.yml) runs on push/PR:
Expand Down
29 changes: 6 additions & 23 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Code Chat Browser</title>
<script>
/* Apply saved theme before first paint (avoids dark flash in light mode).
hljs URLs/integrity must match HLJS_THEME_SHEETS in static/js/shared/theme.js */
(function () {
var t = localStorage.getItem('theme');
if (t !== 'light' && t !== 'dark') t = 'dark';
document.documentElement.setAttribute('data-theme', t);
})();
</script>
<script src="/static/js/theme-init.js"></script>
<link rel="stylesheet" href="/static/css/style.css">
<!-- SRI hashes pin each CDN asset to a specific known-good payload; the
browser refuses anything whose hash does not match (issue #19).
Expand All @@ -26,16 +18,7 @@
integrity="sha512-mtXspRdOWHCYp+f4c7CkWGYPPRAhq9X+xCvJMUBVAb6pqA4U8pxhT3RWT3LP3bKbiolYL2CkL1bSKZZO4eeTew=="
crossorigin="anonymous"
id="hljs-theme">
<script>
(function () {
var t = document.documentElement.getAttribute('data-theme') || 'dark';
if (t !== 'light') return;
var link = document.getElementById('hljs-theme');
if (!link) return;
link.integrity = 'sha512-0aPQyyeZrWgKOP0mUipLQ6OZXu8l4IcAmD2u31EPEy9VcIMvl7SoAaKe8bLXZhYoMaE/in+gcgA==';
link.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css';
})();
</script>
<script src="/static/js/hljs-theme-init.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"
integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ=="
crossorigin="anonymous"></script>
Expand All @@ -53,15 +36,15 @@
<!-- Navbar -->
<nav class="navbar">
<div class="navbar-inner">
<button class="hamburger" id="hamburger-btn" onclick="toggleSidebar()" aria-label="Toggle sidebar" style="display:none">
<button class="hamburger" id="hamburger-btn" aria-label="Toggle sidebar" style="display:none">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<a href="#" onclick="showProjects();return false;" class="navbar-brand">Claude Code Chat Browser</a>
<a href="#" id="navbar-brand" class="navbar-brand">Claude Code Chat Browser</a>
<div class="navbar-actions">
<a href="#search" onclick="showSearchPage();return false;" class="nav-link" title="Search">
<a href="#search" id="nav-search-link" class="nav-link" title="Search">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</a>
<button id="theme-toggle" class="nav-link" title="Toggle theme" onclick="toggleTheme()">
<button id="theme-toggle" class="nav-link" title="Toggle theme" type="button">
<svg id="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<svg id="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
</button>
Expand Down
37 changes: 16 additions & 21 deletions static/js/app.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
// Claude Code Chat Browser — Entry module (router + theme + window registrations).
// Claude Code Chat Browser — Entry module (router + theme + navbar wiring).
// Route modules live in sessions.js, projects.js, search.js, export.js.
// Shared helpers live in shared/utils.js, shared/markdown.js, shared/theme.js.

import { state } from './shared/state.js';
import { toggleSidebar, closeSidebar, loadingBar } from './shared/utils.js';
import { HLJS_THEME_SHEETS, applyHljsTheme, applyTheme, toggleTheme, setWorkspaceMode } from './shared/theme.js';
import { toggleSidebar, closeSidebar } from './shared/utils.js';
import { HLJS_THEME_SHEETS, applyTheme, toggleTheme } from './shared/theme.js';
import { showProjects } from './projects.js';
import { showWorkspace, loadSession, selectSession, copyAll } from './sessions.js';
import { showSearchPage, doSearch } from './search.js';
import { bulkExport, downloadSession } from './export.js';
import { showWorkspace, loadSession } from './sessions.js';
import { showSearchPage } from './search.js';

// ==================== Router ====================

Expand Down Expand Up @@ -69,22 +68,18 @@ document.addEventListener('DOMContentLoaded', () => {
topBtn.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
document.body.appendChild(topBtn);
window.addEventListener('scroll', () => topBtn.classList.toggle('show', window.scrollY > 400));
});

// ==================== Window registrations for inline HTML handlers ====================
// Functions listed here are called from onclick="..." attributes in index.html or
// in HTML strings generated by route modules. ES module scope does not expose
// identifiers to the global scope automatically, so they must be registered on window.

window.showProjects = showProjects;
window.showSearchPage = showSearchPage;
window.toggleTheme = toggleTheme;
window.toggleSidebar = toggleSidebar;
window.selectSession = selectSession;
window.copyAll = copyAll;
window.downloadSession = downloadSession;
window.bulkExport = bulkExport;
window.doSearch = doSearch;
document.getElementById('hamburger-btn')?.addEventListener('click', toggleSidebar);
document.getElementById('navbar-brand')?.addEventListener('click', (e) => {
e.preventDefault();
showProjects();
});
document.getElementById('nav-search-link')?.addEventListener('click', (e) => {
e.preventDefault();
showSearchPage();
});
document.getElementById('theme-toggle')?.addEventListener('click', toggleTheme);
});
Comment thread
clean6378-max-it marked this conversation as resolved.

// Keep HLJS_THEME_SHEETS accessible for test_hljs_theme_consistency.py (source-level check)
export { HLJS_THEME_SHEETS };
60 changes: 59 additions & 1 deletion static/js/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@ const showProjects = vi.fn();
const showWorkspace = vi.fn();
const loadSession = vi.fn();
const showSearchPage = vi.fn();
const toggleTheme = vi.fn();
const toggleSidebar = vi.fn();

vi.mock('./projects.js', () => ({ showProjects }));
vi.mock('./sessions.js', () => ({ showWorkspace, loadSession, selectSession: vi.fn(), copyAll: vi.fn() }));
vi.mock('./search.js', () => ({ showSearchPage, doSearch: vi.fn() }));
vi.mock('./export.js', () => ({ bulkExport: vi.fn(), downloadSession: vi.fn() }));
vi.mock('./shared/utils.js', async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, toggleSidebar, closeSidebar: vi.fn() };
});
vi.mock('./shared/theme.js', () => ({
HLJS_THEME_SHEETS: {},
applyHljsTheme: vi.fn(),
applyTheme: vi.fn(),
toggleTheme: vi.fn(),
toggleTheme,
setWorkspaceMode: vi.fn(),
}));

Expand Down Expand Up @@ -109,3 +115,55 @@ describe('router (app.js)', () => {
expect(showWorkspace).toHaveBeenCalledWith('other');
});
});

describe('navbar handlers (app.js DOMContentLoaded)', () => {
const origScrollTo = window.scrollTo;

beforeAll(async () => {
vi.resetModules();
window.scrollTo = vi.fn();
document.body.innerHTML = `
<button id="hamburger-btn"></button>
<a id="navbar-brand" href="#"></a>
<a id="nav-search-link" href="#search"></a>
<button id="theme-toggle"></button>
<div id="content"></div>
<span id="footer-year"></span>
`;
await import('./app.js');
document.dispatchEvent(new Event('DOMContentLoaded'));
});

afterAll(() => {
window.scrollTo = origScrollTo;
});

beforeEach(() => {
toggleTheme.mockClear();
toggleSidebar.mockClear();
showProjects.mockClear();
showSearchPage.mockClear();
});

it('wires hamburger click to toggleSidebar', () => {
document.getElementById('hamburger-btn').click();
expect(toggleSidebar).toHaveBeenCalledTimes(1);
});

it('wires navbar brand click to showProjects', () => {
const brand = document.getElementById('navbar-brand');
brand.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(showProjects).toHaveBeenCalled();
});

it('wires nav search link click to showSearchPage', () => {
const link = document.getElementById('nav-search-link');
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(showSearchPage).toHaveBeenCalled();
});

it('wires theme toggle click to toggleTheme', () => {
document.getElementById('theme-toggle').click();
expect(toggleTheme).toHaveBeenCalledTimes(1);
});
});
11 changes: 11 additions & 0 deletions static/js/hljs-theme-init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* Initial hljs stylesheet for light mode — must match HLJS_THEME_SHEETS.light
in static/js/shared/theme.js (runtime swaps use applyHljsTheme). */
(function () {
var t = document.documentElement.getAttribute('data-theme') || 'dark';
if (t !== 'light') return;
var link = document.getElementById('hljs-theme');
if (!link) return;
// Set integrity before href — browser reads integrity at fetch time (see applyHljsTheme).
link.integrity = 'sha512-0aPQyyeZrWj9sCA46UlmWgKOP0mUipLQ6OZXu8l4IcAmD2u31EPEy9VcIMvl7SoAaKe8bLXZhYoMaE/in+gcgA==';
Comment thread
clean6378-max-it marked this conversation as resolved.
link.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css';
})();
34 changes: 34 additions & 0 deletions static/js/hljs-theme-init.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

describe('hljs-theme-init.js', () => {
beforeEach(() => {
vi.resetModules();
document.documentElement.setAttribute('data-theme', 'dark');
document.body.innerHTML = '';
});

it('does nothing when data-theme is dark', async () => {
document.body.innerHTML = '<link id="hljs-theme" href="https://example.com/dark.css" />';
await import('./hljs-theme-init.js');
const link = document.getElementById('hljs-theme');
expect(link.href).toContain('dark.css');
expect(link.getAttribute('integrity')).toBeNull();
});

it('sets light-theme CDN href and SRI when data-theme is light', async () => {
document.documentElement.setAttribute('data-theme', 'light');
document.body.innerHTML = '<link id="hljs-theme" href="about:blank" />';
await import('./hljs-theme-init.js');
const link = document.getElementById('hljs-theme');
expect(link.href).toContain('github.min.css');
expect(link.integrity).toBe(
'sha512-0aPQyyeZrWj9sCA46UlmWgKOP0mUipLQ6OZXu8l4IcAmD2u31EPEy9VcIMvl7SoAaKe8bLXZhYoMaE/in+gcgA==',
);
});

it('no-ops when hljs-theme link is missing', async () => {
document.documentElement.setAttribute('data-theme', 'light');
await import('./hljs-theme-init.js');
expect(document.getElementById('hljs-theme')).toBeNull();
});
});
11 changes: 9 additions & 2 deletions static/js/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@
import { state } from './shared/state.js';
import { esc, formatDate, smoothSet, loadingBar, setHamburgerVisible } from './shared/utils.js';
import { setWorkspaceMode } from './shared/theme.js';
import { bulkExport } from './export.js';

// ==================== Projects (home) ====================

function bindProjectsExportButtons(root) {
root.querySelector('#btn-export-since')?.addEventListener('click', () => bulkExport('incremental'));
root.querySelector('#btn-export-all')?.addEventListener('click', () => bulkExport('all'));
}

export async function showProjects() {
state.currentProject = null;
setHamburgerVisible(false);
Expand Down Expand Up @@ -69,7 +75,7 @@ export async function showProjects() {
}

const sinceBtnHtml = hasPreviousExport
? `<button class="btn btn-primary btn-sm" id="btn-export-since" onclick="bulkExport('incremental')">
? `<button type="button" class="btn btn-primary btn-sm" id="btn-export-since">
<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>
Export new since last
</button>`
Expand All @@ -80,7 +86,7 @@ export async function showProjects() {
<h1>Projects</h1>
<div class="btn-group">
${sinceBtnHtml}
<button class="btn btn-outline btn-sm" id="btn-export-all" onclick="bulkExport('all')">
<button type="button" class="btn btn-outline btn-sm" id="btn-export-all">
<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>
Export all
</button>
Expand Down Expand Up @@ -140,6 +146,7 @@ export async function showProjects() {

loadingBar.done();
smoothSet(content, html);
bindProjectsExportButtons(content);
} catch (e) {
loadingBar.done();
smoothSet(content, `<div class="loading"><p class="text-danger">Failed to load projects.</p></div>`);
Expand Down
Loading
Loading