Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion web/src/app/api/_utils/backend-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
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", () => {
Expand Down Expand Up @@ -249,4 +249,53 @@
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

Check warning on line 268 in web/src/app/api/_utils/backend-proxy.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `global`.

See more on https://sonarcloud.io/project/issues?id=jerry609_PaperBot&issues=AZzwY_cBuzevaEjDs4mL&open=AZzwY_cBuzevaEjDs4mL&pullRequest=410

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")
})
})
33 changes: 33 additions & 0 deletions web/src/app/api/_utils/backend-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -123,6 +127,35 @@ export async function proxyStream(
}
}

export async function proxyBinary(
req: Request,
upstreamUrl: string,
method: ProxyMethod,
options: BinaryProxyOptions = {},
): Promise<Response> {
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,
Expand Down
101 changes: 101 additions & 0 deletions web/src/app/api/_utils/final-proxy-route-contracts.test.ts
Original file line number Diff line number Diff line change
@@ -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"))
Comment on lines +18 to +20

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("<html><body>ok</body></html>", {
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\" <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 &#39;html&#39; &quot;tag&quot; &lt;tag&gt; &amp; stuff",
)
})
})
46 changes: 26 additions & 20 deletions web/src/app/api/newsletter/unsubscribe/[token]/route.ts
Original file line number Diff line number Diff line change
@@ -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, "&amp;")

Check warning on line 7 in web/src/app/api/newsletter/unsubscribe/[token]/route.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=jerry609_PaperBot&issues=AZzwY_aCuzevaEjDs4mH&open=AZzwY_aCuzevaEjDs4mH&pullRequest=410
.replace(/</g, "&lt;")

Check warning on line 8 in web/src/app/api/newsletter/unsubscribe/[token]/route.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=jerry609_PaperBot&issues=AZzwY_aCuzevaEjDs4mI&open=AZzwY_aCuzevaEjDs4mI&pullRequest=410
.replace(/>/g, "&gt;")

Check warning on line 9 in web/src/app/api/newsletter/unsubscribe/[token]/route.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=jerry609_PaperBot&issues=AZzwY_aCuzevaEjDs4mJ&open=AZzwY_aCuzevaEjDs4mJ&pullRequest=410
.replace(/"/g, "&quot;")

Check warning on line 10 in web/src/app/api/newsletter/unsubscribe/[token]/route.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=jerry609_PaperBot&issues=AZzwY_aCuzevaEjDs4mK&open=AZzwY_aCuzevaEjDs4mK&pullRequest=410
.replace(/'/g, "&#39;")

Check warning on line 11 in web/src/app/api/newsletter/unsubscribe/[token]/route.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=jerry609_PaperBot&issues=AZzweMwoa2oXHuy7cKlQ&open=AZzweMwoa2oXHuy7cKlQ&pullRequest=410
}

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(
`<html><body><h2>Error</h2><p>${escapeHtml(detail)}</p></body></html>`,
{ status: 502, headers: { "Content-Type": "text/html" } },
)
},
})
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
const escaped = detail.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
return new Response(
`<html><body><h2>Error</h2><p>${escaped}</p></body></html>`,
{ status: 502, headers: { "Content-Type": "text/html" } },
)
}
},
)
}
67 changes: 0 additions & 67 deletions web/src/app/api/papers/export/route.test.ts

This file was deleted.

36 changes: 13 additions & 23 deletions web/src/app/api/papers/export/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
),
})
}
Loading