* docs: brainstorm resilience/adoption features and plan follow-up work
Brainstorm and deep-dive on where Immich Folio breaks under Immich
outages and what blocks self-hosted adoption, plus an implementation
plan for the remaining gaps after v0.9.2 (route-handler tests,
stale-while-error caching, loading skeletons).
* test: enable route-handler tests and cover admin guards and image content-type
vitest.config.ts only ran lib/__tests__/**, so route handlers were
structurally untestable. Two mechanisms live only at the route level and
cannot be extracted the way lib/imageSize.ts was: the admin auth guards
and the image proxy's Content-Type sanitisation (the stored-XSS fix from
c2fa8e7). Both were correct but unverified by any test.
Adds a table-driven test over all ten guarded /api/admin/* handlers,
including a filesystem walk that fails if a future route is added
without a row in the table. Adds a route-level test for
/api/image/[id] covering token validation, the six Content-Type
rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError)
distinction.
Verified both catch real regressions by temporarily removing the admin
guards and the Content-Type rewrite and confirming the expected tests
fail, then restoring and confirming green again.
134 new tests (244 total, up from 210), tsc 0 errors, build succeeds.
* feat: serve stale cache entries while Immich is unavailable
v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable"
(ImmichUnavailableError), and pages now render a 5xx instead of a false
404 during an outage — but that 5xx still replaced a gallery that had
loaded fine seconds earlier. lib/cache.ts deleted expired entries on
access, so nothing survived long enough to fall back to.
Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL,
after which get() still reports a miss) and hardExpiresAt (the new
STALE_MAX_AGE, default 24h, after which the entry is dropped entirely).
Between the two, getStale() can still return the data.
getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError
and fall back to getStale() before propagating. The two MISSING-sentinel
writes (definitive 404s) are deliberately left on the short cacheTtl
window — a corrected asset ID should take effect after the normal TTL,
not sit in the 24h stale window meant for outages.
STALE_MAX_AGE=0 disables the fallback. Verified the fallback only
engages on a real outage, not on a cache hit at the TTL boundary, and
that a definitive 404 still reports missing rather than falling back.
256 tests (up from 244), tsc 0 errors, build succeeds.
* feat: add loading skeletons for dynamic gallery routes
Every gallery route is force-dynamic and waits on a live Immich
round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling —
with nothing rendered until it resolves. loading.tsx renders instantly
while the page component awaits its data.
Not browser-verified against live data: this worktree has no
.env.local and only .example content files, so the app boots into
setup mode (confirmed the dev server starts cleanly and the setup
screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint
errors.
* docs: document stale cache window, image token revocation, route-test convention
- README: STALE_MAX_AGE env var and a "Behaviour when Immich is
unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404,
outages never cached).
- docs/gallery-config.md: new "Image Token Revocation" subsection under
System & Security — un-publishing an album does not revoke already
issued image URLs; rotating AUTH_SECRET does.
- CLAUDE.md: updated cache description to cover the stale window and
MISSING sentinel, and a new code-convention note that route-level
logic (guards, header sanitisation) should get a route-level test
rather than being extracted into lib/ just to reach the test runner.
---------
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
139 lines
11 KiB
Markdown
139 lines
11 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
## What this project is
|
|
|
|
Immich Folio is a self-hosted photography portfolio that acts as a secure reverse proxy between visitors and a private Immich instance. It serves albums via an image proxy so the Immich server and API key are never exposed to the public internet. Asset UUIDs are AES-256 encrypted into opaque URL tokens.
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
npm run dev # Start dev server at http://localhost:3000
|
|
npm run build # Production build (must pass before any PR)
|
|
npm run lint # ESLint
|
|
npm run format # Prettier (write)
|
|
npm run format:check # Prettier (check only)
|
|
npx tsc --noEmit # TypeScript type-check (must be 0 errors before any PR)
|
|
|
|
# Unit tests (Vitest, lib/__tests__/ only)
|
|
npm run test # Watch mode
|
|
npm run test:unit # Single run
|
|
npm run test:coverage
|
|
|
|
# E2E tests (Playwright, requires dev server running or starts it automatically)
|
|
npm run test:e2e
|
|
```
|
|
|
|
**Formatting caution:** Running `npm run format` at the repo root applies changes to the entire codebase and pollutes diffs. Prefer formatting only modified files.
|
|
|
|
## Architecture
|
|
|
|
### Configuration loading (`lib/config/`)
|
|
|
|
Configuration is loaded once at startup (cached in a module-level singleton, re-read in dev mode per request).
|
|
|
|
- `lib/env.ts` — parses and validates all environment variables into a typed `Env` object. All env access in the codebase must go through this module.
|
|
- `lib/config/schema.ts` — TypeScript types for `AppConfig`, `GalleryYaml`, `SettingsYaml`, `SubpageConfig`, `GridConfig`, `ThemeConfig`, etc., plus the `slugify()` utility.
|
|
- `lib/config/index.ts` — `getConfig()`: reads `content/gallery.yaml` and `content/settings.yaml`, merges them into a single `AppConfig`. Also exports `buildSubpageGrid()` and re-exports from schema/theme.
|
|
- `lib/config/theme.ts` — six built-in theme presets (`studio`, `minimal`, `editorial`, `classic`, `noir`, `monograph`) and `resolveTheme()` which merges partial overrides over a preset.
|
|
|
|
`getConfig()` returns a `needsSetup: true` dummy config (instead of throwing) when `gallery.yaml` or credentials are missing — this lets the app render a `SetupScreen` instead of crashing.
|
|
|
|
### Content files (`content/`)
|
|
|
|
- `content/gallery.yaml` — gallery structure: hero asset IDs, standalone albums, subpages (with optional sections, passwords, per-subpage grid overrides). Use `gallery.yaml.example` as reference.
|
|
- `content/settings.yaml` — site-wide settings: title, subtitle, theme, grid defaults, footer, legal, map, transitions, SEO. Use `settings.yaml.example` as reference.
|
|
- `content/about.md` — Markdown with frontmatter for the about page (portrait, name, location, gear).
|
|
|
|
### Immich client (`lib/immich.ts`)
|
|
|
|
Singleton `ImmichClient` class exported as `immich`. All Immich API calls are server-side only. Key design points:
|
|
|
|
- **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<id, Promise>` 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. 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-<id>`, `asset-<id>`.
|
|
- **Image streaming** — `streamAsset()` proxies binary responses; never loads the full image into memory.
|
|
|
|
### Asset token security (`lib/tokens.ts`)
|
|
|
|
`encodeAssetId(uuid)` / `decodeAssetId(token)` — AES-256-GCM with a deterministic IV derived from the asset ID (same UUID → same token, enabling browser caching). Token format: `v2:<base64url(iv+authTag+ciphertext)>`. The encryption key is derived from `AUTH_SECRET` via SHA-256. Legacy CBC tokens are still decoded for backward compatibility.
|
|
|
|
### 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 |
|
|
|
|
The image proxy maps requested pixel widths (`?w=`) to Immich size tiers: `≤250px→thumbnail`, `≤1440px→preview`, `>1440px→original` (`lib/imageSize.ts`).
|
|
|
|
`?size=` (written by `lib/urls.ts`) acts as a **ceiling**, not an override. When both parameters are present the smaller tier wins, so `?w=` can narrow the request but never widen it — `next/image` emits widths up to 3840, so letting width win outright would serve full-size originals to every large display.
|
|
|
|
### 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
|
|
|
|
All pages are `dynamic = 'force-dynamic'` (no SSG; requires live Immich).
|
|
|
|
### Password protection (`lib/auth.ts`)
|
|
|
|
Per-subpage and per-album password gating using HMAC tokens in `HttpOnly` cookies (no database). Cookie names: `lb_auth_<slug>` (subpage) and `lb_auth_album_<slug>` (album). Password storage supports plaintext (deprecated, logs a warning with recommended scrypt hash), `scrypt:salt:hash` format, and rejects legacy bcrypt. Token expiry: 24 hours.
|
|
|
|
### Rate limiting (`lib/rate-limit.ts`)
|
|
|
|
In-memory sliding-window rate limiter. Important: **in-memory only** — does not work across multiple Node.js instances. Uses FIFO eviction (not reject-on-full) to prevent DoS via store flooding. Configurable via `RATE_LIMIT_RPM`. `TRUSTED_PROXY_HOPS` must be set to the number of reverse proxies in front of the app (nginx = 1); the client IP is then read that many entries from the right of `X-Forwarded-For`, which proxies append to. Without it the IP comes from a spoofable header. Bucket keys are namespaced per endpoint (`image:`, `map:`, `auth:`, …) so limits don't collide.
|
|
|
|
### Theming system
|
|
|
|
Theme is applied via CSS custom properties on the `<html>` element (`data-preset`, `data-grain`, `data-photo-frame`, etc.) and inline `style` vars (`--accent`, `--font-serif`, etc.). Theme preset CSS files live in `app/themes/`. Base tokens are in `app/tokens.css`. Vanilla CSS throughout — no Tailwind, no CSS-in-JS.
|
|
|
|
### `next/image` loader
|
|
|
|
A custom loader (`lib/immichLoader.ts`) maps `next/image` requests to `/api/image/[token]?w=<width>`, keeping all image traffic through the proxy.
|
|
|
|
### Admin Panel (`app/admin/`, `lib/admin/`, `app/api/admin/`)
|
|
|
|
Visual page builder and settings editor at `/admin`. Enabled by setting `ADMIN_PASSWORD` env var.
|
|
|
|
- `lib/admin/auth.ts` — HMAC-signed session tokens (HttpOnly cookie `folio_admin_session`), constant-time password verification, 24h expiry.
|
|
- `lib/admin/yaml-service.ts` — Atomic YAML read/write with automatic backups to `content/.backups/` (max 10 per file). Writes use temp-file + rename pattern.
|
|
- `app/api/admin/auth` — POST login, GET session check, DELETE logout.
|
|
- `app/api/admin/gallery` — GET/PUT for `gallery.yaml`.
|
|
- `app/api/admin/settings` — GET/PUT for `settings.yaml`.
|
|
- `app/api/admin/albums` — GET lists ALL shared Immich albums (bypasses allowlist, admin-only).
|
|
- `app/api/admin/reload` — POST invalidates config cache + Immich cache.
|
|
- `app/admin/components/PageBuilder.tsx` — Tree editor for hero, standalone albums, subpages, and sections.
|
|
- `app/admin/components/AlbumPicker.tsx` — Modal to browse/search all Immich albums.
|
|
- `app/admin/components/SettingsEditor.tsx` — Sidebar-nav settings form (theme, grid, footer, legal, SEO).
|
|
|
|
After saving, `invalidateConfigCache()` is called so the next request picks up the new YAML without restart.
|
|
|
|
### Proxy (`proxy.ts`)
|
|
|
|
Applies CSP (with per-request nonce), HSTS, and other security headers on all non-API, non-static routes. The nonce is passed to pages via the `x-nonce` request header.
|
|
|
|
Next.js 16 renamed the `middleware` file convention to `proxy`: the file is `proxy.ts` and it exports `proxy()` (not `middleware()`). The `config.matcher` export is unchanged.
|
|
|
|
## Code conventions
|
|
|
|
- **TypeScript strict mode** — no untyped `any`; use `@ts-expect-error` (not `@ts-ignore`) with a comment when suppressing type errors.
|
|
- **Server Components by default** — add `'use client'` only when browser APIs or React state/effects are needed.
|
|
- **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.
|