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>
365 lines
17 KiB
Markdown
365 lines
17 KiB
Markdown
# Deep Dive: die vier priorisierten Themen
|
||
|
||
_Stand: 2026-08-05 · Basis: v0.9.1 · Vertiefung zu [`brainstorm-2026-08-05.md`](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:
|
||
|
||
```ts
|
||
// 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
|
||
|
||
```ts
|
||
// 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.
|