Merge pull request #9 from ralksta/fix/exif-rate-limit-3163002533333096016
🛡️ Sentinel: [HIGH] Fix missing rate limiting on /api/exif/[id]
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
## 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.
|
||||
**Action:** Added `i` shortcut and `title` attributes on interactive elements in Lightbox.
|
||||
|
||||
5
.jules/sentinel.md
Normal file
5
.jules/sentinel.md
Normal 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.
|
||||
@@ -37,7 +37,7 @@ Immich Folio acts as a **secure reverse proxy** between your visitors and your p
|
||||
<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 |
|
||||
|
||||
@@ -29,7 +29,11 @@ export function AlbumDetailView({
|
||||
<div className="album-header">
|
||||
<BackLink href={backLinkHref} label={backLinkLabel} />
|
||||
<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">
|
||||
{images.length} {images.length === 1 ? 'photo' : 'photos'}
|
||||
{album.description && ` · ${album.description}`}
|
||||
|
||||
@@ -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
|
||||
const albumMap = new Map(albums.map((a) => [a.id, a]));
|
||||
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) => (
|
||||
<span key={sec.slug} className="subpage-toc__entry">
|
||||
<a href={`#${sec.slug}`} className="subpage-toc__link">
|
||||
<span className="subpage-toc__num">
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span className="subpage-toc__num">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span className="subpage-toc__label">{sec.title}</span>
|
||||
</a>
|
||||
</span>
|
||||
@@ -118,9 +123,7 @@ export function SubpageGridView({ slug, title, subtitle, albums, coverPlaceholde
|
||||
<section key={sec.slug} id={sec.slug} className="subpage-section">
|
||||
<header className="subpage-section__header">
|
||||
<h2 className="subpage-section__title">{sec.title}</h2>
|
||||
{sec.description && (
|
||||
<p className="subpage-section__desc">{sec.description}</p>
|
||||
)}
|
||||
{sec.description && <p className="subpage-section__desc">{sec.description}</p>}
|
||||
<div className="subpage-section__rule" aria-hidden="true" />
|
||||
</header>
|
||||
<AlbumGrid
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
* 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 { decodeAssetId } from '@/lib/tokens';
|
||||
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();
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
|
||||
// Decode the opaque token back to an Immich asset ID
|
||||
|
||||
@@ -218,14 +218,21 @@
|
||||
padding: 64px 40px;
|
||||
/* Multi-stop for contrast on any image brightness */
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(0, 0, 0, 0.55) 0%, rgba(0, 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%);
|
||||
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%
|
||||
);
|
||||
}
|
||||
|
||||
.hero--fullbleed .hero__title {
|
||||
color: #fff;
|
||||
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 {
|
||||
@@ -299,7 +306,12 @@
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
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 {
|
||||
@@ -658,7 +670,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.subpage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@@ -763,10 +774,12 @@
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 24px;
|
||||
background: linear-gradient(to top,
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.8) 0%,
|
||||
rgba(0, 0, 0, 0.3) 40%,
|
||||
transparent 100%);
|
||||
transparent 100%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
@@ -981,10 +994,12 @@
|
||||
|
||||
/* ── Loading States ───────────────────────────────── */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg,
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-card) 0%,
|
||||
var(--bg-card-hover) 50%,
|
||||
var(--bg-card) 100%);
|
||||
var(--bg-card) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
@@ -65,28 +65,71 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
minHeight: '100dvh', padding: '2rem', backgroundColor: '#ffffff',
|
||||
color: '#111111', fontFamily: 'system-ui, sans-serif'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100dvh',
|
||||
padding: '2rem',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#111111',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
<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
|
||||
</h1>
|
||||
<p 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
|
||||
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>
|
||||
<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' }}>
|
||||
<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' }}>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>
|
||||
<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' }}>
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
-webkit-user-drag: none;
|
||||
opacity: 0;
|
||||
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 {
|
||||
@@ -251,7 +253,6 @@
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
|
||||
.close,
|
||||
.nav,
|
||||
.counter {
|
||||
|
||||
@@ -7,6 +7,7 @@ cp content/gallery.yaml.example content/gallery.yaml
|
||||
```
|
||||
|
||||
**Contents:**
|
||||
|
||||
- [Hero Images](#hero-images)
|
||||
- [Standalone Albums](#standalone-albums)
|
||||
- [Subpages & Categories](#subpages)
|
||||
@@ -34,6 +35,7 @@ hero:
|
||||
## 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.
|
||||
|
||||
@@ -51,8 +53,8 @@ The overall **Subpage Cover Image** shown on the homepage is automatically inher
|
||||
```yaml
|
||||
subpages:
|
||||
- name: Japan # → /japan
|
||||
title: "Trip to Japan" # Optional: overrides the page heading
|
||||
subtitle: "2024 adventures" # Optional: adds a subline under the heading
|
||||
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
|
||||
@@ -67,9 +69,9 @@ Alternatively, you can use the object notation (recommended):
|
||||
|
||||
```yaml
|
||||
subpages:
|
||||
"Japan":
|
||||
title: "Trip to Japan"
|
||||
subtitle: "2024 adventures"
|
||||
'Japan':
|
||||
title: 'Trip to Japan'
|
||||
subtitle: '2024 adventures'
|
||||
albums:
|
||||
- 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:
|
||||
|
||||
| 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) |
|
||||
@@ -90,8 +92,8 @@ Within the `albums` list you can use a plain UUID or override the display title:
|
||||
|
||||
```yaml
|
||||
albums:
|
||||
- "album-uuid" # → uses the Immich album name
|
||||
- "album-uuid": My Title # → displays "My Title" instead
|
||||
- 'album-uuid' # → uses the Immich album name
|
||||
- 'album-uuid': My Title # → displays "My Title" instead
|
||||
```
|
||||
|
||||
**Full example:**
|
||||
@@ -100,29 +102,28 @@ albums:
|
||||
subpages:
|
||||
- name: Japan
|
||||
title: Japan
|
||||
subtitle: "Reisen durch ein Land voller Kontraste."
|
||||
subtitle: 'Reisen durch ein Land voller Kontraste.'
|
||||
sections:
|
||||
- title: Tokyo
|
||||
description: "Megacity, Neonlichter, Stille im Lärm."
|
||||
description: 'Megacity, Neonlichter, Stille im Lärm.'
|
||||
albums:
|
||||
- "33333333-3333-3333-3333-333333333333"
|
||||
- "44444444-4444-4444-4444-444444444444": Shinjuku bei Nacht
|
||||
- '33333333-3333-3333-3333-333333333333'
|
||||
- '44444444-4444-4444-4444-444444444444': Shinjuku bei Nacht
|
||||
|
||||
- title: Kyoto
|
||||
description: "Tempel und Bambuswälder."
|
||||
description: 'Tempel und Bambuswälder.'
|
||||
albums:
|
||||
- "55555555-5555-5555-5555-555555555555": Fushimi Inari
|
||||
- "66666666-6666-6666-6666-666666666666"
|
||||
- '55555555-5555-5555-5555-555555555555': Fushimi Inari
|
||||
- '66666666-6666-6666-6666-666666666666'
|
||||
|
||||
- title: Osaka
|
||||
albums: # description is optional
|
||||
- "77777777-7777-7777-7777-777777777777"
|
||||
- '77777777-7777-7777-7777-777777777777'
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Each section title is automatically converted to a URL-safe anchor (`#tokyo`, `#kyoto`, …). The TOC appearance (separator character, numbering style, section rule) is fully controlled by the active theme.
|
||||
|
||||
|
||||
## Grid Layout
|
||||
|
||||
Configure the photo grid globally or per-subpage:
|
||||
@@ -132,7 +133,7 @@ Configure the photo grid globally or per-subpage:
|
||||
grid:
|
||||
columns: 3 # number of columns (default: 3)
|
||||
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"
|
||||
|
||||
subpages:
|
||||
@@ -140,7 +141,7 @@ subpages:
|
||||
grid: # per-subpage override
|
||||
columns: 4
|
||||
layout: uniform
|
||||
aspectRatio: "3/2"
|
||||
aspectRatio: '3/2'
|
||||
albums:
|
||||
- ...
|
||||
```
|
||||
|
||||
@@ -5,7 +5,7 @@ Eine konsolidierte Liste an Features und Ideen für zukünftige Versionen, berei
|
||||
## 🏆 Quick Wins (High Impact / Low Effort)
|
||||
|
||||
| Idea | Effort | Impact |
|
||||
|------|--------|--------|
|
||||
| ------------------------------ | ------ | -------------------------------------------------- |
|
||||
| **Slideshow mode** | Low | High — exhibition ready (Auto-advance 3s/5s/10s) |
|
||||
| **Fullscreen API in lightbox** | Low | Medium — immersive (Chrome-free) |
|
||||
| **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
|
||||
|
||||
*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
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ That's it. Your entire gallery switches to the minimal theme.
|
||||
## Built-in Presets
|
||||
|
||||
| Preset | Style | Accent | Fonts | Hero | Frame | Grain |
|
||||
| ------------- | ------------------------------ | ------------ | ---------------------------------- | ----------- | ------------ | ----- |
|
||||
| ------------- | ------------------------------ | ------------ | --------------------------------- | ----------- | ------------ | ----- |
|
||||
| **studio** | Leica-inspired, editorial | 🔴 `#e60012` | Playfair Display + DM Sans | Split | Passepartout | ✓ |
|
||||
| **minimal** | Swiss brutalist, high contrast | ⚫ `#000000` | Geist + IBM Plex Mono | Fullbleed | None | ✗ |
|
||||
| **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.
|
||||
|
||||
### Theme Gallery
|
||||
|
||||
_From top to bottom: Studio, Minimal, Editorial, Classic, Noir, Monograph_
|
||||
|
||||
<p align="center">
|
||||
@@ -38,6 +39,7 @@ _From top to bottom: Studio, Minimal, Editorial, Classic, Noir, Monograph_
|
||||
</p>
|
||||
|
||||
### Grid View Examples
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/theme-studio-grid.png" width="49%" alt="Studio grid" />
|
||||
<img src="screenshots/theme-minimal-grid.png" width="49%" alt="Minimal grid" />
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { env } from '../env';
|
||||
import { loadYaml, validateUuid } from './parser';
|
||||
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 './theme';
|
||||
@@ -16,7 +24,9 @@ export function getConfig(): AppConfig {
|
||||
const authSecret = env.AUTH_SECRET || apiKey;
|
||||
|
||||
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');
|
||||
@@ -32,7 +42,12 @@ export function getConfig(): AppConfig {
|
||||
subpages: [],
|
||||
siteTitle: env.SITE_TITLE || 'Immich Folio',
|
||||
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: [],
|
||||
exifOnHover: true,
|
||||
grid: { columns: 3, gap: 12, aspectRatio: '1', layout: 'masonry' },
|
||||
@@ -44,7 +59,7 @@ export function getConfig(): AppConfig {
|
||||
albumOverrides: {},
|
||||
cacheTtl: env.CACHE_TTL * 1000,
|
||||
rateLimitRpm: env.RATE_LIMIT_RPM,
|
||||
needsSetup: true
|
||||
needsSetup: true,
|
||||
};
|
||||
return _config;
|
||||
}
|
||||
@@ -63,8 +78,16 @@ export function getConfig(): AppConfig {
|
||||
return validatedUuid;
|
||||
}
|
||||
|
||||
const standaloneAlbumIds = (gallery.albums ?? []).map((entry) => 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))) {
|
||||
const standaloneAlbumIds = (gallery.albums ?? []).map((entry) =>
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -85,12 +108,16 @@ export function getConfig(): AppConfig {
|
||||
title: sec.title,
|
||||
slug: slugify(sec.title),
|
||||
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
|
||||
albumIds = sections.flatMap((s) => s.albumIds);
|
||||
// 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];
|
||||
} else {
|
||||
const albums = sp.albums ?? [];
|
||||
@@ -108,22 +135,40 @@ export function getConfig(): AppConfig {
|
||||
albumIds,
|
||||
sections,
|
||||
password: sp.password,
|
||||
...(sp.grid ? {
|
||||
...(sp.grid
|
||||
? {
|
||||
grid: {
|
||||
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
||||
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
||||
...(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) {
|
||||
subpages = Object.entries(gallery.subpages).map(([name, 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 albumEntries = sp.albums || [];
|
||||
return {
|
||||
@@ -133,14 +178,22 @@ export function getConfig(): AppConfig {
|
||||
subtitle: sp.subtitle,
|
||||
albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
|
||||
password: sp.password,
|
||||
...(sp.grid ? {
|
||||
...(sp.grid
|
||||
? {
|
||||
grid: {
|
||||
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
||||
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
||||
...(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,
|
||||
seo: {
|
||||
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,
|
||||
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,
|
||||
grid: {
|
||||
columns: settings.grid?.columns ?? 3,
|
||||
gap: settings.grid?.gap ?? 12,
|
||||
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,
|
||||
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: {
|
||||
enabled: settings.legal?.enabled === true,
|
||||
name: settings.legal?.name || '',
|
||||
@@ -189,7 +259,7 @@ export function getConfig(): AppConfig {
|
||||
albumOverrides,
|
||||
cacheTtl: env.CACHE_TTL * 1000,
|
||||
rateLimitRpm: env.RATE_LIMIT_RPM,
|
||||
needsSetup: false
|
||||
needsSetup: false,
|
||||
};
|
||||
return _config;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,14 @@ export const THEME_PRESETS: Record<string, ThemeConfig> = {
|
||||
};
|
||||
|
||||
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 function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
|
||||
@@ -83,7 +90,9 @@ export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
|
||||
if (typeof raw === 'string') {
|
||||
const preset = THEME_PRESETS[raw];
|
||||
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 };
|
||||
}
|
||||
@@ -91,7 +100,9 @@ export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
|
||||
const baseName = raw.preset ?? 'studio';
|
||||
const base = THEME_PRESETS[baseName];
|
||||
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 {
|
||||
|
||||
@@ -29,10 +29,12 @@ function parseEnv(): Env {
|
||||
const apiKey = process.env.IMMICH_API_KEY || '';
|
||||
|
||||
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 rateLimit = rateLimitStr && !isNaN(parseInt(rateLimitStr, 10)) ? parseInt(rateLimitStr, 10) : 600;
|
||||
const rateLimit =
|
||||
rateLimitStr && !isNaN(parseInt(rateLimitStr, 10)) ? parseInt(rateLimitStr, 10) : 600;
|
||||
|
||||
return {
|
||||
IMMICH_API_URL: apiUrl,
|
||||
|
||||
@@ -55,7 +55,6 @@ export interface ImmichExifInfo {
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
|
||||
export type ImageSize = 'thumbnail' | 'preview' | 'original';
|
||||
|
||||
/** Enriched subpage with album metadata (for rendering cards). */
|
||||
@@ -325,7 +324,6 @@ class ImmichClient {
|
||||
return asset;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the Immich server is reachable.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user