Files
immich-folio/lib/env.ts
Ralksta 2f546f440d release: v0.11.0 (#459)
Ships `dev` as **v0.11.0**. `main` contains nothing that `dev` does not,
so this is a clean sync.

## Headline changes since v0.10.0

- **Journal & Journal Studio** — photo essays at `/journal`,
split-screen block editor with live preview, drafts, per-entry
passwords, cover images
- **First-run setup wizard at `/install`** — configure a fresh
deployment from the browser; credentials are verified against Immich
before anything is written, and the wizard is gated by a one-time token
- **About page editor and toggle** in the admin panel
- **Per-album photo order** independent of Immich, with a drag & drop
editor
- **Custom favicon upload**
- **Experimental portfolio features** — justified grid, `cover` hero,
unlisted subpages, per-album grid overrides, cover focal points,
external nav links
- **Security** — asset tokens length-capped before decoding (#423),
journal author markdown escaped rather than filtered, journal file paths
contained, admin session key derived with scrypt (#432), Next.js 16.3.0
for CVE-2025-13465
- **Fixed** — client proofing honours its own configuration again
(#454); journal photo rendering (#432)

Full detail in [CHANGELOG.md](CHANGELOG.md#0110--2026-08-15).

## Verified

`npx tsc --noEmit` clean · `npx vitest run` 435 tests pass · `npm run
build` succeeds · lint 0 errors · `prettier --check` clean · local
`docker build` of the release tree.

## After merge

Tag `v0.11.0` on `main` and publish the release — that triggers
`docker-publish.yml`, which pushes the image and moves `latest`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 09:09:18 +02:00

117 lines
4.9 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;
INSTALL_CONTENT_DIR?: 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,
INSTALL_CONTENT_DIR: process.env.INSTALL_CONTENT_DIR || undefined,
};
}
/** Validated, typed environment variables. */
export const env = parseEnv();