`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.
113 lines
4.6 KiB
TypeScript
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>');
|