import { getKarakeepConfig } from '../db/queries'; // Where Karakeep is, for the officer-karakeep 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 | null = null; async function read(): Promise { try { const config = await getKarakeepConfig(); cache = { url: config.url, enabled: config.enabled, at: Date.now() }; return cache; } catch (err) { console.error(`[karakeep] 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 { 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 getKarakeepBase(): Promise { 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'); /* ── `Set-Cookie` is the one header that legitimately repeats, and `.set()` keeps only the last ── `stripHopByHop` copies with `out.set(key, value)`, which OVERWRITES. Fine for every other header and wrong for this one: a response setting three cookies arrives as one. Karakeep authenticates with an API key and should never need this — which is exactly why it is here. The sister plugin built against Linkwarden lost a csrf cookie this way and produced a sign-in that looped with nothing in any log to explain it. A proxy that is correct only for the traffic its author happened to test is a trap for whoever changes the upstream later. `getSetCookie()` returns the values unjoined, which matters because a cookie's `Expires` contains a comma and naive comma-splitting corrupts it. */ out.delete('set-cookie'); for (const cookie of src.getSetCookie()) out.append('set-cookie', cookie); return out; } /* Paths are logged; tokens are not. Karakeep 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');