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>
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @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;
|
||||
@@ -1457,6 +1457,241 @@ router.post("/:id/git/upload", sameOriginGuard, uploadBody, async (req, res) =>
|
||||
}
|
||||
});
|
||||
|
||||
// ── File Explorer (server/lib/file-explorer.js) ────────────────────────────
|
||||
// Every route resolves its folder like the git reads (`gitTargetOr404`:
|
||||
// `?session=` / `?source=`), every write is same-origin and runs as the
|
||||
// folder's account. `conflict` (fail|skip|keep|replace) settles a taken
|
||||
// name; 409 EEXISTS carries `names` so the browser can ask.
|
||||
const fileExplorer = require("../lib/file-explorer");
|
||||
|
||||
function fsFail(res, err) {
|
||||
const status =
|
||||
{
|
||||
EBADPATH: 400,
|
||||
EBADNAME: 400,
|
||||
ENOFILE: 404,
|
||||
ESECRET: 403,
|
||||
EFOREIGN: 403,
|
||||
EEXISTS: 409,
|
||||
EUPGRADE: 409,
|
||||
EMEMBERGONE: 409,
|
||||
ETOOBIG: 413,
|
||||
ENOHOME: 404,
|
||||
}[err.code] ?? 500;
|
||||
res.status(status).json({
|
||||
error: {
|
||||
code: err.code || "EFS",
|
||||
message: err.message,
|
||||
...(Array.isArray(err.names) ? { names: err.names } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const fsUserKey = (req) => (req.user ? String(req.user.id) : "local");
|
||||
const fsPaths = (body) =>
|
||||
Array.isArray(body?.paths) ? body.paths.filter((p) => typeof p === "string") : [];
|
||||
/** The trash a `replace` sends the old file to, for this workspace. */
|
||||
const fsTrashFor = (req, t) => (rel) =>
|
||||
fileExplorer.trashOne(t.cwd, t.lane.id, rel, fsUserKey(req));
|
||||
|
||||
router.get("/:id/fs/list", async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
res.json(
|
||||
await fileExplorer.list(t.cwd, typeof req.query.path === "string" ? req.query.path : "")
|
||||
);
|
||||
} catch (err) {
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/fs/mkdir", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
res.status(201).json(await fileExplorer.mkdir(t.cwd, req.body?.path));
|
||||
} catch (err) {
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/fs/newfile", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
res.status(201).json(await fileExplorer.newFile(t.cwd, req.body?.path));
|
||||
} catch (err) {
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/fs/rename", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
res.json(await fileExplorer.rename(t.cwd, req.body?.path, req.body?.name));
|
||||
} catch (err) {
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/fs/move", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
res.json(
|
||||
await fileExplorer.move(t.cwd, fsPaths(req.body), String(req.body?.dest ?? ""), {
|
||||
conflict: req.body?.conflict,
|
||||
trash: fsTrashFor(req, t),
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Copy into this workspace — from itself, or (`fromLane`) from another
|
||||
* workspace the caller can see; both must belong to one account (EFOREIGN).
|
||||
*/
|
||||
router.post("/:id/fs/copy", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
let srcDir = t.cwd;
|
||||
let srcHome = false;
|
||||
if (typeof req.body?.fromHome === "string") {
|
||||
// From a home in the File Explorer (`me`, or an admin's read-only view).
|
||||
try {
|
||||
srcDir = require("../lib/homes").resolveHome(req, req.body.fromHome).dir;
|
||||
srcHome = true;
|
||||
} catch (err) {
|
||||
return fsFail(res, err);
|
||||
}
|
||||
} else if (req.body?.fromLane != null && Number(req.body.fromLane) !== t.lane.id) {
|
||||
const from = /^\d+$/.test(String(req.body.fromLane))
|
||||
? lanesLib.getLane(Number(req.body.fromLane))
|
||||
: null;
|
||||
if (!from || !scope.canSeeLane(req, from.id)) {
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
}
|
||||
srcDir = from.cwd;
|
||||
}
|
||||
try {
|
||||
res.json(
|
||||
await fileExplorer.copy(t.cwd, fsPaths(req.body), String(req.body?.dest ?? ""), {
|
||||
srcDir,
|
||||
srcHome,
|
||||
conflict: req.body?.conflict,
|
||||
trash: fsTrashFor(req, t),
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Delete = move into the account's trash (restorable for TRASH_DAYS). */
|
||||
router.post("/:id/fs/delete", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
res.json(await fileExplorer.remove(t.cwd, t.lane.id, fsPaths(req.body), fsUserKey(req)));
|
||||
} catch (err) {
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:id/fs/trash", async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
const out = await fileExplorer.listTrash(t.cwd, fsUserKey(req));
|
||||
// Only what came from workspaces this caller can see.
|
||||
out.items = out.items.filter((i) => i.laneId == null || scope.canSeeLane(req, i.laneId));
|
||||
res.json(out);
|
||||
} catch (err) {
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/fs/trash/restore", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
const resolveLane = (laneId) => {
|
||||
const l = lanesLib.getLane(Number(laneId));
|
||||
return l && scope.canSeeLane(req, l.id) ? l.cwd : null;
|
||||
};
|
||||
res.json(await fileExplorer.restore(t.cwd, req.body?.id, fsUserKey(req), resolveLane));
|
||||
} catch (err) {
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/fs/trash/purge", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
res.json(await fileExplorer.purgeTrash(t.cwd, fsUserKey(req), req.body?.id));
|
||||
} catch (err) {
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Upload one file, streamed (no body parser buffers it), to `?path=` — any
|
||||
* missing sub-folders are made (a folder upload sends each file with its
|
||||
* relative path). Up to UPLOAD_MAX_BYTES; `?conflict=` settles a taken name.
|
||||
*/
|
||||
router.post("/:id/fs/upload", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
const out = await fileExplorer.upload(
|
||||
t.cwd,
|
||||
typeof req.query.path === "string" ? req.query.path : "",
|
||||
req,
|
||||
{
|
||||
conflict: req.query.conflict,
|
||||
length: req.headers["content-length"],
|
||||
trash: fsTrashFor(req, t),
|
||||
}
|
||||
);
|
||||
res.status(out.skipped ? 200 : 201).json(out);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
fsFail(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Files and folders as one zip, streamed (`?p=` once per path). */
|
||||
router.get("/:id/fs/zip", async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
const paths = [].concat(req.query.p ?? []).filter((p) => typeof p === "string");
|
||||
if (!paths.length) {
|
||||
return res.status(400).json({ error: { code: "EBADPATH", message: "name at least one path" } });
|
||||
}
|
||||
try {
|
||||
// Sized and checked BEFORE the first byte, so a refusal is still JSON.
|
||||
await fileExplorer.collect(t.cwd, paths);
|
||||
} catch (err) {
|
||||
return fsFail(res, err);
|
||||
}
|
||||
const base = paths.length === 1 ? path.posix.basename(paths[0]) : path.basename(t.cwd) || "files";
|
||||
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",
|
||||
});
|
||||
fileExplorer.zip(t.cwd, paths, res).catch(() => res.destroy());
|
||||
});
|
||||
|
||||
/** Tracked paths matching `?q=`, for quick-open. */
|
||||
router.get("/:id/git/ls", async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
|
||||
Reference in New Issue
Block a user