diff --git a/middleware.ts b/middleware.ts index ad242b08..e96028fb 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,134 +1,152 @@ // middleware.ts import { NextResponse, NextRequest } from "next/server"; -const PUBLIC_PATHS = new Set(["/auth/sign-in", "/auth/sign-up"]); +/** + * 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"]); -/** 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) { - // start from existing params so we preserve any others - for (const [k, v] of Object.entries(mapped.query)) dest.searchParams.set(k, v); - } - return dest.href === req.url; -} + /** + * 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 + ]; -export function middleware(req: NextRequest) { - const url = req.nextUrl.clone(); - const { pathname } = url; + /** Helper: does the path start with any prefix in a list? */ + function startsWithAny(pathname: string, prefixes: string[]) { + return prefixes.some((p) => pathname.startsWith(p)); + } - // ── 1) Legacy → Portal / Canonical mapping (runs before auth gating) - const mapped = legacyMap(pathname); - if (mapped && !isSameUrl(req, mapped)) { - // Build destination on the same URL object to keep host/proto - 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); - } + /** 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; + } - // ── 2) Auth gating - const token = req.cookies.get("ma_at")?.value ?? ""; - const isAuthRoute = pathname.startsWith("/auth/"); + export function middleware(req: NextRequest) { + const url = req.nextUrl.clone(); + const { pathname } = url; - // Authed users on any /auth/* route → /portal - if (token && isAuthRoute) { - url.pathname = "/portal"; - url.search = ""; - return NextResponse.redirect(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); + } - // Unauthed users on protected paths → sign-in (no ?next=) - if (!token && !isPublicPath(pathname)) { - url.pathname = "/auth/sign-in"; - url.search = ""; - 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/"); - return NextResponse.next(); -} + // If signed in and visiting /auth/*, send to portal + if (token && isAuthRoute) { + url.pathname = "/portal"; + url.search = ""; + return NextResponse.redirect(url); + } -type MapResult = { pathname: string; query?: Record }; + // 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); + } -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; + return NextResponse.next(); + } - // 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] } })], + type MapResult = { pathname: string; query?: Record }; - // 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] } })], + 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; - // (no lasers/projects detail remap here on purpose) - ]; - for (const [re, to] of detailRules) { - const m = re.exec(pathname); - if (m) return to(m); - } + // 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] } })], - // 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 + [/^\/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); + } - // 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" } }], + // 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 - // 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; - } + // 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" } }], - return null; -} + // 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; + } -function isPublicPath(pathname: string): boolean { - if (PUBLIC_PATHS.has(pathname)) return true; + return null; + } - // Static assets / internals - if ( - pathname.startsWith("/_next/") || - pathname.startsWith("/static/") || - pathname.startsWith("/images/") || - pathname === "/favicon.ico" || - pathname === "/robots.txt" || - pathname === "/sitemap.xml" - ) return true; + function isPublicPath(pathname: string): boolean { + // 1) Public pages (auth screens) + if (PUBLIC_PAGES.has(pathname)) return true; - // API routes aren’t gated here; each route should enforce auth as needed - if (pathname.startsWith("/api/")) 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; + } - // Everything else is protected - return false; -} + // 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); + } -export const config = { - matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|images|static).*)"], -}; + // 4) Everything else is protected + return false; + } + + export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|images|static).*)"], + };