Forráskód Böngészése

RHL-015 feat(api): implement PDF streaming endpoint with validation and error handling

Code_Uwe 4 hete
szülő
commit
86919900b1

+ 204 - 0
app/api/files/[branch]/[year]/[month]/[day]/[filename]/route.js

@@ -0,0 +1,204 @@
+import fs from "node:fs";
+import fsp from "node:fs/promises";
+import path from "node:path";
+import { Readable } from "node:stream";
+
+import { getSession } from "@/lib/auth/session";
+import { canAccessBranch } from "@/lib/auth/permissions";
+import {
+	withErrorHandling,
+	badRequest,
+	unauthorized,
+	forbidden,
+	notFound,
+	ApiError,
+} from "@/lib/api/errors";
+import { mapStorageReadError } from "@/lib/api/storageErrors";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+const BRANCH_RE = /^NL\d+$/;
+const YEAR_RE = /^\d{4}$/;
+const MONTH_RE = /^(0[1-9]|1[0-2])$/;
+const DAY_RE = /^(0[1-9]|[12]\d|3[01])$/;
+
+function getNasRootOrThrow() {
+	const root = process.env.NAS_ROOT_PATH;
+
+	if (!root) {
+		throw new ApiError({
+			status: 500,
+			code: "FS_STORAGE_ERROR",
+			message: "Internal server error",
+		});
+	}
+
+	return root;
+}
+
+function isSafeFilename(name) {
+	if (typeof name !== "string") return false;
+
+	const trimmed = name.trim();
+	if (!trimmed) return false;
+
+	// Reject special path segments
+	if (trimmed === "." || trimmed === "..") return false;
+
+	// Reject any path separators (defense-in-depth)
+	if (trimmed.includes("/") || trimmed.includes("\\")) return false;
+
+	// Reject control chars (header injection)
+	if (/[\r\n\t]/.test(trimmed)) return false;
+
+	// Reject quotes to keep Content-Disposition predictable/safe
+	if (trimmed.includes('"')) return false;
+
+	// Ensure it's a basename (no sneaky segments)
+	if (path.basename(trimmed) !== trimmed) return false;
+
+	return true;
+}
+
+function isPdfFilename(name) {
+	return typeof name === "string" && name.toLowerCase().endsWith(".pdf");
+}
+
+function validateParamsOrThrow({ branch, year, month, day, filename }) {
+	if (!BRANCH_RE.test(branch)) {
+		throw badRequest("VALIDATION_BRANCH", "Invalid branch parameter", {
+			branch,
+		});
+	}
+
+	if (!YEAR_RE.test(year)) {
+		throw badRequest("VALIDATION_YEAR", "Invalid year parameter", { year });
+	}
+
+	if (!MONTH_RE.test(month)) {
+		throw badRequest("VALIDATION_MONTH", "Invalid month parameter", { month });
+	}
+
+	if (!DAY_RE.test(day)) {
+		throw badRequest("VALIDATION_DAY", "Invalid day parameter", { day });
+	}
+
+	if (!isSafeFilename(filename)) {
+		throw badRequest("VALIDATION_FILENAME", "Invalid filename parameter", {
+			filename,
+		});
+	}
+
+	if (!isPdfFilename(filename)) {
+		throw badRequest(
+			"VALIDATION_FILE_EXTENSION",
+			"Only PDF files are allowed",
+			{ filename }
+		);
+	}
+}
+
+function resolvePdfPathOrThrow({ root, branch, year, month, day, filename }) {
+	const rootAbs = path.resolve(root);
+	const absPath = path.resolve(rootAbs, branch, year, month, day, filename);
+
+	// Ensure the resolved path stays within NAS_ROOT_PATH
+	const rel = path.relative(rootAbs, absPath);
+	if (rel.startsWith("..") || path.isAbsolute(rel)) {
+		throw badRequest("VALIDATION_PATH_TRAVERSAL", "Invalid file path", {
+			branch,
+			year,
+			month,
+			day,
+			filename,
+		});
+	}
+
+	return absPath;
+}
+
+/**
+ * GET /api/files/:branch/:year/:month/:day/:filename
+ *
+ * Query (optional):
+ * - download=1 | download=true  => Content-Disposition: attachment
+ * - default => inline
+ */
+export const GET = withErrorHandling(
+	async function GET(request, ctx) {
+		const session = await getSession();
+
+		if (!session) {
+			throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
+		}
+
+		const { branch, year, month, day, filename } = await ctx.params;
+
+		const missing = [];
+		if (!branch) missing.push("branch");
+		if (!year) missing.push("year");
+		if (!month) missing.push("month");
+		if (!day) missing.push("day");
+		if (!filename) missing.push("filename");
+
+		if (missing.length > 0) {
+			throw badRequest(
+				"VALIDATION_MISSING_PARAM",
+				"Missing required route parameter(s)",
+				{ params: missing }
+			);
+		}
+
+		if (!canAccessBranch(session, branch)) {
+			throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
+		}
+
+		validateParamsOrThrow({ branch, year, month, day, filename });
+
+		const root = getNasRootOrThrow();
+		const absPath = resolvePdfPathOrThrow({
+			root,
+			branch,
+			year,
+			month,
+			day,
+			filename,
+		});
+
+		const details = { branch, year, month, day, filename };
+
+		let stat;
+		try {
+			stat = await fsp.stat(absPath);
+		} catch (err) {
+			throw await mapStorageReadError(err, { details });
+		}
+
+		if (!stat.isFile()) {
+			throw notFound("FS_NOT_FOUND", "Not found", details);
+		}
+
+		const { searchParams } = new URL(request.url);
+		const download = (searchParams.get("download") || "").toLowerCase();
+		const asAttachment = download === "1" || download === "true";
+
+		const dispositionType = asAttachment ? "attachment" : "inline";
+		const contentDisposition = `${dispositionType}; filename="${filename}"`;
+
+		const nodeStream = fs.createReadStream(absPath);
+		const webStream = Readable.toWeb(nodeStream);
+
+		return new Response(webStream, {
+			status: 200,
+			headers: {
+				"Content-Type": "application/pdf",
+				"Content-Disposition": contentDisposition,
+				"Content-Length": String(stat.size),
+				"Cache-Control": "no-store",
+				"X-Content-Type-Options": "nosniff",
+			},
+		});
+	},
+	{ logPrefix: "[api/files/[branch]/[year]/[month]/[day]/[filename]]" }
+);

