Files
immich-folio/docs/deep-dive-2026-08-05.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

17 KiB
Raw Permalink Blame History

Deep Dive: die vier priorisierten Themen

Stand: 2026-08-05 · Basis: v0.9.1 · Vertiefung zu brainstorm-2026-08-05.md

Vertieft: (1) Fehler-Boundaries, (2) Setup-Assistent, (3) Stale-while-error, (4) API-Routen-Tests.

Vorab drei Korrekturen an meiner ersten Einschätzung, die sich beim Lesen des Codes ergeben haben:

  • Ein unerreichbarer Immich führt nicht zu einem Absturz. request() fängt den Fehler und gibt null zurück — die Seite wird still leer und liefert HTTP 404 für real existierende Alben. Das ist ein anderes (und heimtückischeres) Problem als ein Crash.
  • Ein app/error.tsx würde den wichtigsten Fehlerfall gar nicht abfangen, weil app/layout.tsx:53 selbst getConfig() aufruft. Nötig ist global-error.tsx.
  • Der Setup-Assistent hat ein architektonisches Hindernis, das man erst beim Bauen bemerkt: Credentials kommen aus Env-Variablen, nicht aus YAML — und lib/env.ts parst genau einmal beim Prozessstart.

1. Fehler-, Lade- und 404-Boundaries

Befund

Kein error.tsx, global-error.tsx, not-found.tsx oder loading.tsx im gesamten app/-Baum. Es gibt zwei völlig verschiedene Fehlermodi, die unterschiedliche Antworten brauchen:

Modus A — die Konfiguration wirft. getConfig() wirft an mehreren Stellen hart:

Stelle Auslöser
lib/config/index.ts:136 gallery.yaml ohne Album und ohne Subpage
lib/config/index.ts:144 Subpage ohne name
lib/config/index.ts:170 Subpage ohne Alben/Sections
lib/secret.ts:20 AUTH_SECRET fehlt in Production

Nicht in dieser Liste: validateUuid() (lib/config/parser.ts) wirft nicht — es warnt und liefert bewusst eine Dummy-UUID zurück, damit der Build nicht bricht. Eine kaputte Album-ID führt also zu einem stillschweigend fehlenden Album, nicht zu einem Fehler. Das ist ein Fall für den Config-Doctor (A5), nicht für die Boundaries.

Modus B — Immich ist nicht erreichbar. Hier wirft nichts: request() fängt alles ab (lib/immich.ts:108–111) und liefert null. Folge:

  • getAlbums() → [] → Startseite zeigt stillschweigend keine Alben
  • getAlbum() → null → notFound() (app/[...path]/page.tsx:233) → HTTP 404 für eine URL, die es wirklich gibt
  • Map, EXIF, OG-Images: leer

Der entscheidende technische Punkt

app/error.tsx wird innerhalb des Root-Layouts gerendert. Da das Layout selbst getConfig() aufruft (app/layout.tsx:53), wirft bei Modus A das Layout — und ein error.tsx kommt nie zum Zug. Nur app/global-error.tsx greift, weil es <html> und <body> vollständig ersetzt.

Daraus folgt: global-error.tsx muss komplett eigenständig sein — eigene <html>/<body>, Inline-Styles, kein getConfig(), kein Theme-Zugriff. Genau wie der heutige SetupScreen, der aus demselben Grund schon inline gestylt ist.

Entwurf

app/global-error.tsx — der Konfigurations-Notausgang. Sollte nicht „Etwas ist schiefgelaufen" sagen, sondern die Datei nennen, in der das Problem steckt. Die Fehlermeldungen aus getConfig() sind bereits präzise formuliert (Subpage "Reisen" must have albums or sections) — sie sind die halbe Miete, sie kommen nur nirgends an. In Production nur Dateiname + Meldung zeigen, keine Stacktraces.

app/error.tsx — für Render-Fehler unterhalb des Layouts, im Theme-Look, mit reset()-Button.

app/not-found.tsx — gestaltete 404 statt Next.js-Default.

loading.tsx pro Segment — Skeletons aus ThumbHash (lib/thumbhash.ts ist da). Wirkt besonders, weil alle Seiten force-dynamic sind und heute komplett auf die Immich-Antwort warten, bevor irgendetwas erscheint.

Der Teil, der wirklich zählt: 404 vs. 503

Heute kann die App nicht unterscheiden zwischen „dieses Album existiert nicht" und „ich kann Immich gerade nicht fragen". Beides endet in notFound().

Das ist nicht nur kosmetisch: Ein 404 sagt Suchmaschinen „diese URL löschen", ein 503 sagt „später nochmal versuchen". Ein Immich-Update während eines Google-Crawls kann so die Sichtbarkeit des Portfolios kosten.

