chore: v2.8.71 — remote clients (company hub), artifacts shared with chosen people, folders and viewable copies, fixes
Remote clients (docs/plans/remote-clients/README.md, B1–B3 for the Claude pool): - server/hub/ holds the hub-only code (devices, remote sessions, upload ingest, LAN gateway), required only in server mode; hub-boundary.test.js keeps local code from importing it. - A local install enrolls with `agenthub remote login` or Settings → AI tools → Company hub; the "Company account" AI Proxy (reserved name agenthub.company) sends a session through the hub's gateway, which adds a pool account's token — no credential reaches the machine. Only those sessions are uploaded, hourly and at their end; secret-shaped strings are redacted on the client. - Gateway refuses: unknown device (401), a message without a session id, an unregistered session under AGENTHUB_REMOTE_GATE=block, a user over quota (EQUOTA), a purged session (410). Device tokens 90 days, conversations 30. - New alert rule type remote_unmonitored: a company-account session its device never registered; a late registration clears the flag instead. - The hub imports a session once per upload round (chunks carry `more`), not once per chunk. Artifacts: - Share an artifact with chosen people (artifact_shares, PATCH sharedWith, GET /api/artifacts/people) besides everyone; a "Shared with you" tab. Only the owner or an admin sees or edits who it is shared with; audited. - Folders: each account's own arrangement (agenthub.artifacts.folders pref). - A claude.ai page keeps a copy viewable without the publishing account: its HTML file (and supporting files) snapshotted at publish, or the copy Claude saves for a Doc / Slides / Design in artifacts-export/ (a synchronous PostToolUse hint hook, scripts/artifact-export-hint.js, admin switch), attached to the page at Stop. Viewed in HtmlFrame's sandbox, Markdown, or pdf.js (pdfjs-dist 6.4.299, legacy build, loaded only for a PDF). Fixes: - tmux: a cached /proc/<pid>/exe of an exited server falls back to PATH's tmux (stop the last pane, start again within 10 s failed with ENOENT). - shelves: a shelf restores beside changes in other files. - chat: a finished reply ends the turn even with no hook at all. - Settings → AI tools: profile list display tweaks.
This commit is contained in:
+389
-21
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file artifacts.js
|
||||
* @description The Artifacts hub (docs/plans/artifacts/README.md): what agents
|
||||
* produce, kept per OWNER and — in server mode — shared to every user.
|
||||
* produce, kept per OWNER and — in server mode — shared to every user (the
|
||||
* Hub, `shared = 1`) or to chosen people (`artifact_shares`).
|
||||
*
|
||||
* Four kinds:
|
||||
* - `page` a claude.ai Artifact a session published: only a link, accepted
|
||||
@@ -24,7 +25,7 @@
|
||||
* them — and a path from a request only ever picks one of the snapshot's
|
||||
* own recorded files (`snapshotFile`).
|
||||
* - Visibility: in local mode every row; in server mode a developer sees
|
||||
* their own rows plus shared ones, admin and analyst see every row (like
|
||||
* their own rows plus shared ones (to everyone, or to them by name), admin and analyst see every row (like
|
||||
* sessions), and only the owner (or an admin) changes or deletes one.
|
||||
* - Installing a shared skill or plugin is the viewer's click, into the
|
||||
* VIEWER's own accounts only (`installAccounts`): a member's home under S3
|
||||
@@ -94,13 +95,23 @@ const installsOffFlag = () => path.join(root(), "installs-off");
|
||||
|
||||
// ── Visibility ────────────────────────────────────────────────────────────
|
||||
|
||||
/** SQL over alias `a`, one `?` = a user id: the row was shared with that user by name. */
|
||||
const SHARED_TO_ME =
|
||||
"EXISTS (SELECT 1 FROM artifact_shares sh WHERE sh.artifact_id = a.id AND sh.user_id = ?)";
|
||||
|
||||
const sharedTo = (artifactId, userId) =>
|
||||
!!sql("SELECT 1 FROM artifact_shares WHERE artifact_id = ? AND user_id = ?").get(
|
||||
Number(artifactId),
|
||||
Number(userId)
|
||||
);
|
||||
|
||||
/** `{where, params}` over an `artifacts` alias `a`: the rows this request sees. */
|
||||
function visibleWhere(req) {
|
||||
const u = req?.user;
|
||||
if (!u || u.role === "admin" || u.role === "analyst") return { where: "1", params: [] };
|
||||
return {
|
||||
where: "(a.owner_id = ? OR a.shared = 1 OR (a.owner_id IS NULL AND a.suggested_owner_id = ?))",
|
||||
params: [u.id, u.id],
|
||||
where: `(a.owner_id = ? OR a.shared = 1 OR (a.owner_id IS NULL AND a.suggested_owner_id = ?) OR ${SHARED_TO_ME})`,
|
||||
params: [u.id, u.id, u.id],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,7 +122,8 @@ const canSee = (req, row) => {
|
||||
return (
|
||||
row.owner_id === u.id ||
|
||||
row.shared === 1 ||
|
||||
(row.owner_id == null && row.suggested_owner_id === u.id)
|
||||
(row.owner_id == null && row.suggested_owner_id === u.id) ||
|
||||
(row.id != null && sharedTo(row.id, u.id))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -123,7 +135,9 @@ const canChange = (req, row) =>
|
||||
function canSeeId(req, id) {
|
||||
return canSee(
|
||||
req,
|
||||
sql("SELECT owner_id, shared, suggested_owner_id FROM artifacts WHERE id = ?").get(Number(id))
|
||||
sql("SELECT id, owner_id, shared, suggested_owner_id FROM artifacts WHERE id = ?").get(
|
||||
Number(id)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,6 +149,42 @@ function load(req, id) {
|
||||
return row;
|
||||
}
|
||||
|
||||
/** `[{id, name}]` of the people `artifactId` is shared with by name. */
|
||||
function sharedWithOf(artifactId) {
|
||||
return sql(
|
||||
`SELECT u.id, COALESCE(NULLIF(u.display_name, ''), u.username) AS name
|
||||
FROM artifact_shares sh JOIN users u ON u.id = sh.user_id
|
||||
WHERE sh.artifact_id = ? AND u.disabled = 0 ORDER BY name`
|
||||
).all(Number(artifactId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every recipient of `artifactId` by name that is still an account (the
|
||||
* broadcast audience, and whether anyone keeps a Hub-only row alive). A
|
||||
* disabled one keeps their row (enabling them again restores it) but is not
|
||||
* listed to the owner, so it never blocks an edit of the list.
|
||||
*/
|
||||
function shareRecipients(artifactId) {
|
||||
return sql(
|
||||
`SELECT sh.user_id FROM artifact_shares sh JOIN users u ON u.id = sh.user_id
|
||||
WHERE sh.artifact_id = ?`
|
||||
)
|
||||
.all(Number(artifactId))
|
||||
.map((r) => r.user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The people a user may share with: every enabled account but themselves,
|
||||
* name only (server mode). Nothing else about them is returned.
|
||||
*/
|
||||
function people(req) {
|
||||
if (!isServer() || !req?.user) return [];
|
||||
return sql(
|
||||
`SELECT id, username, COALESCE(NULLIF(display_name, ''), username) AS name
|
||||
FROM users WHERE disabled = 0 AND id != ? ORDER BY name COLLATE NOCASE`
|
||||
).all(req.user.id);
|
||||
}
|
||||
|
||||
function view(row, req, owners = null) {
|
||||
if (!row) return null;
|
||||
let owner = null;
|
||||
@@ -165,6 +215,10 @@ function view(row, req, owners = null) {
|
||||
size: row.size,
|
||||
shared: row.shared === 1,
|
||||
sharedAt: row.shared_at,
|
||||
// The people it is shared with by name: only its owner (or an admin) sees
|
||||
// the list; anyone else learns only that it reached them.
|
||||
sharedWith: canChange(req, row) ? sharedWithOf(row.id) : [],
|
||||
sharedWithMe: !!req?.user && row.owner_id !== req.user.id && sharedTo(row.id, req.user.id),
|
||||
inLibrary: row.in_library !== 0,
|
||||
installs: row.installs,
|
||||
auto: row.auto === 1,
|
||||
@@ -194,11 +248,16 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
||||
// Their own library, plus what others shared that they installed (kept
|
||||
// below only when `installed` — a disk read, so it cannot be SQL).
|
||||
where.push(
|
||||
"((a.owner_id = ? AND a.in_library = 1) OR (a.shared = 1 AND a.owner_id IS NOT ? AND a.kind IN ('skill', 'plugin')))"
|
||||
`((a.owner_id = ? AND a.in_library = 1) OR ((a.shared = 1 OR ${SHARED_TO_ME}) AND a.owner_id IS NOT ? AND a.kind IN ('skill', 'plugin')))`
|
||||
);
|
||||
params.push(req.user.id, req.user.id);
|
||||
params.push(req.user.id, req.user.id, req.user.id);
|
||||
} else if (scope === "hub") {
|
||||
where.push("a.shared = 1");
|
||||
} else if (scope === "withme") {
|
||||
// Shared with the caller by name (server mode).
|
||||
if (!isServer() || !req?.user) return [];
|
||||
where.push(SHARED_TO_ME, "a.owner_id IS NOT ?");
|
||||
params.push(req.user.id, req.user.id);
|
||||
} else if (scope === "unowned") {
|
||||
if (!isServer() || !req?.user) return [];
|
||||
where.push("a.owner_id IS NULL");
|
||||
@@ -432,9 +491,15 @@ function captureFromHook(sessionId, data, peer = null) {
|
||||
url,
|
||||
ownerId
|
||||
);
|
||||
if (same) return same;
|
||||
const file = typeof input.file_path === "string" ? input.file_path : "";
|
||||
return insert({
|
||||
// A publish of a page from a file: keep a copy anyone it is shared with can
|
||||
// view here, without a claude.ai login (a republish takes the new version).
|
||||
const copy = laneId != null && file ? pageCopy(laneId, data?.cwd, file, input.files) : null;
|
||||
if (same) {
|
||||
if (copy) schedulePageSnapshot(same.id, copy);
|
||||
return same;
|
||||
}
|
||||
const row = insert({
|
||||
kind: "page",
|
||||
title: cleanTitle(birth ? birth.name : input.title, file ? path.basename(file) : "Artifact"),
|
||||
description: cleanDescription(input.description),
|
||||
@@ -445,6 +510,186 @@ function captureFromHook(sessionId, data, peer = null) {
|
||||
url,
|
||||
suggested_owner_id: ownerId == null ? suggestOwner(data?.cwd) : null,
|
||||
});
|
||||
if (copy) schedulePageSnapshot(row.id, copy);
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── A page's own copy ─────────────────────────────────────────────────────
|
||||
// A claude.ai page is private to the account that published it, so a viewer
|
||||
// without it saw nothing. A page published FROM a file keeps a snapshot of
|
||||
// that file (and the files it names) — viewed here in the sandbox, like a
|
||||
// pinned HTML file. A Docs doc or a typed artifact (Slides, Design) has no
|
||||
// such file: Claude is asked (scripts/artifact-export-hint.js) to export a
|
||||
// copy to `artifacts-export/<artifact id>.(md|pdf)`, which the Stop capture
|
||||
// attaches to the row instead (`captureOutputsNow`).
|
||||
|
||||
const PAGE_FILE_EXT = /\.html?$/i;
|
||||
const PAGE_FILES_MAX = 50;
|
||||
const PUBLISHED_RE =
|
||||
/^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._ ()@+-][A-Za-z0-9._ ()@+/-]{0,299}$/;
|
||||
/** Where Claude is asked to export a typed artifact's copy. */
|
||||
const EXPORT_DIR = "artifacts-export";
|
||||
|
||||
/**
|
||||
* What to copy for a page: `{laneId, main, extra: [{rel, as}]}` with every
|
||||
* path workspace-relative, or null when the main file is not an HTML file
|
||||
* inside the workspace. `cwd` is the session's folder (a relative path in
|
||||
* the hook is relative to it); `files` is the Artifact tool's supporting
|
||||
* files map (`{published: source | {from}}`) or list (`[{path}]`).
|
||||
*/
|
||||
function pageCopy(laneId, cwd, file, files) {
|
||||
const lane = require("./lanes").getLane(Number(laneId));
|
||||
if (!lane || !PAGE_FILE_EXT.test(file)) return null;
|
||||
const base = typeof cwd === "string" && path.isAbsolute(cwd) ? cwd : lane.cwd;
|
||||
const inLane = (p) => {
|
||||
const rel = path.relative(lane.cwd, path.resolve(base, p));
|
||||
return rel && !rel.startsWith("..") && !path.isAbsolute(rel)
|
||||
? rel.split(path.sep).join("/")
|
||||
: null;
|
||||
};
|
||||
const main = inLane(file);
|
||||
if (!main) return null;
|
||||
const extra = [];
|
||||
const add = (as, src) => {
|
||||
if (typeof as !== "string" || !PUBLISHED_RE.test(as) || typeof src !== "string") return;
|
||||
const rel = inLane(src);
|
||||
if (rel && extra.length < PAGE_FILES_MAX) extra.push({ rel, as });
|
||||
};
|
||||
if (Array.isArray(files)) {
|
||||
for (const f of files) add(f?.path, f?.path);
|
||||
} else if (files && typeof files === "object") {
|
||||
for (const [as, v] of Object.entries(files)) {
|
||||
add(as, typeof v === "string" ? v : v && typeof v.from === "string" ? v.from : null);
|
||||
}
|
||||
}
|
||||
return { laneId: lane.id, main, extra };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy several workspace files into ONE new snapshot: `entries` =
|
||||
* `[{rel, as}]`, each read under the same guards as a pinned file and
|
||||
* written at its published path `as`. A file that is a secret, gone or
|
||||
* outside the workspace is skipped; the total stays under FILE_MAX_BYTES.
|
||||
* Returns `{snap, files: [{path, size}]}`, or null when nothing was copied.
|
||||
*/
|
||||
async function snapshotLaneFiles(lane, entries) {
|
||||
const { openDownload } = require("./lane-file-transfer");
|
||||
const snap = newSnapshotDir();
|
||||
const files = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (const { rel, as } of entries) {
|
||||
// A key or a login is never copied, under either name.
|
||||
if (
|
||||
SECRET_NAME_RE.test(path.posix.basename(rel)) ||
|
||||
SECRET_NAME_RE.test(path.posix.basename(as))
|
||||
)
|
||||
continue;
|
||||
let file;
|
||||
try {
|
||||
file = await openDownload(lane.cwd, rel);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (total + file.size > FILE_MAX_BYTES || files.some((f) => f.path === as)) {
|
||||
file.stream.destroy();
|
||||
continue;
|
||||
}
|
||||
const dest = path.join(snap.dir, ...as.split("/"));
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
|
||||
await new Promise((resolve, reject) => {
|
||||
const out = fs.createWriteStream(dest, { mode: 0o600 });
|
||||
file.stream.on("error", reject);
|
||||
out.on("error", reject);
|
||||
out.on("finish", resolve);
|
||||
file.stream.pipe(out);
|
||||
});
|
||||
total += file.size;
|
||||
files.push({ path: as, size: file.size });
|
||||
}
|
||||
} catch (err) {
|
||||
dropSnapshot(snap.id);
|
||||
throw err;
|
||||
}
|
||||
if (!files.length) {
|
||||
dropSnapshot(snap.id);
|
||||
return null;
|
||||
}
|
||||
return { snap, files, size: total };
|
||||
}
|
||||
|
||||
/** Put a new snapshot on a row, dropping the one it replaces. */
|
||||
function setSnapshot(rowId, shot) {
|
||||
const old = sql("SELECT snapshot FROM artifacts WHERE id = ?").get(rowId);
|
||||
sql(
|
||||
`UPDATE artifacts SET snapshot = ?, files = ?, size = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
||||
version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(shot.snap.id, JSON.stringify(shot.files), shot.size, rowId);
|
||||
if (old?.snapshot && old.snapshot !== shot.snap.id) dropSnapshot(old.snapshot);
|
||||
}
|
||||
|
||||
/** Snapshot a page's copy; the main file goes first, under its own name. */
|
||||
async function snapshotPage(rowId, copy) {
|
||||
const lane = require("./lanes").getLane(Number(copy.laneId));
|
||||
if (!lane) return null;
|
||||
const mainAs = path.posix.basename(copy.main);
|
||||
const shot = await snapshotLaneFiles(lane, [
|
||||
{ rel: copy.main, as: mainAs },
|
||||
...copy.extra.filter((e) => e.as !== mainAs),
|
||||
]);
|
||||
if (!shot || shot.files[0].path !== mainAs) {
|
||||
if (shot) dropSnapshot(shot.snap.id);
|
||||
return null;
|
||||
}
|
||||
if (!sql("SELECT 1 FROM artifacts WHERE id = ?").get(rowId)) {
|
||||
dropSnapshot(shot.snap.id); // deleted meanwhile
|
||||
return null;
|
||||
}
|
||||
setSnapshot(rowId, shot);
|
||||
return sql("SELECT * FROM artifacts WHERE id = ?").get(rowId);
|
||||
}
|
||||
|
||||
// One copy at a time per row: a republish waits for the copy before it.
|
||||
const pageCopies = new Map();
|
||||
function schedulePageSnapshot(rowId, copy) {
|
||||
const prev = pageCopies.get(rowId) || Promise.resolve();
|
||||
const run = prev
|
||||
.catch(() => null)
|
||||
.then(() => snapshotPage(rowId, copy))
|
||||
.catch((err) => {
|
||||
console.warn(`[artifacts] page #${rowId} not copied: ${err.message}`);
|
||||
return null;
|
||||
});
|
||||
pageCopies.set(rowId, run);
|
||||
run.finally(() => {
|
||||
if (pageCopies.get(rowId) === run) pageCopies.delete(rowId);
|
||||
});
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page captured before pages kept a copy: take it now from the file it was
|
||||
* published from, if that file is still there (once per row per process).
|
||||
* Any viewer may ask; what they get is the copy, never the workspace file.
|
||||
*/
|
||||
const backfillTried = new Set();
|
||||
async function ensurePageCopy(req, id) {
|
||||
const row = load(req, id);
|
||||
if (row.kind !== "page" || row.snapshot || !row.source || row.lane_id == null) {
|
||||
return view(row, req);
|
||||
}
|
||||
if (backfillTried.has(row.id)) return view(row, req);
|
||||
backfillTried.add(row.id);
|
||||
const copy = pageCopy(row.lane_id, null, row.source, null);
|
||||
const next = copy ? await schedulePageSnapshot(row.id, copy) : null;
|
||||
return view(next || row, req);
|
||||
}
|
||||
|
||||
/** The artifact id a claude.ai page link ends in (`…/artifact/<id>`). */
|
||||
function pageIdOf(url) {
|
||||
const m = /\/artifact\/([A-Za-z0-9_-]+)/.exec(String(url || ""));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// ── file ──────────────────────────────────────────────────────────────────
|
||||
@@ -549,6 +794,8 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
||||
|
||||
/** Documents a session makes for people (slides, Word, Excel, PDF, HTML reports). */
|
||||
const OUTPUT_EXT = /\.(pptx|docx|xlsx|pdf|html?)$/i;
|
||||
/** A typed artifact's exported copy: `artifacts-export/<artifact id>.<ext>`. */
|
||||
const EXPORT_RE = /^artifacts-export\/([A-Za-z0-9_-]+)\.(md|markdown|pdf|html?|png)$/i;
|
||||
/** Most files one stop captures, so a script that writes hundreds stays bounded. */
|
||||
const OUTPUT_MAX = 20;
|
||||
/** Folders that hold a site's own pages, never a report someone reads. */
|
||||
@@ -596,10 +843,25 @@ async function outputCandidates(lane, since) {
|
||||
} else {
|
||||
paths = (await gd.walkFolder(lane.cwd)).files;
|
||||
}
|
||||
// The export folder is read directly: once AgentHub has excluded it from
|
||||
// git, `ls-files --others --exclude-standard` no longer lists it.
|
||||
try {
|
||||
for (const name of await fs.promises.readdir(path.join(lane.cwd, EXPORT_DIR))) {
|
||||
paths.push(`${EXPORT_DIR}/${name}`);
|
||||
}
|
||||
} catch {
|
||||
/* no export folder */
|
||||
}
|
||||
const out = [];
|
||||
for (const rel of new Set(paths)) {
|
||||
if (!OUTPUT_EXT.test(rel)) continue;
|
||||
if (/\.html?$/i.test(rel) && (tracked.has(rel) || (await isSitePage(lane.cwd, rel)))) continue;
|
||||
const exported = EXPORT_RE.test(rel);
|
||||
if (!OUTPUT_EXT.test(rel) && !exported) continue;
|
||||
if (
|
||||
!exported &&
|
||||
/\.html?$/i.test(rel) &&
|
||||
(tracked.has(rel) || (await isSitePage(lane.cwd, rel)))
|
||||
)
|
||||
continue;
|
||||
let st;
|
||||
try {
|
||||
st = await fs.promises.lstat(path.join(lane.cwd, rel));
|
||||
@@ -638,6 +900,31 @@ async function captureOutputsNow(laneId, sessionId) {
|
||||
const since = Date.parse(started?.started_at || "") || Date.now() - 24 * 3600 * 1000;
|
||||
const done = [];
|
||||
for (const { rel, mtimeMs } of await outputCandidates(lane, since)) {
|
||||
const exp = EXPORT_RE.exec(rel);
|
||||
if (exp) {
|
||||
// An exported copy of a page of this workspace: it becomes that page's
|
||||
// copy (no second card). One naming no page of ours is left alone.
|
||||
const page = sql(
|
||||
"SELECT * FROM artifacts WHERE kind = 'page' AND lane_id = ? AND url LIKE ? ORDER BY id DESC"
|
||||
)
|
||||
.all(lane.id, `%/artifact/${exp[1]}%`)
|
||||
.find((r) => pageIdOf(r.url) === exp[1]);
|
||||
if (!page) continue;
|
||||
const kept = page.files ? JSON.parse(page.files)[0] : null;
|
||||
if (kept && kept.mtime === mtimeMs && kept.path === path.posix.basename(rel)) continue;
|
||||
try {
|
||||
const shot = await snapshotLaneFiles(lane, [{ rel, as: path.posix.basename(rel) }]);
|
||||
if (!shot) continue;
|
||||
shot.files[0].mtime = mtimeMs;
|
||||
setSnapshot(page.id, shot);
|
||||
// AgentHub's folder, not the user's work: kept out of their git status.
|
||||
require("./git-exclude").excludeFromGit(lane.cwd, [`${EXPORT_DIR}/`]);
|
||||
done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(page.id));
|
||||
} catch {
|
||||
/* a secret name, a vanished file */
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const rows = sql(
|
||||
"SELECT * FROM artifacts WHERE kind = 'file' AND lane_id = ? AND source = ?"
|
||||
).all(lane.id, rel);
|
||||
@@ -1543,7 +1830,12 @@ function update(req, id, body = {}) {
|
||||
if (body.skill && typeof body.skill === "object") {
|
||||
editSkill(req, row, body.skill);
|
||||
row = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||
if (!("title" in body) && !("shared" in body) && !("description" in body))
|
||||
if (
|
||||
!("title" in body) &&
|
||||
!("shared" in body) &&
|
||||
!("sharedWith" in body) &&
|
||||
!("description" in body)
|
||||
)
|
||||
return view(row, req);
|
||||
}
|
||||
const sets = [];
|
||||
@@ -1576,22 +1868,77 @@ function update(req, id, body = {}) {
|
||||
params.push(body.shared ? 1 : 0, body.shared ? new Date().toISOString() : null);
|
||||
}
|
||||
}
|
||||
if (!sets.length) return view(row, req);
|
||||
if (shareChange === "unshared" && row.in_library === 0) {
|
||||
let people = null;
|
||||
if ("sharedWith" in body) {
|
||||
if (!isServer()) throw fail("ELOCALMODE", "sharing needs server mode");
|
||||
people = cleanPeople(body.sharedWith, row.owner_id);
|
||||
}
|
||||
if (!sets.length && !people) return view(row, req);
|
||||
const nowShared = "shared" in body ? body.shared : row.shared === 1;
|
||||
const keepsReaders = nowShared || (people ? people.length > 0 : shareRecipients(row.id).length);
|
||||
if (row.in_library === 0 && !keepsReaders) {
|
||||
// Off the Hub and out of its owner's library: nothing keeps it.
|
||||
deleteRow(row);
|
||||
audit(req, row, "unshared");
|
||||
return { ...view(row, req), shared: false, removed: true };
|
||||
}
|
||||
sets.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')");
|
||||
sql(`UPDATE artifacts SET ${sets.join(", ")} WHERE id = ?`).run(...params, row.id);
|
||||
let peopleChange = null;
|
||||
require("../db").db.transaction(() => {
|
||||
sets.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')");
|
||||
sql(`UPDATE artifacts SET ${sets.join(", ")} WHERE id = ?`).run(...params, row.id);
|
||||
if (people) peopleChange = setSharedWith(row.id, people);
|
||||
})();
|
||||
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||
if (shareChange) audit(req, next, shareChange);
|
||||
if (peopleChange) audit(req, next, peopleChange);
|
||||
return view(next, req);
|
||||
}
|
||||
|
||||
const MAX_PEOPLE = 100;
|
||||
|
||||
/** A `sharedWith` body value → distinct ids of enabled users, never the owner. */
|
||||
function cleanPeople(v, ownerId) {
|
||||
if (!Array.isArray(v) || v.length > MAX_PEOPLE) {
|
||||
throw fail("EBADFIELD", `sharedWith: up to ${MAX_PEOPLE} user ids`);
|
||||
}
|
||||
const ids = [...new Set(v)];
|
||||
if (!ids.every((n) => Number.isInteger(n) && n > 0)) {
|
||||
throw fail("EBADFIELD", "sharedWith: user ids");
|
||||
}
|
||||
const known = new Set(
|
||||
ids.length
|
||||
? sql(`SELECT id FROM users WHERE disabled = 0 AND id IN (${ids.map(() => "?").join(",")})`)
|
||||
.all(...ids)
|
||||
.map((r) => r.id)
|
||||
: []
|
||||
);
|
||||
const bad = ids.find((n) => !known.has(n));
|
||||
if (bad != null) throw fail("EBADFIELD", `sharedWith: no such user ${bad}`);
|
||||
return ids.filter((n) => n !== ownerId);
|
||||
}
|
||||
|
||||
/** Replace who `artifactId` is shared with by name; the audit line, or null if unchanged. */
|
||||
function setSharedWith(artifactId, ids) {
|
||||
const before = new Set(shareRecipients(artifactId));
|
||||
const added = ids.filter((n) => !before.has(n));
|
||||
const removed = [...before].filter((n) => !ids.includes(n));
|
||||
if (!added.length && !removed.length) return null;
|
||||
const del = sql("DELETE FROM artifact_shares WHERE artifact_id = ? AND user_id = ?");
|
||||
for (const n of removed) del.run(artifactId, n);
|
||||
const ins = sql("INSERT OR IGNORE INTO artifact_shares (artifact_id, user_id) VALUES (?, ?)");
|
||||
for (const n of added) ins.run(artifactId, n);
|
||||
const name = (n) => sql("SELECT username FROM users WHERE id = ?").get(n)?.username || `#${n}`;
|
||||
return [
|
||||
added.length ? `shared with ${added.map(name).join(", ")}` : "",
|
||||
removed.length ? `unshared from ${removed.map(name).join(", ")}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function deleteRow(row) {
|
||||
sql("DELETE FROM artifact_installs WHERE artifact_id = ?").run(row.id);
|
||||
sql("DELETE FROM artifact_shares WHERE artifact_id = ?").run(row.id);
|
||||
sql("DELETE FROM artifacts WHERE id = ?").run(row.id);
|
||||
dropSnapshot(row.snapshot);
|
||||
}
|
||||
@@ -1606,14 +1953,15 @@ function remove(req, id) {
|
||||
const row = load(req, id);
|
||||
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner deletes an artifact");
|
||||
const owner = !req?.user || row.owner_id === req.user.id;
|
||||
if (owner && row.shared === 1 && row.in_library !== 0) {
|
||||
const readers = row.shared === 1 || shareRecipients(row.id).length > 0;
|
||||
if (owner && readers && row.in_library !== 0) {
|
||||
sql(
|
||||
"UPDATE artifacts SET in_library = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||
).run(row.id);
|
||||
return { ...row, in_library: 0, kept: true };
|
||||
}
|
||||
deleteRow(row);
|
||||
if (row.shared === 1) audit(req, row, "deleted");
|
||||
if (readers) audit(req, row, "deleted");
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -1703,6 +2051,16 @@ function snapshotFile(req, id, rel) {
|
||||
|
||||
const installsEnabled = () => !fs.existsSync(installsOffFlag());
|
||||
|
||||
/** Ask Claude to export a copy of a Doc / Slides / Design (the pane env decides). */
|
||||
const exportsOffFlag = () => path.join(root(), "exports-off");
|
||||
const exportsEnabled = () => !fs.existsSync(exportsOffFlag());
|
||||
function setExportsEnabled(on) {
|
||||
fs.mkdirSync(root(), { recursive: true, mode: 0o700 });
|
||||
if (on) fs.rmSync(exportsOffFlag(), { force: true });
|
||||
else fs.writeFileSync(exportsOffFlag(), "", { mode: 0o600 });
|
||||
return exportsEnabled();
|
||||
}
|
||||
|
||||
function setInstallsEnabled(on) {
|
||||
fs.mkdirSync(root(), { recursive: true, mode: 0o700 });
|
||||
if (on) fs.rmSync(installsOffFlag(), { force: true });
|
||||
@@ -2137,13 +2495,16 @@ function reshareSkill(req, id) {
|
||||
/** Share a row (server mode, when it is not yet) and answer its view; `audit` names a new version. */
|
||||
function shareNow(req, id, changed) {
|
||||
const row = sql("SELECT * FROM artifacts WHERE id = ?").get(id);
|
||||
if (isServer() && row.shared !== 1) {
|
||||
// A row shared with chosen people stays with them: Share again only
|
||||
// takes the new version, it never widens who sees it.
|
||||
const byName = shareRecipients(id).length > 0;
|
||||
if (isServer() && row.shared !== 1 && !byName) {
|
||||
sql(
|
||||
`UPDATE artifacts SET shared = 1, shared_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(id);
|
||||
audit(req, row, "shared");
|
||||
} else if (changed && row.shared === 1) {
|
||||
} else if (changed && (row.shared === 1 || byName)) {
|
||||
audit(req, row, "updated");
|
||||
}
|
||||
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(id), req);
|
||||
@@ -2430,12 +2791,19 @@ module.exports = {
|
||||
addPlugin,
|
||||
update,
|
||||
remove,
|
||||
people,
|
||||
shareRecipients,
|
||||
snapshotFile,
|
||||
syncSkillSources,
|
||||
install,
|
||||
uninstall,
|
||||
installsEnabled,
|
||||
setInstallsEnabled,
|
||||
exportsEnabled,
|
||||
setExportsEnabled,
|
||||
ensurePageCopy,
|
||||
pageIdOf,
|
||||
__pageCopies: () => [...pageCopies.values()],
|
||||
__setExecImpl,
|
||||
__setInspectImpl,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user