|
|
|
@@ -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,
|
|
|
|
|