/** * 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; CONTACT_NOTIFY_URL?: 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; /** * Base URL of a pull CDN in front of this server. Image and video URLs are * rewritten onto it; see lib/cdn.ts. */ CDN_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 ''; } } /** * Validate CDN_URL: an absolute http(s) URL, optionally with a path prefix, no * query or fragment (a query would be glued in front of the image path). * Returned without trailing slash so `${base}/api/image/…` never doubles it. * An unusable value returns `''`, which leaves CDN mode off. */ export function normalizeCdnUrl(raw: string | undefined): string { const value = raw?.trim(); if (!value) return ''; let url: URL; try { url = new URL(value); } catch { return ''; } if (url.protocol !== 'https:' && url.protocol !== 'http:') return ''; if (url.search || url.hash || url.username || url.password) return ''; return `${url.origin}${url.pathname}`.replace(/\/+$/, ''); } 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; const cdnUrl = normalizeCdnUrl(process.env.CDN_URL); if (process.env.CDN_URL?.trim() && !cdnUrl) { console.warn( '⚠️ CDN_URL is not an absolute http(s) URL without query string — CDN mode stays off.', ); } return { IMMICH_API_URL: apiUrl, IMMICH_API_KEY: apiKey as string, // No English default here: lib/config falls back to the locale's word. SITE_TITLE: process.env.SITE_TITLE || '', 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, CONTACT_NOTIFY_URL: process.env.CONTACT_NOTIFY_URL || 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, CDN_URL: cdnUrl || undefined, }; } /** Validated, typed environment variables. */ export const env = parseEnv();