/** * @file The workspace File Explorer (`/api/lanes/:id/fs/*`): list a folder, * make a folder or an empty file, rename, move and copy (within a workspace * or into another of the same account), delete into a per-account TRASH that * can be restored (kept TRASH_DAYS) — and, for `/api/homes`, one recycle bin * per USER over every store their deletes went to — upload a file streamed from the request * into any (new) sub-folder, and download files or folders as one streamed zip. * * Every path is the read path's own (git-diff.js): `lanePath` (lexical, no * `..`), the PARENT resolved by `resolveInLane` (realpath-confined, so no * symlink walks out) and the last segment kept as a name — so an operation on * a symlink acts on the link itself, never on what it points to — and no * secret (`settings.local.json`, a member's dot-dirs) by name or by what it * resolves to. Nothing is created, moved or deleted in or under `.git` (a * hook there runs the next time the dashboard runs git), and the workspace * folder itself is never moved or deleted. * * Writes run as the folder's ACCOUNT (run-as.js): a member's through * `write-as` (sudo, as them — the host never writes in their home), the * host's directly. A move/copy/trash in a member's folder needs write-as's * `move`/`copy` (`EUPGRADE` until `agenthub server install-system` has * installed it). A copy or move never crosses accounts (`EFOREIGN`). * * A name that is taken is never silently replaced: `conflict` decides — * `fail` (default, 409 EEXISTS with the names), `skip`, `keep` (a free * "name (1).ext") or `replace` (the old one goes to the trash first). * * A user's HOME is browsed through the same functions, run inside `inHome`: * there every dot entry at the top of the home (`.ssh`, `.claude`, `.config`, * `.agenthub`…, where logins and pane tokens live) is hidden from listings and * refused for every read and write — also when reached through a symlink — * the same rule `inMemberDotDir` keeps for a member's home everywhere. * @author Nguyễn Ngọc Trí Vĩ */ const crypto = require("node:crypto"); const fs = require("node:fs"); const path = require("node:path"); const zlib = require("node:zlib"); const { Readable, Transform } = require("node:stream"); const { AsyncLocalStorage } = require("node:async_hooks"); const { pipeline } = require("node:stream/promises"); const { isSecretOnDisk, lanePath, laneRoot, resolveInLane } = require("./git-diff"); const runAs = require("./run-as"); const { getDataDir } = require("./claude-home"); /** One uploaded file, streamed (the client keeps a whole batch to this too). */ const UPLOAD_MAX_BYTES = 500 * 1024 * 1024; /** One zip download, uncompressed — and below ZIP64's 4 GiB offsets. */ const ZIP_MAX_BYTES = 2 * 1024 * 1024 * 1024; const ZIP_MAX_FILES = 50_000; const LIST_MAX = 5000; const TRASH_DAYS = 7; const CONFLICTS = new Set(["fail", "skip", "keep", "replace"]); const bad = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra }); // Set while a request works on a user's home (see the file header). const homeCtx = new AsyncLocalStorage(); /** Run `fn` with `dir` treated as a home: its top-level dot entries are off limits. */ const inHome = (fn) => homeCtx.run(true, fn); /** Whether `abs` is, or lies under, a dot entry at the top of a home root `root`. */ function topDot(root, abs) { if (homeCtx.getStore() !== true) return false; const rel = path.relative(root, abs); return Boolean(rel) && !escapes(root, abs) && rel.split(path.sep)[0].startsWith("."); } const hidden = () => bad("ESECRET", "hidden files at the top of a home are not reachable here"); const gitInside = (rel) => rel.split("/").includes(".git"); const escapes = (base, target) => { const r = path.relative(base, target); return r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r); }; /** One plain path segment a user may name. */ function validName(n) { const name = typeof n === "string" ? n.trim() : ""; if ( !name || name === "." || name === ".." || /[/\\\0]/.test(name) || Buffer.byteLength(name) > 255 ) { throw bad("EBADNAME", "a name without '/' or '\\', at most 255 bytes"); } return name; } function conflictOf(v) { return CONFLICTS.has(v) ? v : "fail"; } /** A workspace folder by relative path ("" = the workspace root). */ async function folder(dir, rel) { const root = await laneRoot(dir); if (!rel) return { rel: "", abs: root, root }; const p = lanePath(rel); const abs = await resolveInLane(dir, p); if (topDot(root, abs)) throw hidden(); const st = await fs.promises.stat(abs).catch(() => null); if (!st?.isDirectory()) throw bad("ENOFILE", "no such folder in the workspace"); return { rel: p, abs, root }; } /** * An entry by relative path, existing or not: its parent resolved and * confined, its name kept (a symlink is the link). `st` is its lstat or null. */ async function locate(dir, rel) { const p = lanePath(rel); if (await isSecretOnDisk(dir, p)) throw bad("ESECRET", "that path holds secrets"); const parent = path.posix.dirname(p); const name = path.posix.basename(p); const par = await folder(dir, parent === "." ? "" : parent); const abs = path.join(par.abs, name); if (escapes(par.root, abs)) throw bad("EBADPATH", "path escapes the workspace directory"); if (topDot(par.root, abs)) throw hidden(); if (await isSecretOnDisk(par.root, path.relative(par.root, abs))) { throw bad("ESECRET", "that path holds secrets"); } const st = await fs.promises.lstat(abs).catch(() => null); return { rel: p, name, abs, parent: par, root: par.root, st }; } async function existing(dir, rel) { const loc = await locate(dir, rel); if (!loc.st) throw bad("ENOFILE", `no such file or folder: ${loc.rel}`); return loc; } function writable(loc) { if (gitInside(loc.rel)) throw bad("EBADPATH", "nothing in .git is changed from here"); } /** The account a write at `abs` runs as (null = the host); EMEMBERGONE throws. */ const accountAt = (abs) => runAs.accountForPath(abs); function needMove(acct) { if (acct && !runAs.writeAsCanMove()) { throw bad( "EUPGRADE", "moving or copying in a member's folder needs the updated helper: run `agenthub server install-system` once" ); } } /** A name in `parentAbs` not taken yet: "a (1).txt", "a (2).txt"… */ async function freeName(parentAbs, name) { const ext = name.startsWith(".") ? "" : path.extname(name); const stem = ext ? name.slice(0, -ext.length) : name; for (let i = 1; i < 10_000; i++) { const n = `${stem} (${i})${ext}`; if (!(await fs.promises.lstat(path.join(parentAbs, n)).catch(() => null))) return n; } throw bad("EEXISTS", `no free name for ${name}`); } // ── Reading ──────────────────────────────────────────────────────────────── /** A folder's entries, folders first: `{name, type, size, mtime}`. */ async function list(dir, rel = "") { const f = await folder(dir, rel); const names = (await fs.promises.readdir(f.abs)).sort((a, b) => a.localeCompare(b)); const entries = []; for (const name of names.slice(0, LIST_MAX)) { const relChild = f.rel ? `${f.rel}/${name}` : name; if (topDot(f.root, path.join(f.abs, name))) continue; if (await isSecretOnDisk(f.root, path.relative(f.root, path.join(f.abs, name)))) continue; const st = await fs.promises.lstat(path.join(f.abs, name)).catch(() => null); if (!st) continue; let type = st.isDirectory() ? "dir" : st.isFile() ? "file" : st.isSymbolicLink() ? "link" : "other"; if (type === "link") { // Shown as what it points to, but only while that stays inside. const real = await fs.promises.realpath(path.join(f.abs, name)).catch(() => null); const tst = real && !escapes(f.root, real) ? await fs.promises.stat(real).catch(() => null) : null; if (tst?.isDirectory()) type = "linkdir"; } entries.push({ name, path: relChild, type, size: st.isFile() ? st.size : null, mtime: st.mtimeMs, }); } const rank = (e) => (e.type === "dir" || e.type === "linkdir" ? 0 : 1); entries.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name)); return { path: f.rel, parent: f.rel ? (path.posix.dirname(f.rel) === "." ? "" : path.posix.dirname(f.rel)) : null, entries, truncated: names.length > LIST_MAX, }; } // ── Making ───────────────────────────────────────────────────────────────── async function mkdir(dir, rel) { const loc = await locate(dir, rel); validName(loc.name); writable(loc); if (loc.st) throw bad("EEXISTS", `${loc.rel} already exists`, { names: [loc.name] }); const acct = accountAt(loc.abs); if (acct) runAs.mkdirAs(acct, loc.abs); else await fs.promises.mkdir(loc.abs); return { path: loc.rel }; } async function newFile(dir, rel) { const loc = await locate(dir, rel); validName(loc.name); writable(loc); if (loc.st) throw bad("EEXISTS", `${loc.rel} already exists`, { names: [loc.name] }); const acct = accountAt(loc.abs); if (acct) await runAs.writeAsAsync(acct, loc.abs, ["replace"], { input: "" }); else await fs.promises.writeFile(loc.abs, "", { flag: "wx" }); return { path: loc.rel }; } /** Make every missing folder of `relDir` (each one checked like `mkdir`). */ async function ensureFolders(dir, relDir) { if (!relDir) return; const parts = lanePath(relDir).split("/"); for (let i = 1; i <= parts.length; i++) { const sub = parts.slice(0, i).join("/"); const loc = await locate(dir, sub); if (loc.st) { if (!loc.st.isDirectory()) throw bad("EEXISTS", `${sub} is a file, not a folder`); continue; } validName(loc.name); writable(loc); const acct = accountAt(loc.abs); if (acct) runAs.mkdirAs(acct, loc.abs); else await fs.promises.mkdir(loc.abs); } } // ── Moving, copying ──────────────────────────────────────────────────────── /** Host-side move: a rename, or copy + remove across filesystems. */ async function hostMove(from, to) { try { await fs.promises.rename(from, to); } catch (e) { if (e.code !== "EXDEV") throw e; await fs.promises.cp(from, to, { recursive: true, verbatimSymlinks: true, errorOnExist: true, force: false, preserveTimestamps: true, }); await fs.promises.rm(from, { recursive: true, force: true }); } } async function moveAs(acct, from, to) { if (acct) { needMove(acct); await runAs.writeAsAsync(acct, from, ["move", to]); } else { await hostMove(from, to); } } /** * The targets of a move/copy of `srcs` into `dest`, conflicts settled by * `conflict` — or EEXISTS (409) naming every taken name when it is `fail`, * before anything is done. */ async function plan(srcs, dest, conflict) { const out = []; const taken = []; for (const s of srcs) { let name = s.name; let target = path.join(dest.abs, name); const st = await fs.promises.lstat(target).catch(() => null); // Onto itself is handled below (a copy keeps both); only INTO itself is refused. if (s.st.isDirectory() && target.startsWith(`${s.abs}${path.sep}`)) { throw bad("EBADPATH", `${s.rel} cannot go inside itself`); } if (target === s.abs) { // Onto itself: a copy keeps both, a move does nothing. out.push({ src: s, target, name, self: true }); continue; } if (st) { if (conflict === "fail") { taken.push(name); continue; } if (conflict === "skip") continue; if (conflict === "keep") { name = await freeName(dest.abs, name); target = path.join(dest.abs, name); } else { out.push({ src: s, target, name, replace: true }); continue; } } out.push({ src: s, target, name }); } if (taken.length) { throw bad("EEXISTS", `already in the destination: ${taken.join(", ")}`, { names: taken }); } return out; } function sameAccount(...paths) { if (!runAs.sameOwner(...paths)) { throw bad("EFOREIGN", "files move or copy only between folders of one account"); } } async function move(dir, rels, destRel, { conflict, trash } = {}) { const dest = await folder(dir, destRel); if (gitInside(dest.rel)) throw bad("EBADPATH", "nothing is moved into .git"); const srcs = []; for (const r of rels) { const s = await existing(dir, r); writable(s); srcs.push(s); } sameAccount(dest.abs, ...srcs.map((s) => s.abs)); const steps = await plan(srcs, dest, conflictOf(conflict)); const done = []; for (const st of steps) { if (st.self) continue; const acct = accountAt(st.target); if (st.replace) await trash(path.relative(dest.root, st.target).split(path.sep).join("/")); await moveAs(acct, st.src.abs, st.target); done.push(path.relative(dest.root, st.target).split(path.sep).join("/")); } return { moved: done }; } async function rename(dir, rel, newName) { const s = await existing(dir, rel); writable(s); const name = validName(newName); if (name === s.name) return { path: s.rel }; const to = await locate(dir, s.rel.includes("/") ? `${path.posix.dirname(s.rel)}/${name}` : name); writable(to); if (to.st) throw bad("EEXISTS", `${to.rel} already exists`, { names: [name] }); await moveAs(accountAt(to.abs), s.abs, to.abs); return { path: to.rel }; } /** Copy `rels` of workspace `srcDir` into `destRel` of workspace `dir`. */ async function copy(dir, rels, destRel, { srcDir = dir, srcHome, conflict, trash } = {}) { const dest = await folder(dir, destRel); if (gitInside(dest.rel)) throw bad("EBADPATH", "nothing is copied into .git"); // The sources are checked under THEIR place's rule (a home's dot entries // stay out of reach; a workspace's `.github` does not), the destination // under this call's. const destHome = homeCtx.getStore() === true; const fromHome = srcHome ?? (srcDir === dir ? destHome : false); const srcs = await homeCtx.run(fromHome, async () => { const out = []; for (const r of rels) { const s = await existing(srcDir, r); const real = await fs.promises.realpath(s.abs).catch(() => s.abs); if (topDot(s.root, real)) throw hidden(); out.push(s); } return out; }); sameAccount(dest.abs, ...srcs.map((s) => s.abs)); let steps = await plan(srcs, dest, conflictOf(conflict)); // A copy onto itself keeps both, like a file manager's paste in place. steps = await Promise.all( steps.map(async (st) => st.self ? { ...st, name: await freeName(dest.abs, st.name), self: false } : st ) ); const done = []; for (const st of steps) { const target = path.join(dest.abs, st.name); const acct = accountAt(target); if (st.replace) await trash(path.relative(dest.root, target).split(path.sep).join("/")); if (acct) { needMove(acct); await runAs.writeAsAsync(acct, st.src.abs, ["copy", target]); } else { await fs.promises.cp(st.src.abs, target, { recursive: true, verbatimSymlinks: true, errorOnExist: true, force: false, preserveTimestamps: true, }); } done.push(path.relative(dest.root, target).split(path.sep).join("/")); } return { copied: done }; } // ── Trash ────────────────────────────────────────────────────────────────── const TRASH_ID_RE = /^[a-z0-9]{8,40}$/; /** Where `acct`'s (null: the host's, for AgentHub user `userKey`) trash lives. */ function trashRoot(acct, userKey) { if (acct) return path.join(acct.home, ".local", "share", "agenthub", "trash"); return path.join(getDataDir(), "trash", String(userKey || "local").replace(/[^\w.-]/g, "_")); } /** Move `rel` of workspace `dir` (workspace `laneId`) into its account's trash. */ async function trashOne(dir, laneId, rel, userKey) { const loc = await existing(dir, rel); writable(loc); const acct = accountAt(loc.abs); const id = `${Date.now().toString(36)}${crypto.randomBytes(6).toString("hex")}`; const idDir = path.join(trashRoot(acct, userKey), id); const meta = JSON.stringify({ id, name: loc.name, laneId, rel: loc.rel, isDir: loc.st.isDirectory(), deletedAt: new Date().toISOString(), }); if (acct) { needMove(acct); runAs.mkdirAs(acct, idDir); await runAs.writeAsAsync(acct, path.join(idDir, "meta.json"), ["private"], { input: meta }); await runAs.writeAsAsync(acct, loc.abs, ["move", path.join(idDir, "item")]); } else { await fs.promises.mkdir(idDir, { recursive: true, mode: 0o700 }); await fs.promises.writeFile(path.join(idDir, "meta.json"), meta, { mode: 0o600 }); await hostMove(loc.abs, path.join(idDir, "item")); } return id; } async function remove(dir, laneId, rels, userKey) { const ids = []; for (const r of rels) ids.push(await trashOne(dir, laneId, r, userKey)); return { trashed: ids }; } function readMeta(acct, idDir) { try { const text = acct ? runAs.readFileSafe(path.join(idDir, "meta.json"), "utf8", { max: 64 * 1024 }) : fs.readFileSync(path.join(idDir, "meta.json"), "utf8"); return JSON.parse(text); } catch { return null; } } async function dropTrash(acct, idDir) { if (acct) runAs.removeAs(acct, idDir); else await fs.promises.rm(idDir, { recursive: true, force: true }); } /** The trash of the account owning `dir`, expired entries dropped first. */ async function listTrash(dir, userKey) { const root = await laneRoot(dir); const acct = accountAt(root); const items = await readStore(acct, trashRoot(acct, userKey)); return { items, keepDays: TRASH_DAYS }; } /** The live items of one trash folder `tr` (of `acct`), expired ones dropped. */ async function readStore(acct, tr, store) { let ids = []; try { ids = (await fs.promises.readdir(tr)).filter((n) => TRASH_ID_RE.test(n)); } catch { return []; } const cutoff = Date.now() - TRASH_DAYS * 86_400_000; const items = []; for (const id of ids) { const meta = readMeta(acct, path.join(tr, id)); if (!meta || Date.parse(meta.deletedAt) < cutoff) { await dropTrash(acct, path.join(tr, id)).catch(() => {}); continue; } items.push({ id, name: meta.name, laneId: meta.laneId, path: meta.rel, isDir: !!meta.isDir, deletedAt: meta.deletedAt, ...(store ? { store } : {}), }); } items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt)); return items; } function trashEntry(acct, userKey, id) { return entryAt(acct, trashRoot(acct, userKey), id); } function entryAt(acct, tr, id) { if (!TRASH_ID_RE.test(String(id || ""))) throw bad("EBADPATH", "no such trash item"); const idDir = path.join(tr, id); const meta = readMeta(acct, idDir); if (!meta) throw bad("ENOFILE", "no such trash item"); return { idDir, meta }; } /** * Put trash item `id` back where it was in workspace `dir` (its folders made * again if gone; a taken name gets a free one). The item must belong to the * account of `dir` — `resolveLane(meta.laneId)` returns that workspace's cwd * when the caller may write there, else null (restored into `dir` instead). */ async function restore(dir, id, userKey, resolveLane) { const root = await laneRoot(dir); const acct = accountAt(root); const { idDir, meta } = trashEntry(acct, userKey, id); const home = (meta.laneId != null && resolveLane(meta.laneId)) || dir; return putBack(acct, idDir, meta, home, dir); } /** Move trash entry `idDir` back under folder `home` (`dir`: the fallback). */ async function putBack(acct, idDir, meta, home, dir) { const parentRel = path.posix.dirname(String(meta.rel || meta.name)); const relDir = parentRel === "." ? "" : parentRel; await ensureFolders(home, relDir); const dest = await folder(home, relDir); sameAccount(dest.abs, idDir); let name = validName(meta.name); if (await fs.promises.lstat(path.join(dest.abs, name)).catch(() => null)) { name = await freeName(dest.abs, name); } const target = path.join(dest.abs, name); await moveAs(acct, path.join(idDir, "item"), target); await dropTrash(acct, idDir); return { path: relDir ? `${relDir}/${name}` : name, laneId: home === dir ? null : meta.laneId }; } // ── A user's recycle bin ─────────────────────────────────────────────────── // // One Trash per USER, whatever place a delete happened in: the trash of their // home's account (a member's `~/.local/share/agenthub/trash/`, or the host's // `/trash//` when their home is the host's) plus — when // that is another folder — the host trash under their user id, where what // they deleted in a host-folder workspace went. Items carry `store` // ("home" | "host") so a restore or purge finds the right one. /** The trash stores of the user `userKey` whose home is `homeDir`. */ function userStores(homeDir, userKey) { const acct = accountAt(homeDir); const out = [{ store: "home", acct, root: trashRoot(acct, userKey) }]; const host = trashRoot(null, userKey); if (acct || path.resolve(host) !== path.resolve(out[0].root)) { out.push({ store: "host", acct: null, root: host }); } return out; } function pickStore(homeDir, userKey, store) { const s = userStores(homeDir, userKey).find((x) => x.store === (store || "home")); if (!s) throw bad("EBADPATH", "no such trash"); return s; } /** Every item of the user's recycle bin, newest first. */ async function listUserTrash(homeDir, userKey) { const items = []; for (const s of userStores(homeDir, userKey)) { items.push(...(await readStore(s.acct, s.root, s.store))); } items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt)); return { items, keepDays: TRASH_DAYS }; } /** * Restore item `id` of the user's `store`: into the workspace it came from * when `resolveLane` still gives one, else — for the home store — into the * home. A host-store item whose workspace is gone has nowhere of the same * account to go (`ENOFILE`). */ async function restoreUserTrash(homeDir, userKey, store, id, resolveLane) { const s = pickStore(homeDir, userKey, store); const { idDir, meta } = entryAt(s.acct, s.root, id); const fallback = s.store === "home" ? homeDir : null; const home = (meta.laneId != null && resolveLane(meta.laneId)) || fallback; if (!home) { throw bad("ENOFILE", "the workspace this came from is gone — there is nowhere to restore it"); } return putBack(s.acct, idDir, meta, home, fallback); } /** Delete item `id` of `store` forever, or `"all"` of every store. */ async function purgeUserTrash(homeDir, userKey, store, id) { if (id === "all") { let n = 0; for (const s of userStores(homeDir, userKey)) { for (const it of await readStore(s.acct, s.root)) { await dropTrash(s.acct, path.join(s.root, it.id)); n++; } } return { purged: n }; } const s = pickStore(homeDir, userKey, store); const { idDir } = entryAt(s.acct, s.root, id); await dropTrash(s.acct, idDir); return { purged: 1 }; } async function purgeTrash(dir, userKey, id) { const root = await laneRoot(dir); const acct = accountAt(root); if (id === "all") { const { items } = await listTrash(dir, userKey); for (const it of items) await dropTrash(acct, path.join(trashRoot(acct, userKey), it.id)); return { purged: items.length }; } const { idDir } = trashEntry(acct, userKey, id); await dropTrash(acct, idDir); return { purged: 1 }; } // ── Upload ───────────────────────────────────────────────────────────────── /** A pass-through that fails once more than `max` bytes went by. */ function capped(max) { let n = 0; return new Transform({ transform(chunk, _enc, cb) { n += chunk.length; if (n > max) return cb(bad("ETOOBIG", `a file may be at most ${max / 1024 / 1024} MB`)); cb(null, chunk); }, }); } /** * Write the request `stream` to `rel` (sub-folders made as needed). Returns * `{path}`, or `{skipped: true}` under `conflict: "skip"`. */ async function upload(dir, rel, stream, { conflict, length, trash } = {}) { const p = lanePath(rel); for (const seg of p.split("/")) validName(seg); if (gitInside(p)) throw bad("EBADPATH", "nothing is uploaded into .git"); if (length != null && Number(length) > UPLOAD_MAX_BYTES) { throw bad("ETOOBIG", `a file may be at most ${UPLOAD_MAX_BYTES / 1024 / 1024} MB`); } const parentRel = path.posix.dirname(p) === "." ? "" : path.posix.dirname(p); await ensureFolders(dir, parentRel); let loc = await locate(dir, p); const policy = conflictOf(conflict); if (loc.st) { if (loc.st.isDirectory()) throw bad("EEXISTS", `${loc.rel} is a folder`, { names: [loc.name] }); if (policy === "fail") throw bad("EEXISTS", `${loc.rel} already exists`, { names: [loc.name] }); if (policy === "skip") { stream.resume(); return { path: loc.rel, skipped: true }; } if (policy === "keep") { const name = await freeName(loc.parent.abs, loc.name); loc = await locate(dir, parentRel ? `${parentRel}/${name}` : name); } else { await trash(loc.rel); } } const acct = accountAt(loc.abs); const limit = capped(UPLOAD_MAX_BYTES); if (acct) { // write-as `replace`: a new file renamed over the name, as the member. const done = runAs.writeAsAsync(acct, loc.abs, ["replace"], { input: limit }); await Promise.all([ pipeline(stream, limit).catch((e) => { limit.destroy(e); throw e; }), done, ]); } else { const tmp = path.join( loc.parent.abs, `.${loc.name}.agenthub-upload-${crypto.randomBytes(4).toString("hex")}` ); try { await pipeline(stream, limit, fs.createWriteStream(tmp, { flags: "wx" })); // Linked, not renamed: a name taken meanwhile is refused, never replaced. await fs.promises.link(tmp, loc.abs).catch(async (e) => { if (e.code === "EEXIST") throw bad("EEXISTS", `${loc.rel} already exists`, { names: [loc.name] }); if (e.code === "EPERM" || e.code === "ENOTSUP") return fs.promises.rename(tmp, loc.abs); throw e; }); } finally { await fs.promises.rm(tmp, { force: true }); } } return { path: loc.rel }; } /** * One file for a download (the Files tab's `openDownload`, after this * module's own checks — the home rule included, on the file's REAL path). */ async function download(dir, rel) { const loc = await existing(dir, rel); const real = await fs.promises.realpath(loc.abs).catch(() => loc.abs); if (topDot(loc.root, real) || escapes(loc.root, real)) throw hidden(); return require("./lane-file-transfer").openDownload(dir, loc.rel); } // ── Zip download ─────────────────────────────────────────────────────────── /** Every regular file and folder under `rels` (no symlink followed, no secret). */ async function collect(dir, rels) { const root = await laneRoot(dir); const files = []; const dirs = []; let total = 0; const visit = async (abs, inZip) => { const rel = path.relative(root, abs); if (topDot(root, abs) || (await isSecretOnDisk(root, rel))) return; const st = await fs.promises.lstat(abs).catch(() => null); if (!st) return; if (st.isDirectory()) { dirs.push({ name: `${inZip}/`, mtime: st.mtime }); for (const n of (await fs.promises.readdir(abs)).sort()) await visit(path.join(abs, n), `${inZip}/${n}`); } else if (st.isFile()) { total += st.size; if (total > ZIP_MAX_BYTES) throw bad("ETOOBIG", "more than 2 GB to zip — download fewer files"); if (files.length >= ZIP_MAX_FILES) throw bad("ETOOBIG", "more than 50,000 files to zip"); files.push({ abs, name: inZip, size: st.size, mtime: st.mtime, mode: st.mode }); } }; for (const r of rels) { const loc = await existing(dir, r); await visit(loc.abs, loc.name); } return { files, dirs, total }; } function dosTime(d) { const t = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1); const day = ((Math.max(1980, d.getFullYear()) - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(); return { t, day }; } /** * Stream a zip of `rels` (files and folders) into `out`. Deflate with data * descriptors, UTF-8 names, no ZIP64 (collect caps it at 2 GB). A member's * file is opened through `openSafeAsync` (a regular file they own). */ async function zip(dir, rels, out) { const { files, dirs } = await collect(dir, rels); let offset = 0; const central = []; const write = (buf) => new Promise((resolve, reject) => { offset += buf.length; out.write(buf, (e) => (e ? reject(e) : resolve())); }); const header = (name, method, flags, d) => { const n = Buffer.from(name, "utf8"); const h = Buffer.alloc(30); h.writeUInt32LE(0x04034b50, 0); h.writeUInt16LE(20, 4); h.writeUInt16LE(flags, 6); h.writeUInt16LE(method, 8); h.writeUInt16LE(d.t, 10); h.writeUInt16LE(d.day, 12); h.writeUInt16LE(n.length, 26); return Buffer.concat([h, n]); }; for (const d of dirs) { const dt = dosTime(d.mtime); central.push({ name: d.name, method: 0, flags: 0x0800, dt, crc: 0, csize: 0, size: 0, offset, attr: (0o40755 << 16) | 0x10, }); await write(header(d.name, 0, 0x0800, dt)); } for (const f of files) { const dt = dosTime(f.mtime); const start = offset; await write(header(f.name, 8, 0x0808, dt)); let crc = 0; let size = 0; let csize = 0; let fh; try { fh = await runAs.openSafeAsync(f.abs); } catch { fh = null; // gone, or not the member's own regular file: stored empty } const tap = new Transform({ transform(c, _e, cb) { crc = zlib.crc32(c, crc); size += c.length; cb(null, c); }, }); const count = new Transform({ transform(c, _e, cb) { csize += c.length; offset += c.length; cb(null, c); }, }); const src = fh && f.size ? fh.createReadStream({ start: 0, end: f.size - 1 }) : Readable.from([]); if (fh && !f.size) await fh.close(); await pipeline(src, tap, zlib.createDeflateRaw(), count, out, { end: false }); const dd = Buffer.alloc(16); dd.writeUInt32LE(0x08074b50, 0); dd.writeUInt32LE(crc >>> 0, 4); dd.writeUInt32LE(csize, 8); dd.writeUInt32LE(size, 12); await write(dd); central.push({ name: f.name, method: 8, flags: 0x0808, dt, crc, csize, size, offset: start, attr: ((f.mode & 0o777) | 0o100000) << 16, }); } const cdStart = offset; for (const c of central) { const n = Buffer.from(c.name, "utf8"); const h = Buffer.alloc(46); h.writeUInt32LE(0x02014b50, 0); h.writeUInt16LE((3 << 8) | 20, 4); h.writeUInt16LE(20, 6); h.writeUInt16LE(c.flags, 8); h.writeUInt16LE(c.method, 10); h.writeUInt16LE(c.dt.t, 12); h.writeUInt16LE(c.dt.day, 14); h.writeUInt32LE(c.crc >>> 0, 16); h.writeUInt32LE(c.csize, 20); h.writeUInt32LE(c.size, 24); h.writeUInt16LE(n.length, 28); h.writeUInt32LE(c.attr >>> 0, 38); h.writeUInt32LE(c.offset, 42); await write(Buffer.concat([h, n])); } const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(central.length, 8); end.writeUInt16LE(central.length, 10); end.writeUInt32LE(offset - cdStart, 12); end.writeUInt32LE(cdStart, 16); await write(end); out.end(); } module.exports = { inHome, download, list, mkdir, newFile, rename, move, copy, remove, trashOne, listTrash, listUserTrash, restoreUserTrash, purgeUserTrash, restore, purgeTrash, upload, zip, collect, UPLOAD_MAX_BYTES, ZIP_MAX_BYTES, TRASH_DAYS, };