|
|
@@ -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]]" }
|
|
|
+);
|