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
48 changes: 45 additions & 3 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@

import logging
import os
import secrets
import sys
from datetime import datetime
from pathlib import Path
from typing import cast

from flask import Flask, Response, render_template, send_from_directory
from flask import Flask, Response, g, render_template, send_from_directory

from utils.debug_flag import resolve_debug_flag

Expand All @@ -24,6 +25,31 @@
from api.config_api import bp as config_bp
from utils.exclusion_rules import resolve_exclusion_rules_path, load_rules

_CDNJS_ORIGIN = "https://cdnjs.cloudflare.com"


def build_content_security_policy(nonce: str) -> str:
"""Build a restrictive CSP for served HTML pages.

script-src allows self-hosted JS, SRI-pinned cdnjs assets, and one
per-response nonce for inline page scripts. style-src keeps
'unsafe-inline' because highlight.js themes apply inline styles.
"""
return "; ".join(
[
"default-src 'self'",
f"script-src 'self' {_CDNJS_ORIGIN} 'nonce-{nonce}'",
f"style-src 'self' 'unsafe-inline' {_CDNJS_ORIGIN}",
"img-src 'self' data:",
"connect-src 'self'",
"font-src 'self'",
"object-src 'none'",
"form-action 'self'",
"base-uri 'self'",
"frame-ancestors 'none'",
]
)


