Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list and the upgrade notes. Ten additions — the SEO package (site URL, robots.txt, sitemap.xml, JSON-LD), slideshow mode, opt-in original downloads, copy-link, previous/next album navigation, alt text from the Immich description, per-album location precision, the update notice, a Help tab, and a subpage cover grid separate from its photo grid. Two fixes: CJK album slugs, and the lightbox control bar. Photo of the Day (#476) was built on `dev` and reverted as not planned; it never appears in a release. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre --------- 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>
128 lines
5.5 KiB
TypeScript
128 lines
5.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;
|
||
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;
|
||
}
|
||
|
||
function parseEnv(): Env {
|
||
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,
|
||
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();
|