Files
AgentHub/server/routes/homes.js
T
nntrivi2001 e94038481e feat: v2.7.7 — File Explorer on workspaces and the user's home, clone with per-user git identity
- File Explorer (Files in the sidebar, /files; also from a workspace's Files
  tab, portalled so no panel clips it), laid out like GNOME Files: places
  (Home, workspaces, Trash), Back/Forward/Up, path bar, search, icon grid with
  image thumbnails or list, labelled New/Upload, selection action bar, hidden
  files behind Ctrl+H, right-click menus and the usual keys.
  Make, rename, move, copy (within a workspace, between workspaces, home ↔
  workspace), delete into a per-account trash restorable for 7 days, streamed
  upload of files and whole folders (≤ 500 MB a batch, Replace / Keep both /
  Skip on a taken name), download a file or a zip of anything.
  server/lib/file-explorer.js, /api/lanes/:id/fs/*, /api/homes/:who/fs/*.
- The user's home: every dot entry at its top stays hidden and refused
  (also via a symlink, download, zip or as a copy source); an admin may view
  other users' homes read-only, audited.
- write-as learns move/copy (re-run `sudo agenthub server install-system`).
- Hardening: a write to a workspace re-checks its owner by the live role;
  S3 keeps its server.conf if the file stops reading and a tmux call never
  falls back to the host's server; the AI Proxy gateway marks what it relays
  and the dashboard refuses it; a NUL in a pane's argv/env is refused.
- OpenAPI for the new routes (server/openapi-extra/files.js); docs (API,
  SERVER-MODE, CLAUDE.md) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:17:39 +07:00

261 lines
7.4 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/: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.
* @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 } = {}) {
return async (req, res) => {
let home;
try {
home = homes.resolveHome(req, req.params.who);
if (write && 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 ?? "")}`
);
}
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));
router.get("/", (req, res) => {
res.json({ homes: homes.listHomes(req) });
});
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.listTrash(home.dir, userKey(req));
out.items = out.items.filter((i) => i.laneId == null || scope.canSeeLane(req, i.laneId));
res.json(out);
})
);
const W = { write: 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.restore(home.dir, req.body?.id, userKey(req), resolveLane));
}, 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
)
);
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;