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>
117 lines
4.9 KiB
TypeScript
117 lines
4.9 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;
|
||
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();
|