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>
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 typedEnvobject. All env access in the codebase must go through this module.lib/config/schema.ts— TypeScript types forAppConfig,GalleryYaml,SettingsYaml,SubpageConfig,GridConfig,ThemeConfig, etc., plus theslugify()utility.lib/config/index.ts—getConfig(): readscontent/gallery.yamlandcontent/settings.yaml, merges them into a singleAppConfig. Also exportsbuildSubpageGrid()and re-exports from schema/theme.lib/config/theme.ts— seven built-in theme presets (studio-modern,studio,minimal,editorial,classic,noir,monograph) andresolveTheme()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 fromlib/config/themedirectly, sincelib/config/index.tspulls infs.
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). Usegallery.yaml.exampleas reference.content/settings.yaml— site-wide settings: title, subtitle, theme, grid defaults, footer, legal, map, transitions, SEO. Usesettings.yaml.exampleas 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. Mode0600; 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/installgate token (mode0600), 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=truealbums but only returns IDs listed inconfig.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 forPromise.allcalls in grids). - In-memory LRU cache (
lib/cache.ts) — 200-entry LRU. Entries carry two deadlines:staleAt(theCACHE_TTLwindow, after whichget()reports a miss) andhardExpiresAt(STALE_MAX_AGE, after which the entry is dropped). Between the two,getStale()still returns the data —lib/immich.tsfalls back to it when a request fails withImmichUnavailableError, so the gallery keeps serving the last known albums during an Immich outage instead of erroring. Definitive 404s are cached as aMISSINGsentinel 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:
/[subpage-slug]/[album-slug]— renders album detail with back-link to subpage/[subpage-slug]— if subpage has >1 album, rendersSubpageGridView; if exactly 1 album, rendersAlbumDetailViewdirectly/[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, nofs. Parses and serializes the block markdown (parseJournalMarkdown/serializeJournalMarkdown), and ownssanitizeHtml,isValidSlug, reading time and excerpts. Photo blocks areand.lib/admin/journal-service.ts— server only: reads/writescontent/journal/, falls back to the legacycontent/essays/, rotates backups. The client/server split is load-bearing — importing the service from a client component breaks the build.essayFileis a slug, not a path:isValidSlugrejects dots and slashes, socontent/essays/x.mdcannot resolve.- Author markdown is escaped, never filtered —
sanitizeHtmlruns 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 thelb_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 cookiefolio_admin_session), 24h expiry. The signing key is derived fromADMIN_PASSWORD+AUTH_SECRETwith scrypt and cached keyed on its inputs.lib/admin/yaml-service.ts— Atomic YAML read/write with automatic backups tocontent/.backups/(max 10 per file). Writes use temp-file + rename pattern.lib/admin/journal-service.ts— the same pattern forcontent/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()fromlib/urls.tsbefore passing IDs to components. - Rate-limit all expensive endpoints — apply
checkRateLimitfromlib/rate-limit.tsto any route doing heavy computation or upstream API calls. - Commit style — Conventional Commits:
feat:,fix:,security:,docs:,chore:. - Route handlers are testable —
vitest.config.tsincludesapp/**/__tests__/**/*.test.ts. Logic that lives in a route (auth guards, header sanitisation) belongs in a route-level test; do not extract it intolib/purely to make it reachable by the test runner.