/** * @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ĩ */ 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;