7a88b74016
- Artifacts hub: upload a file from the computer; make a skill by uploading a Markdown file, files, a folder or a .zip/.skill, or by writing it in the dialog; add plugins by picking from the list a marketplace link answers (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub. - Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid). - Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB, <data dir>/avatars/), shown with name and role at the sidebar's foot and on the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at). - Users page: per-user storage (GET /api/users/storage, measured in a worker) and account-pool spend/tokens per account (server/lib/pool-usage.js). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
346 lines
9.3 KiB
JavaScript
346 lines
9.3 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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;
|