Files
NextChair/manager/src/app/api/backups/upload/route.ts
T
Stefan Lazic 17eee5b932
CI / ci (push) Canceled after 0s
Updates 1
The session built:

Appointments page: a single list, newest first.
Block Time: edit and delete.
Customer CSV export: with barber notes excluded.
Guest-link regression test: a test that an appointment id alone is rejected, plus fixes to the stale docs.

It built the scheduler, job runner with live progress, backup and restore, and the health alerts. Its own report says a real delete-and-restore on throwaway Docker passed, but I haven’t repeated that. It also says real SMTP and large dumps were not exercised. The manager image needs rebuilding for the backups to work, because it now installs 7zip.
2026-10-17 22:29:00 +01:00

72 lines
3.1 KiB
TypeScript

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 });
}