Workflow-App/Workflow/middleware.ts
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

84 lines
No EOL
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)$).*)",
],
};