From 1ef7876c652901d044095f50c74b6a14a934dca5 Mon Sep 17 00:00:00 2001 From: Dinesh <13635627+HumbleBee14@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:29:31 -0700 Subject: [PATCH 1/6] Security and quality pass: harden create-pr, sanitize SVG, a11y and dedup fixes --- astro.config.mjs | 12 +++ functions/api/create-pr.ts | 90 ++++++++++++++++--- src/components/HeroFigure.tsx | 45 ++++++++-- src/components/SearchInline.tsx | 69 +++----------- src/components/SearchModal.astro | 87 +++++++++--------- src/layouts/BaseLayout.astro | 13 +-- src/lib/search.ts | 62 +++++++++++++ src/lib/tools.ts | 21 +++++ .../[...page].astro} | 55 ++++++++---- src/pages/blog/[slug].astro | 6 +- src/pages/index.astro | 13 +-- src/pages/playground/index.astro | 57 ++---------- .../{[tag].astro => [tag]/[...page].astro} | 56 +++++++----- src/styles/global.css | 2 +- src/write/dialogs/OpenExistingDialog.tsx | 6 ++ src/write/dialogs/PublishDialog.tsx | 6 ++ src/write/editor/blocks/SvgBlock.tsx | 9 +- src/write/editor/usePasteImages.ts | 2 + src/write/lib/sanitizeSvg.ts | 32 +++++++ src/write/lib/useDialogFocus.ts | 51 +++++++++++ src/write/meta/AuthorModal.tsx | 6 +- src/write/preview/PreviewPane.tsx | 23 +++-- src/write/serialize/toMdx.ts | 4 +- 23 files changed, 484 insertions(+), 243 deletions(-) create mode 100644 src/lib/search.ts create mode 100644 src/lib/tools.ts rename src/pages/authors/[handle]/{articles.astro => articles/[...page].astro} (65%) rename src/pages/tags/{[tag].astro => [tag]/[...page].astro} (57%) create mode 100644 src/write/lib/sanitizeSvg.ts create mode 100644 src/write/lib/useDialogFocus.ts diff --git a/astro.config.mjs b/astro.config.mjs index 8937b53..75d2234 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -55,6 +55,16 @@ try { /** @type {string[]} */ const SKIP_PATTERNS = ['/write', '/search']; +// Paginated listing pages (/blog/2, /topics/x/2, /tags/x/2, /authors/x/articles/2) +// are secondary — keep them below their first page and below real articles. +/** + * @param {string} path + * @returns {boolean} + */ +function isPaginatedListing(path) { + return /\/\d+$/.test(path); +} + /** * @param {string} path * @returns {number} @@ -64,6 +74,7 @@ function priorityFor(path) { if (path === '/blog' || path === '/topics') return 0.9; if (path === '/playground' || path === '/community' || path === '/contribute' || path === '/why') return 0.8; + if (isPaginatedListing(path)) return 0.4; if (path.startsWith('/blog/')) return 0.7; if (path.startsWith('/topics/') || path.startsWith('/tags/')) return 0.6; if (path.startsWith('/authors/')) return 0.6; @@ -76,6 +87,7 @@ function priorityFor(path) { */ function changefreqFor(path) { if (path === '/' || path === '/blog' || path === '/topics') return 'daily'; + if (isPaginatedListing(path)) return 'weekly'; if (path.startsWith('/blog/') || path.startsWith('/authors/')) return 'monthly'; return 'weekly'; } diff --git a/functions/api/create-pr.ts b/functions/api/create-pr.ts index ae0cdb5..687a251 100644 --- a/functions/api/create-pr.ts +++ b/functions/api/create-pr.ts @@ -1,11 +1,18 @@ // Cloudflare Pages Function: opens a pull request on the repo directly via github app +interface KVStore { + get(key: string): Promise; + put(key: string, value: string, opts?: { expirationTtl?: number }): Promise; +} + type Env = { GH_APP_ID?: string; GH_APP_INSTALLATION_ID?: string; GH_APP_PRIVATE_KEY?: string; GH_REPO?: string; ALLOWED_ORIGIN?: string; + // Optional KV namespace; when bound, submissions are throttled per IP. + RATE_LIMIT?: KVStore; }; type PostFile = { path: string; content: string; encoding: 'utf-8' | 'base64' }; @@ -15,6 +22,30 @@ const DEFAULT_ORIGIN = 'https://mlsystems.dev'; const CONTACT_EMAIL = 'admin@mlsystems.dev'; const UA = 'mlsystems-write'; +const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const FILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const AUTHOR_PATH_RE = /^src\/content\/authors\/[a-z0-9]+(?:-[a-z0-9]+)*\.json$/; +const MAX_FILES = 40; +const MAX_FILE_CHARS = 8_000_000; +const MAX_TOTAL_CHARS = 24_000_000; +const MAX_SUBMISSIONS_PER_HOUR = 5; + +// Every file must live in the post's own folder (flat, safe names) — except a +// single new-author profile. Anything else could overwrite arbitrary repo files. +function invalidPath(files: PostFile[], slug: string): string | null { + const postDir = `src/content/posts/${slug}/`; + let authorFiles = 0; + for (const f of files) { + if (AUTHOR_PATH_RE.test(f.path)) { + if (++authorFiles > 1) return f.path; + continue; + } + if (!f.path.startsWith(postDir)) return f.path; + if (!FILE_NAME_RE.test(f.path.slice(postDir.length))) return f.path; + } + return null; +} + function json(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -124,11 +155,37 @@ export async function onRequestPost(context: { request: Request; env: Env }): Pr return json({ error: 'Invalid request body.' }, 400); } const slug = (payload.slug ?? '').trim(); - const title = (payload.title ?? '').trim() || slug; - const summary = (payload.summary ?? '').trim(); + const title = (payload.title ?? '').trim().slice(0, 200) || slug; + const summary = (payload.summary ?? '').trim().slice(0, 500); const files = payload.files ?? []; const isEdit = payload.isEdit === true; if (!slug || files.length === 0) return json({ error: 'Missing post data.' }, 400); + if (!SLUG_RE.test(slug) || slug.length > 64) { + return json({ error: 'Invalid URL slug.' }, 400); + } + if (files.length > MAX_FILES) return json({ error: 'Too many files in this post.' }, 400); + let totalChars = 0; + for (const f of files) { + if (typeof f.path !== 'string' || typeof f.content !== 'string') { + return json({ error: 'Invalid file entry.' }, 400); + } + totalChars += f.content.length; + if (f.content.length > MAX_FILE_CHARS || totalChars > MAX_TOTAL_CHARS) { + return json({ error: 'This post is too large to submit — reduce image sizes.' }, 413); + } + } + const badPath = invalidPath(files, slug); + if (badPath) return json({ error: `File path not allowed: ${badPath}` }, 400); + + if (env.RATE_LIMIT) { + const ip = request.headers.get('cf-connecting-ip') ?? 'unknown'; + const key = `create-pr:${ip}`; + const count = Number((await env.RATE_LIMIT.get(key)) ?? '0'); + if (count >= MAX_SUBMISSIONS_PER_HOUR) { + return json({ error: 'Too many submissions — please try again in an hour.' }, 429); + } + await env.RATE_LIMIT.put(key, String(count + 1), { expirationTtl: 3600 }); + } const [owner, name] = (env.GH_REPO || DEFAULT_REPO).split('/'); @@ -140,16 +197,22 @@ export async function onRequestPost(context: { request: Request; env: Env }): Pr const token = inst.token; // A brand-new post must not silently overwrite an existing one at the same slug. - // Edits (loaded via the portal's "Open existing post") are meant to, so skip then. - if ( - !isEdit && - (await fileExistsOnMain(owner, name, `src/content/posts/${slug}/index.mdx`, token)) - ) { + // Edits (loaded via the portal's "Open existing post") are meant to. isEdit is + // client-supplied, so the server checks reality itself and labels updates loudly — + // maintainer review of the PR is the trust boundary for overwrites. + const postExists = await fileExistsOnMain( + owner, + name, + `src/content/posts/${slug}/index.mdx`, + token, + ); + if (!isEdit && postExists) { return json( { error: 'A post with this URL already exists. Change the URL slug and try again.' }, 409, ); } + const isUpdate = isEdit && postExists; // A newly registered author must not overwrite an existing profile at the same handle. const authorFile = files.find((f) => f.path.startsWith('src/content/authors/')); @@ -188,7 +251,7 @@ export async function onRequestPost(context: { request: Request; env: Env }): Pr const commit = (await gh(`/repos/${owner}/${name}/git/commits`, token, { method: 'POST', body: JSON.stringify({ - message: `Add post: ${title}`, + message: `${isUpdate ? 'Update' : 'Add'} post: ${title}`, tree: newTree.sha, parents: [baseSha], }), @@ -265,7 +328,12 @@ export async function onRequestPost(context: { request: Request; env: Env }): Pr const pr = (await gh(`/repos/${owner}/${name}/pulls`, token, { method: 'POST', - body: JSON.stringify({ title: `New post: ${title}`, head: branch, base: 'main', body }), + body: JSON.stringify({ + title: `${isUpdate ? 'Update post' : 'New post'}: ${title}`, + head: branch, + base: 'main', + body, + }), })) as { html_url: string; number: number }; // Best-effort label for triage. Needs Issues: write on the App + the label to @@ -273,7 +341,9 @@ export async function onRequestPost(context: { request: Request; env: Env }): Pr try { await gh(`/repos/${owner}/${name}/issues/${pr.number}/labels`, token, { method: 'POST', - body: JSON.stringify({ labels: ['blog-submission'] }), + body: JSON.stringify({ + labels: ['blog-submission', ...(isUpdate ? ['post-update'] : [])], + }), }); } catch { // labeling is optional diff --git a/src/components/HeroFigure.tsx b/src/components/HeroFigure.tsx index 11ce209..65560b7 100644 --- a/src/components/HeroFigure.tsx +++ b/src/components/HeroFigure.tsx @@ -1,19 +1,22 @@ 'use client'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; -function useAnimationFrame() { +function useAnimationFrame(active: boolean) { const [t, setT] = useState(0); + const tRef = useRef(0); useEffect(() => { + if (!active) return; let raf: number; - const start = performance.now(); + const start = performance.now() - tRef.current * 1000; const tick = (now: number) => { - setT((now - start) / 1000); + tRef.current = (now - start) / 1000; + setT(tRef.current); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); - }, []); + }, [active]); return t; } @@ -556,19 +559,45 @@ export default function HeroFigure() { ]; const [idx, setIdx] = useState(0); + const rootRef = useRef(null); + const [reducedMotion] = useState( + () => + typeof window !== 'undefined' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches, + ); + const [pageVisible, setPageVisible] = useState(true); + const [onScreen, setOnScreen] = useState(true); + + useEffect(() => { + const onVisibility = () => setPageVisible(!document.hidden); + onVisibility(); + document.addEventListener('visibilitychange', onVisibility); + return () => document.removeEventListener('visibilitychange', onVisibility); + }, []); + + useEffect(() => { + const el = rootRef.current; + if (!el) return; + const observer = new IntersectionObserver(([entry]) => setOnScreen(entry.isIntersecting)); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const active = !reducedMotion && pageVisible && onScreen; useEffect(() => { + if (!active) return; const cycleMs = 9000; const interval = setInterval(() => setIdx((i) => (i + 1) % FIGS.length), cycleMs); return () => clearInterval(interval); - }, [FIGS.length]); + }, [active, FIGS.length]); - const t = useAnimationFrame(); + const t = useAnimationFrame(active); const current = FIGS[idx]; const Comp = current.Comp; return ( -
+
{FIGS.map((f, i) => ( diff --git a/src/components/SearchInline.tsx b/src/components/SearchInline.tsx index d228ceb..4f56cea 100644 --- a/src/components/SearchInline.tsx +++ b/src/components/SearchInline.tsx @@ -2,48 +2,17 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { loadPagefind, type PagefindResultData } from '@/lib/pagefind'; +import { + GROUP_LABEL, + buildMeta, + classifyUrl, + sortRows, + topicMatches as matchTopics, + type SearchRow, +} from '@/lib/search'; type Topic = { id: string; name: string; desc: string }; -type Group = 'topic' | 'article' | 'tool' | 'author' | 'page'; - -type RowItem = { - group: Group; - url: string; - title: string; - excerpt?: string; - meta?: string; -}; - -function classifyUrl(url: string): Group { - if (url.startsWith('/blog/')) return 'article'; - if (url.startsWith('/playground/')) return 'tool'; - if (url.startsWith('/authors/')) return 'author'; - return 'page'; -} - -const GROUP_LABEL: Record = { - topic: 'Topics', - article: 'Articles', - tool: 'Tools', - author: 'Authors', - page: 'Pages', -}; - -function buildMeta(group: Group, meta: PagefindResultData['meta']): string { - const parts: string[] = []; - if (group === 'article') { - if (meta.topic) parts.push(meta.topic); - if (meta.read) parts.push(meta.read); - if (meta.authors) parts.push(meta.authors); - } else if (group === 'tool') { - parts.push('Tool'); - } else if (group === 'author') { - parts.push('Contributor'); - } - return parts.join(' · '); -} - export default function SearchInline({ topics }: { topics: Topic[] }) { const [query, setQuery] = useState(''); const [pageResults, setPageResults] = useState([]); @@ -67,22 +36,14 @@ export default function SearchInline({ topics }: { topics: Topic[] }) { window.history.replaceState({}, '', url.toString()); }, [query]); - const topicMatches = useMemo(() => { + const topicRows = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return []; - return topics - .filter((t) => t.name.toLowerCase().includes(q) || t.desc.toLowerCase().includes(q)) - .slice(0, 3) - .map((t) => ({ - group: 'topic' as const, - url: `/topics/${t.id}`, - title: t.name, - excerpt: t.desc, - })); + return matchTopics(topics, q); }, [query, topics]); - const rows = useMemo(() => { - const fromPagefind: RowItem[] = pageResults.map((r) => { + const rows = useMemo(() => { + const fromPagefind: SearchRow[] = pageResults.map((r) => { const g = classifyUrl(r.url); return { group: g, @@ -92,10 +53,8 @@ export default function SearchInline({ topics }: { topics: Topic[] }) { meta: buildMeta(g, r.meta), }; }); - const merged = [...topicMatches, ...fromPagefind]; - const order: Group[] = ['topic', 'article', 'tool', 'author', 'page']; - return merged.sort((a, b) => order.indexOf(a.group) - order.indexOf(b.group)); - }, [topicMatches, pageResults]); + return sortRows([...topicRows, ...fromPagefind]); + }, [topicRows, pageResults]); useEffect(() => { let cancelled = false; diff --git a/src/components/SearchModal.astro b/src/components/SearchModal.astro index fabe56f..41019c1 100644 --- a/src/components/SearchModal.astro +++ b/src/components/SearchModal.astro @@ -108,30 +108,19 @@ const HINT_TERMS = ['attention', 'quantization', 'vLLM', 'FSDP', 'evals']; ' })])).toBe( + 'safe', + ); + expect(serializeInline([t('safe', { backgroundColor: 'x;}