Repository Public
1/**2 * /fs/upload (raw body stream → temp file in the destination directory → rename) and3 * /fs/download (stream with Content-Length + safe Content-Disposition).4 */5import { randomBytes } from 'node:crypto';6import { createWriteStream, promises as fsp } from 'node:fs';7import type { IncomingMessage } from 'node:http';8import path from 'node:path';9import type { FastifyInstance } from 'fastify';10import { z } from 'zod';11import type { RuntimeConfig } from './config.js';12import { RtError } from './plugins.js';13import { resolveInsideEx } from './paths.js';1415export const MAX_UPLOAD_BYTES = 200 * 1024 * 1024;1617const TransferQuery = z.object({18 root: z.string().min(1),19 rel: z.string().min(1),20 overwrite: z.enum(['0', '1']).optional(),21});2223function parseQuery(input: unknown): z.infer<typeof TransferQuery> {24 const r = TransferQuery.safeParse(input);25 if (!r.success) throw new RtError(400, 'bad_request', 'root and rel query parameters are required');26 return r.data;27}2829/** Pipe `src` to `tmp`, counting bytes; rejects with RtError 413 as soon as the cap is crossed. */30function streamToFile(src: NodeJS.ReadableStream, tmp: string, cap: number): Promise<number> {31 return new Promise((resolve, reject) => {32 const out = createWriteStream(tmp, { flags: 'wx', mode: 0o644 });33 let size = 0;34 let settled = false;35 const fail = (e: unknown): void => {36 if (settled) return;37 settled = true;38 src.unpipe(out);39 out.destroy();40 (src as IncomingMessage).resume?.(); // drain the rest so the connection can answer41 reject(e);42 };43 src.on('data', (chunk: Buffer) => {44 size += chunk.length;45 if (size > cap) fail(new RtError(413, 'too_large', `upload is larger than ${cap} bytes`));46 });47 src.on('error', () => fail(new RtError(400, 'upload_aborted', 'upload stream failed')));48 src.on('aborted', () => fail(new RtError(400, 'upload_aborted', 'upload aborted')));49 out.on('error', (e) => fail(e));50 out.on('finish', () => {51 if (settled) return;52 settled = true;53 resolve(size);54 });55 src.pipe(out);56 });57}5859/** RFC 6266 / 5987: ASCII fallback + UTF-8 form; no quotes, backslashes, CR/LF or separators. */60export function contentDisposition(name: string): string {61 const ascii = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\/;%]/g, '_') || 'download';62 const utf8 = encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);63 return `attachment; filename="${ascii}"; filename*=UTF-8''${utf8}`;64}6566export async function registerTransfer(app: FastifyInstance, config: RuntimeConfig, cap = MAX_UPLOAD_BYTES): Promise<void> {67 // Encapsulated scope: the raw-body passthrough parser applies to /fs/upload only.68 await app.register(async (scoped) => {69 scoped.removeAllContentTypeParsers();70 scoped.addContentTypeParser('*', (_req, payload, done) => done(null, payload));71 scoped.post('/fs/upload', { bodyLimit: cap }, async (req) => {72 const q = parseQuery(req.query);73 const declared = Number(req.headers['content-length']);74 if (Number.isFinite(declared) && declared > cap) throw new RtError(413, 'too_large', `upload is larger than ${cap} bytes`);75 const dest = await resolveInsideEx(config, q.root, q.rel, { forWrite: true });76 if (dest.abs === dest.rootReal || dest.stats?.isDirectory()) throw new RtError(400, 'is_directory', 'destination is a directory');77 if (dest.exists && q.overwrite !== '1') throw new RtError(409, 'exists', 'destination already exists');78 const dir = path.dirname(dest.abs);79 await fsp.mkdir(dir, { recursive: true });80 const tmp = path.join(dir, `.cr-upload-${randomBytes(8).toString('hex')}.part`);81 const body = (req.body as NodeJS.ReadableStream | undefined) ?? req.raw;82 try {83 const size = await streamToFile(body, tmp, cap);84 const fh = await fsp.open(tmp, 'r+');85 try {86 await fh.sync();87 } finally {88 await fh.close();89 }90 if (dest.exists && dest.stats) await fsp.chmod(tmp, dest.stats.mode & 0o7777);91 await fsp.rename(tmp, dest.abs);92 const st = await fsp.stat(dest.abs);93 return { path: dest.rel, size, mtimeMs: Math.round(st.mtimeMs) };94 } catch (e) {95 await fsp.rm(tmp, { force: true }).catch(() => undefined);96 if (e instanceof RtError) throw e;97 const code = (e as NodeJS.ErrnoException).code;98 if (code === 'ENOSPC') throw new RtError(507, 'no_space', 'no space left on device');99 throw new RtError(500, 'upload_failed', `upload failed${code ? ` (${code})` : ''}`);100 }101 });102 });103104 app.get('/fs/download', async (req, reply) => {105 const q = parseQuery(req.query);106 const r = await resolveInsideEx(config, q.root, q.rel);107 if (!r.exists || !r.stats) throw new RtError(404, 'not_found', 'file does not exist');108 if (r.stats.isDirectory()) throw new RtError(400, 'is_directory', 'directories cannot be downloaded');109 if (!r.stats.isFile()) throw new RtError(400, 'special_file', 'not a regular file');110 // Open first so the size we announce is the size of the handle we stream.111 const fh = await fsp.open(r.abs, 'r').catch(() => {112 throw new RtError(403, 'forbidden', 'file is not readable');113 });114 const st = await fh.stat();115 void reply116 .header('Content-Type', 'application/octet-stream')117 .header('Content-Length', String(st.size))118 .header('Content-Disposition', contentDisposition(path.basename(r.abs)))119 .header('X-Content-Type-Options', 'nosniff')120 .header('Cache-Control', 'no-store');121 // Bounded to the announced size so a file growing meanwhile cannot break Content-Length.122 return reply.send(st.size > 0 ? fh.createReadStream({ start: 0, end: st.size - 1 }) : fh.createReadStream());123 });124}125