/** * @file artifacts.js (routes) * @description `/api/artifacts` — the Artifacts hub (server/lib/artifacts.js): * list, add (a claude.ai page, a pinned workspace file or an uploaded one, a * skill — picked, uploaded (file, folder, zip) or written —, a plugin, after * reading its marketplace's plugin list from a link), * 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, EMARKETFETCH: 502, ENEEDLOGIN: 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 }); }) ); // Read a marketplace (a git link, owner/repo, or a marketplace.json link) // and list its plugins to pick from. A POST: it reaches the network. router.post( "/plugin/inspect", sameOriginGuard, wrap(async (req, res) => res.json(await artifacts.inspectMarket(req.body?.market))) ); // A skill written in the dialog: {name, description, content, title?}. router.post( "/skill/new", sameOriginGuard, wrap((req, res) => { const item = artifacts.writeSkill(req, req.body || {}); changed(item); res.status(201).json({ item, items: [item] }); }) ); /** multer, in memory, for one upload route; errors answered like ours. */ function upload(limits) { return (req, res, next) => { const multer = require("multer"); multer({ storage: multer.memoryStorage(), limits }).array("files")(req, res, (err) => { if (!err) return next(); const big = err.code === "LIMIT_FILE_SIZE" || err.code === "LIMIT_FILE_COUNT"; return res.status(big ? 413 : 400).json({ error: { code: big ? "ETOOBIG" : "EBADUPLOAD", message: err.message }, }); }); }; } /** Each uploaded file with the relative path the browser sent in `paths` (a folder pick keeps it). */ function uploads(req) { let paths = []; try { paths = JSON.parse(req.body?.paths || "[]"); } catch { paths = []; } return (req.files || []).map((f, i) => ({ path: typeof paths[i] === "string" && paths[i] ? paths[i] : f.originalname, data: f.buffer, })); } // Skills from files: one Markdown file, a folder, several files, a .zip/.skill, // or several skills at once (server/lib/artifacts.js `skillsFromUpload`). router.post( "/skill/upload", sameOriginGuard, upload({ files: 400, fileSize: 10 * 1024 * 1024, fields: 10 }), wrap((req, res) => { const b = req.body || {}; const items = artifacts.uploadSkills(req, { uploads: uploads(req), name: b.name, title: b.title, description: b.description, }); for (const item of items) changed(item); res.status(201).json({ items }); }) ); // One file from the user's computer, kept like a pinned workspace file. router.post( "/file/upload", sameOriginGuard, upload({ files: 1, fileSize: 25 * 1024 * 1024, fields: 10 }), wrap((req, res) => { const f = (req.files || [])[0]; if (!f) { return res.status(400).json({ error: { code: "ENOFILE", message: "attach the file as `files`" } }); } const item = artifacts.uploadFile(req, { name: f.originalname, data: f.buffer, title: req.body?.title, description: req.body?.description, }); 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;