* docs: brainstorm resilience/adoption features and plan follow-up work
Brainstorm and deep-dive on where Immich Folio breaks under Immich
outages and what blocks self-hosted adoption, plus an implementation
plan for the remaining gaps after v0.9.2 (route-handler tests,
stale-while-error caching, loading skeletons).
* test: enable route-handler tests and cover admin guards and image content-type
vitest.config.ts only ran lib/__tests__/**, so route handlers were
structurally untestable. Two mechanisms live only at the route level and
cannot be extracted the way lib/imageSize.ts was: the admin auth guards
and the image proxy's Content-Type sanitisation (the stored-XSS fix from
c2fa8e7). Both were correct but unverified by any test.
Adds a table-driven test over all ten guarded /api/admin/* handlers,
including a filesystem walk that fails if a future route is added
without a row in the table. Adds a route-level test for
/api/image/[id] covering token validation, the six Content-Type
rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError)
distinction.
Verified both catch real regressions by temporarily removing the admin
guards and the Content-Type rewrite and confirming the expected tests
fail, then restoring and confirming green again.
134 new tests (244 total, up from 210), tsc 0 errors, build succeeds.
* feat: serve stale cache entries while Immich is unavailable
v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable"
(ImmichUnavailableError), and pages now render a 5xx instead of a false
404 during an outage — but that 5xx still replaced a gallery that had
loaded fine seconds earlier. lib/cache.ts deleted expired entries on
access, so nothing survived long enough to fall back to.
Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL,
after which get() still reports a miss) and hardExpiresAt (the new
STALE_MAX_AGE, default 24h, after which the entry is dropped entirely).
Between the two, getStale() can still return the data.
getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError
and fall back to getStale() before propagating. The two MISSING-sentinel
writes (definitive 404s) are deliberately left on the short cacheTtl
window — a corrected asset ID should take effect after the normal TTL,
not sit in the 24h stale window meant for outages.
STALE_MAX_AGE=0 disables the fallback. Verified the fallback only
engages on a real outage, not on a cache hit at the TTL boundary, and
that a definitive 404 still reports missing rather than falling back.
256 tests (up from 244), tsc 0 errors, build succeeds.
* feat: add loading skeletons for dynamic gallery routes
Every gallery route is force-dynamic and waits on a live Immich
round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling —
with nothing rendered until it resolves. loading.tsx renders instantly
while the page component awaits its data.
Not browser-verified against live data: this worktree has no
.env.local and only .example content files, so the app boots into
setup mode (confirmed the dev server starts cleanly and the setup
screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint
errors.
* docs: document stale cache window, image token revocation, route-test convention
- README: STALE_MAX_AGE env var and a "Behaviour when Immich is
unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404,
outages never cached).
- docs/gallery-config.md: new "Image Token Revocation" subsection under
System & Security — un-publishing an album does not revoke already
issued image URLs; rotating AUTH_SECRET does.
- CLAUDE.md: updated cache description to cover the stale window and
MISSING sentinel, and a new code-convention note that route-level
logic (guards, header sanitisation) should get a route-level test
rather than being extracted into lib/ just to reach the test runner.
---------
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
117 lines
4.8 KiB
TypeScript
117 lines
4.8 KiB
TypeScript
/**
|
||
* Environment variables parser.
|
||
* Parsed once at startup — all env access should go through this module.
|
||
*/
|
||
|
||
export interface Env {
|
||
IMMICH_API_URL: string;
|
||
IMMICH_API_KEY: string;
|
||
SITE_TITLE: string;
|
||
SITE_SUBTITLE: string;
|
||
CACHE_TTL: number;
|
||
STALE_MAX_AGE: number;
|
||
IMAGE_CACHE_VERSION: string;
|
||
IMMICH_TIMEOUT_MS: number;
|
||
RATE_LIMIT_RPM: number;
|
||
AUTH_SECRET?: string;
|
||
TRUSTED_PROXY_HOPS: number;
|
||
WEBHOOK_SECRET?: string;
|
||
ADMIN_PASSWORD?: string;
|
||
}
|
||
|
||
function parseEnv(): Env {
|
||
const errors: string[] = [];
|
||
|
||
const urlRaw = process.env.IMMICH_API_URL;
|
||
let apiUrl = '';
|
||
if (urlRaw) {
|
||
try {
|
||
apiUrl = new URL(urlRaw).toString().replace(/\/+$/, '');
|
||
} catch {
|
||
console.warn('⚠️ IMMICH_API_URL is invalid, falling back to empty string for setup.');
|
||
}
|
||
}
|
||
|
||
const apiKey = process.env.IMMICH_API_KEY || '';
|
||
|
||
const cacheTtlStr = process.env.CACHE_TTL;
|
||
const cacheTtl =
|
||
cacheTtlStr && !isNaN(parseInt(cacheTtlStr, 10)) ? parseInt(cacheTtlStr, 10) : 300;
|
||
|
||
// How long an expired cache entry may still be served while Immich is
|
||
// unreachable. Keeps the public gallery online across an Immich restart
|
||
// instead of showing an error page. 0 disables the fallback.
|
||
const staleMaxAgeStr = process.env.STALE_MAX_AGE;
|
||
const staleMaxAge =
|
||
staleMaxAgeStr && !isNaN(parseInt(staleMaxAgeStr, 10)) ? parseInt(staleMaxAgeStr, 10) : 86400; // 24 hours
|
||
|
||
// How long to wait on Immich before giving up. Without an explicit budget the
|
||
// only ceiling is undici's built-in default (minutes), and since every page is
|
||
// force-dynamic an Immich that accepts connections but never answers would
|
||
// hold each visitor's render open for that entire window.
|
||
//
|
||
// This bounds the wait for a *response*, not the transfer of an image or video
|
||
// body — those are legitimately slow and are not capped by this value.
|
||
const timeoutStr = process.env.IMMICH_TIMEOUT_MS;
|
||
const timeoutMs =
|
||
timeoutStr && !isNaN(parseInt(timeoutStr, 10)) ? parseInt(timeoutStr, 10) : 15000;
|
||
|
||
// Manual cache-buster for image and video URLs. Empty by default, so normal
|
||
// operation is untouched.
|
||
//
|
||
// Image responses are served `immutable` for a year, which means the browser
|
||
// does not revalidate at all — not even the ETag is consulted. That is the
|
||
// right default (these URLs are content-addressed by an encrypted asset ID),
|
||
// but it leaves no way to push a changed rendition: Immich regenerates
|
||
// thumbnails and previews when a job is re-run or a photo is rotated, and the
|
||
// asset ID — hence the token, hence the URL — stays the same. Bumping this
|
||
// value changes every image URL at once and is the only escape hatch short of
|
||
// rotating AUTH_SECRET.
|
||
const imageCacheVersion = (process.env.IMAGE_CACHE_VERSION || '').trim();
|
||
|
||
const rateLimitStr = process.env.RATE_LIMIT_RPM;
|
||
const rateLimit =
|
||
rateLimitStr && !isNaN(parseInt(rateLimitStr, 10)) ? parseInt(rateLimitStr, 10) : 1500;
|
||
|
||
// Number of reverse proxies in front of the app. Determines how far from the
|
||
// right of X-Forwarded-For the real client IP sits. nginx/Traefik/Caddy = 1;
|
||
// add 1 for each additional layer (e.g. Cloudflare in front of nginx = 2).
|
||
const hopsStr = process.env.TRUSTED_PROXY_HOPS;
|
||
let trustedProxyHops = hopsStr && !isNaN(parseInt(hopsStr, 10)) ? parseInt(hopsStr, 10) : 0;
|
||
|
||
// TRUSTED_PROXIES (removed) matched proxy IPs against the socket peer address,
|
||
// which self-hosted Next.js never exposes — so it silently disabled per-client
|
||
// rate limiting and lumped every visitor into one shared bucket. Migrate the
|
||
// common single-proxy case rather than leaving those deploys broken.
|
||
if (trustedProxyHops === 0 && process.env.TRUSTED_PROXIES) {
|
||
trustedProxyHops = 1;
|
||
console.warn(
|
||
'\n⚠️ TRUSTED_PROXIES is deprecated and has no effect. Assuming TRUSTED_PROXY_HOPS=1.\n' +
|
||
' Set TRUSTED_PROXY_HOPS explicitly (nginx/Traefik/Caddy = 1) and remove TRUSTED_PROXIES.\n',
|
||
);
|
||
}
|
||
|
||
if (trustedProxyHops < 0) trustedProxyHops = 0;
|
||
|
||
return {
|
||
IMMICH_API_URL: apiUrl,
|
||
IMMICH_API_KEY: apiKey as string,
|
||
SITE_TITLE: process.env.SITE_TITLE || 'Gallery',
|
||
SITE_SUBTITLE: process.env.SITE_SUBTITLE || '',
|
||
CACHE_TTL: Math.max(0, cacheTtl),
|
||
STALE_MAX_AGE: Math.max(0, staleMaxAge),
|
||
// Kept URL-safe: this value is appended to every image and video URL.
|
||
IMAGE_CACHE_VERSION: encodeURIComponent(imageCacheVersion),
|
||
// A sub-second budget would abort healthy requests; 0 must not mean "no timeout".
|
||
IMMICH_TIMEOUT_MS: Math.max(1000, timeoutMs),
|
||
RATE_LIMIT_RPM: Math.max(1, rateLimit),
|
||
AUTH_SECRET: process.env.AUTH_SECRET,
|
||
TRUSTED_PROXY_HOPS: trustedProxyHops,
|
||
WEBHOOK_SECRET: process.env.WEBHOOK_SECRET || undefined,
|
||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || undefined,
|
||
};
|
||
}
|
||
|
||
/** Validated, typed environment variables. */
|
||
export const env = parseEnv();
|