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>
This commit is contained in:
Ralksta
2026-08-15 09:09:18 +02:00
committed by GitHub
parent 6eda34cc50
commit 2f546f440d
61 changed files with 3953 additions and 1115 deletions

View File

@@ -30,12 +30,12 @@ unterschiedliche Antworten brauchen:
**Modus A — die Konfiguration wirft.** `getConfig()` wirft an mehreren Stellen hart:
| Stelle | Auslöser |
|---|---|
| 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 |
| `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.
@@ -85,8 +85,8 @@ Immich-Antwort warten, bevor irgendetwas erscheint.
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
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
@@ -108,12 +108,12 @@ erst mit der Statusunterscheidung werden sie ein Feature.
### Befund: das Hindernis
`components/SetupScreen.tsx` erklärt vier Dateikopien und endet mit
*„restart your server"*. Ein Assistent müsste stattdessen selbst schreiben — und
_„restart your server"_. Ein Assistent müsste stattdessen selbst schreiben — und
stößt sofort auf ein Problem:
```ts
// lib/env.ts, letzte Zeile
export const env = parseEnv(); // läuft genau einmal beim Modul-Load
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
@@ -145,14 +145,14 @@ aufgelöst werden (Zeilen 53–54). Ein Eingriff, eine Stelle.
Bemerkenswert viel — der Assistent ist eher Verdrahtung als Neubau:
| Baustein | Wo |
|---|---|
| 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()` |
| 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").
@@ -199,12 +199,12 @@ Credentials-Entscheidung (c) sinnvoll anzufangen, weil sie den Rest bestimmt.
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 |
| 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
@@ -230,7 +230,7 @@ zuerst `cache.getStale()` versuchen. Die Rückgabe sollte mitführen, ob die Dat
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,
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
@@ -286,8 +286,8 @@ Der Einstieg ist also billiger, als es aussieht.
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.
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
@@ -313,7 +313,7 @@ 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
_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.