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:
@@ -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 () => {
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user