chore: v2.8.5 — Trash opens on one folder per user, server setup installs S3

- Files → Trash: ONE sidebar entry. An admin's opens on a folder per user's
  Trash (My Trash first, item counts from GET /api/homes/trash, an unreadable
  one showing its error); a folder opens that user's Trash (path bar
  Trash › user, Up back to the folders). An admin may restore/purge in
  another user's Trash (audited); purging asks first. The merged "All users"
  list is gone from the UI.
- A home's Trash is its user's whole recycle bin (home store + host store,
  items tagged `store`).
- `agenthub server setup` installs one Linux user per account on Linux by
  default (`--no-system` opts out); install-system messages drop `sudo`.
- Settings → Git: the author email suggests <username>@<AGENTHUB_EMAIL_DOMAIN>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 16:02:11 +07:00
parent 09efb089cf
commit 758609775f
27 changed files with 839 additions and 107 deletions
+7
View File
@@ -246,6 +246,8 @@ describe("/api/me/git", () => {
const made = await request("POST", "/api/me/git/ssh-key", {});
assert.equal(made.status, 201);
assert.match(made.body.ssh.publicKey, /^ssh-ed25519 /);
// No commit author yet: the key is labelled with the default email.
assert.match(made.body.ssh.publicKey, /@smartgift\.vn$/);
assert.match(made.body.ssh.fingerprint, /^SHA256:/);
assert.ok(!JSON.stringify(made.body).includes("PRIVATE"));
const key = path.join(ROOT, ".claude", "agenthub-ssh", "id_ed25519");
@@ -311,6 +313,7 @@ describe("/api/me/git", () => {
it("reads and sets the commit author, refusing a bad one", async () => {
const r0 = await request("GET", "/api/me/git");
assert.deepEqual(r0.body.author, { name: null, email: null });
assert.equal(r0.body.defaultEmail, `${os.userInfo().username}@smartgift.vn`);
const bad = await request("PUT", "/api/me/git/author", { name: "a\nb", email: "x@y" });
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADAUTHOR");
@@ -318,6 +321,10 @@ describe("/api/me/git", () => {
assert.equal(ok.status, 200);
assert.deepEqual(ok.body.author, { name: "Vi", email: "vi@example.com" });
assert.match(fs.readFileSync(path.join(ROOT, ".gitconfig"), "utf8"), /email = vi@example.com/);
// A key made now is labelled with the author's email.
const key = await request("POST", "/api/me/git/ssh-key", { replace: true });
assert.match(key.body.ssh.publicKey, / vi@example\.com$/);
await request("DELETE", "/api/me/git/ssh-key");
});
it("saves an https login without ever returning the token", async () => {
+142 -1
View File
@@ -5,7 +5,8 @@
* (also through a symlink, a download or a zip) while a nested dotfile is
* fine, delete/restore work with no workspace, a copy goes home ↔ workspace
* under each side's own rule — and in server mode a developer never reaches
* another user's home while an admin gets it read-only.
* another user's home while an admin gets it read-only — except its Trash,
* which the admin lists, restores from and purges as the member's own.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -204,3 +205,143 @@ describe("homes in server mode", () => {
assert.ok(homes.listHomes({ user: ann }).some((x) => x.id === "bob" && x.readOnly));
});
});
describe("an admin manages a member's Trash", () => {
const auth = require("../lib/auth");
const USERS = path.join(ROOT, "users");
let cara;
let dan;
const call = (method, p, cookie, body) =>
fetch(`${base}${p}`, {
method,
headers: { "Content-Type": "application/json", cookie },
body: body === undefined ? undefined : JSON.stringify(body),
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
const login = async (username, password) => {
const r = await fetch(`${base}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return r.headers.get("set-cookie").split(";")[0];
};
before(async () => {
fs.mkdirSync(path.join(USERS, "cara"), { recursive: true });
process.env.AGENTHUB_MODE = "server";
process.env.AGENTHUB_USERS_ROOT = USERS;
await auth.createUser("cara", "cara-pass-1", "developer");
await auth.createUser("dan", "dan-pass-1", "admin");
cara = await login("cara", "cara-pass-1");
dan = await login("dan", "dan-pass-1");
});
after(() => {
delete process.env.AGENTHUB_MODE;
delete process.env.AGENTHUB_USERS_ROOT;
});
it("lists, restores and purges the member's own trash — the rest stays read-only", async () => {
const C = "/api/homes/me/fs";
const D = "/api/homes/cara/fs";
for (const n of ["old", "junk"]) {
assert.equal((await call("POST", `${C}/mkdir`, cara, { path: n })).status, 201);
}
assert.equal((await call("POST", `${C}/delete`, cara, { paths: ["old", "junk"] })).status, 200);
// The admin sees CARA's trash, not their own.
let items = (await call("GET", `${D}/trash`, dan)).body.items;
assert.deepEqual(items.map((i) => i.name).sort(), ["junk", "old"]);
assert.equal((await call("GET", "/api/homes/me/fs/trash", dan)).body?.items?.length ?? 0, 0);
// …and every user's in one list, each item tagged with its home and owner.
const all = (await call("GET", "/api/homes/trash", dan)).body;
assert.deepEqual(
all.items.map((i) => [i.name, i.home, i.owner]).sort(),
[
["junk", "cara", "cara"],
["old", "cara", "cara"],
]
);
// A developer's merged view is only their own.
assert.deepEqual(
(await call("GET", "/api/homes/trash", cara)).body.items.map((i) => i.home),
["me", "me"]
);
const old = items.find((i) => i.name === "old");
const r = await call("POST", `${D}/trash/restore`, dan, { id: old.id });
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.ok(fs.statSync(path.join(USERS, "cara", "old")).isDirectory());
assert.equal((await call("POST", `${D}/trash/purge`, dan, { id: "all" })).body.purged, 1);
assert.equal((await call("GET", `${C}/trash`, cara)).body.items.length, 0);
// Everything else in another user's home is still refused.
const w = await call("POST", `${D}/mkdir`, dan, { path: "nope" });
assert.equal(w.status, 403);
assert.equal(w.body.error.code, "EREADONLY");
assert.equal((await call("POST", `${D}/delete`, dan, { paths: ["old"] })).status, 403);
// A developer still never reaches another user's trash.
assert.equal((await call("GET", "/api/homes/dan/fs/trash", cara)).status, 404);
});
});
describe("one recycle bin per user", () => {
const fx = require("../lib/file-explorer");
const runAs = require("../lib/run-as");
const MEMBER = path.join(ROOT, "member-home");
const put = (root, id, name, laneId) => {
fs.mkdirSync(path.join(root, id, "item"), { recursive: true });
fs.writeFileSync(
path.join(root, id, "meta.json"),
JSON.stringify({
id,
name,
laneId,
rel: name,
isDir: true,
deletedAt: new Date().toISOString(),
})
);
};
it("has one store when the home is the host's", async () => {
const out = await fx.listUserTrash(HOME, "solo");
assert.deepEqual(out.items, []);
put(path.join(process.env.DASHBOARD_DATA_DIR, "trash", "solo"), "solo0001", "x", null);
const items = (await fx.listUserTrash(HOME, "solo")).items;
assert.deepEqual(
items.map((i) => [i.name, i.store]),
[["x", "home"]]
);
assert.equal(
(await fx.purgeUserTrash(HOME, "solo", "host", "solo0001").catch((e) => e)).code,
"EBADPATH"
);
});
it("merges a member's home trash with their host trash, each item tagged", async (t) => {
fs.mkdirSync(MEMBER, { recursive: true });
const acct = { name: "mem", home: MEMBER, uid: process.getuid() };
const orig = runAs.accountForPath;
t.after(() => {
runAs.accountForPath = orig;
});
runAs.accountForPath = (p) => (String(p).startsWith(MEMBER) ? acct : null);
put(path.join(MEMBER, ".local", "share", "agenthub", "trash"), "memhome01", "fromHome", null);
put(path.join(process.env.DASHBOARD_DATA_DIR, "trash", "77"), "memhost01", "fromHostWs", 5);
const items = (await fx.listUserTrash(MEMBER, "77")).items;
assert.deepEqual(items.map((i) => [i.name, i.store]).sort(), [
["fromHome", "home"],
["fromHostWs", "host"],
]);
// A host item whose workspace is gone has nowhere to go back to.
const r = await fx
.restoreUserTrash(MEMBER, "77", "host", "memhost01", () => null)
.catch((e) => e);
assert.equal(r.code, "ENOFILE");
assert.equal((await fx.purgeUserTrash(MEMBER, "77", "host", "memhost01")).purged, 1);
assert.ok(
!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "trash", "77", "memhost01"))
);
});
});
+1 -1
View File
@@ -1146,7 +1146,7 @@ async function installSkill(req, row, { replace = false } = {}) {
);
if (acct) {
if (!runAs.writeAsCanMove()) {
throw fail("EUPGRADE", "replacing needs the updated helper: run `sudo agenthub server install-system` once");
throw fail("EUPGRADE", "replacing needs the updated helper: run `agenthub server install-system` once");
}
runAs.mkdirAs(acct, path.dirname(aside));
await runAs.writeAsAsync(acct, target, ["move", aside]);
+97 -7
View File
@@ -2,7 +2,8 @@
* @file The workspace File Explorer (`/api/lanes/:id/fs/*`): list a folder,
* make a folder or an empty file, rename, move and copy (within a workspace
* or into another of the same account), delete into a per-account TRASH that
* can be restored (kept TRASH_DAYS), upload a file streamed from the request
* can be restored (kept TRASH_DAYS) — and, for `/api/homes`, one recycle bin
* per USER over every store their deletes went to — upload a file streamed from the request
* into any (new) sub-folder, and download files or folders as one streamed zip.
*
* Every path is the read path's own (git-diff.js): `lanePath` (lexical, no
@@ -17,7 +18,7 @@
* Writes run as the folder's ACCOUNT (run-as.js): a member's through
* `write-as` (sudo, as them — the host never writes in their home), the
* host's directly. A move/copy/trash in a member's folder needs write-as's
* `move`/`copy` (`EUPGRADE` until `sudo agenthub server install-system` has
* `move`/`copy` (`EUPGRADE` until `agenthub server install-system` has
* installed it). A copy or move never crosses accounts (`EFOREIGN`).
*
* A name that is taken is never silently replaced: `conflict` decides —
@@ -140,7 +141,7 @@ function needMove(acct) {
if (acct && !runAs.writeAsCanMove()) {
throw bad(
"EUPGRADE",
"moving or copying in a member's folder needs the updated helper: run `sudo agenthub server install-system` once"
"moving or copying in a member's folder needs the updated helper: run `agenthub server install-system` once"
);
}
}
@@ -468,12 +469,17 @@ async function dropTrash(acct, idDir) {
async function listTrash(dir, userKey) {
const root = await laneRoot(dir);
const acct = accountAt(root);
const tr = trashRoot(acct, userKey);
const items = await readStore(acct, trashRoot(acct, userKey));
return { items, keepDays: TRASH_DAYS };
}
/** The live items of one trash folder `tr` (of `acct`), expired ones dropped. */
async function readStore(acct, tr, store) {
let ids = [];
try {
ids = (await fs.promises.readdir(tr)).filter((n) => TRASH_ID_RE.test(n));
} catch {
return { items: [] };
return [];
}
const cutoff = Date.now() - TRASH_DAYS * 86_400_000;
const items = [];
@@ -490,15 +496,20 @@ async function listTrash(dir, userKey) {
path: meta.rel,
isDir: !!meta.isDir,
deletedAt: meta.deletedAt,
...(store ? { store } : {}),
});
}
items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
return { items, keepDays: TRASH_DAYS };
return items;
}
function trashEntry(acct, userKey, id) {
return entryAt(acct, trashRoot(acct, userKey), id);
}
function entryAt(acct, tr, id) {
if (!TRASH_ID_RE.test(String(id || ""))) throw bad("EBADPATH", "no such trash item");
const idDir = path.join(trashRoot(acct, userKey), id);
const idDir = path.join(tr, id);
const meta = readMeta(acct, idDir);
if (!meta) throw bad("ENOFILE", "no such trash item");
return { idDir, meta };
@@ -515,6 +526,11 @@ async function restore(dir, id, userKey, resolveLane) {
const acct = accountAt(root);
const { idDir, meta } = trashEntry(acct, userKey, id);
const home = (meta.laneId != null && resolveLane(meta.laneId)) || dir;
return putBack(acct, idDir, meta, home, dir);
}
/** Move trash entry `idDir` back under folder `home` (`dir`: the fallback). */
async function putBack(acct, idDir, meta, home, dir) {
const parentRel = path.posix.dirname(String(meta.rel || meta.name));
const relDir = parentRel === "." ? "" : parentRel;
await ensureFolders(home, relDir);
@@ -530,6 +546,77 @@ async function restore(dir, id, userKey, resolveLane) {
return { path: relDir ? `${relDir}/${name}` : name, laneId: home === dir ? null : meta.laneId };
}
// ── A user's recycle bin ───────────────────────────────────────────────────
//
// One Trash per USER, whatever place a delete happened in: the trash of their
// home's account (a member's `~/.local/share/agenthub/trash/`, or the host's
// `<data dir>/trash/<user id>/` when their home is the host's) plus — when
// that is another folder — the host trash under their user id, where what
// they deleted in a host-folder workspace went. Items carry `store`
// ("home" | "host") so a restore or purge finds the right one.
/** The trash stores of the user `userKey` whose home is `homeDir`. */
function userStores(homeDir, userKey) {
const acct = accountAt(homeDir);
const out = [{ store: "home", acct, root: trashRoot(acct, userKey) }];
const host = trashRoot(null, userKey);
if (acct || path.resolve(host) !== path.resolve(out[0].root)) {
out.push({ store: "host", acct: null, root: host });
}
return out;
}
function pickStore(homeDir, userKey, store) {
const s = userStores(homeDir, userKey).find((x) => x.store === (store || "home"));
if (!s) throw bad("EBADPATH", "no such trash");
return s;
}
/** Every item of the user's recycle bin, newest first. */
async function listUserTrash(homeDir, userKey) {
const items = [];
for (const s of userStores(homeDir, userKey)) {
items.push(...(await readStore(s.acct, s.root, s.store)));
}
items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
return { items, keepDays: TRASH_DAYS };
}
/**
* Restore item `id` of the user's `store`: into the workspace it came from
* when `resolveLane` still gives one, else — for the home store — into the
* home. A host-store item whose workspace is gone has nowhere of the same
* account to go (`ENOFILE`).
*/
async function restoreUserTrash(homeDir, userKey, store, id, resolveLane) {
const s = pickStore(homeDir, userKey, store);
const { idDir, meta } = entryAt(s.acct, s.root, id);
const fallback = s.store === "home" ? homeDir : null;
const home = (meta.laneId != null && resolveLane(meta.laneId)) || fallback;
if (!home) {
throw bad("ENOFILE", "the workspace this came from is gone — there is nowhere to restore it");
}
return putBack(s.acct, idDir, meta, home, fallback);
}
/** Delete item `id` of `store` forever, or `"all"` of every store. */
async function purgeUserTrash(homeDir, userKey, store, id) {
if (id === "all") {
let n = 0;
for (const s of userStores(homeDir, userKey)) {
for (const it of await readStore(s.acct, s.root)) {
await dropTrash(s.acct, path.join(s.root, it.id));
n++;
}
}
return { purged: n };
}
const s = pickStore(homeDir, userKey, store);
const { idDir } = entryAt(s.acct, s.root, id);
await dropTrash(s.acct, idDir);
return { purged: 1 };
}
async function purgeTrash(dir, userKey, id) {
const root = await laneRoot(dir);
const acct = accountAt(root);
@@ -800,6 +887,9 @@ module.exports = {
remove,
trashOne,
listTrash,
listUserTrash,
restoreUserTrash,
purgeUserTrash,
restore,
purgeTrash,
upload,
+10
View File
@@ -386,6 +386,15 @@ async function getAuthor(account) {
return { name: await get("user.name"), email: await get("user.email") };
}
/** The domain of a suggested email when the account has no author yet. */
const DEFAULT_EMAIL_DOMAIN = process.env.AGENTHUB_EMAIL_DOMAIN || "smartgift.vn";
/** `<username>@<DEFAULT_EMAIL_DOMAIN>`, or null without a usable name. */
function defaultEmail(username) {
const local = String(username || "").replace(/[^\w.+-]/g, "");
return local ? `${local}@${DEFAULT_EMAIL_DOMAIN}` : null;
}
/**
* Set the account's commit author. Neither is a secret, so they may cross
* sudo's argv; each is one line, a name up to 100 and an email up to 254
@@ -798,6 +807,7 @@ module.exports = {
defaultName,
cloneRepo,
getAuthor,
defaultEmail,
setAuthor,
sshConfigFor,
memberHttpsConfig,
+2 -2
View File
@@ -6,7 +6,7 @@
* workspaces in.
*
* S3 is ON when server mode is on AND `/etc/agenthub/server.conf` exists
* (written by `sudo agenthub server install-system`, root-owned). Without it
* (written by `agenthub server install-system`, root-owned). Without it
* everything here is a no-op and server mode behaves as in S2.
*
* `provision` is the dashboard's only way to create, disable or enable a Linux
@@ -158,7 +158,7 @@ function provision(action, username) {
// An installed helper older than this build (root's copy is refreshed
// only by install-system) does not know the action yet.
const hint = /unknown action/.test(why)
? " — the server helper is older than this AgentHub: run `sudo agenthub server install-system` once"
? " — the server helper is older than this AgentHub: run `agenthub server install-system` once"
: "";
throw Object.assign(new Error(`could not ${action} the Linux account: ${why}${hint}`), {
code: "EPROVISION",
+21 -12
View File
@@ -6,13 +6,15 @@
* git uses that key (`core.sshCommand`), the SSH login name per host
* (`url.<…>.insteadOf` — AWS CodeCommit's SSH key ID), and the commit author (`user.name` /
* `user.email` of the account's global git config — a member's commits fail
* without one). The account is the signed-in user's
* without one; a new SSH key is labelled with that email, else
* `<username>@smartgift.vn`). The account is the signed-in user's
* own Linux account under S3, else the host's, which only an admin (or local
* mode) may change (`EHOSTIDENTITY`). Nothing here takes a path from the
* request, and every write passes the same-origin guard.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const express = require("express");
const identity = require("../lib/git-identity");
@@ -39,7 +41,7 @@ function fail(res, e) {
.json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
async function view(account) {
async function view(account, username) {
const keys = identity.sshKeysInfo(account);
const terminal = keys.length ? await identity.terminalSshState(account) : undefined;
return {
@@ -52,9 +54,14 @@ async function view(account) {
sshUsers: await identity.listSshUsers(account),
https: identity.listHttps(account),
author: await identity.getAuthor(account),
// What the author email form suggests while none is set.
defaultEmail: identity.defaultEmail(username),
};
}
/** The signed-in user's name, or the OS user's in local mode. */
const usernameOf = (req) => req.user?.username || os.userInfo().username;
/** The request's account; a write to the host's identity needs an admin. */
function accountOf(req, { write = false } = {}) {
const account = identity.accountForRequest(req);
@@ -68,7 +75,7 @@ function accountOf(req, { write = false } = {}) {
router.get("/", async (req, res) => {
try {
res.json(await view(accountOf(req)));
res.json(await view(accountOf(req), usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -80,9 +87,11 @@ router.post("/ssh-key", sameOriginGuard, async (req, res) => {
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",
// The key is labelled with the commit author's email, else the default one.
comment:
(await identity.getAuthor(account)).email || identity.defaultEmail(usernameOf(req)),
});
res.status(201).json(await view(account));
res.status(201).json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -95,7 +104,7 @@ router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
if (!(await identity.removeSshKey(account, type))) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
}
res.json(await view(account));
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -112,7 +121,7 @@ router.put("/ssh-terminal", sameOriginGuard, async (req, res) => {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
}
await identity.setTerminalSsh(account, req.body?.on === true);
res.json(await view(account));
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -126,7 +135,7 @@ router.put("/ssh-user", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
await identity.setSshUser(account, req.body || {});
res.json(await view(account));
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -138,7 +147,7 @@ router.delete("/ssh-user/:host", sameOriginGuard, async (req, res) => {
if (!(await identity.removeSshUser(account, req.params.host))) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such SSH user" } });
}
res.json(await view(account));
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -149,7 +158,7 @@ router.post("/https", sameOriginGuard, async (req, res) => {
const account = accountOf(req, { write: true });
const { host, username, password } = req.body || {};
identity.saveHttps(account, { protocol: "https", host, username, password });
res.json(await view(account));
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -161,7 +170,7 @@ router.delete("/https/:host", sameOriginGuard, async (req, res) => {
if (!identity.removeHttps(account, "https", req.params.host)) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such login" } });
}
res.json(await view(account));
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -172,7 +181,7 @@ router.put("/author", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
await identity.setAuthor(account, req.body || {});
res.json(await view(account));
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
+69 -11
View File
@@ -4,13 +4,18 @@
* `inHome` so the dot entries at the top of a home stay out of reach).
*
* GET /api/homes the homes the caller may open
* GET /api/homes/trash every Trash the caller may open, merged
* GET /api/homes/:who/fs/list|trash|zip|download
* POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
*
* `:who` is `me`, or — for an admin — another user's name: read-only
* (`403 EREADONLY` on every write) and written to the audit log. Writes are
* same-origin; a copy may come from a workspace (`fromLane`) the caller can
* see, of the same Linux account.
* (`403 EREADONLY` on every write) and written to the audit log. The one
* exception is that user's TRASH: an admin lists, restores and purges it
* (the recycle-bin management in /files), each write audited with the item
* id. A home's Trash is its USER's whole recycle bin (`fx.listUserTrash`:
* their home account's trash plus their host trash), each item tagged with
* the `store` a restore/purge names. Writes are same-origin; a copy may come
* from a workspace (`fromLane`) the caller can see, of the same Linux account.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -57,12 +62,12 @@ const paths = (body) =>
* Resolve `:who`, refuse a write to someone else's home, audit an admin's
* read of it, then run `fn(home)` inside the home rule.
*/
function handle(fn, { write = false } = {}) {
function handle(fn, { write = false, trash = false } = {}) {
return async (req, res) => {
let home;
try {
home = homes.resolveHome(req, req.params.who);
if (write && home.readOnly) {
if (write && !trash && home.readOnly) {
throw Object.assign(new Error("another user's home is read-only"), { code: "EREADONLY" });
}
if (home.readOnly && req.user) {
@@ -70,7 +75,7 @@ function handle(fn, { write = false } = {}) {
req.user.id,
home.ownerId,
req.method,
`home ${req.params.who}: ${req.path} ${String(req.query.path ?? "")}`
`home ${req.params.who}: ${req.path} ${String(req.query.path ?? req.body?.id ?? "")}`
);
}
await fx.inHome(() => fn(req, res, home));
@@ -81,11 +86,51 @@ function handle(fn, { write = false } = {}) {
};
}
const trashFor = (req, home) => (rel) => fx.trashOne(home.dir, null, rel, userKey(req));
/** Whose trash a home's Trash view shows: the home OWNER's (an admin looking
* at another user's home manages THEIR recycle bin, not the admin's own). */
const trashKey = (req, home) =>
home.readOnly && home.ownerId != null ? String(home.ownerId) : userKey(req);
router.get("/", (req, res) => {
res.json({ homes: homes.listHomes(req) });
});
/**
* Every recycle bin the caller may open in one list — an admin's is every
* user's — each item tagged with its home (`home`, the `:who` a restore or
* purge goes to) and that user's name (`owner`). A home whose trash cannot
* be read is reported in `errors`, never silently left out. Each other
* user's trash read is audited like opening their home.
*/
router.get("/trash", async (req, res) => {
const items = [];
const errors = [];
let keepDays;
for (const h of homes.listHomes(req)) {
try {
const home = homes.resolveHome(req, h.id);
if (home.readOnly && req.user) {
require("../lib/audit").recordHomeRead(
req.user.id,
home.ownerId,
"GET",
`home ${h.id}: /trash (all trashes)`
);
}
const out = await fx.listUserTrash(home.dir, trashKey(req, home));
keepDays = out.keepDays;
for (const i of out.items) {
if (i.laneId != null && !scope.canSeeLane(req, i.laneId)) continue;
items.push({ ...i, home: h.id, owner: h.label });
}
} catch (err) {
errors.push({ home: h.id, owner: h.label, message: err.message });
}
}
items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
res.json({ items, errors, keepDays });
});
router.get(
"/:who/fs/list",
handle(async (req, res, home) => {
@@ -136,13 +181,15 @@ router.get(
router.get(
"/:who/fs/trash",
handle(async (req, res, home) => {
const out = await fx.listTrash(home.dir, userKey(req));
const out = await fx.listUserTrash(home.dir, trashKey(req, home));
out.items = out.items.filter((i) => i.laneId == null || scope.canSeeLane(req, i.laneId));
res.json(out);
})
);
const W = { write: true };
// Restore/purge: writes, but allowed in another user's home for an admin.
const TRASH_W = { write: true, trash: true };
router.post(
"/:who/fs/mkdir",
sameOriginGuard,
@@ -223,15 +270,26 @@ router.post(
const l = lanesLib.getLane(Number(laneId));
return l && scope.canSeeLane(req, l.id) ? l.cwd : null;
};
res.json(await fx.restore(home.dir, req.body?.id, userKey(req), resolveLane));
}, W)
res.json(
await fx.restoreUserTrash(
home.dir,
trashKey(req, home),
req.body?.store,
req.body?.id,
resolveLane
)
);
}, TRASH_W)
);
router.post(
"/:who/fs/trash/purge",
sameOriginGuard,
handle(
async (req, res, home) => res.json(await fx.purgeTrash(home.dir, userKey(req), req.body?.id)),
W
async (req, res, home) =>
res.json(
await fx.purgeUserTrash(home.dir, trashKey(req, home), req.body?.store, req.body?.id)
),
TRASH_W
)
);
router.post(