🛡️ Sentinel: [HIGH] Fix missing rate limiting on /api/exif/[id]

Co-authored-by: ralksta <16941050+ralksta@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot]
2026-03-12 11:30:45 +00:00
parent a0364ac9f0
commit 63e6a2f784
30 changed files with 1046 additions and 874 deletions

View File

@@ -1,3 +1,4 @@
## 2024-03-XX - Lightbox Accessibility ## 2024-03-XX - Lightbox Accessibility
**Learning:** Found that Lightbox overlay has good keyboard navigation (esc/left/right) but lacked tooltip hints for those controls and lacked a shortcut for EXIF data toggle. **Learning:** Found that Lightbox overlay has good keyboard navigation (esc/left/right) but lacked tooltip hints for those controls and lacked a shortcut for EXIF data toggle.
**Action:** Added `i` shortcut and `title` attributes on interactive elements in Lightbox. **Action:** Added `i` shortcut and `title` attributes on interactive elements in Lightbox.

5
.jules/sentinel.md Normal file
View File

@@ -0,0 +1,5 @@
## 2024-03-12 - [Missing Rate Limiting on EXIF Endpoint]
**Vulnerability:** The `/api/exif/[id]` endpoint lacked rate limiting, making it vulnerable to DoS attacks. An attacker could rapidly request EXIF metadata, exhausting server resources and the Immich API rate limits.
**Learning:** Even simple GET endpoints that fetch metadata from an upstream server (like Immich) must be protected with rate limiting to prevent downstream DoS and resource exhaustion, especially when they perform backend API requests per hit.
**Prevention:** Always implement rate limiting on endpoints that proxy requests or query a backend API, utilizing the shared `checkRateLimit` utility.

View File

@@ -37,7 +37,7 @@ Immich Folio acts as a **secure reverse proxy** between your visitors and your p
<br> <br>
| Concern | Protection | | Concern | Protection |
| --- | --- | | ----------------------- | --------------------------------------------------------------------- |
| **Server exposure** | Immich URL never leaves your network — all requests proxy server-side | | **Server exposure** | Immich URL never leaves your network — all requests proxy server-side |
| **API key** | Stored only in `.env.local`, never in client code | | **API key** | Stored only in `.env.local`, never in client code |
| **Asset IDs** | Immich UUIDs encrypted (AES-256) into opaque tokens | | **Asset IDs** | Immich UUIDs encrypted (AES-256) into opaque tokens |

View File

@@ -29,7 +29,11 @@ export function AlbumDetailView({
<div className="album-header"> <div className="album-header">
<BackLink href={backLinkHref} label={backLinkLabel} /> <BackLink href={backLinkHref} label={backLinkLabel} />
<h1 className="album-header__title">{album.albumName}</h1> <h1 className="album-header__title">{album.albumName}</h1>
{subtitle && <p className="subpage-subtitle" style={{ textAlign: "left", marginLeft: 0 }}>{subtitle}</p>} {subtitle && (
<p className="subpage-subtitle" style={{ textAlign: 'left', marginLeft: 0 }}>
{subtitle}
</p>
)}
<p className="album-header__meta"> <p className="album-header__meta">
{images.length} {images.length === 1 ? 'photo' : 'photos'} {images.length} {images.length === 1 ? 'photo' : 'photos'}
{album.description && ` · ${album.description}`} {album.description && ` · ${album.description}`}

View File

@@ -75,7 +75,14 @@ function AlbumGrid({
); );
} }
export function SubpageGridView({ slug, title, subtitle, albums, coverPlaceholders, sections }: SubpageGridViewProps) { export function SubpageGridView({
slug,
title,
subtitle,
albums,
coverPlaceholders,
sections,
}: SubpageGridViewProps) {
// Build lookup maps once // Build lookup maps once
const albumMap = new Map(albums.map((a) => [a.id, a])); const albumMap = new Map(albums.map((a) => [a.id, a]));
const placeholderMap = new Map(albums.map((a, i) => [a.id, coverPlaceholders[i] ?? null])); const placeholderMap = new Map(albums.map((a, i) => [a.id, coverPlaceholders[i] ?? null]));
@@ -97,9 +104,7 @@ export function SubpageGridView({ slug, title, subtitle, albums, coverPlaceholde
{sections.map((sec, i) => ( {sections.map((sec, i) => (
<span key={sec.slug} className="subpage-toc__entry"> <span key={sec.slug} className="subpage-toc__entry">
<a href={`#${sec.slug}`} className="subpage-toc__link"> <a href={`#${sec.slug}`} className="subpage-toc__link">
<span className="subpage-toc__num"> <span className="subpage-toc__num">{String(i + 1).padStart(2, '0')}</span>
{String(i + 1).padStart(2, '0')}
</span>
<span className="subpage-toc__label">{sec.title}</span> <span className="subpage-toc__label">{sec.title}</span>
</a> </a>
</span> </span>
@@ -118,9 +123,7 @@ export function SubpageGridView({ slug, title, subtitle, albums, coverPlaceholde
<section key={sec.slug} id={sec.slug} className="subpage-section"> <section key={sec.slug} id={sec.slug} className="subpage-section">
<header className="subpage-section__header"> <header className="subpage-section__header">
<h2 className="subpage-section__title">{sec.title}</h2> <h2 className="subpage-section__title">{sec.title}</h2>
{sec.description && ( {sec.description && <p className="subpage-section__desc">{sec.description}</p>}
<p className="subpage-section__desc">{sec.description}</p>
)}
<div className="subpage-section__rule" aria-hidden="true" /> <div className="subpage-section__rule" aria-hidden="true" />
</header> </header>
<AlbumGrid <AlbumGrid

View File

@@ -5,12 +5,13 @@
* The :token is an encoded asset ID (not a raw UUID). * The :token is an encoded asset ID (not a raw UUID).
*/ */
import { NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { immich } from '@/lib/immich'; import { immich } from '@/lib/immich';
import { decodeAssetId } from '@/lib/tokens'; import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { checkRateLimit } from '@/lib/rate-limit';
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const config = getConfig(); const config = getConfig();
// Security: If EXIF is globally disabled, do not serve it via API either // Security: If EXIF is globally disabled, do not serve it via API either
@@ -18,6 +19,21 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'EXIF metadata is disabled' }, { status: 403 }); return NextResponse.json({ error: 'EXIF metadata is disabled' }, { status: 403 });
} }
// ── Rate limiting ──────────────────────────────────
// Security: Prioritize x-real-ip (set by trusted reverse proxies like nginx/Traefik)
// over x-forwarded-for which can be spoofed by clients.
const ip =
request.headers.get('x-real-ip') ??
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ??
'unknown';
const { success, resetAt } = checkRateLimit(`exif:${ip}`, config.rateLimitRpm);
if (!success) {
const retryAfter = Math.ceil((resetAt - Date.now()) / 1000);
console.warn(`[EXIF API] ⚠️ Rate limit exceeded for IP: ${ip}. Retry after ${retryAfter}s`);
return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
}
const { id: token } = await params; const { id: token } = await params;
// Decode the opaque token back to an Immich asset ID // Decode the opaque token back to an Immich asset ID

View File

@@ -197,8 +197,8 @@
} }
/* HeroCarousel images fill the entire container */ /* HeroCarousel images fill the entire container */
.hero--fullbleed>span, .hero--fullbleed > span,
.hero--fullbleed>img { .hero--fullbleed > img {
position: absolute !important; position: absolute !important;
inset: 0; inset: 0;
width: 100% !important; width: 100% !important;
@@ -218,14 +218,21 @@
padding: 64px 40px; padding: 64px 40px;
/* Multi-stop for contrast on any image brightness */ /* Multi-stop for contrast on any image brightness */
background: background:
radial-gradient(ellipse at center, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0.0) 70%), radial-gradient(ellipse at center, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0) 70%),
linear-gradient(to bottom, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.15) 40%, rgba(0, 0, 0, 0.45) 100%); linear-gradient(
to bottom,
rgba(0, 0, 0, 0.4) 0%,
rgba(0, 0, 0, 0.15) 40%,
rgba(0, 0, 0, 0.45) 100%
);
} }
.hero--fullbleed .hero__title { .hero--fullbleed .hero__title {
color: #fff; color: #fff;
font-size: 4rem; font-size: 4rem;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7), 0 8px 48px rgba(0, 0, 0, 0.6); text-shadow:
0 2px 8px rgba(0, 0, 0, 0.7),
0 8px 48px rgba(0, 0, 0, 0.6);
} }
.hero--fullbleed .hero__subtitle { .hero--fullbleed .hero__subtitle {
@@ -299,7 +306,12 @@
right: 0; right: 0;
z-index: 2; z-index: 2;
padding: 48px 40px; padding: 48px 40px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.3) 50%, transparent 100%); background: linear-gradient(
to top,
rgba(0, 0, 0, 0.8) 0%,
rgba(0, 0, 0, 0.3) 50%,
transparent 100%
);
} }
.hero--stacked .hero__title { .hero--stacked .hero__title {
@@ -589,7 +601,7 @@
align-items: center; align-items: center;
} }
.subpage-toc__entry+.subpage-toc__entry::before { .subpage-toc__entry + .subpage-toc__entry::before {
content: '·'; content: '·';
padding: 0 12px; padding: 0 12px;
color: var(--text-muted); color: var(--text-muted);
@@ -658,7 +670,6 @@
width: 100%; width: 100%;
} }
.subpage-grid { .subpage-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@@ -763,10 +774,12 @@
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
padding: 24px; padding: 24px;
background: linear-gradient(to top, background: linear-gradient(
to top,
rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.8) 0%,
rgba(0, 0, 0, 0.3) 40%, rgba(0, 0, 0, 0.3) 40%,
transparent 100%); transparent 100%
);
opacity: 0; opacity: 0;
transition: opacity var(--transition-normal); transition: opacity var(--transition-normal);
} }
@@ -981,10 +994,12 @@
/* ── Loading States ───────────────────────────────── */ /* ── Loading States ───────────────────────────────── */
.skeleton { .skeleton {
background: linear-gradient(90deg, background: linear-gradient(
90deg,
var(--bg-card) 0%, var(--bg-card) 0%,
var(--bg-card-hover) 50%, var(--bg-card-hover) 50%,
var(--bg-card) 100%); var(--bg-card) 100%
);
background-size: 200% 100%; background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite; animation: skeleton-shimmer 1.5s infinite;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);

View File

@@ -65,28 +65,71 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body> <body>
<div style={{ <div
display: 'flex', alignItems: 'center', justifyContent: 'center', style={{
minHeight: '100dvh', padding: '2rem', backgroundColor: '#ffffff', display: 'flex',
color: '#111111', fontFamily: 'system-ui, sans-serif' alignItems: 'center',
}}> justifyContent: 'center',
minHeight: '100dvh',
padding: '2rem',
backgroundColor: '#ffffff',
color: '#111111',
fontFamily: 'system-ui, sans-serif',
}}
>
<div style={{ maxWidth: '600px', width: '100%' }}> <div style={{ maxWidth: '600px', width: '100%' }}>
<h1 style={{ fontSize: 'clamp(2rem, 5vw, 3rem)', fontWeight: 500, marginBottom: '0.5rem', letterSpacing: '-0.02em' }}> <h1
style={{
fontSize: 'clamp(2rem, 5vw, 3rem)',
fontWeight: 500,
marginBottom: '0.5rem',
letterSpacing: '-0.02em',
}}
>
Setup Required Setup Required
</h1> </h1>
<p style={{ fontSize: '1.125rem', opacity: 0.7, marginBottom: '2rem', lineHeight: 1.5 }}> <p
Immich Folio is running, but it looks like your configuration files or environment variables are missing. style={{
fontSize: '1.125rem',
opacity: 0.7,
marginBottom: '2rem',
lineHeight: 1.5,
}}
>
Immich Folio is running, but it looks like your configuration files or environment
variables are missing.
</p>
<div
style={{
backgroundColor: 'rgba(0, 0, 0, 0.03)',
padding: '1.5rem',
borderRadius: '8px',
fontSize: '0.95rem',
lineHeight: 1.6,
}}
>
<p style={{ marginBottom: '1rem' }}>
To get started, follow these steps in your repository or mounted{' '}
<code>content/</code> volume:
</p> </p>
<div style={{ backgroundColor: 'rgba(0, 0, 0, 0.03)', padding: '1.5rem', borderRadius: '8px', fontSize: '0.95rem', lineHeight: 1.6 }}>
<p style={{ marginBottom: '1rem' }}>To get started, follow these steps in your repository or mounted <code>content/</code> volume:</p>
<ol style={{ margin: 0, paddingLeft: '1.25rem' }}> <ol style={{ margin: 0, paddingLeft: '1.25rem' }}>
<li style={{ marginBottom: '0.5rem' }}>Copy <code>.env.example</code> to <code>.env.local</code> and fill in your Immich API URL and Key</li> <li style={{ marginBottom: '0.5rem' }}>
<li style={{ marginBottom: '0.5rem' }}>Copy <code>settings.yaml.example</code> to <code>settings.yaml</code></li> Copy <code>.env.example</code> to <code>.env.local</code> and fill in your
<li style={{ marginBottom: '0.5rem' }}>Copy <code>gallery.yaml.example</code> to <code>gallery.yaml</code></li> Immich API URL and Key
<li style={{ marginBottom: '0.5rem' }}>Copy <code>about.md.example</code> to <code>about.md</code> (optional)</li> </li>
<li style={{ marginBottom: '0.5rem' }}>
Copy <code>settings.yaml.example</code> to <code>settings.yaml</code>
</li>
<li style={{ marginBottom: '0.5rem' }}>
Copy <code>gallery.yaml.example</code> to <code>gallery.yaml</code>
</li>
<li style={{ marginBottom: '0.5rem' }}>
Copy <code>about.md.example</code> to <code>about.md</code> (optional)
</li>
</ol> </ol>
<p style={{ marginTop: '1.5rem', marginBottom: 0 }}> <p style={{ marginTop: '1.5rem', marginBottom: 0 }}>
Open <code>gallery.yaml</code> and add the album IDs you want to display, then <strong>restart your server</strong>. Open <code>gallery.yaml</code> and add the album IDs you want to display, then{' '}
<strong>restart your server</strong>.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -198,7 +198,7 @@
justify-content: center; justify-content: center;
} }
[data-preset='classic'] .subpage-toc__entry+.subpage-toc__entry::before { [data-preset='classic'] .subpage-toc__entry + .subpage-toc__entry::before {
content: '◆'; content: '◆';
padding: 0 14px; padding: 0 14px;
font-size: 0.45rem; font-size: 0.45rem;

View File

@@ -181,7 +181,7 @@
margin-bottom: 64px; margin-bottom: 64px;
} }
[data-preset='editorial'] .subpage-toc__entry+.subpage-toc__entry::before { [data-preset='editorial'] .subpage-toc__entry + .subpage-toc__entry::before {
content: '/'; content: '/';
padding: 0 12px; padding: 0 12px;
font-family: var(--font-caption); font-family: var(--font-caption);

View File

@@ -198,7 +198,7 @@
margin-bottom: 48px; margin-bottom: 48px;
} }
[data-preset='minimal'] .subpage-toc__entry+.subpage-toc__entry::before { [data-preset='minimal'] .subpage-toc__entry + .subpage-toc__entry::before {
content: '—'; content: '—';
padding: 0 10px; padding: 0 10px;
font-size: 0.6rem; font-size: 0.6rem;

View File

@@ -164,7 +164,7 @@
} }
/* Main: extra generous margins */ /* Main: extra generous margins */
[data-preset='monograph']~body .main { [data-preset='monograph'] ~ body .main {
padding: 80px 60px 120px; padding: 80px 60px 120px;
} }
@@ -203,7 +203,7 @@
border-bottom: none; border-bottom: none;
} }
[data-preset='monograph'] .subpage-toc__entry+.subpage-toc__entry::before { [data-preset='monograph'] .subpage-toc__entry + .subpage-toc__entry::before {
display: none; display: none;
} }

