import { NextRequest } from "next/server"; /** * The same-origin API proxy: everything the browser sends to `/api/v1/...` is streamed * through to the API container and its response streamed back. * * This is what makes a prebuilt web image work on any domain with no rebuild. The * browser only ever talks to the origin that served the page (`lib/api-base.ts`), so * there is no cross-site request to configure CORS for, no cross-site cookie to lose to * `SameSite=Lax`, and no API address baked into the client bundle. The one address that * does have to be known - the API container's - is read from `process.env` *per request* * here, which is why this is a route handler rather than a `rewrites()` entry: rewrites * are serialized into the build output and a prebuilt image would carry whatever the * builder happened to have set. * * See `DECISIONS.md`, "The web app proxies `/api/v1` to the API, and that is the * shipped topology". */ const DEFAULT_INTERNAL_URL = "http://api:8000"; const DEFAULT_DEV_INTERNAL_URL = "http://localhost:8000"; // Per RFC 9110 these describe a single transport connection, so forwarding them onto the // next one is meaningless at best and a framing bug at worst - `transfer-encoding` in // particular, since the hop this proxy opens does its own chunking. `trailer` is the // spelling in the RFC; `trailers` is the one the older RFC 2616 list used and is still // sent by some clients. const HOP_BY_HOP_HEADERS = new Set([ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "trailers", "transfer-encoding", "upgrade", ]); // Dropped from the *request* on top of the hop-by-hop set. const REQUEST_ONLY_DROPS = new Set([ // Names the origin the browser asked for, which is not the one being dialled. Left in // place it would reach the API as its `Host`, and any absolute URL the API builds from // it - or any host-based routing in front of it - would be wrong. "host", // `fetch` recomputes framing for the hop it opens. A forwarded length is either // redundant or, once the body is a stream, a lie. "content-length", // Never forwarded verbatim: `fetch` negotiates its own encoding for the hop it opens // and decodes the reply transparently, so the browser's list is the wrong one to send - // advertising a codec `fetch` cannot decode would leave a compressed body paired with a // `content-encoding` this proxy then has to strip (see `downstreamHeaders`), which is // corruption rather than a wrong header. `fetch` substitutes its own `accept-encoding`. "accept-encoding", // A `100-continue` negotiation with *this* server, which Node has already answered by // the time a handler runs. Forwarded, it reaches `fetch` as an unsupported header and // the whole request fails with `UND_ERR_NOT_SUPPORTED` - a 502 for every large upload // from curl or any other client that asks first. Browsers never send it, which is // exactly why this would have gone unnoticed. "expect", ]); /** * The API container's address, read at request time. * * `API_INTERNAL_URL` is an *origin* - `http://api:8000`, no `/api/v1` - because the * request's own path is appended to it unchanged. The default is the compose service * name, so the shipped bundle needs no configuration at all; under `next dev` it is * `localhost:8000`, where the API's own compose file publishes it. */ export function resolveApiInternalUrl( env: { API_INTERNAL_URL?: string; NODE_ENV?: string } = process.env, ): string { const configured = env.API_INTERNAL_URL?.trim(); if (configured) return configured.replace(/\/+$/, ""); return env.NODE_ENV === "production" ? DEFAULT_INTERNAL_URL : DEFAULT_DEV_INTERNAL_URL; } // Everything named in `Connection` is hop-by-hop too, by definition - that header is how // a peer declares its own additions to the list. function connectionScopedHeaders(headers: Headers): Set { const declared = headers.get("connection"); if (!declared) return new Set(); return new Set( declared .split(",") .map((name) => name.trim().toLowerCase()) .filter(Boolean), ); } function upstreamHeaders(request: NextRequest): Headers { const perConnection = connectionScopedHeaders(request.headers); const headers = new Headers(); for (const [name, value] of request.headers) { if (HOP_BY_HOP_HEADERS.has(name)) continue; if (REQUEST_ONLY_DROPS.has(name)) continue; if (perConnection.has(name)) continue; headers.set(name, value); } // The forwarding chain the API's `client_ip()` walks to key its ten per-IP rate limits. // It arrives already assembled: Next fills `x-forwarded-for` from the socket peer and // `x-forwarded-proto` from the connection when they are absent, and leaves whatever a // proxy in front set when they are not - so the loop above has copied both across, and // that is the whole job. It is not optional. Drop them and every caller reaches the API // as this one container, collapsing all ten buckets into one, which is exactly the // failure the API's `TRUSTED_PROXY_IPS` exists to prevent. That variable is still what // decides whether the API believes the chain, and it has to name *this* container (the // API's peer), not the operator's own proxy, which the API never talks to directly - // but only where something that *appends* `X-Forwarded-For` really fronts this // container. Nothing is appended here, so on a directly exposed one the caller supplies // the whole chain and naming this container is what makes all ten buckets forgeable; // omitting it costs one shared bucket, which is the safe direction there. // `DECISIONS.md` and `.env.example` carry the long version. // // `x-forwarded-for` has no fallback here because there is nothing to fall back to: a // route handler cannot see the socket peer (`NextRequest.ip` was removed in Next 15), // so Next's own normalization is the only source for it. Proto and host can be // reconstructed from the request URL, which is what the two lines below do for a caller // that reached this function without them - a unit test today. if (!headers.has("x-forwarded-proto")) { headers.set("x-forwarded-proto", request.nextUrl.protocol.replace(":", "")); } if (!headers.has("x-forwarded-host")) { headers.set("x-forwarded-host", request.nextUrl.host); } return headers; } // Starlette builds a redirect's `Location` from the `Host` it was dialled on, which is // the internal address: passed through, it would send the browser somewhere it cannot // reach and tell it the private API host on the way. One that names that origin is cut // to its path, which the browser resolves against this one; any other is the API's to // mean and goes through untouched. Resolved against `target` so a relative or // scheme-relative value is judged by where it really points. function browserFacingLocation(location: string, target: string): string { let resolved: URL; try { resolved = new URL(location, target); } catch { return location; } if (resolved.origin !== new URL(target).origin) return location; return `${resolved.pathname}${resolved.search}${resolved.hash}`; } function downstreamHeaders(upstream: Response, target: string): Headers { const perConnection = connectionScopedHeaders(upstream.headers); // `fetch` decodes a compressed body transparently but leaves the header describing it // in place, so forwarding the pair would tell the browser to decode plain bytes - and // `content-length` with it, being the compressed length of a body that no longer is. // This is the common path, not a defensive one: `fetch` substitutes its own // `accept-encoding: gzip, deflate` for the one dropped above, so any API that // compresses at all comes back through here. const decoded = upstream.headers.has("content-encoding"); const headers = new Headers(); for (const [name, value] of upstream.headers) { if (HOP_BY_HOP_HEADERS.has(name)) continue; if (perConnection.has(name)) continue; if (decoded && (name === "content-encoding" || name === "content-length")) continue; // Merged into one comma-joined value by this loop, which is wrong for cookies - // `Expires` contains a comma. Re-added individually below. if (name === "set-cookie") continue; headers.set( name, name === "location" ? browserFacingLocation(value, target) : value, ); } // The refresh-token cookie is the reason this proxy has to exist as more than a URL // rewrite: it is httpOnly, and it has to reach the browser as if the API had set it. for (const cookie of upstream.headers.getSetCookie()) { headers.append("set-cookie", cookie); } return headers; } /** * Streams one request through to the API and its response back. * * Nothing is buffered in either direction: every upload, a logbook archive included, * goes up as a stream and the export archive comes down as one. That holds because * `src/proxy.ts`'s matcher excludes `api/v1/` - a request that reaches middleware has its * body cloned into memory first, capped at Next's 10 MB default, and silently truncated * past it. * * Exported for `route.ts`, which binds it to every method. Route files may only export * handlers and segment config, so the wiring lives there and the logic lives here. */ export async function proxyToApi(request: NextRequest): Promise { // The raw URL rather than the resolved `params`: catch-all params arrive // percent-decoded, so re-joining them would mangle any segment containing an encoded // slash or space on the way back out. const { pathname, search } = new URL(request.url); const base = resolveApiInternalUrl(); const target = `${base}${pathname}${search}`; // GET and HEAD carry no body; anything else streams whatever the browser sent. const body = request.method === "GET" || request.method === "HEAD" ? null : request.body; let upstream: Response; try { upstream = await fetch(target, { method: request.method, headers: upstreamHeaders(request), body, // `fetch` refuses a stream body without it. Not in the DOM lib's `RequestInit`, // which is what the cast below is for. ...(body ? { duplex: "half" } : {}), // A redirect is the API's answer to the browser, not an instruction to this proxy. redirect: "manual", // Next patches `fetch` with its own caching layer; an API proxy must never serve a // second caller from the first one's response. cache: "no-store", // A closed browser connection cancels the upstream request rather than leaving it // to run to completion against the API. signal: request.signal, } as RequestInit); } catch (error) { // The API being down, unresolvable, or still starting. Shaped like FastAPI's own // error body so `getApiErrorMessage` finds a message rather than its generic // fallback, and logged with the route so the cause is one line away. // // The path only, never the query string: `?token=...` on the two magic-link precheck // routes (`auth/email/verify/check`, `user/email-change/verify/check`) is a live, // single-use sign-in credential, and this branch is exactly the case where the check // never reached the API - so the token is still valid when it lands in the log. The // API refuses to log those for the same reason. console.error( `[api-proxy] ${request.method} ${base}${pathname} failed:`, error, ); return Response.json( { detail: "The API is unreachable. Check that it is running." }, { status: 502 }, ); } // A body is forbidden on these, and constructing a `Response` with one throws. const bodyless = request.method === "HEAD" || upstream.status === 204 || upstream.status === 205 || upstream.status === 304; return new Response(bodyless ? null : upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: downstreamHeaders(upstream, target), }); }