From 62e9bc67f19b865c52163240b6a0caf5565f561a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:31:39 +0000 Subject: [PATCH 1/2] feat(media): stream artifacts over HTTP with Range support Videos and audio can be hundreds of megabytes. Artifacts only travelled as base64 inside RPC calls, capped at 10 MiB and held whole in memory, so media could neither be uploaded, played with seeking nor produced by media jobs. - ArtifactStore gains stat, createReadStream (inclusive byte ranges), putStream (size cap enforced while writing, real SHA-256) and an optional localPath so ffmpeg can read stored files in place. LocalArtifactStore implements them with the same safety as before (O_EXCL, O_NOFOLLOW, 0600) and now validates space ids and keys before building paths. - storeArtifactStream / storeArtifactFromFile store a stream or a job's output file as an artifact row, with width, height, duration and parent, and remove the blob again if the row cannot be written. - GET /api/artifacts/:id serves an artifact with Range/206/416, ETag, inline disposition for media and attachment otherwise (safe RFC 6266 filenames), nosniff and same-origin CORP. Authorization matches artifacts.get: the owner's artifact of an active bot thread or group, with membership of the artifact's space (media elements cannot send the space header; an explicit header must name the same space). - POST /api/artifacts takes a raw streamed body with the target and name in the query, authorized like artifacts.create, with the MIME allowlist and the per-kind caps checked from Content-Length and again while writing. - The API CSP allows media from the app origin and blob URLs. - Web: artifactUrl(artifactId) and uploadArtifactFile for the UI. Tests cover the streaming store, range parsing, filename sanitizing, the file helper, and a Postgres suite for both routes: ranges, caching, other users, shared-space members, other spaces, archived threads, signed-out requests and upload limits including chunked bodies. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/app.ts | 9 +- apps/api/src/artifact-http.test.ts | 142 ++++++++ apps/api/src/artifact-http.ts | 285 +++++++++++++++ apps/api/src/request-body-limit.ts | 6 +- apps/web/src/lib/artifact-url.ts | 38 ++ packages/adapter-kit/src/interfaces.ts | 17 + packages/adapter-kit/src/types.ts | 28 ++ packages/adapters/src/artifact-files.test.ts | 125 +++++++ packages/adapters/src/artifact-files.ts | 116 ++++++ packages/adapters/src/artifacts.test.ts | 156 +++++++- packages/adapters/src/artifacts.ts | 198 +++++++++- packages/adapters/src/index.ts | 1 + packages/testkit/src/artifact-routes.test.ts | 361 +++++++++++++++++++ packages/testkit/src/cli/harness.ts | 1 + 14 files changed, 1460 insertions(+), 23 deletions(-) create mode 100644 apps/api/src/artifact-http.test.ts create mode 100644 apps/api/src/artifact-http.ts create mode 100644 apps/web/src/lib/artifact-url.ts create mode 100644 packages/adapters/src/artifact-files.test.ts create mode 100644 packages/adapters/src/artifact-files.ts create mode 100644 packages/testkit/src/artifact-routes.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 948d735..c05a71f 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -89,6 +89,7 @@ import { requestLogging } from "@rakazo/logging/hono"; import { MarkdownMemoryStore } from "@rakazo/memory"; import { Hono } from "hono"; import { cors } from "hono/cors"; +import { mountArtifactHttpRoutes } from "./artifact-http.js"; import type { AppEnv } from "./env.js"; import { loadEnv } from "./env.js"; import { mountLocalSettings } from "./local-settings.js"; @@ -513,7 +514,7 @@ export async function createApp( await next(); c.res.headers.set( "Content-Security-Policy", - "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", ); c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); c.res.headers.set("X-Frame-Options", "DENY"); @@ -583,6 +584,12 @@ export async function createApp( if (actor) enrichLogContext({ "user.id": actor.userId, "space.id": actor.spaceId }); return actor; }); + mountArtifactHttpRoutes(app, { + prisma, + artifacts, + sessionUserId: async (request) => + (await auth.api.getSession({ headers: sessionHeaders(request) }))?.user?.id ?? null, + }); mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs }); // Shared with stop so a shutdown during retry delays does not restart polling. let messagingStopped = false; diff --git a/apps/api/src/artifact-http.test.ts b/apps/api/src/artifact-http.test.ts new file mode 100644 index 0000000..a1d6d44 --- /dev/null +++ b/apps/api/src/artifact-http.test.ts @@ -0,0 +1,142 @@ +import type { ArtifactStore } from "@rakazo/adapter-kit"; +import type { PrismaClient } from "@rakazo/db"; +import { Hono } from "hono"; +import { describe, expect, it, vi } from "vitest"; +import { contentDisposition, mountArtifactHttpRoutes, parseByteRange } from "./artifact-http.js"; + +describe("parseByteRange", () => { + it("ignores an absent header or another unit", () => { + expect(parseByteRange(undefined, 100)).toBeNull(); + expect(parseByteRange("", 100)).toBeNull(); + expect(parseByteRange("items=0-1", 100)).toBeNull(); + }); + + it("parses closed, open and suffix ranges and clamps the end to the size", () => { + expect(parseByteRange("bytes=0-9", 100)).toEqual({ start: 0, end: 9 }); + expect(parseByteRange("bytes=90-", 100)).toEqual({ start: 90, end: 99 }); + expect(parseByteRange("bytes=-10", 100)).toEqual({ start: 90, end: 99 }); + expect(parseByteRange("bytes=-500", 100)).toEqual({ start: 0, end: 99 }); + expect(parseByteRange("bytes=50-1000", 100)).toEqual({ start: 50, end: 99 }); + expect(parseByteRange("Bytes= 0-0 ", 100)).toEqual({ start: 0, end: 0 }); + }); + + it("rejects multiple, inverted, out-of-file and malformed ranges", () => { + for (const header of [ + "bytes=0-1,5-6", + "bytes=5-4", + "bytes=100-", + "bytes=100-200", + "bytes=-0", + "bytes=-", + "bytes=a-b", + "bytes=1.5-2", + "bytes= -1-2", + "bytes=0x10-20", + `bytes=${"9".repeat(16)}-`, + ]) { + expect(parseByteRange(header, 100), header).toBe("unsatisfiable"); + } + expect(parseByteRange("bytes=0-0", 0)).toBe("unsatisfiable"); + }); +}); + +describe("contentDisposition", () => { + it("keeps plain names and marks media inline", () => { + expect(contentDisposition("clip.mp4", true)).toBe( + `inline; filename="clip.mp4"; filename*=UTF-8''clip.mp4`, + ); + }); + + it("neutralizes quotes, separators, control characters and header injection", () => { + const header = contentDisposition('../evil"\r\nSet-Cookie: x=1;.txt', false); + expect(header.startsWith("attachment; ")).toBe(true); + expect(header).not.toMatch(/[\r\n]/); + expect(header).toContain(`filename=".._evil___Set-Cookie: x=1_.txt"`); + expect(header).not.toContain("/"); + }); + + it("encodes non-ASCII names with an ASCII fallback", () => { + expect(contentDisposition("Grüße (1).png", true)).toBe( + `inline; filename="Gr__e (1).png"; filename*=UTF-8''Gr%C3%BC%C3%9Fe%20%281%29.png`, + ); + expect(contentDisposition(" ", false)).toBe( + `attachment; filename="download"; filename*=UTF-8''download`, + ); + }); +}); + +describe("artifact HTTP routes", () => { + function app(sessionUserId: () => Promise) { + const prisma = { + artifact: { findUnique: vi.fn(), create: vi.fn() }, + spaceMember: { findFirst: vi.fn() }, + }; + const artifacts = { putStream: vi.fn(), stat: vi.fn(), createReadStream: vi.fn() }; + const hono = new Hono(); + mountArtifactHttpRoutes(hono, { + prisma: prisma as unknown as PrismaClient, + artifacts: artifacts as unknown as ArtifactStore, + sessionUserId, + }); + return { hono, prisma, artifacts }; + } + + it("rejects signed-out reads and uploads before touching storage", async () => { + const { hono, prisma, artifacts } = app(async () => null); + const read = await hono.request("/api/artifacts/art-1"); + const upload = await hono.request("/api/artifacts?botId=bot-1&name=a.mp4", { + method: "POST", + headers: { "content-type": "video/mp4" }, + body: "data", + }); + expect(read.status).toBe(401); + expect(upload.status).toBe(401); + expect(prisma.artifact.findUnique).not.toHaveBeenCalled(); + expect(artifacts.putStream).not.toHaveBeenCalled(); + }); + + it("answers ids that are not artifact ids with 404 without a lookup", async () => { + const { hono, prisma } = app(async () => "user-1"); + const response = await hono.request(`/api/artifacts/${encodeURIComponent("../etc/passwd")}`); + expect(response.status).toBe(404); + expect(prisma.artifact.findUnique).not.toHaveBeenCalled(); + }); + + it("rejects declared oversized and disallowed uploads without reading the body", async () => { + const { hono, prisma, artifacts } = app(async () => "user-1"); + prisma.spaceMember.findFirst.mockResolvedValue({ + userId: "user-1", + spaceId: "space-1", + member: { user: { email: "user@example.test" } }, + }); + (prisma as unknown as { deploymentSettings: unknown }).deploymentSettings = { + findUnique: vi.fn(async () => null), + }; + const cancel = vi.fn(() => new Promise(() => undefined)); + const oversized = await hono.request( + new Request("http://localhost/api/artifacts?botId=bot-1&name=a.mp4", { + method: "POST", + headers: { "content-type": "video/mp4", "content-length": String(501 * 1024 * 1024) }, + body: new ReadableStream({ cancel }), + duplex: "half", + } as RequestInit & { duplex: "half" }), + ); + expect(oversized.status).toBe(413); + expect(cancel).toHaveBeenCalled(); + + const html = await hono.request("/api/artifacts?botId=bot-1&name=a.html", { + method: "POST", + headers: { "content-type": "text/html" }, + body: "", + }); + expect(html.status).toBe(415); + + const noTarget = await hono.request("/api/artifacts?name=a.mp4", { + method: "POST", + headers: { "content-type": "video/mp4" }, + body: "data", + }); + expect(noTarget.status).toBe(400); + expect(artifacts.putStream).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/artifact-http.ts b/apps/api/src/artifact-http.ts new file mode 100644 index 0000000..b0c048c --- /dev/null +++ b/apps/api/src/artifact-http.ts @@ -0,0 +1,285 @@ +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 { + createGroupRepos, + createRepos, + IsolationError, + type PrismaClient, + requireMembership, +} from "@rakazo/db"; +import type { Context, Hono } from "hono"; +import { cancelBody } from "./http-body.js"; + +export const ARTIFACT_HTTP_PATH = "/api/artifacts"; + +export type ArtifactHttpDeps = { + prisma: PrismaClient; + artifacts: ArtifactStore; + /** The signed-in user (cookie or bearer session), or null. */ + sessionUserId: (request: Request) => Promise; +}; + +/** 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; + //