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

@@ -14,17 +14,17 @@
Der ursprüngliche Plan (v1) entstand gegen v0.9.1. PR #394 hat davon einen großen Teil umgesetzt — teils anders und besser als geplant. Ersatzlos gestrichen:
| Ehemalige Task | Status in v0.9.2 |
|---|---|
| Upstream-Statusmodul (`lib/upstream.ts`) | **Überholt.** Statt eines Seiten-Pings unterscheidet `request()` jetzt an der Quelle: `ImmichUnavailableError` für Ausfälle, `null` nur für 404/410. Das ist die bessere Lösung — kein Nebenkanal, keine Race zwischen Ping und Abruf. |
| 404-vs-503-Politik in `app/[...path]/page.tsx` | **Erledigt.** Der Fehler propagiert, Seiten liefern 5xx statt 404. Zusätzlich geben `/api/exif` und `/api/map` 503 mit `Retry-After`. |
| `app/error.tsx`, `app/not-found.tsx` | **Erledigt**, mit `.empty-state` aus `globals.css` und 8 Tests in `lib/__tests__/error-pages.test.ts`. |
| `app/global-error.tsx` | **Nicht mehr nötig.** Das Layout wirft nicht mehr: `getConfigOrNull()` (`lib/config/index.ts:39`) fängt ab und rendert den SetupScreen, wobei `/admin` erreichbar bleibt. Das war der einzige konkrete Anlass für die Boundary. |
| Bild-Größen-Tiers | **Erledigt**, nach `lib/imageSize.ts` ausgelagert und getestet. |
| Ehemalige Task | Status in v0.9.2 |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Upstream-Statusmodul (`lib/upstream.ts`) | **Überholt.** Statt eines Seiten-Pings unterscheidet `request()` jetzt an der Quelle: `ImmichUnavailableError` für Ausfälle, `null` nur für 404/410. Das ist die bessere Lösung — kein Nebenkanal, keine Race zwischen Ping und Abruf. |
| 404-vs-503-Politik in `app/[...path]/page.tsx` | **Erledigt.** Der Fehler propagiert, Seiten liefern 5xx statt 404. Zusätzlich geben `/api/exif` und `/api/map` 503 mit `Retry-After`. |
| `app/error.tsx`, `app/not-found.tsx` | **Erledigt**, mit `.empty-state` aus `globals.css` und 8 Tests in `lib/__tests__/error-pages.test.ts`. |
| `app/global-error.tsx` | **Nicht mehr nötig.** Das Layout wirft nicht mehr: `getConfigOrNull()` (`lib/config/index.ts:39`) fängt ab und rendert den SetupScreen, wobei `/admin` erreichbar bleibt. Das war der einzige konkrete Anlass für die Boundary. |
| Bild-Größen-Tiers | **Erledigt**, nach `lib/imageSize.ts` ausgelagert und getestet. |
Obendrein neu und relevant: `IMMICH_TIMEOUT_MS`, ein `MISSING`-Sentinel, der definitive 404-Antworten cacht, `middleware.ts` → `proxy.ts`, sowie 210 statt 98 Unit-Tests.
**Was das für Task 1 bedeutet:** Die vitest-Grenze wurde in #394 zweimal *umgangen* statt gehoben — `lib/imageSize.ts` wurde eigens ausgelagert, damit die Größenlogik aus `lib/__tests__/` erreichbar ist, und `error-pages.test.ts` benutzt `createElement` statt JSX, weil eine `.tsx`-Datei dort nie ausgeführt würde (so im Commit-Text vermerkt). Die Sicherheitslogik, die sich nicht auslagern lässt — Admin-Guards und Content-Type-Sanitisierung — ist deshalb weiterhin ungetestet.
**Was das für Task 1 bedeutet:** Die vitest-Grenze wurde in #394 zweimal _umgangen_ statt gehoben — `lib/imageSize.ts` wurde eigens ausgelagert, damit die Größenlogik aus `lib/__tests__/` erreichbar ist, und `error-pages.test.ts` benutzt `createElement` statt JSX, weil eine `.tsx`-Datei dort nie ausgeführt würde (so im Commit-Text vermerkt). Die Sicherheitslogik, die sich nicht auslagern lässt — Admin-Guards und Content-Type-Sanitisierung — ist deshalb weiterhin ungetestet.
## Global Constraints
@@ -44,25 +44,25 @@ Obendrein neu und relevant: `IMMICH_TIMEOUT_MS`, ein `MISSING`-Sentinel, der def
**Neu:**
| Datei | Verantwortung |
|---|---|
| Datei | Verantwortung |
| ---------------------------------------------- | ---------------------------------------------------------- |
| `app/api/admin/__tests__/admin-guards.test.ts` | Tabellengetrieben: jede Admin-Route lehnt ohne Session ab. |
| `app/api/image/__tests__/image-route.test.ts` | Token-Grenze, Content-Type-Sanitisierung, ETag/304. |
| `lib/__tests__/cache-stale.test.ts` | Stale-Fenster und harte Obergrenze des Caches. |
| `app/loading.tsx` | Skeleton für die Startseite. |
| `app/[...path]/loading.tsx` | Skeleton für Album-/Subpage-Routen. |
| `app/loading.module.css` | Skeleton-Styles. |
| `app/api/image/__tests__/image-route.test.ts` | Token-Grenze, Content-Type-Sanitisierung, ETag/304. |
| `lib/__tests__/cache-stale.test.ts` | Stale-Fenster und harte Obergrenze des Caches. |
| `app/loading.tsx` | Skeleton für die Startseite. |
| `app/[...path]/loading.tsx` | Skeleton für Album-/Subpage-Routen. |
| `app/loading.module.css` | Skeleton-Styles. |
**Geändert:**
| Datei | Änderung |
|---|---|
| `vitest.config.ts` | `include` um `app/**/__tests__/**/*.test.ts` erweitern. |
| `lib/cache.ts` | Zwei Deadlines pro Eintrag, `getStale()`. |
| `lib/immich.ts` | `ImmichUnavailableError` abfangen, Stale-Fallback, sonst weiterwerfen. |
| `lib/env.ts`, `.env.local.example` | `STALE_MAX_AGE`. |
| `lib/config/schema.ts`, `lib/config/index.ts` | `staleMaxAge` in `AppConfig` und beide Rückgabepfade. |
| `README.md`, `CLAUDE.md` | Stale-Verhalten und Token-Widerruf dokumentieren. |
| Datei | Änderung |
| --------------------------------------------- | ---------------------------------------------------------------------- |
| `vitest.config.ts` | `include` um `app/**/__tests__/**/*.test.ts` erweitern. |
| `lib/cache.ts` | Zwei Deadlines pro Eintrag, `getStale()`. |
| `lib/immich.ts` | `ImmichUnavailableError` abfangen, Stale-Fallback, sonst weiterwerfen. |
| `lib/env.ts`, `.env.local.example` | `STALE_MAX_AGE`. |
| `lib/config/schema.ts`, `lib/config/index.ts` | `staleMaxAge` in `AppConfig` und beide Rückgabepfade. |
| `README.md`, `CLAUDE.md` | Stale-Verhalten und Token-Widerruf dokumentieren. |
**Abhängigkeiten:**
@@ -79,14 +79,16 @@ Task 4 (Doku) ── nach Task 2
**Warum:** `vitest.config.ts` schließt alles außerhalb von `lib/__tests__/` aus. Zwei Stellen sind deshalb ungetestet und lassen sich auch nicht auslagern: die Guards der acht Admin-Routen und die Content-Type-Normalisierung in `app/api/image/[id]/route.ts:99-107`, die aus einem Stored-XSS-Fix stammt (`c2fa8e7`).
**Erwartungshaltung:** Beide Mechanismen funktionieren heute korrekt. Diese Tests finden keinen Bug — sie sind Regressionsbremsen. Der Wert der Admin-Tabelle liegt darin, dass eine *künftig* ergänzte Route ohne Guard die Suite rot macht.
**Erwartungshaltung:** Beide Mechanismen funktionieren heute korrekt. Diese Tests finden keinen Bug — sie sind Regressionsbremsen. Der Wert der Admin-Tabelle liegt darin, dass eine _künftig_ ergänzte Route ohne Guard die Suite rot macht.
**Files:**
- Modify: `vitest.config.ts`
- Create: `app/api/admin/__tests__/admin-guards.test.ts`
- Create: `app/api/image/__tests__/image-route.test.ts`
**Interfaces:**
- Consumes: `isAdminAuthenticated()`, `isAdminEnabled()` (`lib/admin/auth.ts`, gemockt); `encodeAssetId()` (`lib/tokens.ts`); `immich.streamAsset()` (gemockt).
- Produces: nichts für spätere Tasks.
@@ -414,6 +416,7 @@ Run: `npm run test:unit -- app/api/image/__tests__/image-route.test.ts`
Erwartung: 12 Tests PASS.
Zwei mögliche Stolperstellen — beide zuerst gegen den Code prüfen, bevor der Test angepasst wird:
- Falls der Rate-Limiter über die Aufrufe hinweg greift, in `vitest.setup.ts` `process.env.RATE_LIMIT_RPM = '100000'` ergänzen.
- Falls die 503-Header anders heißen als erwartet, den tatsächlichen Wert aus `app/api/image/[id]/route.ts` übernehmen — der Test soll das reale Verhalten festschreiben.
@@ -451,9 +454,10 @@ git commit -m "test: enable route-handler tests and cover admin guards and image
**Warum:** v0.9.2 unterscheidet Ausfall und Nichtexistenz sauber und liefert bei Ausfall 5xx statt 404 — das war die dringendere Hälfte. Die andere Hälfte fehlt: Ein Besucher sieht während eines Immich-Neustarts eine Fehlerseite, obwohl die Alben Sekunden zuvor noch im Speicher lagen. `lib/cache.ts` löscht abgelaufene Einträge beim Zugriff (Zeile 23).
**Wichtig zur Abgrenzung:** v0.9.2 cacht Ausfälle bewusst *nicht* — ein `ImmichUnavailableError` wird nie gespeichert, damit die Galerie nach der Erholung nicht kaputt bleibt. Diese Task ändert daran nichts. Sie sorgt nur dafür, dass ein früherer **Erfolg** länger verfügbar bleibt, als seine TTL erlaubt. Beides zusammen ergibt: Ausfälle werden nicht eingebrannt, Erfolge überdauern sie.
**Wichtig zur Abgrenzung:** v0.9.2 cacht Ausfälle bewusst _nicht_ — ein `ImmichUnavailableError` wird nie gespeichert, damit die Galerie nach der Erholung nicht kaputt bleibt. Diese Task ändert daran nichts. Sie sorgt nur dafür, dass ein früherer **Erfolg** länger verfügbar bleibt, als seine TTL erlaubt. Beides zusammen ergibt: Ausfälle werden nicht eingebrannt, Erfolge überdauern sie.
**Files:**
- Modify: `lib/cache.ts`
- Create: `lib/__tests__/cache-stale.test.ts`
- Modify: `lib/env.ts`, `.env.local.example`
@@ -461,6 +465,7 @@ git commit -m "test: enable route-handler tests and cover admin guards and image
- Modify: `lib/immich.ts`, `lib/__tests__/immich.test.ts`
**Interfaces:**
- Consumes: `ImmichUnavailableError` aus `lib/immich.ts` (in v0.9.2 eingeführt, Zeile 97).
- Produces:
- `cache.get<T>(key): T | null` — unverändert, nur frische Daten.
@@ -664,19 +669,17 @@ Erwartung: alle 218 Tests grün. Schlägt `config-cache.test.ts` oder `immich.te
In `lib/env.ts` im `Env`-Interface direkt nach `CACHE_TTL: number;` ergänzen:
```ts
STALE_MAX_AGE: number;
STALE_MAX_AGE: number;
```
In `parseEnv()` nach dem `cacheTtl`-Block einfügen:
```ts
// How long an expired cache entry may still be served while Immich is
// unreachable. 0 disables the fallback.
const staleMaxAgeStr = process.env.STALE_MAX_AGE;
const staleMaxAge =
staleMaxAgeStr && !isNaN(parseInt(staleMaxAgeStr, 10))
? parseInt(staleMaxAgeStr, 10)
: 86400; // 24 hours
// How long an expired cache entry may still be served while Immich is
// unreachable. 0 disables the fallback.
const staleMaxAgeStr = process.env.STALE_MAX_AGE;
const staleMaxAge =
staleMaxAgeStr && !isNaN(parseInt(staleMaxAgeStr, 10)) ? parseInt(staleMaxAgeStr, 10) : 86400; // 24 hours
```
Im Rückgabeobjekt nach `CACHE_TTL: Math.max(0, cacheTtl),` einfügen:
@@ -688,7 +691,7 @@ Im Rückgabeobjekt nach `CACHE_TTL: Math.max(0, cacheTtl),` einfügen:
In `lib/config/schema.ts` im `AppConfig`-Interface neben `cacheTtl` ergänzen:
```ts
staleMaxAge: number;
staleMaxAge: number;
```
In `lib/config/index.ts` **beide** Rückgabepfade ergänzen — jeweils direkt nach `cacheTtl: env.CACHE_TTL * 1000,` (Zeilen 278 und 358):
@@ -924,11 +927,13 @@ git commit -m "feat: serve stale cache entries while Immich is unavailable"
**Warum:** Alle Galerieseiten sind `force-dynamic` und warten vollständig auf Immich. Mit `IMMICH_TIMEOUT_MS` (Default 15 s) kann diese Wartezeit im Störungsfall lang werden — ohne `loading.tsx` sieht der Besucher währenddessen nichts.
**Files:**
- Create: `app/loading.module.css`
- Create: `app/loading.tsx`
- Create: `app/[...path]/loading.tsx`
**Interfaces:**
- Consumes: Tokens aus `app/tokens.css`; die Grid-Variablen, die `app/[...path]/page.tsx` setzt.
- Produces: nichts.
@@ -1060,6 +1065,7 @@ git commit -m "feat: add loading skeletons for dynamic gallery routes"
## Task 4: Dokumentation
**Files:**
- Modify: `README.md`
- Modify: `CLAUDE.md`