def _get_base_path() -> Path:
"""Return the directory that contains templates/ and static/.
Expand Down Expand Up @@ -58,8 +84,24 @@ def create_app(exclusion_rules_path: str | None = None) -> Flask:
app.config["EXCLUSION_RULES"] = loaded_rules

@app.context_processor
def inject_year() -> dict[str, int]:
return {"current_year": datetime.now().year}
def inject_year() -> dict[str, int | str]:
return {
"current_year": datetime.now().year,
"csp_nonce": getattr(g, "csp_nonce", ""),
}

@app.before_request
def _assign_csp_nonce() -> None:
g.csp_nonce = secrets.token_urlsafe(16)

@app.after_request
def _set_content_security_policy(response: Response) -> Response:
content_type = response.content_type or ""
if content_type.startswith("text/html"):
nonce = getattr(g, "csp_nonce", "")
if nonce:
response.headers["Content-Security-Policy"] = build_content_security_policy(nonce)
return response

# Register API blueprints
app.register_blueprint(workspaces_bp)
Expand Down
4 changes: 4 additions & 0 deletions static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ function toggleTheme() {
// Apply on load
document.addEventListener('DOMContentLoaded', () => {
applyTheme(getStoredTheme());
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', toggleTheme);
}
});


Expand Down
2 changes: 1 addition & 1 deletion static/js/download.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ function copyAllMarkdown() {
const md = convertChatToMarkdown(selectedTab, true);
navigator.clipboard.writeText(md).then(function() {
// Brief feedback (you could show a toast)
const btn = document.querySelector('[onclick="copyAllMarkdown()"]');
const btn = document.getElementById('btn-copy-all');
if (btn) {
const orig = btn.innerHTML;
btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg> Copied!';
Expand Down
7 changes: 7 additions & 0 deletions static/js/theme-bootstrap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Apply stored theme before the stylesheet loads to avoid a dark->light flash.
(function () {
try {
var t = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', t);
} catch (e) {}
})();
11 changes: 2 additions & 9 deletions templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,7 @@
<title>{% block title %}Cursor Chat Browser{% endblock %}</title>
<!-- Apply stored theme BEFORE the stylesheet loads to avoid a dark->light
flash on every navigation when light is the user's preference. -->
<script>
(function () {
try {
var t = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', t);
} catch (e) {}
})();
</script>
<script src="/static/js/theme-bootstrap.js"></script>
<link rel="stylesheet" href="/static/css/style.css">
<!-- Highlight.js for code syntax highlighting -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css" id="hljs-theme"
Expand Down Expand Up @@ -44,7 +37,7 @@
<a href="/config" class="nav-link" title="Configuration">
<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="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></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" style="display:none"><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
6 changes: 4 additions & 2 deletions templates/config.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ <h1>Configuration</h1>
<div id="status-msg" style="display:none"></div>

<div class="btn-group" style="margin-top:1rem">
<button class="btn btn-primary" onclick="validateAndSave()">Save Configuration</button>
<button class="btn btn-primary" id="btn-save-config" type="button">Save Configuration</button>
</div>
</div>

<script>
<script nonce="{{ csp_nonce }}">
document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('btn-save-config')?.addEventListener('click', validateAndSave);

const stored = localStorage.getItem('workspacePath');
if (stored) {
document.getElementById('workspace-path').value = stored;
Expand Down
9 changes: 6 additions & 3 deletions templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ <h1>Projects</h1>
</div>
<div class="btn-group" style="flex-direction:column;align-items:flex-end;gap:0.25rem">
<div style="display:flex;gap:0.5rem">
<button class="btn btn-outline btn-sm" id="btn-export-all" onclick="runExport('all')">
<button class="btn btn-outline btn-sm" id="btn-export-all" type="button">
<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>
<button class="btn btn-outline btn-sm" id="btn-export-new" onclick="runExport('last')">
<button class="btn btn-outline btn-sm" id="btn-export-new" type="button">
Export new since last
</button>
</div>
Expand All @@ -29,8 +29,11 @@ <h1>Projects</h1>
<div id="parse-warnings-host"></div>
<div id="projects-container" style="display:none"></div>

<script>
<script nonce="{{ csp_nonce }}">
document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('btn-export-all')?.addEventListener('click', () => runExport('all'));
document.getElementById('btn-export-new')?.addEventListener('click', () => runExport('last'));

try {
// Fetch projects and export state in parallel
const [projRes, stateRes] = await Promise.all([
Expand Down
18 changes: 12 additions & 6 deletions templates/search.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
<div class="page-header">
<h1>Search</h1>
<div class="btn-group" style="align-self:center">
<button class="btn" id="filter-all" onclick="setFilter('all')">All</button>
<button class="btn btn-outline" id="filter-chat" onclick="setFilter('chat')">Ask Logs</button>
<button class="btn btn-outline" id="filter-composer" onclick="setFilter('composer')">Agent Logs</button>
<button class="btn" id="filter-all" type="button">All</button>
<button class="btn btn-outline" id="filter-chat" type="button">Ask Logs</button>
<button class="btn btn-outline" id="filter-composer" type="button">Agent Logs</button>
</div>
</div>

Expand All @@ -28,10 +28,10 @@ <h1>Search</h1>
Check &ldquo;Include chats older than 30 days&rdquo; to search full history.
</span>
</button>
<button class="btn btn-primary" onclick="doSearch()">Search</button>
<button class="btn btn-primary" id="btn-search" type="button">Search</button>
</div>
<label class="text-sm" style="display:flex;align-items:center;gap:0.5rem;margin-top:0.75rem;cursor:pointer">
<input type="checkbox" id="all-history" onchange="doSearch()">
<input type="checkbox" id="all-history">
Include chats older than 30 days <span class="text-muted">(searches all history — slower)</span>
</label>
</div>
Expand All @@ -46,10 +46,16 @@ <h1>Search</h1>
<div id="results-container"></div>
</div>

<script>
<script nonce="{{ csp_nonce }}">
let currentFilter = 'all';

document.addEventListener('DOMContentLoaded', () => {
document.getElementById('filter-all')?.addEventListener('click', () => setFilter('all'));
document.getElementById('filter-chat')?.addEventListener('click', () => setFilter('chat'));
document.getElementById('filter-composer')?.addEventListener('click', () => setFilter('composer'));
document.getElementById('btn-search')?.addEventListener('click', doSearch);
document.getElementById('all-history')?.addEventListener('change', doSearch);

const params = new URLSearchParams(window.location.search);
const q = params.get('q');
const t = params.get('type') || 'all';
Expand Down
41 changes: 31 additions & 10 deletions templates/workspace.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,22 @@
Back to Projects
</a>
<div class="btn-group" id="action-buttons" style="display:none">
<button class="btn btn-outline btn-sm" onclick="copyAllMarkdown()" title="Copy all as Markdown">
<button class="btn btn-outline btn-sm" id="btn-copy-all" type="button" title="Copy all as Markdown">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy All
</button>
<div class="dropdown">
<button class="btn btn-outline btn-sm dropdown-toggle" onclick="this.parentElement.classList.toggle('open')">
<button class="btn btn-outline btn-sm dropdown-toggle" id="btn-download-toggle" type="button">
<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>
Download
</button>
<div class="dropdown-menu">
<a class="dropdown-item" href="#" onclick="downloadAs('md'); return false;">Download as Markdown</a>
<a class="dropdown-item" href="#" onclick="downloadAs('html'); return false;">Download as HTML</a>
<a class="dropdown-item" href="#" onclick="downloadAs('pdf'); return false;">Download as PDF</a>
<a class="dropdown-item" href="#" onclick="downloadAs('json'); return false;">Download as JSON</a>
<a class="dropdown-item" href="#" onclick="downloadAs('csv'); return false;">Download as CSV</a>
<a class="dropdown-item" href="#" id="dl-code-edits" onclick="downloadAs('csv-code'); return false;" style="display:none">Download code edits (CSV)</a>
<a class="dropdown-item" href="#" data-download-format="md">Download as Markdown</a>
<a class="dropdown-item" href="#" data-download-format="html">Download as HTML</a>
<a class="dropdown-item" href="#" data-download-format="pdf">Download as PDF</a>
<a class="dropdown-item" href="#" data-download-format="json">Download as JSON</a>
<a class="dropdown-item" href="#" data-download-format="csv">Download as CSV</a>
<a class="dropdown-item" href="#" id="dl-code-edits" data-download-format="csv-code" style="display:none">Download code edits (CSV)</a>
</div>
</div>
</div>
Expand Down Expand Up @@ -55,13 +55,34 @@ <h2 id="project-name">Loading...</h2>
</div>
</div>

<script>
<script nonce="{{ csp_nonce }}">
const WORKSPACE_ID = "{{ workspace_id }}";
let allTabs = []; // summary tabs (no bubbles) from ?summary=1
let tabCache = {}; // id → full tab (with bubbles), populated on first open
let selectedTab = null; // always a full tab once a conversation is opened

function wireWorkspaceActions() {
document.getElementById('btn-copy-all')?.addEventListener('click', copyAllMarkdown);
document.getElementById('btn-download-toggle')?.addEventListener('click', function () {
this.parentElement.classList.toggle('open');
});
document.querySelectorAll('[data-download-format]').forEach((el) => {
el.addEventListener('click', (e) => {
e.preventDefault();
downloadAs(el.dataset.downloadFormat);
});
});
document.getElementById('sidebar')?.addEventListener('click', (e) => {
const item = e.target.closest('.sidebar-item');
if (item?.dataset.id) {
selectTab(item.dataset.id);
}
});
}

document.addEventListener('DOMContentLoaded', async () => {
wireWorkspaceActions();

try {
const [wsRes, tabsRes] = await Promise.all([
fetch(`/api/workspaces/${WORKSPACE_ID}`),
Expand Down Expand Up @@ -110,7 +131,7 @@ <h2 id="project-name">Loading...</h2>
const ts = formatDate(tab.timestamp);
const model = (tab.metadata && tab.metadata.modelsUsed && tab.metadata.modelsUsed[0]) || '';
const modelBadge = model ? `<span style="font-size:0.65rem;opacity:0.5;display:block;margin-top:1px">${escapeHtml(model)}</span>` : '';
html += `<button class="sidebar-item" data-id="${tab.id}" onclick="selectTab('${tab.id}')" title="${title}">
html += `<button class="sidebar-item" data-id="${tab.id}" type="button" title="${title}">
<div class="sidebar-item-title">${title}</div>
<div class="sidebar-item-time">${ts}${modelBadge}</div>
</button>`;
Expand Down
Loading
Loading