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