Files
LambdaMail/services/webmail/src/app/api/v1/[...path]/route.ts
T
Lucas RayanandClaude Opus 5 b5baf33a63 fix(bimi): as rotas existiam no protocols e o proxy nao as conhecia
O painel respondia "API endpoint not found" e o logotipo devolvia a pagina de
404 do Next. As rotas estavam registadas no servico protocols, mas o proxy do
webmail decide o upstream por uma lista propria, e eu nao acrescentei a "bimi"
-- exactamente o erro contra o qual o comentario dessa lista avisa.

Ficam ambas cobertas por testes: cada area administrativa que o protocols
possui, e o caminho well-known do logotipo. Esquecer isto nao volta a ser
silencioso.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:41:00 -03:00

125 lines
4.3 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
/**
* Proxies /api/v1/* to the auth service.
*
* The browser cannot reach http://auth:3001 - that name only resolves inside
* the compose network - so every call the pages make used to 404 against the
* Next server. Routing them through here keeps the session cookie first-party
* (same origin, so SameSite=Strict still applies) and keeps the auth service
* off the public network.
*/
const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL ?? "http://auth:3001";
// The mail screens are served by the protocols service, which owns the folder,
// UID, flag and blob logic. Both verify the same session token, so the browser
// sees one origin and one cookie either way.
const PROTOCOLS_SERVICE_URL = process.env.PROTOCOLS_SERVICE_URL ?? "http://protocols:8080";
/**
* Admin areas the protocols service owns rather than the auth service.
*
* Each is here because that process holds something the auth service does not:
* the DKIM vault, the certificate watcher, and the DNS resolver plus the
* expected-record spec.
*
* Adding a route to protocols without adding it here sends the request to the
* auth service, which answers 401 from its admin guard - indistinguishable from
* a session problem, and the reason to keep this list beside the services it
* names.
*/
const PROTOCOLS_ADMIN_AREAS = new Set(["dkim", "tls", "dns", "bimi"]);
function upstreamFor(path: string[]): string {
if (path[0] === "mail") return PROTOCOLS_SERVICE_URL;
if (path[0] === "admin" && PROTOCOLS_ADMIN_AREAS.has(path[1])) return PROTOCOLS_SERVICE_URL;
return AUTH_SERVICE_URL;
}
// Hop-by-hop headers must not be forwarded (RFC 9110 section 7.6.1), and the
// upstream sets its own content-length.
const STRIPPED = new Set([
"host",
"connection",
"keep-alive",
"transfer-encoding",
"upgrade",
"proxy-authorization",
"proxy-authenticate",
"te",
"trailer",
"content-length",
]);
function filterHeaders(source: Headers): Headers {
const out = new Headers();
source.forEach((value, key) => {
if (!STRIPPED.has(key.toLowerCase())) out.append(key, value);
});
return out;
}
async function forward(req: NextRequest, path: string[]): Promise<Response> {
const target = `${upstreamFor(path)}/api/v1/${path.join("/")}${req.nextUrl.search}`;
const headers = filterHeaders(req.headers);
// The client's address is what the auth service records on sessions and
// recovery-code use; without this every login looks like it came from the
// web container.
const forwardedFor = req.headers.get("x-forwarded-for");
if (forwardedFor) headers.set("x-forwarded-for", forwardedFor);
const hasBody = req.method !== "GET" && req.method !== "HEAD";
let upstream: Response;
try {
upstream = await fetch(target, {
method: req.method,
headers,
body: hasBody ? await req.arrayBuffer() : undefined,
redirect: "manual",
cache: "no-store",
});
} catch {
// A dead upstream is a gateway problem, not a 404 - the difference decides
// whether an operator looks at the auth service or at this route.
return NextResponse.json(
{ error: "UPSTREAM_UNAVAILABLE", message: "Upstream service is unreachable" },
{ status: 502 },
);
}
const responseHeaders = new Headers();
upstream.headers.forEach((value, key) => {
if (!STRIPPED.has(key.toLowerCase())) responseHeaders.append(key, value);
});
// Set-Cookie can legitimately appear more than once (logout clears both
// surfaces); getSetCookie preserves them where forEach would fold them into
// one malformed header.
responseHeaders.delete("set-cookie");
for (const cookie of upstream.headers.getSetCookie?.() ?? []) {
responseHeaders.append("set-cookie", cookie);
}
return new NextResponse(upstream.body, {
status: upstream.status,
headers: responseHeaders,
});
}
type Context = { params: Promise<{ path: string[] }> };
async function handler(req: NextRequest, ctx: Context): Promise<Response> {
const { path } = await ctx.params;
return forward(req, path ?? []);
}
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const DELETE = handler;
// Sessions are per-request; nothing here may be cached or prerendered.
export const dynamic = "force-dynamic";