Files
immich-folio/CLAUDE.md
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

18 KiB

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

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 — seven built-in theme presets (studio-modern, studio, minimal, editorial, classic, noir, monograph) and resolveTheme() which merges partial overrides over a preset. DEFAULT_PRESET (studio-modern) is the single source for every "no preset configured" fallback — import it rather than writing the literal, and note the client components (InstallWizard, SettingsEditor) import it from lib/config/theme directly, since lib/config/index.ts pulls in fs.

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).
  • content/journal/*.md — journal entries, one file per entry (<slug>.md → /journal/<slug>). content/essays/ is the legacy location, still resolved.
  • content/install.json — written by the setup wizard: Immich URL, API key, generated site secret, scrypt-hashed admin password. Mode 0600; env vars override every field.
  • content/analytics.json — cookieless view counters.
  • content/favicon.svg — uploaded favicon (whatever the source format), served via /api/favicon.
  • content/.setup-token — one-time /install gate token (mode 0600), deleted once setup completes.
  • content/.backups/, content/journal/.backups/ — rotating backups (10 per file) written before every admin save.

The whole content/ directory must be writable: the wizard, admin panel, journal, favicon upload, analytics and backup rotation all write into it.

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/)

Public:

Route Purpose
GET /api/image/[id] Image proxy — decodes token, rate-limits, streams from Immich
GET /api/video/[id] Video proxy, range requests included
GET /api/exif/[id] EXIF data for lightbox panel
POST /api/auth Password submission (subpage | album | journal) → sets HttpOnly cookie
GET /api/og Dynamic OG image generation (rate-limited)
GET /api/map Aggregated GPS coordinates for map view
GET /api/favicon Uploaded favicon from content/, with an SVG-safe content policy
POST /api/analytics/track Cookieless view counter; refuses when analytics: false
POST /api/webhook Immich cache invalidation, HMAC-verified; 501 unless WEBHOOK_SECRET is set
GET /api/health Health check
POST /api/install, GET /api/install/albums First-run wizard; refuse to run once setup completed

Admin (every route re-checks isAdminEnabled() + isAdminAuthenticated()):

Route Purpose
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/PUT /api/admin/about Read/write content/about.md
GET/POST /api/admin/journal List / create journal entries
GET/PUT/DELETE /api/admin/journal/[slug] Read, write, delete one entry
GET /api/admin/albums Browse all shared Immich albums (bypasses allowlist)
GET /api/admin/albums/[albumId]/assets Assets of one album, for the manual-order editor
GET /api/admin/assets Library search (POST /search/metadata) for pickers
GET /api/admin/thumbnail/[id] Raw-UUID thumbnails for the admin UI
GET/POST /api/admin/backups List and restore backups
POST/DELETE /api/admin/favicon Favicon upload and reset
GET /api/admin/analytics Aggregated view counts
GET /api/admin/status Immich reachability, config validity, cache, backups
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

A subpage renders as an essay instead when any of grid.layout === 'essay', essayFile, or essayText is set. With layout: essay and no markdown, the essay is generated from the albums (heading per album + its photos).

Fixed routes outside the catch-all: /about, /map, /impressum, /journal, /journal/[slug], /install, /admin/*.

Journal & essays (lib/journal.ts, lib/essay.ts, lib/admin/journal-service.ts)

  • lib/journal.ts — client-safe, no fs. Parses and serializes the block markdown (parseJournalMarkdown / serializeJournalMarkdown), and owns sanitizeHtml, isValidSlug, reading time and excerpts. Photo blocks are ![<assetId>:fullbleed|wide](caption) and ![<id>, <id>](caption).
  • lib/admin/journal-service.ts — server only: reads/writes content/journal/, falls back to the legacy content/essays/, rotates backups. The client/server split is load-bearing — importing the service from a client component breaks the build.
  • essayFile is a slug, not a path: isValidSlug rejects dots and slashes, so content/essays/x.md cannot resolve.
  • Author markdown is escaped, never filtered — sanitizeHtml runs first and the renderer only emits tags it builds itself.
  • Drafts (draft: true) are hidden from the index and the nav check, but remain visible to an authenticated admin. Entry passwords use the lb_auth_journal_<slug> cookie.

Proofing (lib/proofing.ts, components/ProofingContext.tsx)

Client-side favourite selection encoded as a bitmask in the URL and mirrored to localStorage; nothing is stored server-side. Note that PhotoGrid mounts ProofingProvider unconditionally — the proofing.enabled / per-subpage proofing config keys are parsed but not consulted, so the feature is currently always on for grids.

Analytics

POST /api/analytics/track appends to content/analytics.json and short-circuits when config.analytics === false. GET /api/admin/analytics is admin-only. No cookies, no third party.

First-run setup (lib/install.ts, app/install/)

A deployment with no gallery.yaml and no credentials serves SetupScreen, and /install runs the wizard. It is gated by a one-time token printed to the server log and stored in content/.setup-token — a fresh deployment is reachable before it is configured. Credentials are verified against Immich before anything is written, then land in content/install.json. Environment variables always take precedence over that file, so rotation works without touching it. Once isInstalled() is true the wizard routes return 403.

All pages are dynamic = 'force-dynamic' (no SSG; requires live Immich).

Password protection (lib/auth.ts)

Per-subpage, per-album and per-journal-entry password gating using HMAC tokens in HttpOnly cookies (no database). Cookie names: lb_auth_<slug> (subpage), lb_auth_album_<slug> (album) and lb_auth_journal_<slug> (journal entry). Password storage supports plaintext (deprecated, logs a warning with recommended scrypt hash), scrypt:salt:hash format (lib/password.ts), 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. 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.

RATE_LIMIT_RPM (default 1500) covers image, video, EXIF and health. Endpoints where a high ceiling would be wrong carry a fixed constant in the route: admin-auth 5, auth 10, install 10, og 30, install-albums 30, webhook 60, map 120.

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, journal studio, settings editor and analytics at /admin. Enabled by setting ADMIN_PASSWORD env var, or via the setup wizard (scrypt hash in install.json).

Each area is a real route — /admin/pages, /admin/journal, /admin/journal/[slug], /admin/settings/[section], /admin/analytics. app/admin/AdminShell.tsx lives in the layout and holds the auth gate and chrome, so switching tabs does not re-run the session check.

  • lib/admin/auth.ts — HMAC-signed session tokens (HttpOnly cookie folio_admin_session), 24h expiry. The signing key is derived from ADMIN_PASSWORD + AUTH_SECRET with scrypt and cached keyed on its inputs.
  • 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.
  • lib/admin/journal-service.ts — the same pattern for content/journal/*.md.
  • lib/admin/paths.ts — containedPath(); every admin file path goes through it so a slug cannot escape its directory.
  • app/admin/components/PageBuilder.tsx — Tree editor for hero, standalone albums, subpages, and sections, with a slide-over drawer per item.
  • app/admin/components/JournalStudio.tsx — Split-screen block editor with live preview.
  • app/admin/components/SettingsEditor.tsx — Sidebar-nav settings form (general, theme, grid, footer, legal, SEO, security & protection, about).
  • app/admin/components/AlbumPicker.tsx / AssetPicker.tsx / AssetOrderEditor.tsx — album browser, library search, manual photo ordering.
  • app/admin/components/BackupManagerModal.tsx — list and restore backups.

Every /api/admin/* route must check isAdminEnabled() and isAdminAuthenticated() itself — there is no shared middleware guard. app/api/admin/__tests__/admin-guards.test.ts enforces this across all of them.

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.