1088 lines
47 KiB
Markdown
1088 lines
47 KiB
Markdown
# Gallery Configuration
|
||
|
||
Configuration is split across two files in `content/`:
|
||
|
||
| File | Contains |
|
||
| --------------- | ---------------------------------------------------------------------------- |
|
||
| `gallery.yaml` | **Structure** — hero assets, albums, subpages, sections, per-album settings |
|
||
| `settings.yaml` | **Behaviour and identity** — title, theme, grid defaults, footer, legal, SEO |
|
||
|
||
Copy the examples to get started:
|
||
|
||
```bash
|
||
cp content/gallery.yaml.example content/gallery.yaml
|
||
cp content/settings.yaml.example content/settings.yaml
|
||
```
|
||
|
||
`settings.yaml` is optional — without it, everything falls back to defaults.
|
||
|
||
> [!TIP]
|
||
> Prefer a visual interface? Enable the **[Admin Panel](admin-panel.md)** by setting `ADMIN_PASSWORD` in your environment. It provides a drag-and-drop page builder that writes to these same YAML files automatically.
|
||
|
||
**Contents:**
|
||
|
||
`gallery.yaml`
|
||
|
||
- [Hero Images](#hero-images)
|
||
- [Standalone Albums](#standalone-albums)
|
||
- [Subpages & Categories](#subpages)
|
||
- [Per-Album Options](#per-album-options)
|
||
- [Photo Order Within an Album](#photo-order-within-an-album)
|
||
|
||
`settings.yaml`
|
||
|
||
- [Grid Layout](#grid-layout)
|
||
- [Site Behaviour](#site-behaviour)
|
||
- [Navigation Links](#navigation-links)
|
||
- [Client Proofing](#client-proofing)
|
||
- [Client Proofing Links](#client-proofing-links)
|
||
- [Photo Zoom](#photo-zoom)
|
||
- [Image Protection & Watermark](#image-protection--watermark)
|
||
- [SEO](#seo)
|
||
- [Footer](#footer)
|
||
|
||
Other
|
||
|
||
- [About Page](#about-page)
|
||
- [Journal & Photo Essays](journal.md)
|
||
- [Finding UUIDs](#finding-uuids)
|
||
- [System & Security](#system--security)
|
||
|
||
## Hero Images
|
||
|
||
Single image or crossfade carousel on the homepage:
|
||
|
||
```yaml
|
||
# 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.
|
||
|
||
```yaml
|
||
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.
|
||
|
||
```yaml
|
||
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):
|
||
|
||
```yaml
|
||
subpages:
|
||
'Japan':
|
||
title: 'Trip to Japan'
|
||
subtitle: '2024 adventures'
|
||
albums:
|
||
- 33333333-3333-3333-3333-333333333333
|
||
```
|
||
|
||
### Subpage Options
|
||
|
||
| Key | Type | Description |
|
||
| ----------- | ------- | ------------------------------------------------------------------------------------------------ |
|
||
| `name` | string | Navigation label; the URL slug is derived from it |
|
||
| `title` | string | Page heading, defaults to `name` |
|
||
| `subtitle` | string | Subline under the heading |
|
||
| `password` | string | Gates the whole subpage — see [Password Protection](#password-protection) |
|
||
| `albums` | list | Album UUIDs, optionally with [per-album options](#per-album-options) |
|
||
| `sections` | list | Named groups of albums — see [Sections](#sections) |
|
||
| `grid` | object | Grid override for the photos on this page — see [Grid Layout](#grid-layout) |
|
||
| `coverGrid` | object | Grid override for the album covers on this page — see [Album covers](#album-covers-on-a-subpage) |
|
||
| `proofing` | boolean | Enables [client proofing](#client-proofing) on this page |
|
||
| `zoom` | boolean | [Photo zoom](#photo-zoom) for the albums on this page, over the site setting |
|
||
| `essayFile` | string | Render a Markdown essay instead of a grid — see [Journal & Photo Essays](journal.md) |
|
||
| `essayText` | string | Essay Markdown inline; written by the admin block editor |
|
||
| `enabled` | boolean | `false` takes the page offline without deleting it |
|
||
| `hidden` | boolean | **Experimental.** Reachable by direct link, but hidden from the navigation |
|
||
|
||
```yaml
|
||
subpages:
|
||
- name: Client Preview
|
||
hidden: true # unlisted: not in the nav, still reachable at /client-preview
|
||
proofing: true # visitors can mark favourites and export the selection
|
||
password: 'clientpass123'
|
||
albums:
|
||
- 55555555-5555-5555-5555-555555555555
|
||
|
||
- name: Old Series
|
||
enabled: false # offline, keeps its configuration
|
||
albums:
|
||
- 66666666-6666-6666-6666-666666666666
|
||
```
|
||
|
||
> [!NOTE]
|
||
> `hidden` is unlisting, not access control. The URL is guessable — a slug is
|
||
> derived from the name — and nothing stops a visitor who has it. Combine it
|
||
> with `password` for anything that must stay private.
|
||
|
||
### 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 — see [Per-Album Options](#per-album-options).
|
||
|
||
**Full example:**
|
||
|
||
```yaml
|
||
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.
|
||
|
||
## Per-Album Options
|
||
|
||
Every album entry — standalone, on a subpage, or inside a section — accepts the
|
||
same three notations:
|
||
|
||
```yaml
|
||
albums:
|
||
- 'album-uuid' # → uses the Immich album name
|
||
- 'album-uuid': My Title # → displays "My Title" instead
|
||
- 'album-uuid':
|
||
title: My Private Title
|
||
description: 'Shot on 35mm film in Vienna, spring 2025.'
|
||
password: 'secure-password'
|
||
heroImage: 'asset-uuid'
|
||
sort: filename
|
||
download: true
|
||
```
|
||
|
||
| Key | Description |
|
||
| --------------- | ----------------------------------------------------------------------------- |
|
||
| `title` | Display name instead of the Immich album name |
|
||
| `description` | Shown below the album title |
|
||
| `password` | Gates this album alone — see [Password Protection](#password-protection) |
|
||
| `heroImage` | Asset UUID overriding the album cover |
|
||
| `sort` | Photo order — see [Photo Order Within an Album](#photo-order-within-an-album) |
|
||
| `assetOrder` | Pinned asset UUIDs for `sort: manual` |
|
||
| `grid` | **Experimental.** Grid override for this album only |
|
||
| `coverPosition` | **Experimental.** Focal point for the cover crop |
|
||
| `download` | Offer originals for download — see [Originals download](#originals-download) |
|
||
| `zoom` | [Photo zoom](#photo-zoom) for this album, over its page and the site setting |
|
||
|
||
### Per-album grid (experimental)
|
||
|
||
An album can override the grid it is shown in. It is merged over the subpage
|
||
grid, which is merged over the global default — so an album only has to state
|
||
what differs:
|
||
|
||
```yaml
|
||
albums:
|
||
- 'album-uuid':
|
||
title: Panoramas
|
||
grid:
|
||
layout: justified
|
||
columns: 2
|
||
```
|
||
|
||
### Cover focal point (experimental)
|
||
|
||
Album covers are cropped to the card. `coverPosition` decides which part
|
||
survives the crop — the same values CSS `object-position` takes:
|
||
|
||
```yaml
|
||
albums:
|
||
- 'album-uuid':
|
||
coverPosition: '50% 25%' # or: "top", "center bottom", "left"
|
||
```
|
||
|
||
Useful when the automatic centre crop cuts off a horizon or a face.
|
||
|
||
### Originals download
|
||
|
||
Opt in per album with `download: true`. Off everywhere by default, and opt-in
|
||
on purpose: a public portfolio should not start handing out full-resolution
|
||
originals because one client gallery needed to.
|
||
|
||
```yaml
|
||
albums:
|
||
- 'album-uuid':
|
||
download: true
|
||
```
|
||
|
||
Once enabled, visitors get a **Download album** link in the album header (every
|
||
original as a ZIP), and — where [client proofing](#client-proofing) is on — a
|
||
**Download selected (.zip)** action in the export dialog that zips just the
|
||
favourited photos.
|
||
|
||
The download route re-checks the allowlist, the `download` flag, every password
|
||
gate on a route to the album, and that each asset really belongs to that album,
|
||
so an edited URL cannot reach anything else. The ZIP keeps the original
|
||
filenames (deduplicated on collision) and is streamed, so album size does not
|
||
drive memory use.
|
||
|
||
Originals keep their metadata except their location. JPEG, HEIC/HEIF and AVIF
|
||
files are delivered with camera, lens, exposure, copyright/IPTC and colour
|
||
profile intact, but with GPS coordinates removed from EXIF and XMP; the image
|
||
data itself is not touched. Other formats (RAW, DNG, PNG, TIFF, WebP, video) are
|
||
delivered exactly as Immich stores them, including any GPS. The diagnostics page
|
||
warns when a download album contains such files.
|
||
|
||
A photo edited in Immich's own editor (crop, rotate) is downloaded as edited,
|
||
not as the camera file. Immich renders the edit as a full-resolution JPEG
|
||
without EXIF, so that file has no camera data either, and its name gets a
|
||
`.jpg` extension (`IMG_0001.HEIC` arrives as `IMG_0001.jpg`). It passes the
|
||
same location scrubber.
|
||
|
||
One visitor (by client IP, see `TRUSTED_PROXY_HOPS`) can have at most two ZIPs
|
||
downloading at once, album and proofing downloads counted together; a third
|
||
gets a "too many requests" page until one finishes. A ZIP whose download stops
|
||
reading for 60 seconds is closed, so an abandoned or stalled download does not
|
||
hold its connection to Immich open.
|
||
|
||
## Password Protection
|
||
|
||
A password can be set on the whole site, on a subpage, on a single album, and on
|
||
a [journal entry](journal.md#password-protected-entries). Unlocking sets an
|
||
`HttpOnly` cookie that is valid for 24 hours; nothing is stored server-side.
|
||
|
||
Three storage formats are accepted:
|
||
|
||
| Format | Status |
|
||
| ------------------ | --------------------------------------------------------------------- |
|
||
| `scrypt:salt:hash` | **Recommended.** The password is not recoverable from `gallery.yaml`. |
|
||
| Plaintext | Works, but deprecated — logs a warning naming the recommended hash. |
|
||
| bcrypt (`$2a$…`) | Rejected. |
|
||
|
||
To get the hash for an existing plaintext password, unlock the gallery once and
|
||
read the server log: the successful unlock prints the matching `scrypt:…` line
|
||
to paste back into `gallery.yaml`. Simpler: open the page or album in the
|
||
admin panel and save it once. Every password saved there is stored as a
|
||
`scrypt:…` hash, and the field then reads **Protected** with **Change** and
|
||
**Remove** instead of showing the stored value.
|
||
|
||
> [!NOTE]
|
||
> Album and subpage gates protect the **page**. Image URLs handed out while a
|
||
> gallery was unlocked keep working — see
|
||
> [Image Token Revocation](#image-token-revocation).
|
||
|
||
### Locking the whole site
|
||
|
||
`sitePassword` in `settings.yaml` puts everything public behind one password —
|
||
useful while a portfolio is still being built, or for a folio that is only ever
|
||
shown to clients.
|
||
|
||
```yaml
|
||
# settings.yaml
|
||
sitePassword: 'scrypt:...'
|
||
```
|
||
|
||
`SITE_PASSWORD` in the environment overrides the file, so the password can be
|
||
rotated without editing (or backing up) `settings.yaml`. It accepts the same
|
||
three storage formats as every other gate.
|
||
|
||
Unlike the per-page gates, this one is enforced in `proxy.ts`, before the
|
||
requested page renders at all. That matters: a gate that merely swapped the page
|
||
for a login form would still have let the page produce its payload, album names
|
||
and image tokens included. The public API routes carry the same check
|
||
themselves, since route handlers do not pass through the proxy.
|
||
|
||
These stay reachable on a locked site:
|
||
|
||
| Path | Why |
|
||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `/api/health` | A health probe runs without cookies; gating it takes the container down. |
|
||
| `/admin` | It has its own password, and it is where `sitePassword` is set. |
|
||
| `/install` | A fresh deployment has to be configurable before it can be locked. |
|
||
| `/impressum`, `/privacy` | The password page is public and links them; a German Impressum must be reachable directly. Neither shows an album or the site's menu. |
|
||
| `/contact`, `/api/contact` | The Impressum links the form as its second contact channel. Honeypot, fill-time check and rate limit apply as on an open site. |
|
||
|
||
A legal or contact page that is switched off is still a 404 on a locked site.
|
||
|
||
A locked site also serves `noindex, nofollow` regardless of the SEO settings —
|
||
there is nothing there for a crawler.
|
||
|
||
## Photo Order Within an Album
|
||
|
||
By default a Folio album mirrors the Immich timeline. That is usually right, but a curated series has a narrative order that has nothing to do with capture time — and changing the sort on the Immich album would change it for the archive too. The optional `sort` key decouples the two.
|
||
|
||
| Mode | Order |
|
||
| ---------- | --------------------------------------------------------------------------------------- |
|
||
| `immich` | Default. Inherits the album's own `asc`/`desc` setting in Immich. |
|
||
| `newest` | Capture time, newest first, regardless of the Immich setting. |
|
||
| `oldest` | Capture time, oldest first. |
|
||
| `filename` | `originalFileName` in natural order — `IMG_2` before `IMG_10`. |
|
||
| `manual` | Pinned photos first, in the order you set; everything else follows in the Immich order. |
|
||
|
||
```yaml
|
||
albums:
|
||
- 'album-uuid' # no sort key → Immich order, exactly as before
|
||
- 'album-uuid':
|
||
sort: filename # an entry may carry only a sort, with no title override
|
||
- 'album-uuid':
|
||
title: Hokkaido, in sequence
|
||
sort: manual
|
||
assetOrder: # pinned, in this order
|
||
- 'asset-uuid-opening-frame'
|
||
- 'asset-uuid-second-frame'
|
||
```
|
||
|
||
`sort` works the same on standalone, subpage and section albums.
|
||
|
||
### About `manual`
|
||
|
||
`manual` is a pinned **prefix**, not a full permutation: `assetOrder` lists only the photos you placed by hand, and everything else follows in the album's Immich order. Curating an opening sequence therefore costs a handful of UUIDs rather than one per photo in the album.
|
||
|
||
This also makes the album resilient to changes in Immich. Photos removed from the album are ignored, and photos added later appear at the end rather than being hidden — so `gallery.yaml` never has to be kept in sync by hand.
|
||
|
||
The `/admin` page builder has a drag & drop editor for this (album → **Photo order** → **Manual** → **Reorder photos**), which is considerably easier than writing asset UUIDs by hand. It only writes a cleaned-up `assetOrder` when you explicitly apply it.
|
||
|
||
> [!NOTE]
|
||
> Two consequences worth knowing. On a subpage with several albums, `sort` orders photos _within_ each album — the albums themselves stay in the order they are listed in. And lightbox permalinks (`#photo-3`) are positional, so changing an album's sort moves where a previously shared link lands.
|
||
|
||
> [!TIP]
|
||
> `immich` means "inherit the album's sort setting from Immich", which is capture-time order in one direction or the other. It is not the manual drag order from the Immich web UI — Immich does not expose that order through its API, so Folio cannot reproduce it. Use `manual` for a hand-picked sequence.
|
||
|
||
## Grid Layout
|
||
|
||
The global default lives in `settings.yaml`; subpages and individual albums can
|
||
override it in `gallery.yaml`. The three levels merge, most specific winning:
|
||
|
||
**global (`settings.yaml`) → subpage → album**
|
||
|
||
> [!NOTE]
|
||
> Leaving `gap` out is not the same as setting `12`. Each theme preset picks its
|
||
> own photo spacing (2px `minimal`, 32px `editorial`, 40px `monograph`), and an
|
||
> unset `gap` lets that stand. Setting one wins in every preset — which is what
|
||
> `minimal`, `editorial` and `monograph` used to ignore
|
||
> ([#513](https://github.com/ralksta/immich-folio/issues/513)).
|
||
|
||
```yaml
|
||
# settings.yaml — global defaults
|
||
grid:
|
||
columns: 3 # number of columns (default: 3)
|
||
gap: 12 # gap in pixels — unset means the theme preset decides
|
||
aspectRatio: '1' # "1", "3/2", "2/3", "16/9", "auto" (default: "1")
|
||
layout: masonry
|
||
```
|
||
|
||
```yaml
|
||
# gallery.yaml — per-subpage override
|
||
subpages:
|
||
- name: Japan
|
||
grid:
|
||
columns: 4
|
||
layout: uniform
|
||
aspectRatio: '3/2'
|
||
albums:
|
||
- '33333333-3333-3333-3333-333333333333'
|
||
```
|
||
|
||
Available layouts: `masonry`, `uniform`, `showcase`, `filmstrip`,
|
||
`editorial-flow`, `justified` (experimental), and `essay`
|
||
(see [Journal & Photo Essays](journal.md)). Each is illustrated in the
|
||
**[Theming guide](theming.md#gridlayout)**.
|
||
|
||
### Album covers on a subpage
|
||
|
||
A subpage with more than one album shows a grid of album covers rather than
|
||
photos. That grid has its own key, `coverGrid`, which sizes the cover tiles and
|
||
nothing else. `grid` next to it stays what it always was: the photos inside
|
||
those albums.
|
||
|
||
An unset `columns` falls back to the site-wide `grid.columns`, so a page that
|
||
states nothing tiles its covers the way it tiles its photos. Valid range is 1–6;
|
||
below 1024px viewport width at most two covers share a row, and below 640px they
|
||
stack.
|
||
|
||
`gap` is the deliberate exception: it does **not** cascade from the global
|
||
setting to the covers. Every theme preset picks its own cover spacing as part of
|
||
its look — 1px in `monograph`, 2px in `minimal` and `editorial`, 20px in
|
||
`studio-modern` — and letting the site-wide photo gap through would flatten all
|
||
of them into one look. Only an explicit `coverGrid.gap` overrides the preset:
|
||
|
||
```yaml
|
||
# gallery.yaml — the two grids of one page, set independently
|
||
subpages:
|
||
- name: Editorial
|
||
coverGrid:
|
||
columns: 3 # three covers per row (site-wide default otherwise)
|
||
gap: 24 # overrides the theme's own cover spacing
|
||
grid:
|
||
columns: 4 # the photos inside each album
|
||
gap: 6
|
||
albums:
|
||
- '33333333-3333-3333-3333-333333333333'
|
||
- '44444444-4444-4444-4444-444444444444'
|
||
```
|
||
|
||
Both are editable per page in the admin panel, under **Pages › _subpage_ ›
|
||
Album Cover Grid** and **Photo Grid**; left empty they follow the site-wide
|
||
setting and the theme respectively.
|
||
|
||
> [!NOTE]
|
||
> The covers and the photos shared a single `grid` key until
|
||
> [#523](https://github.com/ralksta/immich-folio/issues/523), where a gap typed
|
||
> into the admin's cover field turned out to retune every photo grid on the page
|
||
> as well. A `gallery.yaml` that predates the split has no `coverGrid`, so the
|
||
> covers fall back to `grid` and the page renders exactly as before; the first
|
||
> save from the admin panel writes the two out separately.
|
||
|
||
## Site Behaviour
|
||
|
||
Feature toggles in `settings.yaml`:
|
||
|
||
```yaml
|
||
title: 'My Portfolio'
|
||
subtitle: 'A visual journal'
|
||
lang: 'en'
|
||
|
||
mode: dark # dark (default) | light | auto — what a first-time visitor sees
|
||
exifOnHover: true # the summary overlay on photo grid hover
|
||
exif: # which groups may be shown at all
|
||
camera: true # body, lens, focal length
|
||
settings: true # aperture, shutter speed, ISO
|
||
location: true # city and country
|
||
caption: true # the description written in Immich
|
||
map: true # the interactive world map at /map
|
||
transitions: true # smooth page transitions between routes
|
||
scrollToTop: true # floating back-to-top arrow on long pages
|
||
analytics: true # cookieless view counts, see below
|
||
zoom: false # 1:1 zoom in the lightbox, see Photo Zoom
|
||
|
||
about:
|
||
enabled: true # the /about page, rendered from content/about.md
|
||
```
|
||
|
||
| Key | Default | Notes |
|
||
| --------------- | ------- | ------------------------------------------- |
|
||
| `lang` | `en` | Interface language, `<html lang>`, dates |
|
||
| `mode` | `dark` | Visitor's starting colour mode, see below |
|
||
| `exifOnHover` | on | The grid hover overlay only, see below |
|
||
| `exif.*` | on | Per-group EXIF visibility, see below |
|
||
| `map` | **off** | Opt-in: must be set to `true` explicitly |
|
||
| `transitions` | on | |
|
||
| `scrollToTop` | on | |
|
||
| `analytics` | on | |
|
||
| `zoom` | **off** | Opt-in, see [Photo Zoom](#photo-zoom) |
|
||
| `about.enabled` | on | Needs a `content/about.md` to show anything |
|
||
|
||
### Colour mode
|
||
|
||
`mode` decides what a visitor sees before they touch anything: `dark` (the
|
||
default the site has always had), `light`, or `auto` to follow their operating
|
||
system. It is rendered onto `<html>` server-side, so the first paint is already
|
||
right rather than flipping after the page loads.
|
||
|
||
A visitor's own choice from the header toggle is kept in their browser and
|
||
always wins over this — `mode` only sets the starting point. The light/dark
|
||
switch in the admin panel previews _your_ view and does not change what
|
||
visitors get; that is what this setting is for
|
||
([#512](https://github.com/ralksta/immich-folio/issues/512)).
|
||
|
||
### EXIF visibility
|
||
|
||
`exif` decides what the site may publish about a photo. It governs all three
|
||
places the data appears — the grid hover overlay, the lightbox info panel, and
|
||
the camera line in the album header — so they cannot disagree.
|
||
|
||
`caption` is the description written in Immich. It used to be shown regardless
|
||
of any setting, on the grounds that a caption is editorial rather than
|
||
metadata — which left it as the one field nobody could switch off, and Immich
|
||
descriptions are also where people keep private notes and whatever a decade of
|
||
cataloguing software left behind ([#506](https://github.com/ralksta/immich-folio/issues/506)).
|
||
It is a group like any other now.
|
||
|
||
`exifOnHover` predates the block and stays: it decides whether the grid shows
|
||
its overlay at all, and it supplies the default for the three technical groups,
|
||
so an existing `exifOnHover: false` still means "no technical EXIF anywhere". An
|
||
explicit `exif` key always wins over it. Captions never followed that switch and
|
||
still do not — set `caption: false` to turn them off.
|
||
|
||
With every group off, the lightbox withdraws its info button and the `i` key
|
||
rather than opening an empty panel.
|
||
|
||
### Interface language
|
||
|
||
`lang` picks the language of the visitor-facing interface — navigation, the
|
||
lightbox, the password gate, the proofing dialog, the legal notice, error pages
|
||
— and is also emitted as `<html lang>` and used for date formatting.
|
||
|
||
| Value | Interface |
|
||
| ----- | ----------------- |
|
||
| `en` | English (default) |
|
||
| `de` | German |
|
||
| `fr` | French |
|
||
| `es` | Spanish |
|
||
| `it` | Italian |
|
||
| `nl` | Dutch |
|
||
|
||
Region subtags are accepted and dropped for the interface (`fr-CA` → French,
|
||
`nl-BE` → Dutch), while the full value still reaches `<html lang>`.
|
||
|
||
Any other value (`ja`, `pt`, …) still reaches `<html lang>` and
|
||
`toLocaleDateString`, but the interface stays English: there is no dictionary
|
||
for it yet. That is deliberate — claiming `lang="en"` on a French site would be
|
||
worse for screen readers than an English interface on a page correctly marked
|
||
as Japanese.
|
||
|
||
Two things stay in one language by design:
|
||
|
||
- **The admin panel** is always English, whatever `lang` says.
|
||
- **`/impressum`** keeps its German headings when `lang: de` and the § 5 DDG
|
||
citation in every language, because that is what the statute names
|
||
(`legal.heading` replaces it where another law applies). Under `lang: en`
|
||
the footer link reads "Legal Notice" rather than "Impressum" — the page is
|
||
optional (`legal.enabled`), and an unexplained German word in the footer of
|
||
an English site is what drove this out of the backlog.
|
||
|
||
Adding a language means adding one file under `lib/i18n/locales/` and listing
|
||
it in `SUPPORTED_LOCALES` (`lib/i18n/index.ts`). The dictionary is typed
|
||
against the English one, so a missing key fails the type-check rather than
|
||
falling back silently, and `lib/__tests__/i18n.test.ts` additionally catches
|
||
keys that were copied over but never translated. Words a language genuinely
|
||
shares with English (`ISO`, `Info`, French "photos") go into that locale's
|
||
allowance list in the same test, which in turn fails if an entry stops being
|
||
identical. Add the language to the **Language** select in
|
||
`app/admin/components/SettingsEditor.tsx` so it can be picked from the admin
|
||
panel.
|
||
|
||
### Analytics
|
||
|
||
Page and album view counts, stored in `content/analytics.json` — no cookies, no
|
||
third party, nothing leaving your server. The numbers are shown in the admin
|
||
panel's **Analytics** tab.
|
||
|
||
```yaml
|
||
analytics: false # stop counting entirely
|
||
```
|
||
|
||
With `analytics: false` the tracking endpoint refuses to record, so no data is
|
||
collected regardless of what the browser sends.
|
||
|
||
## Navigation Links
|
||
|
||
**Experimental.** External links appended to the header navigation. Only
|
||
`http(s)` URLs are accepted, and they open in a new tab:
|
||
|
||
```yaml
|
||
navLinks:
|
||
- label: 'Shop'
|
||
url: 'https://shop.example.com'
|
||
- label: 'Instagram'
|
||
url: 'https://instagram.com/your-handle'
|
||
```
|
||
|
||
## Client Proofing
|
||
|
||
Lets a visitor mark favourites and export the selection — for handing a client a
|
||
gallery and getting their picks back. A heart button sits on every photo in the
|
||
grid; once something is picked, a bar appears with a filter and an export
|
||
dialog.
|
||
|
||
The selection is encoded into the URL as a compact bitmask and mirrored into
|
||
`localStorage`, so **nothing is stored server-side** and a set of picks can be
|
||
shared, bookmarked, or sent back as a plain link.
|
||
|
||
When an album also opts into [originals download](#originals-download), the
|
||
export dialog gains a **Download selected (.zip)** action, and the album header
|
||
a **Download album** link.
|
||
|
||
Configured in `settings.yaml`:
|
||
|
||
```yaml
|
||
proofing:
|
||
enabled: true # hearts, selection bar and export modal (default: true)
|
||
allowMailto: true # offer "send by email" alongside the copyable list (default: true)
|
||
email: proofs@example.com # recipient of that email (default: footer.email)
|
||
```
|
||
|
||
The email draft goes to `proofing.email`, or to the footer contact email
|
||
(`footer.email`) when that is unset. With neither configured the dialog hides
|
||
the email button rather than opening a draft with an empty **To:**. The address
|
||
is set in the admin panel under **Settings → General → Proofing email**, and it
|
||
reaches the page encoded like the footer address, so it does not appear in the
|
||
HTML in plain text.
|
||
|
||
A subpage overrides `enabled` in either direction — useful to keep proofing off
|
||
across a public portfolio and switch it on for a single client handover:
|
||
|
||
```yaml
|
||
# gallery.yaml
|
||
subpages:
|
||
- name: Wedding – Smith
|
||
password: clientpass123
|
||
proofing: true # or `false` to disable it on this subpage only
|
||
albums:
|
||
- ...
|
||
```
|
||
|
||
Precedence is **subpage → global**: a subpage's own `proofing:` wins, and an
|
||
album reached without a subpage follows `proofing.enabled`.
|
||
|
||
> [!NOTE]
|
||
> Photo essays are the exception. A published story is not an album handover,
|
||
> so an essay (`layout: essay`, `essayFile`, `essayText`) shows the proofing
|
||
> controls only when its subpage sets `proofing: true` **explicitly** — the
|
||
> global default does not reach into essays. Journal entries never show them.
|
||
|
||
## Client Proofing Links
|
||
|
||
The proofing above is anonymous: picks live in the visitor's browser and come
|
||
back to you as a link or an email, if they come back at all. A **client
|
||
proofing link** is the handover version. Create one per client in
|
||
**Admin → Proofing**:
|
||
|
||
- **Client** — a name for your overview and the greeting on the page.
|
||
- **Album** — any Immich album. It does not have to be in `gallery.yaml`; the
|
||
link itself is what grants access to it.
|
||
- **Valid until** — optional. From the day after, the link shows "This link has
|
||
expired" and every action on it is refused.
|
||
- **Downloads** — none, the client's selection as a ZIP, or the whole album as
|
||
a ZIP, with an optional limit on how many ZIPs the link may start.
|
||
|
||
The client opens `https://your-site/proof/<token>` and hearts photos as usual.
|
||
Every change is **saved on the server** within a second, so they can stop and
|
||
come back later, from another device too. **Submit selection** locks it and
|
||
notifies you. Until then, you can watch the picks come in under
|
||
**Admin → Proofing**. **Reopen for changes** unlocks a submitted selection.
|
||
|
||
**Export.** Each link offers:
|
||
|
||
- **Copy file names (Lightroom)** — the selected file names without extension,
|
||
comma-separated. Pasted into Lightroom's or Capture One's filename filter,
|
||
`IMG_0412` finds the RAW as well as the JPEG the client saw.
|
||
- **CSV** — position in the album, file name and capture time.
|
||
- **TXT** — full file names, one per line.
|
||
|
||
**Notification.** Set `PROOFING_WEBHOOK_URL` and every first submit POSTs JSON
|
||
to it. The body carries `content` (Discord), `text` (Slack, Mattermost),
|
||
`title` and `message` (Gotify) and a structured `proofing` object (n8n, Home
|
||
Assistant, your own script), so most receivers work without an adapter. With
|
||
`SITE_URL` set, the message links straight to the selection in the admin panel.
|
||
A failing webhook never fails the client's submit.
|
||
|
||
```env
|
||
PROOFING_WEBHOOK_URL=https://discord.com/api/webhooks/…
|
||
```
|
||
|
||
**Security notes.**
|
||
|
||
- The token in the link is the only credential: 192 random bits, never shown
|
||
in the admin panel's own URLs. Treat the link like a password; delete it in
|
||
the admin panel to revoke it at once.
|
||
- Links are stored in `content/proofing.json` (mode `0600`), next to
|
||
`analytics.json`, and are not part of the YAML backups.
|
||
- A site-wide password still applies: on a locked site, the client needs it
|
||
too.
|
||
- Proofing pages are `noindex`, left out of the sitemap, and send no
|
||
`Referer`.
|
||
- A subpage slugged `proof` would be hidden behind this route.
|
||
|
||
## Photo Zoom
|
||
|
||
Visitors can zoom into a photo in the lightbox to full resolution — one image
|
||
pixel per screen pixel, which is what judging sharpness takes. Off by default:
|
||
it hands out the full-resolution file, so it is switched on deliberately.
|
||
|
||
```yaml
|
||
# settings.yaml — the site-wide default
|
||
zoom: true
|
||
```
|
||
|
||
A subpage and an album can each override it, in either direction. The most
|
||
specific setting wins: **album → subpage → site**. An album's `zoom:` belongs to
|
||
the entry it is written on: an album listed on two pages can have zoom on one
|
||
and off on the other, and each page keeps its own choice. Listed twice on the
|
||
same page (in two sections), `false` wins.
|
||
|
||
```yaml
|
||
# gallery.yaml
|
||
subpages:
|
||
- name: Clients
|
||
zoom: true # every album on this page, unless it says otherwise
|
||
albums:
|
||
- 'album-uuid':
|
||
zoom: false # not this one
|
||
```
|
||
|
||
In the admin panel the switch is under **Settings › General › Portfolio
|
||
Features**, and the page and album drawers in the page builder have an
|
||
_Inherit / On / Off_ choice.
|
||
|
||
**Using it.** Double-click (or double-tap) zooms to 1:1 around the point
|
||
clicked and back to fit; so does the magnifier button in the lightbox bar.
|
||
Pinch, or Ctrl + scroll wheel / trackpad pinch, zooms continuously between fit
|
||
and 1:1 — never further, since enlarging past 1:1 only interpolates pixels and
|
||
makes good focus look soft. Drag (or one finger) pans; `+` / `-` step, `0` and
|
||
`Esc` go back to fit (a second `Esc` closes the viewer). Swiping to the next
|
||
photo is off while zoomed, and changing photos resets the zoom.
|
||
|
||
**What is shown.** The full-resolution file is requested only when a visitor
|
||
zooms — never on opening the viewer, and never for the neighbouring photos.
|
||
Until it arrives the preview is shown enlarged, with a small spinner on the
|
||
zoom button.
|
||
|
||
| Original | Zoom shows |
|
||
| ---------------------------------- | ---------------------------------------------- |
|
||
| JPEG, AVIF | The original, with its location removed |
|
||
| HEIC/HEIF, RAW/DNG, TIFF, JPEG XL… | Immich's full-size rendition, location removed |
|
||
| PNG, WebP, GIF | Not zoomable — their metadata is not scrubbed |
|
||
| Edited in Immich (crop, rotate) | Immich's edited full-size rendition |
|
||
| Video | Not zoomable |
|
||
|
||
Location is removed the same way as for [originals download](#originals-download):
|
||
GPS coordinates and place names below city level are overwritten in place;
|
||
camera data and colour profile stay. A file whose metadata cannot be read is
|
||
refused rather than sent.
|
||
|
||
Formats a browser cannot display need Immich's **full-size preview**
|
||
(_Administration › Settings › Image Settings › Full-size image_, JPEG format).
|
||
Immich generates it only while that setting is on, so turn it on and run the
|
||
_Generate Thumbnails_ job for existing photos. Without it, those photos show
|
||
the zoom button but answer "Full resolution is not available for this photo",
|
||
and the viewer stays on the preview. Immich does not say whether a rendition
|
||
exists until it is asked, so Folio cannot hide the button in advance.
|
||
|
||
AVIF originals tagged only with a BT.2020 colour tag (typical of Lightroom AVIF
|
||
exports) can look more saturated at 1:1 than in the preview: Immich's preview
|
||
ignores that tag and comes out desaturated (#827), while the browser shows the
|
||
original as tagged. The original is the accurate one.
|
||
|
||
A photo edited in Immich's own editor zooms into the edit, never into its
|
||
original: Immich keeps the edit apart from the file, so the original is the
|
||
unedited photo and whatever a crop removed would come back at 1:1. Immich has
|
||
a full-resolution rendition of every edit, whether or not full-size previews
|
||
are switched on, so edited photos do not depend on that setting.
|
||
|
||
On touch devices, photos above **50 megapixels** get no zoom control. Showing a
|
||
photo at 1:1 means decoding all of it — 200 MB of memory at 50 MP, 240 MB for a
|
||
60 MP frame — which phones and tablets do not reliably survive. With a mouse or
|
||
trackpad there is no limit.
|
||
|
||
Zoom is offered on album pages, including the album pages under a photo-essay
|
||
subpage (`/<page>/<album>`). The essay itself, journal entries and client
|
||
proofing links do not have it.
|
||
|
||
The zoom route re-checks the album allowlist, every password gate on a route to
|
||
the album, the zoom setting for that route, and that the photo is published and
|
||
belongs to the album. Answers are `Cache-Control: private` (browser only, one
|
||
hour, never a CDN), and each visitor can open 20 full-resolution files a minute.
|
||
|
||
**Bandwidth.** Every zoom sends a full-resolution file from Immich through
|
||
Folio to the visitor — commonly 5–25 MB per photo (a 60 MP JPEG is about
|
||
20 MB). At the rate limit of 20 a minute, one visitor can pull several hundred
|
||
megabytes a minute, and none of it is cached at a CDN. That is why zoom is off
|
||
by default; on a metered or slow uplink, switch it on only for the pages that
|
||
need it.
|
||
|
||
## Image Protection & Watermark
|
||
|
||
```yaml
|
||
protection:
|
||
disableRightClick: true # suppress the context menu on images
|
||
disableImageDrag: true # block drag-to-desktop
|
||
|
||
watermark:
|
||
enabled: true
|
||
text: '© Your Name'
|
||
opacity: 0.5 # fraction, 0–1: 0.5 is 50%
|
||
position: 'bottom-right' # bottom-right | bottom-left | center
|
||
```
|
||
|
||
`opacity` is a fraction between 0 and 1, the same unit the admin slider writes.
|
||
A value above 1 is read as a percentage (`90` means 0.9), so configs written
|
||
that way before the unit was fixed keep working.
|
||
|
||
> [!NOTE]
|
||
> Both are deterrents against casual copying, not protection. The image is in
|
||
> the browser, and anyone who wants it can take a screenshot. The watermark is
|
||
> an overlay on the lightbox, not burned into the file.
|
||
|
||
## SEO
|
||
|
||
```yaml
|
||
seo:
|
||
title: 'My Portfolio | Photography'
|
||
description: 'A curated selection of my best photography work.'
|
||
titleTemplate: '%s | My Portfolio' # %s is the subpage or album title
|
||
noIndex: false # true → ask search engines not to index the site
|
||
noFollow: false # true → ask search engines not to follow links
|
||
```
|
||
|
||
`titleTemplate` shapes the browser and search-result title of every subpage and
|
||
album; the homepage uses `title` unchanged.
|
||
|
||
## Footer
|
||
|
||
Optional minimal footer with social links, in `settings.yaml`:
|
||
|
||
```yaml
|
||
footer:
|
||
name: John Doe
|
||
instagram: johndoe
|
||
email: hello@example.com
|
||
website: https://example.com
|
||
```
|
||
|
||
A `legal:` block with `enabled: true` adds an Impressum page at `/impressum` and
|
||
links it in the footer — see `content/settings.yaml.example` for every field.
|
||
|
||
Email, phone and `contactUrl` render as links. `contactUrl` is a second contact
|
||
channel such as a contact form, which case law accepts in place of a phone
|
||
number; it takes http(s) URLs only and is dropped with a warning otherwise.
|
||
`heading` replaces the "Angaben gemäß § 5 DDG" line, e.g. for § 5 ECG in Austria.
|
||
|
||
A privacy policy is written in the admin panel and stored in
|
||
`content/privacy.md`; `/privacy` shows it and the footer links it once the file
|
||
has text. `privacy: { enabled: false }` hides it without deleting the file. See
|
||
[Privacy Policy](admin-panel.md#privacy-policy).
|
||
|
||
A `contact:` block with `enabled: true` adds a contact form at `/contact` and
|
||
links it in the footer. Messages stay on the server (`content/messages/`) and
|
||
are read in the admin panel; see [Messages](admin-panel.md#messages). While the
|
||
Impressum has no `contactUrl`, it links this form as the second contact channel.
|
||
|
||
```yaml
|
||
contact:
|
||
enabled: true
|
||
notifyUrl: https://ntfy.sh/your-secret-topic # optional push for new messages
|
||
retentionDays: 90 # delete messages after this many days
|
||
```
|
||
|
||
## About Page
|
||
|
||
Create `content/about.md` with YAML frontmatter:
|
||
|
||
```markdown
|
||
---
|
||
portrait: asset-uuid-for-your-portrait
|
||
name: Your Name
|
||
location: City, Country
|
||
gear:
|
||
- Camera Body
|
||
- Favorite Lens
|
||
---
|
||
|
||
Your bio text here. Supports full Markdown.
|
||
```
|
||
|
||
The page is served at `/about` and linked in the navigation. Turn it off in
|
||
`settings.yaml` without deleting the file:
|
||
|
||
```yaml
|
||
about:
|
||
enabled: false
|
||
```
|
||
|
||
The admin panel's **Settings → About** section edits the same file — portrait,
|
||
name, location, gear list and bio — so `about.md` does not have to be written by
|
||
hand.
|
||
|
||
## Content Pages
|
||
|
||
Pages beyond the gallery — pricing and packages, workshops, press, a booking
|
||
FAQ — are content pages. Each one is a file, `content/pages/<slug>.md`, served
|
||
at `/<slug>`.
|
||
|
||
```markdown
|
||
---
|
||
title: 'Pricing'
|
||
description: 'Packages and rates for portrait and wedding sessions'
|
||
draft: true
|
||
---
|
||
|
||
## Portrait sessions
|
||
|
||
One hour on location, 25 edited photos.
|
||
|
||

|
||
```
|
||
|
||
The frontmatter keys are `title`, `description` (used for search engines and
|
||
link previews), `password` and `draft`. The body uses the journal's block
|
||
syntax ([Journal & Photo Essays](journal.md)): headings, text and photo blocks
|
||
(`:wide` or `:fullbleed`). A page has no date, no reading time and no index,
|
||
and it has no map block.
|
||
|
||
### In the menu
|
||
|
||
A page appears in the header menu through a reference among the subpages in
|
||
`gallery.yaml`, at the position it should take:
|
||
|
||
```yaml
|
||
subpages:
|
||
- name: Landscapes
|
||
albums:
|
||
- 'album-uuid-1'
|
||
- page: pricing
|
||
```
|
||
|
||
A page without a reference is still reachable at `/<slug>`, but it is not in
|
||
the menu, which suits a page that is only shared by link. The menu stays flat:
|
||
a page cannot sit inside a subpage's sections. A reference to a page whose file
|
||
does not exist is skipped, and the config doctor reports it.
|
||
|
||
### Slugs
|
||
|
||
A page's slug must not be taken by a subpage, a standalone album, a journal
|
||
entry or one of the built-in routes (`about`, `map`, `journal`, `impressum`,
|
||
`privacy`, `install`, `admin`, `gate`, `api`, `proof`, `contact`). The admin
|
||
panel refuses to save a page on such a slug, and the doctor reports a collision
|
||
in a hand-edited file. Renaming a page's slug breaks old links: there are no
|
||
automatic redirects.
|
||
|
||
### Draft and password
|
||
|
||
A draft is visible only to a signed-in admin, and it is left out of the menu and
|
||
the sitemap. A `password` locks the page like a journal entry, with its own
|
||
cookie (`lb_auth_page_<slug>`). The site password, when one is set, applies
|
||
before either. Password-protected pages are not listed in the sitemap.
|
||
|
||
### In the admin panel
|
||
|
||
Admin → Pages lists pages next to the subpages, with a document icon and a
|
||
PAGE tag, and a separate "Not in menu" group for pages without a reference.
|
||
**+ New page** creates one (it starts blank and as a draft). Select a page to
|
||
edit its title, URL, draft flag, password and description, and to switch
|
||
"Show in menu"; dragging a page into or out of "Not in menu" does the same.
|
||
The menu change is saved with **Save Changes**, like any other change to the
|
||
structure. **Edit content →** opens the journal's block editor with a live
|
||
preview. Every save keeps a backup in `content/pages/.backups/`, and deleting a
|
||
page that is in the menu also removes its `gallery.yaml` reference; both files
|
||
are backed up first.
|
||
|
||
## Journal & Photo Essays
|
||
|
||
Long-form storytelling with fullbleed photos, side-by-side pairs, quotes and
|
||
captions lives in `content/journal/`, served at `/journal`. A subpage can also
|
||
be turned into a single photo essay.
|
||
|
||
→ **[Journal & Photo Essays](journal.md)**
|
||
|
||
## 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. Each endpoint has its own bucket, so heavy image traffic cannot exhaust the budget for the password endpoints.
|
||
|
||
- `RATE_LIMIT_RPM`: requests per minute per IP for image, video, EXIF and health requests (default: `1500`).
|
||
|
||
The endpoints where a high limit would be the wrong default are fixed and not configurable:
|
||
|
||
| Endpoint | Limit (req/min/IP) |
|
||
| ---------------------- | ------------------ |
|
||
| `POST /api/admin/auth` | 5 |
|
||
| `POST /api/auth` | 10 |
|
||
| `/api/install` | 10 |
|
||
| `/api/zoom` | 20 |
|
||
| `/api/og` | 30 |
|
||
| `/api/install/albums` | 30 |
|
||
| `/api/webhook` | 60 |
|
||
| `/api/map` | 120 |
|
||
|
||
> [!IMPORTANT]
|
||
> The limiter lives in the process memory of a single instance. It does not
|
||
> coordinate across replicas, and it starts empty after a restart.
|
||
|
||
### 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`).
|
||
|
||
```bash
|
||
# 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:
|
||
|
||
```nginx
|
||
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.
|