fix(drop): stream every dropped file to disk; no local size cap

- POST /api/run/drop-file pipes the body to its file as it arrives in every
  case — a member's (S3) through `write-as … replace` with the bytes on
  stdin — instead of buffering up to 20 MB in memory for a remote browser.
- Remote limit (server mode, a LAN browser) 20 MB → 500 MB, as a Files upload.
- A loopback browser in local mode has no cap any more, only the data dir's
  free space less a 1 GB reserve; ETOOBIG's max_bytes is what still fits, so
  the UI's "at most N MB" stays true.
- drop-image keeps its 20 MB in-memory limit, now checked on its own.
- fake-sudo gains a spawn for run-as.__setSpawnImpl.
This commit is contained in:
2026-10-26 16:36:29 +07:00
parent a0cd808456
commit dce32b96b7
6 changed files with 98 additions and 73 deletions
+73 -56
View File
@@ -25,6 +25,8 @@ const express = require("express");
const { Router } = express;
const fs = require("node:fs");
const crypto = require("node:crypto");
const { Transform } = require("node:stream");
const { pipeline } = require("node:stream/promises");
const path = require("node:path");
const runs = require("../lib/pty-run");
const tmux = require("../lib/tmux");
@@ -114,21 +116,35 @@ const DROP_TYPES = {
"image/webp": "webp",
};
const DROP_MAX_BYTES = 20 * 1024 * 1024;
// A browser on this machine (loopback, never server mode) crosses no network:
// its drop is streamed to disk, so a video or a dump is no reason to refuse.
// Chrome hands a dropped file no `file://` path; the client first asks for
// the file manager's own (`/selected-files`, `/clipboard-files`) and only
// uploads what that cannot place.
const DROP_LOCAL_MAX_BYTES = 2 * 1024 ** 3;
// drop-file streams every body to disk, never holding it in memory: from
// another machine (server mode, a LAN browser) up to what a Files upload may
// carry, so a video or a dump dropped on a pane is no reason to refuse.
const DROP_REMOTE_MAX_BYTES = 500 * 1024 * 1024;
// A browser on this machine (loopback, never server mode) crosses no network,
// so its drop has no size cap — only what the disk can take: the copy must
// leave DROP_LOCAL_RESERVE_BYTES free. Chrome hands a dropped file no
// `file://` path; the client first asks for the file manager's own
// (`/selected-files`, `/clipboard-files`) and only uploads what that cannot
// place.
const DROP_LOCAL_RESERVE_BYTES = 1024 ** 3;
const DROP_TTL_MS = 24 * 60 * 60_000;
const localDrop = (req) => !require("../lib/auth").isServerMode() && isLocalRequest(req);
const dropLimit = (req) => (localDrop(req) ? DROP_LOCAL_MAX_BYTES : DROP_MAX_BYTES);
/** The free space a local drop may fill (a local drop lands in the data dir). */
function localRoom() {
try {
const st = fs.statfsSync(require("../lib/claude-home").getDataDir());
return Math.max(0, st.bavail * st.bsize - DROP_LOCAL_RESERVE_BYTES);
} catch {
return Infinity; // unknown: a full disk still fails the write, and leaves no file
}
}
const dropLimit = (req) => (localDrop(req) ? localRoom() : DROP_REMOTE_MAX_BYTES);
function tooBig(res, max) {
res.set("Connection", "close");
res.status(413).json({
error: {
code: "ETOOBIG",
message: `a dropped file may be at most ${max / 1024 / 1024} MB`,
message: `a dropped file may be at most ${Math.floor(max / 1024 / 1024)} MB`,
max_bytes: max,
},
});
@@ -136,11 +152,11 @@ function tooBig(res, max) {
/** A body declared over the limit is refused before a byte of it is read, and
* the connection is closed: express.raw would read (and discard) all of it
* first, so a 4 GB drop crossed the network in full just to be told 413. */
function refuseOversize(req, res, next) {
const max = dropLimit(req);
const refuseOversize = (limit) => (req, res, next) => {
const max = limit(req);
if (!(Number(req.headers["content-length"]) > max)) return next();
tooBig(res, max);
}
};
/** Where a drop goes: the handoff dir of the account the PANE runs as — the
* workspace `?laneId=` names (scopeGuard already refused one this user may
* not see), else the requester's own — written AS that member under S3, so
@@ -195,7 +211,8 @@ function saveDrop(req, res, name, body) {
router.post(
"/drop-image",
refuseOversize,
// An image is held in memory: 20 MB, wherever the browser is.
refuseOversize(() => DROP_MAX_BYTES),
express.raw({ type: Object.keys(DROP_TYPES), limit: DROP_MAX_BYTES }),
(req, res) => {
const ext =
@@ -222,63 +239,63 @@ const dropName = (req) =>
.replace(/^\.+/, "")
.slice(-100);
/** A local browser's drop is piped to its file as it arrives, never held in
* memory whole; a body that runs past the limit (or is empty) leaves no file. */
function streamLocalDrop(req, res, next) {
if (!localDrop(req)) return next();
/** A drop is piped to its file as it arrives, never held in memory whole —
* a member's (S3) through `write-as` as them, its bytes on stdin; a body that
* runs past the limit (or is empty, or is cut off) leaves no file. */
async function streamDrop(req, res) {
const safe = dropName(req);
if (!safe) return res.status(400).json({ error: "?name= and a non-empty file body required" });
const runAs = require("../lib/run-as");
const max = dropLimit(req);
let file;
let out;
let member;
try {
const { dir, member } = prepareDropDir(req);
if (member) return next(); // a member's file is written as them, by saveDrop
file = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safe}`);
out = fs.createWriteStream(file, { mode: 0o600, flags: "wx" });
const dropped = prepareDropDir(req);
member = dropped.member;
file = path.join(dropped.dir, `${crypto.randomBytes(8).toString("hex")}-${safe}`);
} catch (err) {
return res.status(500).json({ error: err.message });
}
let bytes = 0;
let failed = null;
const fail = (status, send) => {
if (failed) return;
failed = status;
req.unpipe(out);
out.destroy();
fs.rm(file, { force: true }, () => {});
if (!res.headersSent) send();
};
req.on("data", (c) => {
bytes += c.length;
if (bytes > DROP_LOCAL_MAX_BYTES) fail(413, () => tooBig(res, DROP_LOCAL_MAX_BYTES));
const count = new Transform({
transform(chunk, _enc, cb) {
bytes += chunk.length;
if (bytes > max) return cb(Object.assign(new Error("too big"), { code: "ETOOBIG" }));
cb(null, chunk);
},
});
req.on("aborted", () => fail(400, () => {}));
out.on("error", (err) => fail(500, () => res.status(500).json({ error: err.message })));
out.on("finish", () => {
if (failed) return;
if (bytes === 0) {
return fail(400, () =>
res.status(400).json({ error: "?name= and a non-empty file body required" })
);
try {
if (member) {
await Promise.all([
pipeline(req, count).catch((e) => {
count.destroy(e);
throw e;
}),
runAs.writeAsAsync(member, file, ["replace"], { input: count }),
]);
} else {
await pipeline(req, count, fs.createWriteStream(file, { mode: 0o600, flags: "wx" }));
}
res.json({ path: file });
});
req.pipe(out);
}
router.post(
"/drop-file",
refuseOversize,
streamLocalDrop,
express.raw({ type: () => true, limit: DROP_MAX_BYTES }),
(req, res) => {
const safe = dropName(req);
if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) {
if (bytes === 0) throw Object.assign(new Error("empty"), { code: "EEMPTY" });
} catch (err) {
try {
if (member) runAs.removeAs(member, file);
else fs.rmSync(file, { force: true });
} catch {
/* never written */
}
// Past the limit mid-body, pipeline has already closed the socket.
if (res.headersSent) return;
if (err.code === "ETOOBIG") return tooBig(res, max);
if (err.code === "EEMPTY") {
return res.status(400).json({ error: "?name= and a non-empty file body required" });
}
saveDrop(req, res, `${crypto.randomBytes(8).toString("hex")}-${safe}`, req.body);
return res.status(500).json({ error: err.message });
}
);
res.json({ path: file });
}
router.post("/drop-file", refuseOversize(dropLimit), streamDrop);
// A browser on this machine is handed a file manager's files as `file://`
// paths beside their bytes; the composer attaches such a path instead of