Files
immich-folio/docs/gallery-config.md
Ralksta 716ae14b0f 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

8.4 KiB
Raw Blame History

Gallery Configuration

All gallery structure is defined in content/gallery.yaml. Copy the example to get started:

cp content/gallery.yaml.example content/gallery.yaml

Tip

Prefer a visual interface? Enable the Admin Panel by setting ADMIN_PASSWORD in your environment. It provides a drag-and-drop page builder that writes to these same YAML files automatically.

Contents:

Hero Images

Single image or crossfade carousel on the homepage:

# Single hero image
hero: 00000000-0000-0000-0000-000000000000

# Carousel (crossfade between multiple images)
hero:
  - 00000000-0000-0000-0000-000000000000
  - 11111111-1111-1111-1111-111111111111
  - 22222222-2222-2222-2222-222222222222

Standalone Albums

Albums shown directly on the homepage as cards.

Note

The thumbnail image for each album grid card is automatically synced with the explicit "Cover Image" you select for that album inside the Immich Web UI.

albums:
  - 11111111-1111-1111-1111-111111111111
  - 22222222-2222-2222-2222-222222222222

Subpages

Group multiple albums into named collections. URLs are auto-generated from the name. The overall Subpage Cover Image shown on the homepage is automatically inherited from the Cover Image of the first album inside its list.

subpages:
  - name: Japan # → /japan
    title: 'Trip to Japan' # Optional: overrides the page heading
    subtitle: '2024 adventures' # Optional: adds a subline under the heading
    albums:
      - 33333333-3333-3333-3333-333333333333 # Tokyo
      - 44444444-4444-4444-4444-444444444444 # Kyoto

  - name: Wedding – Smith # → /wedding-smith
    password: clientpass123 # Optional: subpage-level protection
    albums:
      - 55555555-5555-5555-5555-555555555555
      - 66666666-6666-6666-6666-666666666666:
          title: 'Private Highlights'
          password: 'album-secret-123' # Optional: album-level protection

Alternatively, you can use the object notation (recommended):

subpages:
  'Japan':
    title: 'Trip to Japan'
    subtitle: '2024 adventures'
    albums:
      - 33333333-3333-3333-3333-333333333333

Sections

When a subpage contains many albums you can split them into named sections. A typographic table of contents with anchor links is automatically rendered above the albums. Sections are fully optional — omit them and you get the standard flat grid.

Each section can have:

Field Type Required Description
title string ✅ Section heading + anchor name
description string ➖ Optional subline under the heading
albums list ✅ Album UUIDs (same format as regular albums)

Within the albums list you can use a plain UUID, a simple title override, or an object with title and password:

albums:
  - 'album-uuid' # → uses the Immich album name
  - 'album-uuid': My Title # → displays "My Title" instead
  - 'album-uuid':
      title: My Private Title
      password: 'secure-password' # → adds a password gate to this specific album

Full example:

subpages:
  - name: Japan
    title: Japan
    subtitle: 'Travel through a land full of contrasts.'
    sections:
      - title: Tokyo
        description: 'Megacity, neon lights, silence in the noise.'
        albums:
          - '33333333-3333-3333-3333-333333333333'
          - '44444444-4444-4444-4444-444444444444': Shinjuku at night

      - title: Kyoto
        description: 'Temples and bamboo forests.'
        albums:
          - '55555555-5555-5555-5555-555555555555': Fushimi Inari
          - '66666666-6666-6666-6666-666666666666'

      - title: Osaka
        albums: # description is optional
          - '77777777-7777-7777-7777-777777777777'

Note

Each section title is automatically converted to a URL-safe anchor (#tokyo, #kyoto, …). The TOC appearance (separator character, numbering style, section rule) is fully controlled by the active theme.

Grid Layout

Configure the photo grid globally or per-subpage:

# Global defaults
grid:
  columns: 3 # number of columns (default: 3)
  gap: 12 # gap in pixels (default: 12)
  aspectRatio: '1' # "1", "3/2", "auto", etc. (default: "1")
  layout: masonry # "masonry" | "uniform" | "showcase" | "filmstrip" | "editorial-flow"

subpages:
  - name: Japan
    grid: # per-subpage override
      columns: 4
      layout: uniform
      aspectRatio: '3/2'
    albums:
      - ...

EXIF on Hover

Show camera/lens/settings when hovering over photos (enabled by default):

exifOnHover: false # set to false to disable

Optional minimal footer with social links:

footer:
  name: John Doe
  instagram: johndoe
  email: hello@example.com
  website: https://example.com

About Page

Create content/about.md with YAML frontmatter:

---
portrait: asset-uuid-for-your-portrait
name: Your Name
location: City, Country
gear:
  - Camera Body
  - Favorite Lens
---

Your bio text here. Supports full Markdown.

Finding UUIDs

  • Album UUIDs: In Immich, go to Albums → click an album → the UUID is in the URL bar
  • Asset UUIDs: Click any photo → the UUID is in the URL bar

System & Security

Advanced system settings are configured via environment variables in your .env or .env.local file.

Rate Limiting

To protect against brute-force attacks and resource exhaustion, Immich Folio includes an in-memory rate limiter.

  • RATE_LIMIT_RPM: Maximum requests per minute per IP (default: 1500 for general API, 120 for map data).

Trusted Proxies

If you run Immich Folio behind a reverse proxy (nginx, Traefik, Caddy, Cloudflare), tell it how many proxies sit in front. Without this, the rate limiter reads a client-supplied header and an attacker can pick their own bucket — defeating brute-force protection on the password endpoints.

  • TRUSTED_PROXY_HOPS: Number of reverse proxies in front of the app (default: 0).
# nginx / Traefik / Caddy directly in front of the app
TRUSTED_PROXY_HOPS=1

# Cloudflare in front of nginx
TRUSTED_PROXY_HOPS=2

The client IP is taken that many entries from the right of X-Forwarded-For. Proxies append the address they actually observed, so everything to the left is client-supplied and ignored — a visitor sending X-Forwarded-For: 1.1.1.1 cannot influence which bucket they land in.

⚠️ The app must be reachable only through the proxy. Bind it to localhost (127.0.0.1:3000) or keep it on an internal Docker network. If clients can connect directly they control the entire header, and no setting can recover the real IP.

Matching nginx config:

location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

$proxy_add_x_forwarded_for is the important one: it appends the real peer address rather than overwriting the header.

Migrating from TRUSTED_PROXIES

TRUSTED_PROXIES has been removed. It matched proxy IPs against the socket peer address, which a self-hosted Next.js server never exposes — so the check never passed. Setting it silently put every visitor into one shared rate-limit bucket, letting a single client trip the limit for the entire site. If it is still set, the app assumes TRUSTED_PROXY_HOPS=1 and logs a warning; replace it with an explicit value.

Image Token Revocation

Asset tokens are deterministic — the same photo always yields the same token, which is what lets browsers cache images for a year. The image proxy validates the token but does not re-check album membership on every request.

Removing an album from gallery.yaml hides it from the site, but any image URL already handed out (bookmarked, embedded, cached by a browser) keeps working. To invalidate previously issued links, rotate AUTH_SECRET — this also signs out every password-protected gallery and admin session.