Vorschlag: Bevor app/[...path]/page.tsx notFound() aufruft, den Upstream-Status prüfen (immich.ping() ist über /api/health bereits mit 10-Sekunden-Cache versehen — wiederverwenden, nicht neu bauen). Ist Immich unerreichbar, stattdessen werfen → Error-Boundary → 5xx. Das greift direkt in Thema 3 über.

Aufwand

XS für die drei Boundary-Dateien. S, wenn die 404/503-Unterscheidung mitkommt. Ich würde beides zusammen machen — die Boundaries allein sind Kosmetik, erst mit der Statusunterscheidung werden sie ein Feature.


2. Setup-Assistent im Browser

Befund: das Hindernis

components/SetupScreen.tsx erklärt vier Dateikopien und endet mit „restart your server". Ein Assistent müsste stattdessen selbst schreiben — und stößt sofort auf ein Problem:

// lib/env.ts, letzte Zeile
export const env = parseEnv(); // läuft genau einmal beim Modul-Load

IMMICH_API_URL und IMMICH_API_KEY kommen aus der Umgebung. Ein Browser-Formular kann eine Umgebungsvariable eines laufenden Prozesses nicht ändern. YAML dagegen ist bereits heiß nachladbar — loadYaml() prüft die mtime, und getConfig() verzichtet bewusst auf einen In-Memory-Cache (Kommentar in lib/config/index.ts:49–52).

Die Credentials liegen also ausgerechnet in der einzigen Schicht, die einen Neustart erzwingt.

Drei Wege

(a) Assistent schreibt .env.local. Erfordert docker restart. Zerstört das Versprechen „in fünf Minuten fertig", und in vielen Compose-Setups liegt .env.local nicht einmal in einem gemounteten Volume — die Datei wäre nach dem Neustart weg.

(b) Credentials nach content/ verlagern, z. B. content/connection.yaml (Mode 0600), gelesen über denselben mtime-Loader. Kein Neustart, Assistent läuft durch. Bricht aber bestehende Deployments, wenn Env nicht mehr gilt.

(c) Hybrid — Empfehlung. Env gewinnt, wenn gesetzt; sonst Fallback auf content/connection.yaml. Bestehende Installationen ändern sich um nichts, neue Nutzer bekommen den Assistenten. lib/env.ts bleibt der alleinige Env-Parser; der Fallback gehört nach lib/config/index.ts, wo apiUrl/apiKey ohnehin schon aufgelöst werden (Zeilen 53–54). Ein Eingriff, eine Stelle.

Was schon existiert

Bemerkenswert viel — der Assistent ist eher Verdrahtung als Neubau:

Baustein Wo
/admin bleibt während des Setups erreichbar app/layout.tsx:68–79, isAdminPath(), Commit 8f0bb94
Atomares YAML-Schreiben mit Backups lib/admin/yaml-service.ts
Alle Shared Albums listen (ohne Allowlist) GET /api/admin/albums
Visuelle Album-/Asset-Auswahl AlbumPicker.tsx, AssetPicker.tsx
Theme-Vorschaubilder docs/screenshots/theme-*.png
Cache nach dem Speichern invalidieren invalidateConfigCache()

Dass /admin während needsSetup offen bleibt, wurde in #392 bewusst gebaut — mit genau dieser Begründung („it is the tool that completes the setup").

Ablauf

  1. Verbinden — URL + API-Key eingeben, sofort gegen /server/ping testen, Permissions prüfen, Immich-Version prüfen (Folio braucht ≥ 3.0, siehe die Warnung in lib/immich.ts:400). Fehler hier sofort und verständlich melden — das ist die Stelle, an der die meisten scheitern.
  2. Alben wählen — visuell aus den Shared Albums, keine UUIDs.
  3. Look wählen — Preset-Karten mit den vorhandenen Screenshots.
  4. Identität — Titel, Untertitel, Footer.

Schreibt connection.yaml, gallery.yaml, settings.yaml; ruft invalidateConfigCache(); leitet auf die fertige Galerie.

Die Sicherheitsfrage, die vorher beantwortet sein muss

Während needsSetup gilt, ist die Instanz frisch — möglicherweise ohne gesetztes ADMIN_PASSWORD. Ein ungeschützter Assistent auf einer öffentlichen URL bedeutet: Wer den Container zuerst findet, besitzt ihn.

Zwei gängige Muster: einmaliges Setup-Token ins Container-Log schreiben (wie es diverse Self-Hosted-Tools machen), oder ADMIN_PASSWORD zur Voraussetzung machen.

