server/browser-upload.ts
Rama main · réplica de lectura del repositorio original.
import { randomUUID } from "node:crypto";
import { closeSync, constants, fstatSync, mkdirSync, openSync, read, readdirSync, readlinkSync, realpathSync, rmSync, statSync, write, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, isAbsolute, join, relative } from "node:path";
import { promisify } from "node:util";
import { BROWSER_UPLOAD_MAX_BYTES, FILE_MAX_BYTES, limitMegabytes } from "../shared/upload-limits.ts";
import { safeFileName } from "./browser/cdp/downloads.ts";
import { inSecretFolder, SECRET_FILE_REFUSAL } from "./secret-folder.ts";
const MAX_FILES = 10;
const readChunk = promisify(read), writeChunk = promisify(write);
// Node 24 omits Darwin's O_NOFOLLOW_ANY, which rejects symlinks in every path component.
const O_NOFOLLOW_ANY = 0x20000000;
const NAMED_COPY_MAX_AGE_MS = 24 * 60 * 60 * 1000;
export class BrowserUploadRefusal extends Error {}
function realRoot(root: string): string | null {
try { return realpathSync.native(root); } catch { return null; }
}
function inside(root: string, file: string): boolean {
const path = relative(root, file);
return path !== "" && !path.startsWith("..") && !isAbsolute(path);
}
/** A page may receive only the person's attachments and the bot's own folders; symlinks are followed before the check. */
export function uploadableFiles(files: unknown, roots: readonly string[]): { files: string[] } | { error: string } {
if (!Array.isArray(files) || files.length === 0 || files.length > MAX_FILES || !files.every((file) => typeof file === "string" && file)) {
return { error: `files must list 1 to ${MAX_FILES} absolute file paths.` };
}
const allowed = roots.flatMap((root) => realRoot(root) ?? []);
const resolved: string[] = [];
for (const file of files as string[]) {
const path = file.startsWith("~/") ? join(homedir(), file.slice(2)) : file;
if (!isAbsolute(path)) return { error: `${file} is not an absolute path.` };
let real: string;
try { real = realpathSync.native(path); } catch { return { error: `${file} does not exist.` }; }
if (!allowed.some((root) => inside(root, real))) {
return { error: `${file} is not one of this conversation's attachments or in your working folder, so it cannot be uploaded. Ask the person to attach the file.` };
}
if (inSecretFolder(real)) return { error: SECRET_FILE_REFUSAL };
if (!statSync(real).isFile()) return { error: `${file} is not a file.` };
resolved.push(real);
}
return { files: resolved };
}
/** Chrome names an uploaded file after its file on disk and reads it only when the form is sent, so an attachment stored
* under a random id gets a copy with the person's own name that outlives the call. `everyFile`: stage every file in
* `folder`, outside the bot's writable workspace. */
export async function namedUploadPaths(files: ReadonlyArray<{ file: string; name: string | null }>, folder: string, now = Date.now(), { everyFile = false } = {}): Promise<string[]> {
sweepNamedCopies(folder, now);
const created: string[] = [];
const opened: Array<{ input: number; size: number }> = [];
try {
let total = 0;
const prepared = files.map(({ file, name: given }) => {
const name = given && basename(given) === given ? given : everyFile ? basename(file) : null;
if (!name || (name === basename(file) && !everyFile)) return { file };
const source = openUpload(file);
opened.push(source);
total += source.size;
if (total > BROWSER_UPLOAD_MAX_BYTES) throw new BrowserUploadRefusal("Uploads are limited to 100 MiB per batch.");
return { file, name, source };
});
const paths: string[] = [];
for (const entry of prepared) {
if (!entry.source) { paths.push(entry.file); continue; }
const dir = join(folder, randomUUID());
mkdirSync(dir, { recursive: true, mode: 0o700 });
created.push(dir);
const named = join(dir, entry.name);
await copyUpload(entry.source, named, entry.file);
paths.push(named);
}
return paths;
} catch (error) {
for (const dir of created) rmSync(dir, { recursive: true, force: true });
throw error;
} finally {
for (const { input } of opened) closeSync(input);
}
}
function openUpload(source: string): { input: number; size: number } {
// Windows has no O_NOFOLLOW: there the copy is read from the path the allowlist just checked, before any wait.
const flags = process.platform === "win32" ? constants.O_RDONLY
: constants.O_RDONLY | constants.O_NONBLOCK | (process.platform === "darwin" ? O_NOFOLLOW_ANY : constants.O_NOFOLLOW);
const input = openSync(source, flags);
try {
if (process.platform === "linux" && readlinkSync(`/proc/self/fd/${input}`) !== source) throw new BrowserUploadRefusal(`${source} changed while staging the upload.`);
const file = fstatSync(input, { bigint: true });
if (!file.isFile()) throw new BrowserUploadRefusal(`${source} is not a file.`);
if (file.size > BigInt(FILE_MAX_BYTES)) throw new BrowserUploadRefusal(`Uploads are limited to ${limitMegabytes(FILE_MAX_BYTES)} MiB per file.`);
return { input, size: Number(file.size) };
} catch (error) {
closeSync(input);
throw error;
}
}
async function copyUpload({ input, size }: { input: number; size: number }, destination: string, source: string): Promise<void> {
const output = openSync(destination, "wx", 0o600);
try {
const chunk = Buffer.allocUnsafe(1024 * 1024);
for (let remaining = size; remaining > 0;) {
const { bytesRead: count } = await readChunk(input, chunk, 0, Math.min(chunk.length, remaining), null);
if (!count) throw new BrowserUploadRefusal(`${source} changed while staging the upload.`);
let offset = 0;
while (offset < count) {
const { bytesWritten } = await writeChunk(output, chunk, offset, count - offset, null);
if (!bytesWritten) throw new Error(`Could not stage ${source}.`);
offset += bytesWritten;
}
remaining -= count;
}
} finally {
closeSync(output);
}
}
/** Files a person picked on their own device for a page's file picker, kept under their own names for a day. */
export function storeUploads(files: ReadonlyArray<{ name: string; bytes: Uint8Array }>, folder: string, now = Date.now()): string[] {
sweepNamedCopies(folder, now);
return files.map(({ name, bytes }) => {
const dir = join(folder, randomUUID());
mkdirSync(dir, { recursive: true, mode: 0o700 });
const path = join(dir, safeFileName(name));
writeFileSync(path, bytes, { mode: 0o600 });
return path;
});
}
function sweepNamedCopies(folder: string, now: number): void {
let entries: string[];
try { entries = readdirSync(folder); } catch { return; }
for (const entry of entries) {
const dir = join(folder, entry);
try { if (now - statSync(dir).mtimeMs > NAMED_COPY_MAX_AGE_MS) rmSync(dir, { recursive: true, force: true }); } catch {}
}
}