feat(stammbaum): server-clamped initial view from ?cx&cy&z (#692)
The server load parses and sanitises the shareable pan/zoom params (degrading Infinity/NaN, clamping zoom) into initialView, which seeds the page view. A crafted link can no longer blank the SVG (Nora). US-PANEL-002 AC2 groundwork. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { parsePanZoomParams } from '$lib/person/genealogy/panZoom';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
export async function load({ fetch, url }) {
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.GET('/api/network');
|
||||
|
||||
@@ -12,6 +13,15 @@ export async function load({ fetch }) {
|
||||
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
|
||||
}
|
||||
|
||||
// Sanitise the shareable pan/zoom params server-side so a crafted link
|
||||
// (?z=Infinity, ?cx=NaN) degrades to a safe view before reaching layout
|
||||
// geometry (Nora #692).
|
||||
const initialView = parsePanZoomParams({
|
||||
cx: url.searchParams.get('cx'),
|
||||
cy: url.searchParams.get('cy'),
|
||||
z: url.searchParams.get('z')
|
||||
});
|
||||
|
||||
const network = result.data!;
|
||||
return { nodes: network.nodes ?? [], edges: network.edges ?? [] };
|
||||
return { nodes: network.nodes ?? [], edges: network.edges ?? [], initialView };
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
interface Props {
|
||||
data: { nodes: PersonNodeDTO[]; edges: RelationshipDTO[] };
|
||||
data: { nodes: PersonNodeDTO[]; edges: RelationshipDTO[]; initialView: PanZoomState };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
@@ -31,7 +31,7 @@ let selectedId = $state<string | null>(
|
||||
|
||||
const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null);
|
||||
|
||||
let view = $state<PanZoomState>(DEFAULT_VIEW);
|
||||
let view = $state<PanZoomState>(data.initialView);
|
||||
function zoomIn() {
|
||||
view = { ...view, z: clampZoom(view.z + ZOOM_STEP_KB) };
|
||||
}
|
||||
|
||||
56
frontend/src/routes/stammbaum/page.server.test.ts
Normal file
56
frontend/src/routes/stammbaum/page.server.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
import { DEFAULT_VIEW, MAX_ZOOM } from '$lib/person/genealogy/panZoom';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function mockNetwork() {
|
||||
vi.mocked(createApiClient).mockReturnValue({
|
||||
GET: vi.fn().mockResolvedValue({ response: { ok: true }, data: { nodes: [], edges: [] } })
|
||||
} as unknown as ReturnType<typeof createApiClient>);
|
||||
}
|
||||
|
||||
function loadEvent(query = '') {
|
||||
const url = new URL(`http://localhost/stammbaum${query}`);
|
||||
return {
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
request: new Request(url),
|
||||
url
|
||||
};
|
||||
}
|
||||
|
||||
describe('/stammbaum +page.server load — initialView', () => {
|
||||
it('returns DEFAULT_VIEW when no pan/zoom params are present', async () => {
|
||||
mockNetwork();
|
||||
const { load } = await import('./+page.server');
|
||||
const result = await load(loadEvent() as never);
|
||||
expect(result.initialView).toEqual(DEFAULT_VIEW);
|
||||
});
|
||||
|
||||
it('parses and returns valid ?cx&cy&z params', async () => {
|
||||
mockNetwork();
|
||||
const { load } = await import('./+page.server');
|
||||
const result = await load(loadEvent('?cx=120&cy=-40&z=1.5') as never);
|
||||
expect(result.initialView).toEqual({ x: 120, y: -40, z: 1.5 });
|
||||
});
|
||||
|
||||
it('degrades a crafted ?z=Infinity to a safe view (Nora #692)', async () => {
|
||||
mockNetwork();
|
||||
const { load } = await import('./+page.server');
|
||||
const result = await load(loadEvent('?z=Infinity&cx=NaN') as never);
|
||||
expect(result.initialView).toEqual(DEFAULT_VIEW);
|
||||
});
|
||||
|
||||
it('clamps an out-of-range zoom server-side', async () => {
|
||||
mockNetwork();
|
||||
const { load } = await import('./+page.server');
|
||||
const result = await load(loadEvent('?z=99') as never);
|
||||
expect(result.initialView.z).toBe(MAX_ZOOM);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { DEFAULT_VIEW } from '$lib/person/genealogy/panZoom';
|
||||
|
||||
const mockPage = {
|
||||
url: new URL('http://localhost/stammbaum'),
|
||||
@@ -28,7 +29,7 @@ describe('stammbaum page', () => {
|
||||
it('shows the empty state when there are no family nodes', async () => {
|
||||
mockPage.url = new URL('http://localhost/stammbaum');
|
||||
const Stammbaum = await loadComponent();
|
||||
render(Stammbaum, { props: { data: { nodes: [], edges: [] } } });
|
||||
render(Stammbaum, { props: { data: { nodes: [], edges: [], initialView: DEFAULT_VIEW } } });
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('heading', { name: /noch keine familienmitglieder/i }))
|
||||
@@ -41,7 +42,7 @@ describe('stammbaum page', () => {
|
||||
it('hides zoom controls when there are no nodes', async () => {
|
||||
mockPage.url = new URL('http://localhost/stammbaum');
|
||||
const Stammbaum = await loadComponent();
|
||||
render(Stammbaum, { props: { data: { nodes: [], edges: [] } } });
|
||||
render(Stammbaum, { props: { data: { nodes: [], edges: [], initialView: DEFAULT_VIEW } } });
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /vergrößern/i })).not.toBeInTheDocument();
|
||||
await expect
|
||||
@@ -52,7 +53,9 @@ describe('stammbaum page', () => {
|
||||
it('renders the page heading and zoom controls when nodes are present', async () => {
|
||||
mockPage.url = new URL('http://localhost/stammbaum');
|
||||
const Stammbaum = await loadComponent();
|
||||
render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } });
|
||||
render(Stammbaum, {
|
||||
props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('heading', { name: /stammbaum/i })).toBeVisible();
|
||||
await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeVisible();
|
||||
@@ -62,7 +65,9 @@ describe('stammbaum page', () => {
|
||||
it('preselects a node when the URL has a focus query param matching an existing node', async () => {
|
||||
mockPage.url = new URL('http://localhost/stammbaum?focus=p-1');
|
||||
const Stammbaum = await loadComponent();
|
||||
render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } });
|
||||
render(Stammbaum, {
|
||||
props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('complementary')).toBeVisible();
|
||||
});
|
||||
@@ -70,7 +75,9 @@ describe('stammbaum page', () => {
|
||||
it('does not preselect when the focus param does not match any node', async () => {
|
||||
mockPage.url = new URL('http://localhost/stammbaum?focus=missing');
|
||||
const Stammbaum = await loadComponent();
|
||||
render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } });
|
||||
render(Stammbaum, {
|
||||
props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('complementary')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -78,7 +85,9 @@ describe('stammbaum page', () => {
|
||||
it('clamps the zoom level when the zoom-out button is clicked many times', async () => {
|
||||
mockPage.url = new URL('http://localhost/stammbaum');
|
||||
const Stammbaum = await loadComponent();
|
||||
render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } });
|
||||
render(Stammbaum, {
|
||||
props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
|
||||
});
|
||||
|
||||
const zoomOut = page.getByRole('button', { name: /verkleinern/i });
|
||||
for (let i = 0; i < 10; i++) await zoomOut.click();
|
||||
|
||||
Reference in New Issue
Block a user