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<GateFile> {
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<ReadableStream<Uint8Array>> {
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<void> {
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<RequestInit, "method" | "body">,
): Promise<Response> {
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<string> {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}