security: implement AUTH_SECRET and secure map/exif API routes

This commit is contained in:
ralksta
2026-03-07 08:18:23 +01:00
parent 2a16a9386f
commit e394fe84a0
14 changed files with 849 additions and 793 deletions

View File

@@ -22,6 +22,10 @@ CACHE_TTL=300
# Rate limit for image proxy (integer >= 1, requests per minute per IP, default: 120) # Rate limit for image proxy (integer >= 1, requests per minute per IP, default: 120)
# RATE_LIMIT_RPM=120 # RATE_LIMIT_RPM=120
# Secret for signing auth cookies and encrypting asset tokens (string, min 16 chars).
# If not provided, IMMICH_API_KEY will be used as a fallback (not recommended).
# AUTH_SECRET=your-super-long-random-secret-here
# ── Album configuration ────────────────────────────────────────── # ── Album configuration ──────────────────────────────────────────
# Albums, subpages, hero image, and passwords are defined in content/gallery.yaml # Albums, subpages, hero image, and passwords are defined in content/gallery.yaml
# See content/gallery.yaml.example for the full reference. # See content/gallery.yaml.example for the full reference.

View File

@@ -136,8 +136,9 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
{assets.map((asset, index) => ( {assets.map((asset, index) => (
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}> <FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}>
<div <div
className={`photo-grid__item${layout === 'showcase' && index === 0 ? ' photo-grid__featured' : '' className={`photo-grid__item${
}`} layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''
}`}
onClick={() => openLightbox(index)} onClick={() => openLightbox(index)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {

View File

@@ -24,15 +24,22 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
} }
const exif = asset.exifInfo; const exif = asset.exifInfo;
return NextResponse.json({ return NextResponse.json(
make: exif.make, {
model: exif.model, make: exif.make,
lensModel: exif.lensModel, model: exif.model,
focalLength: exif.focalLength, lensModel: exif.lensModel,
fNumber: exif.fNumber, focalLength: exif.focalLength,
exposureTime: exif.exposureTime, fNumber: exif.fNumber,
iso: exif.iso, exposureTime: exif.exposureTime,
city: exif.city, iso: exif.iso,
country: exif.country, city: exif.city,
}); country: exif.country,
},
{
headers: {
'Cache-Control': 'private, max-age=86400, stale-while-revalidate=3600',
},
},
);
} }

View File

@@ -4,39 +4,56 @@
* Cover asset IDs are encrypted for security. * Cover asset IDs are encrypted for security.
*/ */
import { NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { immich } from '@/lib/immich'; import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { imageUrl } from '@/lib/urls'; import { imageUrl } from '@/lib/urls';
import { isAuthenticated } from '@/lib/auth';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export async function GET() { export async function GET(_request: NextRequest) {
const config = getConfig(); const config = getConfig();
if (!config.map) { if (!config.map) {
return NextResponse.json({ error: 'Map is not enabled' }, { status: 404 }); return NextResponse.json({ error: 'Map is not enabled' }, { status: 404 });
} }
const locations = await immich.getMapData(); const cookieStore = await cookies();
const getCookie = (name: string) => cookieStore.get(name)?.value;
// Encrypt cover asset IDs and convert to public-safe image URLs const locations = await immich.getMapData();
const publicLocations = locations.map((loc) => ({
// Filter locations and albums based on auth
const publicLocations = locations
.map((loc) => {
// Only keep albums the user is allowed to see
const allowedAlbums = loc.albums.filter((a) => {
if (!a.subpageSlug) return true; // Standalone albums are public
return isAuthenticated(a.subpageSlug, getCookie);
});
if (allowedAlbums.length === 0) return null;
return {
city: loc.city, city: loc.city,
country: loc.country, country: loc.country,
lat: loc.lat, lat: loc.lat,
lng: loc.lng, lng: loc.lng,
photoCount: loc.photoCount, photoCount: loc.photoCount, // Note: This count might be slightly off if some photos are in hidden albums
coverUrl: imageUrl(loc.coverAssetId, 'thumbnail'), coverUrl: imageUrl(loc.coverAssetId, 'thumbnail'),
albums: loc.albums.map((a) => ({ albums: allowedAlbums.map((a) => ({
name: a.name, name: a.name,
url: a.subpageSlug ? `/${a.subpageSlug}/${a.slug}` : `/${a.slug}`, url: a.subpageSlug ? `/${a.subpageSlug}/${a.slug}` : `/${a.slug}`,
})), })),
})); };
})
.filter((loc) => loc !== null);
return NextResponse.json(publicLocations, { return NextResponse.json(publicLocations, {
headers: { headers: {
'Cache-Control': `public, max-age=${Math.floor(config.cacheTtl / 1000)}, stale-while-revalidate=60`, 'Cache-Control': `private, no-cache, no-store, must-revalidate`, // Don't cache personalized map data publicly
}, },
}); });
} }

View File

@@ -13,34 +13,34 @@ import './map.css';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Map', title: 'Map',
}; };
export default function MapPage() { export default function MapPage() {
const config = getConfig(); const config = getConfig();
if (!config.map) { if (!config.map) {
notFound(); notFound();
} }
return ( return (
<> <>
{/* Leaflet CSS from CDN */} {/* Leaflet CSS from CDN */}
{/* eslint-disable-next-line @next/next/no-page-custom-font */} {}
<link <link
rel="stylesheet" rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossOrigin="" crossOrigin=""
/> />
<div className="map-page"> <div className="map-page">
<div className="map-page__header"> <div className="map-page__header">
<BackLink href="/" label="Back to Gallery" /> <BackLink href="/" label="Back to Gallery" />
<h1 className="map-page__title">Map</h1> <h1 className="map-page__title">Map</h1>
<p className="map-page__subtitle">Where the photos were taken</p> <p className="map-page__subtitle">Where the photos were taken</p>
</div> </div>
<MapView /> <MapView />
</div> </div>
</> </>
); );
} }

View File

@@ -80,9 +80,7 @@ export default async function HomePage() {
<nav className="hero__nav hero__nav--indexed"> <nav className="hero__nav hero__nav--indexed">
{subpages.map((sp, i) => ( {subpages.map((sp, i) => (
<Link key={sp.slug} href={`/${sp.slug}`} className="hero__nav-link"> <Link key={sp.slug} href={`/${sp.slug}`} className="hero__nav-link">
<span className="hero__nav-index"> <span className="hero__nav-index">{String(i + 1).padStart(2, '0')}</span>
{String(i + 1).padStart(2, '0')}
</span>
{sp.name} {sp.name}
</Link> </Link>
))} ))}
@@ -127,11 +125,7 @@ export default async function HomePage() {
<FadeIn delay={200}> <FadeIn delay={200}>
<nav className="hero__thumbnail-strip"> <nav className="hero__thumbnail-strip">
{allEntries.map((entry) => ( {allEntries.map((entry) => (
<Link <Link key={entry.slug} href={`/${entry.slug}`} className="hero__thumbnail-item">
key={entry.slug}
href={`/${entry.slug}`}
className="hero__thumbnail-item"
>
<span className="hero__thumbnail-label">{entry.name}</span> <span className="hero__thumbnail-label">{entry.name}</span>
</Link> </Link>
))} ))}

View File

@@ -11,7 +11,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useLayoutEffect } from 'react';
const PRESETS = ['studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph', 'botanica']; const PRESETS = ['studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph', 'botanica'];
const FRAMES = ['none', 'passepartout', 'shadow']; const FRAMES = ['none', 'passepartout', 'shadow'];
@@ -20,426 +20,429 @@ const GRID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-
// Font stacks for each preset (must match THEME_PRESETS in config.ts) // Font stacks for each preset (must match THEME_PRESETS in config.ts)
const PRESET_FONTS: Record<string, { heading: string; body: string; caption: string }> = { const PRESET_FONTS: Record<string, { heading: string; body: string; caption: string }> = {
studio: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' }, studio: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
minimal: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' }, minimal: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' },
editorial: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'IBM Plex Mono' }, editorial: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'IBM Plex Mono' },
classic: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'EB Garamond' }, classic: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'EB Garamond' },
noir: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'IBM Plex Mono' }, noir: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'IBM Plex Mono' },
monograph: { heading: 'Instrument Serif', body: 'Inter', caption: 'JetBrains Mono' }, monograph: { heading: 'Instrument Serif', body: 'Inter', caption: 'JetBrains Mono' },
botanica: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Lora' }, botanica: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Lora' },
}; };
const PRESET_ACCENTS: Record<string, string> = { const PRESET_ACCENTS: Record<string, string> = {
studio: '#e60012', studio: '#e60012',
minimal: '#000000', minimal: '#000000',
editorial: '#8B2500', editorial: '#8B2500',
classic: '#c49a3c', classic: '#c49a3c',
noir: '#ff6b35', noir: '#ff6b35',
monograph: '#333333', monograph: '#333333',
botanica: '#4a7c59', botanica: '#4a7c59',
}; };
function getAttr(attr: string): string { function getAttr(attr: string): string {
return document.documentElement.getAttribute(attr) ?? ''; return document.documentElement.getAttribute(attr) ?? '';
} }
function setAttr(attr: string, value: string) { function setAttr(attr: string, value: string) {
document.documentElement.setAttribute(attr, value); document.documentElement.setAttribute(attr, value);
} }
function setVar(name: string, value: string) { function setVar(name: string, value: string) {
document.documentElement.style.setProperty(name, value); document.documentElement.style.setProperty(name, value);
} }
export function DevToolbar() { export function DevToolbar() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [preset, setPreset] = useState(''); const [preset, setPreset] = useState('');
const [frame, setFrame] = useState(''); const [frame, setFrame] = useState('');
const [theme, setTheme] = useState(''); const [theme, setTheme] = useState('');
const [grain, setGrain] = useState(''); const [grain, setGrain] = useState('');
const [copiedYaml, setCopiedYaml] = useState(''); const [copiedYaml, setCopiedYaml] = useState('');
const [gridCols, setGridCols] = useState(3); const [gridCols, setGridCols] = useState(3);
const [gridGap, setGridGap] = useState(12); const [gridGap, setGridGap] = useState(12);
// Read initial values from DOM // Read initial values from DOM
useEffect(() => { useLayoutEffect(() => {
setPreset(getAttr('data-preset')); setPreset(getAttr('data-preset')); // eslint-disable-line react-hooks/set-state-in-effect
setFrame(getAttr('data-photo-frame')); setFrame(getAttr('data-photo-frame'));
setTheme(getAttr('data-theme') || 'dark'); setTheme(getAttr('data-theme') || 'dark');
setGrain(getAttr('data-grain')); setGrain(getAttr('data-grain'));
// Read grid vars // Read grid vars
const style = getComputedStyle(document.documentElement); const style = getComputedStyle(document.documentElement);
const cols = parseInt(style.getPropertyValue('--grid-columns') || '3', 10); const cols = parseInt(style.getPropertyValue('--grid-columns') || '3', 10);
const gap = parseInt(style.getPropertyValue('--grid-gap') || '12', 10); const gap = parseInt(style.getPropertyValue('--grid-gap') || '12', 10);
if (!isNaN(cols)) setGridCols(cols); if (!isNaN(cols)) setGridCols(cols);
if (!isNaN(gap)) setGridGap(gap); if (!isNaN(gap)) setGridGap(gap);
}, []); }, []);
const switchPreset = useCallback((p: string) => { const switchPreset = useCallback((p: string) => {
setAttr('data-preset', p); setAttr('data-preset', p);
setPreset(p); setPreset(p);
// Update accent + fonts // Update accent + fonts
const accent = PRESET_ACCENTS[p] || '#e60012'; const accent = PRESET_ACCENTS[p] || '#e60012';
setVar('--accent', accent); setVar('--accent', accent);
setVar('--accent-dim', `${accent}1f`); setVar('--accent-dim', `${accent}1f`);
const fonts = PRESET_FONTS[p]; const fonts = PRESET_FONTS[p];
if (fonts) { if (fonts) {
setVar('--font-serif', `'${fonts.heading}', Georgia, 'Times New Roman', serif`); setVar('--font-serif', `'${fonts.heading}', Georgia, 'Times New Roman', serif`);
setVar('--font-sans', `'${fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`); setVar(
setVar('--font-caption', `'${fonts.caption}', Georgia, serif`); '--font-sans',
// Dynamically load Google Fonts if not already loaded `'${fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
const families = [fonts.heading, fonts.body, fonts.caption] );
.filter((f, i, a) => a.indexOf(f) === i) setVar('--font-caption', `'${fonts.caption}', Georgia, serif`);
.map((f) => `family=${f.replace(/\s+/g, '+')}:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400`) // Dynamically load Google Fonts if not already loaded
.join('&'); const families = [fonts.heading, fonts.body, fonts.caption]
const href = `https://fonts.googleapis.com/css2?${families}&display=swap`; .filter((f, i, a) => a.indexOf(f) === i)
// Check if already loaded .map(
const existing = document.querySelector(`link[href="${href}"]`); (f) => `family=${f.replace(/\s+/g, '+')}:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400`,
if (!existing) { )
const link = document.createElement('link'); .join('&');
link.rel = 'stylesheet'; const href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
link.href = href; // Check if already loaded
document.head.appendChild(link); const existing = document.querySelector(`link[href="${href}"]`);
} if (!existing) {
} const link = document.createElement('link');
}, []); link.rel = 'stylesheet';
link.href = href;
document.head.appendChild(link);
}
}
}, []);
const switchFrame = useCallback((f: string) => { const switchFrame = useCallback((f: string) => {
setAttr('data-photo-frame', f); setAttr('data-photo-frame', f);
setFrame(f); setFrame(f);
}, []); }, []);
const switchTheme = useCallback((t: string) => { const switchTheme = useCallback((t: string) => {
setAttr('data-theme', t); setAttr('data-theme', t);
setTheme(t); setTheme(t);
}, []); }, []);
const toggleGrain = useCallback(() => { const toggleGrain = useCallback(() => {
const next = grain === 'true' ? 'false' : 'true'; const next = grain === 'true' ? 'false' : 'true';
setAttr('data-grain', next); setAttr('data-grain', next);
setGrain(next); setGrain(next);
}, [grain]); }, [grain]);
const updateGridCols = useCallback((cols: number) => { const updateGridCols = useCallback((cols: number) => {
setGridCols(cols); setGridCols(cols);
// Update all photo-grid elements // Update all photo-grid elements
document.querySelectorAll<HTMLElement>('.photo-grid').forEach((el) => { document.querySelectorAll<HTMLElement>('.photo-grid').forEach((el) => {
el.style.setProperty('--grid-columns', String(cols)); el.style.setProperty('--grid-columns', String(cols));
}); });
}, []); }, []);
const updateGridGap = useCallback((gap: number) => { const updateGridGap = useCallback((gap: number) => {
setGridGap(gap); setGridGap(gap);
document.querySelectorAll<HTMLElement>('.photo-grid').forEach((el) => { document.querySelectorAll<HTMLElement>('.photo-grid').forEach((el) => {
el.style.setProperty('--grid-gap', `${gap}px`); el.style.setProperty('--grid-gap', `${gap}px`);
}); });
}, []); }, []);
const copyYaml = useCallback( const copyYaml = useCallback((section: string, yaml: string) => {
(section: string, yaml: string) => { navigator.clipboard.writeText(yaml);
navigator.clipboard.writeText(yaml); setCopiedYaml(section);
setCopiedYaml(section); setTimeout(() => setCopiedYaml(''), 1500);
setTimeout(() => setCopiedYaml(''), 1500); }, []);
},
[],
);
const currentYaml = `theme: const currentYaml = `theme:
preset: ${preset} preset: ${preset}
photoFrame: ${frame} photoFrame: ${frame}
grain: ${grain === 'true'}`; grain: ${grain === 'true'}`;
// Keyboard shortcut: Ctrl/Cmd + Shift + T // Keyboard shortcut: Ctrl/Cmd + Shift + T
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'T') { if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'T') {
e.preventDefault(); e.preventDefault();
setOpen((o) => !o); setOpen((o) => !o);
} }
}; };
document.addEventListener('keydown', handler); document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler);
}, []); }, []);
return ( return (
<> <>
{/* ── Floating Toggle Button ─────────────────────── */} {/* ── Floating Toggle Button ─────────────────────── */}
<button <button
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
aria-label="Dev Toolbar" aria-label="Dev Toolbar"
style={{ style={{
position: 'fixed', position: 'fixed',
bottom: 20, bottom: 20,
left: 20, left: 20,
zIndex: 99999, zIndex: 99999,
width: 44, width: 44,
height: 44, height: 44,
borderRadius: '50%', borderRadius: '50%',
border: '2px solid rgba(255,255,255,0.2)', border: '2px solid rgba(255,255,255,0.2)',
background: open background: open ? 'linear-gradient(135deg, #f43f5e, #a855f7)' : 'rgba(30,30,30,0.9)',
? 'linear-gradient(135deg, #f43f5e, #a855f7)' backdropFilter: 'blur(12px)',
: 'rgba(30,30,30,0.9)', color: '#fff',
backdropFilter: 'blur(12px)', fontSize: 18,
color: '#fff', cursor: 'pointer',
fontSize: 18, display: 'flex',
cursor: 'pointer', alignItems: 'center',
display: 'flex', justifyContent: 'center',
alignItems: 'center', transition: 'all 0.2s ease',
justifyContent: 'center', boxShadow: '0 4px 24px rgba(0,0,0,0.4)',
transition: 'all 0.2s ease', }}
boxShadow: '0 4px 24px rgba(0,0,0,0.4)', >
}} {open ? '✕' : '🎨'}
</button>
{/* ── Panel ──────────────────────────────────────── */}
{open && (
<div
style={{
position: 'fixed',
bottom: 76,
left: 20,
zIndex: 99998,
width: 300,
maxHeight: 'calc(100vh - 120px)',
overflowY: 'auto',
background: 'rgba(18,18,22,0.96)',
backdropFilter: 'blur(20px)',
borderRadius: 16,
border: '1px solid rgba(255,255,255,0.1)',
boxShadow: '0 8px 48px rgba(0,0,0,0.6)',
color: '#e4e4e7',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontSize: 12,
padding: 0,
}}
>
{/* ── Header ──────────────────────────── */}
<div
style={{
padding: '14px 16px 10px',
borderBottom: '1px solid rgba(255,255,255,0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span style={{ fontWeight: 700, fontSize: 13, letterSpacing: '0.02em' }}>
🎨 Theme Builder
</span>
<span
style={{
fontSize: 10,
color: '#71717a',
background: 'rgba(255,255,255,0.06)',
padding: '2px 8px',
borderRadius: 6,
}}
> >
{open ? '✕' : '🎨'} DEV ONLY
</span>
</div>
{/* ── Preset ──────────────────────────── */}
<Section title="Preset">
<PillGrid items={PRESETS} active={preset} onSelect={switchPreset} />
</Section>
{/* ── Dark / Light ────────────────────── */}
<Section title="Mode">
<PillGrid items={['dark', 'light']} active={theme} onSelect={switchTheme} />
</Section>
{/* ── Photo Frame ─────────────────────── */}
<Section title="Photo Frame">
<PillGrid items={FRAMES} active={frame} onSelect={switchFrame} />
</Section>
{/* ── Grain ───────────────────────────── */}
<Section title="Film Grain">
<button
onClick={toggleGrain}
style={{
...pillStyle,
background:
grain === 'true'
? 'linear-gradient(135deg, #f43f5e, #a855f7)'
: 'rgba(255,255,255,0.06)',
color: grain === 'true' ? '#fff' : '#a1a1aa',
border:
grain === 'true'
? '1px solid rgba(255,255,255,0.2)'
: '1px solid rgba(255,255,255,0.06)',
}}
>
{grain === 'true' ? '● On' : '○ Off'}
</button> </button>
</Section>
{/* ── Panel ──────────────────────────────────────── */} {/* ── Grid Controls ───────────────────── */}
{open && ( <Section title="Grid">
<div <div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
style={{ <label style={{ color: '#71717a', fontSize: 11, minWidth: 45 }}>
position: 'fixed', Cols: {gridCols}
bottom: 76, </label>
left: 20, <input
zIndex: 99998, type="range"
width: 300, min={1}
maxHeight: 'calc(100vh - 120px)', max={6}
overflowY: 'auto', value={gridCols}
background: 'rgba(18,18,22,0.96)', onChange={(e) => updateGridCols(Number(e.target.value))}
backdropFilter: 'blur(20px)', style={{ flex: 1, accentColor: '#a855f7' }}
borderRadius: 16, />
border: '1px solid rgba(255,255,255,0.1)', </div>
boxShadow: '0 8px 48px rgba(0,0,0,0.6)', <div style={{ display: 'flex', gap: 12, alignItems: 'center', marginTop: 8 }}>
color: '#e4e4e7', <label style={{ color: '#71717a', fontSize: 11, minWidth: 45 }}>
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', Gap: {gridGap}px
fontSize: 12, </label>
padding: 0, <input
}} type="range"
> min={0}
{/* ── Header ──────────────────────────── */} max={32}
<div value={gridGap}
style={{ onChange={(e) => updateGridGap(Number(e.target.value))}
padding: '14px 16px 10px', style={{ flex: 1, accentColor: '#a855f7' }}
borderBottom: '1px solid rgba(255,255,255,0.08)', />
display: 'flex', </div>
alignItems: 'center', </Section>
justifyContent: 'space-between',
}}
>
<span style={{ fontWeight: 700, fontSize: 13, letterSpacing: '0.02em' }}>
🎨 Theme Builder
</span>
<span
style={{
fontSize: 10,
color: '#71717a',
background: 'rgba(255,255,255,0.06)',
padding: '2px 8px',
borderRadius: 6,
}}
>
DEV ONLY
</span>
</div>
{/* ── Preset ──────────────────────────── */} {/* ── Hero Style (info only) ──────────── */}
<Section title="Preset"> <Section title="Hero Style (server)">
<PillGrid <PillGrid
items={PRESETS} items={HERO_STYLES}
active={preset} active=""
onSelect={switchPreset} onSelect={(h) => {
/> copyYaml('hero', `theme:\n heroStyle: ${h}`);
</Section> }}
/>
{copiedYaml === 'hero' && <CopyBadge />}
</Section>
{/* ── Dark / Light ────────────────────── */} {/* ── Grid Layout (info only) ─────────── */}
<Section title="Mode"> <Section title="Grid Layout (server)">
<PillGrid <PillGrid
items={['dark', 'light']} items={GRID_LAYOUTS}
active={theme} active=""
onSelect={switchTheme} onSelect={(l) => {
/> copyYaml('layout', `grid:\n layout: ${l}`);
</Section> }}
/>
{copiedYaml === 'layout' && <CopyBadge />}
</Section>
{/* ── Photo Frame ─────────────────────── */} {/* ── Export YAML ──────────────────────── */}
<Section title="Photo Frame"> <div
<PillGrid style={{
items={FRAMES} padding: '12px 16px 16px',
active={frame} borderTop: '1px solid rgba(255,255,255,0.08)',
onSelect={switchFrame} }}
/> >
</Section> <button
onClick={() => copyYaml('all', currentYaml)}
{/* ── Grain ───────────────────────────── */} style={{
<Section title="Film Grain"> width: '100%',
<button padding: '10px 0',
onClick={toggleGrain} borderRadius: 10,
style={{ border: 'none',
...pillStyle, background:
background: grain === 'true' copiedYaml === 'all' ? '#22c55e' : 'linear-gradient(135deg, #a855f7, #6366f1)',
? 'linear-gradient(135deg, #f43f5e, #a855f7)' color: '#fff',
: 'rgba(255,255,255,0.06)', fontWeight: 600,
color: grain === 'true' ? '#fff' : '#a1a1aa', fontSize: 12,
border: grain === 'true' ? '1px solid rgba(255,255,255,0.2)' : '1px solid rgba(255,255,255,0.06)', cursor: 'pointer',
}} transition: 'all 0.15s ease',
> letterSpacing: '0.02em',
{grain === 'true' ? '● On' : '○ Off'} }}
</button> >
</Section> {copiedYaml === 'all' ? '✓ Copied to clipboard' : 'Copy YAML to clipboard'}
</button>
{/* ── Grid Controls ───────────────────── */} </div>
<Section title="Grid"> </div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}> )}
<label style={{ color: '#71717a', fontSize: 11, minWidth: 45 }}>Cols: {gridCols}</label> </>
<input );
type="range"
min={1}
max={6}
value={gridCols}
onChange={(e) => updateGridCols(Number(e.target.value))}
style={{ flex: 1, accentColor: '#a855f7' }}
/>
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginTop: 8 }}>
<label style={{ color: '#71717a', fontSize: 11, minWidth: 45 }}>Gap: {gridGap}px</label>
<input
type="range"
min={0}
max={32}
value={gridGap}
onChange={(e) => updateGridGap(Number(e.target.value))}
style={{ flex: 1, accentColor: '#a855f7' }}
/>
</div>
</Section>
{/* ── Hero Style (info only) ──────────── */}
<Section title="Hero Style (server)">
<PillGrid items={HERO_STYLES} active="" onSelect={(h) => {
copyYaml('hero', `theme:\n heroStyle: ${h}`);
}} />
{copiedYaml === 'hero' && <CopyBadge />}
</Section>
{/* ── Grid Layout (info only) ─────────── */}
<Section title="Grid Layout (server)">
<PillGrid items={GRID_LAYOUTS} active="" onSelect={(l) => {
copyYaml('layout', `grid:\n layout: ${l}`);
}} />
{copiedYaml === 'layout' && <CopyBadge />}
</Section>
{/* ── Export YAML ──────────────────────── */}
<div
style={{
padding: '12px 16px 16px',
borderTop: '1px solid rgba(255,255,255,0.08)',
}}
>
<button
onClick={() => copyYaml('all', currentYaml)}
style={{
width: '100%',
padding: '10px 0',
borderRadius: 10,
border: 'none',
background: copiedYaml === 'all'
? '#22c55e'
: 'linear-gradient(135deg, #a855f7, #6366f1)',
color: '#fff',
fontWeight: 600,
fontSize: 12,
cursor: 'pointer',
transition: 'all 0.15s ease',
letterSpacing: '0.02em',
}}
>
{copiedYaml === 'all' ? '✓ Copied to clipboard' : 'Copy YAML to clipboard'}
</button>
</div>
</div>
)}
</>
);
} }
/* ── Sub-components ───────────────────────────────── */ /* ── Sub-components ───────────────────────────────── */
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<div style={{ padding: '12px 16px 8px' }}> <div style={{ padding: '12px 16px 8px' }}>
<div <div
style={{ style={{
fontSize: 10, fontSize: 10,
fontWeight: 600, fontWeight: 600,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: '0.1em', letterSpacing: '0.1em',
color: '#71717a', color: '#71717a',
marginBottom: 8, marginBottom: 8,
}} }}
> >
{title} {title}
</div> </div>
{children} {children}
</div> </div>
); );
} }
const pillStyle: React.CSSProperties = { const pillStyle: React.CSSProperties = {
padding: '6px 12px', padding: '6px 12px',
borderRadius: 8, borderRadius: 8,
fontSize: 11, fontSize: 11,
fontWeight: 500, fontWeight: 500,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.12s ease', transition: 'all 0.12s ease',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}; };
function PillGrid({ function PillGrid({
items, items,
active, active,
onSelect, onSelect,
}: { }: {
items: string[]; items: string[];
active: string; active: string;
onSelect: (item: string) => void; onSelect: (item: string) => void;
}) { }) {
return ( return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{items.map((item) => ( {items.map((item) => (
<button <button
key={item} key={item}
onClick={() => onSelect(item)} onClick={() => onSelect(item)}
style={{ style={{
...pillStyle, ...pillStyle,
background: background:
item === active item === active
? 'linear-gradient(135deg, #a855f7, #6366f1)' ? 'linear-gradient(135deg, #a855f7, #6366f1)'
: 'rgba(255,255,255,0.06)', : 'rgba(255,255,255,0.06)',
color: item === active ? '#fff' : '#a1a1aa', color: item === active ? '#fff' : '#a1a1aa',
border: border:
item === active item === active
? '1px solid rgba(255,255,255,0.2)' ? '1px solid rgba(255,255,255,0.2)'
: '1px solid rgba(255,255,255,0.06)', : '1px solid rgba(255,255,255,0.06)',
}} }}
> >
{item} {item}
</button> </button>
))} ))}
</div> </div>
); );
} }
function CopyBadge() { function CopyBadge() {
return ( return (
<span <span
style={{ style={{
display: 'inline-block', display: 'inline-block',
marginTop: 6, marginTop: 6,
fontSize: 10, fontSize: 10,
color: '#22c55e', color: '#22c55e',
fontWeight: 500, fontWeight: 500,
}} }}
> >
✓ YAML copied — paste into gallery.yaml ✓ YAML copied — paste into gallery.yaml
</span> </span>
); );
} }

View File

@@ -8,79 +8,80 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
interface MapLocationPublic { interface MapLocationPublic {
city: string; city: string;
country: string; country: string;
lat: number; lat: number;
lng: number; lng: number;
photoCount: number; photoCount: number;
coverUrl: string; coverUrl: string;
albums: { name: string; url: string }[]; albums: { name: string; url: string }[];
} }
export function MapView() { export function MapView() {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<unknown>(null); const mapRef = useRef<unknown>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
async function init() { async function init() {
// Dynamically import Leaflet (client-only) // Dynamically import Leaflet (client-only)
const L = (await import('leaflet')).default; const L = (await import('leaflet')).default;
if (cancelled || !containerRef.current || mapRef.current) return; if (cancelled || !containerRef.current || mapRef.current) return;
// Fetch map data // Fetch map data
const res = await fetch('/api/map'); const res = await fetch('/api/map');
if (!res.ok) { if (!res.ok) {
setError('Failed to load map data'); setError('Failed to load map data');
setLoading(false); setLoading(false);
return; return;
} }
const locations: MapLocationPublic[] = await res.json(); const locations: MapLocationPublic[] = await res.json();
if (cancelled) return; if (cancelled) return;
// Initialize map // Initialize map
const map = L.map(containerRef.current, { const map = L.map(containerRef.current, {
zoomControl: true, zoomControl: true,
attributionControl: true, attributionControl: true,
}); });
mapRef.current = map; mapRef.current = map;
// Dark-themed tiles (CartoDB Dark Matter) // Dark-themed tiles (CartoDB Dark Matter)
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>', attribution:
subdomains: 'abcd', '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19, subdomains: 'abcd',
}).addTo(map); maxZoom: 19,
}).addTo(map);
if (locations.length === 0) { if (locations.length === 0) {
// No geotagged photos — show world view // No geotagged photos — show world view
map.setView([20, 0], 2); map.setView([20, 0], 2);
setLoading(false); setLoading(false);
return; return;
} }
// Create markers with custom HTML icons // Create markers with custom HTML icons
const markers: L.Marker[] = []; const markers: L.Marker[] = [];
for (const loc of locations) { for (const loc of locations) {
const icon = L.divIcon({ const icon = L.divIcon({
className: '', className: '',
html: `<div class="map-marker">${loc.photoCount}</div>`, html: `<div class="map-marker">${loc.photoCount}</div>`,
iconSize: [32, 32], iconSize: [32, 32],
iconAnchor: [16, 16], iconAnchor: [16, 16],
popupAnchor: [0, -20], popupAnchor: [0, -20],
}); });
const albumLinks = loc.albums const albumLinks = loc.albums
.map((a) => `<a href="${a.url}" class="map-popup__album-link">${a.name} →</a>`) .map((a) => `<a href="${a.url}" class="map-popup__album-link">${a.name} →</a>`)
.join(''); .join('');
const popupHtml = ` const popupHtml = `
<div class="map-popup"> <div class="map-popup">
<img src="${loc.coverUrl}" alt="${loc.city}" class="map-popup__cover" loading="lazy" /> <img src="${loc.coverUrl}" alt="${loc.city}" class="map-popup__cover" loading="lazy" />
<div class="map-popup__body"> <div class="map-popup__body">
@@ -92,61 +93,61 @@ export function MapView() {
</div> </div>
`; `;
const marker = L.marker([loc.lat, loc.lng], { icon }) const marker = L.marker([loc.lat, loc.lng], { icon })
.addTo(map) .addTo(map)
.bindPopup(popupHtml, { maxWidth: 260, minWidth: 200 }); .bindPopup(popupHtml, { maxWidth: 260, minWidth: 200 });
markers.push(marker); markers.push(marker);
} }
// Fit bounds to show all markers // Fit bounds to show all markers
if (markers.length > 0) { if (markers.length > 0) {
const group = L.featureGroup(markers); const group = L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.15)); map.fitBounds(group.getBounds().pad(0.15));
} }
// Force Leaflet to recalculate tile grid // Force Leaflet to recalculate tile grid
setTimeout(() => map.invalidateSize(), 100); setTimeout(() => map.invalidateSize(), 100);
setLoading(false); setLoading(false);
}
init().catch((err) => {
console.error('[Map] Init error:', err);
if (!cancelled) {
setError('Failed to initialize map');
setLoading(false);
}
});
return () => {
cancelled = true;
if (mapRef.current) {
(mapRef.current as L.Map).remove();
mapRef.current = null;
}
};
}, []);
if (error) {
return (
<div className="map-container__loading">
<p>{error}</p>
</div>
);
} }
init().catch((err) => {
console.error('[Map] Init error:', err);
if (!cancelled) {
setError('Failed to initialize map');
setLoading(false);
}
});
return () => {
cancelled = true;
if (mapRef.current) {
(mapRef.current as L.Map).remove();
mapRef.current = null;
}
};
}, []);
if (error) {
return ( return (
<div style={{ position: 'relative' }}> <div className="map-container__loading">
{loading && ( <p>{error}</p>
<div className="map-container__loading" style={{ position: 'absolute', inset: 0, zIndex: 1000 }}> </div>
<p>Loading map…</p>
</div>
)}
<div
ref={containerRef}
className="map-container"
/>
</div>
); );
}
return (
<div style={{ position: 'relative' }}>
{loading && (
<div
className="map-container__loading"
style={{ position: 'absolute', inset: 0, zIndex: 1000 }}
>
<p>Loading map…</p>
</div>
)}
<div ref={containerRef} className="map-container" />
</div>
);
} }

