From a7d0e96613710a99c84d56e4d52b7eb2dd9e7127 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 16:25:11 +0200 Subject: [PATCH] feat(stammbaum): parse + sanitise URL pan/zoom params (#692) Degrade Infinity/NaN/overflow per axis and clamp zoom into bounds so a crafted ?cx/?cy/?z shared link cannot blank the SVG (Nora's review). Co-Authored-By: Claude Opus 4.8 --- .../src/lib/person/genealogy/panZoom.test.ts | 37 +++++++++++++++++- frontend/src/lib/person/genealogy/panZoom.ts | 39 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts index 509acdd8..fed80448 100644 --- a/frontend/src/lib/person/genealogy/panZoom.test.ts +++ b/frontend/src/lib/person/genealogy/panZoom.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { clampZoom, MIN_ZOOM, MAX_ZOOM } from './panZoom'; +import { + clampZoom, + parsePanZoomParams, + DEFAULT_VIEW, + DEFAULT_ZOOM, + MIN_ZOOM, + MAX_ZOOM +} from './panZoom'; describe('clampZoom', () => { it('returns the value unchanged when within range', () => { @@ -24,3 +31,31 @@ describe('clampZoom', () => { expect(MAX_ZOOM).toBe(3.0); }); }); + +describe('parsePanZoomParams', () => { + it('parses well-formed cx/cy/z params', () => { + expect(parsePanZoomParams({ cx: '120', cy: '-40', z: '1.5' })).toEqual({ + x: 120, + y: -40, + z: 1.5 + }); + }); + + it('falls back to DEFAULT_VIEW when params are absent', () => { + expect(parsePanZoomParams({})).toEqual(DEFAULT_VIEW); + expect(DEFAULT_VIEW).toEqual({ x: 0, y: 0, z: DEFAULT_ZOOM }); + }); + + it('rejects Infinity and NaN, degrading each axis to its default (Nora #692)', () => { + expect(parsePanZoomParams({ z: 'Infinity' }).z).toBe(DEFAULT_ZOOM); + expect(parsePanZoomParams({ z: 'NaN' }).z).toBe(DEFAULT_ZOOM); + expect(parsePanZoomParams({ cx: 'NaN', cy: 'Infinity' })).toEqual(DEFAULT_VIEW); + expect(parsePanZoomParams({ cx: '1e500' }).x).toBe(0); + }); + + it('clamps an out-of-range zoom into the supported bounds', () => { + expect(parsePanZoomParams({ z: '99' }).z).toBe(MAX_ZOOM); + expect(parsePanZoomParams({ z: '0.01' }).z).toBe(MIN_ZOOM); + expect(parsePanZoomParams({ z: '-3' }).z).toBe(MIN_ZOOM); + }); +}); diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 7b107440..1ec304ed 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -12,8 +12,47 @@ /** Resolved zoom bounds (OQ-001). */ export const MIN_ZOOM = 0.25; export const MAX_ZOOM = 3.0; +export const DEFAULT_ZOOM = 1; + +/** + * The canvas view state. `x`/`y` are pan offsets applied to the viewBox centre + * (SVG user units); `z` is the zoom factor. The default `{0, 0, 1}` frames the + * whole tree (fit-to-screen) because the base viewBox already encloses the + * layout bounding box at z=1. + */ +export type PanZoomState = { x: number; y: number; z: number }; + +/** Fit-to-screen / initial view (US-PAN-004). */ +export const DEFAULT_VIEW: PanZoomState = { x: 0, y: 0, z: DEFAULT_ZOOM }; /** Clamp a zoom factor into the supported range. */ export function clampZoom(z: number): number { return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)); } + +/** Parse a raw value to a finite number, or return `fallback` for NaN/Infinity/absent. */ +function finiteOr(raw: string | null | undefined, fallback: number): number { + if (raw == null) return fallback; + const n = Number(raw); + return Number.isFinite(n) ? n : fallback; +} + +/** + * Parse URL-supplied pan/zoom params into a safe {@link PanZoomState} (OQ-003). + * + * Every axis is sanitised independently: `Infinity`, `NaN`, overflow (`1e500`), + * and absent values degrade to the default for that axis, and the zoom is + * clamped into [MIN_ZOOM, MAX_ZOOM]. This guards against a crafted shared link + * (`?z=Infinity`, `?cx=NaN`) rendering the SVG blank — see Nora's review (#692). + */ +export function parsePanZoomParams(raw: { + cx?: string | null; + cy?: string | null; + z?: string | null; +}): PanZoomState { + return { + x: finiteOr(raw.cx, DEFAULT_VIEW.x), + y: finiteOr(raw.cy, DEFAULT_VIEW.y), + z: clampZoom(finiteOr(raw.z, DEFAULT_ZOOM)) + }; +}