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>
183 lines
7.5 KiB
TypeScript
183 lines
7.5 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;
|
||
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();
|