agriflow-api / dashboard /proxy.ts
masterAAA123's picture
Space deploy v5: orphan snapshot of main, zero binary files
b81a86b
Raw
History Blame Contribute Delete
4.73 kB
// Server-side route protection — login-first.
//
// In Next.js 16 this file is `proxy.ts` (renamed from `middleware.ts` in 16)
// and the exported function must be named `proxy`.
//
// GATE MODEL
// ----------
// Every page requires a signed-in user OR a guest cookie. Unauthenticated
// visitors are funneled to /login. The auth pages themselves (/login,
// /forgot-password, /reset-password) stay reachable while signed out, otherwise
// you could never get in.
//
// The guest cookie is the judge bypass (see app/lib/guest.ts). It is a UX
// funnel, not security: this proxy decides which PAGE renders, while the data
// itself is authorized separately by JWT verification in the FastAPI backend
// (whatsapp_bot/auth.py). A forged cookie gets someone to the map, which shows
// public government data anyway, and no further.
//
// Per the Next.js docs, proxy is for optimistic checks, not authorization —
// which is exactly this split: routing funnel here, real enforcement at the
// data layer.
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
const GUEST_COOKIE = "agriflow_guest"; // keep in sync with app/lib/guest.ts
const DEV_COOKIE = "agriflow_dev"; // keep in sync with app/lib/devauth.ts
// Pages that must stay reachable while signed out.
const PUBLIC_PREFIXES = ["/login", "/forgot-password", "/reset-password"];
export async function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
const isPublic = PUBLIC_PREFIXES.some((p) => path.startsWith(p));
const hasGuest = request.cookies.get(GUEST_COOKIE)?.value === "1";
// Dev-login cookie (see app/lib/devauth.ts). Only honoured when dev login is
// explicitly enabled, so production ignores it even if someone sets it by
// hand — the guest cookie is the only intended bypass there. Like guest, it
// is a page-routing bypass only and never satisfies the backend's JWT check.
const hasDev =
process.env.NEXT_PUBLIC_DEV_LOGIN === "true" &&
Boolean(request.cookies.get(DEV_COOKIE)?.value);
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
let response = NextResponse.next({ request });
let user = null;
// Resolve the real session when Supabase is configured. Wrapped in try/catch
// because a misconfigured or unreachable Supabase URL (e.g. placeholder creds
// in local preview) would otherwise throw and 500 every page. On any failure
// we treat the visitor as signed out and let the guest cookie be the way in.
if (url && anonKey) {
try {
const supabase = createServerClient(url, anonKey, {
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
// Write refreshed tokens onto both the request (so any later read
// this pass sees them) and the response (so the browser stores
// them). Skipping the request copy logs the user out one navigation
// after a token refresh.
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value),
);
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options),
);
},
},
});
// getUser() revalidates the token with Supabase rather than trusting the
// cookie contents, and refreshes it near expiry.
user = (await supabase.auth.getUser()).data.user;
} catch {
user = null;
}
}
const authed = Boolean(user) || hasGuest || hasDev;
// A genuinely signed-in user has no reason to see the login page.
// Guests are NOT redirected away from /login, so they can upgrade to a real
// account whenever they want.
if (path.startsWith("/login") && user) {
return NextResponse.redirect(new URL("/", request.url));
}
// Auth pages are always reachable.
if (isPublic) return response;
// Everything else is gated.
if (!authed) {
const redirect = request.nextUrl.clone();
redirect.pathname = "/login";
// Preserve the destination so login can return them there. LoginForm only
// honours relative paths, so this cannot become an open redirect.
redirect.searchParams.set("next", path);
return NextResponse.redirect(redirect);
}
return response;
}
export const config = {
// Skip static assets and image optimization — gating a .svg on a Supabase
// round trip would add one to every asset request.
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};