// 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([ "/", // ← splash page is public "/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(/\/$/, ""); type MapResult = { pathname: string; query?: Record }; /** 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?next=. * Only set reauth=1 (and clear cookies) when opts.reauth === true. */ function kickToSignIn(req: NextRequest, opts?: { reauth?: boolean }) { const wantReauth = !!opts?.reauth; 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 = ""; if (wantReauth) url.searchParams.set("reauth", "1"); url.searchParams.set("next", next); const res = NextResponse.redirect(url); // Only clear auth markers in true re-auth scenarios if (wantReauth) { 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; // ── 0) Absolute rule: the homepage must never redirect (no mapping, no gating). if (pathname === "/") { return NextResponse.next(); } // ── 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 WITHOUT reauth if (!token && isProtected) { return kickToSignIn(req, { reauth: false }); } // 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, 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) { // True reauth return kickToSignIn(req, { reauth: true }); } // ── 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 → true reauth, carry next return kickToSignIn(req, { reauth: true }); } // 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, { reauth: true }); } } } } } // 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(); } function legacyMap(pathname: string): MapResult | null { // Never map the homepage, and if we’re already inside the portal, don’t remap again. if (pathname === "/" || 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 (detail) [/^\/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 (detail) [/^\/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]> = [ // ── Canonicals for direct, public URLs ─────────────────────────────────── // https://makearmy.io/background-remover → /portal/utilities?t=background-remover [/^\/background-remover\/?$/i, { pathname: "/portal/utilities", query: { t: "background-remover" } }], // https://makearmy.io/laser-toolkit → /portal/utilities?t=laser-toolkit [/^\/laser-toolkit\/?$/i, { pathname: "/portal/utilities", query: { t: "laser-toolkit" } }], // https://makearmy.io/files → /portal/utilities?t=files [/^\/files\/?$/i, { pathname: "/portal/utilities", query: { t: "files" } }], // https://makearmy.io/buying-guide → /portal/buying-guide [/^\/buying-guide\/?$/i, { pathname: "/portal/buying-guide" }], // 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/sections [/^\/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 (root splash & 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).*)"], };