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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@astrojs/markdown-remark": "^7.3.1",
"@astrojs/rss": "^4.0.19",
"@astrojs/svelte": "^9.0.1",
"@civic-source/shared": "workspace:*",
"@civic-source/types": "workspace:*",
"@fontsource/public-sans": "^5.3.0",
"@octokit/rest": "^22.0.1",
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/__tests__/markdown-sanitize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,18 @@ describe('markdown sanitization wiring', () => {
it('astro config registers rehype-sanitize as a markdown rehype plugin', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const unifiedOptions = (astroConfig.markdown as any)?.unified?.options;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const plugins = unifiedOptions?.rehypePlugins ?? (astroConfig.markdown as any)?.rehypePlugins ?? [];
expect(plugins).toContain(rehypeSanitize);
});

it('unified markdown renderer strips malicious script tags from output', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const unifiedProcessor = (astroConfig.markdown as any)?.unified;
expect(unifiedProcessor).toBeDefined();
const renderer = await unifiedProcessor.createRenderer({});
const result = await renderer.render('# Section Title\n\n<script>alert("xss")</script>\n\nLegitimate statutory text.');
expect(result.code).not.toContain('<script>');
expect(result.code).toContain('Legitimate statutory text.');
});
});
43 changes: 31 additions & 12 deletions apps/web/src/components/ReadingToolbar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,28 @@

$effect(() => {
if (typeof window === 'undefined') return;
const storedFont = localStorage.getItem('reading-font');
if (storedFont === 'sans' || storedFont === 'serif') fontMode = storedFont;
try {
const storedFont = localStorage.getItem('reading-font');
if (storedFont === 'sans' || storedFont === 'serif') fontMode = storedFont;

const storedSize = localStorage.getItem('reading-size');
if (storedSize === 'sm' || storedSize === 'base' || storedSize === 'lg') textSize = storedSize;
const storedSize = localStorage.getItem('reading-size');
if (storedSize === 'sm' || storedSize === 'base' || storedSize === 'lg') textSize = storedSize;

const storedMeasure = localStorage.getItem('reading-measure');
if (storedMeasure === 'standard' || storedMeasure === 'wide') measureMode = storedMeasure;
const storedMeasure = localStorage.getItem('reading-measure');
if (storedMeasure === 'standard' || storedMeasure === 'wide') measureMode = storedMeasure;
} catch {
// Storage access blocked or restricted
}

applyClasses();
});

