aegida-console / lib / chat / gate-files.ts
gate-files.ts
Raw
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);
}