From 52eab5b6e4a827c680c8694a0706646f7078e13b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:00:41 +0000 Subject: [PATCH] feat: robust login with rate limiting + diff propagation across tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login hardening: - API: in-memory rate limiter (10 attempts / IP / 15 min), timing-safe credential comparison, strict body validation - Middleware: validate session token timestamp expiry (7-day window) not just HMAC signature - Logout: also clears oidc_token_pub cookie - Login page: try/catch around fetch to surface network errors, parse OIDC error query params, disable inputs during loading, wrap useSearchParams in Suspense Diff propagation: - DiffViewer: shows inherited ancestor patches in a collapsible section above own patches - CVTree: annotates each branch with ↑N ancestor patch count badge; propagate (↓) button on hover for versions with own patches and child branches - Dashboard: computeInheritedPatches/getDescendants helpers; Propagate ↓ action button in version header; handlePropagate sends current version's patches to all descendants via appendPatches Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01Stdyi4YmG1BUSurArZPe8w --- apps/webapp/src/app/api/auth/login/route.ts | 47 ++++++++++- apps/webapp/src/app/api/auth/logout/route.ts | 1 + apps/webapp/src/app/dashboard/page.tsx | 86 +++++++++++++++++++- apps/webapp/src/app/login/page.tsx | 85 ++++++++++--------- apps/webapp/src/components/cv/CVTree.tsx | 57 +++++++++++-- apps/webapp/src/components/cv/DiffViewer.tsx | 85 ++++++++++++++----- apps/webapp/src/middleware.ts | 7 +- 7 files changed, 295 insertions(+), 73 deletions(-) diff --git a/apps/webapp/src/app/api/auth/login/route.ts b/apps/webapp/src/app/api/auth/login/route.ts index 9ef3d00..863dec3 100644 --- a/apps/webapp/src/app/api/auth/login/route.ts +++ b/apps/webapp/src/app/api/auth/login/route.ts @@ -1,20 +1,59 @@ import { NextRequest, NextResponse } from 'next/server'; -import { createHmac } from 'crypto'; +import { createHmac, timingSafeEqual } from 'crypto'; const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production'; const LOGIN_USER = process.env.LOGIN_USER ?? 'admin'; const LOGIN_PASS = process.env.LOGIN_PASS ?? 'admin'; +// in-memory rate limiter: max 10 attempts per IP per 15 min +const attempts = new Map(); + +function checkRate(ip: string): boolean { + const now = Date.now(); + const e = attempts.get(ip); + if (!e || now > e.resetAt) { attempts.set(ip, { n: 1, resetAt: now + 15 * 60_000 }); return true; } + if (e.n >= 10) return false; + e.n++; + return true; +} + +function safeEq(a: string, b: string): boolean { + try { + const ab = Buffer.from(a), bb = Buffer.from(b); + if (ab.length !== bb.length) { + // still run comparison on equal-length buffers to avoid timing leak + timingSafeEqual(ab, ab); + return false; + } + return timingSafeEqual(ab, bb); + } catch { return false; } +} + function sign(value: string) { return createHmac('sha256', SECRET).update(value).digest('hex'); } export async function POST(req: NextRequest) { - const body = await req.json().catch(() => ({})); - const { username, password } = body as Record; - if (!username || !password || username !== LOGIN_USER || password !== LOGIN_PASS) { + const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? 'unknown'; + if (!checkRate(ip)) { + return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 }); + } + + const body = await req.json().catch(() => null); + if (!body || typeof body !== 'object') { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } + const { username, password } = body as Record; + if (typeof username !== 'string' || typeof password !== 'string' || !username || !password) { + return NextResponse.json({ error: 'Username and password required' }, { status: 400 }); + } + + const validUser = safeEq(username, LOGIN_USER); + const validPass = safeEq(password, LOGIN_PASS); + if (!validUser || !validPass) { return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }); } + const payload = `${username}:${Date.now()}`; const token = `${payload}.${sign(payload)}`; const res = NextResponse.json({ ok: true }); diff --git a/apps/webapp/src/app/api/auth/logout/route.ts b/apps/webapp/src/app/api/auth/logout/route.ts index 65a5400..a12505c 100644 --- a/apps/webapp/src/app/api/auth/logout/route.ts +++ b/apps/webapp/src/app/api/auth/logout/route.ts @@ -4,5 +4,6 @@ export async function POST() { const res = NextResponse.json({ ok: true }); res.cookies.delete('session'); res.cookies.delete('oidc_token'); + res.cookies.delete('oidc_token_pub'); return res; } diff --git a/apps/webapp/src/app/dashboard/page.tsx b/apps/webapp/src/app/dashboard/page.tsx index c92b0cb..aadc203 100644 --- a/apps/webapp/src/app/dashboard/page.tsx +++ b/apps/webapp/src/app/dashboard/page.tsx @@ -13,6 +13,7 @@ import { fetchDocuments, fetchInsights, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl, InsightsResult, IS_DEMO, + Patch, publishVersion, PublicAsset, PublicAssetAnalytics, requestAiSuggestions, Submission, @@ -29,6 +30,25 @@ import { DEMO_DOCUMENTS, DEMO_DOC_ID, DEMO_INSIGHTS, DEMO_SUBMISSIONS, } from './demo-data'; +// ── diff propagation helpers ────────────────────────────────────────────────── + +function getAncestorChain(versions: Version[], versionId: string): Version[] { + const map = new Map(versions.map(v => [v.id, v])); + const chain: Version[] = []; + let cur = map.get(versionId); + while (cur) { chain.unshift(cur); cur = cur.parent_version_id ? map.get(cur.parent_version_id) : undefined; } + return chain; +} + +function computeInheritedPatches(versions: Version[], versionId: string): Patch[] { + return getAncestorChain(versions, versionId).slice(0, -1).flatMap(v => v.patches); +} + +function getDescendants(versions: Version[], versionId: string): Version[] { + const children = versions.filter(v => v.parent_version_id === versionId); + return children.flatMap(c => [c, ...getDescendants(versions, c.id)]); +} + // ── helpers ─────────────────────────────────────────────────────────────────── function fmt(iso: string) { @@ -601,6 +621,8 @@ export default function Dashboard() { const [insights, setInsights] = useState(null); const [uploadBranchLoading, setUploadBranchLoading] = useState(false); const [uploadBranchError, setUploadBranchError] = useState(''); + const [propagateLoading, setPropagateLoading] = useState(false); + const [propagateError, setPropagateError] = useState(''); const uploadBranchRef = useRef(null); useEffect(() => { @@ -633,6 +655,7 @@ export default function Dashboard() { setApplyLoading(false); setPublishedAnalytics({}); setRecentlyPublishedSlug(null); + setPropagateError(''); }, [selectedVersionId]); useEffect(() => { @@ -807,6 +830,31 @@ export default function Dashboard() { } }; + const handlePropagate = async (versionId: string) => { + if (IS_DEMO || !selectedDoc) return; + const version = selectedDoc.versions.find(v => v.id === versionId); + if (!version || !version.patches.length) return; + const descendants = getDescendants(selectedDoc.versions, versionId); + if (!descendants.length) return; + const msg = `Propagate ${version.patches.length} patch${version.patches.length !== 1 ? 'es' : ''} from "${version.branch_name}" to ${descendants.length} downstream branch${descendants.length !== 1 ? 'es' : ''}?`; + if (!confirm(msg)) return; + setPropagateLoading(true); + setPropagateError(''); + try { + await Promise.all(descendants.map(d => + appendPatches(d.id, version.patches.map(p => ({ + target_path: p.target_path, operation: p.operation, + old_value: p.old_value ?? undefined, new_value: p.new_value ?? undefined, + })) as Record[]) + )); + await refreshDocs(); + } catch (e: unknown) { + setPropagateError(e instanceof Error ? e.message : 'Propagation failed'); + } finally { + setPropagateLoading(false); + } + }; + const selectVersion = (id: string) => { setSelectedVersionId(id); setActiveTab('content'); @@ -939,6 +987,7 @@ export default function Dashboard() { onSelect={selectVersion} onDeleteVersion={handleDeleteVersion} onCopyMarkdown={handleCopyBranchMarkdown} + onPropagate={handlePropagate} /> )} @@ -992,6 +1041,7 @@ export default function Dashboard() { selectedVersionId={selectedVersionId} onSelect={selectVersion} onCopyMarkdown={handleCopyBranchMarkdown} + onPropagate={handlePropagate} /> @@ -1042,6 +1092,17 @@ export default function Dashboard() { {!IS_DEMO && } {!IS_DEMO && } {!IS_DEMO && } + {!IS_DEMO && selectedVersion.patches.length > 0 && selectedDoc && getDescendants(selectedDoc.versions, selectedVersion.id).length > 0 && ( + + )} {IS_DEMO && ( ↓ DOCX @@ -1182,6 +1243,17 @@ export default function Dashboard() { )} + {propagateError && ( +
+ {propagateError} + +
+ )} + {/* tabs */}
{(['content', 'patches', 'submissions', 'insights', 'preview'] as Tab[]).map(t => ( @@ -1211,9 +1283,17 @@ export default function Dashboard() { onEdit={stageEdit} /> )} - {activeTab === 'patches' && ( - - )} + {activeTab === 'patches' && (() => { + const inherited = selectedDoc ? computeInheritedPatches(selectedDoc.versions, selectedVersion.id) : []; + const parentBranch = selectedDoc?.versions.find(v => v.id === selectedVersion.parent_version_id)?.branch_name; + return ( + + ); + })()} {activeTab === 'submissions' && ( = { + no_code: 'No authorization code received from provider.', + oidc_not_configured: 'OIDC provider not configured.', + token_exchange: 'Failed to exchange token with provider.', +}; function authentikBase(url?: string | null) { if (!url) return null; - try { - const parsed = new URL(url); - return parsed.origin.replace(/\/$/, ''); - } catch { - return null; - } + try { return new URL(url).origin.replace(/\/$/, ''); } catch { return null; } } function authentikUrl() { @@ -18,37 +19,45 @@ function authentikUrl() { const clientId = process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID; const base = process.env.NEXT_PUBLIC_BASE_URL ?? (typeof window !== 'undefined' ? window.location.origin : ''); if (!baseHost || !clientId) return null; - const params = new URLSearchParams({ - response_type: 'code', - client_id: clientId, - redirect_uri: `${base}/api/auth/callback`, - scope: 'openid email profile', - }); - return `${baseHost}/application/o/authorize/?${params}`; + return `${baseHost}/application/o/authorize/?${new URLSearchParams({ + response_type: 'code', client_id: clientId, + redirect_uri: `${base}/api/auth/callback`, scope: 'openid email profile', + })}`; } -export default function LoginPage() { +function LoginForm() { const router = useRouter(); + const params = useSearchParams(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const oidcUrl = typeof window !== 'undefined' ? authentikUrl() : null; + useEffect(() => { + const e = params.get('error'); + if (e) setError(OIDC_ERROR_LABELS[e] ?? `Auth error: ${e}`); + }, [params]); + const submit = async (e: React.FormEvent) => { e.preventDefault(); - if (!username || !password) return; + if (!username || !password || loading) return; setLoading(true); setError(''); - const res = await fetch('/api/auth/login', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ username, password }), - }); - if (res.ok) { - router.push('/dashboard'); - } else { - const j = await res.json().catch(() => ({})); - setError(j.error ?? 'Login failed'); + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (res.ok) { + router.push('/dashboard'); + } else { + const j = await res.json().catch(() => ({})); + setError(j.error ?? `Login failed (${res.status})`); + setLoading(false); + } + } catch { + setError('Network error — check your connection and try again.'); setLoading(false); } }; @@ -59,17 +68,11 @@ export default function LoginPage() { justifyContent: 'center', background: 'var(--bg)', }}>
- {/* brand */}
-
- cvfs -
-
- Sign in to your account -
+
cvfs
+
Sign in to your account
- {/* form card */}
setUsername(e.target.value)} - placeholder="admin" + placeholder="admin" disabled={loading} />
@@ -92,7 +95,7 @@ export default function LoginPage() { setPassword(e.target.value)} - placeholder="••••••••" + placeholder="••••••••" disabled={loading} />
@@ -142,3 +145,11 @@ export default function LoginPage() {
); } + +export default function LoginPage() { + return ( + + + + ); +} diff --git a/apps/webapp/src/components/cv/CVTree.tsx b/apps/webapp/src/components/cv/CVTree.tsx index f5be22e..e461a5c 100644 --- a/apps/webapp/src/components/cv/CVTree.tsx +++ b/apps/webapp/src/components/cv/CVTree.tsx @@ -3,15 +3,21 @@ import { MouseEvent, useState } from 'react'; import { Version } from '@/libs/api'; -type TreeNode = { version: Version; children: TreeNode[] }; +type TreeNode = { version: Version; children: TreeNode[]; ancestorPatchCount: number }; function buildTree(versions: Version[]): TreeNode | null { - const map = new Map(versions.map(v => [v.id, { version: v, children: [] as TreeNode[] }])); + const map = new Map(versions.map(v => [v.id, { version: v, children: [] as TreeNode[], ancestorPatchCount: 0 }])); let root: TreeNode | null = null; for (const node of map.values()) { const pid = node.version.parent_version_id; if (!pid) { root = node; } else { map.get(pid)?.children.push(node); } } + // annotate each node with total ancestor patch count + function annotate(node: TreeNode, inherited: number) { + node.ancestorPatchCount = inherited; + for (const child of node.children) annotate(child, inherited + node.version.patches.length); + } + if (root) annotate(root, 0); return root; } @@ -25,11 +31,12 @@ export function versionToMarkdown(version: Version, parentName?: string): string const DOT_COLORS = ['#0a0a0a', '#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2']; -function Node({ node, depth, selectedId, onSelect, onDelete, onCopyMarkdown, colorIndex = 0 }: { +function Node({ node, depth, selectedId, onSelect, onDelete, onCopyMarkdown, onPropagate, colorIndex = 0 }: { node: TreeNode; depth: number; selectedId: string | null; onSelect: (id: string) => void; onDelete?: (id: string) => void; onCopyMarkdown?: (id: string) => void; + onPropagate?: (id: string) => void; colorIndex?: number; }) { const [open, setOpen] = useState(true); @@ -39,6 +46,8 @@ function Node({ node, depth, selectedId, onSelect, onDelete, onCopyMarkdown, col const isRoot = !v.parent_version_id; const isSelected = v.id === selectedId; const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length]; + const hasChildren = node.children.length > 0; + const canPropagate = hasChildren && v.patches.length > 0; const handleCopy = (e: MouseEvent) => { e.stopPropagation(); @@ -74,7 +83,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, onCopyMarkdown, col width: 12, height: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: 'none', border: 'none', padding: 0, color: 'var(--text-faint)', flexShrink: 0, - opacity: node.children.length > 0 ? 1 : 0, pointerEvents: node.children.length > 0 ? 'auto' : 'none', + opacity: hasChildren ? 1 : 0, pointerEvents: hasChildren ? 'auto' : 'none', }} > + {/* own patch count */} {v.patches.length > 0 && ( )} + {/* ancestor patch indicator on non-root branches */} + {!isRoot && node.ancestorPatchCount > 0 && ( + + ↑{node.ancestorPatchCount} + + )} + {hovered && onCopyMarkdown && ( + )} + {!isRoot && onDelete && hovered && (
- {open && node.children.length > 0 && ( + {open && hasChildren && (
0 ? 22 : 14, borderLeft: `1px solid var(--border)`, @@ -158,6 +199,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, onCopyMarkdown, col onSelect={onSelect} onDelete={onDelete} onCopyMarkdown={onCopyMarkdown} + onPropagate={onPropagate} colorIndex={depth === 0 ? i + 1 : colorIndex} /> ))} @@ -167,17 +209,18 @@ function Node({ node, depth, selectedId, onSelect, onDelete, onCopyMarkdown, col ); } -export default function CVTree({ versions, selectedVersionId, onSelect, onDeleteVersion, onCopyMarkdown }: { +export default function CVTree({ versions, selectedVersionId, onSelect, onDeleteVersion, onCopyMarkdown, onPropagate }: { versions: Version[]; selectedVersionId: string | null; onSelect: (id: string) => void; onDeleteVersion?: (id: string) => void; onCopyMarkdown?: (id: string) => void; + onPropagate?: (id: string) => void; }) { const tree = buildTree(versions); if (!tree) return
No versions
; return (
- +
); } diff --git a/apps/webapp/src/components/cv/DiffViewer.tsx b/apps/webapp/src/components/cv/DiffViewer.tsx index 7761f26..f2e687c 100644 --- a/apps/webapp/src/components/cv/DiffViewer.tsx +++ b/apps/webapp/src/components/cv/DiffViewer.tsx @@ -1,13 +1,46 @@ 'use client'; +import { useState } from 'react'; import { Patch } from '@/libs/api'; const OP_SYMBOL: Record = { replace_text: '±', remove_block: '−', reorder_section: '↕', boost_keyword: '+', }; -export default function DiffViewer({ patches }: { patches: Patch[] }) { - if (!patches.length) { +function PatchRow({ p, dim }: { p: Patch; dim?: boolean }) { + return ( +
+
+ + {OP_SYMBOL[p.operation] ?? '·'} {p.target_path} + + {p.operation} +
+ {p.old_value && ( +
+ − {p.old_value} +
+ )} + {p.new_value && ( +
+ + {p.new_value} +
+ )} +
+ ); +} + +export default function DiffViewer({ + patches, inheritedPatches, ancestorLabel, +}: { + patches: Patch[]; + inheritedPatches?: Patch[]; + ancestorLabel?: string; +}) { + const [showInherited, setShowInherited] = useState(false); + const hasInherited = (inheritedPatches?.length ?? 0) > 0; + + if (!patches.length && !hasInherited) { return (
No patches — identical to parent. @@ -16,27 +49,37 @@ export default function DiffViewer({ patches }: { patches: Patch[] }) { } return ( -
- {patches.map(p => ( -
-
- - {OP_SYMBOL[p.operation] ?? '·'} {p.target_path} - - {p.operation} -
- {p.old_value && ( -
- − {p.old_value} -
- )} - {p.new_value && ( -
- + {p.new_value} -
+
+ {hasInherited && ( +
+ + {ancestorLabel && ( + via {ancestorLabel} )}
- ))} + )} + + {showInherited && hasInherited && ( +
+ {inheritedPatches!.map(p => )} +
+ )} + + {patches.length > 0 ? ( +
+ {patches.map(p => )} +
+ ) : ( +
+ No direct patches on this branch. +
+ )}
); } diff --git a/apps/webapp/src/middleware.ts b/apps/webapp/src/middleware.ts index 5b0cd29..9e05b1a 100644 --- a/apps/webapp/src/middleware.ts +++ b/apps/webapp/src/middleware.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production'; +const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms async function verifySession(token: string): Promise { const lastDot = token.lastIndexOf('.'); @@ -13,7 +14,11 @@ async function verifySession(token: string): Promise { { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'], ); const sigBytes = new Uint8Array((sigHex.match(/.{1,2}/g) ?? []).map(b => parseInt(b, 16))); - return await globalThis.crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload)); + const valid = await globalThis.crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload)); + if (!valid) return false; + // check expiry from embedded timestamp (payload = "username:timestamp") + const ts = parseInt(payload.split(':').pop() ?? '0', 10); + return !isNaN(ts) && Date.now() - ts < SESSION_MAX_AGE; } catch { return false; } }