function applyClasses() {
if (typeof document === 'undefined') return;
const article = document.querySelector('article.prose') || document.querySelector('.prose');
const article =
document.querySelector('article.prose') ||
document.querySelector('.prose-statute') ||
document.querySelector('.prose');
if (!article) return;

// Font family
Expand All @@ -38,21 +45,33 @@

function setFont(mode: 'serif' | 'sans') {
fontMode = mode;
localStorage.setItem('reading-font', mode);
try {
localStorage.setItem('reading-font', mode);
} catch {
// Storage access blocked or restricted
}
applyClasses();
}

function cycleSize() {
if (textSize === 'sm') textSize = 'base';
else if (textSize === 'base') textSize = 'lg';
else textSize = 'sm';
localStorage.setItem('reading-size', textSize);
try {
localStorage.setItem('reading-size', textSize);
} catch {
// Storage access blocked or restricted
}
applyClasses();
}

function toggleMeasure() {
measureMode = measureMode === 'standard' ? 'wide' : 'standard';
localStorage.setItem('reading-measure', measureMode);
try {
localStorage.setItem('reading-measure', measureMode);
} catch {
// Storage access blocked or restricted
}
applyClasses();
}
</script>
Expand Down Expand Up @@ -91,7 +110,7 @@
class="inline-flex items-center gap-1 rounded-md border border-gray-200 bg-white px-2.5 py-1 text-xs font-medium text-slate shadow-2xs hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
aria-label="Toggle text size"
>
<span class="text-[11px] text-gray-400">Size:</span>
<span class="text-[11px] text-slate dark:text-gray-300">Size:</span>
<span class="font-bold">{textSize === 'sm' ? '17px' : textSize === 'base' ? '19px' : '21px'}</span>
</button>

Expand All @@ -102,7 +121,7 @@
class="inline-flex items-center gap-1 rounded-md border border-gray-200 bg-white px-2.5 py-1 text-xs font-medium text-slate shadow-2xs hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
aria-label="Toggle reading line width"
>
<span class="text-[11px] text-gray-400">Width:</span>
<span class="text-[11px] text-slate dark:text-gray-300">Width:</span>
<span>{measureMode === 'standard' ? 'Standard' : 'Wide'}</span>
</button>
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/SearchBar.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { sanitizeExcerpt } from "../lib/github";
import { sanitizeExcerpt } from "../lib/sanitize";

// Pagefind types for the dynamically imported module
interface PagefindResult {
Expand Down
40 changes: 26 additions & 14 deletions apps/web/src/components/SummaryBox.astro
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,22 @@ const {

const isRepealed = status === 'repealed';
const isTransferred = status === 'transferred';
const isRenumbered = status === 'renumbered';
const isOmitted = status === 'omitted';
const isReserved = status === 'reserved';

const borderAccent = isRepealed
? 'border-crimson bg-crimson-light/20 dark:border-red-500 dark:bg-red-950/20'
: isTransferred || isOmitted || isReserved
: isTransferred || isRenumbered || isOmitted || isReserved
? 'border-amber bg-amber-light/20 dark:border-amber-500 dark:bg-amber-950/20'
: 'border-teal bg-teal-light/20 dark:border-teal dark:bg-teal/10';

const statusBadge = isRepealed
? { label: 'Repealed', class: 'bg-crimson/15 text-crimson dark:bg-red-900/40 dark:text-red-300' }
: isTransferred
? { label: 'Transferred', class: 'bg-amber/15 text-amber dark:bg-amber-900/40 dark:text-amber' }
: isRenumbered
? { label: 'Renumbered', class: 'bg-amber/15 text-amber dark:bg-amber-900/40 dark:text-amber' }
: isOmitted
? { label: 'Omitted', class: 'bg-gray-200 text-slate dark:bg-gray-800 dark:text-gray-300' }
: isReserved
Expand Down Expand Up @@ -88,7 +91,8 @@ const statusBadge = isRepealed
</a>

<button
onclick="window.print()"
id="print-section-btn"
type="button"
class="inline-flex items-center gap-1 rounded bg-white px-2 py-1 text-xs text-slate shadow-2xs hover:bg-gray-50 border border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700 dark:hover:bg-gray-700"
aria-label="Print this section"
>
Expand Down Expand Up @@ -124,18 +128,26 @@ const statusBadge = isRepealed

<script is:inline>
(function() {
var btn = document.getElementById('copy-cite-btn');
var copyBtn = document.getElementById('copy-cite-btn');
var label = document.getElementById('copy-cite-label');
if (!btn || !label) return;
btn.addEventListener('click', function() {
var citation = btn.getAttribute('data-citation');
if (!citation) return;
navigator.clipboard.writeText(citation).then(function() {
label.textContent = 'Copied!';
setTimeout(function() {
label.textContent = 'Copy cite';
}, 2000);
}).catch(function() {});
});
if (copyBtn && label) {
copyBtn.addEventListener('click', function() {
var citation = copyBtn.getAttribute('data-citation');
if (!citation) return;
navigator.clipboard.writeText(citation).then(function() {
label.textContent = 'Copied!';
setTimeout(function() {
label.textContent = 'Copy cite';
}, 2000);
}).catch(function() {});
});
}

var printBtn = document.getElementById('print-section-btn');
if (printBtn) {
printBtn.addEventListener('click', function() {
window.print();
});
}
})();
</script>
14 changes: 8 additions & 6 deletions apps/web/src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const titleEntries = Object.entries(TITLE_NAMES)
<meta name="twitter:title" content={title === 'US Code Tracker' ? title : `${title} | US Code Tracker`} />
<meta name="twitter:description" content={description} />
<!-- CSP: defense-in-depth against XSS -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.github.com;" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.github.com; base-uri 'self'; object-src 'none'; form-action 'self';" />
<title>{title === 'US Code Tracker' ? title : `${title} | US Code Tracker`}</title>
<script is:inline>
// Apply theme before render to prevent flash
Expand Down Expand Up @@ -129,11 +129,13 @@ const titleEntries = Object.entries(TITLE_NAMES)
</nav>
</aside>

<!-- Main content — flex container -->
<main id="main-content" class="min-h-0 flex-1 overflow-y-auto 2xl:flex 2xl:flex-row">
<article class="prose prose-lg prose-gray mx-auto max-w-3xl p-6 font-serif leading-relaxed dark:prose-invert lg:p-10 2xl:mx-0 2xl:flex-1 2xl:min-w-0">
<slot />
</article>
<!-- Main content — vertical container with full-width bottom footer -->
<main id="main-content" tabindex="-1" class="min-h-0 flex-1 overflow-y-auto flex flex-col focus:outline-none">
<div class="flex-1 2xl:flex 2xl:flex-row 2xl:justify-center">
<article class="prose prose-lg prose-gray mx-auto max-w-3xl p-6 font-serif leading-relaxed dark:prose-invert lg:p-10 2xl:mx-0 2xl:flex-1 2xl:min-w-0">
<slot />
</article>
</div>

<!-- Footer -->
<footer class="border-t border-gray-200 bg-warm-gray px-6 py-8 font-sans text-xs text-slate dark:border-gray-800 dark:bg-[#07131F] dark:text-gray-400">
Expand Down
65 changes: 8 additions & 57 deletions apps/web/src/lib/github.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Octokit } from "@octokit/rest";
import sanitizeHtml from "sanitize-html";
import { sanitizeContent, sanitizeExcerpt } from "./sanitize.js";

export interface CommitInfo {
sha: string;
Expand Down Expand Up @@ -60,48 +60,22 @@ export async function getFileDiff(
const file = files.find((f) => f.filename === options.path);
if (!file?.patch) return [];

return file.patch.split("\n").map((line) => {
return file.patch.split("\n").flatMap((line): DiffLine[] => {
if (line.startsWith("@@")) return [];
if (line.startsWith("+") && !line.startsWith("+++")) {
return { type: "add", content: line.slice(1) };
return [{ type: "add", content: line.slice(1) }];
}
if (line.startsWith("-") && !line.startsWith("---")) {
return { type: "del", content: line.slice(1) };
return [{ type: "del", content: line.slice(1) }];
}
return { type: "context", content: line };
const content = line.startsWith(" ") ? line.slice(1) : line;
return [{ type: "context", content }];
});
} catch {
return null;
}
}

export interface ReleaseTag {
name: string;
date: string;
}

export async function getReleaseTags(
owner: string,
repo: string,
token?: string,
): Promise<ReleaseTag[]> {
const octokit = createClient(token);
const response = await octokit.repos.listTags({ owner, repo, per_page: 100 });

const parseTag = (name: string): [number, number] => {
const m = /pl-(\d+)-(\d+)/.exec(name);
return m && m[1] && m[2] ? [parseInt(m[1], 10), parseInt(m[2], 10)] : [0, 0];
};

return response.data
.filter((t) => t.name.startsWith("pl-"))
.map((t) => ({ name: t.name, date: "" }))
.sort((a, b) => {
const [ac, al] = parseTag(a.name);
const [bc, bl] = parseTag(b.name);
return ac !== bc ? ac - bc : al - bl;
});
}

export async function getFileAtRef(
owner: string,
repo: string,
Expand All @@ -123,30 +97,7 @@ export async function getFileAtRef(
}
}

/**
* Strip all HTML tags to plain text.
* Uses sanitize-html (a real HTML parser), not regex, so encoded entities and
* malformed/nested tags can't bypass it. For displaying untrusted content as text.
*/
export function sanitizeContent(raw: string): string {
return sanitizeHtml(raw, {
allowedTags: [],
allowedAttributes: {},
disallowedTagsMode: "discard",
});
}

/**
* Sanitize Pagefind excerpt HTML, preserving only <mark> highlight tags.
* Used in client-side search results rendered via {@html ...}.
*/
export function sanitizeExcerpt(html: string): string {
return sanitizeHtml(html, {
allowedTags: ["mark"],
allowedAttributes: {},
disallowedTagsMode: "discard",
});
}
export { sanitizeContent, sanitizeExcerpt };

/** Format a pl-* tag name into a human-readable label */
export function formatTagName(tag: string): string {
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/lib/sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import sanitizeHtml from 'sanitize-html';

/**
* Strip all HTML tags to plain text.
* Uses sanitize-html (a real HTML parser), not regex, so encoded entities and
* malformed/nested tags can't bypass it. For displaying untrusted content as text.
*/
export function sanitizeContent(raw: string): string {
return sanitizeHtml(raw, {
allowedTags: [],
allowedAttributes: {},
disallowedTagsMode: 'discard',
});
}

/**
* Sanitize Pagefind excerpt HTML, preserving only <mark> highlight tags.
* Used in client-side search results rendered via {@html ...}.
*/
export function sanitizeExcerpt(html: string): string {
return sanitizeHtml(html, {
allowedTags: ['mark'],
allowedAttributes: {},
disallowedTagsMode: 'discard',
});
}
Loading
Loading