/** * @file artifacts.js (routes) * @description `/api/artifacts` — the Artifacts hub (server/lib/artifacts.js): * list, add (a claude.ai page, a pinned workspace file, a skill, a plugin), * rename/share/unshare, delete, read a snapshot file (preview/download, under * a `sandbox` CSP with `nosniff`, so nothing shared runs on the dashboard's * origin), and install a shared skill or plugin into the caller's own * account. Every write is same-origin. A path in a request only ever names a * file inside a workspace (pin, under the read path's guards) or one of a * snapshot's own recorded files. * @author Nguyễn Ngọc Trí Vĩ */ const express = require("express"); const fs = require("node:fs"); const artifacts = require("../lib/artifacts"); const { sameOriginGuard } = require("./run"); const { broadcast } = require("../websocket"); const router = express.Router(); const STATUS = { ENOTFOUND: 404, ENOLANE: 404, ENOFILE: 404, EFORBIDDEN: 403, ESECRET: 403, EFOREIGN: 403, EINSTALLSOFF: 403, EEXISTS: 409, ENOACCOUNT: 409, EUPGRADE: 409, ENOTSHARED: 409, EPLUGIN: 502, ETOOBIG: 413, }; function sendError(res, err) { if (err && typeof err.code === "string" && /^E[A-Z]+$/.test(err.code)) { return res .status(STATUS[err.code] || 400) .json({ error: { code: err.code, message: err.message } }); } console.warn("[artifacts]", err); return res.status(500).json({ error: { code: "EINTERNAL", message: String(err?.message || err) } }); } /** Tell clients a row changed: everyone when shared, else its owner (+ staff). */ function changed(row) { if (!row) return; broadcast("artifact_changed", { id: row.id, ownerId: row.ownerId ?? row.owner_id ?? null, shared: !!(row.shared === true || row.shared === 1) }); } const wrap = (fn) => async (req, res) => { try { await fn(req, res); } catch (err) { sendError(res, err); } }; router.get( "/", wrap((req, res) => { const q = req.query; res.json({ items: artifacts.list(req, { scope: q.scope, kind: q.kind || undefined, q: q.q, laneId: q.laneId, sessionId: q.sessionId, }), }); }) ); router.get( "/settings", wrap((req, res) => { const server = require("../lib/auth").isServerMode(); res.json({ serverMode: server, canShare: server, installsEnabled: artifacts.installsEnabled(), canManage: !req.user || req.user.role === "admin", }); }) ); router.put( "/settings", sameOriginGuard, wrap((req, res) => { if (req.user && req.user.role !== "admin") { return res.status(403).json({ error: { code: "EADMIN", message: "admin only" } }); } const on = req.body?.installsEnabled; if (typeof on !== "boolean") { return res.status(400).json({ error: { code: "EBADFIELD", message: "installsEnabled: boolean" } }); } artifacts.setInstallsEnabled(on); if (req.user) { require("../lib/audit").recordAccountChange( req.user.id, null, "PUT", `artifact hub installs ${on ? "on" : "off"}` ); } res.json({ installsEnabled: artifacts.installsEnabled() }); }) ); router.get( "/candidates", wrap((req, res) => res.json({ items: artifacts.fileCandidates(req.query.laneId) })) ); router.get( "/skills", wrap((req, res) => res.json({ items: artifacts.availableSkills(req, req.query.laneId) })) ); router.post( "/page", sameOriginGuard, wrap((req, res) => { const item = artifacts.addPage(req, req.body || {}); changed(item); res.status(201).json({ item }); }) ); router.post( "/file", sameOriginGuard, wrap(async (req, res) => { const item = await artifacts.pinFile(req, req.body || {}); changed(item); res.status(201).json({ item }); }) ); router.post( "/skill", sameOriginGuard, wrap((req, res) => { const item = artifacts.addSkill(req, req.body || {}); changed(item); res.status(201).json({ item }); }) ); router.post( "/plugin", sameOriginGuard, wrap((req, res) => { const item = artifacts.addPlugin(req, req.body || {}); changed(item); res.status(201).json({ item }); }) ); router.get( "/:id", wrap((req, res) => res.json({ item: artifacts.view(artifacts.load(req, req.params.id), req) })) ); router.patch( "/:id", sameOriginGuard, wrap((req, res) => { const item = artifacts.update(req, req.params.id, req.body || {}); changed(item); res.json({ item }); }) ); router.delete( "/:id", sameOriginGuard, wrap((req, res) => { const row = artifacts.remove(req, req.params.id); changed(row); res.json({ ok: true }); }) ); // One recorded file of a snapshot. Always sandboxed; `?download=1` makes it // an attachment. The type is only ever one of a fixed list, else a download. const TYPES = { html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8", md: "text/plain; charset=utf-8", markdown: "text/plain; charset=utf-8", txt: "text/plain; charset=utf-8", log: "text/plain; charset=utf-8", csv: "text/plain; charset=utf-8", tsv: "text/plain; charset=utf-8", json: "text/plain; charset=utf-8", yaml: "text/plain; charset=utf-8", yml: "text/plain; charset=utf-8", sh: "text/plain; charset=utf-8", py: "text/plain; charset=utf-8", js: "text/plain; charset=utf-8", ts: "text/plain; charset=utf-8", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", pdf: "application/pdf", mp4: "video/mp4", webm: "video/webm", mp3: "audio/mpeg", wav: "audio/wav", }; router.get( "/:id/raw", wrap((req, res) => { const { abs, name } = artifacts.snapshotFile(req, req.params.id, req.query.path); const ext = name.split(".").pop().toLowerCase(); const type = TYPES[ext]; const download = req.query.download === "1" || !type; res.set({ "Content-Type": download ? "application/octet-stream" : type, "Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; media-src 'self'; sandbox", "X-Content-Type-Options": "nosniff", "Cache-Control": "private, max-age=60", ...(download ? { "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(name)}` } : {}), }); fs.createReadStream(abs) .on("error", () => res.destroy()) .pipe(res); }) ); router.post( "/:id/install", sameOriginGuard, wrap(async (req, res) => { const result = await artifacts.install(req, req.params.id, { replace: req.body?.replace === true, }); res.json(result); }) ); module.exports = router;