diff --git a/web/src/app/api/_utils/backend-proxy.test.ts b/web/src/app/api/_utils/backend-proxy.test.ts index fc695081..8cf1f0b2 100644 --- a/web/src/app/api/_utils/backend-proxy.test.ts +++ b/web/src/app/api/_utils/backend-proxy.test.ts @@ -10,7 +10,7 @@ vi.mock("./auth-headers", () => ({ withBackendAuth: withBackendAuthMock, })) -import { apiBaseUrl, proxyJson, proxyStream, proxyText } from "./backend-proxy" +import { apiBaseUrl, proxyBinary, proxyJson, proxyStream, proxyText } from "./backend-proxy" describe("apiBaseUrl", () => { it("uses the shared backend base URL helper", () => { @@ -249,4 +249,53 @@ describe("backend proxy helpers", () => { expect(res.headers.get("content-type")).toContain("application/json") await expect(res.json()).resolves.toEqual({ done: true }) }) + + it("preserves binary download headers and auth for protected exports", async () => { + withBackendAuthMock.mockResolvedValue({ + Accept: "*/*", + authorization: "Bearer export-token", + }) + + const fetchMock = vi.fn(async () => + new Response("bibtex-body", { + status: 200, + headers: { + "content-type": "application/x-bibtex", + "content-disposition": "attachment; filename=papers.bib", + }, + }), + ) + global.fetch = fetchMock as typeof fetch + + const req = new Request("https://localhost/api/papers/export?format=bibtex", { + method: "GET", + }) + const res = await proxyBinary( + req, + "https://backend/api/research/papers/export?format=bibtex", + "GET", + { + accept: "*/*", + auth: true, + }, + ) + + expect(withBackendAuthMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + "https://backend/api/research/papers/export?format=bibtex", + { + method: "GET", + headers: { + Accept: "*/*", + authorization: "Bearer export-token", + }, + body: undefined, + signal: expect.any(AbortSignal), + }, + ) + expect(res.status).toBe(200) + expect(await res.text()).toBe("bibtex-body") + expect(res.headers.get("content-type")).toContain("application/x-bibtex") + expect(res.headers.get("content-disposition")).toContain("papers.bib") + }) }) diff --git a/web/src/app/api/_utils/backend-proxy.ts b/web/src/app/api/_utils/backend-proxy.ts index 4e766ff6..145d5a70 100644 --- a/web/src/app/api/_utils/backend-proxy.ts +++ b/web/src/app/api/_utils/backend-proxy.ts @@ -30,6 +30,10 @@ type StreamProxyOptions = ProxyOptions & { responseContentType?: string } +type BinaryProxyOptions = ProxyOptions & { + responseHeaders?: HeadersInit +} + const DEFAULT_TIMEOUT_MS = 120_000 const SSE_DISPATCHER = new Agent({ bodyTimeout: 0, @@ -123,6 +127,35 @@ export async function proxyStream( } } +export async function proxyBinary( + req: Request, + upstreamUrl: string, + method: ProxyMethod, + options: BinaryProxyOptions = {}, +): Promise { + try { + const upstream = await fetchUpstream(req, upstreamUrl, method, options) + const headers = new Headers(options.responseHeaders) + headers.set("Cache-Control", "no-cache") + headers.set( + "Content-Type", + upstream.headers.get("content-type") || "application/octet-stream", + ) + + const contentDisposition = upstream.headers.get("content-disposition") + if (contentDisposition) { + headers.set("Content-Disposition", contentDisposition) + } + + return new Response(upstream.body, { + status: upstream.status, + headers, + }) + } catch (error) { + return handleProxyError(error, upstreamUrl, options.onError) + } +} + async function fetchUpstream( req: Request, upstreamUrl: string, diff --git a/web/src/app/api/_utils/final-proxy-route-contracts.test.ts b/web/src/app/api/_utils/final-proxy-route-contracts.test.ts new file mode 100644 index 00000000..399a5525 --- /dev/null +++ b/web/src/app/api/_utils/final-proxy-route-contracts.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest" + +const { apiBaseUrlMock, proxyBinaryMock, proxyTextMock } = vi.hoisted(() => ({ + apiBaseUrlMock: vi.fn(() => "http://backend.example.com"), + proxyBinaryMock: vi.fn(), + proxyTextMock: vi.fn(), +})) + +vi.mock("@/app/api/_utils/backend-proxy", () => ({ + apiBaseUrl: apiBaseUrlMock, + proxyBinary: proxyBinaryMock, + proxyText: proxyTextMock, +})) + +import { GET as newsletterUnsubscribeGet } from "@/app/api/newsletter/unsubscribe/[token]/route" +import { GET as papersExportGet } from "@/app/api/papers/export/route" + +describe("final proxy route contracts", () => { + it("proxies protected exports through the shared binary helper", async () => { + proxyBinaryMock.mockResolvedValueOnce(new Response("bibtex-body")) + + const res = await papersExportGet( + new Request("http://localhost/api/papers/export?format=bibtex", { + method: "GET", + }), + ) + + expect(proxyBinaryMock).toHaveBeenCalledWith( + expect.any(Request), + "http://backend.example.com/api/research/papers/export?format=bibtex", + "GET", + { + accept: "*/*", + auth: true, + onError: expect.any(Function), + }, + ) + expect(res).toBeInstanceOf(Response) + expect(await res.text()).toBe("bibtex-body") + + const binaryCalls = vi.mocked(proxyBinaryMock).mock.calls as unknown[][] + const binaryOptions = binaryCalls[0]?.[3] as + | { onError?: (context: { error: unknown; isTimeout: boolean; upstreamUrl: string }) => Response } + | undefined + const fallback = binaryOptions?.onError?.({ + error: new Error("offline"), + isTimeout: false, + upstreamUrl: "http://backend.example.com/api/research/papers/export?format=bibtex", + }) + + expect(fallback).toBeInstanceOf(Response) + expect(fallback?.status).toBe(502) + await expect(fallback?.json()).resolves.toEqual({ + detail: "Upstream API unreachable", + error: "offline", + }) + }) + + it("proxies newsletter unsubscribe pages through the shared text helper", async () => { + const req = new Request("http://localhost/api/newsletter/unsubscribe/token") + proxyTextMock.mockResolvedValueOnce( + new Response("ok", { + headers: { "Content-Type": "text/html" }, + }), + ) + + const res = await newsletterUnsubscribeGet(req, { + params: Promise.resolve({ token: "token\"&<>'" }), + }) + + expect(proxyTextMock).toHaveBeenCalledWith( + req, + "http://backend.example.com/api/newsletter/unsubscribe/token%22%26%3C%3E'", + "GET", + expect.objectContaining({ + accept: "text/html", + responseContentType: "text/html", + onError: expect.any(Function), + }), + ) + expect(res).toBeInstanceOf(Response) + expect(await res.text()).toContain("ok") + + const textCalls = vi.mocked(proxyTextMock).mock.calls as unknown[][] + const textOptions = textCalls[0]?.[3] as + | { onError?: (context: { error: unknown; isTimeout: boolean; upstreamUrl: string }) => Response } + | undefined + const fallback = textOptions?.onError?.({ + error: new Error("bad 'html' \"tag\" & stuff"), + isTimeout: false, + upstreamUrl: "http://backend.example.com/api/newsletter/unsubscribe/token", + }) + + expect(fallback).toBeInstanceOf(Response) + expect(fallback?.status).toBe(502) + expect(fallback?.headers.get("content-type")).toContain("text/html") + await expect(fallback?.text()).resolves.toContain( + "bad 'html' "tag" <tag> & stuff", + ) + }) +}) diff --git a/web/src/app/api/newsletter/unsubscribe/[token]/route.ts b/web/src/app/api/newsletter/unsubscribe/[token]/route.ts index c644c2b3..253e2324 100644 --- a/web/src/app/api/newsletter/unsubscribe/[token]/route.ts +++ b/web/src/app/api/newsletter/unsubscribe/[token]/route.ts @@ -1,29 +1,35 @@ export const runtime = "nodejs" -import { apiBaseUrl } from "../../../research/_base" +import { apiBaseUrl, proxyText } from "@/app/api/_utils/backend-proxy" + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} export async function GET( - _req: Request, + req: Request, { params }: { params: Promise<{ token: string }> }, ) { const { token } = await params - try { - const upstream = await fetch( - `${apiBaseUrl()}/api/newsletter/unsubscribe/${encodeURIComponent(token)}`, - ) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "text/html", + return proxyText( + req, + `${apiBaseUrl()}/api/newsletter/unsubscribe/${encodeURIComponent(token)}`, + "GET", + { + accept: "text/html", + responseContentType: "text/html", + onError: ({ error }) => { + const detail = error instanceof Error ? error.message : String(error) + return new Response( + `

Error

${escapeHtml(detail)}

`, + { status: 502, headers: { "Content-Type": "text/html" } }, + ) }, - }) - } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - const escaped = detail.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """) - return new Response( - `

Error

${escaped}

`, - { status: 502, headers: { "Content-Type": "text/html" } }, - ) - } + }, + ) } diff --git a/web/src/app/api/papers/export/route.test.ts b/web/src/app/api/papers/export/route.test.ts deleted file mode 100644 index 6363450b..00000000 --- a/web/src/app/api/papers/export/route.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" - -const { withBackendAuthMock } = vi.hoisted(() => ({ - withBackendAuthMock: vi.fn(), -})) - -vi.mock("@/app/api/_utils/auth-headers", () => ({ - withBackendAuth: withBackendAuthMock, -})) - -vi.mock("@/app/api/research/_base", () => ({ - apiBaseUrl: () => "http://backend.example.com", -})) - -import { GET } from "./route" - -describe("papers export proxy", () => { - const originalFetch = global.fetch - - beforeEach(() => { - vi.resetAllMocks() - }) - - afterEach(() => { - global.fetch = originalFetch - }) - - it("forwards backend auth headers to the protected export endpoint", async () => { - withBackendAuthMock.mockResolvedValue({ - Accept: "*/*", - authorization: "Bearer export-token", - }) - - const fetchMock = vi.fn(async () => - new Response("bibtex-body", { - status: 200, - headers: { - "content-type": "application/x-bibtex", - "content-disposition": "attachment; filename=papers.bib", - }, - }), - ) - global.fetch = fetchMock as typeof fetch - - const res = await GET( - new Request("http://localhost/api/papers/export?format=bibtex", { - method: "GET", - }), - ) - - expect(withBackendAuthMock).toHaveBeenCalledTimes(1) - expect(fetchMock).toHaveBeenCalledWith( - "http://backend.example.com/api/research/papers/export?format=bibtex", - { - method: "GET", - headers: { - Accept: "*/*", - authorization: "Bearer export-token", - }, - }, - ) - expect(res.status).toBe(200) - expect(await res.text()).toBe("bibtex-body") - expect(res.headers.get("Content-Type")).toBe("application/x-bibtex") - expect(res.headers.get("Content-Disposition")).toBe("attachment; filename=papers.bib") - }) -}) diff --git a/web/src/app/api/papers/export/route.ts b/web/src/app/api/papers/export/route.ts index f20e3831..0cf65fee 100644 --- a/web/src/app/api/papers/export/route.ts +++ b/web/src/app/api/papers/export/route.ts @@ -1,31 +1,21 @@ export const runtime = "nodejs" -import { withBackendAuth } from "@/app/api/_utils/auth-headers" -import { apiBaseUrl } from "@/app/api/research/_base" +import { apiBaseUrl, proxyBinary } from "@/app/api/_utils/backend-proxy" export async function GET(req: Request) { const url = new URL(req.url) const upstream = `${apiBaseUrl()}/api/research/papers/export?${url.searchParams.toString()}` - try { - const res = await fetch(upstream, { - method: "GET", - headers: await withBackendAuth(req, { Accept: "*/*" }), - }) - const body = await res.arrayBuffer() - return new Response(body, { - status: res.status, - headers: { - "Content-Type": res.headers.get("content-type") || "application/octet-stream", - "Content-Disposition": res.headers.get("content-disposition") || "", - "Cache-Control": "no-cache", - }, - }) - } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - return Response.json( - { detail: `Upstream API unreachable`, error: detail }, - { status: 502 }, - ) - } + return proxyBinary(req, upstream, "GET", { + accept: "*/*", + auth: true, + onError: ({ error }) => + Response.json( + { + detail: "Upstream API unreachable", + error: error instanceof Error ? error.message : String(error), + }, + { status: 502 }, + ), + }) }