Files
PolyWeather/frontend/middleware.ts
T
2026-04-24 03:01:53 +08:00

197 lines
5.4 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import {
createSupabaseMiddlewareClient,
hasSupabaseServerEnv,
} from "@/lib/supabase/server";
const SESSION_COOKIE = "polyweather_entitlement";
function readEnvBool(name: string, fallback: boolean) {
const raw = process.env[name];
if (raw == null) return fallback;
return String(raw).trim().toLowerCase() === "true";
}
const SUPABASE_AUTH_ENABLED =
readEnvBool("POLYWEATHER_AUTH_ENABLED", false);
const SUPABASE_AUTH_REQUIRED = readEnvBool(
"POLYWEATHER_AUTH_REQUIRED",
SUPABASE_AUTH_ENABLED,
);
function isStaticAsset(pathname: string) {
return (
pathname.startsWith("/_next/") ||
pathname.startsWith("/favicon") ||
pathname === "/apple-touch-icon.png" ||
pathname === "/manifest.webmanifest" ||
pathname === "/site.webmanifest" ||
pathname.startsWith("/android-chrome-") ||
pathname.startsWith("/robots.txt") ||
pathname.startsWith("/sitemap.xml") ||
pathname.startsWith("/icons/") ||
pathname.startsWith("/images/") ||
pathname.startsWith("/scenery/") ||
pathname.startsWith("/static/")
);
}
function isPublicPage(pathname: string) {
return (
pathname === "/" ||
pathname.startsWith("/docs") ||
pathname.startsWith("/subscription-help") ||
pathname === "/entitlement-required" ||
pathname.startsWith("/auth/login") ||
pathname.startsWith("/auth/callback")
);
}
function isPublicApi(pathname: string) {
return (
pathname === "/api/auth/me" ||
pathname === "/api/analytics/events" ||
pathname === "/api/cities" ||
pathname === "/api/scan/terminal" ||
pathname === "/api/vitals" ||
/^\/api\/city\/[^/]+$/i.test(pathname) ||
/^\/api\/city\/[^/]+\/summary$/i.test(pathname) ||
/^\/api\/city\/[^/]+\/market-scan$/i.test(pathname)
);
}
function shouldRefreshOptionalSupabaseSession(pathname: string) {
return (
pathname.startsWith("/account") ||
pathname.startsWith("/ops") ||
pathname.startsWith("/api/ops/") ||
pathname.startsWith("/api/payments/") ||
pathname === "/api/system/status"
);
}
function handleLegacyTokenGate(request: NextRequest) {
const requiredToken = process.env.POLYWEATHER_DASHBOARD_ACCESS_TOKEN?.trim();
if (!requiredToken) {
return NextResponse.next();
}
const { pathname, searchParams } = request.nextUrl;
if (isStaticAsset(pathname) || isPublicPage(pathname) || isPublicApi(pathname)) {
return NextResponse.next();
}
const cookieToken = request.cookies.get(SESSION_COOKIE)?.value;
if (cookieToken && cookieToken === requiredToken) {
return NextResponse.next();
}
const queryToken = searchParams.get("access_token");
if (queryToken && queryToken === requiredToken) {
const cleanUrl = request.nextUrl.clone();
cleanUrl.searchParams.delete("access_token");
const response = NextResponse.redirect(cleanUrl);
response.cookies.set(SESSION_COOKIE, requiredToken, {
httpOnly: true,
sameSite: "lax",
secure: cleanUrl.protocol === "https:",
path: "/",
maxAge: 60 * 60 * 12,
});
return response;
}
if (pathname.startsWith("/api/")) {
return NextResponse.json(
{ error: "Unauthorized", detail: "Entitlement token required" },
{ status: 401 },
);
}
const deniedUrl = request.nextUrl.clone();
deniedUrl.pathname = "/entitlement-required";
deniedUrl.search = "";
deniedUrl.searchParams.set("next", pathname);
return NextResponse.redirect(deniedUrl);
}
async function handleSupabaseAuthGate(request: NextRequest) {
const { pathname } = request.nextUrl;
if (isPublicPage(pathname) || isPublicApi(pathname)) {
return NextResponse.next();
}
if (pathname.startsWith("/api/")) {
const authHeader = String(request.headers.get("authorization") || "").trim();
if (/^bearer\s+\S+/i.test(authHeader)) {
return NextResponse.next();
}
}
const response = NextResponse.next({
request: {
headers: request.headers,
},
});
const supabase = createSupabaseMiddlewareClient(request, response);
const {
data: { user },
} = await supabase.auth.getUser();
if (user) {
return response;
}
if (pathname.startsWith("/api/")) {
return NextResponse.json(
{ error: "Unauthorized", detail: "Supabase session required" },
{ status: 401 },
);
}
const loginUrl = request.nextUrl.clone();
loginUrl.pathname = "/auth/login";
loginUrl.search = "";
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
async function handleSupabaseOptionalSession(request: NextRequest) {
const { pathname } = request.nextUrl;
if (
isPublicPage(pathname) ||
isPublicApi(pathname) ||
!shouldRefreshOptionalSupabaseSession(pathname)
) {
return NextResponse.next();
}
const response = NextResponse.next({
request: {
headers: request.headers,
},
});
const supabase = createSupabaseMiddlewareClient(request, response);
await supabase.auth.getUser();
return response;
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (isStaticAsset(pathname)) {
return NextResponse.next();
}
if (SUPABASE_AUTH_ENABLED && hasSupabaseServerEnv()) {
if (SUPABASE_AUTH_REQUIRED) {
return handleSupabaseAuthGate(request);
}
return handleSupabaseOptionalSession(request);
}
return handleLegacyTokenGate(request);
}
export const config = {
matcher: ["/((?!_next/static|_next/image).*)"],
};