diff --git a/.env.local.example b/.env.local.example index d2cc2ea..6178a1d 100644 --- a/.env.local.example +++ b/.env.local.example @@ -19,6 +19,12 @@ SITE_SUBTITLE=A visual journal # Cache TTL in seconds (integer >= 0, default: 300 = 5 minutes) CACHE_TTL=300 +# How long (in seconds) an expired cache entry may still be served while the +# Immich server is unreachable. Keeps the public gallery online across an +# Immich restart instead of showing an error page. Set to 0 to disable. +# Default: 86400 (24 hours) +STALE_MAX_AGE=86400 + # Cache-buster for image and video URLs (any string, default: empty = off). # Images are served with a one-year immutable cache, so browsers never # revalidate them. If you regenerate thumbnails in Immich, or rotate/edit a diff --git a/CLAUDE.md b/CLAUDE.md index 59be0de..2cc3453 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ Singleton `ImmichClient` class exported as `immich`. All Immich API calls are se - **Album allowlist** — `getAlbums()` fetches `?shared=true` albums but only returns IDs listed in `config.albums`. Requests for unlisted albums are silently rejected. - **Request coalescing** — pending promises are stored in `Map` fields to deduplicate concurrent requests for the same album/asset (important for `Promise.all` calls in grids). -- **In-memory LRU cache** (`lib/cache.ts`) — 200-entry LRU, configurable TTL via `CACHE_TTL`. Cache keys: `albums-list`, `album-`, `asset-`. +- **In-memory LRU cache** (`lib/cache.ts`) — 200-entry LRU. Entries carry two deadlines: `staleAt` (the `CACHE_TTL` window, after which `get()` reports a miss) and `hardExpiresAt` (`STALE_MAX_AGE`, after which the entry is dropped). Between the two, `getStale()` still returns the data — `lib/immich.ts` falls back to it when a request fails with `ImmichUnavailableError`, so the gallery keeps serving the last known albums during an Immich outage instead of erroring. Definitive 404s are cached as a `MISSING` sentinel under the normal TTL and are deliberately excluded from the stale window. Cache keys: `albums-list`, `album-`, `asset-`. - **Image streaming** — `streamAsset()` proxies binary responses; never loads the full image into memory. ### Asset token security (`lib/tokens.ts`) @@ -61,19 +61,19 @@ Singleton `ImmichClient` class exported as `immich`. All Immich API calls are se ### API routes (`app/api/`) -| Route | Purpose | -|---|---| -| `GET /api/image/[id]` | Image proxy — decodes token, rate-limits, streams from Immich | -| `GET /api/exif/[id]` | EXIF data for lightbox panel | -| `POST /api/auth` | Password submission → sets `HttpOnly` cookie | -| `GET /api/og` | Dynamic OG image generation (rate-limited) | -| `GET /api/map` | Aggregated GPS coordinates for map view | -| `GET /api/health` | Health check | -| `GET/POST/DELETE /api/admin/auth` | Admin login, session check, logout | -| `GET/PUT /api/admin/gallery` | Read/write `gallery.yaml` | -| `GET/PUT /api/admin/settings` | Read/write `settings.yaml` | -| `GET /api/admin/albums` | Browse all shared Immich albums (admin-only) | -| `POST /api/admin/reload` | Invalidate config + Immich cache | +| Route | Purpose | +| --------------------------------- | ------------------------------------------------------------- | +| `GET /api/image/[id]` | Image proxy — decodes token, rate-limits, streams from Immich | +| `GET /api/exif/[id]` | EXIF data for lightbox panel | +| `POST /api/auth` | Password submission → sets `HttpOnly` cookie | +| `GET /api/og` | Dynamic OG image generation (rate-limited) | +| `GET /api/map` | Aggregated GPS coordinates for map view | +| `GET /api/health` | Health check | +| `GET/POST/DELETE /api/admin/auth` | Admin login, session check, logout | +| `GET/PUT /api/admin/gallery` | Read/write `gallery.yaml` | +| `GET/PUT /api/admin/settings` | Read/write `settings.yaml` | +| `GET /api/admin/albums` | Browse all shared Immich albums (admin-only) | +| `POST /api/admin/reload` | Invalidate config + Immich cache | The image proxy maps requested pixel widths (`?w=`) to Immich size tiers: `≤250px→thumbnail`, `≤1440px→preview`, `>1440px→original` (`lib/imageSize.ts`). @@ -82,6 +82,7 @@ The image proxy maps requested pixel widths (`?w=`) to Immich size tiers: `≤25 ### Routing (`app/[...path]/page.tsx`) Single catch-all route handles three cases: + 1. `/[subpage-slug]/[album-slug]` — renders album detail with back-link to subpage 2. `/[subpage-slug]` — if subpage has >1 album, renders `SubpageGridView`; if exactly 1 album, renders `AlbumDetailView` directly 3. `/[album-slug]` — standalone album detail @@ -134,3 +135,4 @@ Next.js 16 renamed the `middleware` file convention to `proxy`: the file is `pro - **All Immich data flows server-side** — raw asset UUIDs must never appear in client-rendered HTML or JS. Always use `encodeAssetId()` / `imageUrl()` / `exifUrl()` from `lib/urls.ts` before passing IDs to components. - **Rate-limit all expensive endpoints** — apply `checkRateLimit` from `lib/rate-limit.ts` to any route doing heavy computation or upstream API calls. - **Commit style** — Conventional Commits: `feat:`, `fix:`, `security:`, `docs:`, `chore:`. +- **Route handlers are testable** — `vitest.config.ts` includes `app/**/__tests__/**/*.test.ts`. Logic that lives in a route (auth guards, header sanitisation) belongs in a route-level test; do not extract it into `lib/` purely to make it reachable by the test runner. diff --git a/README.md b/README.md index 61b301e..2a93690 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ IMMICH_API_KEY=your-api-key SITE_TITLE=My Photography # default: "Gallery" SITE_SUBTITLE=A visual journal # default: empty CACHE_TTL=300 # seconds, default: 300 +STALE_MAX_AGE=86400 # seconds an expired cache entry survives during an outage, default: 86400 (24h), 0 disables IMMICH_TIMEOUT_MS=15000 # Immich response wait, default: 15000 IMAGE_CACHE_VERSION=1 # bump to bust browser image caches, default: off RATE_LIMIT_RPM=1500 # requests/min/IP, default: 1500 @@ -191,6 +192,16 @@ The container includes a built-in health check at `/api/health`: curl http://localhost:7211/api/health ``` +### Behaviour when Immich is unreachable + +Immich Folio buffers your gallery rather than merely proxying it: + +- Album and asset pages keep serving the last known good data for up to `STALE_MAX_AGE`, so a restarting or briefly unreachable Immich does not take the public site down with it. +- Once nothing cached is left, they return `503`, never `404` — a `404` would tell search engines to drop a URL for content that still exists. +- Outages are never cached, so the gallery recovers as soon as Immich does. + +The cache lives in the process, so it is empty right after a container restart. + ### Reverse Proxy Put Immich Folio behind nginx / Caddy / Traefik with TLS. Example Caddy config: diff --git a/app/[...path]/loading.tsx b/app/[...path]/loading.tsx new file mode 100644 index 0000000..f604de3 --- /dev/null +++ b/app/[...path]/loading.tsx @@ -0,0 +1,20 @@ +/** + * Instant shell for album and subpage routes. These are force-dynamic and wait + * on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is + * struggling — so without this the viewer stares at a blank page. + */ + +import styles from '../loading.module.css'; + +export default function Loading() { + return ( + <> +