diff --git a/api/router.ts b/api/router.ts index 3c8ea08..6d25c11 100644 --- a/api/router.ts +++ b/api/router.ts @@ -1,7 +1,7 @@ import { createRouter } from '@@/create-router'; import { createSidecarProxy } from '@@/sidecar/create-proxy'; import { getLinkwardenConfig, setLinkwardenConfig, getAccount } from '../db/queries'; -import { stripHopByHop } from '../sidecar/upstream'; +import { stripHopByHop, stripResponseHeaders } from '../sidecar/upstream'; /** * Mounted at `/api/linkwarden`. @@ -73,7 +73,7 @@ router.all('/lw/*', async (ctx) => { try { const upstream = await fetch(`${base}${subpath}${url.search}`, init); - return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) }); + return new Response(upstream.body, { status: upstream.status, headers: stripResponseHeaders(upstream.headers) }); } catch { return ctx.text('Linkwarden unreachable', 502); } @@ -174,7 +174,7 @@ router.all('/api/*', async (ctx) => { try { const upstream = await fetch(`${base}${subpath}${url.search}`, init); - return new Response(upstream.body, { status: upstream.status, headers: upstream.headers }); + return new Response(upstream.body, { status: upstream.status, headers: stripResponseHeaders(upstream.headers) }); } catch { return ctx.json({ error: 'Linkwarden unreachable' }, 502); } diff --git a/sidecar/index.ts b/sidecar/index.ts index 358ff00..3811bad 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -1,6 +1,6 @@ import { createSidecarConnector } from '@@/sidecar/connect'; import { API_URL } from '@@/officer-url.mjs'; -import { getLinkwardenBase, stripHopByHop, redactPath } from './upstream'; +import { getLinkwardenBase, stripHopByHop, stripResponseHeaders, redactPath } from './upstream'; // The officer-linkwarden sidecar: a singleton process that registers with the API server and owns a // transparent reverse-proxy to a self-hosted Linkwarden. The platform API is a thin forwarder to this; @@ -86,7 +86,7 @@ Bun.serve({ return new Response(upstream.body, { status: upstream.status, - headers: stripHopByHop(upstream.headers), + headers: stripResponseHeaders(upstream.headers), }); }, }); diff --git a/sidecar/upstream.ts b/sidecar/upstream.ts index 4133f9a..e26d597 100644 --- a/sidecar/upstream.ts +++ b/sidecar/upstream.ts @@ -79,6 +79,29 @@ export function stripHopByHop(src: Headers): Headers { return out; } +/** + * Headers for a response we are handing back, which is NOT the same as headers for a request. + * + * ── `content-encoding` has to go, and finding out why cost a 502 ── + * + * `fetch` decompresses a gzipped response body before anyone sees it. The headers it returns still say + * `content-encoding: gzip`, so copying them onto the decoded bytes tells the client to gunzip plain + * JSON — `Decompression error: ZlibError`, surfacing as a 502 from the catch below. + * + * It hides well. A 401 body is a few bytes and is not compressed, so every unauthenticated probe passes + * and the first REAL request is the one that fails. Measured exactly that way on alpha: the door + * answered 401 correctly without a token and 502 with one. + * + * `content-length` goes with it for the same reason — it describes the compressed length, and the body + * being handed on is not that length. + */ +export function stripResponseHeaders(src: Headers): Headers { + const out = stripHopByHop(src); + out.delete('content-encoding'); + out.delete('content-length'); + return out; +} + /* Paths are logged; tokens are not.