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
+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(