Compare commits

...

3 Commits

Author SHA1 Message Date
Daniel Alves Rösel
1e57ec7642 Merge pull request #12 from velocitatem/claude/webapp-robustness-diff-propagation-433ive
Add patch propagation and improve auth security
2026-06-19 19:07:02 +02:00
Claude
52eab5b6e4 feat: robust login with rate limiting + diff propagation across tree
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Stdyi4YmG1BUSurArZPe8w
2026-06-19 11:00:41 +00:00
Daniel Alves Rösel
f625dead76 Merge pull request #11 from velocitatem/claude/improve-docs-preview-94gyn
Add PDF preview and DOCX upload to version branches
2026-05-03 01:34:13 +04:00
7 changed files with 295 additions and 73 deletions

View File

@@ -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<string, { n: number; resetAt: number }>();
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<string, string>;
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<string, unknown>;
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 });

View File

@@ -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;
}

View File

@@ -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<InsightsResult | null>(null);
const [uploadBranchLoading, setUploadBranchLoading] = useState(false);
const [uploadBranchError, setUploadBranchError] = useState('');
const [propagateLoading, setPropagateLoading] = useState(false);
const [propagateError, setPropagateError] = useState('');
const uploadBranchRef = useRef<HTMLInputElement>(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<string, unknown>[])
));
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}
/>
</div>
)}
@@ -992,6 +1041,7 @@ export default function Dashboard() {
selectedVersionId={selectedVersionId}
onSelect={selectVersion}
onCopyMarkdown={handleCopyBranchMarkdown}
onPropagate={handlePropagate}
/>
</div>
@@ -1042,6 +1092,17 @@ export default function Dashboard() {
{!IS_DEMO && <button className="btn btn-ghost" onClick={() => setModal('branch')}>Branch</button>}
{!IS_DEMO && <button className="btn btn-ghost" onClick={() => { setModal('submission'); }}>Submit</button>}
{!IS_DEMO && <button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button>}
{!IS_DEMO && selectedVersion.patches.length > 0 && selectedDoc && getDescendants(selectedDoc.versions, selectedVersion.id).length > 0 && (
<button
className="btn btn-ghost"
style={{ color: '#7c3aed', borderColor: '#c4b5fd' }}
onClick={() => handlePropagate(selectedVersion.id)}
disabled={propagateLoading}
title="Apply this version's patches to all descendant branches"
>
{propagateLoading ? 'Propagating…' : 'Propagate ↓'}
</button>
)}
{IS_DEMO && (
<a href="/demo-cv.docx" download="alex-rivera-cv.docx" className="btn btn-ghost">
DOCX
@@ -1182,6 +1243,17 @@ export default function Dashboard() {
</div>
)}
{propagateError && (
<div style={{
padding: '6px 12px', background: '#fef2f2', border: '1px solid #fca5a5',
borderRadius: 5, marginBottom: 12, fontSize: 12, color: '#b91c1c',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
<span>{propagateError}</span>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '1px 6px' }} onClick={() => setPropagateError('')}>×</button>
</div>
)}
{/* tabs */}
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', overflowX: 'auto' }}>
{(['content', 'patches', 'submissions', 'insights', 'preview'] as Tab[]).map(t => (
@@ -1211,9 +1283,17 @@ export default function Dashboard() {
onEdit={stageEdit}
/>
)}
{activeTab === 'patches' && (
<DiffViewer patches={selectedVersion.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 (
<DiffViewer
patches={selectedVersion.patches}
inheritedPatches={inherited}
ancestorLabel={parentBranch}
/>
);
})()}
{activeTab === 'submissions' && (
<SubmissionsTab
submissions={IS_DEMO

View File

@@ -1,16 +1,17 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
const OIDC_ERROR_LABELS: Record<string, string> = {
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)',
}}>
<div style={{ width: '100%', maxWidth: 360, padding: '0 20px' }}>
{/* brand */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}>
cvfs
</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
Sign in to your account
</div>
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}>cvfs</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Sign in to your account</div>
</div>
{/* form card */}
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, padding: '24px 24px 20px',
@@ -82,7 +85,7 @@ export default function LoginPage() {
<input
type="text" autoComplete="username" autoFocus
value={username} onChange={e => setUsername(e.target.value)}
placeholder="admin"
placeholder="admin" disabled={loading}
/>
</div>
<div>
@@ -92,7 +95,7 @@ export default function LoginPage() {
<input
type="password" autoComplete="current-password"
value={password} onChange={e => setPassword(e.target.value)}
placeholder="••••••••"
placeholder="••••••••" disabled={loading}
/>
</div>
@@ -142,3 +145,11 @@ export default function LoginPage() {
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginForm />
</Suspense>
);
}

View File

@@ -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',
}}
>
<svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor"
@@ -100,6 +109,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, onCopyMarkdown, col
{v.version_label || v.branch_name}
</span>
{/* own patch count */}
{v.patches.length > 0 && (
<span style={{
fontSize: 10, color: 'var(--text-faint)',
@@ -110,6 +120,20 @@ function Node({ node, depth, selectedId, onSelect, onDelete, onCopyMarkdown, col
</span>
)}
{/* ancestor patch indicator on non-root branches */}
{!isRoot && node.ancestorPatchCount > 0 && (
<span
title={`${node.ancestorPatchCount} patch${node.ancestorPatchCount !== 1 ? 'es' : ''} inherited from ancestors`}
style={{
fontSize: 10, color: '#2563eb',
background: '#eff6ff', padding: '1px 4px',
borderRadius: 3, flexShrink: 0,
}}
>
{node.ancestorPatchCount}
</span>
)}
{hovered && onCopyMarkdown && (
<button
onClick={handleCopy}
@@ -126,6 +150,23 @@ function Node({ node, depth, selectedId, onSelect, onDelete, onCopyMarkdown, col
</button>
)}
{/* propagate button: version has own patches and has children */}
{hovered && canPropagate && onPropagate && (
<button
onClick={e => { e.stopPropagation(); onPropagate(v.id); }}
title={`Propagate ${v.patches.length} patch${v.patches.length !== 1 ? 'es' : ''} to children`}
aria-label="Propagate patches to children"
style={{
width: 18, height: 18, display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: 'pointer', background: 'none',
border: 'none', padding: 0, color: '#7c3aed',
flexShrink: 0, borderRadius: 3, fontSize: 12, lineHeight: 1,
}}
>
</button>
)}
{!isRoot && onDelete && hovered && (
<button
onClick={e => { e.stopPropagation(); onDelete(v.id); }}
@@ -143,7 +184,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, onCopyMarkdown, col
)}
</div>
{open && node.children.length > 0 && (
{open && hasChildren && (
<div style={{
marginLeft: depth > 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 <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>No versions</div>;
return (
<div style={{ paddingBottom: 8 }}>
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} onDelete={onDeleteVersion} onCopyMarkdown={onCopyMarkdown} />
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} onDelete={onDeleteVersion} onCopyMarkdown={onCopyMarkdown} onPropagate={onPropagate} />
</div>
);
}

View File

@@ -1,13 +1,46 @@
'use client';
import { useState } from 'react';
import { Patch } from '@/libs/api';
const OP_SYMBOL: Record<string, string> = {
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 (
<div style={{ borderLeft: '2px solid var(--border-strong)', paddingLeft: 12, opacity: dim ? 0.6 : 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)' }}>
{OP_SYMBOL[p.operation] ?? '·'} {p.target_path}
</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{p.operation}</span>
</div>
{p.old_value && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, padding: '3px 6px', background: '#fef2f2', borderRadius: 3, marginBottom: 3, color: '#991b1b', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{p.old_value}
</div>
)}
{p.new_value && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, padding: '3px 6px', background: '#f0fdf4', borderRadius: 3, color: '#166534', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
+ {p.new_value}
</div>
)}
</div>
);
}
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 (
<div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>
No patches identical to parent.
@@ -16,27 +49,37 @@ export default function DiffViewer({ patches }: { patches: Patch[] }) {
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{patches.map(p => (
<div key={p.id} style={{ borderLeft: '2px solid var(--border-strong)', paddingLeft: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)' }}>
{OP_SYMBOL[p.operation] ?? '·'} {p.target_path}
</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{p.operation}</span>
</div>
{p.old_value && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, padding: '3px 6px', background: '#fef2f2', borderRadius: 3, marginBottom: 3, color: '#991b1b', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{p.old_value}
</div>
)}
{p.new_value && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, padding: '3px 6px', background: '#f0fdf4', borderRadius: 3, color: '#166534', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
+ {p.new_value}
</div>
<div>
{hasInherited && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<button
className="btn btn-ghost"
style={{ fontSize: 11, padding: '2px 8px' }}
onClick={() => setShowInherited(v => !v)}
>
{showInherited ? '▾' : '▸'} Inherited from ancestors ({inheritedPatches!.length})
</button>
{ancestorLabel && (
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>via {ancestorLabel}</span>
)}
</div>
))}
)}
{showInherited && hasInherited && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 18, paddingBottom: 14, borderBottom: '1px dashed var(--border)' }}>
{inheritedPatches!.map(p => <PatchRow key={p.id} p={p} dim />)}
</div>
)}
{patches.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{patches.map(p => <PatchRow key={p.id} p={p} />)}
</div>
) : (
<div style={{ padding: '8px 0', color: 'var(--text-faint)', fontSize: 13 }}>
No direct patches on this branch.
</div>
)}
</div>
);
}

View File

@@ -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<boolean> {
const lastDot = token.lastIndexOf('.');
@@ -13,7 +14,11 @@ async function verifySession(token: string): Promise<boolean> {
{ 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; }
}