+ 246 - 0
app/api/files/[branch]/[year]/[month]/[day]/[filename]/route.test.js

@@ -0,0 +1,246 @@
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import fsp from "node:fs/promises";
+
+vi.mock("@/lib/auth/session", () => ({
+	getSession: vi.fn(),
+}));
+
+import { getSession } from "@/lib/auth/session";
+import { GET, dynamic, runtime } from "./route.js";
+
+describe("GET /api/files/[branch]/[year]/[month]/[day]/[filename]", () => {
+	let tmpRoot;
+	const originalNasRoot = process.env.NAS_ROOT_PATH;
+
+	const paramsOk = {
+		branch: "NL01",
+		year: "2024",
+		month: "10",
+		day: "23",
+		filename: "test.pdf",
+	};
+
+	beforeEach(async () => {
+		vi.clearAllMocks();
+
+		tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-pdf-"));
+		process.env.NAS_ROOT_PATH = tmpRoot;
+
+		const dir = path.join(tmpRoot, "NL01", "2024", "10", "23");
+		await fs.mkdir(dir, { recursive: true });
+
+		await fs.writeFile(path.join(dir, "test.pdf"), "dummy-pdf-content");
+	});
+
+	afterEach(async () => {
+		process.env.NAS_ROOT_PATH = originalNasRoot;
+		if (tmpRoot) await fs.rm(tmpRoot, { recursive: true, force: true });
+		vi.restoreAllMocks();
+	});
+
+	it('exports dynamic="force-dynamic" (RHL-006)', () => {
+		expect(dynamic).toBe("force-dynamic");
+	});
+
+	it('exports runtime="nodejs" (required for streaming)', () => {
+		expect(runtime).toBe("nodejs");
+	});
+
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
+
+		const res = await GET(
+			new Request("http://localhost/api/files/NL01/2024/10/23/test.pdf"),
+			{ params: Promise.resolve(paramsOk) }
+		);
+
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
+	});
+
+	it("returns 403 when branch access is forbidden", async () => {
+		getSession.mockResolvedValue({
+			role: "branch",
+			branchId: "NL01",
+			userId: "u1",
+		});
+
+		const res = await GET(
+			new Request("http://localhost/api/files/NL02/2024/10/23/test.pdf"),
+			{
+				params: Promise.resolve({
+					...paramsOk,
+					branch: "NL02",
+				}),
+			}
+		);
+
+		expect(res.status).toBe(403);
+		expect(await res.json()).toEqual({
+			error: { message: "Forbidden", code: "AUTH_FORBIDDEN_BRANCH" },
+		});
+	});
+
+	it("returns 400 for non-pdf filename", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const res = await GET(
+			new Request("http://localhost/api/files/NL01/2024/10/23/test.txt"),
+			{
+				params: Promise.resolve({
+					...paramsOk,
+					filename: "test.txt",
+				}),
+			}
+		);
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Only PDF files are allowed",
+				code: "VALIDATION_FILE_EXTENSION",
+				details: { filename: "test.txt" },
+			},
+		});
+	});
+
+	it("returns 400 for unsafe filename", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const res = await GET(
+			new Request("http://localhost/api/files/NL01/2024/10/23/foo/bar.pdf"),
+			{
+				params: Promise.resolve({
+					...paramsOk,
+					filename: "foo/bar.pdf",
+				}),
+			}
+		);
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Invalid filename parameter",
+				code: "VALIDATION_FILENAME",
+				details: { filename: "foo/bar.pdf" },
+			},
+		});
+	});
+
+	it("returns 404 when the PDF does not exist (authorized)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const res = await GET(
+			new Request("http://localhost/api/files/NL01/2024/10/23/missing.pdf"),
+			{
+				params: Promise.resolve({
+					...paramsOk,
+					filename: "missing.pdf",
+				}),
+			}
+		);
+
+		expect(res.status).toBe(404);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Not found",
+				code: "FS_NOT_FOUND",
+				details: {
+					branch: "NL01",
+					year: "2024",
+					month: "10",
+					day: "23",
+					filename: "missing.pdf",
+				},
+			},
+		});
+	});
+
+	it("returns 500 for other filesystem errors (mocked)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const spy = vi
+			.spyOn(fsp, "stat")
+			.mockRejectedValue(
+				Object.assign(new Error("EACCES"), { code: "EACCES" })
+			);
+
+		const res = await GET(
+			new Request("http://localhost/api/files/NL01/2024/10/23/test.pdf"),
+			{ params: Promise.resolve(paramsOk) }
+		);
+
+		expect(res.status).toBe(500);
+		expect(await res.json()).toEqual({
+			error: { message: "Internal server error", code: "FS_STORAGE_ERROR" },
+		});
+
+		spy.mockRestore();
+	});
+
+	it("streams the PDF with inline Content-Disposition by default", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const res = await GET(
+			new Request("http://localhost/api/files/NL01/2024/10/23/test.pdf"),
+			{ params: Promise.resolve(paramsOk) }
+		);
+
+		expect(res.status).toBe(200);
+		expect(res.headers.get("Content-Type")).toBe("application/pdf");
+		expect(res.headers.get("Cache-Control")).toBe("no-store");
+		expect(res.headers.get("Content-Disposition")).toBe(
+			'inline; filename="test.pdf"'
+		);
+
+		const buf = await res.arrayBuffer();
+		expect(Buffer.from(buf).toString("utf8")).toBe("dummy-pdf-content");
+	});
+
+	it("uses attachment disposition when download=1", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const res = await GET(
+			new Request(
+				"http://localhost/api/files/NL01/2024/10/23/test.pdf?download=1"
+			),
+			{ params: Promise.resolve(paramsOk) }
+		);
+
+		expect(res.status).toBe(200);
+		expect(res.headers.get("Content-Disposition")).toBe(
+			'attachment; filename="test.pdf"'
+		);
+	});
+});