// middleware.ts // Access control. // // WHY this exists: // This app has no per-user accounts. It exposes a PocketBase proxy at // /api/pb/records/* carrying client names, director ID numbers, addresses // and financials. Once the app is hosted (Coolify, tunnel, port-forward) // that proxy must NOT be reachable by anyone who finds the URL. // // The rule: // • /login and /api/auth/* are always reachable — otherwise you could not // sign in in the first place. // • The resign/ signing flow stays PUBLIC: the unguessable per-resignation // token IS the credential, which is the whole point of that page. // • Everything else requires a valid signed session cookie (see lib/auth). // • A local request (your machine, via localhost) keeps full access, so // day-to-day development is unaffected. import { NextRequest, NextResponse } from "next/server"; import { SESSION_COOKIE, verifySessionToken } from "@/lib/auth"; /** 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/"; /** The gate itself. */ const PUBLIC_PATHS = ["/login", "/api/auth/login", "/api/auth/logout"]; 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 async function middleware(req: NextRequest) { const { pathname } = req.nextUrl; const method = req.method.toUpperCase(); // 1. Signing flow stays public. 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(); } // 2. The login gate itself. if (PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + "/"))) { return NextResponse.next(); } // 3. Local development keeps working with no password at all. if (isLocalRequest(clientIp(req))) { return NextResponse.next(); } // 4. Otherwise: a valid session or nothing. const secret = process.env.AUTH_SECRET; const token = req.cookies.get(SESSION_COOKIE)?.value; if (await verifySessionToken(secret, token)) { return NextResponse.next(); } // 5. Not signed in. // API callers get JSON; browsers get sent to the login page. if (pathname.startsWith("/api/")) { return NextResponse.json( { error: "Unauthorized" }, { status: 401, headers: { "Content-Type": "application/json" } } ); } const url = req.nextUrl.clone(); url.pathname = "/login"; url.search = `?next=${encodeURIComponent(pathname + req.nextUrl.search)}`; return NextResponse.redirect(url); } export const config = { matcher: [ // Everything except Next internals and static/image files. "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)", ], };