// middleware.ts // Access control for the signing flow. // // WHY: this app has no login. It exposes a PocketBase proxy at // /api/pb/records/* that carries client names, director ID numbers, // addresses, emails and financials. If the app is put on the public // internet (tunnel, port-forward, or Coolify) without a guard, anyone who // guesses or observes the URL can read all of it. // // The rule implemented here: // • The resign/ signing flow is PUBLIC — the unguessable per-resignation // token IS the credential, which is the whole point of that page. // • Everything else (the registry, PocketBase proxies, reports, PB // writes) is LOCAL ONLY. From outside, it 404s as if it does not exist. // // A local request — you, on this machine, via localhost:6001 — keeps full // access to the entire app. // // Deploying behind Coolify later: delete this file once the app has real // authentication, or keep it and add a session check. import { NextRequest, NextResponse } from "next/server"; /** Paths a director on a phone must be able to reach. */ const PUBLIC_GET_PREFIXES = [ "/resign/", "/api/resignations/sign/", ]; /** The signing page saves the drawn signature with a PATCH to this path. * It is only safe to expose because the PocketBase record id is a * 15-character random string that cannot be enumerated. */ const PUBLIC_PATCH_PREFIX = "/api/resignations/"; function clientIp(req: NextRequest): string { const fwd = req.headers.get("x-forwarded-for"); const first = fwd ? fwd.split(",")[0].trim() : ""; const real = req.headers.get("x-real-ip") || ""; return (first || real || "").replace(/^::ffff:/, "").trim(); } function isLocalRequest(ip: string): boolean { if (!ip) return true; // no proxy header at all — a direct local request if (ip === "127.0.0.1" || ip === "::1" || ip === "localhost") return true; if (/^127\./.test(ip)) return true; // Private LAN ranges if (/^10\./.test(ip)) return true; if (/^192\.168\./.test(ip)) return true; if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true; return false; } export function middleware(req: NextRequest) { const { pathname } = req.nextUrl; const method = req.method.toUpperCase(); const isPublicGet = method === "GET" && PUBLIC_GET_PREFIXES.some((p) => pathname.startsWith(p)); const isPublicPatch = method === "PATCH" && pathname.startsWith(PUBLIC_PATCH_PREFIX) && !pathname.startsWith("/api/resignations/sign/"); if (isPublicGet || isPublicPatch) { return NextResponse.next(); } if (isLocalRequest(clientIp(req))) { return NextResponse.next(); } // External request for an internal route. Disguise as "not found" rather // than "forbidden" so probing does not reveal what exists. return new NextResponse("Not found", { status: 404, headers: { "Content-Type": "text/plain" }, }); } export const config = { matcher: [ // Everything except Next internals and the verification image. "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)", ], };