Files
Synapsis/src/lib/stuffbox/browser-upload.ts
T
cyph3rasi b14be97965 Make Stuffbox linking resilient to cross-origin popup isolation and stale callbacks
Hop-State: A_06FP8WHY25K99094H3X60A8
Hop-Proposal: R_06FP8WGCVDDG61H0VZ1XQJR
Hop-Task: T_06FP8V5D2HSJG08NRN6VS3G
Hop-Attempt: AT_06FP8V5D2KATGJNV2M2SGRR
2026-07-14 23:23:56 -07:00

142 lines
5.1 KiB
TypeScript

'use client';
export interface UploadedMedia {
id: string;
url: string;
altText?: string | null;
mimeType?: string | null;
}
export class MediaUploadError extends Error {
constructor(
message: string,
readonly code?: string,
readonly status?: number,
) {
super(message);
this.name = 'MediaUploadError';
}
}
interface JsonResponse extends Record<string, unknown> {
id?: string;
uploadUrl?: string;
requiredHeaders?: Record<string, string>;
media?: UploadedMedia;
error?: string;
code?: string;
provider?: string | null;
stuffboxUpdatedAt?: string | null;
}
export type StorageProvider = 'stuffbox' | 's3' | null;
async function json(response: Response): Promise<JsonResponse> {
return response.json().catch(() => ({})) as Promise<JsonResponse>;
}
export async function getStorageProvider(): Promise<StorageProvider> {
const response = await fetch('/api/storage/configuration', { cache: 'no-store' });
const configuration = await json(response);
if (!response.ok) {
throw new MediaUploadError(configuration.error || 'Unable to load storage configuration', undefined, response.status);
}
return configuration.provider === 'stuffbox' || configuration.provider === 's3'
? configuration.provider
: null;
}
export async function hasNewStuffboxConnection(startedAt: string): Promise<boolean> {
const response = await fetch('/api/storage/configuration', { cache: 'no-store' });
const configuration = await json(response);
if (!response.ok) {
throw new MediaUploadError(configuration.error || 'Unable to verify the Stuffbox connection', undefined, response.status);
}
const updatedAt = typeof configuration.stuffboxUpdatedAt === 'string'
? Date.parse(configuration.stuffboxUpdatedAt)
: Number.NaN;
return configuration.provider === 'stuffbox'
&& Number.isFinite(updatedAt)
&& updatedAt >= Date.parse(startedAt);
}
function directPut(
uploadUrl: string,
file: File,
headers: Record<string, string>,
onProgress?: (progress: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open('PUT', uploadUrl);
for (const [name, value] of Object.entries(headers)) request.setRequestHeader(name, value);
request.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) onProgress?.(event.loaded / event.total);
});
request.addEventListener('load', () => {
if (request.status >= 200 && request.status < 300) {
onProgress?.(1);
resolve();
} else {
reject(new MediaUploadError(`Stuffbox rejected the upload (${request.status})`, 'DIRECT_UPLOAD_FAILED', request.status));
}
});
request.addEventListener('error', () => reject(new MediaUploadError(
'The browser could not reach Stuffbox. Check its CORS and public URL settings.',
'DIRECT_UPLOAD_FAILED',
)));
request.addEventListener('abort', () => reject(new MediaUploadError('Upload cancelled', 'UPLOAD_CANCELLED')));
request.send(file);
});
}
async function uploadToStuffbox(file: File, onProgress?: (progress: number) => void): Promise<UploadedMedia> {
const sessionResponse = await fetch('/api/media/stuffbox/uploads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, mimeType: file.type, size: file.size }),
});
const session = await json(sessionResponse);
if (!sessionResponse.ok) {
throw new MediaUploadError(session.error || 'Unable to start upload', session.code, sessionResponse.status);
}
if (!session.id || !session.uploadUrl) {
throw new MediaUploadError('Stuffbox returned an invalid upload session', 'INVALID_UPLOAD_SESSION');
}
await directPut(session.uploadUrl, file, session.requiredHeaders || {}, onProgress);
const completeResponse = await fetch(`/api/media/stuffbox/uploads/${encodeURIComponent(session.id)}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
const complete = await json(completeResponse);
if (!completeResponse.ok || !complete.media?.url) {
throw new MediaUploadError(complete.error || 'Unable to finish upload', complete.code, completeResponse.status);
}
return complete.media as UploadedMedia;
}
async function uploadToS3(file: File): Promise<UploadedMedia> {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/media/upload', { method: 'POST', body: formData });
const data = await json(response);
if (!response.ok || !data.media?.url) {
throw new MediaUploadError(data.error || 'Upload failed', data.code, response.status);
}
return data.media as UploadedMedia;
}
export async function uploadMediaFile(
file: File,
onProgress?: (progress: number) => void,
): Promise<UploadedMedia> {
const provider = await getStorageProvider();
if (provider === 'stuffbox') return uploadToStuffbox(file, onProgress);
if (provider === 's3') return uploadToS3(file);
throw new MediaUploadError('Connect media storage before uploading.', 'STORAGE_NOT_CONFIGURED', 409);
}