makearmy-app/middleware.ts

272 lines
12 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>([
"/", // ← 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<string, string> };
/** 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=<original>.
* 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 were already inside the portal, dont 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).*)"],
};