import type { AdapterContext, ArtifactByteRange, ArtifactStore } from "@rakazo/adapter-kit"; import { ArtifactTooLargeError } from "@rakazo/adapter-kit"; import { artifactMaxBytes, storeArtifactStream } from "@rakazo/adapters"; import type { Actor } from "@rakazo/contracts"; import { attachmentKindOf, isAllowedAttachmentMimeType } from "@rakazo/contracts"; import type { PrismaClient } from "@rakazo/db"; import { createGroupRepos, createRepos, IsolationError, requireMembership } from "@rakazo/db"; import type { Context, Hono } from "hono"; import { cancelBody } from "./http-body.js"; export const ARTIFACT_HTTP_PATH = "/api/artifacts"; /** * Node's default 5-minute requestTimeout cuts off a 500 MiB video upload on any uplink slower than * about 14 Mbit/s; 30 minutes fits it at about 2.5 Mbit/s. headersTimeout keeps its default, and * the per-upload stall timeout stops a slow-drip client from holding a connection that long. */ export const API_SERVER_OPTIONS = { requestTimeout: 30 * 60 * 1000 }; /** An upload that sends no bytes for this long is aborted and its partial file deleted. */ export const ARTIFACT_UPLOAD_STALL_TIMEOUT_MS = 60_000; export class UploadStalledError extends Error { constructor() { super("Upload stalled"); this.name = "UploadStalledError"; } } /** * Yields the body's chunks and throws UploadStalledError when the next chunk takes longer than * stallMs. Only time spent waiting for the client counts, not time spent writing to disk. */ export async function* stallGuardedChunks( body: ReadableStream, stallMs: number, ): AsyncGenerator { const reader = body.getReader(); try { for (;;) { let timer: ReturnType | undefined; const stalled = new Promise((_, reject) => { timer = setTimeout(() => reject(new UploadStalledError()), stallMs); }); try { const { done, value } = await Promise.race([reader.read(), stalled]); if (done) return; yield value; } finally { clearTimeout(timer); } } } finally { // Never wait on the client here: a stalled or hostile body may never confirm the cancel. void reader.cancel().catch(() => undefined); reader.releaseLock(); } } export type ArtifactHttpDeps = { prisma: PrismaClient; artifacts: ArtifactStore; /** The signed-in user (cookie or bearer session), or null. */ sessionUserId: (request: Request) => Promise; /** Same rule as CORS. Raw uploads can be CORS "simple" requests, so the origin is checked. */ trustedOrigin: (origin: string) => boolean; /** Overrides ARTIFACT_UPLOAD_STALL_TIMEOUT_MS (tests). */ uploadStallTimeoutMs?: number; }; /** Generated ids only; anything else is not an artifact. */ const ARTIFACT_ID = /^[A-Za-z0-9_-]{1,64}$/; const MAX_RANGE_DIGITS = 15; /** * Parses a single `bytes=` range against the stored size. Returns null when the header should * be ignored (absent or another unit) and "unsatisfiable" for multiple ranges or offsets * outside the file, which the caller answers with 416. */ export function parseByteRange( header: string | undefined, size: number, ): ArtifactByteRange | "unsatisfiable" | null { if (!header) return null; const value = header.trim(); if (!value.toLowerCase().startsWith("bytes=")) return null; const spec = value.slice("bytes=".length).trim(); const match = new RegExp(`^(\\d{0,${MAX_RANGE_DIGITS}})-(\\d{0,${MAX_RANGE_DIGITS}})$`).exec( spec, ); if (!match || size <= 0) return "unsatisfiable"; const [, first = "", last = ""] = match; if (first === "" && last === "") return "unsatisfiable"; if (first === "") { const suffix = Number(last); if (suffix <= 0) return "unsatisfiable"; return { start: Math.max(0, size - suffix), end: size - 1 }; } const start = Number(first); const end = last === "" ? size - 1 : Number(last); if (start >= size || end < start) return "unsatisfiable"; return { start, end: Math.min(end, size - 1) }; } /** `inline` or `attachment` with an ASCII fallback and the exact UTF-8 name (RFC 6266). */ export function contentDisposition(name: string, inline: boolean): string { const cleaned = Array.from(name, (char) => { const code = char.codePointAt(0) ?? 0; return code < 0x20 || code === 0x7f || char === "/" || char === "\\" ? "_" : char; }) .join("") .trim() || "download"; const ascii = cleaned.replace(/[^ -~]/g, "_").replace(/["%;]/g, "_"); const encoded = encodeURIComponent(cleaned).replace( /['()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, ); return `${inline ? "inline" : "attachment"}; filename="${ascii}"; filename*=UTF-8''${encoded}`; } function adapterContext(actor: Actor, operationId: string, signal: AbortSignal): AdapterContext { return { operationId, traceId: operationId, spaceId: actor.spaceId, userId: actor.userId, signal, }; } function notFound(c: Context) { return c.json({ error: "Not found" }, 404); } function artifactDto(row: { id: string; botId: string | null; groupId: string | null; runId: string | null; name: string; mimeType: string; size: number; createdAt: Date; }) { return { id: row.id, botId: row.botId, groupId: row.groupId, runId: row.runId, name: row.name, mimeType: row.mimeType, size: row.size, createdAt: row.createdAt.toISOString(), }; } /** * Streams artifacts over HTTP so media can play with seeking and large files never travel as * base64 RPC payloads. Authorization matches `artifacts.get` and `artifacts.create`. */ export function mountArtifactHttpRoutes(app: Hono, deps: ArtifactHttpDeps) { const repos = createRepos(deps.prisma); const groupRepos = createGroupRepos(deps.prisma); /** Same rule as artifacts.get: the owner's artifact of an active bot thread or group. */ async function readableArtifact( userId: string, requestedSpaceId: string | undefined, id: string, ) { if (!ARTIFACT_ID.test(id)) return null; const row = await deps.prisma.artifact.findUnique({ where: { id } }); if (!row) return null; //