758609775f
- 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>
319 lines
9.6 KiB
JavaScript
319 lines
9.6 KiB
JavaScript
/**
|
|
* @file `/api/homes` — the File Explorer on a user's HOME (server/lib/homes.js
|
|
* decides which folder; server/lib/file-explorer.js does the work, inside
|
|
* `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. 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>
|
|
*/
|
|
|
|
const express = require("express");
|
|
const path = require("node:path");
|
|
|
|
const fx = require("../lib/file-explorer");
|
|
const homes = require("../lib/homes");
|
|
const scope = require("../lib/scope");
|
|
const lanesLib = require("../lib/lanes");
|
|
const { sameOriginGuard } = require("../lib/same-origin");
|
|
|
|
const router = express.Router();
|
|
|
|
function fail(res, err) {
|
|
const status =
|
|
{
|
|
ENOHOME: 404,
|
|
EBADPATH: 400,
|
|
EBADNAME: 400,
|
|
ENOFILE: 404,
|
|
ESECRET: 403,
|
|
EFOREIGN: 403,
|
|
EREADONLY: 403,
|
|
EEXISTS: 409,
|
|
EUPGRADE: 409,
|
|
EMEMBERGONE: 409,
|
|
ETOOBIG: 413,
|
|
}[err.code] ?? 500;
|
|
res.status(status).json({
|
|
error: {
|
|
code: err.code || "EFS",
|
|
message: err.message,
|
|
...(Array.isArray(err.names) ? { names: err.names } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
const userKey = (req) => (req.user ? String(req.user.id) : "local");
|
|
const paths = (body) =>
|
|
Array.isArray(body?.paths) ? body.paths.filter((p) => typeof p === "string") : [];
|
|
|
|
/**
|
|
* 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, trash = false } = {}) {
|
|
return async (req, res) => {
|
|
let home;
|
|
try {
|
|
home = homes.resolveHome(req, req.params.who);
|
|
if (write && !trash && home.readOnly) {
|
|
throw Object.assign(new Error("another user's home is read-only"), { code: "EREADONLY" });
|
|
}
|
|
if (home.readOnly && req.user) {
|
|
require("../lib/audit").recordHomeRead(
|
|
req.user.id,
|
|
home.ownerId,
|
|
req.method,
|
|
`home ${req.params.who}: ${req.path} ${String(req.query.path ?? req.body?.id ?? "")}`
|
|
);
|
|
}
|
|
await fx.inHome(() => fn(req, res, home));
|
|
} catch (err) {
|
|
if (!res.headersSent) fail(res, err);
|
|
else res.destroy();
|
|
}
|
|
};
|
|
}
|
|
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) => {
|
|
res.json(await fx.list(home.dir, typeof req.query.path === "string" ? req.query.path : ""));
|
|
})
|
|
);
|
|
|
|
router.get(
|
|
"/:who/fs/download",
|
|
handle(async (req, res, home) => {
|
|
const file = await fx.download(home.dir, String(req.query.path ?? ""));
|
|
const ascii = file.name.replace(/[^\x20-\x7e]|["\\]/g, "_");
|
|
res.set({
|
|
"Content-Type": "application/octet-stream",
|
|
"Content-Length": String(file.size),
|
|
"Content-Disposition": `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
|
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Cache-Control": "no-store",
|
|
});
|
|
file.stream.on("error", () => res.destroy());
|
|
file.stream.pipe(res);
|
|
})
|
|
);
|
|
|
|
router.get(
|
|
"/:who/fs/zip",
|
|
handle(async (req, res, home) => {
|
|
const ps = [].concat(req.query.p ?? []).filter((p) => typeof p === "string");
|
|
if (!ps.length) {
|
|
throw Object.assign(new Error("name at least one path"), { code: "EBADPATH" });
|
|
}
|
|
await fx.collect(home.dir, ps);
|
|
const base = ps.length === 1 ? path.posix.basename(ps[0]) : "home";
|
|
const name = `${base}.zip`;
|
|
const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, "_");
|
|
res.set({
|
|
"Content-Type": "application/zip",
|
|
"Content-Disposition": `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`,
|
|
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Cache-Control": "no-store",
|
|
});
|
|
await fx.zip(home.dir, ps, res);
|
|
})
|
|
);
|
|
|
|
router.get(
|
|
"/:who/fs/trash",
|
|
handle(async (req, res, home) => {
|
|
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,
|
|
handle(
|
|
async (req, res, home) => res.status(201).json(await fx.mkdir(home.dir, req.body?.path)),
|
|
W
|
|
)
|
|
);
|
|
router.post(
|
|
"/:who/fs/newfile",
|
|
sameOriginGuard,
|
|
handle(
|
|
async (req, res, home) => res.status(201).json(await fx.newFile(home.dir, req.body?.path)),
|
|
W
|
|
)
|
|
);
|
|
router.post(
|
|
"/:who/fs/rename",
|
|
sameOriginGuard,
|
|
handle(
|
|
async (req, res, home) => res.json(await fx.rename(home.dir, req.body?.path, req.body?.name)),
|
|
W
|
|
)
|
|
);
|
|
router.post(
|
|
"/:who/fs/move",
|
|
sameOriginGuard,
|
|
handle(
|
|
async (req, res, home) =>
|
|
res.json(
|
|
await fx.move(home.dir, paths(req.body), String(req.body?.dest ?? ""), {
|
|
conflict: req.body?.conflict,
|
|
trash: trashFor(req, home),
|
|
})
|
|
),
|
|
W
|
|
)
|
|
);
|
|
router.post(
|
|
"/:who/fs/copy",
|
|
sameOriginGuard,
|
|
handle(async (req, res, home) => {
|
|
let srcDir = home.dir;
|
|
if (req.body?.fromLane != null) {
|
|
const from = /^\d+$/.test(String(req.body.fromLane))
|
|
? lanesLib.getLane(Number(req.body.fromLane))
|
|
: null;
|
|
if (!from || !scope.canSeeLane(req, from.id)) {
|
|
throw Object.assign(new Error("workspace not found"), { code: "ENOFILE" });
|
|
}
|
|
srcDir = from.cwd;
|
|
}
|
|
const srcHome = srcDir === home.dir;
|
|
res.json(
|
|
await fx.copy(home.dir, paths(req.body), String(req.body?.dest ?? ""), {
|
|
srcDir,
|
|
srcHome,
|
|
conflict: req.body?.conflict,
|
|
trash: trashFor(req, home),
|
|
})
|
|
);
|
|
}, W)
|
|
);
|
|
router.post(
|
|
"/:who/fs/delete",
|
|
sameOriginGuard,
|
|
handle(
|
|
async (req, res, home) =>
|
|
res.json(await fx.remove(home.dir, null, paths(req.body), userKey(req))),
|
|
W
|
|
)
|
|
);
|
|
router.post(
|
|
"/:who/fs/trash/restore",
|
|
sameOriginGuard,
|
|
handle(async (req, res, home) => {
|
|
const resolveLane = (laneId) => {
|
|
const l = lanesLib.getLane(Number(laneId));
|
|
return l && scope.canSeeLane(req, l.id) ? l.cwd : null;
|
|
};
|
|
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.purgeUserTrash(home.dir, trashKey(req, home), req.body?.store, req.body?.id)
|
|
),
|
|
TRASH_W
|
|
)
|
|
);
|
|
router.post(
|
|
"/:who/fs/upload",
|
|
sameOriginGuard,
|
|
handle(async (req, res, home) => {
|
|
try {
|
|
const out = await fx.upload(
|
|
home.dir,
|
|
typeof req.query.path === "string" ? req.query.path : "",
|
|
req,
|
|
{
|
|
conflict: req.query.conflict,
|
|
length: req.headers["content-length"],
|
|
trash: trashFor(req, home),
|
|
}
|
|
);
|
|
res.status(out.skipped ? 200 : 201).json(out);
|
|
} catch (err) {
|
|
req.resume();
|
|
throw err;
|
|
}
|
|
}, W)
|
|
);
|
|
|
|
module.exports = router;
|