View File

@@ -1,75 +1,75 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test.describe('Photo Grid', () => { test.describe('Photo Grid', () => {
// Use a known public subpage that has photos // Use a known public subpage that has photos
const GRID_URL = '/deutschland/kloster-chorin-2024'; const GRID_URL = '/deutschland/kloster-chorin-2024';
test('renders grid items with visible images', async ({ page }) => { test('renders grid items with visible images', async ({ page }) => {
await page.goto(GRID_URL); await page.goto(GRID_URL);
// Grid container should exist // Grid container should exist
const grid = page.locator('.photo-grid'); const grid = page.locator('.photo-grid');
await expect(grid).toBeVisible(); await expect(grid).toBeVisible();
// Should have grid items // Should have grid items
const items = grid.locator('.photo-grid__item'); const items = grid.locator('.photo-grid__item');
const count = await items.count(); const count = await items.count();
expect(count).toBeGreaterThan(0); expect(count).toBeGreaterThan(0);
// Scroll to trigger IntersectionObserver (FadeIn) // Scroll to trigger IntersectionObserver (FadeIn)
await items.first().scrollIntoViewIfNeeded(); await items.first().scrollIntoViewIfNeeded();
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
// First grid item should have real dimensions (not collapsed) // First grid item should have real dimensions (not collapsed)
const firstItem = items.first(); const firstItem = items.first();
const box = await firstItem.boundingBox(); const box = await firstItem.boundingBox();
expect(box).not.toBeNull(); expect(box).not.toBeNull();
expect(box!.width).toBeGreaterThan(100); expect(box!.width).toBeGreaterThan(100);
expect(box!.height).toBeGreaterThan(50); expect(box!.height).toBeGreaterThan(50);
// Image inside should exist and have the fill layout // Image inside should exist and have the fill layout
const img = firstItem.locator('img'); const img = firstItem.locator('img');
await expect(img).toBeAttached(); await expect(img).toBeAttached();
});
test('grid items have overflow hidden (CSS regression)', async ({ page }) => {
// This test guards against the CSS cascade bug where a later rule
// strips `overflow: hidden` from .photo-grid__item, which breaks
// next/image fill rendering.
await page.goto(GRID_URL);
const grid = page.locator('.photo-grid');
await expect(grid).toBeVisible();
const item = grid.locator('.photo-grid__item').first();
await item.scrollIntoViewIfNeeded();
const overflow = await item.evaluate((el) => getComputedStyle(el).overflow);
expect(overflow).toBe('hidden');
});
test('images load with real content (not just blurhash)', async ({ page }) => {
await page.goto(GRID_URL);
// Scroll to trigger lazy loading
const grid = page.locator('.photo-grid');
await grid.scrollIntoViewIfNeeded();
await page.waitForTimeout(3000);
// Check that at least one image has loaded with real pixel data
const imgLoaded = await page.evaluate(() => {
const img = document.querySelector('.photo-grid__item img') as HTMLImageElement;
if (!img) return { found: false };
return {
found: true,
complete: img.complete,
naturalWidth: img.naturalWidth,
src: img.src.substring(0, 60),
};
}); });
test('grid items have overflow hidden (CSS regression)', async ({ page }) => { expect(imgLoaded.found).toBe(true);
// This test guards against the CSS cascade bug where a later rule expect(imgLoaded.complete).toBe(true);
// strips `overflow: hidden` from .photo-grid__item, which breaks expect(imgLoaded.naturalWidth).toBeGreaterThan(0);
// next/image fill rendering. });
await page.goto(GRID_URL);
const grid = page.locator('.photo-grid');
await expect(grid).toBeVisible();
const item = grid.locator('.photo-grid__item').first();
await item.scrollIntoViewIfNeeded();
const overflow = await item.evaluate((el) => getComputedStyle(el).overflow);
expect(overflow).toBe('hidden');
});
test('images load with real content (not just blurhash)', async ({ page }) => {
await page.goto(GRID_URL);
// Scroll to trigger lazy loading
const grid = page.locator('.photo-grid');
await grid.scrollIntoViewIfNeeded();
await page.waitForTimeout(3000);
// Check that at least one image has loaded with real pixel data
const imgLoaded = await page.evaluate(() => {
const img = document.querySelector('.photo-grid__item img') as HTMLImageElement;
if (!img) return { found: false };
return {
found: true,
complete: img.complete,
naturalWidth: img.naturalWidth,
src: img.src.substring(0, 60),
};
});
expect(imgLoaded.found).toBe(true);
expect(imgLoaded.complete).toBe(true);
expect(imgLoaded.naturalWidth).toBeGreaterThan(0);
});
}); });

View File

@@ -12,7 +12,7 @@ import { getConfig, SubpageConfig } from './config';
const TOKEN_EXPIRY_HOURS = 24; const TOKEN_EXPIRY_HOURS = 24;
function hmac(data: string): string { function hmac(data: string): string {
return crypto.createHmac('sha256', getConfig().immich.apiKey).update(data).digest('hex'); return crypto.createHmac('sha256', getConfig().authSecret).update(data).digest('hex');
} }
function authToken(slug: string, password: string): string { function authToken(slug: string, password: string): string {

View File

@@ -125,17 +125,17 @@ interface GalleryYaml {
exifOnHover?: boolean; exifOnHover?: boolean;
map?: boolean; map?: boolean;
theme?: theme?:
| string | string
| { | {
preset?: string; preset?: string;
accent?: string; accent?: string;
fonts?: { heading?: string; body?: string; caption?: string }; fonts?: { heading?: string; body?: string; caption?: string };
radius?: number; radius?: number;
photoFrame?: string; photoFrame?: string;
grain?: boolean; grain?: boolean;
headerDot?: boolean; headerDot?: boolean;
heroStyle?: string; heroStyle?: string;
}; };
grid?: { grid?: {
columns?: number; columns?: number;
gap?: number; gap?: number;
@@ -149,18 +149,18 @@ interface GalleryYaml {
website?: string; website?: string;
}; };
subpages?: subpages?:
| Record<string, string[] | Array<string | Record<string, string>>> | Record<string, string[] | Array<string | Record<string, string>>>
| Array<{ | Array<{
name: string; name: string;
albums: Array<string | Record<string, string>>; albums: Array<string | Record<string, string>>;
password?: string; password?: string;
grid?: { grid?: {
columns?: number; columns?: number;
gap?: number; gap?: number;
aspectRatio?: string; aspectRatio?: string;
layout?: string; layout?: string;
}; };
}>; }>;
} }
// ── YAML loading ─────────────────────────────────────────────── // ── YAML loading ───────────────────────────────────────────────
@@ -171,7 +171,7 @@ function loadGalleryYaml(): GalleryYaml {
if (!fs.existsSync(yamlPath)) { if (!fs.existsSync(yamlPath)) {
throw new Error( throw new Error(
`Gallery config not found: ${yamlPath}\n` + `Gallery config not found: ${yamlPath}\n` +
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`, `Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
); );
} }
@@ -224,6 +224,8 @@ export interface GridConfig {
export interface AppConfig { export interface AppConfig {
immich: { apiUrl: string; apiKey: string }; immich: { apiUrl: string; apiKey: string };
/** Secret used for signing auth cookies and encrypting asset tokens. */
authSecret: string;
albums: string[]; albums: string[];
standaloneAlbums: string[]; standaloneAlbums: string[];
subpages: SubpageConfig[]; subpages: SubpageConfig[];
@@ -317,6 +319,15 @@ export function getConfig(): AppConfig {
const apiUrl = env.IMMICH_API_URL; const apiUrl = env.IMMICH_API_URL;
const apiKey = env.IMMICH_API_KEY; const apiKey = env.IMMICH_API_KEY;
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',
);
}
const gallery = loadGalleryYaml(); const gallery = loadGalleryYaml();
// Resolve theme: string shorthand → preset, object → merged with preset base // Resolve theme: string shorthand → preset, object → merged with preset base
@@ -340,7 +351,13 @@ export function getConfig(): AppConfig {
processAlbumEntry(entry, 'gallery.yaml albums'), 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))) { 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');
} }
@@ -362,19 +379,19 @@ export function getConfig(): AppConfig {
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 ...(sp.grid.layout != null
? { ? {
layout: (VALID_LAYOUTS.includes(sp.grid.layout) layout: (VALID_LAYOUTS.includes(sp.grid.layout)
? sp.grid.layout ? sp.grid.layout
: 'masonry') as GridConfig['layout'], : 'masonry') as GridConfig['layout'],
} }
: {}), : {}),
}, },
} }
: {}), : {}),
}; };
}); });
@@ -400,19 +417,19 @@ export function getConfig(): AppConfig {
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 ...(sp.grid.layout != null
? { ? {
layout: (VALID_LAYOUTS.includes(sp.grid.layout) layout: (VALID_LAYOUTS.includes(sp.grid.layout)
? sp.grid.layout ? sp.grid.layout
: 'masonry') as GridConfig['layout'], : 'masonry') as GridConfig['layout'],
} }
: {}), : {}),
}, },
} }
: {}), : {}),
}; };
}); });
@@ -427,6 +444,7 @@ export function getConfig(): AppConfig {
apiUrl: `${apiUrl}/api`, apiUrl: `${apiUrl}/api`,
apiKey, apiKey,
}, },
authSecret,
albums: allAlbumIds, albums: allAlbumIds,
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)), standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
subpages, subpages,
@@ -434,8 +452,8 @@ export function getConfig(): AppConfig {
siteSubtitle: env.SITE_SUBTITLE, siteSubtitle: env.SITE_SUBTITLE,
heroImages: gallery.hero heroImages: gallery.hero
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) => ? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
validateUuid(id, 'gallery.yaml hero'), validateUuid(id, 'gallery.yaml hero'),
) )
: [], : [],
exifOnHover: gallery.exifOnHover !== false, exifOnHover: gallery.exifOnHover !== false,
grid: { grid: {
@@ -449,11 +467,11 @@ export function getConfig(): AppConfig {
theme, theme,
footer: gallery.footer footer: gallery.footer
? { ? {
name: gallery.footer.name, name: gallery.footer.name,
instagram: gallery.footer.instagram, instagram: gallery.footer.instagram,
email: gallery.footer.email, email: gallery.footer.email,
website: gallery.footer.website, website: gallery.footer.website,
} }
: null, : null,
map: gallery.map === true, map: gallery.map === true,
albumOverrides, albumOverrides,

View File

@@ -26,6 +26,12 @@ const envSchema = z.object({
/** Rate limit: max requests per minute per IP */ /** Rate limit: max requests per minute per IP */
RATE_LIMIT_RPM: z.coerce.number().int().min(1).default(120), RATE_LIMIT_RPM: z.coerce.number().int().min(1).default(120),
/**
* Secret used for signing auth cookies and encrypting asset tokens.
* If not provided, IMMICH_API_KEY will be used as a fallback (not recommended for production).
*/
AUTH_SECRET: z.string().min(16, 'AUTH_SECRET should be at least 16 characters').optional(),
}); });
export type Env = z.infer<typeof envSchema>; export type Env = z.infer<typeof envSchema>;

View File

@@ -13,8 +13,8 @@ let _key: Buffer | null = null;
function getKey(): Buffer { function getKey(): Buffer {
if (!_key) { if (!_key) {
const apiKey = getConfig().immich.apiKey; const authSecret = getConfig().authSecret;
_key = crypto.createHash('sha256').update(apiKey).digest(); _key = crypto.createHash('sha256').update(authSecret).digest();
} }
return _key; return _key;
} }

View File

@@ -15,7 +15,7 @@
* docs/screenshots/theme-{preset}-{page}.png * docs/screenshots/theme-{preset}-{page}.png
*/ */
import { execSync, spawn, type ChildProcess } from 'child_process'; import { spawn, type ChildProcess } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@@ -28,122 +28,127 @@ const VIEWPORT = { width: 1440, height: 900 };
const IMAGE_LOAD_TIMEOUT = 8000; // ms to wait for images after networkidle const IMAGE_LOAD_TIMEOUT = 8000; // ms to wait for images after networkidle
async function main() { async function main() {
// Ensure output directory exists // Ensure output directory exists
fs.mkdirSync(OUTPUT_DIR, { recursive: true }); fs.mkdirSync(OUTPUT_DIR, { recursive: true });
// Read original gallery.yaml to restore later // Read original gallery.yaml to restore later
const originalYaml = fs.readFileSync(GALLERY_YAML, 'utf8'); const originalYaml = fs.readFileSync(GALLERY_YAML, 'utf8');
// Dynamic import for Playwright (may not be installed globally) // Dynamic import for Playwright (may not be installed globally)
const { chromium } = await import('playwright'); const { chromium } = await import('playwright');
const browser = await chromium.launch(); const browser = await chromium.launch();
try { try {
for (const theme of THEMES) { for (const theme of THEMES) {
console.log(`\n🎨 Theme: ${theme}`); console.log(`\n🎨 Theme: ${theme}`);
console.log('─'.repeat(50)); console.log('─'.repeat(50));
// Update gallery.yaml with the theme // Update gallery.yaml with the theme
const yamlWithTheme = setThemeInYaml(originalYaml, theme); const yamlWithTheme = setThemeInYaml(originalYaml, theme);
fs.writeFileSync(GALLERY_YAML, yamlWithTheme); fs.writeFileSync(GALLERY_YAML, yamlWithTheme);
// Start dev server // Start dev server
console.log(' ⏳ Starting dev server...'); console.log(' ⏳ Starting dev server...');
const server = startDevServer(); const server = startDevServer();
await waitForServer(BASE_URL, 15000); await waitForServer(BASE_URL, 15000);
console.log(' ✅ Server ready'); console.log(' ✅ Server ready');
const context = await browser.newContext({ viewport: VIEWPORT }); const context = await browser.newContext({ viewport: VIEWPORT });
const page = await context.newPage(); const page = await context.newPage();
// ── Homepage screenshot ── // ── Homepage screenshot ──
console.log(' 📸 Capturing homepage...'); console.log(' 📸 Capturing homepage...');
await page.goto(BASE_URL, { waitUntil: 'networkidle' }); await page.goto(BASE_URL, { waitUntil: 'networkidle' });
await waitForImages(page); await waitForImages(page);
await page.screenshot({ await page.screenshot({
path: path.join(OUTPUT_DIR, `theme-${theme}-home.png`), path: path.join(OUTPUT_DIR, `theme-${theme}-home.png`),
fullPage: false, fullPage: false,
}); });
console.log(` ✅ theme-${theme}-home.png`); console.log(` ✅ theme-${theme}-home.png`);
// ── Grid view screenshot ── // ── Grid view screenshot ──
console.log(' 📸 Capturing grid view...'); console.log(' 📸 Capturing grid view...');
await page.goto(`${BASE_URL}${GRID_PATH}`, { waitUntil: 'networkidle' }); await page.goto(`${BASE_URL}${GRID_PATH}`, { waitUntil: 'networkidle' });
// Force all fade-in elements visible (don't scroll — keep album title in view) // Force all fade-in elements visible (don't scroll — keep album title in view)
await page.evaluate(() => { await page.evaluate(() => {
document.querySelectorAll('.fade-in').forEach(el => el.classList.add('fade-in--visible')); document.querySelectorAll('.fade-in').forEach((el) => el.classList.add('fade-in--visible'));
// Trigger lazy images by switching to eager loading // Trigger lazy images by switching to eager loading
document.querySelectorAll('img[loading="lazy"]').forEach(img => { document.querySelectorAll('img[loading="lazy"]').forEach((img) => {
(img as HTMLImageElement).loading = 'eager'; (img as HTMLImageElement).loading = 'eager';
}); });
}); });
await waitForImages(page); await waitForImages(page);
// Extra settle time for late-loading images in lower columns // Extra settle time for late-loading images in lower columns
await page.waitForTimeout(3000); await page.waitForTimeout(3000);
await page.screenshot({ await page.screenshot({
path: path.join(OUTPUT_DIR, `theme-${theme}-grid.png`), path: path.join(OUTPUT_DIR, `theme-${theme}-grid.png`),
fullPage: false, fullPage: false,
}); });
console.log(` ✅ theme-${theme}-grid.png`); console.log(` ✅ theme-${theme}-grid.png`);
await context.close(); await context.close();
// Stop dev server // Stop dev server
server.kill('SIGTERM'); server.kill('SIGTERM');
await new Promise((resolve) => server.on('close', resolve)); await new Promise((resolve) => server.on('close', resolve));
console.log(' 🛑 Server stopped'); console.log(' 🛑 Server stopped');
}
} finally {
// Always restore original gallery.yaml
fs.writeFileSync(GALLERY_YAML, originalYaml);
console.log('\n✅ Restored original gallery.yaml');
await browser.close();
} }
} finally {
// Always restore original gallery.yaml
fs.writeFileSync(GALLERY_YAML, originalYaml);
console.log('\n✅ Restored original gallery.yaml');
await browser.close();
}
console.log(`\n🎉 Done! Screenshots saved to ${OUTPUT_DIR}/`); console.log(`\n🎉 Done! Screenshots saved to ${OUTPUT_DIR}/`);
console.log(` ${fs.readdirSync(OUTPUT_DIR).filter(f => f.endsWith('.png')).join(', ')}`); console.log(
` ${fs
.readdirSync(OUTPUT_DIR)
.filter((f) => f.endsWith('.png'))
.join(', ')}`,
);
} }
// ── Helpers ────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────
function setThemeInYaml(yaml: string, theme: string): string { function setThemeInYaml(yaml: string, theme: string): string {
// Replace existing theme line or insert after the header comment // Replace existing theme line or insert after the header comment
if (/^theme:\s*.+$/m.test(yaml)) { if (/^theme:\s*.+$/m.test(yaml)) {
return yaml.replace(/^theme:\s*.+$/m, `theme: ${theme}`); return yaml.replace(/^theme:\s*.+$/m, `theme: ${theme}`);
} }
// Insert theme after the header comments // Insert theme after the header comments
const marker = '# Secrets (API key, server URL) stay in .env.local.'; const marker = '# Secrets (API key, server URL) stay in .env.local.';
return yaml.replace(marker, `${marker}\n\ntheme: ${theme}`); return yaml.replace(marker, `${marker}\n\ntheme: ${theme}`);
} }
function startDevServer(): ChildProcess { function startDevServer(): ChildProcess {
const server = spawn('npm', ['run', 'dev'], { const server = spawn('npm', ['run', 'dev'], {
cwd: process.cwd(), cwd: process.cwd(),
stdio: 'pipe', stdio: 'pipe',
env: { ...process.env }, env: { ...process.env },
}); });
// Suppress server output unless DEBUG is set // Suppress server output unless DEBUG is set
if (process.env.DEBUG) { if (process.env.DEBUG) {
server.stdout?.pipe(process.stdout); server.stdout?.pipe(process.stdout);
server.stderr?.pipe(process.stderr); server.stderr?.pipe(process.stderr);
} }
return server; return server;
} }
async function waitForServer(url: string, timeoutMs: number): Promise<void> { async function waitForServer(url: string, timeoutMs: number): Promise<void> {
const start = Date.now(); const start = Date.now();
while (Date.now() - start < timeoutMs) { while (Date.now() - start < timeoutMs) {
try { try {
const res = await fetch(url); const res = await fetch(url);
if (res.ok) return; if (res.ok) return;
} catch { } catch {
// Server not ready yet // Server not ready yet
}
await sleep(500);
} }
throw new Error(`Server did not start within ${timeoutMs}ms`); await sleep(500);
}
throw new Error(`Server did not start within ${timeoutMs}ms`);
} }
/** /**
@@ -151,50 +156,50 @@ async function waitForServer(url: string, timeoutMs: number): Promise<void> {
* This prevents capturing blurhash placeholders instead of real photos. * This prevents capturing blurhash placeholders instead of real photos.
*/ */
async function waitForImages(page: import('playwright').Page): Promise<void> { async function waitForImages(page: import('playwright').Page): Promise<void> {
await page.waitForTimeout(1000); // Initial settle await page.waitForTimeout(1000); // Initial settle
await page.evaluate((timeout) => { await page.evaluate((timeout) => {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
const images = Array.from(document.querySelectorAll('img')); const images = Array.from(document.querySelectorAll('img'));
let pending = images.filter((img) => !img.complete).length; let pending = images.filter((img) => !img.complete).length;
if (pending === 0) { if (pending === 0) {
// All images already loaded, wait a bit for any CSS transitions // All images already loaded, wait a bit for any CSS transitions
setTimeout(resolve, 500); setTimeout(resolve, 500);
return; return;
} }
const done = () => { const done = () => {
pending--; pending--;
if (pending <= 0) setTimeout(resolve, 500); if (pending <= 0) setTimeout(resolve, 500);
}; };
images.forEach((img) => { images.forEach((img) => {
if (!img.complete) { if (!img.complete) {
img.addEventListener('load', done); img.addEventListener('load', done);
img.addEventListener('error', done); img.addEventListener('error', done);
} }
}); });
// Safety timeout // Safety timeout
setTimeout(resolve, timeout); setTimeout(resolve, timeout);
}); });
}, IMAGE_LOAD_TIMEOUT); }, IMAGE_LOAD_TIMEOUT);
} }
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
// ── Run ────────────────────────────────────────────────────────── // ── Run ──────────────────────────────────────────────────────────
main().catch((err) => { main().catch((err) => {
console.error('❌ Screenshot generation failed:', err); console.error('❌ Screenshot generation failed:', err);
// Try to restore gallery.yaml on error // Try to restore gallery.yaml on error
try { try {
const original = fs.readFileSync(GALLERY_YAML + '.bak', 'utf8'); const original = fs.readFileSync(GALLERY_YAML + '.bak', 'utf8');
fs.writeFileSync(GALLERY_YAML, original); fs.writeFileSync(GALLERY_YAML, original);
} catch { } catch {
// Backup may not exist // Backup may not exist
} }
process.exit(1); process.exit(1);
}); });