Workflow-App/Workflow/middleware.ts

106 lines
3.7 KiB
TypeScript
Raw Normal View History

// 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)$).*)",
],
};