Share CSRF headers across guest uploads
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-30 13:10:19 +01:00
parent 96aaea23e4
commit 3ba784154b
4 changed files with 93 additions and 46 deletions

View File

@@ -0,0 +1,36 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { buildCsrfHeaders } from '../csrf';
describe('buildCsrfHeaders', () => {
beforeEach(() => {
localStorage.setItem('device-id', 'device-123');
});
afterEach(() => {
localStorage.clear();
document.head.querySelectorAll('meta[name="csrf-token"]').forEach((node) => node.remove());
document.cookie = 'XSRF-TOKEN=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
});
it('reads token from meta tag', () => {
const meta = document.createElement('meta');
meta.setAttribute('name', 'csrf-token');
meta.setAttribute('content', 'meta-token');
document.head.appendChild(meta);
const headers = buildCsrfHeaders('device-xyz');
expect(headers['X-CSRF-TOKEN']).toBe('meta-token');
expect(headers['X-XSRF-TOKEN']).toBe('meta-token');
expect(headers['X-Device-Id']).toBe('device-xyz');
});
it('falls back to cookie token', () => {
const raw = btoa('cookie-token');
document.cookie = `XSRF-TOKEN=${raw}; path=/`;
const headers = buildCsrfHeaders();
expect(headers['X-CSRF-TOKEN']).toBe('cookie-token');
expect(headers['X-XSRF-TOKEN']).toBe('cookie-token');
expect(headers['X-Device-Id']).toBe('device-123');
});
});

View File

@@ -0,0 +1,49 @@
import { getDeviceId } from './device';
function getCsrfToken(): string | null {
if (typeof document === 'undefined') {
return null;
}
const metaToken = document.querySelector('meta[name="csrf-token"]');
if (metaToken instanceof HTMLMetaElement) {
return metaToken.getAttribute('content') || null;
}
const name = 'XSRF-TOKEN=';
const decodedCookie = decodeURIComponent(document.cookie ?? '');
const parts = decodedCookie.split(';');
for (const part of parts) {
const trimmed = part.trimStart();
if (!trimmed.startsWith(name)) {
continue;
}
const token = trimmed.substring(name.length);
try {
return decodeURIComponent(atob(token));
} catch {
return token;
}
}
return null;
}
export function buildCsrfHeaders(deviceId?: string): Record<string, string> {
const token = getCsrfToken();
const resolvedDeviceId = deviceId ?? (typeof window !== 'undefined' ? getDeviceId() : undefined);
const headers: Record<string, string> = {
Accept: 'application/json',
};
if (resolvedDeviceId) {
headers['X-Device-Id'] = resolvedDeviceId;
}
if (token) {
headers['X-CSRF-TOKEN'] = token;
headers['X-XSRF-TOKEN'] = token;
}
return headers;
}

View File

@@ -1,4 +1,5 @@
import type { QueueItem } from './queue'; import type { QueueItem } from './queue';
import { buildCsrfHeaders } from '../lib/csrf';
export async function createUpload( export async function createUpload(
url: string, url: string,
@@ -9,7 +10,10 @@ export async function createUpload(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', url, true); xhr.open('POST', url, true);
xhr.setRequestHeader('X-Device-Id', deviceId); const headers = buildCsrfHeaders(deviceId);
Object.entries(headers).forEach(([key, value]) => {
xhr.setRequestHeader(key, value);
});
const form = new FormData(); const form = new FormData();
form.append('photo', it.blob, it.fileName); form.append('photo', it.blob, it.fileName);
if (it.emotion_id) form.append('emotion_id', String(it.emotion_id)); if (it.emotion_id) form.append('emotion_id', String(it.emotion_id));

View File

@@ -1,5 +1,6 @@
// @ts-nocheck // @ts-nocheck
import { getDeviceId } from '../lib/device'; import { getDeviceId } from '../lib/device';
import { buildCsrfHeaders } from '../lib/csrf';
export type UploadError = Error & { export type UploadError = Error & {
code?: string; code?: string;
@@ -7,51 +8,8 @@ export type UploadError = Error & {
meta?: Record<string, unknown>; meta?: Record<string, unknown>;
}; };
function getCsrfToken(): string | null {
// Method 1: Meta tag (preferred for SPA)
const metaToken = document.querySelector('meta[name="csrf-token"]');
if (metaToken) {
return (metaToken as HTMLMetaElement).getAttribute('content') || null;
}
// Method 2: XSRF-TOKEN cookie (Sanctum fallback)
const name = 'XSRF-TOKEN=';
const decodedCookie = decodeURIComponent(document.cookie);
const ca = decodedCookie.split(';');
for (let i = 0; i < ca.length; i++) {
const c = ca[i].trimStart();
if (c.startsWith(name)) {
const token = c.substring(name.length);
try {
// Decode base64 if needed
return decodeURIComponent(atob(token));
} catch {
return token;
}
}
}
console.warn('No CSRF token found - API requests may fail');
return null;
}
function getCsrfHeaders(): Record<string, string> {
const token = getCsrfToken();
const headers: Record<string, string> = {
'X-Device-Id': getDeviceId(),
'Accept': 'application/json',
};
if (token) {
headers['X-CSRF-TOKEN'] = token;
headers['X-XSRF-TOKEN'] = token;
}
return headers;
}
export async function likePhoto(id: number): Promise<number> { export async function likePhoto(id: number): Promise<number> {
const headers = getCsrfHeaders(); const headers = buildCsrfHeaders();
const res = await fetch(`/api/v1/photos/${id}/like`, { const res = await fetch(`/api/v1/photos/${id}/like`, {
method: 'POST', method: 'POST',
@@ -122,7 +80,7 @@ export async function uploadPhoto(
const maxRetries = options.maxRetries ?? 2; const maxRetries = options.maxRetries ?? 2;
const url = `/api/v1/events/${encodeURIComponent(eventToken)}/upload`; const url = `/api/v1/events/${encodeURIComponent(eventToken)}/upload`;
const headers = getCsrfHeaders(); const headers = buildCsrfHeaders();
const attemptUpload = (): Promise<Record<string, unknown>> => const attemptUpload = (): Promise<Record<string, unknown>> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {