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,7 +136,8 @@ 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) => {

View File

@@ -24,7 +24,8 @@ 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, make: exif.make,
model: exif.model, model: exif.model,
lensModel: exif.lensModel, lensModel: exif.lensModel,
@@ -34,5 +35,11 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
iso: exif.iso, iso: exif.iso,
city: exif.city, city: exif.city,
country: exif.country, 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 cookieStore = await cookies();
const getCookie = (name: string) => cookieStore.get(name)?.value;
const locations = await immich.getMapData(); const locations = await immich.getMapData();
// Encrypt cover asset IDs and convert to public-safe image URLs // Filter locations and albums based on auth
const publicLocations = locations.map((loc) => ({ 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

@@ -26,7 +26,7 @@ export default function MapPage() {
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"

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'];
@@ -62,8 +62,8 @@ export function DevToolbar() {
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'));
@@ -85,12 +85,17 @@ export function DevToolbar() {
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(
'--font-sans',
`'${fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
);
setVar('--font-caption', `'${fonts.caption}', Georgia, serif`); setVar('--font-caption', `'${fonts.caption}', Georgia, serif`);
// Dynamically load Google Fonts if not already loaded // Dynamically load Google Fonts if not already loaded
const families = [fonts.heading, fonts.body, fonts.caption] const families = [fonts.heading, fonts.body, fonts.caption]
.filter((f, i, a) => a.indexOf(f) === i) .filter((f, i, a) => a.indexOf(f) === i)
.map((f) => `family=${f.replace(/\s+/g, '+')}:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400`) .map(
(f) => `family=${f.replace(/\s+/g, '+')}:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400`,
)
.join('&'); .join('&');
const href = `https://fonts.googleapis.com/css2?${families}&display=swap`; const href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
// Check if already loaded // Check if already loaded
@@ -135,14 +140,11 @@ export function DevToolbar() {
}); });
}, []); }, []);
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}
@@ -176,9 +178,7 @@ export function DevToolbar() {
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)'
: 'rgba(30,30,30,0.9)',
backdropFilter: 'blur(12px)', backdropFilter: 'blur(12px)',
color: '#fff', color: '#fff',
fontSize: 18, fontSize: 18,
@@ -243,29 +243,17 @@ export function DevToolbar() {
{/* ── Preset ──────────────────────────── */} {/* ── Preset ──────────────────────────── */}
<Section title="Preset"> <Section title="Preset">
<PillGrid <PillGrid items={PRESETS} active={preset} onSelect={switchPreset} />
items={PRESETS}
active={preset}
onSelect={switchPreset}
/>
</Section> </Section>
{/* ── Dark / Light ────────────────────── */} {/* ── Dark / Light ────────────────────── */}
<Section title="Mode"> <Section title="Mode">
<PillGrid <PillGrid items={['dark', 'light']} active={theme} onSelect={switchTheme} />
items={['dark', 'light']}
active={theme}
onSelect={switchTheme}
/>
</Section> </Section>
{/* ── Photo Frame ─────────────────────── */} {/* ── Photo Frame ─────────────────────── */}
<Section title="Photo Frame"> <Section title="Photo Frame">
<PillGrid <PillGrid items={FRAMES} active={frame} onSelect={switchFrame} />
items={FRAMES}
active={frame}
onSelect={switchFrame}
/>
</Section> </Section>
{/* ── Grain ───────────────────────────── */} {/* ── Grain ───────────────────────────── */}
@@ -274,11 +262,15 @@ export function DevToolbar() {
onClick={toggleGrain} onClick={toggleGrain}
style={{ style={{
...pillStyle, ...pillStyle,
background: grain === 'true' background:
grain === 'true'
? 'linear-gradient(135deg, #f43f5e, #a855f7)' ? 'linear-gradient(135deg, #f43f5e, #a855f7)'
: 'rgba(255,255,255,0.06)', : 'rgba(255,255,255,0.06)',
color: grain === 'true' ? '#fff' : '#a1a1aa', color: grain === 'true' ? '#fff' : '#a1a1aa',
border: grain === 'true' ? '1px solid rgba(255,255,255,0.2)' : '1px solid rgba(255,255,255,0.06)', border:
grain === 'true'
? '1px solid rgba(255,255,255,0.2)'
: '1px solid rgba(255,255,255,0.06)',
}} }}
> >
{grain === 'true' ? '● On' : '○ Off'} {grain === 'true' ? '● On' : '○ Off'}
@@ -288,7 +280,9 @@ export function DevToolbar() {
{/* ── Grid Controls ───────────────────── */} {/* ── Grid Controls ───────────────────── */}
<Section title="Grid"> <Section title="Grid">
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<label style={{ color: '#71717a', fontSize: 11, minWidth: 45 }}>Cols: {gridCols}</label> <label style={{ color: '#71717a', fontSize: 11, minWidth: 45 }}>
Cols: {gridCols}
</label>
<input <input
type="range" type="range"
min={1} min={1}
@@ -299,7 +293,9 @@ export function DevToolbar() {
/> />
</div> </div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginTop: 8 }}> <div style={{ display: 'flex', gap: 12, alignItems: 'center', marginTop: 8 }}>
<label style={{ color: '#71717a', fontSize: 11, minWidth: 45 }}>Gap: {gridGap}px</label> <label style={{ color: '#71717a', fontSize: 11, minWidth: 45 }}>
Gap: {gridGap}px
</label>
<input <input
type="range" type="range"
min={0} min={0}
@@ -313,17 +309,25 @@ export function DevToolbar() {
{/* ── Hero Style (info only) ──────────── */} {/* ── Hero Style (info only) ──────────── */}
<Section title="Hero Style (server)"> <Section title="Hero Style (server)">
<PillGrid items={HERO_STYLES} active="" onSelect={(h) => { <PillGrid
items={HERO_STYLES}
active=""
onSelect={(h) => {
copyYaml('hero', `theme:\n heroStyle: ${h}`); copyYaml('hero', `theme:\n heroStyle: ${h}`);
}} /> }}
/>
{copiedYaml === 'hero' && <CopyBadge />} {copiedYaml === 'hero' && <CopyBadge />}
</Section> </Section>
{/* ── Grid Layout (info only) ─────────── */} {/* ── Grid Layout (info only) ─────────── */}
<Section title="Grid Layout (server)"> <Section title="Grid Layout (server)">
<PillGrid items={GRID_LAYOUTS} active="" onSelect={(l) => { <PillGrid
items={GRID_LAYOUTS}
active=""
onSelect={(l) => {
copyYaml('layout', `grid:\n layout: ${l}`); copyYaml('layout', `grid:\n layout: ${l}`);
}} /> }}
/>
{copiedYaml === 'layout' && <CopyBadge />} {copiedYaml === 'layout' && <CopyBadge />}
</Section> </Section>
@@ -341,9 +345,8 @@ export function DevToolbar() {
padding: '10px 0', padding: '10px 0',
borderRadius: 10, borderRadius: 10,
border: 'none', border: 'none',
background: copiedYaml === 'all' background:
? '#22c55e' copiedYaml === 'all' ? '#22c55e' : 'linear-gradient(135deg, #a855f7, #6366f1)',
: 'linear-gradient(135deg, #a855f7, #6366f1)',
color: '#fff', color: '#fff',
fontWeight: 600, fontWeight: 600,
fontSize: 12, fontSize: 12,

View File

@@ -52,7 +52,8 @@ export function MapView() {
// 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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd', subdomains: 'abcd',
maxZoom: 19, maxZoom: 19,
}).addTo(map); }).addTo(map);
@@ -139,14 +140,14 @@ export function MapView() {
return ( return (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
{loading && ( {loading && (
<div className="map-container__loading" style={{ position: 'absolute', inset: 0, zIndex: 1000 }}> <div
className="map-container__loading"
style={{ position: 'absolute', inset: 0, zIndex: 1000 }}
>
<p>Loading map…</p> <p>Loading map…</p>
</div> </div>
)} )}
<div <div ref={containerRef} className="map-container" />
ref={containerRef}
className="map-container"
/>
</div> </div>
); );
} }

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

@@ -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');
} }
@@ -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,

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';
@@ -71,9 +71,9 @@ async function main() {
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';
}); });
}); });
@@ -101,7 +101,12 @@ async function main() {
} }
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 ──────────────────────────────────────────────────────