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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user