// 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(["/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 ]; /** Directus base (used to remotely validate the token after restarts). */ const DIRECTUS = (process.env.NEXT_PUBLIC_API_BASE_URL || process.env.DIRECTUS_URL || "").replace( /\/$/, "" ); /** 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; } /** Decode JWT exp (seconds since epoch). We don't verify signature here. */ function jwtExp(token: string): number | null { try { const [, payload] = token.split("."); if (!payload) return null; const json = JSON.parse( atob(payload.replace(/-/g, "+").replace(/_/g, "/")) ); return typeof json.exp === "number" ? json.exp : null; } catch { return null; } } /** Build redirect to /auth/sign-in?reauth=1&next=, and clear auth markers. */ function kickToSignIn(req: NextRequest) { const orig = new URL(req.url); const next = orig.pathname + (orig.search || ""); const url = new URL(req.url); url.pathname = "/auth/sign-in"; url.search = ""; url.searchParams.set("reauth", "1"); url.searchParams.set("next", next); const res = NextResponse.redirect(url); // Clear tokens so the very next /auth/* request is truly unauthenticated res.cookies.set("ma_at", "", { maxAge: 0, path: "/" }); res.cookies.set("ma_v", "", { maxAge: 0, path: "/" }); // throttle marker // If you also use a refresh token, clear it here too: // res.cookies.set("ma_rt", "", { maxAge: 0, path: "/" }); return res; } export async 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 + validation (ma_at is the only allowed auth context) const token = req.cookies.get("ma_at")?.value ?? ""; const isAuthRoute = pathname.startsWith("/auth/"); const isProtected = !isPublicPath(pathname); // Allow explicit reauth flow even if a (possibly stale) token cookie exists const forceAuth = isAuthRoute && (url.searchParams.get("reauth") === "1" || url.searchParams.get("force") === "1"); // If unauthenticated and the route is protected, send to sign-in (with next + reauth) if (!token && isProtected) { return kickToSignIn(req); } // If we have a token, perform local expiry check. if (token) { const exp = jwtExp(token); const expired = !exp || exp * 1000 <= Date.now(); // If it's an auth route and token looks valid, keep your existing UX: // bounce away from auth pages — unless this is a forced reauth. if (isAuthRoute && !expired && !forceAuth) { url.pathname = "/portal"; url.search = ""; return NextResponse.redirect(url); } // If protected route: enforce validity if (isProtected) { if (expired) { return kickToSignIn(req); } // ── Throttled remote validation (catches server restarts / revoked tokens) // Only if we have a Directus URL configured. if (DIRECTUS) { const nowMinute = Math.floor(Date.now() / 60000).toString(); const lastValidated = req.cookies.get("ma_v")?.value; if (lastValidated !== nowMinute) { try { const r = await fetch(`${DIRECTUS}/users/me?fields=id`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json", }, cache: "no-store", }); if (!r.ok) { // Token no longer valid on the server → force re-auth, carry next return kickToSignIn(req); } // Cache the success for ~1 minute to avoid hammering Directus const res = NextResponse.next(); res.cookies.set("ma_v", nowMinute, { path: "/", httpOnly: false, sameSite: "lax", maxAge: 90, // seconds }); return res; } catch { // If Directus is unreachable, be conservative and require re-auth return kickToSignIn(req); } } } } } // If signed-in and visiting /auth/* but token is expired/invalid or reauth was requested, fall through (let them sign in). // If public or already validated, proceed. return NextResponse.next(); } type MapResult = { pathname: string; query?: Record }; function legacyMap(pathname: string): MapResult | null { // If we’re already inside the portal, don’t 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).*)", ], };