/** * 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; SITE_PASSWORD?: string; INSTALL_CONTENT_DIR?: string; /** Whether the admin dashboard may ask GitHub for the latest release. */ UPDATE_CHECK: boolean; /** Absolute site URL for sitemap, feed and JSON-LD. Overrides settings.yaml. */ SITE_URL?: string; } /** * Strip a trailing slash (or several) from a URL, after confirming it parses * at all. Deliberately not `/api`-aware, unlike install.ts's * normalizeApiBase(): this is the "base URL, no /api" shape getConfig() * expects (it appends /api itself unless the value already ends with one), * and it's what the install wizard's URL field pre-fills from — appending * /api here would mean re-opening the wizard shows a path nobody typed. * * Exported so lib/install.ts can apply the same normalisation to * content/install.json's apiUrl, which used to skip it entirely: a trailing * slash there produced IMMICH_API_URL//api and every request 404ed (#634). */ export function normalizeApiUrl(raw: string): string { try { return new URL(raw).toString().replace(/\/+$/, ''); } catch { return ''; } } function parseEnv(): Env { const urlRaw = process.env.IMMICH_API_URL; let apiUrl = ''; if (urlRaw) { apiUrl = normalizeApiUrl(urlRaw); if (!apiUrl) { 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, SITE_PASSWORD: process.env.SITE_PASSWORD || undefined, INSTALL_CONTENT_DIR: process.env.INSTALL_CONTENT_DIR || undefined, // On unless switched off: an instance that never learns about a security // release is the worse default. Only `false` disables it, so a typo does // not silently turn the check off. UPDATE_CHECK: process.env.UPDATE_CHECK !== 'false', SITE_URL: process.env.SITE_URL || undefined, }; } /** Validated, typed environment variables. */ export const env = parseEnv();