import { createWriteStream } from "node:fs"; import { mkdir, rm } from "node:fs/promises"; import path from "node:path"; import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; import { NextResponse, type NextRequest } from "next/server"; import { uploadsDir } from "@/lib/manager-paths"; import { randomHex } from "@/lib/random"; import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/session"; export const dynamic = "force-dynamic"; /** Hard cap on an uploaded archive. Override with BACKUP_UPLOAD_MAX_BYTES. */ const DEFAULT_MAX_BYTES = 4 * 1024 * 1024 * 1024; function maxBytes(): number { const configured = Number(process.env.BACKUP_UPLOAD_MAX_BYTES); return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_MAX_BYTES; } /** * Streamed upload of a .7z backup (raw request body, not multipart, so it * is never buffered). This route is EXCLUDED from proxy.ts's matcher on * purpose: with a proxy in front, Next.js buffers the request body in * memory and silently truncates it at proxyClientMaxBodySize (10 MB by * default), which would corrupt a large archive. So it checks the * session cookie itself, first thing, before reading a single body byte. * * The size cap is enforced twice: up front from Content-Length, and while * streaming (a client can lie about, or omit, Content-Length). */ export async function POST(request: NextRequest) { const session = verifySessionToken(request.cookies.get(SESSION_COOKIE_NAME)?.value); if (!session) return NextResponse.json({ error: "Not signed in." }, { status: 401 }); const limit = maxBytes(); const declared = Number(request.headers.get("content-length")); if (Number.isFinite(declared) && declared > limit) { return NextResponse.json({ error: `That file is larger than the ${Math.round(limit / 1024 / 1024)} MB upload limit.` }, { status: 413 }); } if (!request.body) return NextResponse.json({ error: "No file received." }, { status: 400 }); const uploadId = randomHex(8); const destination = path.join(uploadsDir(), `${uploadId}.7z`); await mkdir(uploadsDir(), { recursive: true, mode: 0o700 }); let received = 0; const counter = new Transform({ transform(chunk: Buffer, _encoding, callback) { received += chunk.length; if (received > limit) callback(new Error("LIMIT_EXCEEDED")); else callback(null, chunk); }, }); try { await pipeline(Readable.fromWeb(request.body as import("node:stream/web").ReadableStream), counter, createWriteStream(destination, { mode: 0o600 })); } catch (error) { await rm(destination, { force: true }); if (error instanceof Error && error.message === "LIMIT_EXCEEDED") { return NextResponse.json({ error: `That file is larger than the ${Math.round(limit / 1024 / 1024)} MB upload limit.` }, { status: 413 }); } return NextResponse.json({ error: "The upload was interrupted." }, { status: 400 }); } if (received === 0) { await rm(destination, { force: true }); return NextResponse.json({ error: "The uploaded file was empty." }, { status: 400 }); } return NextResponse.json({ uploadId, sizeBytes: received }); }