Empfehlung: ADMIN_PASSWORD voraussetzen. Das Sicherheitsmodell bleibt exakt wie heute (lib/admin/auth.ts, HMAC-Session-Cookie), es gibt keinen zweiten, schwächeren Pfad — und aus Nutzersicht ist es trotzdem ein Fortschritt: eine Env-Variable statt dreien. Angesichts von Commit 37f7f62 („fix pre-auth bypasses") ist zusätzliche Vorsicht in genau diesem Bereich angebracht.

Aufwand

M. Der größte Einzelhebel für Verbreitung — aber erst nach der Credentials-Entscheidung (c) sinnvoll anzufangen, weil sie den Rest bestimmt.


3. Stale-while-error

Befund: es ist kein Absturz, es ist Stille

Meine ursprüngliche Formulierung war ungenau. request() wirft nicht, es gibt null zurück. Wenn Immich weg ist, passiert Folgendes:

Aufruf Ergebnis Für den Besucher
getAlbums() [] Startseite ohne Alben
getAlbum(id) null → notFound() HTTP 404 auf echter URL
getAssetInfo() null Kein EXIF, keine Map-Punkte
/api/image/[id] 404 pro Bild Kaputte Bildplätze

Immerhin sauber gelöst: das leere Ergebnis wird nicht gecacht (lib/immich.ts:214, if (!all) return [] steht vor cache.set) — es brennt sich also nichts ein.

Das eigentliche Problem ist der 404. Eine Portfolio-Seite, die während eines Immich-Neustarts gecrawlt wird, meldet Suchmaschinen die Löschung ihrer eigenen Alben. Das ist der Grund, warum ich das über ISR stellen würde: Bei ISR geht es um Millisekunden, hier geht es um Auffindbarkeit.

Entwurf — drei kleine Eingriffe

1. lib/cache.ts: abgelaufene Einträge behalten. Heute löscht get() beim Ablauf (Zeilen 23–26). Stattdessen zwei Zeitstempel pro Eintrag — staleAt (bisheriges TTL) und hardExpiresAt — und ein neues getStale<T>(key), das auch abgelaufene Daten herausgibt. Die Speichergrenze regelt weiterhin die LRU-Eviction. Ein hartes Limit (STALE_MAX_AGE, Default 24 h) verhindert, dass ein dauerhaft totes Immich wochenalte Daten ausliefert.

2. lib/immich.ts: Fallback statt Leere. In getAlbums(), getAlbum() und getAssetInfo() bei null aus request() zuerst cache.getStale() versuchen. Die Rückgabe sollte mitführen, ob die Daten frisch oder stale sind — sonst kann die Seite es nicht anzeigen.

3. Konsequenz ziehen: nicht notFound(), wenn der Upstream tot ist. Gibt es weder frische noch stale Daten und ist Immich nachweislich unerreichbar, dann werfen statt 404. Das ist derselbe Eingriff wie in Thema 1 — die beiden Punkte sind eigentlich ein Feature von zwei Seiten. Den Ping nicht neu bauen: app/api/health/route.ts hat bereits einen 10-Sekunden-gecachten, gegen Thundering-Herd abgesicherten immich.ping(); den Mechanismus in ein kleines Modul ziehen und beidseitig nutzen.

Trade-offs, die benannt gehören

  • Stale kann falsch sein. Ein in Immich gelöschtes Foto bliebe bis zu STALE_MAX_AGE sichtbar. Für ein kuratiertes Portfolio ist das Risiko gering, aber es ist eine bewusste Entscheidung, keine Nebenwirkung.
  • Passwörter sind nicht betroffen. Sie stammen aus gallery.yaml, nicht aus Immich — eine Passwortänderung greift also sofort, unabhängig vom Stale-Cache. Das ist der beruhigende Teil.
  • Nur im Prozessspeicher. Nach einem Container-Neustart ist der Cache leer, ein gleichzeitig totes Immich hilft dann nicht. Der persistente Bild-Cache (A3 im Brainstorm) ist die natürliche Fortsetzung — zusammen überleben die beiden auch einen Neustart.

Aufwand

M. Die Cache-Änderung selbst ist klein; die Sorgfalt steckt in der 404/503-Politik und darin, den Stale-Zustand ehrlich anzuzeigen.


4. Tests für die API-Routen

Befund: strukturell ausgeschlossen

// vitest.config.ts
include: ['lib/__tests__/**/*.test.ts'],

Die Routen sind nicht vergessen worden — sie sind per Konfiguration außen vor. Das ist eine Einzeilenänderung.

Und die Infrastruktur passt bereits:

  • environment: 'node' — Route-Handler sind gewöhnliche async-Funktionen, die ein NextRequest nehmen. new NextRequest('http://localhost/api/image/xyz') genügt, es braucht keinen laufenden Server.
  • vitest.setup.ts setzt die Pflicht-Env inklusive AUTH_SECRET.
  • lib/__tests__/immich.test.ts zeigt das Muster: vi.mock('../config') plus global.fetch = vi.fn(). Exakt das, was Routentests brauchen.

Der Einstieg ist also billiger, als es aussieht.

Was zu testen sich lohnt — Invarianten, keine Zeilen

Allowlist. getAlbum() weist nicht gelistete IDs ab (lib/immich.ts:374). Ein Album aus gallery.yaml entfernen → Route muss 404 liefern.

Token-Grenze. /api/image/[id] mit roher UUID statt Token → 400. Mit einem Token, das unter einem anderen AUTH_SECRET erzeugt wurde → 400. tokens.ts ist unit-getestet; hier geht es darum, dass die Route das durchsetzt.

Die Lücke, die dokumentiert gehört. Der Bild-Proxy dekodiert den Token und streamt (app/api/image/[id]/route.ts:78) — er prüft nicht, ob das Asset noch zu einem freigegebenen Album gehört. Da Tokens deterministisch sind (bewusst, für Browser-Caching, siehe CLAUDE.md), folgt daraus: Ein Album aus gallery.yaml zu entfernen widerruft bereits ausgelieferte Bild-URLs nicht. Sie funktionieren weiter, solange AUTH_SECRET gleich bleibt.

Das ist ein legitimer Trade-off, aber ein überraschender. Er verdient einen Test, der das Verhalten festschreibt, und eine Zeile im README — sonst nimmt jemand an, „nicht mehr veröffentlicht" hieße „nicht mehr erreichbar".

Content-Type-Sanitisierung. Zeilen 84–93 der Bild-Route stammen aus einem echten Stored-XSS-Fix (c2fa8e7). image/svg+xml und text/xml müssen zu application/octet-stream werden. Klassischer Regressionskandidat.

Rate-Limit-Namespaces. Die Buckets sind pro Endpunkt getrennt (image:, map:, auth:). Test: image: ausschöpfen darf /api/auth nicht auf 429 setzen.

Admin-Guards — der wertvollste Einzeltest. Alle sieben /api/admin/*-Routen müssen ohne gültiges Session-Cookie ablehnen, ebenso mit einem unter falschem Secret signierten Token. Das ist genau die Fläche von Commit 37f7f62.

Entscheidend ist die Bauform: tabellengetrieben über die Liste der Admin-Routen, nicht sieben Einzeltests. Dann schlägt die Suite auch bei einer künftig hinzugefügten Route ohne Guard an — sie testet dann die Regel statt der sieben bekannten Fälle. Das ist der Unterschied zwischen einer Suite, die alte Bugs festhält, und einer, die neue verhindert.

Passwort-Gate. /api/auth setzt das Cookie nur bei korrektem Passwort; Vergleich zeitkonstant; die drei Pfade scrypt: / Klartext / abgelehntes bcrypt.

304/ETag. Bei If-None-Match kein Body.

Aufwand

M für die volle Abdeckung. Aber der Einstieg ist klein: vitest.config.ts erweitern, dann Admin-Guard-Tabelle und Content-Type-Regression zuerst — das ist etwa ein halber Tag und deckt bereits beide Stellen ab, an denen es in der Vergangenheit real geknallt hat.


Reihenfolge und Zusammenhänge

Die vier Themen sind nicht unabhängig:

Thema 1 (Boundaries)  ─┐
                       ├─→ gemeinsame 404-vs-503-Politik
Thema 3 (Stale)       ─┘

Thema 2 (Assistent)   ──→ hängt an der Credentials-Entscheidung (c)

Thema 4 (Tests)       ──→ sichert 1–3 ab, sollte parallel mitlaufen

Phase 1 — etwa ein Tag, sofort sichtbar global-error.tsx, error.tsx, not-found.tsx, loading.tsx; vitest.config.ts erweitern; Admin-Guard-Tabellentest.

Phase 2 — die Betriebs-Härtung Stale-Cache plus 404/503-Politik. Ein Vorhaben, nicht zwei. Danach die Content-Type- und Allowlist-Tests nachziehen.

Phase 3 — der Verbreitungs-Hebel Setup-Assistent, beginnend mit dem Hybrid-Fallback für Credentials und der Entscheidung, ADMIN_PASSWORD vorauszusetzen.

Der eine Satz dahinter

Phase 1 und 2 sorgen dafür, dass die Seite eines fremden Fotografen auch dann noch etwas Vernünftiges tut, wenn dessen Immich hustet — und Phase 3 sorgt dafür, dass dieser Fotograf überhaupt erst so weit kommt.