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;
|
||||
@@ -26,6 +26,7 @@ const STATUS = {
|
||||
EHOSTIDENTITY: 403,
|
||||
EBADAUTHOR: 400,
|
||||
ESTOREUNREADABLE: 409,
|
||||
EBADKEYTYPE: 400,
|
||||
};
|
||||
|
||||
function fail(res, e) {
|
||||
@@ -66,8 +67,9 @@ router.get("/", async (req, res) => {
|
||||
router.post("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
identity.generateSshKey(account, {
|
||||
await identity.generateSshKey(account, {
|
||||
replace: req.body?.replace === true,
|
||||
type: req.body?.type === "rsa" ? "rsa" : "ed25519",
|
||||
comment: req.user?.username ? `${req.user.username}@agenthub` : "agenthub",
|
||||
});
|
||||
res.status(201).json(await view(account));
|
||||
|
||||
@@ -1230,6 +1230,17 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
broadcast("new_event", event);
|
||||
// A claude.ai page the session published goes into its owner's Artifacts.
|
||||
if (hookType === "PostToolUse" && toolName === "Artifact" && tool !== "codex") {
|
||||
try {
|
||||
const row = require("../lib/artifacts").captureFromHook(sessionId, data);
|
||||
if (row) {
|
||||
broadcast("artifact_changed", { id: row.id, ownerId: row.owner_id, shared: false });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[artifacts] capture failed:", err.message);
|
||||
}
|
||||
}
|
||||
return event;
|
||||
});
|
||||
|
||||
|
||||
+106
-21
@@ -735,8 +735,14 @@ router.post("/browse/mkdir", sameOriginGuard, (req, res) => {
|
||||
* login, 409 `ESSHAUTH` means the key was refused — 409 like the git bar's
|
||||
* `EGITAUTH`, never 401, which the client reads as "signed out".
|
||||
*/
|
||||
router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||
const body = req.body || {};
|
||||
/**
|
||||
* The clone itself, shared by Add workspace → Clone and a workspace's Clone
|
||||
* extra folder: every check above, then `git clone` as the folder's account.
|
||||
* Answers the new folder's path, or null once it has sent the refusal.
|
||||
* `before(target, ownerId)` runs after the checks and BEFORE the clone, so a
|
||||
* refusal there never leaves minutes of cloning behind.
|
||||
*/
|
||||
async function cloneInto(req, res, body, before) {
|
||||
const identity = require("../lib/git-identity");
|
||||
let url;
|
||||
try {
|
||||
@@ -744,7 +750,8 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||
typeof body.url === "string" ? body.url.trim() : body.url
|
||||
);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
return null;
|
||||
}
|
||||
const name =
|
||||
typeof body.name === "string" && body.name.trim()
|
||||
@@ -757,45 +764,57 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||
/[/\\\0]/.test(name) ||
|
||||
Buffer.byteLength(name) > 255
|
||||
) {
|
||||
return res
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADNAME", message: "a folder name without '/' or '\\'" } });
|
||||
return null;
|
||||
}
|
||||
if (typeof body.parent !== "string" || !path.isAbsolute(body.parent)) {
|
||||
return res.status(400).json({ error: { code: "EBADPATH", message: "parent: absolute path" } });
|
||||
res.status(400).json({ error: { code: "EBADPATH", message: "parent: absolute path" } });
|
||||
return null;
|
||||
}
|
||||
if (body.tool !== undefined && !aiTools.has(body.tool)) {
|
||||
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||||
res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||||
return null;
|
||||
}
|
||||
const once = body.credentials;
|
||||
if (once != null && (typeof once !== "object" || Array.isArray(once))) {
|
||||
return res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } });
|
||||
res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } });
|
||||
return null;
|
||||
}
|
||||
const own = scope.ownFolder(req);
|
||||
const limited = Boolean(req.user) && req.user.role !== "admin";
|
||||
if (limited && !own) return refuseFolder(res);
|
||||
if (limited && !own) {
|
||||
refuseFolder(res);
|
||||
return null;
|
||||
}
|
||||
const dir = own ? lanesLib.canonicalCwd(path.resolve(body.parent)) : path.resolve(body.parent);
|
||||
if (own && !scope.folderAllowed(req, dir, { allowHome: true })) return refuseFolder(res);
|
||||
if (own && !scope.folderAllowed(req, dir, { allowHome: true })) {
|
||||
refuseFolder(res);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (!fs.statSync(dir).isDirectory()) throw new Error("not a directory");
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "ENOTFOUND", message: "parent folder does not exist" } });
|
||||
res.status(400).json({ error: { code: "ENOTFOUND", message: "parent folder does not exist" } });
|
||||
return null;
|
||||
}
|
||||
const target = path.join(dir, name);
|
||||
if (fs.existsSync(target) || isSymlink(target)) {
|
||||
return res.status(409).json({ error: { code: "EEXISTS", message: "that name is taken" } });
|
||||
res.status(409).json({ error: { code: "EEXISTS", message: "that name is taken" } });
|
||||
return null;
|
||||
}
|
||||
if (!scope.folderAllowed(req, target)) {
|
||||
refuseFolder(res);
|
||||
return null;
|
||||
}
|
||||
if (scope.folderTakenByOther(req, target)) {
|
||||
refuseOwned(res);
|
||||
return null;
|
||||
}
|
||||
if (!scope.folderAllowed(req, target)) return refuseFolder(res);
|
||||
if (scope.folderTakenByOther(req, target)) return refuseOwned(res);
|
||||
const ownerId = scope.ownerFor(req);
|
||||
try {
|
||||
// Before the clone: a workspace this user may not own there, or a tool
|
||||
// that is disabled, is refused up front — not after minutes of cloning,
|
||||
// leaving a clone with no workspace on it.
|
||||
lanesLib.validateTool(body.tool || "claude");
|
||||
require("../lib/run-as").assertOwnerFits(target, ownerId);
|
||||
await before(target, ownerId);
|
||||
await identity.cloneRepo({
|
||||
url,
|
||||
parent: dir,
|
||||
@@ -810,7 +829,7 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||
: ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code)
|
||||
? 409
|
||||
: 500;
|
||||
return res.status(status).json({
|
||||
res.status(status).json({
|
||||
error: {
|
||||
code: err.code || "ECLONE",
|
||||
message: err.message,
|
||||
@@ -819,7 +838,22 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { target, name, ownerId };
|
||||
}
|
||||
|
||||
router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||
const body = req.body || {};
|
||||
// Before the clone: a workspace this user may not own there, or a tool
|
||||
// that is disabled, is refused up front — not after minutes of cloning,
|
||||
// leaving a clone with no workspace on it.
|
||||
const done = await cloneInto(req, res, body, (target, ownerId) => {
|
||||
lanesLib.validateTool(body.tool || "claude");
|
||||
require("../lib/run-as").assertOwnerFits(target, ownerId);
|
||||
});
|
||||
if (!done) return;
|
||||
const { target, name, ownerId } = done;
|
||||
try {
|
||||
const lane = lanesLib.createLane({
|
||||
cwd: lanesLib.canonicalCwd(target),
|
||||
@@ -838,6 +872,57 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Clone a related repo as one of a workspace's extra folders: the same clone
|
||||
* as above (URL allowlist, the folder account's own identity, `ECREDENTIALS`
|
||||
* / `ESSHAUTH`), always BESIDE the workspace folder (its parent — no path
|
||||
* from the request), then `lane_sources`' own folder checks. The clone is the
|
||||
* user's from then on: removing the extra folder never deletes it.
|
||||
*/
|
||||
router.post("/:id/sources/clone", sameOriginGuard, async (req, res) => {
|
||||
const lane = lanesLib.getLane(Number.parseInt(req.params.id, 10));
|
||||
if (!lane) {
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
}
|
||||
const sources = require("../lib/lane-sources");
|
||||
if (!sources.supports(lane.tool)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EUNSUPPORTED", message: "this tool takes no extra folders" } });
|
||||
}
|
||||
const body = req.body || {};
|
||||
const done = await cloneInto(
|
||||
req,
|
||||
res,
|
||||
{
|
||||
url: body.url,
|
||||
name: body.name,
|
||||
credentials: body.credentials,
|
||||
parent: path.dirname(lane.cwd),
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
if (!done) return;
|
||||
try {
|
||||
const allowed = (p) => scope.folderAllowed(req, lanesLib.canonicalCwd(path.resolve(p)));
|
||||
sources.addFolder(lane.id, done.target, { allowed });
|
||||
broadcastLane(lane.id);
|
||||
return res.status(201).json({
|
||||
path: done.target,
|
||||
instructions: lane.instructions || "",
|
||||
supported: true,
|
||||
maxFileBytes: sources.MAX_FILE_BYTES,
|
||||
maxInstructions: sources.MAX_INSTRUCTIONS,
|
||||
sources: sources.listSources(lane.id),
|
||||
});
|
||||
} catch (err) {
|
||||
// The clone stays where it is; it can still be added by its path.
|
||||
return res
|
||||
.status(err.code === "EFOLDER" ? 403 : 400)
|
||||
.json({ error: { code: err.code || "ESOURCE", message: err.message }, path: done.target });
|
||||
}
|
||||
});
|
||||
|
||||
function isSymlink(p) {
|
||||
try {
|
||||
return fs.lstatSync(p).isSymbolicLink();
|
||||
|
||||
@@ -504,7 +504,8 @@ router.get("/:id/outline", (req, res) => {
|
||||
|
||||
// An image the session's own Read tool returned (`?path=`, exact), for the Chat
|
||||
// pane's Files read preview when it lies outside the workspace (a screenshot
|
||||
// under ~/.cache, /tmp). The path is authorized only by that event, so: raster
|
||||
// under ~/.cache, /tmp) — or one its Bash or a reply named (a chart a script
|
||||
// drew, `sessionNamedFile`). The path is authorized only by that event, so: raster
|
||||
// types only, under a sandbox CSP with nosniff; never for a pushed session
|
||||
// (another machine's paths). In server mode only with S3, where a member's hook
|
||||
// reports sessions in its own home only (hook-auth.js): a member's session gets
|
||||
@@ -524,6 +525,56 @@ const sessionReadFile = db.prepare(
|
||||
AND json_extract(data, '$.tool_input.file_path') = ?
|
||||
LIMIT 1`
|
||||
);
|
||||
|
||||
// The other way a session can point at a file (issue #76): a report its own
|
||||
// script generated (`python audit.py -o ~/Documents/r.md`), which no
|
||||
// Read/Write names, but its Bash command or output, or a reply's text, does.
|
||||
const sessionNamedText = db.prepare(
|
||||
`SELECT t FROM (
|
||||
SELECT coalesce(json_extract(data, '$.tool_input.command'), '') || char(10) ||
|
||||
coalesce(json_extract(data, '$.tool_response.stdout'), '') || char(10) ||
|
||||
coalesce(json_extract(data, '$.last_assistant_message'), '') AS t
|
||||
FROM events
|
||||
WHERE session_id = ?
|
||||
AND ((event_type = 'PostToolUse' AND tool_name = 'Bash')
|
||||
OR event_type IN ('Stop', 'SubagentStop')))
|
||||
WHERE instr(t, ?) > 0
|
||||
LIMIT 50`
|
||||
);
|
||||
const PATH_CHAR = /[\w./\\~-]/;
|
||||
/** Whether `text` names `file` as a whole path — `/a/b` is not named by
|
||||
* `/a/b.md` or `/x/a/b`; a `file://` link and a sentence's full stop are. */
|
||||
function namesPath(text, file) {
|
||||
for (let i = text.indexOf(file); i !== -1; i = text.indexOf(file, i + 1)) {
|
||||
const before = text[i - 1];
|
||||
const after = text[i + file.length];
|
||||
const okBefore =
|
||||
before === undefined ||
|
||||
!PATH_CHAR.test(before) ||
|
||||
text.slice(Math.max(0, i - 7), i).toLowerCase() === "file://";
|
||||
const okAfter =
|
||||
after === undefined ||
|
||||
(after === "." ? !/\w/.test(text[i + file.length + 1] || "") : !PATH_CHAR.test(after));
|
||||
if (okBefore && okAfter) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Whether the session's own Bash calls or replies named `file`. */
|
||||
function sessionNamedFile(sessionId, file) {
|
||||
return sessionNamedText.all(sessionId, file).some((r) => namesPath(r.t, file));
|
||||
}
|
||||
/** A file only NAMED (not touched) is served only when the session's account
|
||||
* could read it itself: access(2) checks without the unit's
|
||||
* CAP_DAC_READ_SEARCH, so a reply quoting `/etc/shadow` serves nothing. */
|
||||
async function readableByAccount(file, member) {
|
||||
if (member) return true; // inside their home and owned by them, checked below
|
||||
try {
|
||||
await fs.promises.access(file, fs.constants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
router.get("/:id/image", async (req, res) => {
|
||||
const fail = (status, code, message) => res.status(status).json({ error: { code, message } });
|
||||
const session = stmts.getSession.get(req.params.id);
|
||||
@@ -540,7 +591,10 @@ router.get("/:id/image", async (req, res) => {
|
||||
if (member && !require("../lib/hook-auth").inside(member.home, file)) {
|
||||
return fail(404, "ENOTFOUND", "the session never read that image");
|
||||
}
|
||||
if (!sessionReadFile.get(session.id, file)) {
|
||||
if (
|
||||
!sessionReadFile.get(session.id, file) &&
|
||||
!(sessionNamedFile(session.id, file) && (await readableByAccount(file, member)))
|
||||
) {
|
||||
return fail(404, "ENOTFOUND", "the session never read that image");
|
||||
}
|
||||
let fh;
|
||||
@@ -571,8 +625,10 @@ router.get("/:id/image", async (req, res) => {
|
||||
|
||||
// A text file the session's own Read, Write, Edit, MultiEdit or NotebookEdit
|
||||
// named (`?path=`, exact), for the Chat pane's Outputs/Files preview when it
|
||||
// lies outside the workspace (a report written to ~/Documents, issue #76).
|
||||
// The same authorization as `/image` — that event, never a pushed session,
|
||||
// lies outside the workspace (a report written to ~/Documents, issue #76) —
|
||||
// or one its Bash command, its output or a reply named as a whole path (a
|
||||
// report a script generated, `sessionNamedFile`), when the session's account
|
||||
// could read it itself. The same authorization as `/image` — that event, never a pushed session,
|
||||
// server mode only with S3 and a member's session only inside their own home
|
||||
// on a file they own — plus: never a credential by name
|
||||
// (`isSecretConfigPath`, a `settings.local.json`), never a binary or a
|
||||
@@ -605,7 +661,10 @@ router.get("/:id/file", async (req, res) => {
|
||||
if (member && !require("../lib/hook-auth").inside(member.home, file)) {
|
||||
return fail(404, "ENOTFOUND", "the session never touched that file");
|
||||
}
|
||||
if (!sessionTouchedFile.get(session.id, file, file)) {
|
||||
if (
|
||||
!sessionTouchedFile.get(session.id, file, file) &&
|
||||
!(sessionNamedFile(session.id, file) && (await readableByAccount(file, member)))
|
||||
) {
|
||||
return fail(404, "ENOTFOUND", "the session never touched that file");
|
||||
}
|
||||
let fh;
|
||||
|
||||
@@ -102,6 +102,7 @@ const STATUS = {
|
||||
EEXISTS: 409,
|
||||
ESELF: 409,
|
||||
EOWNERFOLDER: 409, // S3: a workspace in one member's home, assigned to another
|
||||
ELASTADMIN: 409,
|
||||
ETOOMANY: 429,
|
||||
// S3: the Linux account could not be created/locked (the helper said why).
|
||||
EPROVISION: 502,
|
||||
@@ -248,6 +249,27 @@ router.patch(
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete a user for good (auth.deleteUser): their logins, panes, workspaces'
|
||||
* records and — under S3 — their Linux account and whole home. Never
|
||||
* yourself (ESELF) or the last admin (ELASTADMIN); the admin's password
|
||||
* again, like every account change, and written to the audit log.
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
accountChange(async (req) => {
|
||||
const u = target(req);
|
||||
if (u.id === req.user.id) {
|
||||
throw Object.assign(new Error("you cannot delete yourself"), { code: "ESELF" });
|
||||
}
|
||||
const gone = auth.deleteUser(u.username);
|
||||
return {
|
||||
user: auth.publicUser(u),
|
||||
action: `delete ${u.username} (${gone.workspaces} workspace(s), home removed)`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:id/password",
|
||||
accountChange(async (req, body) => {
|
||||
|
||||
Reference in New Issue
Block a user