84 lines
3 KiB
TypeScript
84 lines
3 KiB
TypeScript
|
|
// 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)$).*)",
|
||
|
|
],
|
||
|
|
};
|