Files
immich-folio/lib/env.ts
Ralksta ad8b09700b release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-09-29 22:50:30 +02:00

183 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();