makearmy-app/middleware.ts

152 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// middleware.ts
import { NextResponse, NextRequest } from "next/server";
/**
* Public pages that should remain reachable without being signed in.
* Everything else is considered protected (including most /api/*).
*/
const PUBLIC_PAGES = new Set<string>(["/auth/sign-in", "/auth/sign-up"]);
/**
* API paths that are explicitly allowed without auth.
* Keep this list tiny. If you don't need any public APIs, leave it empty.
*/
const PUBLIC_API_PREFIXES: string[] = [
"/api/auth", // login/refresh/callback endpoints
// "/api/health", // uncomment if you intentionally expose a healthcheck
];
/** Helper: does the path start with any prefix in a list? */
function startsWithAny(pathname: string, prefixes: string[]) {
return prefixes.some((p) => pathname.startsWith(p));
}
/** Helper: are we about to redirect to the same URL? */
function isSameUrl(req: NextRequest, mapped: MapResult) {
const dest = new URL(req.url);
dest.pathname = mapped.pathname;
if (mapped.query) {
for (const [k, v] of Object.entries(mapped.query)) dest.searchParams.set(k, v);
}
return dest.href === req.url;
}
export function middleware(req: NextRequest) {
const url = req.nextUrl.clone();
const { pathname } = url;
// ── 1) Legacy → Portal / Canonical mapping (runs before auth gating)
const mapped = legacyMap(pathname);
if (mapped && !isSameUrl(req, mapped)) {
url.pathname = mapped.pathname;
if (mapped.query) {
for (const [k, v] of Object.entries(mapped.query)) url.searchParams.set(k, v);
}
return NextResponse.redirect(url);
}
// ── 2) Auth gating (ma_at is the only allowed auth context)
const token = req.cookies.get("ma_at")?.value ?? "";
const isAuthRoute = pathname.startsWith("/auth/");
// If signed in and visiting /auth/*, send to portal
if (token && isAuthRoute) {
url.pathname = "/portal";
url.search = "";
return NextResponse.redirect(url);
}
// If unauthenticated and the route is protected, send to sign-in
if (!token && !isPublicPath(pathname)) {
url.pathname = "/auth/sign-in";
url.search = "";
return NextResponse.redirect(url);
}
return NextResponse.next();
}
type MapResult = { pathname: string; query?: Record<string, string> };
function legacyMap(pathname: string): MapResult | null {
// If were already inside the portal, dont try to remap again.
if (pathname.startsWith("/portal")) return null;
// 1) DETAIL PAGES: map legacy detail URLs straight into the portal with ?id=
// NOTE: We intentionally DO NOT remap `/lasers/:id` and `/projects/:id`
// so the portal iframes can load those canonical pages without recursion.
const detailRules: Array<[RegExp, (m: RegExpExecArray) => MapResult]> = [
// Laser settings
[/^\/fiber-settings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/laser-settings", query: { t: "fiber", id: m[1] } })],
[/^\/uv-settings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/laser-settings", query: { t: "uv", id: m[1] } })],
[/^\/co2-galvo-settings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/laser-settings", query: { t: "co2-galvo", id: m[1] } })],
[/^\/co2-gantry-settings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/laser-settings", query: { t: "co2-gantry", id: m[1] } })],
[/^\/co2gantry-settings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/laser-settings", query: { t: "co2-gantry", id: m[1] } })],
// Materials
[/^\/materials\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/materials", query: { t: "materials", id: m[1] } })],
[/^\/materials-coatings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/materials", query: { t: "materials-coatings", id: m[1] } })],
];
for (const [re, to] of detailRules) {
const m = re.exec(pathname);
if (m) return to(m);
}
// 2) LIST PAGES: legacy lists → portal lists (with tab param) or sections
const listRules: Array<[RegExp, MapResult]> = [
// Laser settings lists
[/^\/fiber-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "fiber" } }],
[/^\/uv-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "uv" } }],
[/^\/co2-galvo-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-galvo" } }],
[/^\/co2-ganry-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } }], // typo catch
[/^\/co2-gantry-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } }],
[/^\/co2gantry-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } }], // old alias
// Materials lists
[/^\/materials\/?$/i, { pathname: "/portal/materials", query: { t: "materials" } }],
[/^\/materials\/materials\/?$/i, { pathname: "/portal/materials", query: { t: "materials" } }],
[/^\/materials\/materials-coatings\/?$/i, { pathname: "/portal/materials", query: { t: "materials-coatings" } }],
[/^\/materials-coatings\/?$/i, { pathname: "/portal/materials", query: { t: "materials-coatings" } }],
// Other lists
[/^\/lasers\/?$/i, { pathname: "/portal/laser-sources" }],
[/^\/projects\/?$/i, { pathname: "/portal/projects" }],
[/^\/my\/rigs\/?$/i, { pathname: "/portal/rigs", query: { t: "my" } }],
];
for (const [re, dest] of listRules) {
if (re.test(pathname)) return dest;
}
return null;
}
function isPublicPath(pathname: string): boolean {
// 1) Public pages (auth screens)
if (PUBLIC_PAGES.has(pathname)) return true;
// 2) Static assets / internals
if (
pathname.startsWith("/_next/") ||
pathname.startsWith("/static/") ||
pathname.startsWith("/images/") ||
pathname === "/favicon.ico" ||
pathname === "/robots.txt" ||
pathname === "/sitemap.xml"
) {
return true;
}
// 3) APIs:
// By default, /api/* is PROTECTED.
// Only allow specific public API prefixes listed above.
if (pathname.startsWith("/api/")) {
return startsWithAny(pathname, PUBLIC_API_PREFIXES);
}
// 4) Everything else is protected
return false;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|images|static).*)"],
};