diff --git a/resources/js/guest/lib/__tests__/csrf.test.ts b/resources/js/guest/lib/__tests__/csrf.test.ts new file mode 100644 index 0000000..2fd6d33 --- /dev/null +++ b/resources/js/guest/lib/__tests__/csrf.test.ts @@ -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'); + }); +}); diff --git a/resources/js/guest/lib/csrf.ts b/resources/js/guest/lib/csrf.ts new file mode 100644 index 0000000..7ceabaa --- /dev/null +++ b/resources/js/guest/lib/csrf.ts @@ -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 { + const token = getCsrfToken(); + const resolvedDeviceId = deviceId ?? (typeof window !== 'undefined' ? getDeviceId() : undefined); + const headers: Record = { + Accept: 'application/json', + }; + + if (resolvedDeviceId) { + headers['X-Device-Id'] = resolvedDeviceId; + } + + if (token) { + headers['X-CSRF-TOKEN'] = token; + headers['X-XSRF-TOKEN'] = token; + } + + return headers; +} diff --git a/resources/js/guest/queue/xhr.ts b/resources/js/guest/queue/xhr.ts index fbdd1f5..e391663 100644 --- a/resources/js/guest/queue/xhr.ts +++ b/resources/js/guest/queue/xhr.ts @@ -1,4 +1,5 @@ import type { QueueItem } from './queue'; +import { buildCsrfHeaders } from '../lib/csrf'; export async function createUpload( url: string, @@ -9,7 +10,10 @@ export async function createUpload( return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); 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(); form.append('photo', it.blob, it.fileName); if (it.emotion_id) form.append('emotion_id', String(it.emotion_id)); diff --git a/resources/js/guest/services/photosApi.ts b/resources/js/guest/services/photosApi.ts index 7946b17..15dca3e 100644 --- a/resources/js/guest/services/photosApi.ts +++ b/resources/js/guest/services/photosApi.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { getDeviceId } from '../lib/device'; +import { buildCsrfHeaders } from '../lib/csrf'; export type UploadError = Error & { code?: string; @@ -7,51 +8,8 @@ export type UploadError = Error & { meta?: Record; }; -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 { - const token = getCsrfToken(); - const headers: Record = { - '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 { - const headers = getCsrfHeaders(); + const headers = buildCsrfHeaders(); const res = await fetch(`/api/v1/photos/${id}/like`, { method: 'POST', @@ -122,7 +80,7 @@ export async function uploadPhoto( const maxRetries = options.maxRetries ?? 2; const url = `/api/v1/events/${encodeURIComponent(eventToken)}/upload`; - const headers = getCsrfHeaders(); + const headers = buildCsrfHeaders(); const attemptUpload = (): Promise> => new Promise((resolve, reject) => {