diff --git a/frontend/src/lib/document/BulkDocumentEditLayout.svelte b/frontend/src/lib/document/BulkDocumentEditLayout.svelte index c4eaf2df..be369bc6 100644 --- a/frontend/src/lib/document/BulkDocumentEditLayout.svelte +++ b/frontend/src/lib/document/BulkDocumentEditLayout.svelte @@ -17,7 +17,7 @@ import PdfViewer from '$lib/document/viewer/PdfViewer.svelte'; import { bulkTitleFromFilename } from '$lib/document/filename'; import type { Tag } from '$lib/tag/TagInput.svelte'; import type { components } from '$lib/generated/api'; -import { withCsrf } from '$lib/shared/cookies'; +import { csrfFetch } from '$lib/shared/cookies'; type Person = components['schemas']['Person']; @@ -184,10 +184,10 @@ async function saveUpload() { // FormData with per-chunk progress. Session cookie is sent automatically // by the browser for same-origin requests. try { - const res = await fetch( - '/api/documents/quick-upload', - withCsrf({ method: 'POST', body: formData }) - ); + const res = await csrfFetch('/api/documents/quick-upload', { + method: 'POST', + body: formData + }); const body = await res.json().catch(() => ({ errors: [] })); const errorFilenames = new Set( (body.errors ?? []).map((err: { filename: string }) => err.filename) @@ -241,7 +241,7 @@ async function saveBulkEdit() { for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; try { - const res = await fetch('/api/documents/bulk', { + const res = await csrfFetch('/api/documents/bulk', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...dto, documentIds: chunk }) diff --git a/frontend/src/lib/document/DocumentEditLayout.svelte b/frontend/src/lib/document/DocumentEditLayout.svelte index a451da48..c32887c6 100644 --- a/frontend/src/lib/document/DocumentEditLayout.svelte +++ b/frontend/src/lib/document/DocumentEditLayout.svelte @@ -13,6 +13,7 @@ import WhoWhenSection from '$lib/document/WhoWhenSection.svelte'; import DescriptionSection from '$lib/document/DescriptionSection.svelte'; import type { Tag } from '$lib/tag/TagInput.svelte'; import type { components } from '$lib/generated/api'; +import { csrfFetch } from '$lib/shared/cookies'; import type { DatePrecision } from '$lib/shared/utils/documentDate'; type Person = components['schemas']['Person']; @@ -86,7 +87,7 @@ async function handleFile(file: File) { try { const formData = new FormData(); formData.append('file', file); - const res = await fetch(`/api/documents/${doc.id}/file`, { + const res = await csrfFetch(`/api/documents/${doc.id}/file`, { method: 'POST', body: formData, signal: controller.signal diff --git a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte index 225e1e4e..4d764a61 100644 --- a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte +++ b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte @@ -6,7 +6,7 @@ import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyStat import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types'; import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte'; import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte'; -import { withCsrf } from '$lib/shared/cookies'; +import { csrfFetch } from '$lib/shared/cookies'; type Props = { documentId: string; @@ -114,14 +114,11 @@ function handleDelete(blockId: string) { async function reorder(newOrder: string[]) { try { - const res = await fetch( - `/api/documents/${documentId}/transcription-blocks/reorder`, - withCsrf({ - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ blockIds: newOrder }) - }) - ); + const res = await csrfFetch(`/api/documents/${documentId}/transcription-blocks/reorder`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ blockIds: newOrder }) + }); if (!res.ok) return; const updated = await res.json(); for (const b of updated) { diff --git a/frontend/src/lib/document/transcription/useBlockAutoSave.svelte.ts b/frontend/src/lib/document/transcription/useBlockAutoSave.svelte.ts index d2b0bb47..2d6b915e 100644 --- a/frontend/src/lib/document/transcription/useBlockAutoSave.svelte.ts +++ b/frontend/src/lib/document/transcription/useBlockAutoSave.svelte.ts @@ -1,6 +1,6 @@ import { SvelteMap } from 'svelte/reactivity'; import type { PersonMention } from '$lib/shared/types'; -import { withCsrf } from '$lib/shared/cookies'; +import { csrfFetch } from '$lib/shared/cookies'; export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error'; @@ -117,15 +117,12 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { for (const [blockId, text] of pendingTexts) { const mentions = pendingMentions.get(blockId) ?? []; clearDebounce(blockId); - void fetch( - `/api/documents/${documentId}/transcription-blocks/${blockId}`, - withCsrf({ - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text, mentionedPersons: mentions }), - keepalive: true - }) - ); + void csrfFetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, mentionedPersons: mentions }), + keepalive: true + }); pendingTexts.delete(blockId); pendingMentions.delete(blockId); } diff --git a/frontend/src/lib/document/viewer/PdfViewer.svelte b/frontend/src/lib/document/viewer/PdfViewer.svelte index 311033c8..5b6d09c3 100644 --- a/frontend/src/lib/document/viewer/PdfViewer.svelte +++ b/frontend/src/lib/document/viewer/PdfViewer.svelte @@ -6,6 +6,7 @@ import AnnotationLayer from '$lib/document/annotation/AnnotationLayer.svelte'; import type { Annotation } from '$lib/shared/types'; import { m } from '$lib/paraglide/messages.js'; import { parseBackendError, getErrorMessage } from '$lib/shared/errors'; +import { csrfFetch } from '$lib/shared/cookies'; type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number }; @@ -132,7 +133,7 @@ async function updateAnnotation( coords: { x: number; y: number; width: number; height: number } ) { if (!documentId) return; - const res = await fetch(`/api/documents/${documentId}/annotations/${annotationId}`, { + const res = await csrfFetch(`/api/documents/${documentId}/annotations/${annotationId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(coords) diff --git a/frontend/src/lib/ocr/OcrTrainingCard.svelte b/frontend/src/lib/ocr/OcrTrainingCard.svelte index 88ae1c1f..707da730 100644 --- a/frontend/src/lib/ocr/OcrTrainingCard.svelte +++ b/frontend/src/lib/ocr/OcrTrainingCard.svelte @@ -2,7 +2,7 @@ import TrainingHistory from './TrainingHistory.svelte'; import { m } from '$lib/paraglide/messages.js'; import type { TrainingRun } from '$lib/ocr/training.js'; -import { withCsrf } from '$lib/shared/cookies'; +import { csrfFetch } from '$lib/shared/cookies'; interface TrainingInfo { availableBlocks?: number; @@ -34,7 +34,7 @@ async function startTraining() { successMessage = null; errorMessage = null; try { - const res = await fetch('/api/ocr/train', withCsrf({ method: 'POST' })); + const res = await csrfFetch('/api/ocr/train', { method: 'POST' }); if (res.ok) { successMessage = m.training_success(); setTimeout(() => { diff --git a/frontend/src/lib/ocr/SegmentationTrainingCard.svelte b/frontend/src/lib/ocr/SegmentationTrainingCard.svelte index c1e2f50b..b5fde3c3 100644 --- a/frontend/src/lib/ocr/SegmentationTrainingCard.svelte +++ b/frontend/src/lib/ocr/SegmentationTrainingCard.svelte @@ -2,7 +2,7 @@ import TrainingHistory from './TrainingHistory.svelte'; import { m } from '$lib/paraglide/messages.js'; import type { TrainingRun } from '$lib/ocr/training.js'; -import { withCsrf } from '$lib/shared/cookies'; +import { csrfFetch } from '$lib/shared/cookies'; interface TrainingInfo { availableSegBlocks?: number; @@ -28,7 +28,7 @@ async function startTraining() { training = true; successMessage = null; try { - const res = await fetch('/api/ocr/segtrain', withCsrf({ method: 'POST' })); + const res = await csrfFetch('/api/ocr/segtrain', { method: 'POST' }); if (res.ok) { successMessage = m.training_success(); setTimeout(() => { diff --git a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte index c3a34a50..bb695b02 100644 --- a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte @@ -6,6 +6,7 @@ import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/rel import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte'; import type { RelFormData } from '$lib/person/relationship/AddRelationshipForm.svelte'; import type { components } from '$lib/generated/api'; +import { csrfFetch } from '$lib/shared/cookies'; type PersonNodeDTO = components['schemas']['PersonNodeDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO']; @@ -59,7 +60,7 @@ async function handleAddRelationship(data: RelFormData) { }; if (data.fromYear !== undefined) body.fromYear = data.fromYear; if (data.toYear !== undefined) body.toYear = data.toYear; - const res = await fetch(`/api/persons/${node.id}/relationships`, { + const res = await csrfFetch(`/api/persons/${node.id}/relationships`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) diff --git a/frontend/src/lib/shared/cookies.test.ts b/frontend/src/lib/shared/cookies.test.ts new file mode 100644 index 00000000..8e09f6a6 --- /dev/null +++ b/frontend/src/lib/shared/cookies.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { getCsrfToken, withCsrf, makeCsrfFetch, csrfFetch } from './cookies'; + +// Helper that builds a minimal document.cookie stub. +function stubDocument(cookieStr: string) { + vi.stubGlobal('document', { cookie: cookieStr }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +// --------------------------------------------------------------------------- +// getCsrfToken +// --------------------------------------------------------------------------- + +describe('getCsrfToken', () => { + it('returns null when document is undefined (server-side / Node)', () => { + // No stub — document is undefined in Node vitest environment. + expect(getCsrfToken()).toBeNull(); + }); + + it('returns null when XSRF-TOKEN cookie is absent', () => { + stubDocument('other=abc; another=xyz'); + expect(getCsrfToken()).toBeNull(); + }); + + it('returns the token when XSRF-TOKEN is the only cookie', () => { + stubDocument('XSRF-TOKEN=secret42'); + expect(getCsrfToken()).toBe('secret42'); + }); + + it('returns the token when XSRF-TOKEN appears among other cookies', () => { + stubDocument('fa_session=sess1; XSRF-TOKEN=csrf99; locale=de'); + expect(getCsrfToken()).toBe('csrf99'); + }); + + it('URL-decodes the token value', () => { + stubDocument('XSRF-TOKEN=hello%2Fworld'); + expect(getCsrfToken()).toBe('hello/world'); + }); +}); + +// --------------------------------------------------------------------------- +// withCsrf +// --------------------------------------------------------------------------- + +describe('withCsrf', () => { + it('returns an empty RequestInit when cookie is absent and no init given', () => { + // No document stub → getCsrfToken() returns null. + expect(withCsrf()).toEqual({}); + }); + + it('passes init through unchanged when cookie is absent', () => { + const init: RequestInit = { method: 'POST', body: 'data' }; + expect(withCsrf(init)).toEqual(init); + }); + + it('injects X-XSRF-TOKEN header when token is present', () => { + stubDocument('XSRF-TOKEN=tok123'); + const result = withCsrf({ method: 'POST' }); + expect((result.headers as Headers).get('X-XSRF-TOKEN')).toBe('tok123'); + }); + + it('preserves existing headers when injecting the CSRF token', () => { + stubDocument('XSRF-TOKEN=tok123'); + const result = withCsrf({ headers: { 'Content-Type': 'application/json' } }); + const headers = result.headers as Headers; + expect(headers.get('Content-Type')).toBe('application/json'); + expect(headers.get('X-XSRF-TOKEN')).toBe('tok123'); + }); + + it('preserves the rest of the RequestInit when injecting', () => { + stubDocument('XSRF-TOKEN=tok'); + const result = withCsrf({ method: 'PUT', body: '{}' }); + expect(result.method).toBe('PUT'); + expect(result.body).toBe('{}'); + }); + + it('does not mutate the original init object', () => { + stubDocument('XSRF-TOKEN=tok'); + const init: RequestInit = { method: 'POST' }; + withCsrf(init); + expect(init.headers).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// makeCsrfFetch +// --------------------------------------------------------------------------- + +describe('makeCsrfFetch', () => { + const MUTATING = ['POST', 'PUT', 'PATCH', 'DELETE'] as const; + const SAFE = ['GET', 'HEAD'] as const; + + it('wraps the injected fetch — not the global one', async () => { + const inner = vi.fn().mockResolvedValue(new Response()); + const globalMock = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal('fetch', globalMock); + + const wrapped = makeCsrfFetch(inner); + await wrapped('/api/test', { method: 'GET' }); + + expect(inner).toHaveBeenCalledOnce(); + expect(globalMock).not.toHaveBeenCalled(); + }); + + it.each(MUTATING)('calls withCsrf for %s requests', async (method) => { + stubDocument('XSRF-TOKEN=tok'); + const inner = vi.fn().mockResolvedValue(new Response()); + const wrapped = makeCsrfFetch(inner); + + await wrapped('/api/resource', { method }); + + const passedInit = inner.mock.calls[0][1] as RequestInit; + expect((passedInit.headers as Headers).get('X-XSRF-TOKEN')).toBe('tok'); + }); + + it.each(SAFE)('passes %s requests through without CSRF header', async (method) => { + stubDocument('XSRF-TOKEN=tok'); + const inner = vi.fn().mockResolvedValue(new Response()); + const wrapped = makeCsrfFetch(inner); + + await wrapped('/api/resource', { method }); + + // init is passed as-is (no headers added). + const passedInit = inner.mock.calls[0][1] as RequestInit; + expect(passedInit?.headers).toBeUndefined(); + }); + + it('defaults to GET semantics when method is omitted', async () => { + stubDocument('XSRF-TOKEN=tok'); + const inner = vi.fn().mockResolvedValue(new Response()); + const wrapped = makeCsrfFetch(inner); + + await wrapped('/api/resource'); + + expect(inner).toHaveBeenCalledOnce(); + const passedInit = inner.mock.calls[0][1] as RequestInit | undefined; + expect(passedInit?.headers).toBeUndefined(); + }); + + it('is case-insensitive for method names', async () => { + stubDocument('XSRF-TOKEN=tok'); + const inner = vi.fn().mockResolvedValue(new Response()); + const wrapped = makeCsrfFetch(inner); + + await wrapped('/api/resource', { method: 'post' }); + + const passedInit = inner.mock.calls[0][1] as RequestInit; + expect((passedInit.headers as Headers).get('X-XSRF-TOKEN')).toBe('tok'); + }); +}); + +// --------------------------------------------------------------------------- +// csrfFetch — uses the global fetch (regression guard for the module-const bug) +// --------------------------------------------------------------------------- + +describe('csrfFetch', () => { + const MUTATING = ['POST', 'PUT', 'PATCH', 'DELETE'] as const; + const SAFE = ['GET', 'HEAD'] as const; + + it('picks up a vi.stubGlobal fetch stub — does NOT bypass the mock', async () => { + // This is the regression test for the original module-level-const bug. + // If csrfFetch were `export const csrfFetch = makeCsrfFetch(fetch)` the + // reference captured at module init time would skip any later stub. + const mock = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal('fetch', mock); + + await csrfFetch('/api/test', { method: 'GET' }); + + expect(mock).toHaveBeenCalledOnce(); + }); + + it.each(MUTATING)('injects X-XSRF-TOKEN for %s when cookie is set', async (method) => { + stubDocument('XSRF-TOKEN=csrf-val'); + const mock = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal('fetch', mock); + + await csrfFetch('/api/resource', { method }); + + const passedInit = mock.mock.calls[0][1] as RequestInit; + expect((passedInit.headers as Headers).get('X-XSRF-TOKEN')).toBe('csrf-val'); + }); + + it.each(SAFE)('does NOT inject CSRF header for %s', async (method) => { + stubDocument('XSRF-TOKEN=csrf-val'); + const mock = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal('fetch', mock); + + await csrfFetch('/api/resource', { method }); + + const passedInit = mock.mock.calls[0][1] as RequestInit; + expect(passedInit?.headers).toBeUndefined(); + }); + + it('does not inject header when XSRF-TOKEN cookie is absent', async () => { + // No document stub → getCsrfToken() returns null → withCsrf() is a no-op. + const mock = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal('fetch', mock); + + await csrfFetch('/api/resource', { method: 'POST' }); + + const passedInit = mock.mock.calls[0][1] as RequestInit; + // withCsrf returns { ...init } when no token — headers key won't be set. + const headers = passedInit.headers; + if (headers instanceof Headers) { + expect(headers.has('X-XSRF-TOKEN')).toBe(false); + } else { + expect((headers as Record | undefined)?.['X-XSRF-TOKEN']).toBeUndefined(); + } + }); + + it('forwards the URL and init body to fetch', async () => { + const mock = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal('fetch', mock); + + await csrfFetch('/api/resource', { method: 'GET', body: null }); + + expect(mock.mock.calls[0][0]).toBe('/api/resource'); + }); + + it('is case-insensitive for method names', async () => { + stubDocument('XSRF-TOKEN=tok'); + const mock = vi.fn().mockResolvedValue(new Response()); + vi.stubGlobal('fetch', mock); + + await csrfFetch('/api/resource', { method: 'delete' }); + + const passedInit = mock.mock.calls[0][1] as RequestInit; + expect((passedInit.headers as Headers).get('X-XSRF-TOKEN')).toBe('tok'); + }); +}); diff --git a/frontend/src/lib/shared/cookies.ts b/frontend/src/lib/shared/cookies.ts index 3b2a273a..4b029dbf 100644 --- a/frontend/src/lib/shared/cookies.ts +++ b/frontend/src/lib/shared/cookies.ts @@ -41,6 +41,23 @@ export function makeCsrfFetch(inner: typeof fetch): typeof fetch { }; } +/** + * Drop-in replacement for fetch that automatically injects X-XSRF-TOKEN on + * all mutating requests (POST, PUT, PATCH, DELETE). Use this everywhere in + * client-side code instead of bare fetch + withCsrf(). + * + * Implemented as a function (not a module-level const) so that test stubs + * applied via vi.stubGlobal('fetch', mock) are picked up at call time rather + * than being silently bypassed by a pre-captured reference. + */ +export function csrfFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const method = (init?.method ?? 'GET').toUpperCase(); + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { + return fetch(input, withCsrf(init)); + } + return fetch(input, init); +} + /** * Extracts the fa_session cookie value from a list of Set-Cookie response headers. * diff --git a/frontend/src/lib/shared/discussion/CommentThread.svelte b/frontend/src/lib/shared/discussion/CommentThread.svelte index bdd9c3c9..64bbcf26 100644 --- a/frontend/src/lib/shared/discussion/CommentThread.svelte +++ b/frontend/src/lib/shared/discussion/CommentThread.svelte @@ -5,6 +5,7 @@ import type { Comment, FlatMessage, MentionDTO } from '$lib/shared/types'; import MentionEditor from '$lib/shared/discussion/MentionEditor.svelte'; import CommentMessage from '$lib/shared/discussion/CommentMessage.svelte'; import { extractContent } from '$lib/shared/discussion/mention'; +import { csrfFetch } from '$lib/shared/cookies'; type Props = { documentId: string; annotationId?: string | null; @@ -79,7 +80,7 @@ async function postComment() { posting = true; try { const { content, mentionedUserIds } = extractContent(text, newMentionCandidates); - const res = await fetch(commentsBase, { + const res = await csrfFetch(commentsBase, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, mentionedUserIds }) @@ -104,7 +105,7 @@ async function saveEdit(commentId: string) { if (!text || posting) return; posting = true; try { - const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, { + const res = await csrfFetch(`/api/documents/${documentId}/comments/${commentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: text }) @@ -138,7 +139,7 @@ async function deleteComment(commentId: string) { if (posting) return; posting = true; try { - const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, { + const res = await csrfFetch(`/api/documents/${documentId}/comments/${commentId}`, { method: 'DELETE' }); if (res.ok) { diff --git a/frontend/src/routes/admin/system/+page.svelte b/frontend/src/routes/admin/system/+page.svelte index 8b5b6ccf..d9394144 100644 --- a/frontend/src/routes/admin/system/+page.svelte +++ b/frontend/src/routes/admin/system/+page.svelte @@ -3,7 +3,7 @@ import { onDestroy } from 'svelte'; import { m } from '$lib/paraglide/messages.js'; import ImportStatusCard from './ImportStatusCard.svelte'; import type { ImportStatus } from './types.js'; -import { withCsrf } from '$lib/shared/cookies'; +import { csrfFetch } from '$lib/shared/cookies'; let backfillResult: number | null = $state(null); let backfillLoading = $state(false); @@ -62,7 +62,7 @@ async function fetchImportStatus() { } async function triggerImport() { - const res = await fetch('/api/admin/trigger-import', withCsrf({ method: 'POST' })); + const res = await csrfFetch('/api/admin/trigger-import', { method: 'POST' }); if (res.ok) { importStatus = await res.json(); if (importStatus!.state === 'RUNNING') { @@ -84,7 +84,7 @@ async function fetchThumbnailStatus() { } async function triggerThumbnails() { - const res = await fetch('/api/admin/generate-thumbnails', withCsrf({ method: 'POST' })); + const res = await csrfFetch('/api/admin/generate-thumbnails', { method: 'POST' }); if (res.ok) { thumbnailStatus = await res.json(); if (thumbnailStatus!.state === 'RUNNING') { @@ -107,7 +107,7 @@ async function backfillVersions() { backfillLoading = true; backfillResult = null; try { - const res = await fetch('/api/admin/backfill-versions', withCsrf({ method: 'POST' })); + const res = await csrfFetch('/api/admin/backfill-versions', { method: 'POST' }); if (res.ok) { const data = await res.json(); backfillResult = data.count; @@ -121,7 +121,7 @@ async function backfillFileHashes() { backfillHashesLoading = true; backfillHashesResult = null; try { - const res = await fetch('/api/admin/backfill-file-hashes', withCsrf({ method: 'POST' })); + const res = await csrfFetch('/api/admin/backfill-file-hashes', { method: 'POST' }); if (res.ok) { const data = await res.json(); backfillHashesResult = data.count; diff --git a/frontend/src/routes/documents/bulk-edit/+page.svelte b/frontend/src/routes/documents/bulk-edit/+page.svelte index 7138a3d0..14fd258e 100644 --- a/frontend/src/routes/documents/bulk-edit/+page.svelte +++ b/frontend/src/routes/documents/bulk-edit/+page.svelte @@ -7,6 +7,7 @@ import BulkDocumentEditLayout, { } from '$lib/document/BulkDocumentEditLayout.svelte'; import { getErrorMessage, parseBackendError } from '$lib/shared/errors'; import { m } from '$lib/paraglide/messages.js'; +import { csrfFetch } from '$lib/shared/cookies'; let entries = $state([]); let loading = $state(true); @@ -22,7 +23,7 @@ onMount(async () => { return; } try { - const res = await fetch('/api/documents/batch-metadata', { + const res = await csrfFetch('/api/documents/batch-metadata', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) diff --git a/frontend/src/routes/geschichten/[id]/+page.svelte b/frontend/src/routes/geschichten/[id]/+page.svelte index b13c2189..6a48ec91 100644 --- a/frontend/src/routes/geschichten/[id]/+page.svelte +++ b/frontend/src/routes/geschichten/[id]/+page.svelte @@ -5,6 +5,7 @@ import { safeHtml } from '$lib/shared/utils/sanitize'; import { formatDate } from '$lib/shared/utils/date'; import { getConfirmService } from '$lib/shared/services/confirm.svelte'; import BackButton from '$lib/shared/primitives/BackButton.svelte'; +import { csrfFetch } from '$lib/shared/cookies'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -35,7 +36,7 @@ async function handleDelete() { destructive: true }); if (!ok) return; - const res = await fetch(`/api/geschichten/${g.id}`, { method: 'DELETE' }); + const res = await csrfFetch(`/api/geschichten/${g.id}`, { method: 'DELETE' }); if (res.ok) { goto('/geschichten'); } diff --git a/frontend/src/routes/geschichten/[id]/edit/+page.svelte b/frontend/src/routes/geschichten/[id]/edit/+page.svelte index 709563bb..b35f5a35 100644 --- a/frontend/src/routes/geschichten/[id]/edit/+page.svelte +++ b/frontend/src/routes/geschichten/[id]/edit/+page.svelte @@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js'; import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte'; import BackButton from '$lib/shared/primitives/BackButton.svelte'; import { getErrorMessage } from '$lib/shared/errors'; +import { csrfFetch } from '$lib/shared/cookies'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -21,7 +22,7 @@ async function handleSubmit(payload: { submitting = true; errorMessage = null; try { - const res = await fetch(`/api/geschichten/${data.geschichte.id}`, { + const res = await csrfFetch(`/api/geschichten/${data.geschichte.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) diff --git a/frontend/src/routes/geschichten/new/+page.svelte b/frontend/src/routes/geschichten/new/+page.svelte index cf94c1f0..70ec081e 100644 --- a/frontend/src/routes/geschichten/new/+page.svelte +++ b/frontend/src/routes/geschichten/new/+page.svelte @@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js'; import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte'; import BackButton from '$lib/shared/primitives/BackButton.svelte'; import { getErrorMessage } from '$lib/shared/errors'; +import { csrfFetch } from '$lib/shared/cookies'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -21,7 +22,7 @@ async function handleSubmit(payload: { submitting = true; errorMessage = null; try { - const res = await fetch('/api/geschichten', { + const res = await csrfFetch('/api/geschichten', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)