File size: 4,731 Bytes
b81a86b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// 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)$).*)",
  ],
};