diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 948d735..2a9e5c1 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -25,6 +25,8 @@ import { createCloudAgentConnection, createConnectorStack, createJobReconciler, + createMediaOperations, + createMediaRuntime, createMessagingContextLoader, createMessagingTeamChatSender, createProfileConsolidationScheduler, @@ -47,6 +49,7 @@ import { isPipedreamEnabled, LocalAgentHomeStore, LocalArtifactStore, + loadMediaSettings, McpConnector, McpOAuthBroker, messagingPlatformsFromEnv, @@ -89,6 +92,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"; @@ -375,6 +379,15 @@ export async function createApp( CLOUD_AGENT_SPACE_ID: env.cloudAgentSpaceId, }); const shutdown = new AbortController(); + const media = createMediaRuntime({ + prisma, + events, + jobs, + artifacts, + registrations: createMediaOperations({ + loadSettings: () => loadMediaSettings({ prisma, secrets }), + }), + }); const executor = createRunExecutor({ prisma, runtime, @@ -411,6 +424,7 @@ export async function createApp( messaging: messaging ? createMessagingContextLoader(prisma) : undefined, web: createWebProvider(), cloudAgent, + media, shutdownSignal: shutdown.signal, }); @@ -428,6 +442,7 @@ export async function createApp( deploymentModelKey: env.deploymentModelKey, messaging, cloudAgent, + media, }); if (inMemoryJobs) { await inMemoryJobs.start(jobHandlers); @@ -446,6 +461,7 @@ export async function createApp( reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }), reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }), reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }), + reconcileMediaJobs: () => media.reconcile(), }) : undefined; reconciler?.start(); @@ -513,7 +529,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 +599,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; + //