- Fastify API: demos/steps CRUD, content-addressed assets, scoped API keys (read/author/publish/admin, draft-only default per D4) - Player: image + sandboxed DOM steps, hotspots, tooltips, keyboard nav, privacy-first beacons (no IP storage, viewer tokens only — R7) - Editor: React SPA — demo list, drag-to-draw hotspots, annotations, publish - MCP server: Streamable HTTP, scope-filtered tools, untrusted-data wrapping on viewer-supplied content (R8); platform never calls a model - Capture extension (MV3): screenshot-flow recording, hotspots pre-placed from real clicks - Docker: single app image + postgres, Traefik-ready (expose only) E2E verified: capture->author->publish->play->beacon->lead->analytics + full MCP tool surface with scope filtering.
40 lines
1.9 KiB
TypeScript
40 lines
1.9 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdirSync, writeFileSync, createReadStream, existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { pool } from "../db.js";
|
|
import { requireScope } from "../auth.js";
|
|
|
|
const DATA_DIR = process.env.DATA_DIR ?? "/data";
|
|
|
|
export async function assetRoutes(app: FastifyInstance): Promise<void> {
|
|
app.post("/api/assets", { preHandler: requireScope("author") }, async (req, reply) => {
|
|
const file = await req.file({ limits: { fileSize: 50 * 1024 * 1024 } });
|
|
if (!file) return reply.code(400).send({ error: "multipart file required" });
|
|
const buf = await file.toBuffer();
|
|
const sha = createHash("sha256").update(buf).digest("hex");
|
|
// content-addressed dedup
|
|
const existing = await pool.query("SELECT id, path, mime FROM assets WHERE sha256=$1", [sha]);
|
|
if (existing.rowCount) return existing.rows[0];
|
|
const kind = file.mimetype.startsWith("image/") ? "image" : "other";
|
|
const dir = join(DATA_DIR, "assets", sha.slice(0, 2));
|
|
mkdirSync(dir, { recursive: true });
|
|
const path = join(dir, sha);
|
|
writeFileSync(path, buf);
|
|
const r = await pool.query(
|
|
"INSERT INTO assets (kind, path, mime, bytes, sha256) VALUES ($1,$2,$3,$4,$5) RETURNING id, path, mime",
|
|
[kind, path, file.mimetype, buf.length, sha],
|
|
);
|
|
return r.rows[0];
|
|
});
|
|
|
|
// Public asset serving (assets are content-addressed; ids are unguessable UUIDs)
|
|
app.get("/assets/:id", async (req, reply) => {
|
|
const { id } = req.params as { id: string };
|
|
const r = await pool.query("SELECT path, mime FROM assets WHERE id=$1", [id]);
|
|
if (!r.rowCount || !existsSync(r.rows[0].path)) return reply.code(404).send({ error: "not found" });
|
|
reply.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
reply.type(r.rows[0].mime);
|
|
return reply.send(createReadStream(r.rows[0].path));
|
|
});
|
|
}
|