Share CSRF headers across guest uploads
This commit is contained in:
36
resources/js/guest/lib/__tests__/csrf.test.ts
Normal file
36
resources/js/guest/lib/__tests__/csrf.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
49
resources/js/guest/lib/csrf.ts
Normal file
49
resources/js/guest/lib/csrf.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user