feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt
- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the Artifact tool's hook), pinned workspace files, skills and plugins, each kept as a snapshot; shared ones appear in a Hub and a skill or plugin installs into the viewer's own account (server/lib/artifacts.js, /api/artifacts, docs/plans/artifacts/). - Workspace extra folders: clone one or more related repos beside the workspace and add them in one step (POST /api/lanes/:id/sources/clone). - A pane that ended by itself while tmux kept running is no longer offered for resume (tmux.serverStartedAt in reconcileOrphans). - Changes panel: a folder that is not a git repo opens on Files, and its files open from there. - Plus the parallel sessions' work in this tree: File Explorer button in the panel header, user delete, RSA SSH keys, chat/terminal and pet fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* @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ĩ <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,
|
||||
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;
|
||||
Reference in New Issue
Block a user