feat: add durable CMS-free image video subsystem and UI components
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { HarnessError, ImageVideoDraftSchema, taskIdSchema } from "@agent-studio/harness";
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppDependencies } from "../app.js";
|
||||
import { requireSession } from "./sessions.js";
|
||||
|
||||
export function registerImageVideoRoutes(app: FastifyInstance, dependencies: AppDependencies) {
|
||||
const context = (request: FastifyRequest) => {
|
||||
const host = request.headers.host ?? "";
|
||||
const ports = dependencies.imageVideoPorts ?? [80, 3001, 5173, 4173];
|
||||
let url: URL;
|
||||
try { url = new URL(`http://${host}`); } catch { throw new HarnessError("I2V_ORIGIN_DENIED", "Local origin required"); }
|
||||
if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) || !ports.includes(Number(url.port || 80)) || request.headers["sec-fetch-site"] === "cross-site" || (request.headers.origin !== undefined && request.headers.origin !== url.origin)) throw new HarnessError("I2V_ORIGIN_DENIED", "Image video tasks are available only from the local page");
|
||||
if (!dependencies.imageVideo) throw new HarnessError("I2V_TASK_NOT_FOUND", "Image video service unavailable");
|
||||
return dependencies.imageVideo;
|
||||
};
|
||||
app.get("/api/image-video/tasks", async (request, reply) => {
|
||||
const service = context(request); void reply.header("Cache-Control", "no-store");
|
||||
const history = await service.list();
|
||||
return { tasks: await Promise.all(history.map(task => service.refresh(task.id))) };
|
||||
});
|
||||
app.get("/api/image-video/tasks/:taskId", async (request, reply) => {
|
||||
const service = context(request); const { taskId } = z.object({ taskId: taskIdSchema }).parse(request.params);
|
||||
void reply.header("Cache-Control", "no-store"); return { task: await service.refresh(taskId) };
|
||||
});
|
||||
app.post("/api/sessions/:sessionId/image-video/tasks", async (request, reply) => {
|
||||
const service = context(request); const { sessionId } = z.object({ sessionId: z.string().regex(/^ses_[\w-]+$/) }).parse(request.params);
|
||||
requireSession(dependencies, sessionId);
|
||||
if (!request.headers["content-type"]?.startsWith("application/json")) throw new HarnessError("I2V_JSON_REQUIRED", "Draft creation requires JSON");
|
||||
void reply.header("Cache-Control", "no-store"); return { task: await service.create(sessionId, ImageVideoDraftSchema.parse(request.body)) };
|
||||
});
|
||||
for (const kind of ["reference", "video"] as const) app.get(`/api/image-video/tasks/:taskId/${kind}`, async (request, reply) => {
|
||||
const service = context(request); const { taskId } = z.object({ taskId: taskIdSchema }).parse(request.params);
|
||||
const query = z.object({ download: z.literal("1").optional() }).strict().parse(request.query);
|
||||
const media = await service.media(taskId, kind);
|
||||
void reply.header("Cache-Control", "no-store").header("X-Content-Type-Options", "nosniff").header("Cross-Origin-Resource-Policy", "same-origin").header("Accept-Ranges", "bytes").type(media.contentType);
|
||||
if (query.download === "1") void reply.header("Content-Disposition", `attachment; filename="${taskId}${kind === "video" ? ".mp4" : ".image"}"`);
|
||||
const range = request.headers.range;
|
||||
if (range !== undefined) {
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(range); let start = 0; let end = media.size - 1;
|
||||
if (match?.[1]) { start = Number(match[1]); if (match[2]) end = Math.min(Number(match[2]), end); }
|
||||
else if (match?.[2]) start = Math.max(0, media.size - Number(match[2]));
|
||||
if (!match || (!match[1] && !match[2]) || !Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= media.size || end < 0) return reply.code(416).header("Content-Range", `bytes */${String(media.size)}`).send();
|
||||
return reply.code(206).header("Content-Range", `bytes ${String(start)}-${String(end)}/${String(media.size)}`).header("Content-Length", end - start + 1).send(createReadStream(media.path, { start, end }));
|
||||
}
|
||||
return reply.header("Content-Length", media.size).send(createReadStream(media.path));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildApp, type AppDependencies } from "../src/app.js";
|
||||
import { ImageVideoService, FileAttachmentStore } from "@agent-studio/harness";
|
||||
import type { ImageVideoPublicTask } from "@agent-studio/harness";
|
||||
import { mkdtemp, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), "i2v-api-")); await mkdir(join(root, "ses_1"));
|
||||
const attachments = new FileAttachmentStore({ sessionsRoot: root });
|
||||
const image = await attachments.add("ses_1", { name: "pixel.png", mediaType: "image/png", stream: Readable.from(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAE0lEQVQImWP4z8DwnwGM/zMwAAAf7gP9qS/A4gAAAABJRU5ErkJggg==", "base64")) });
|
||||
const service = new ImageVideoService({ root: join(root, "tasks"), attachments, remote: {} as never, collect: () => Promise.reject(new Error("No external operations permitted")) });
|
||||
const dependencies = { imageVideo: service, sessionRegistry: { get: (id: string) => id === "ses_1" ? { sessionId: id } : undefined, disposeAll: async () => {} }, skillCatalog: { list: () => [] }, modelStore: {}, attachmentStore: attachments, approvalBroker: {} } as unknown as AppDependencies;
|
||||
return { app: buildApp(dependencies), service, draft: { attachmentId: image.attachmentId, title: "Reference", prompt: "Natural motion and ambient sound", durationSeconds: 10 } };
|
||||
}
|
||||
|
||||
describe("image video local API", () => {
|
||||
it("creates locally, persists history independently from Sessions and serves registered reference ranges", async () => {
|
||||
const { app, service, draft } = await fixture();
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: "/api/sessions/ses_1/image-video/tasks", payload: draft });
|
||||
expect(response.statusCode).toBe(200); const task = response.json<{ task: ImageVideoPublicTask }>().task;
|
||||
expect(task.state).toBe("draft");
|
||||
expect((await app.inject({ method: "GET", url: "/api/image-video/tasks" })).json<{ tasks: ImageVideoPublicTask[] }>().tasks).toHaveLength(1);
|
||||
const ranged = await app.inject({ method: "GET", url: task.referenceUrl, headers: { range: "bytes=0-3" } });
|
||||
expect(ranged.statusCode).toBe(206); expect(ranged.rawPayload.length).toBe(4); expect(ranged.headers["cache-control"]).toBe("no-store");
|
||||
expect((await app.inject({ method: "GET", url: task.referenceUrl, headers: { range: "bytes=999999-" } })).statusCode).toBe(416);
|
||||
expect((await app.inject({ method: "GET", url: `/api/image-video/tasks/${task.id}/video` })).statusCode).toBe(404);
|
||||
expect((await service.list())[0]?.id).toBe(task.id);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
it("rejects foreign origins, rebinding hosts, unknown sessions, arbitrary media paths and mutation types", async () => {
|
||||
const { app, draft } = await fixture();
|
||||
try {
|
||||
for (const headers of [{ host: "evil.example", origin: "http://evil.example" }, { origin: "https://evil.example" }, { "sec-fetch-site": "cross-site" }]) expect((await app.inject({ method: "GET", url: "/api/image-video/tasks", headers })).statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect((await app.inject({ method: "POST", url: "/api/sessions/ses_other/image-video/tasks", payload: draft })).statusCode).toBe(404);
|
||||
expect((await app.inject({ method: "POST", url: "/api/sessions/ses_1/image-video/tasks", payload: "text", headers: { "content-type": "text/plain" } })).statusCode).toBe(400);
|
||||
expect((await app.inject({ method: "GET", url: "/api/image-video/tasks/secret.env/reference" })).statusCode).toBe(400);
|
||||
expect((await app.inject({ method: "POST", url: "/api/sessions/ses_1/image-video/tasks", payload: { ...draft, path: "/etc/passwd" } })).statusCode).toBe(400);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user