Files
immich-folio/README.md
Ralksta 3193c500e4 release: v0.18.0 (#725)
Release v0.18.0 — merges `dev` into `main`.

## Thanks

- [@lancetm714](https://github.com/lancetm714) for the accent-contrast
fix (#680 → #724).
- [@RichKidsDev](https://github.com/RichKidsDev) for reporting the
outdated Impressum (#697).

### Security

- **Journal drafts and password-protected entries no longer render
publicly
  as a subpage essay**
  ([#684](https://github.com/ralksta/immich-folio/pull/684),

[GHSA-987w-cpwm-c9g9](https://github.com/ralksta/immich-folio/security/advisories/GHSA-987w-cpwm-c9g9),
medium). A subpage's `essayFile` read the journal entry straight from
disk,
skipping the draft and password rules `/journal/<slug>` applies. It now
resolves through the same rules: a draft reads as missing for visitors,
and
  a protected entry shows its password gate, sharing the
  `lb_auth_journal_<slug>` cookie.

- **Immich descriptions are escaped in generated subpage essays**
  ([#684](https://github.com/ralksta/immich-folio/pull/684),

[GHSA-8ppx-39jv-q5xm](https://github.com/ralksta/immich-folio/security/advisories/GHSA-8ppx-39jv-q5xm),
  low). The essay built from a subpage's albums (`layout: essay` without
markdown) put each asset's Immich description into the page as raw HTML.
Captions are now HTML-escaped, and they go through `assetCaption()`, so
  `exif.caption: false` is respected there as well. Tracked in
  [#689](https://github.com/ralksta/immich-folio/issues/689).

- **Admin drafts no longer keep a typed password, and the contact form's
  email check runs in linear time**
([#727](https://github.com/ralksta/immich-folio/pull/727)). The admin's
  session drafts wrote a password as typed to `sessionStorage`; a draft
holding a plaintext password is now not kept (the unsaved-changes guard
still warns). The email pattern could backtrack polynomially on a
crafted
domain; its labels now exclude the dot and the length is checked first.
  Both found by CodeQL before release.

### Added

- **A built-in contact form with an inbox in the admin**
  ([#703](https://github.com/ralksta/immich-folio/pull/703), closes
[#702](https://github.com/ralksta/immich-folio/issues/702)). `/contact`
  needs neither SMTP nor a form service: each message is one file in
`content/messages/` (mode `0600`), read under the new _Messages_ tab,
where
_Reply by email_ opens the owner's mail client with the message quoted.
Messages older than `contact.retentionDays` (default 90) are deleted. An
optional ntfy push goes to `contact.notifyUrl` or `CONTACT_NOTIFY_URL`,
and
since [#709](https://github.com/ralksta/immich-folio/pull/709) it
carries a
  fixed "new message" line and nothing about the sender — no name, no
  address, no text. Spam is kept out without a captcha: an off-screen
honeypot, a minimum fill time, three submissions a minute per IP and a
cap
of 500 stored messages. With the Impressum on and no `legal.contactUrl`,
the Impressum links the form as its second contact channel. A new doctor
  check warns when the form is on but nobody is notified.

- **A privacy policy page, next to a list of what the site processes**
  ([#708](https://github.com/ralksta/immich-folio/pull/708)). `/privacy`
renders `content/privacy.md`, edited under Settings → Legal, and the
footer
links it once the file has text; `privacy.enabled: false` hides it.
Folio
writes no legal text. Beside the editor it lists what _this_
installation
  does, read off the configuration — the map, statistics, contact form,
password cookies, CDN — and marks every entry that involves a third
party.
_Insert headings_ adds the matching section headings and nothing else.
The
  doctor warns while the Impressum is on and there is no policy.

- **French, Spanish, Italian and Dutch interface translations**
([#681](https://github.com/ralksta/immich-folio/pull/681)), selected
with
  `settings.yaml: lang` as before. The admin panel stays English.

- **CDN mode for photos and videos**
([#681](https://github.com/ralksta/immich-folio/pull/681)). With
`CDN_URL`
set, image and video URLs point at a pull CDN whose origin is this
server,
  so repeat views reach neither the server nor Immich. A site password
switches the mode off, since a CDN would hand out cached photos without
  checking the unlock cookie. The CDN origin is added to the CSP, and a
  doctor check covers the password and `TRUSTED_PROXY_HOPS` cases. See
  `docs/deployment.md#cdn-mode`.

- **A reworked admin panel**
  ([#712](https://github.com/ralksta/immich-folio/pull/712),
  [#713](https://github.com/ralksta/immich-folio/pull/713),
  [#714](https://github.com/ralksta/immich-folio/pull/714),
  [#716](https://github.com/ralksta/immich-folio/pull/716),
  [#717](https://github.com/ralksta/immich-folio/pull/717),
  [#718](https://github.com/ralksta/immich-folio/pull/718),
  [#719](https://github.com/ralksta/immich-folio/pull/719),
  [#721](https://github.com/ralksta/immich-folio/pull/721)):
- **A sidebar instead of the header row**, grouped by task, with an
unread
    count on Messages and a dot on Diagnostics while Immich is down. It
    collapses to an icon rail, with names shown on hover and focus, and
    remembers that per browser.
  - **`/admin` is an overview**: what needs attention (unread messages,
    doctor warnings), published pages, journal entries and recent views.
  - **The site's own visual language** — the preset's fonts, hairline
borders, the square marker — and one page header on every screen. The
accent is kept for the current place and the primary action; switches
    are now pills reading ON or OFF, in green rather than the accent, so
    Studio Modern's red no longer makes an enabled setting look like an
    error.
- **The page builder is a structure list and a panel.** Hero, subpages
and
standalone albums sit in one list on the left, subpages drag-sorted in
    place; the selected entry is edited on the right instead of in an
    overlay.
- **Dialogs only where a change is permanent.** Deleting a message or a
journal entry, and resetting the favicon (which used to delete the file
without asking), go through one confirm dialog in the admin's own look.
Removals in the page builder, which only touch unsaved state, happen at
    once and offer _Undo_. No `window.confirm()` is left.
  - **Feature switches are grouped by meaning** as one-line rows under
Settings → General; page transitions and scroll-to-top moved to Theme →
    Look & motion. No setting keys changed.

### Changed

- **Fonts and the Leaflet stylesheet are served from the site itself**
([#700](https://github.com/ralksta/immich-folio/pull/700)). Every page
used
to link `fonts.googleapis.com`, which sends each visitor's IP address to
Google. The server now fetches a theme's Google fonts once, rewrites the
  stylesheet and caches the files in `content/.fonts/`; custom fonts in
`theme.fonts` keep working. If Google cannot be reached before a font is
  cached, the page falls back to the theme's system fonts. `leaflet.css`
  comes from the npm package instead of unpkg. The CSP drops
  `fonts.googleapis.com`, `fonts.gstatic.com` and `unpkg.com`.

- **Email addresses are kept out of the served HTML**
([#705](https://github.com/ralksta/immich-folio/pull/705)). The
Impressum
and footer render the address only after hydration, from an encoded
form,
  so harvesters reading raw HTML find nothing. Without JavaScript the
Impressum shows `name [at] example [dot] de`. This defeats bulk
harvesting,
  not a person with a browser.

- **The Impressum cites § 5 DDG and links its contacts**
  ([#698](https://github.com/ralksta/immich-folio/pull/698), reported in
  [#697](https://github.com/ralksta/immich-folio/issues/697) by
[@RichKidsDev](https://github.com/RichKidsDev)). The TMG was replaced by
the
DDG in May 2024; `legal.heading` replaces the line for other laws (e.g.
  § 5 ECG in Austria). `legal.contactUrl` and `legal.contactLabel` add a
  contact form as the second channel, so a phone number stays optional.
  Email, phone and contact URL are now links, and the "Created with the
Impressum generator by eRecht24" line, shown on every site whether true
or
not, is gone. Settings → Legal gains fields for heading, contact URL,
VAT
  ID and tax number, and a doctor check flags missing details.

- **Missing pages answer `404`**
([#704](https://github.com/ralksta/immich-folio/pull/704)). Every
missing
  page used to answer `200`, because streaming had started before
`notFound()` ran. `/contact`, `/impressum`, `/map` and `/privacy` while
switched off, and `/journal/<slug>` without an entry, now return a real
404 with the same not-found page. Album and subpage slugs still depend
on
  Immich and stay soft 404s.

- **Passwords set in the admin are stored as scrypt hashes**
  ([#706](https://github.com/ralksta/immich-folio/pull/706), closes
  [#690](https://github.com/ralksta/immich-folio/issues/690)). Subpage,
album, journal-entry and site passwords were written as typed. The save
routes now hash them; a stored hash reads _Protected_ with _Change_ and
_Remove_ and is never put into an input. Visitors who unlocked a page
stay
signed in across unrelated saves. Existing plaintext passwords are
hashed
  the next time their file is saved in the admin.

- **One notification surface in the admin**
([#707](https://github.com/ralksta/immich-folio/pull/707)) replaces
every
`alert()`: errors stay until dismissed, successes clear themselves, and
  screen readers announce both.

### Fixed

- **Map tiles load again**
([#701](https://github.com/ralksta/immich-folio/pull/701)). CARTO
started
answering every tile with an "API KEY REQUIRED" image, leaving `/map`
and
journal map blocks as a grey grid. Tiles now come from OpenStreetMap;
the
  dark look is a CSS filter, so the map now follows the colour mode.

- **The page builder no longer drops a subpage's `proofing:` flag on
save**
([#711](https://github.com/ralksta/immich-folio/pull/711)). A page
switched
  off for proofing by hand reverted after any unrelated edit.

- **Unsaved edits in Settings and About survive switching admin tabs,
and a
  backup restore is no longer overwritten by the next save**
([#687](https://github.com/ralksta/immich-folio/pull/687)). Editors that
were open during a restore now reload and offer unsaved edits back as a
  conflict. Also: "Restore my changes" after a conflict found nothing,
removing an album left its drawer showing the next album, and the
journal
  saved (rotating a backup) without changes.

- **Accent-backed buttons stay readable with a light or malformed
accent**
  ([#724](https://github.com/ralksta/immich-folio/pull/724), supersedes
[#680](https://github.com/ralksta/immich-folio/pull/680)) — contributed
by
  [@lancetm714](https://github.com/lancetm714). A white accent made the
  proofing dialog's _Download selected (.zip)_ button white on white.
`theme.accent` is now accepted as hex only and normalised to `#rrggbb`,
anything else falls back to the preset, and the proofing dialog and
filter
  pills use the mode-aware `--on-accent` text colour from #688.

- **Every preset's accent is visible in both colour modes**
([#688](https://github.com/ralksta/immich-folio/pull/688)). Presets had
one
accent for both modes; minimal's dark mode drew black buttons on black.
  Presets now carry a light and a dark accent, and text on accent-filled
  buttons picks black or white for contrast. A custom accent stays the
  owner's choice in both modes.

- **Visitor-side inconsistencies**
([#685](https://github.com/ralksta/immich-folio/pull/685)): after
visiting
`/impressum`, every subpage header turned centred and uppercase; back
links
  announced "Back to Back to …"; the hero EXIF chip ignored
`exif.location`/`camera`/`settings`; videos in journal entries opened as
  stills; the setup screen named a non-existent `.env.example`.

- **An essay's lightbox walks only the photos the story shows**
([#686](https://github.com/ralksta/immich-folio/pull/686)), not every
photo
of the subpage's albums, and the proofing bar in essays shows the
selection
  count instead of a filter button that did nothing.

- **The page panel scrolls with the mouse wheel**
  ([#720](https://github.com/ralksta/immich-folio/pull/720)).

- **The journal editor's top bar wraps on phones**
  ([#723](https://github.com/ralksta/immich-folio/pull/723), closes
[#675](https://github.com/ralksta/immich-folio/issues/675)), so the
title
  stays editable and Save stays on screen.

- **Every admin form field has a name for assistive technology**
([#715](https://github.com/ralksta/immich-folio/pull/715)). Only two
inputs
had a linked label; 47 are linked now, and clicking a label focuses its
  field.

### Internal

- **`SettingsEditor` and `PageBuilder` are split up**
  ([#710](https://github.com/ralksta/immich-folio/pull/710),
[#711](https://github.com/ralksta/immich-folio/pull/711)). The
2,200-line
settings editor is a shell with one file per section; the page builder's
YAML handling and edits are pure functions under test. Both were checked
  side by side against `dev` for identical output.
- **`useAdminFetch()`**
([#707](https://github.com/ralksta/immich-folio/pull/707))
gives Analytics, Messages, Backups and the journal list one loading and
  error pattern.
- README brought up to v0.17.0
  ([#678](https://github.com/ralksta/immich-folio/pull/678),
  [#679](https://github.com/ralksta/immich-folio/pull/679)).

### Upgrade notes

**`content/` gains two directories the app writes to:**
`content/.fonts/`
for the cached fonts and `content/messages/` for contact-form messages.
Both
are created on demand, so a writable `content/` is enough, as before.

**The server needs outbound HTTPS to Google once per font** to fill the
font
cache. Until then pages fall back to the theme's system fonts. Visitors'
browsers no longer contact Google.

**A custom CSP or reverse-proxy allowlist** must now allow
`tile.openstreetmap.org` for images instead of
`*.basemaps.cartocdn.com`,
and no longer needs `fonts.googleapis.com`, `fonts.gstatic.com` or
`unpkg.com`. The built-in CSP is updated.

**`theme.accent` must be hex** (`#rgb`, `#rrggbb` or `#rrggbbaa`).
Anything
else, including a value without `#`, falls back to the preset's accent.

**Plaintext passwords in `gallery.yaml`, `settings.yaml` or journal
entries**
are hashed the next time that file is saved in the admin; one save
clears the
doctor's warning.

**New, optional settings:** `contact.*` (the form is off by default),
`privacy.enabled`, `legal.heading`, `legal.contactUrl`,
`legal.contactLabel`,
and the `CDN_URL` and `CONTACT_NOTIFY_URL` environment variables. See
`content/settings.yaml.example` and `.env.local.example`. In the example
file,
`vatId` and `taxId` had swapped sample values; check yours if you copied
them.

No `IMAGE_CACHE_VERSION` bump is needed this time.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-09-27 14:43:38 +02:00

407 lines
22 KiB
Markdown

# Immich Folio
**Turn your Immich albums into a public photography portfolio — without ever exposing your Immich server to the internet.**
<p>
<a href="LICENSE"><img src="https://img.shields.io/github/license/ralksta/immich-folio" alt="MIT License" /></a>
<a href="https://github.com/ralksta/immich-folio/releases"><img src="https://img.shields.io/github/v/release/ralksta/immich-folio" alt="Latest release" /></a>
</p>
<p align="center">
<img src="docs/screenshots/header.png" width="100%" alt="Immich Folio — the public gallery, an album grid, and the admin page builder" />
</p>
A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **secure reverse proxy** between your visitors and your private Immich instance: your Immich server stays on your local network, completely invisible to the outside world, while your albums are published as a gallery you control.
**Latest: v0.18.0** — built-in contact form, privacy page and a reworked admin panel. → [What's New](#whats-new)
## Contents
- [Features](#features)
- [Requirements](#requirements)
- [Quick Start](#quick-start)
- [First-Run Setup](#first-run-setup)
- [Configuration](#configuration)
- [Docker](#docker)
- [Admin Panel](#admin-panel)
- [What's New](#whats-new)
**Guides:** [Gallery Configuration](docs/gallery-config.md) · [Theming](docs/theming.md) · [Journal & Photo Essays](docs/journal.md) · [Admin Panel](docs/admin-panel.md) · [Deployment](docs/deployment.md)
## Features
### Gallery & Layout
- **Configurable hero layouts** — split, fullbleed, minimal, stacked (image + thumbnail strip), typographic (text-only), or mosaic (multi-image grid)
- **Hero image carousel** — single image or crossfade carousel of multiple Immich assets
- **Masonry photo grid** — responsive layout with natural aspect ratios and configurable columns, gap, and aspect ratio
- **Uniform grid mode** — switch to a fixed-aspect uniform grid per-subpage or globally
- **Showcase / filmstrip / editorial-flow layouts** — featured hero + grid, horizontal scroll strips, or alternating full-width and paired images
- **Justified rows** _(experimental)_ — every row fills the width at one shared height, aspect ratios intact, nothing cropped
- **Per-subpage grid overrides** — each subpage can define its own columns, gap, aspect ratio, and layout mode, and individual albums can override that again _(experimental)_
- **Cover focal points** _(experimental)_ — decide which part of a cover survives the crop
- **Fullscreen lightbox** — keyboard and swipe navigation, EXIF panel, adjacent image preloading
- **EXIF metadata on hover** — camera body, lens, focal length, aperture, shutter speed, ISO shown directly on the grid
- **ThumbHash placeholders** — instant blurred previews while full images load
<p align="center">
<img src="docs/screenshots/grid-masonry.png" width="49%" alt="Masonry grid layout" />
<img src="docs/screenshots/grid-showcase.png" width="49%" alt="Showcase grid layout" />
</p>
<p align="center"><em>Masonry and showcase, both in Studio Modern — the same album, a different grid layout.</em></p>
<p align="center">
<img src="docs/screenshots/lightbox-exif.png" width="98%" alt="Fullscreen lightbox with the EXIF panel open" />
</p>
<p align="center"><em>The fullscreen lightbox with the EXIF panel — camera, lens and exposure for the frame on screen.</em></p>
### Content & Organization
- **Subpage grouping** — organize albums into named collections (e.g. `/japan/tokyo-2023`)
- **Auto-generated slugs** — URL slugs derived from album names automatically
- **YAML gallery config** — all gallery structure defined in a single `content/gallery.yaml` file
- **Markdown about page** — `content/about.md` with frontmatter for portrait, name, location, and gear list, editable from the admin panel
- **Journal** — photo essays and travel stories at `/journal`, with drafts, per-entry passwords, cover images and reading times; new entries can start from a template (wedding, hiking, travel, …)
- **Photo Essay mode** — long-form storytelling pages alternating text with fullbleed, paired and grid image layouts, facts lists, a slice of an album, and a map with the pins you choose — typed places, or photos placed by their GPS
- **Unlisted subpages** _(experimental)_ — reachable by direct link, absent from the navigation
- **Subpage on/off toggle** — take a page offline without deleting it
- **External navigation links** _(experimental)_ — point the header at a shop, a blog, or a social profile
- **Client proofing** — clients favorite photos and export the selection; picks are encoded in the URL, nothing is stored server-side
- **Originals delivery** — per-album opt-in lets visitors download a whole album or their proofing selection as a ZIP of the originals
- **Lightbox watermark** — configurable overlay on fullscreen images
- **Six interface languages** — English, German, French, Spanish, Italian and Dutch for everything visitors see
- **Privacy-friendly analytics** — cookieless view counts, no third parties, can be switched off
- **Dynamic OG images** — auto-generated social preview images per album
<p align="center">
<img src="docs/screenshots/page-collection.png" width="98%" alt="Collection overview grouping several albums under one subpage" />
</p>
<p align="center"><em>A subpage grouping several albums into one collection.</em></p>
<p align="center">
<img src="docs/screenshots/page-about.png" width="49%" alt="About page rendered from content/about.md" />
<img src="docs/screenshots/page-map.png" width="49%" alt="Map page clustering photo locations worldwide" />
</p>
<p align="center"><em>The Markdown about page and the GPS map, both generated from your Immich data.</em></p>
### Admin Panel
- **Visual page builder** — drag & drop interface to manage hero images, standalone albums, subpages, and sections
- **Album picker** — browse all shared Immich albums with search, see photo counts, and add them with one click
- **Settings editor** — configure theme, grid layout, footer, legal/impressum, SEO, image protection and the about page from a visual UI
- **Visual previews everywhere** — grid layout, theme, photo frame, hero style and the Google search snippet are picked from preview cards instead of text fields
- **Journal Studio** — split-screen block editor with a live preview of the real page; start from a template, drag blocks into order
- **Essay block editor** — assemble photo essays block by block without touching Markdown
- **Unsaved edits survive** — leave the page builder or the journal editor mid-edit, and your changes are waiting when you come back
- **Diagnostics** — a page that checks the Immich connection, config and security settings, and links every finding to its fix
- **Photo order editor** — drag & drop a hand-picked opening sequence for any album
- **Favicon upload** — give the site its own icon in the browser tab
- **Backup manager** — every save is backed up automatically; restore any of them with one click
- **Live YAML sync** — changes are written directly to `gallery.yaml` and `settings.yaml` with automatic backups
- **Password protected** — secured with its own admin password, separate from album passwords
<details>
<summary><strong>Security &amp; Infrastructure</strong></summary>
<br>
| Concern | Protection |
| -------------------------- | ------------------------------------------------------------------------------------ |
| **Server exposure** | Immich URL never leaves your network — all requests proxy server-side |
| **API key** | Stored only in `.env.local`, never in client code |
| **Asset IDs** | Immich UUIDs encrypted (AES-256) into opaque tokens |
| **Album scope** | Only albums in `gallery.yaml` are accessible |
| **Password protection** | Per-subpage password support |
| **Rate limiting** | Per-IP sliding-window rate limiter (configurable RPM) |
| **Vulnerability scanning** | Docker image scanned with Trivy on every release, results in the GitHub Security tab |
- Health check endpoint at `GET /api/health`
- In-memory caching with configurable TTL
- Optional CDN mode — photos and videos served through a pull CDN, so repeat views never reach your server ([Deployment Guide](docs/deployment.md#cdn-mode))
- Standalone Docker image — multi-stage, non-root, ~150 MB
- Dependencies kept current via Dependabot (npm + GitHub Actions, weekly)
</details>
## Requirements
- **Immich 3.0 or newer.** Immich 3.0 changed how album assets are retrieved; earlier versions are not supported as of v0.9.0. On an older server, albums render with the correct title but no photos, and the map stays empty — the app logs a warning naming this as the likely cause.
- Node.js 20+ (or just use the Docker image)
## Quick Start
```bash
git clone https://github.com/ralksta/immich-folio.git
cd immich-folio
npm install
npm run dev
```
Then open `http://localhost:3000/install` and let the setup wizard connect you to
Immich — see [First-Run Setup](#first-run-setup) below. It needs a token from the
server log, which is printed on first access.
<details>
<summary><strong>Prefer to configure by hand?</strong></summary>
The wizard writes the same files you would write yourself, so the manual route
remains fully supported:
```bash
cp .env.local.example .env.local
# Edit .env.local with your Immich server URL and API key
cp content/gallery.yaml.example content/gallery.yaml
# Edit gallery.yaml with your album UUIDs
npm run dev
```
</details>
Something not coming up? `npm run doctor` checks the configuration from the
terminal — no running app and no admin password needed. See
[Config Doctor](docs/deployment.md#config-doctor).
## First-Run Setup
A deployment with no `gallery.yaml` and no Immich credentials serves a setup
screen. The wizard at **`/install`** fills both in from the browser: it connects
to Immich, lets you pick albums (optional — you can add them later in `/admin`),
and names the site. Nothing is written until your credentials have been verified
against your Immich server, so a typo cannot leave you with an "installed" site
that loads no photos.
**The wizard is gated by a one-time token** printed to the server log on first
access, because a fresh deployment is reachable before it has any configuration —
and without the gate, whoever finds the URL first could configure it:
```
══════════════════════════════════════════════════════
Immich Folio — First-run setup token
══════════════════════════════════════════════════════
Token: PTmUKFIdce2DwuqBG71RAExH7tVxceXX
```
Read it from your container logs (`docker logs immich-folio`, or
`docker compose logs lightbox` — `immich-folio` is the container name, `lightbox`
the service) and append it to the URL:
```
http://your-site/install?token=PTmUKFIdce2DwuqBG71RAExH7tVxceXX
```
The token is stored in `content/.setup-token` (mode `0600`) so it survives a
restart, and is deleted once setup completes. From then on `/install` redirects
to the gallery and its API routes refuse to run again.
The wizard writes `gallery.yaml`, `settings.yaml` and `content/install.json` —
the last of which **holds your Immich API key**. Environment variables override
everything in it, so credentials can be rotated without touching the file, and
setting them all up front means the wizard never appears at all.
→ **[What the wizard writes, and environment precedence](docs/deployment.md#what-the-setup-wizard-writes)**
## Configuration
### Environment Variables (`.env.local`)
```env
# Required
IMMICH_API_URL=http://your-immich-server:2283
IMMICH_API_KEY=your-api-key
# Optional
SITE_TITLE=My Photography # default: "Gallery"
SITE_SUBTITLE=A visual journal # default: empty
CACHE_TTL=300 # seconds, default: 300
STALE_MAX_AGE=86400 # seconds an expired cache entry survives during an outage, default: 86400 (24h), 0 disables
IMMICH_TIMEOUT_MS=15000 # Immich response wait, default: 15000
IMAGE_CACHE_VERSION=1 # bump to bust browser image caches, default: off
RATE_LIMIT_RPM=1500 # requests/min/IP for images, default: 1500
AUTH_SECRET=long-random-string # required in production
TRUSTED_PROXY_HOPS=1 # reverse proxies in front, default: 0
ADMIN_PASSWORD=your-secure-password # enables /admin panel
WEBHOOK_SECRET=long-random-string # enables POST /api/webhook cache invalidation
CDN_URL=https://cdn.example.com # serve photos and videos through a pull CDN
CONTACT_NOTIFY_URL=https://ntfy.sh/x # push for new contact form messages, overrides settings.yaml
```
> Login and setup endpoints have their own, much lower limits that `RATE_LIMIT_RPM` does not raise — see [Rate Limiting](docs/gallery-config.md#rate-limiting).
> Behind a reverse proxy, set `TRUSTED_PROXY_HOPS` to the number of proxies in front of the app (nginx/Traefik/Caddy = 1; Cloudflare in front of nginx = 2). Without it the client IP is read from a header the client itself can set, which defeats the brute-force limits on the password endpoints. See [Trusted Proxies](docs/gallery-config.md#trusted-proxies).
### Immich API Key Permissions
Create a dedicated API key in Immich under **Account Settings → API Keys**. Immich Folio only needs **read access** — it never modifies your library.
| Permission | Required | Used for |
| ------------ | -------- | -------------------------------------------------------------------- |
| `album.read` | ✅ Yes | List and fetch album metadata & photo lists |
| `asset.read` | ✅ Yes | Fetch asset metadata, EXIF data, thumbnails, previews, and originals |
| `asset.view` | ✅ Yes | Stream image/video files (thumbnail, preview, video playback) |
> **No write permissions needed.** `album.create`, `asset.upload`, `asset.delete`, etc. can all be left **off**.
> **Tip (Admin Panel):** The Admin Panel also uses `POST /search/metadata` to browse your full library for the hero image picker. This is covered by `asset.read` — no additional permission required.
### Gallery Config
All gallery structure — hero images, albums, subpages, grid layout, footer — is defined in `content/gallery.yaml`.
→ **[Gallery Configuration Guide](docs/gallery-config.md)**
### Journal & Photo Essays
Long-form storytelling with fullbleed photos, side-by-side pairs and grids, quotes, facts lists, a map of the entry's own geotagged photos and captions — as a standalone `/journal` section, or as a single essay on one subpage. New entries can start from a template.
→ **[Journal & Photo Essays Guide](docs/journal.md)**
### Theming
Seven built-in presets with distinct visual identities — or mix and match with fine-grained control over colors, fonts, corners, photo frames, hero layout, and grid style.
**Studio Modern** is the default: Leica precision rebuilt around the Archivo grotesque, with IBM Plex Mono for every piece of photographic metadata, hairline rules, zero radius, and red as signal only.
<p align="center">
<img src="docs/screenshots/theme-studio-modern-home.png" width="49%" alt="Studio Modern in dark mode" />
<img src="docs/screenshots/theme-studio-modern-home-light.png" width="49%" alt="Studio Modern in light mode" />
</p>
<p align="center"><em>Studio Modern, dark and light — every preset ships both, and visitors switch with a toggle in the navigation bar.</em></p>
```yaml
theme: studio-modern # or: studio, minimal, editorial, classic, noir, monograph
```
→ **[View all Themes & Configuration Guide](docs/theming.md)**
## Docker
### Docker Compose (recommended)
```yaml
services:
lightbox:
build: .
container_name: immich-folio
restart: unless-stopped
ports:
- '7211:7211'
env_file:
- .env.local
volumes:
- ./content:/app/content
```
Run with:
```bash
docker compose up -d
```
The gallery will be available at `http://localhost:7211`.
> The `content/` volume must be **read-write** — the setup wizard, the admin
> panel, the journal and the backup rotation all write into it. A `:ro` mount
> leaves the wizard unable to complete and the admin panel unable to save.
For standalone `docker run`, the health check, TLS behind a reverse proxy, and
what the gallery does while Immich is unreachable:
→ **[Deployment Guide](docs/deployment.md)**
## Admin Panel
A built-in visual editor at `/admin` lets you manage your gallery without editing YAML files manually.
**Enable it** by setting `ADMIN_PASSWORD` in your environment:
```env
ADMIN_PASSWORD=your-secure-admin-password
```
Or set the password in the [setup wizard](#first-run-setup), which stores it as
an scrypt hash rather than in your environment. An `ADMIN_PASSWORD` variable
takes precedence over the stored one if both are present.
Then navigate to `http://your-site/admin` and log in. The panel is protected by its
own password, separate from any album passwords, and writes straight to
`gallery.yaml` / `settings.yaml` with automatic backups.
<p align="center">
<img src="docs/screenshots/admin-login.png" width="49%" alt="Admin panel login screen" />
<img src="docs/screenshots/admin-page-builder.png" width="49%" alt="Visual page builder with hero images, standalone albums and subpages" />
</p>
<p align="center">
<img src="docs/screenshots/admin-album-picker.png" width="49%" alt="Album picker listing shared Immich albums with photo counts" />
<img src="docs/screenshots/admin-settings.png" width="49%" alt="Settings editor with site identity and feature toggles" />
</p>
<p align="center"><em>Login · page builder · album picker · settings editor</em></p>
→ **[Admin Panel Guide](docs/admin-panel.md)**
## What's New
v0.18.0 — a contact form, a privacy page and a reworked admin:
- **Built-in contact form** — no SMTP or form service: messages land in a new admin inbox, with an optional ntfy push that carries nothing about the sender
- **Privacy page and fewer third parties** — `/privacy` with a list of what your installation actually processes; fonts and map styles are served from your own server, and email addresses stay out of the page source
- **A reworked admin panel** — a collapsible sidebar, an overview page, the page builder as a structure list with Undo, and ON/OFF switches grouped by meaning
- **Four more languages and a CDN mode** — French, Spanish, Italian and Dutch, and `CDN_URL` to serve photos through a pull CDN
- **Two security fixes** — draft and password-protected journal entries used as subpage essays, and unescaped Immich descriptions ([advisories](https://github.com/ralksta/immich-folio/security/advisories))
**Upgrade note:** a custom CSP or proxy allowlist must now allow `tile.openstreetmap.org` for map tiles, and `theme.accent` must be a hex colour. Nothing else to migrate.
Security fixes ship in normal releases, so **running the latest release is the
recommended baseline**.
→ Full history in [CHANGELOG.md](CHANGELOG.md) and the [GitHub releases](https://github.com/ralksta/immich-folio/releases)
## Tech Stack
- **Next.js 16** (App Router, standalone output)
- **React 19**
- **TypeScript**
- **Vanilla CSS** (no framework)
## Contributors
Immich Folio is maintained by [@ralksta](https://github.com/ralksta) and made
better by the people below. Thank you — every one of these made the project
easier to live with.
- **[@lancetm714](https://github.com/lancetm714)** — built the setup wizard that
turns a fresh install into a few clicks in the browser instead of hand-written
config files. Also added custom favicons, so your portfolio gets its own icon
in the browser tab, and made a gallery with no albums yet a perfectly valid
starting point rather than an error. In v0.15.0, made client proofing work
properly: the selection bar floats again with page transitions on, and it is
readable in light mode. Also brought links to journal quote attributions. In
v0.17.0, built the ZIP download: a whole album, or just the photos a client
picked, as one archive of the originals — streamed, so even a large shoot
arrives complete. In v0.18.0, kept accent-coloured buttons readable when the
accent is light, and made a mistyped accent fall back to the theme instead of
turning every button invisible.
- **[@RichKidsDev](https://github.com/RichKidsDev)** — noticed that the
Impressum still cited the TMG, which the DDG replaced in 2024. That report
turned into a proper overhaul in v0.18.0: the right law by default, a heading
you can change for other countries, clickable contacts and a contact form as
a second channel.
- **[@ImScheinox](https://github.com/ImScheinox)** — found and fixed portrait
photos rendering as landscape tiles, because the grid ignored the camera's
EXIF orientation flag. That brought the masonry layout's stagger back for
anyone whose camera records portrait frames that way, which is most of them.
- **[Jules](https://jules.google.com)** — an automated reviewer that has been
quietly hardening the project in the background: better screen-reader support
in the photo grid, and a series of fixes keeping the public endpoints from
being overwhelmed by traffic.
- **[Dependabot](https://github.com/dependabot)** — keeps every dependency
current, which is most of the reason security fixes land here quickly.
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) to get
started. Pull requests target the `dev` branch.
## License
MIT License — free to use and modify for the Immich community.