import "server-only"; const maximumFileObjectBytes = 1_048_576; const bearerPattern = /^Bearer [^\s,]+$/; const gateFileIdPattern = /^file-[A-Za-z0-9_-]{1,128}$/; export type GateFile = { id: string; filename: string; bytes: number; }; export type GateFileErrorReason = "unauthorized" | "unavailable"; export class GateFileError extends Error { constructor(readonly reason: GateFileErrorReason = "unavailable") { super("Aegida Gate Files API is unavailable"); this.name = "GateFileError"; } } export async function uploadGateFile( file: File, authorization: string, signal: AbortSignal, ): Promise { const form = new FormData(); form.append("purpose", "user_data"); form.append("file", file); const response = await gateFetch("/v1/files", authorization, signal, { method: "POST", body: form, }); try { const value = JSON.parse( await readBoundedText(response, maximumFileObjectBytes), ) as unknown; return parseGateFile(value); } catch (error) { if (error instanceof GateFileError) throw error; if (signal.aborted || isAbortError(error)) throw abortError(); throw new GateFileError(); } } export async function getGateFileContent( fileId: string, authorization: string, signal: AbortSignal, ): Promise> { requireGateFileId(fileId); const response = await gateFetch( `/v1/files/${fileId}/content`, authorization, signal, { method: "GET" }, ); if (!response.body) throw new GateFileError(); return response.body; } export async function deleteGateFile( fileId: string, authorization: string, signal: AbortSignal, ): Promise { requireGateFileId(fileId); await gateFetch(`/v1/files/${fileId}`, authorization, signal, { method: "DELETE", }); } function parseGateFile(value: unknown): GateFile { if ( !isRecord(value) || !isGateFileId(value.id) || value.object !== "file" || !Number.isSafeInteger(value.bytes) || (value.bytes as number) <= 0 || !Number.isSafeInteger(value.created_at) || (value.created_at as number) <= 0 || typeof value.filename !== "string" || !value.filename.trim() || value.filename.length > 255 || value.filename.includes("\0") || value.purpose !== "user_data" ) { throw new GateFileError(); } return { id: value.id, filename: value.filename, bytes: value.bytes as number, }; } async function gateFetch( path: string, authorization: string, signal: AbortSignal, init: Pick, ): Promise { if (!bearerPattern.test(authorization)) throw new GateFileError(); let response: Response; try { response = await fetch(`${gateOrigin()}${path}`, { method: init.method, headers: { Authorization: authorization }, ...(init.body ? { body: init.body } : {}), cache: "no-store", redirect: "error", signal, }); } catch (error) { if (signal.aborted || isAbortError(error)) throw abortError(); throw new GateFileError(); } if (response.status === 401) throw new GateFileError("unauthorized"); if (!response.ok) throw new GateFileError(); return response; } function requireGateFileId(value: string): void { if (!isGateFileId(value)) throw new GateFileError(); } function isGateFileId(value: unknown): value is string { return typeof value === "string" && gateFileIdPattern.test(value); } function gateOrigin(): string { const raw = process.env.AEGIDA_GATE_URL; if (!raw) throw new GateFileError(); try { const url = new URL(raw); if ( (url.protocol !== "http:" && url.protocol !== "https:") || !url.hostname || url.username || url.password || (url.pathname !== "" && url.pathname !== "/") || url.search || url.hash ) { throw new Error("invalid origin"); } return url.origin; } catch { throw new GateFileError(); } } async function readBoundedText(response: Response, maximum: number): Promise { if (!response.body) throw new GateFileError(); const reader = response.body.getReader(); const decoder = new TextDecoder(); let text = ""; let bytes = 0; while (true) { const { done, value } = await reader.read(); if (done) break; bytes += value.byteLength; if (bytes > maximum) { await reader.cancel(); throw new GateFileError(); } text += decoder.decode(value, { stream: true }); } return text + decoder.decode(); } function abortError(): DOMException { return new DOMException("The operation was aborted", "AbortError"); } function isAbortError(value: unknown): boolean { return value instanceof DOMException && value.name === "AbortError"; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }