Files
immich-folio/docs/superpowers/plans/2026-08-05-resilience-boundaries.md

1161 lines
42 KiB
Markdown
Raw Normal View History

Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
# Resilience Follow-Up Implementation Plan (v2, gegen v0.9.2)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Die drei Lücken schließen, die nach v0.9.2 aus dem ursprünglichen Resilience-Plan übrig sind: Route-Handler testbar machen und die untestbar gebliebene Sicherheitslogik abdecken, bei Immich-Ausfall die zuletzt bekannten Alben weiter ausliefern, und dynamische Seiten sofort eine Hülle rendern statt einer leeren Seite.
**Architecture:** Drei unabhängige, klein geschnittene Eingriffe. (1) `vitest.config.ts` nimmt Tests unter `app/` auf; darauf aufbauend Tests gegen die Admin-Guards und die Content-Type-Sanitisierung des Bild-Proxys, die beide nur auf Routenebene prüfbar sind. (2) `lib/cache.ts` behält abgelaufene Einträge bis zu einer harten Obergrenze; `lib/immich.ts` fängt den in v0.9.2 eingeführten `ImmichUnavailableError` ab und greift auf diese Daten zurück, bevor es den Fehler weiterreicht. (3) `loading.tsx` liefert Skeletons.
**Tech Stack:** Next.js 16 (App Router, React 19), TypeScript strict, Vitest 4 (node environment), Vanilla CSS, keine neuen Laufzeit-Dependencies.
---
## Was v0.9.2 bereits erledigt hat
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:
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
| 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. |
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
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.
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
**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.
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
## Global Constraints
- **Keine neuen Runtime-Dependencies.**
- **TypeScript strict** — kein untypisiertes `any`; Suppressions nur als `@ts-expect-error` mit Begründung.
- **Rohe Immich-UUIDs dürfen nie im Client-HTML/JS landen.**
- **Vanilla CSS**, Tokens aus `app/tokens.css` (`--bg-secondary`, `--radius-sm`, `--grid-columns`, `--grid-gap`, `--grid-aspect-ratio`).
- **Formatierung:** nur geänderte Dateien (`npx prettier --write <datei>`), nie `npm run format` im Repo-Root.
- **Vor jedem Commit grün:** `npm run test:unit`, `npx tsc --noEmit`, `npm run lint`.
- **Baseline vor Beginn:** 210 Tests in 19 Dateien. Jede Task nennt die erwartete neue Summe.
- **Commit-Stil:** Conventional Commits.
- **Neue Env-Variablen** gehören in `lib/env.ts` **und** `.env.local.example`.
---
## File Structure
**Neu:**
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
| Datei | Verantwortung |
| ---------------------------------------------- | ---------------------------------------------------------- |
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
| `app/api/admin/__tests__/admin-guards.test.ts` | Tabellengetrieben: jede Admin-Route lehnt ohne Session ab. |
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
| `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. |
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
**Geändert:**
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
| 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. |
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
**Abhängigkeiten:**
```
Task 1 (vitest include + Route-Tests) ── zuerst, schaltet Tests unter app/ ein
Task 2 (Stale-while-error) ── unabhängig von Task 1
Task 3 (Loading-Skeletons) ── unabhängig
Task 4 (Doku) ── nach Task 2
```
---
## Task 1: Route-Handler testbar machen und Sicherheitslogik abdecken
**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`).
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
**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.
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
**Files:**
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
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
- Modify: `vitest.config.ts`
- Create: `app/api/admin/__tests__/admin-guards.test.ts`
- Create: `app/api/image/__tests__/image-route.test.ts`
**Interfaces:**
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
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
- Consumes: `isAdminAuthenticated()`, `isAdminEnabled()` (`lib/admin/auth.ts`, gemockt); `encodeAssetId()` (`lib/tokens.ts`); `immich.streamAsset()` (gemockt).
- Produces: nichts für spätere Tasks.
- [ ] **Step 1: `vitest.config.ts` erweitern**
Die `include`-Zeile in `test` ersetzen:
```ts
include: ['lib/__tests__/**/*.test.ts', 'app/**/__tests__/**/*.test.ts'],
```
- [ ] **Step 2: Prüfen, dass die Baseline unverändert läuft**
Run: `npm run test:unit`
Erwartung: weiterhin 210 Tests in 19 Dateien — die Erweiterung findet noch keine neuen Dateien.
- [ ] **Step 3: Admin-Guard-Test schreiben**
Erstelle `app/api/admin/__tests__/admin-guards.test.ts`.
Hintergrund: `isAdminAuthenticated()` liest `cookies()` aus `next/headers`, was außerhalb eines Requests wirft. Statt `next/headers` zu mocken, mocken wir `@/lib/admin/auth` — das ist die Schnittstelle, deren Einhaltung geprüft wird. `lib/__tests__/immich.test.ts` nutzt dasselbe `vi.mock`-Muster.
```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: vi.fn(),
isAdminAuthenticated: vi.fn(),
COOKIE_NAME: 'folio_admin_session',
}));
import { isAdminEnabled, isAdminAuthenticated } from '@/lib/admin/auth';
const mockEnabled = isAdminEnabled as unknown as ReturnType<typeof vi.fn>;
const mockAuthed = isAdminAuthenticated as unknown as ReturnType<typeof vi.fn>;
/**
* Every guarded admin route handler. Adding a new /api/admin route means
* adding a row here — a route that forgets its guard then fails this suite
* instead of shipping. /api/admin/auth is deliberately absent: it is the login
* endpoint and must stay reachable without a session.
*/
const ROUTES: {
name: string;
path: string;
load: () => Promise<Record<string, unknown>>;
method: 'GET' | 'PUT' | 'POST' | 'DELETE';
args: () => unknown[];
}[] = [
{
name: 'GET /api/admin/gallery',
path: 'gallery',
load: () => import('../gallery/route'),
method: 'GET',
args: () => [],
},
{
name: 'PUT /api/admin/gallery',
path: 'gallery',
load: () => import('../gallery/route'),
method: 'PUT',
args: () => [new Request('http://localhost/api/admin/gallery', { method: 'PUT', body: '{}' })],
},
{
name: 'GET /api/admin/settings',
path: 'settings',
load: () => import('../settings/route'),
method: 'GET',
args: () => [],
},
{
name: 'PUT /api/admin/settings',
path: 'settings',
load: () => import('../settings/route'),
method: 'PUT',
args: () => [new Request('http://localhost/api/admin/settings', { method: 'PUT', body: '{}' })],
},
{
name: 'GET /api/admin/albums',
path: 'albums',
load: () => import('../albums/route'),
method: 'GET',
args: () => [],
},
{
name: 'POST /api/admin/reload',
path: 'reload',
load: () => import('../reload/route'),
method: 'POST',
args: () => [],
},
{
name: 'GET /api/admin/status',
path: 'status',
load: () => import('../status/route'),
method: 'GET',
args: () => [],
},
{
name: 'GET /api/admin/assets',
path: 'assets',
load: () => import('../assets/route'),
method: 'GET',
args: () => [new Request('http://localhost/api/admin/assets')],
},
{
name: 'GET /api/admin/thumbnail/[id]',
path: 'thumbnail/[id]',
load: () => import('../thumbnail/[id]/route'),
method: 'GET',
args: () => [
new Request('http://localhost/api/admin/thumbnail/abc'),
{ params: Promise.resolve({ id: 'abc' }) },
],
},
{
name: 'GET /api/admin/albums/[albumId]/assets',
path: 'albums/[albumId]/assets',
load: () => import('../albums/[albumId]/assets/route'),
method: 'GET',
args: () => [
new Request('http://localhost/api/admin/albums/a1/assets'),
{ params: Promise.resolve({ albumId: 'a1' }) },
],
},
];
describe('admin route guards', () => {
beforeEach(() => {
vi.clearAllMocks();
});
for (const route of ROUTES) {
it(`${route.name} returns 403 when the admin panel is disabled`, async () => {
mockEnabled.mockReturnValue(false);
mockAuthed.mockResolvedValue(false);
const mod = await route.load();
const handler = mod[route.method] as (...a: unknown[]) => Promise<Response>;
const res = await handler(...route.args());
expect(res.status).toBe(403);
});
it(`${route.name} returns 401 without a valid session`, async () => {
mockEnabled.mockReturnValue(true);
mockAuthed.mockResolvedValue(false);
const mod = await route.load();
const handler = mod[route.method] as (...a: unknown[]) => Promise<Response>;
const res = await handler(...route.args());
expect(res.status).toBe(401);
});
}
it('has a row for every route module under app/api/admin', async () => {
const fs = await import('fs');
const path = await import('path');
const adminDir = path.join(process.cwd(), 'app/api/admin');
const found: string[] = [];
const walk = (dir: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === '__tests__') continue;
walk(full);
} else if (entry.name === 'route.ts') {
found.push(path.relative(adminDir, path.dirname(full)));
}
}
};
walk(adminDir);
const covered = new Set(ROUTES.map((r) => r.path));
const uncovered = found.filter((p) => p !== 'auth' && !covered.has(p));
expect(uncovered, `Admin routes with no guard test: ${uncovered.join(', ')}`).toEqual([]);
});
});
```
- [ ] **Step 4: Admin-Test laufen lassen**
Run: `npm run test:unit -- app/api/admin/__tests__/admin-guards.test.ts`
Erwartung: 21 Tests PASS (10 Routen × 2 plus der Abdeckungstest).
Schlägt der Abdeckungstest fehl, nennt die Meldung die fehlenden Pfade — dann die entsprechende Zeile in `ROUTES` ergänzen.
- [ ] **Step 5: Beweisen, dass der Guard-Test greift**
In `app/api/admin/status/route.ts` die beiden Guard-Blöcke vorübergehend auskommentieren.
Run: `npm run test:unit -- app/api/admin/__tests__/admin-guards.test.ts`
Erwartung: die zwei `GET /api/admin/status`-Tests schlagen fehl.
Auskommentierung rückgängig machen, erneut laufen lassen. Erwartung: alles grün.
- [ ] **Step 6: Bild-Proxy-Test schreiben**
Erstelle `app/api/image/__tests__/image-route.test.ts`:
```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { GET } from '../[id]/route';
import { encodeAssetId } from '@/lib/tokens';
import { immich } from '@/lib/immich';
vi.mock('@/lib/immich', async () => {
const actual = await vi.importActual<typeof import('@/lib/immich')>('@/lib/immich');
return {
// Keep the real error class — the route branches on it.
ImmichUnavailableError: actual.ImmichUnavailableError,
immich: { streamAsset: vi.fn() },
};
});
const mockStream = immich.streamAsset as unknown as ReturnType<typeof vi.fn>;
const ASSET_ID = '11111111-2222-3333-4444-555555555555';
/** Stream stub — the route only forwards the body, it never reads it. */
function fakeBody(contentType: string) {
return {
stream: new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
controller.close();
},
}),
contentType,
contentLength: '3',
};
}
function call(token: string, query = '', headers?: Record<string, string>) {
const req = new NextRequest(`http://localhost/api/image/${token}${query}`, { headers });
return GET(req, { params: Promise.resolve({ id: token }) });
}
describe('GET /api/image/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('rejects a raw Immich UUID instead of an encoded token', async () => {
const res = await call(ASSET_ID);
expect(res.status).toBe(400);
expect(mockStream).not.toHaveBeenCalled();
});
it('rejects a syntactically broken token', async () => {
const res = await call('v2:not-valid-base64url!!');
expect(res.status).toBe(400);
expect(mockStream).not.toHaveBeenCalled();
});
it('serves a valid token', async () => {
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const res = await call(encodeAssetId(ASSET_ID));
expect(res.status).toBe(200);
expect(mockStream).toHaveBeenCalledWith(ASSET_ID, 'preview');
});
// Regression guard for the stored-XSS fix in c2fa8e7. An SVG served as
// image/svg+xml executes script in the browser under our own origin.
// This logic lives in the route and cannot be extracted the way
// lib/imageSize.ts was, so a route-level test is the only way to pin it.
it.each([
['image/svg+xml', 'application/octet-stream'],
['text/xml', 'application/octet-stream'],
['text/html', 'application/octet-stream'],
['application/octet-stream', 'image/jpeg'],
['image/jpeg', 'image/jpeg'],
['image/webp', 'image/webp'],
])('rewrites upstream Content-Type %s to %s', async (upstream, expected) => {
mockStream.mockResolvedValue(fakeBody(upstream));
const res = await call(encodeAssetId(ASSET_ID));
expect(res.headers.get('Content-Type')).toBe(expected);
});
it('returns 304 with no body when the ETag matches', async () => {
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const token = encodeAssetId(ASSET_ID);
const first = await call(token);
const etag = first.headers.get('ETag');
expect(etag).toBeTruthy();
const res = await call(token, '', { 'if-none-match': etag as string });
expect(res.status).toBe(304);
expect(await res.text()).toBe('');
});
it('never leaks the raw asset UUID in response headers', async () => {
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const res = await call(encodeAssetId(ASSET_ID));
const headerDump = JSON.stringify([...res.headers.entries()]);
expect(headerDump).not.toContain(ASSET_ID);
});
it('404s when the asset is genuinely gone', async () => {
mockStream.mockResolvedValue(null);
const res = await call(encodeAssetId(ASSET_ID));
expect(res.status).toBe(404);
expect(res.headers.get('Cache-Control')).toBe('no-store');
});
it('503s when Immich is unavailable, and does not let it be cached', async () => {
const { ImmichUnavailableError } = await import('@/lib/immich');
mockStream.mockRejectedValue(new ImmichUnavailableError('upstream down'));
const res = await call(encodeAssetId(ASSET_ID));
expect(res.status).toBe(503);
expect(res.headers.get('Cache-Control')).toBe('no-store');
expect(res.headers.get('Retry-After')).toBeTruthy();
});
});
```
- [ ] **Step 7: Bild-Test laufen lassen**
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:
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
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
- 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.
- [ ] **Step 8: Beweisen, dass der XSS-Test greift**
In `app/api/image/[id]/route.ts` die Normalisierung vorübergehend durch `const contentType = result.contentType.toLowerCase();` ersetzen und die nachfolgenden `if`-Blöcke auskommentieren.
Run: `npm run test:unit -- app/api/image/__tests__/image-route.test.ts`
Erwartung: die Fälle `image/svg+xml`, `text/xml`, `text/html`, `application/octet-stream` schlagen fehl.
Änderung rückgängig machen. Erwartung: alles grün.
- [ ] **Step 9: Gesamtprüfung**
Run: `npm run test:unit`
Erwartung: 243 Tests in 21 Dateien (210 + 21 + 12).
Run: `npx tsc --noEmit`
Erwartung: 0 Fehler.
Run: `npm run lint`
Erwartung: 0 Fehler.
- [ ] **Step 10: Commit**
```bash
npx prettier --write vitest.config.ts app/api/admin/__tests__/admin-guards.test.ts app/api/image/__tests__/image-route.test.ts
git add vitest.config.ts app/api/admin/__tests__/admin-guards.test.ts app/api/image/__tests__/image-route.test.ts
git commit -m "test: enable route-handler tests and cover admin guards and image content-type"
```
---
## Task 2: Stale-while-error
**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).
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
**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.
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
**Files:**
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
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
- Modify: `lib/cache.ts`
- Create: `lib/__tests__/cache-stale.test.ts`
- Modify: `lib/env.ts`, `.env.local.example`
- Modify: `lib/config/schema.ts`, `lib/config/index.ts`
- Modify: `lib/immich.ts`, `lib/__tests__/immich.test.ts`
**Interfaces:**
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
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
- 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.
- `cache.getStale<T>(key): T | null` — auch abgelaufene, solange die harte Obergrenze hält.
- `cache.set<T>(key, data, ttlMs, staleMaxAgeMs?)` — vierter Parameter optional, Default 24 h.
- `AppConfig.staleMaxAge: number` (Millisekunden).
- [ ] **Step 1: Cache-Test schreiben**
Erstelle `lib/__tests__/cache-stale.test.ts`:
```ts
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { cache } from '../cache';
describe('MemoryCache stale window', () => {
beforeEach(() => {
cache.clear();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns fresh entries from get()', () => {
cache.set('k', { v: 1 }, 1000);
expect(cache.get<{ v: number }>('k')).toEqual({ v: 1 });
});
it('returns null from get() once the ttl elapsed', () => {
cache.set('k', { v: 1 }, 1000);
vi.advanceTimersByTime(1001);
expect(cache.get('k')).toBeNull();
});
it('still returns the value from getStale() after the ttl elapsed', () => {
cache.set('k', { v: 1 }, 1000, 60_000);
vi.advanceTimersByTime(1001);
expect(cache.getStale<{ v: number }>('k')).toEqual({ v: 1 });
});
it('drops the entry from getStale() past the hard max age', () => {
cache.set('k', { v: 1 }, 1000, 5000);
vi.advanceTimersByTime(5001);
expect(cache.getStale('k')).toBeNull();
});
it('does not let get() destroy the stale fallback', () => {
// get() must not delete an expired entry — otherwise the fallback is gone
// the moment any caller asks for fresh data first.
cache.set('k', { v: 1 }, 1000, 60_000);
vi.advanceTimersByTime(1001);
expect(cache.get('k')).toBeNull();
expect(cache.getStale<{ v: number }>('k')).toEqual({ v: 1 });
});
it('never expires stale before the ttl itself', () => {
// A misconfigured STALE_MAX_AGE below CACHE_TTL must not shorten the fresh
// window.
cache.set('k', { v: 1 }, 10_000, 1000);
vi.advanceTimersByTime(9000);
expect(cache.get<{ v: number }>('k')).toEqual({ v: 1 });
});
it('evicts the oldest entry when capacity is exceeded', () => {
for (let i = 0; i < 201; i++) cache.set(`k${i}`, i, 60_000);
expect(cache.get('k0')).toBeNull();
expect(cache.get('k200')).toBe(200);
});
it('delete() removes the entry from get() and getStale()', () => {
cache.set('k', { v: 1 }, 1000, 60_000);
cache.delete('k');
expect(cache.get('k')).toBeNull();
expect(cache.getStale('k')).toBeNull();
});
});
```
- [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen**
Run: `npm run test:unit -- lib/__tests__/cache-stale.test.ts`
Erwartung: FAIL — `cache.getStale is not a function`.
- [ ] **Step 3: `lib/cache.ts` implementieren**
Vollständig ersetzen:
```ts
/**
* Simple in-memory LRU cache for Immich API responses.
* Avoids hammering the Immich server with identical requests.
*
* Entries carry two deadlines. `staleAt` is the normal TTL — past it, get()
* reports a miss and callers refetch. `hardExpiresAt` is the point past which
* the data is too old to show at all. Between the two, the entry survives as a
* fallback that getStale() hands out when Immich is unreachable, so a
* restarting server does not turn the gallery into an error page.
*/
interface CacheEntry<T> {
data: T;
staleAt: number;
hardExpiresAt: number;
}
const DEFAULT_STALE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
class MemoryCache {
private store = new Map<string, CacheEntry<unknown>>();
private maxEntries = 200;
get size(): number {
return this.store.size;
}
/** Move an entry to the newest end of the LRU order. */
private touch(key: string, entry: CacheEntry<unknown>): void {
this.store.delete(key);
this.store.set(key, entry);
}
/** Fresh data only. Returns null once the TTL has elapsed. */
get<T>(key: string): T | null {
const entry = this.store.get(key);
if (!entry) return null;
const now = Date.now();
if (now > entry.hardExpiresAt) {
this.store.delete(key);
return null;
}
// Past the TTL the entry is no longer fresh, but it is deliberately kept
// so getStale() can still use it.
if (now > entry.staleAt) return null;
this.touch(key, entry);
return entry.data as T;
}
/**
* Fresh *or* stale data. Only for the upstream-unreachable path — prefer
* get() everywhere else.
*/
getStale<T>(key: string): T | null {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.hardExpiresAt) {
this.store.delete(key);
return null;
}
this.touch(key, entry);
return entry.data as T;
}
set<T>(key: string, data: T, ttlMs: number, staleMaxAgeMs = DEFAULT_STALE_MAX_AGE_MS): void {
if (this.store.has(key)) {
this.store.delete(key);
} else if (this.store.size >= this.maxEntries) {
const firstKey = this.store.keys().next().value;
if (firstKey) this.store.delete(firstKey);
}
const now = Date.now();
this.store.set(key, {
data,
staleAt: now + ttlMs,
// Measured from write time, and never shorter than the TTL itself.
hardExpiresAt: now + Math.max(ttlMs, staleMaxAgeMs),
});
}
delete(key: string): void {
this.store.delete(key);
}
clear(): void {
this.store.clear();
}
}
export const cache = new MemoryCache();
export { DEFAULT_STALE_MAX_AGE_MS };
```
- [ ] **Step 4: Cache-Tests prüfen**
Run: `npm run test:unit -- lib/__tests__/cache-stale.test.ts`
Erwartung: 8 Tests PASS.
Run: `npm run test:unit`
Erwartung: alle 218 Tests grün. Schlägt `config-cache.test.ts` oder `immich.test.ts` fehl, weil ein Test auf das Löschen beim Ablauf gebaut hat, den Test auf `getStale()` umstellen — nicht die Implementierung.
- [ ] **Step 5: `STALE_MAX_AGE` durch Env und Config reichen**
In `lib/env.ts` im `Env`-Interface direkt nach `CACHE_TTL: number;` ergänzen:
```ts
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
STALE_MAX_AGE: number;
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
```
In `parseEnv()` nach dem `cacheTtl`-Block einfügen:
```ts
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
// 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
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
```
Im Rückgabeobjekt nach `CACHE_TTL: Math.max(0, cacheTtl),` einfügen:
```ts
STALE_MAX_AGE: Math.max(0, staleMaxAge),
```
In `lib/config/schema.ts` im `AppConfig`-Interface neben `cacheTtl` ergänzen:
```ts
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
staleMaxAge: number;
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
```
In `lib/config/index.ts` **beide** Rückgabepfade ergänzen — jeweils direkt nach `cacheTtl: env.CACHE_TTL * 1000,` (Zeilen 278 und 358):
```ts
staleMaxAge: env.STALE_MAX_AGE * 1000,
```
In `.env.local.example` unterhalb des `CACHE_TTL`-Eintrags:
```sh
# How long (in seconds) an expired cache entry may still be served while the
# Immich server is unreachable. Keeps the public gallery online across an
# Immich restart instead of showing an error page. Set to 0 to disable.
# Default: 86400 (24 hours)
STALE_MAX_AGE=86400
```
- [ ] **Step 6: Typecheck**
Run: `npx tsc --noEmit`
Erwartung: 0 Fehler. Meldet `config-safe.test.ts` oder `config.test.ts` das neue Pflichtfeld, dort `staleMaxAge` in den gemockten Env- bzw. Erwartungsobjekten ergänzen.
- [ ] **Step 7: Immich-Test für den Stale-Fallback schreiben**
An `lib/__tests__/immich.test.ts` anhängen. Zuvor im dortigen `vi.mock('../config', …)`-Block bei den anderen Feldern ergänzen:
```ts
staleMaxAge: 86_400_000,
```
Dann ans Dateiende:
```ts
describe('stale fallback when Immich is unavailable', () => {
beforeEach(() => {
vi.clearAllMocks();
cache.clear();
});
it('serves the previously cached album list instead of throwing', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: async () => [
{
id: 'album-1',
albumName: 'Original',
description: '',
albumThumbnailAssetId: null,
assetCount: 1,
assets: [],
createdAt: '',
updatedAt: '',
order: 'desc',
},
],
});
const fresh = await immich.getAlbums();
expect(fresh).toHaveLength(1);
vi.useFakeTimers();
vi.advanceTimersByTime(60_000);
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const stale = await immich.getAlbums();
vi.useRealTimers();
expect(stale).toHaveLength(1);
expect(stale[0].id).toBe('album-1');
});
it('still throws when there is nothing cached to fall back to', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(immich.getAlbums()).rejects.toThrow(ImmichUnavailableError);
});
it('does not swallow a definitive 404 as an outage', async () => {
// A missing album must keep reporting missing, not fall back to stale data.
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
headers: { get: () => 'application/json' },
});
await expect(immich.getAlbum('album-1')).resolves.toBeNull();
});
it('still refuses albums outside the allowlist', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(immich.getAlbum('not-allowed')).resolves.toBeNull();
});
});
```
Der Import am Dateikopf muss `ImmichUnavailableError` mitführen:
```ts
import { immich, ImmichUnavailableError } from '../immich';
```
- [ ] **Step 8: Test laufen lassen und Fehlschlag prüfen**
Run: `npm run test:unit -- lib/__tests__/immich.test.ts`
Erwartung: Der erste neue Test schlägt fehl — `getAlbums()` wirft, statt die gecachte Liste zu liefern.
- [ ] **Step 9: Stale-Fallback in `lib/immich.ts` einbauen**
Direkt nach dem `private get config()`-Getter einen Helfer einfügen:
```ts
/** Cache write that carries the configured stale window. */
private cacheSet<T>(key: string, data: T): void {
cache.set(key, data, this.config.cacheTtl, this.config.staleMaxAge);
}
/**
* Last resort when Immich is unavailable: hand back the most recent known
* good answer rather than failing the page. Only successes ever reach the
* cache, so this cannot resurrect an outage — and past staleMaxAge the entry
* is gone and the error propagates as before.
*/
private staleOrThrow<T>(cacheKey: string, error: unknown, label: string): T {
if (error instanceof ImmichUnavailableError) {
const stale = cache.getStale<T>(cacheKey);
if (stale !== null) {
console.warn(`[Immich] ⚠️ Upstream unavailable — serving stale ${label}`);
return stale;
}
}
throw error;
}
```
All drei Methoden haben heute die Form `try { … } finally { … }` ohne `catch` — ein geworfener `ImmichUnavailableError` läuft ungefangen durch das `finally` und propagiert. Vor jedes bestehende `finally` wird ein `catch` eingefügt.
In `getAlbums()` den Block
```ts
} finally {
this.pendingAlbumsPromise = null;
}
```
ersetzen durch:
```ts
} catch (error) {
return this.staleOrThrow<ImmichAlbum[]>(cacheKey, error, 'album list');
} finally {
this.pendingAlbumsPromise = null;
}
```
In `getAlbum()` den Block
```ts
} finally {
this.pendingAlbumPromises.delete(albumId);
}
```
ersetzen durch:
```ts
} catch (error) {
return this.staleOrThrow<ImmichAlbum>(cacheKey, error, `album ${albumId}`);
} finally {
this.pendingAlbumPromises.delete(albumId);
}
```
In `getAssetInfo()` den Block
```ts
} finally {
this.pendingAssetPromises.delete(assetId);
}
```
ersetzen durch:
```ts
} catch (error) {
return this.staleOrThrow<ImmichAsset>(cacheKey, error, `asset ${assetId}`);
} finally {
this.pendingAssetPromises.delete(assetId);
}
```
`lib/immich.ts` enthält fünf `cache.set(cacheKey, …, this.config.cacheTtl)`-Aufrufe. Nur die drei mit einem echten Ergebnis umstellen:
- `cache.set(cacheKey, filtered, this.config.cacheTtl);` in `getAlbums()` → `this.cacheSet(cacheKey, filtered);`
- `cache.set(cacheKey, album, this.config.cacheTtl);` in `getAlbum()` → `this.cacheSet(cacheKey, album);`
- `cache.set(cacheKey, asset, this.config.cacheTtl);` in `getAssetInfo()` → `this.cacheSet(cacheKey, asset);`
**Nicht umstellen:** die beiden `cache.set(cacheKey, MISSING, this.config.cacheTtl);`-Aufrufe (in `getAlbum()` und `getAssetInfo()`, direkt nach `if (!album) {`/`if (!asset) {`). Ein „existiert nicht" soll nach der normalen TTL neu geprüft werden und nicht 24 Stunden festhängen — genau das war die Begründung in #394.
- [ ] **Step 10: Tests prüfen**
Run: `npm run test:unit -- lib/__tests__/immich.test.ts`
Erwartung: alle Tests PASS, inklusive der vier neuen.
Der dritte neue Test ist der wichtige: Ein definitives 404 darf **nicht** in den Stale-Pfad laufen. `staleOrThrow` prüft deshalb auf `instanceof ImmichUnavailableError` — ein 404 erzeugt gar keinen Fehler, sondern `null`, und erreicht das `catch` nie.
- [ ] **Step 11: Gesamtprüfung**
Run: `npm run test:unit`
Erwartung: 222 Tests grün.
Run: `npx tsc --noEmit`
Erwartung: 0 Fehler.
Run: `npm run lint`
Erwartung: 0 Fehler.
Run: `npm run build`
Erwartung: Build erfolgreich.
- [ ] **Step 12: Commit**
```bash
npx prettier --write lib/cache.ts lib/immich.ts lib/env.ts lib/config/schema.ts lib/config/index.ts lib/__tests__/cache-stale.test.ts lib/__tests__/immich.test.ts .env.local.example
git add lib/cache.ts lib/immich.ts lib/env.ts lib/config/schema.ts lib/config/index.ts lib/__tests__/cache-stale.test.ts lib/__tests__/immich.test.ts .env.local.example
git commit -m "feat: serve stale cache entries while Immich is unavailable"
```
---
## Task 3: Lade-Skeletons
**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:**
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
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
- Create: `app/loading.module.css`
- Create: `app/loading.tsx`
- Create: `app/[...path]/loading.tsx`
**Interfaces:**
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
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
- Consumes: Tokens aus `app/tokens.css`; die Grid-Variablen, die `app/[...path]/page.tsx` setzt.
- Produces: nichts.
- [ ] **Step 1: `app/loading.module.css` anlegen**
```css
/* Skeleton placeholders shown while a dynamic page waits on Immich. */
.grid {
display: grid;
grid-template-columns: repeat(var(--grid-columns, 3), 1fr);
gap: var(--grid-gap, 12px);
}
.tile {
aspect-ratio: var(--grid-aspect-ratio, 1);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
animation: skeleton-pulse 1.6s ease-in-out infinite;
}
.heading {
height: 2.5rem;
width: min(320px, 60%);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
margin-bottom: 24px;
animation: skeleton-pulse 1.6s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
@media (prefers-reduced-motion: reduce) {
.tile,
.heading {
animation: none;
}
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
```
- [ ] **Step 2: `app/[...path]/loading.tsx` anlegen**
```tsx
/**
* Instant shell for album and subpage routes. These are force-dynamic and wait
* on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is
* struggling — so without this the viewer stares at a blank page.
*/
import styles from '../loading.module.css';
export default function Loading() {
return (
<>
<div className={styles.heading} aria-hidden="true" />
<div className={styles.grid} role="status" aria-label="Loading photos">
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className={styles.tile} />
))}
</div>
</>
);
}
```
- [ ] **Step 3: `app/loading.tsx` anlegen**
```tsx
/**
* Instant shell for the homepage.
*/
import styles from './loading.module.css';
export default function Loading() {
return (
<div className={styles.grid} role="status" aria-label="Loading gallery">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className={styles.tile} />
))}
</div>
);
}
```
- [ ] **Step 4: Im Browser prüfen**
Run: `npm run dev`
In den DevTools unter „Network" auf „Slow 3G" drosseln, dann eine Album-Seite laden.
Erwartung: Kachel-Skeletons erscheinen sofort und werden von den echten Fotos abgelöst. Kein Layoutsprung, keine leere weiße Seite.
- [ ] **Step 5: Verifizieren**
Run: `npx tsc --noEmit`
Erwartung: 0 Fehler.
Run: `npm run lint`
Erwartung: 0 Fehler.
Run: `npm run build`
Erwartung: Build erfolgreich.
- [ ] **Step 6: Commit**
```bash
npx prettier --write app/loading.tsx "app/[...path]/loading.tsx" app/loading.module.css
git add app/loading.tsx "app/[...path]/loading.tsx" app/loading.module.css
git commit -m "feat: add loading skeletons for dynamic gallery routes"
```
---
## Task 4: Dokumentation
**Files:**
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
Close the gaps left after v0.9.2's outage handling: route tests, stale cache, loading states (#397) * docs: brainstorm resilience/adoption features and plan follow-up work Brainstorm and deep-dive on where Immich Folio breaks under Immich outages and what blocks self-hosted adoption, plus an implementation plan for the remaining gaps after v0.9.2 (route-handler tests, stale-while-error caching, loading skeletons). * test: enable route-handler tests and cover admin guards and image content-type vitest.config.ts only ran lib/__tests__/**, so route handlers were structurally untestable. Two mechanisms live only at the route level and cannot be extracted the way lib/imageSize.ts was: the admin auth guards and the image proxy's Content-Type sanitisation (the stored-XSS fix from c2fa8e7). Both were correct but unverified by any test. Adds a table-driven test over all ten guarded /api/admin/* handlers, including a filesystem walk that fails if a future route is added without a row in the table. Adds a route-level test for /api/image/[id] covering token validation, the six Content-Type rewrite cases, ETag/304, and the 404 vs 503 (ImmichUnavailableError) distinction. Verified both catch real regressions by temporarily removing the admin guards and the Content-Type rewrite and confirming the expected tests fail, then restoring and confirming green again. 134 new tests (244 total, up from 210), tsc 0 errors, build succeeds. * feat: serve stale cache entries while Immich is unavailable v0.9.2 (#394) correctly split "gone" (404/410) from "unavailable" (ImmichUnavailableError), and pages now render a 5xx instead of a false 404 during an outage — but that 5xx still replaced a gallery that had loaded fine seconds earlier. lib/cache.ts deleted expired entries on access, so nothing survived long enough to fall back to. Cache entries now carry two deadlines: staleAt (the existing CACHE_TTL, after which get() still reports a miss) and hardExpiresAt (the new STALE_MAX_AGE, default 24h, after which the entry is dropped entirely). Between the two, getStale() can still return the data. getAlbums(), getAlbum() and getAssetInfo() catch ImmichUnavailableError and fall back to getStale() before propagating. The two MISSING-sentinel writes (definitive 404s) are deliberately left on the short cacheTtl window — a corrected asset ID should take effect after the normal TTL, not sit in the 24h stale window meant for outages. STALE_MAX_AGE=0 disables the fallback. Verified the fallback only engages on a real outage, not on a cache hit at the TTL boundary, and that a definitive 404 still reports missing rather than falling back. 256 tests (up from 244), tsc 0 errors, build succeeds. * feat: add loading skeletons for dynamic gallery routes Every gallery route is force-dynamic and waits on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is struggling — with nothing rendered until it resolves. loading.tsx renders instantly while the page component awaits its data. Not browser-verified against live data: this worktree has no .env.local and only .example content files, so the app boots into setup mode (confirmed the dev server starts cleanly and the setup screen renders, HTTP 200). tsc 0 errors, build succeeds, no new lint errors. * docs: document stale cache window, image token revocation, route-test convention - README: STALE_MAX_AGE env var and a "Behaviour when Immich is unreachable" section (stale-serves-until-STALE_MAX_AGE, 503 not 404, outages never cached). - docs/gallery-config.md: new "Image Token Revocation" subsection under System & Security — un-publishing an album does not revoke already issued image URLs; rotating AUTH_SECRET does. - CLAUDE.md: updated cache description to cover the stale window and MISSING sentinel, and a new code-convention note that route-level logic (guards, header sanitisation) should get a route-level test rather than being extracted into lib/ just to reach the test runner. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-08-07 11:07:34 +02:00
- Modify: `README.md`
- Modify: `CLAUDE.md`
- [ ] **Step 1: `README.md` ergänzen**
Bei den Umgebungsvariablen, neben `CACHE_TTL` und `IMMICH_TIMEOUT_MS`:
```md
| `STALE_MAX_AGE` | `86400` | Seconds an expired cache entry may still be served while Immich is unreachable. Keeps the public gallery online across an Immich restart. Set to `0` to disable. |
```
Und einen Abschnitt:
```md
### Behaviour when Immich is unreachable
Folio buffers your gallery rather than merely proxying it:
- Album pages keep serving the last known good data for up to `STALE_MAX_AGE`.
- Once nothing cached is left, they return `503`, never `404` — a `404` would
tell search engines to drop a URL that still exists.
- Outages are never cached, so the gallery recovers as soon as Immich does.
The cache lives in the process, so it is empty after a container restart.
```
Zusätzlich im Sicherheitsabschnitt:
```md
### Un-publishing an album does not revoke image links
Asset tokens are deterministic — the same photo always yields the same token,
which is what lets browsers cache images. The image proxy validates the token
but does not re-check album membership per request.
Removing an album from `gallery.yaml` hides it from the site, but image URLs
already handed out keep working. To invalidate them, rotate `AUTH_SECRET` —
this also signs out every password-protected gallery and admin session.
```
- [ ] **Step 2: `CLAUDE.md` aktualisieren**
Den Absatz zur In-Memory-LRU im Abschnitt „Immich client" ersetzen:
```md
- **In-memory LRU cache** (`lib/cache.ts`) — 200-entry LRU. Entries carry two deadlines: `staleAt` (the `CACHE_TTL` window, after which `get()` reports a miss) and `hardExpiresAt` (`STALE_MAX_AGE`, after which the entry is dropped). Between the two, `getStale()` still returns the data — `lib/immich.ts` uses this to keep serving the last known albums when a request fails with `ImmichUnavailableError`. Definitive 404s are cached as a `MISSING` sentinel under the normal TTL and are deliberately excluded from the stale window. Cache keys: `albums-list`, `album-<id>`, `asset-<id>`.
```
Im Abschnitt „Code conventions" ergänzen:
```md
- **Route handlers are testable** — `vitest.config.ts` includes `app/**/__tests__/**/*.test.ts`. Logic that lives in a route (guards, header sanitisation) belongs in a route-level test; do not extract it into `lib/` purely to make it reachable by the test runner.
```
- [ ] **Step 3: Verifizieren**
Run: `npm run format:check`
Erwartung: keine Beanstandungen. Andernfalls `npx prettier --write README.md CLAUDE.md`.
- [ ] **Step 4: Commit**
```bash
git add README.md CLAUDE.md
git commit -m "docs: document stale cache window and route-test convention"
```
---
## Verifikation von Ende zu Ende
**1. Testsuite** — `npm run test:unit`: 222 Tests grün (Baseline 210 + 33 neue − 21 … die exakte Summe ergibt sich aus den Task-Schritten; entscheidend ist, dass keine bestehende Datei rot wird).
**2. Regressionsbremse Admin** — in einer Admin-Route die Guards auskommentieren, `npm run test:unit`: die Guard-Tests schlagen fehl. Zurücknehmen.
**3. Regressionsbremse XSS** — die Content-Type-Normalisierung in `app/api/image/[id]/route.ts` entfernen: die SVG/XML-Fälle schlagen fehl. Zurücknehmen.
**4. Stale-Fallback live** — `npm run dev`, Album-Seite laden (füllt den Cache), dann den Immich-Container stoppen und neu laden.
Erwartung: die Fotos sind weiterhin da; im Log steht `⚠️ Upstream unavailable — serving stale album`.
**5. Kein Cache, kein Immich** — Dev-Server bei gestopptem Immich neu starten, Album-URL aufrufen:
```bash
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/<album-slug>
```
Erwartung: `500` oder `503` — **kein** `404`.
**6. Erholung** — Immich wieder starten, Seite neu laden.
Erwartung: frische Daten, keine eingebrannte Fehlermeldung.
**7. Skeletons** — mit „Slow 3G" eine Album-Seite laden.
Erwartung: sofort Kacheln, kein weißer Bildschirm.