View File

@@ -111,7 +111,7 @@
margin-bottom: 56px; margin-bottom: 56px;
} }
[data-preset='noir'] .subpage-toc__entry+.subpage-toc__entry::before { [data-preset='noir'] .subpage-toc__entry + .subpage-toc__entry::before {
content: '|'; content: '|';
padding: 0 12px; padding: 0 12px;
font-family: var(--font-caption); font-family: var(--font-caption);

View File

@@ -55,7 +55,9 @@
-webkit-user-drag: none; -webkit-user-drag: none;
opacity: 0; opacity: 0;
transform: scale(0.96); transform: scale(0.96);
transition: opacity var(--transition-normal), transform 600ms cubic-bezier(0.16, 1, 0.3, 1); transition:
opacity var(--transition-normal),
transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
} }
.imageLoaded { .imageLoaded {
@@ -251,7 +253,6 @@
} }
@media (hover: none) { @media (hover: none) {
.close, .close,
.nav, .nav,
.counter { .counter {

View File

@@ -7,6 +7,7 @@ cp content/gallery.yaml.example content/gallery.yaml
``` ```
**Contents:** **Contents:**
- [Hero Images](#hero-images) - [Hero Images](#hero-images)
- [Standalone Albums](#standalone-albums) - [Standalone Albums](#standalone-albums)
- [Subpages & Categories](#subpages) - [Subpages & Categories](#subpages)
@@ -34,6 +35,7 @@ hero:
## Standalone Albums ## Standalone Albums
Albums shown directly on the homepage as cards. Albums shown directly on the homepage as cards.
> [!NOTE] > [!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. > 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.
@@ -51,8 +53,8 @@ The overall **Subpage Cover Image** shown on the homepage is automatically inher
```yaml ```yaml
subpages: subpages:
- name: Japan # → /japan - name: Japan # → /japan
title: "Trip to Japan" # Optional: overrides the page heading title: 'Trip to Japan' # Optional: overrides the page heading
subtitle: "2024 adventures" # Optional: adds a subline under the heading subtitle: '2024 adventures' # Optional: adds a subline under the heading
albums: albums:
- 33333333-3333-3333-3333-333333333333 # Tokyo - 33333333-3333-3333-3333-333333333333 # Tokyo
- 44444444-4444-4444-4444-444444444444 # Kyoto - 44444444-4444-4444-4444-444444444444 # Kyoto
@@ -67,9 +69,9 @@ Alternatively, you can use the object notation (recommended):
```yaml ```yaml
subpages: subpages:
"Japan": 'Japan':
title: "Trip to Japan" title: 'Trip to Japan'
subtitle: "2024 adventures" subtitle: '2024 adventures'
albums: albums:
- 33333333-3333-3333-3333-333333333333 - 33333333-3333-3333-3333-333333333333
``` ```
@@ -81,7 +83,7 @@ When a subpage contains many albums you can split them into **named sections**.
Each section can have: Each section can have:
| Field | Type | Required | Description | | Field | Type | Required | Description |
|---|---|---|---| | ------------- | ------ | -------- | ------------------------------------------- |
| `title` | string | ✅ | Section heading + anchor name | | `title` | string | ✅ | Section heading + anchor name |
| `description` | string | ➖ | Optional subline under the heading | | `description` | string | ➖ | Optional subline under the heading |
| `albums` | list | ✅ | Album UUIDs (same format as regular albums) | | `albums` | list | ✅ | Album UUIDs (same format as regular albums) |
@@ -90,8 +92,8 @@ Within the `albums` list you can use a plain UUID or override the display title:
```yaml ```yaml
albums: albums:
- "album-uuid" # → uses the Immich album name - 'album-uuid' # → uses the Immich album name
- "album-uuid": My Title # → displays "My Title" instead - 'album-uuid': My Title # → displays "My Title" instead
``` ```
**Full example:** **Full example:**
@@ -100,29 +102,28 @@ albums:
subpages: subpages:
- name: Japan - name: Japan
title: Japan title: Japan
subtitle: "Reisen durch ein Land voller Kontraste." subtitle: 'Reisen durch ein Land voller Kontraste.'
sections: sections:
- title: Tokyo - title: Tokyo
description: "Megacity, Neonlichter, Stille im Lärm." description: 'Megacity, Neonlichter, Stille im Lärm.'
albums: albums:
- "33333333-3333-3333-3333-333333333333" - '33333333-3333-3333-3333-333333333333'
- "44444444-4444-4444-4444-444444444444": Shinjuku bei Nacht - '44444444-4444-4444-4444-444444444444': Shinjuku bei Nacht
- title: Kyoto - title: Kyoto
description: "Tempel und Bambuswälder." description: 'Tempel und Bambuswälder.'
albums: albums:
- "55555555-5555-5555-5555-555555555555": Fushimi Inari - '55555555-5555-5555-5555-555555555555': Fushimi Inari
- "66666666-6666-6666-6666-666666666666" - '66666666-6666-6666-6666-666666666666'
- title: Osaka - title: Osaka
albums: # description is optional albums: # description is optional
- "77777777-7777-7777-7777-777777777777" - '77777777-7777-7777-7777-777777777777'
``` ```
> [!NOTE] > [!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. > 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 ## Grid Layout
Configure the photo grid globally or per-subpage: Configure the photo grid globally or per-subpage:
@@ -132,7 +133,7 @@ Configure the photo grid globally or per-subpage:
grid: grid:
columns: 3 # number of columns (default: 3) columns: 3 # number of columns (default: 3)
gap: 12 # gap in pixels (default: 12) gap: 12 # gap in pixels (default: 12)
aspectRatio: "1" # "1", "3/2", "auto", etc. (default: "1") aspectRatio: '1' # "1", "3/2", "auto", etc. (default: "1")
layout: masonry # "masonry" | "uniform" | "showcase" | "filmstrip" | "editorial-flow" layout: masonry # "masonry" | "uniform" | "showcase" | "filmstrip" | "editorial-flow"
subpages: subpages:
@@ -140,7 +141,7 @@ subpages:
grid: # per-subpage override grid: # per-subpage override
columns: 4 columns: 4
layout: uniform layout: uniform
aspectRatio: "3/2" aspectRatio: '3/2'
albums: albums:
- ... - ...
``` ```

View File

@@ -5,7 +5,7 @@ Eine konsolidierte Liste an Features und Ideen für zukünftige Versionen, berei
## 🏆 Quick Wins (High Impact / Low Effort) ## 🏆 Quick Wins (High Impact / Low Effort)
| Idea | Effort | Impact | | Idea | Effort | Impact |
|------|--------|--------| | ------------------------------ | ------ | -------------------------------------------------- |
| **Slideshow mode** | Low | High — exhibition ready (Auto-advance 3s/5s/10s) | | **Slideshow mode** | Low | High — exhibition ready (Auto-advance 3s/5s/10s) |
| **Fullscreen API in lightbox** | Low | Medium — immersive (Chrome-free) | | **Fullscreen API in lightbox** | Low | Medium — immersive (Chrome-free) |
| **Download originals** | Low | High — client delivery (Button in lightbox) | | **Download originals** | Low | High — client delivery (Button in lightbox) |
@@ -29,7 +29,7 @@ Eine konsolidierte Liste an Features und Ideen für zukünftige Versionen, berei
## 📖 Deep Dive Feature Plan: Photo Stories / Essays ## 📖 Deep Dive Feature Plan: Photo Stories / Essays
*Vertical scroll format with images + text interspersed, like a photo essay. A major portfolio differentiator.* _Vertical scroll format with images + text interspersed, like a photo essay. A major portfolio differentiator._
### Proposed Architecture ### Proposed Architecture

View File

@@ -15,7 +15,7 @@ That's it. Your entire gallery switches to the minimal theme.
## Built-in Presets ## Built-in Presets
| Preset | Style | Accent | Fonts | Hero | Frame | Grain | | Preset | Style | Accent | Fonts | Hero | Frame | Grain |
| ------------- | ------------------------------ | ------------ | ---------------------------------- | ----------- | ------------ | ----- | | ------------- | ------------------------------ | ------------ | --------------------------------- | ----------- | ------------ | ----- |
| **studio** | Leica-inspired, editorial | 🔴 `#e60012` | Playfair Display + DM Sans | Split | Passepartout | ✓ | | **studio** | Leica-inspired, editorial | 🔴 `#e60012` | Playfair Display + DM Sans | Split | Passepartout | ✓ |
| **minimal** | Swiss brutalist, high contrast | ⚫ `#000000` | Geist + IBM Plex Mono | Fullbleed | None | ✗ | | **minimal** | Swiss brutalist, high contrast | ⚫ `#000000` | Geist + IBM Plex Mono | Fullbleed | None | ✗ |
| **editorial** | Cinematic magazine, warm tones | 🟤 `#8B2500` | Bodoni Moda + Newsreader | Split | Shadow | ✗ | | **editorial** | Cinematic magazine, warm tones | 🟤 `#8B2500` | Bodoni Moda + Newsreader | Split | Shadow | ✗ |
@@ -26,6 +26,7 @@ That's it. Your entire gallery switches to the minimal theme.
Default is `studio` if no theme is specified. Default is `studio` if no theme is specified.
### Theme Gallery ### Theme Gallery
_From top to bottom: Studio, Minimal, Editorial, Classic, Noir, Monograph_ _From top to bottom: Studio, Minimal, Editorial, Classic, Noir, Monograph_
<p align="center"> <p align="center">
@@ -38,6 +39,7 @@ _From top to bottom: Studio, Minimal, Editorial, Classic, Noir, Monograph_
</p> </p>
### Grid View Examples ### Grid View Examples
<p align="center"> <p align="center">
<img src="screenshots/theme-studio-grid.png" width="49%" alt="Studio grid" /> <img src="screenshots/theme-studio-grid.png" width="49%" alt="Studio grid" />
<img src="screenshots/theme-minimal-grid.png" width="49%" alt="Minimal grid" /> <img src="screenshots/theme-minimal-grid.png" width="49%" alt="Minimal grid" />

View File

@@ -1,7 +1,15 @@
import { env } from '../env'; import { env } from '../env';
import { loadYaml, validateUuid } from './parser'; import { loadYaml, validateUuid } from './parser';
import { resolveTheme, VALID_LAYOUTS } from './theme'; import { resolveTheme, VALID_LAYOUTS } from './theme';
import { slugify, AppConfig, SubpageConfig, SubpageSectionConfig, GalleryYaml, SettingsYaml, GridConfig } from './schema'; import {
slugify,
AppConfig,
SubpageConfig,
SubpageSectionConfig,
GalleryYaml,
SettingsYaml,
GridConfig,
} from './schema';
export * from './schema'; export * from './schema';
export * from './theme'; export * from './theme';
@@ -16,7 +24,9 @@ export function getConfig(): AppConfig {
const authSecret = env.AUTH_SECRET || apiKey; const authSecret = env.AUTH_SECRET || apiKey;
if (!env.AUTH_SECRET && process.env.NODE_ENV === 'production') { if (!env.AUTH_SECRET && process.env.NODE_ENV === 'production') {
console.warn('\n⚠️ SECURITY WARNING: AUTH_SECRET is not set. Falling back to IMMICH_API_KEY.\n Please set a long random string as AUTH_SECRET in your .env for better security.\n'); console.warn(
'\n⚠️ SECURITY WARNING: AUTH_SECRET is not set. Falling back to IMMICH_API_KEY.\n Please set a long random string as AUTH_SECRET in your .env for better security.\n',
);
} }
const gallery = loadYaml<GalleryYaml>('gallery.yaml'); const gallery = loadYaml<GalleryYaml>('gallery.yaml');
@@ -32,7 +42,12 @@ export function getConfig(): AppConfig {
subpages: [], subpages: [],
siteTitle: env.SITE_TITLE || 'Immich Folio', siteTitle: env.SITE_TITLE || 'Immich Folio',
siteSubtitle: env.SITE_SUBTITLE || 'Setup Required', siteSubtitle: env.SITE_SUBTITLE || 'Setup Required',
seo: { title: 'Setup Required', description: 'Please configure Immich Folio', noIndex: true, noFollow: true }, seo: {
title: 'Setup Required',
description: 'Please configure Immich Folio',
noIndex: true,
noFollow: true,
},
heroImages: [], heroImages: [],
exifOnHover: true, exifOnHover: true,
grid: { columns: 3, gap: 12, aspectRatio: '1', layout: 'masonry' }, grid: { columns: 3, gap: 12, aspectRatio: '1', layout: 'masonry' },
@@ -44,7 +59,7 @@ export function getConfig(): AppConfig {
albumOverrides: {}, albumOverrides: {},
cacheTtl: env.CACHE_TTL * 1000, cacheTtl: env.CACHE_TTL * 1000,
rateLimitRpm: env.RATE_LIMIT_RPM, rateLimitRpm: env.RATE_LIMIT_RPM,
needsSetup: true needsSetup: true,
}; };
return _config; return _config;
} }
@@ -63,8 +78,16 @@ export function getConfig(): AppConfig {
return validatedUuid; return validatedUuid;
} }
const standaloneAlbumIds = (gallery.albums ?? []).map((entry) => processAlbumEntry(entry, 'gallery.yaml albums')); const standaloneAlbumIds = (gallery.albums ?? []).map((entry) =>
if (standaloneAlbumIds.length === 0 && (!gallery.subpages || (Array.isArray(gallery.subpages) ? gallery.subpages.length === 0 : Object.keys(gallery.subpages).length === 0))) { processAlbumEntry(entry, 'gallery.yaml albums'),
);
if (
standaloneAlbumIds.length === 0 &&
(!gallery.subpages ||
(Array.isArray(gallery.subpages)
? gallery.subpages.length === 0
: Object.keys(gallery.subpages).length === 0))
) {
throw new Error('gallery.yaml must define at least one album or subpage'); throw new Error('gallery.yaml must define at least one album or subpage');
} }
@@ -85,12 +108,16 @@ export function getConfig(): AppConfig {
title: sec.title, title: sec.title,
slug: slugify(sec.title), slug: slugify(sec.title),
description: sec.description, description: sec.description,
albumIds: sec.albums.map((entry) => processAlbumEntry(entry, `subpage "${sp.name}" section "${sec.title}"`)), albumIds: sec.albums.map((entry) =>
processAlbumEntry(entry, `subpage "${sp.name}" section "${sec.title}"`),
),
})); }));
// flat union of all section albums // flat union of all section albums
albumIds = sections.flatMap((s) => s.albumIds); albumIds = sections.flatMap((s) => s.albumIds);
// Also include any top-level albums (outside sections) // Also include any top-level albums (outside sections)
const topLevel = (sp.albums ?? []).map((entry) => processAlbumEntry(entry, `subpage "${sp.name}"`)); const topLevel = (sp.albums ?? []).map((entry) =>
processAlbumEntry(entry, `subpage "${sp.name}"`),
);
albumIds = [...topLevel, ...albumIds]; albumIds = [...topLevel, ...albumIds];
} else { } else {
const albums = sp.albums ?? []; const albums = sp.albums ?? [];
@@ -108,22 +135,40 @@ export function getConfig(): AppConfig {
albumIds, albumIds,
sections, sections,
password: sp.password, password: sp.password,
...(sp.grid ? { ...(sp.grid
? {
grid: { grid: {
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}), ...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}), ...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}), ...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
...(sp.grid.layout != null ? { layout: (VALID_LAYOUTS.includes(sp.grid.layout) ? sp.grid.layout : 'masonry') as GridConfig['layout'] } : {}), ...(sp.grid.layout != null
? {
layout: (VALID_LAYOUTS.includes(sp.grid.layout)
? sp.grid.layout
: 'masonry') as GridConfig['layout'],
}
: {}),
}, },
} : {}), }
: {}),
}; };
}); });
} else if (gallery.subpages) { } else if (gallery.subpages) {
subpages = Object.entries(gallery.subpages).map(([name, value]) => { subpages = Object.entries(gallery.subpages).map(([name, value]) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return { name, slug: slugify(name), albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)) }; return {
name,
slug: slugify(name),
albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
};
}
interface SubpageObjectValue {
title?: string;
subtitle?: string;
albums?: Array<string | Record<string, string>>;
password?: string;
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
} }
interface SubpageObjectValue { title?: string; subtitle?: string; albums?: Array<string | Record<string, string>>; password?: string; grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string; }; }
const sp = value as SubpageObjectValue; const sp = value as SubpageObjectValue;
const albumEntries = sp.albums || []; const albumEntries = sp.albums || [];
return { return {
@@ -133,14 +178,22 @@ export function getConfig(): AppConfig {
subtitle: sp.subtitle, subtitle: sp.subtitle,
albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)), albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
password: sp.password, password: sp.password,
...(sp.grid ? { ...(sp.grid
? {
grid: { grid: {
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}), ...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}), ...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}), ...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
...(sp.grid.layout != null ? { layout: (VALID_LAYOUTS.includes(sp.grid.layout) ? sp.grid.layout : 'masonry') as GridConfig['layout'] } : {}), ...(sp.grid.layout != null
? {
layout: (VALID_LAYOUTS.includes(sp.grid.layout)
? sp.grid.layout
: 'masonry') as GridConfig['layout'],
}
: {}),
}, },
} : {}), }
: {}),
}; };
}); });
} }
@@ -158,20 +211,37 @@ export function getConfig(): AppConfig {
siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE, siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
seo: { seo: {
title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery', title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery',
description: settings.seo?.description || settings.subtitle || env.SITE_SUBTITLE || 'A curated photography portfolio', description:
settings.seo?.description ||
settings.subtitle ||
env.SITE_SUBTITLE ||
'A curated photography portfolio',
noIndex: settings.seo?.noIndex === true, noIndex: settings.seo?.noIndex === true,
noFollow: settings.seo?.noFollow === true, noFollow: settings.seo?.noFollow === true,
}, },
heroImages: gallery.hero ? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) => validateUuid(id, 'gallery.yaml hero')) : [], heroImages: gallery.hero
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
validateUuid(id, 'gallery.yaml hero'),
)
: [],
exifOnHover: settings.exifOnHover !== false, exifOnHover: settings.exifOnHover !== false,
grid: { grid: {
columns: settings.grid?.columns ?? 3, columns: settings.grid?.columns ?? 3,
gap: settings.grid?.gap ?? 12, gap: settings.grid?.gap ?? 12,
aspectRatio: settings.grid?.aspectRatio ?? '1', aspectRatio: settings.grid?.aspectRatio ?? '1',
layout: (VALID_LAYOUTS.includes(settings.grid?.layout ?? '') ? settings.grid!.layout : 'masonry') as GridConfig['layout'], layout: (VALID_LAYOUTS.includes(settings.grid?.layout ?? '')
? settings.grid!.layout
: 'masonry') as GridConfig['layout'],
}, },
theme, theme,
footer: settings.footer ? { name: settings.footer.name, instagram: settings.footer.instagram, email: settings.footer.email, website: settings.footer.website } : null, footer: settings.footer
? {
name: settings.footer.name,
instagram: settings.footer.instagram,
email: settings.footer.email,
website: settings.footer.website,
}
: null,
legal: { legal: {
enabled: settings.legal?.enabled === true, enabled: settings.legal?.enabled === true,
name: settings.legal?.name || '', name: settings.legal?.name || '',
@@ -189,7 +259,7 @@ export function getConfig(): AppConfig {
albumOverrides, albumOverrides,
cacheTtl: env.CACHE_TTL * 1000, cacheTtl: env.CACHE_TTL * 1000,
rateLimitRpm: env.RATE_LIMIT_RPM, rateLimitRpm: env.RATE_LIMIT_RPM,
needsSetup: false needsSetup: false,
}; };
return _config; return _config;
} }

