SPB Git

Repository Public

fsx-transfer.ts 5.7 KB 125 lines typescript

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
Files main
Menu

Theme