// 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; add broad /api/webhooks to allow ALL webhook endpoints. */ const PUBLIC_API_PREFIXES: string[] = [ "/api/auth", // login/refresh/callback endpoints "/api/files/list", // read-only file endpoints "/api/files/raw", "/api/files/download", "/api/webhooks", // ← allow ALL webhooks (e.g. /api/webhooks/kofi, /api/webhooks/*) ]; /** 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 // res.cookies.set("ma_rt", "", { maxAge: 0, path: "/" }); // if you use refresh tokens } return res; } export async function middleware(req: NextRequest) { const url = req.nextUrl.clone(); const { pathname } = url; // ── -1) Always allow ALL webhook endpoints (no mapping, no gating, no redirects) // This lets external providers (Ko-fi, Patreon, etc.) POST without auth. if (pathname === "/api/webhooks" || pathname.startsWith("/api/webhooks/")) { return NextResponse.next(); } // ── 0) Root must never redirect (no mapping, no gating). if (pathname === "/") return NextResponse.next(); // ── 1) Legacy → Portal mapping (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 const token = req.cookies.get("ma_at")?.value ?? ""; const isAuthRoute = pathname.startsWith("/auth/"); const isProtected = !isPublicPath(pathname); const forceAuth = isAuthRoute && (url.searchParams.get("reauth") === "1" || url.searchParams.get("force") === "1"); if (!token && isProtected) { return kickToSignIn(req, { reauth: false }); } if (token) { const exp = jwtExp(token); const expired = !exp || exp * 1000 <= Date.now(); if (isAuthRoute && !expired && !forceAuth) { url.pathname = "/portal"; url.search = ""; return NextResponse.redirect(url); } if (isProtected) { if (expired) { return kickToSignIn(req, { reauth: true }); } 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) { return kickToSignIn(req, { reauth: true }); } const res = NextResponse.next(); res.cookies.set("ma_v", nowMinute, { path: "/", httpOnly: false, sameSite: "lax", maxAge: 90, }); return res; } catch { return kickToSignIn(req, { reauth: true }); } } } } } return NextResponse.next(); } function legacyMap(pathname: string): MapResult | null { if (pathname === "/" || pathname.startsWith("/portal")) return null; // detail mappings elided for brevity… const listRules: Array<[RegExp, MapResult]> = [ [/^\/background-remover\/?$/i, { pathname: "/portal/utilities", query: { t: "background-remover" } }], [/^\/laser-toolkit\/?$/i, { pathname: "/portal/utilities", query: { t: "laser-toolkit" } }], [/^\/files\/?$/i, { pathname: "/portal/utilities", query: { t: "files" } }], [/^\/buying-guide\/?$/i, { pathname: "/portal/buying-guide" }], [/^\/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 { if (PUBLIC_PAGES.has(pathname)) return true; if ( pathname.startsWith("/_next/") || pathname.startsWith("/static/") || pathname.startsWith("/images/") || pathname === "/favicon.ico" || pathname === "/robots.txt" || pathname === "/sitemap.xml" ) { return true; } if (pathname.startsWith("/api/")) { return startsWithAny(pathname, PUBLIC_API_PREFIXES); } return false; } // Match all except the usual static assets; webhooks are handled above. export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|images|static).*)"], };