Files
linkwarden/sidecar/upstream.ts
T
pastilhas ed40b6798c a gzipped response was handed on still labelled gzipped
`fetch` decompresses a response body before anyone sees it, and the headers it
returns still say `content-encoding: gzip`. Copying those onto the decoded bytes
tells the client to gunzip plain JSON: `Decompression error: ZlibError`, which
surfaced as a 502 from the catch that wraps the forward.

It hides well, and that is the part worth writing down. A 401 body is a few bytes
and is not compressed, so every unauthenticated probe passes. The first request
carrying a real credential is the first one big enough to be gzipped, and the
first one to fail - which reads as "the credential broke it".

Measured on alpha in exactly that order: the extension door answered 401 without
a token and 502 with one.

`content-length` goes too. It describes the compressed length and the body being
handed on is not that length.

Response headers are now a different function from request headers, because they
are a different question and treating them as one is what caused this.
2026-09-05 21:05:06 +01:00

113 lines
4.6 KiB
TypeScript

import { getLinkwardenConfig } from '../db/queries';
// Where Linkwarden is, for the officer-linkwarden sidecar, plus header hygiene.
//
// ── The URL comes from the database, and can change while this is running ──
//
// `db/schema.ts` says why it is not an environment variable. The consequence here is the interesting
// part: a value that would have been constant for the process lifetime is now something the owner can
// change from a settings panel without restarting anything.
//
// ── Why a cache with a TTL rather than a read per request ──
//
// This sits in front of every proxied byte. A Postgres round trip per request would put the database on
// the hot path of a proxy whose whole design rule is to do as little as possible. Ten seconds is chosen
// against what the value actually is — a URL edited by hand, roughly never, and then wanted immediately.
// Imperceptible to somebody who just pressed Save, and free the rest of the time.
//
// The TTL is the WHOLE mechanism. There is no push: `sidecar-registry.ts` carries events from a sidecar
// to the platform, not commands the other way, so the config route has nothing to call. Building that
// channel for a cache hint would be a channel built for the wrong reason.
//
// A failed read keeps serving the last known-good value rather than dropping to null, because "cannot
// reach Postgres" and "the owner never configured this" look identical on screen and are not the same.
const TTL_MS = 10_000;
type Cached = { url: string | null; enabled: boolean; at: number };
let cache: Cached | null = null;
let inflight: Promise<Cached> | null = null;
async function read(): Promise<Cached> {
try {
const config = await getLinkwardenConfig();
cache = { url: config.url, enabled: config.enabled, at: Date.now() };
return cache;
} catch (err) {
console.error(`[linkwarden] could not read config: ${err instanceof Error ? err.message : String(err)}`);
if (cache) return { ...cache, at: Date.now() };
return { url: null, enabled: true, at: Date.now() };
}
}
async function current(): Promise<Cached> {
if (cache && Date.now() - cache.at < TTL_MS) return cache;
// One read at a time. Without this, every request under load misses the same expired entry and fires
// its own query — the stampede that makes a cache slower than no cache.
inflight ??= read().finally(() => {
inflight = null;
});
return inflight;
}
/** The base URL to forward to, or null when unconfigured or switched off. No trailing slash. */
export async function getLinkwardenBase(): Promise<string | null> {
const { url, enabled } = await current();
return enabled ? url : null;
}
const HOP_BY_HOP = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
'host',
]);
/** Copy headers, dropping only hop-by-hop and `proxy-*` ones. Authorization and cookies pass through. */
export function stripHopByHop(src: Headers): Headers {
const out = new Headers();
src.forEach((value, key) => {
const k = key.toLowerCase();
if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return;
out.set(key, value);
});
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.
Linkwarden puts an access token in a query string in more than one place, and a NextAuth callback
carries a csrf token the same way. None of it belongs in a log that gets pasted into a chat.
*/
const REDACT = /([?&](access_token|token|csrfToken|code)=)[^&]*/gi;
export const redactPath = (pathAndQuery: string): string => pathAndQuery.replace(REDACT, '$1<redacted>');