View File

@@ -74,7 +74,14 @@ export const THEME_PRESETS: Record<string, ThemeConfig> = {
}; };
export const VALID_PHOTO_FRAMES = ['none', 'passepartout', 'shadow']; export const VALID_PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
export const VALID_HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic']; export const VALID_HERO_STYLES = [
'split',
'fullbleed',
'minimal',
'stacked',
'typographic',
'mosaic',
];
export const VALID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow']; export const VALID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig { export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
@@ -83,7 +90,9 @@ export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
if (typeof raw === 'string') { if (typeof raw === 'string') {
const preset = THEME_PRESETS[raw]; const preset = THEME_PRESETS[raw];
if (!preset) { if (!preset) {
throw new Error(`Unknown theme preset "${raw}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`); throw new Error(
`Unknown theme preset "${raw}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`,
);
} }
return { ...preset }; return { ...preset };
} }
@@ -91,7 +100,9 @@ export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
const baseName = raw.preset ?? 'studio'; const baseName = raw.preset ?? 'studio';
const base = THEME_PRESETS[baseName]; const base = THEME_PRESETS[baseName];
if (!base) { if (!base) {
throw new Error(`Unknown theme preset "${baseName}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`); throw new Error(
`Unknown theme preset "${baseName}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`,
);
} }
return { return {

View File

@@ -29,10 +29,12 @@ function parseEnv(): Env {
const apiKey = process.env.IMMICH_API_KEY || ''; const apiKey = process.env.IMMICH_API_KEY || '';
const cacheTtlStr = process.env.CACHE_TTL; const cacheTtlStr = process.env.CACHE_TTL;
const cacheTtl = cacheTtlStr && !isNaN(parseInt(cacheTtlStr, 10)) ? parseInt(cacheTtlStr, 10) : 300; const cacheTtl =
cacheTtlStr && !isNaN(parseInt(cacheTtlStr, 10)) ? parseInt(cacheTtlStr, 10) : 300;
const rateLimitStr = process.env.RATE_LIMIT_RPM; const rateLimitStr = process.env.RATE_LIMIT_RPM;
const rateLimit = rateLimitStr && !isNaN(parseInt(rateLimitStr, 10)) ? parseInt(rateLimitStr, 10) : 600; const rateLimit =
rateLimitStr && !isNaN(parseInt(rateLimitStr, 10)) ? parseInt(rateLimitStr, 10) : 600;
return { return {
IMMICH_API_URL: apiUrl, IMMICH_API_URL: apiUrl,

View File

@@ -55,7 +55,6 @@ export interface ImmichExifInfo {
description: string | null; description: string | null;
} }
export type ImageSize = 'thumbnail' | 'preview' | 'original'; export type ImageSize = 'thumbnail' | 'preview' | 'original';
/** Enriched subpage with album metadata (for rendering cards). */ /** Enriched subpage with album metadata (for rendering cards). */
@@ -325,7 +324,6 @@ class ImmichClient {
return asset; return asset;
} }
/** /**
* Check if the Immich server is reachable. * Check if the Immich server is reachable.
*/ */