feat: photography features & image optimizations

- Hero carousel: gallery.yaml hero accepts single UUID or array, crossfade component
- Full-bleed single albums: subpages with 1 album skip grid, render photos directly
- EXIF on hover: camera/lens/focal length overlay on grid items (toggle via exifOnHover)
- Customizable grid: columns/gap/aspectRatio/layout (masonry|uniform) via gallery.yaml
- Per-subpage grid overrides: each subpage can override global grid config
- True masonry: per-image aspect ratios from EXIF dimensions
- next/image migration: homepage hero, album covers, grid thumbnails, about portrait
- ThumbHash blur placeholders and dominant color backgrounds
- Custom next/image loader via image proxy with width/quality params
- Lightbox fade-in transition with dominant color background
- Password-protected subpages, health check, rate limiting, OG images
This commit is contained in:
ralksta
2026-02-14 10:30:27 +01:00
parent 3c69860a64
commit b7217c4a76
31 changed files with 1998 additions and 162 deletions

87
lib/auth.ts Normal file
View File

@@ -0,0 +1,87 @@
/**
* Album authentication helpers.
* Uses HMAC tokens stored in HttpOnly cookies — no database needed.
*
* Token = HMAC-SHA256(slug + password, apiKey)
* Cookie = lb_auth_<slug> = <token>
*/
import crypto from 'crypto';
import { getConfig, SubpageConfig } from './config';
const TOKEN_EXPIRY_HOURS = 24;
function hmac(data: string): string {
return crypto
.createHmac('sha256', getConfig().immich.apiKey)
.update(data)
.digest('hex');
}
function authToken(slug: string, password: string): string {
return hmac(`${slug}:${password}`);
}
function cookieName(slug: string): string {
return `lb_auth_${slug}`;
}
/**
* Find the SubpageConfig for a given slug.
*/
export function findSubpageBySlug(slug: string): SubpageConfig | undefined {
return getConfig().subpages.find((sp) => sp.slug === slug);
}
/**
* Check if a slug corresponds to a password-protected subpage.
*/
export function isProtected(slug: string): boolean {
const sp = findSubpageBySlug(slug);
return !!sp?.password;
}
/**
* Validate a password attempt and return a Set-Cookie header value on success.
* Returns null if the password is wrong.
*/
export function authenticate(
slug: string,
password: string,
): string | null {
const sp = findSubpageBySlug(slug);
if (!sp?.password) return null;
if (password !== sp.password) return null;
const token = authToken(slug, password);
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
return `${cookieName(slug)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict`;
}
/**
* Check if the request has a valid auth cookie for the given slug.
* Works with the cookies() API from Next.js server components.
*/
export function isAuthenticated(
slug: string,
getCookie: (name: string) => string | undefined,
): boolean {
const sp = findSubpageBySlug(slug);
if (!sp?.password) return true; // not protected
const cookie = getCookie(cookieName(slug));
if (!cookie) return false;
const expected = authToken(slug, sp.password);
// Constant-time comparison to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(cookie, 'hex'),
Buffer.from(expected, 'hex'),
);
} catch {
return false;
}
}

View File

@@ -1,8 +1,14 @@
/**
* Typed environment configuration with validation.
* Fails fast at startup if required variables are missing.
* Typed configuration loader.
* Secrets come from env vars; gallery structure comes from content/gallery.yaml.
*/
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
// ── Env helpers ────────────────────────────────────────────────
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
@@ -11,102 +17,79 @@ function requireEnv(name: string): string {
return value;
}
// ── Types ──────────────────────────────────────────────────────
/** A subpage groups multiple Immich albums under one URL path. */
export interface SubpageConfig {
name: string;
slug: string;
albumIds: string[];
/** Optional password — if set, visitors must authenticate to view. */
password?: string;
/** Per-subpage grid overrides (merged with global grid config). */
grid?: Partial<GridConfig>;
}
/**
* Extract a UUID from a string that may be "Label:uuid" or just "uuid".
*/
function extractUuid(entry: string): string | null {
const trimmed = entry.trim();
if (!trimmed) return null;
const uuidMatch = trimmed.match(
/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i,
);
return uuidMatch ? uuidMatch[1] : trimmed;
/** Raw YAML structure (before validation). */
interface GalleryYaml {
hero?: string | string[];
albums?: string[];
exifOnHover?: boolean;
grid?: {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
};
subpages?: Array<{
name: string;
albums: string[];
password?: string;
grid?: {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
};
}>;
}
/**
* Parse LIGHTBOX_SUBPAGES env var.
* Format: "Japan > uuid1, uuid2; Italy > uuid3, uuid4"
* Each subpage separated by ";", name separated from album IDs by ">".
*/
function parseSubpages(raw: string | undefined): SubpageConfig[] {
if (!raw || !raw.trim()) return [];
// ── YAML loading ───────────────────────────────────────────────
// Split on semicolons OR newlines — supports both single-line and multiline formats
return raw
.split(/[;\n]/)
.map((segment) => segment.trim())
.filter(Boolean)
.map((segment) => {
const parts = segment.split('>');
if (parts.length < 2) {
console.warn(`[Config] Skipping malformed subpage entry: "${segment}"`);
return null;
}
const name = parts[0].trim();
const albumIds = parts
.slice(1)
.join('>') // rejoin in case name contained ">"
.split(',')
.map((id) => extractUuid(id))
.filter((id): id is string => id !== null);
function loadGalleryYaml(): GalleryYaml {
const yamlPath = path.join(process.cwd(), 'content', 'gallery.yaml');
if (!name || albumIds.length === 0) {
console.warn(`[Config] Skipping subpage with missing name or albums: "${segment}"`);
return null;
}
return { name, slug: slugify(name), albumIds };
})
.filter((sp): sp is SubpageConfig => sp !== null);
}
export function getConfig() {
const apiUrl = requireEnv('IMMICH_API_URL').replace(/\/+$/, '');
const apiKey = requireEnv('IMMICH_API_KEY');
// Parse standalone albums from LIGHTBOX_ALBUMS
const standaloneAlbumIds = requireEnv('LIGHTBOX_ALBUMS')
.split(',')
.map((entry) => extractUuid(entry))
.filter((id): id is string => id !== null);
if (standaloneAlbumIds.length === 0) {
throw new Error('LIGHTBOX_ALBUMS must contain at least one album ID');
if (!fs.existsSync(yamlPath)) {
throw new Error(
`Gallery config not found: ${yamlPath}\n` +
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
);
}
// Parse subpages (optional)
const subpages = parseSubpages(process.env.LIGHTBOX_SUBPAGES);
const raw = fs.readFileSync(yamlPath, 'utf8');
const data = yaml.load(raw) as GalleryYaml;
// Collect ALL album IDs (standalone + subpage) — deduplicated
const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
if (!data || typeof data !== 'object') {
throw new Error('gallery.yaml is empty or invalid');
}
return {
immich: {
apiUrl: `${apiUrl}/api`,
apiKey,
},
/** All album IDs (standalone + subpage) for allowlist validation */
albums: allAlbumIds,
/** Album IDs that appear only in LIGHTBOX_ALBUMS and NOT in any subpage */
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
/** Subpage definitions */
subpages,
siteTitle: process.env.SITE_TITLE || 'Gallery',
siteSubtitle: process.env.SITE_SUBTITLE || '',
/** Immich asset ID for the homepage hero image */
heroImage: process.env.HERO_IMAGE || '',
cacheTtl: parseInt(process.env.CACHE_TTL || '300', 10) * 1000, // convert to ms
};
return data;
}
// ── UUID validation ────────────────────────────────────────────
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function validateUuid(id: string, context: string): string {
const trimmed = id.trim();
if (!UUID_REGEX.test(trimmed)) {
throw new Error(`Invalid UUID in ${context}: "${trimmed}"`);
}
return trimmed;
}
// ── Slugify ────────────────────────────────────────────────────
/**
* Convert an album name into a URL-friendly slug.
* "Kloster Chorin" → "kloster-chorin"
@@ -120,4 +103,101 @@ export function slugify(name: string): string {
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
}
export type AppConfig = ReturnType<typeof getConfig>;
// ── Config type ────────────────────────────────────────────────
/** Grid layout configuration. */
export interface GridConfig {
columns: number;
gap: number;
aspectRatio: string;
layout: 'masonry' | 'uniform';
}
export interface AppConfig {
immich: { apiUrl: string; apiKey: string };
albums: string[];
standaloneAlbums: string[];
subpages: SubpageConfig[];
siteTitle: string;
siteSubtitle: string;
/** One or more hero image asset IDs for the homepage carousel. */
heroImages: string[];
/** Show EXIF (camera/lens) on photo grid hover. Default: true. */
exifOnHover: boolean;
/** Photo grid layout configuration. */
grid: GridConfig;
cacheTtl: number;
rateLimitRpm: number;
}
// ── Config builder ─────────────────────────────────────────────
let _config: AppConfig | null = null;
export function getConfig(): AppConfig {
if (_config) return _config;
const apiUrl = requireEnv('IMMICH_API_URL').replace(/\/+$/, '');
const apiKey = requireEnv('IMMICH_API_KEY');
const gallery = loadGalleryYaml();
// Parse standalone albums
const standaloneAlbumIds = (gallery.albums ?? [])
.map((id) => validateUuid(id, 'gallery.yaml albums'));
if (standaloneAlbumIds.length === 0 && (!gallery.subpages || gallery.subpages.length === 0)) {
throw new Error('gallery.yaml must define at least one album or subpage');
}
// Parse subpages
const subpages: SubpageConfig[] = (gallery.subpages ?? []).map((sp) => {
if (!sp.name || !sp.albums || sp.albums.length === 0) {
throw new Error(`Subpage "${sp.name || '(unnamed)'}" must have a name and at least one album`);
}
return {
name: sp.name,
slug: slugify(sp.name),
albumIds: sp.albums.map((id) => validateUuid(id, `subpage "${sp.name}"`)),
password: sp.password,
...(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: (sp.grid.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'] } : {}),
},
} : {}),
};
});
// Collect ALL album IDs (standalone + subpage) — deduplicated
const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
_config = {
immich: {
apiUrl: `${apiUrl}/api`,
apiKey,
},
albums: allAlbumIds,
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
subpages,
siteTitle: process.env.SITE_TITLE || 'Gallery',
siteSubtitle: process.env.SITE_SUBTITLE || '',
heroImages: gallery.hero
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero])
.map((id) => validateUuid(id, 'gallery.yaml hero'))
: [],
exifOnHover: gallery.exifOnHover !== false,
grid: {
columns: gallery.grid?.columns ?? 3,
gap: gallery.grid?.gap ?? 12,
aspectRatio: gallery.grid?.aspectRatio ?? '1',
layout: (gallery.grid?.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'],
},
cacheTtl: parseInt(process.env.CACHE_TTL || '300', 10) * 1000,
rateLimitRpm: parseInt(process.env.RATE_LIMIT_RPM || '120', 10),
};
return _config;
}

View File

@@ -44,6 +44,8 @@ export interface ImmichExifInfo {
fNumber: number | null;
exposureTime: string | null;
iso: number | null;
exifImageWidth: number | null;
exifImageHeight: number | null;
city: string | null;
state: string | null;
country: string | null;

24
lib/immichLoader.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* Custom next/image loader for Immich proxy.
*
* Referenced by next.config.ts via images.loaderFile.
* Generates URLs that point to our /api/image proxy route,
* passing width and quality as query parameters.
*
* The src passed to next/image should already be a full proxy URL
* (e.g. /api/image/<token>?size=preview). This loader appends
* w and q params for responsive srcset generation.
*/
'use client';
interface ImageLoaderParams {
src: string;
width: number;
quality?: number;
}
export default function immichLoader({ src, width, quality }: ImageLoaderParams): string {
const separator = src.includes('?') ? '&' : '?';
return `${src}${separator}w=${width}&q=${quality || 75}`;
}

59
lib/rate-limit.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* In-memory sliding-window rate limiter.
* Tracks request counts per IP per minute bucket.
* Auto-evicts expired entries to prevent memory leaks.
*/
interface RateLimitEntry {
count: number;
expiresAt: number;
}
const store = new Map<string, RateLimitEntry>();
// Evict expired entries periodically (every 60s)
let lastCleanup = Date.now();
const CLEANUP_INTERVAL = 60_000;
function cleanup() {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL) return;
lastCleanup = now;
for (const [key, entry] of store) {
if (now > entry.expiresAt) store.delete(key);
}
}
/**
* Check if a request should be allowed.
* @param ip - Client IP address
* @param maxRpm - Maximum requests per minute
* @returns { success, remaining, resetAt (epoch ms) }
*/
export function checkRateLimit(
ip: string,
maxRpm: number,
): { success: boolean; remaining: number; resetAt: number } {
cleanup();
const now = Date.now();
const windowMs = 60_000;
const key = `rl:${ip}`;
const entry = store.get(key);
// New window or expired
if (!entry || now > entry.expiresAt) {
const resetAt = now + windowMs;
store.set(key, { count: 1, expiresAt: resetAt });
return { success: true, remaining: maxRpm - 1, resetAt };
}
// Within window
entry.count++;
if (entry.count > maxRpm) {
return { success: false, remaining: 0, resetAt: entry.expiresAt };
}
return { success: true, remaining: maxRpm - entry.count, resetAt: entry.expiresAt };
}

42
lib/thumbhash.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* ThumbHash utilities for generating blur placeholders and dominant colors.
* Uses the thumbhash package to decode Immich's base64-encoded ThumbHash strings
* into data URLs and hex color values.
*/
import { thumbHashToRGBA, thumbHashToDataURL } from 'thumbhash';
/**
* Convert an Immich base64-encoded ThumbHash into a tiny data URL
* suitable for next/image's blurDataURL prop.
*/
export function thumbHashToBlurDataUrl(base64: string): string {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return thumbHashToDataURL(bytes);
}
/**
* Extract the dominant/average color from a ThumbHash as a hex string.
* Computes the weighted average of all non-transparent pixels.
*/
export function thumbHashToDominantHex(base64: string): string {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const { w, h, rgba } = thumbHashToRGBA(bytes);
let r = 0, g = 0, b = 0, total = 0;
for (let i = 0; i < w * h; i++) {
const a = rgba[i * 4 + 3];
if (a > 0) {
const weight = a / 255;
r += rgba[i * 4] * weight;
g += rgba[i * 4 + 1] * weight;
b += rgba[i * 4 + 2] * weight;
total += weight;
}
}
if (total === 0) return '#888888';
const avg = (v: number) => Math.round(v / total).toString(16).padStart(2, '0');
return `#${avg(r)}${avg(g)}${avg(b)}`;
}

View File

@@ -4,6 +4,8 @@
*/
import { encodeAssetId } from './tokens';
import { thumbHashToBlurDataUrl, thumbHashToDominantHex } from './thumbhash';
import type { ImmichAsset } from './immich';
/**
* Generate a public image proxy URL for an asset.
@@ -18,3 +20,63 @@ export function imageUrl(assetId: string, size: 'thumbnail' | 'preview' | 'origi
export function exifUrl(assetId: string): string {
return `/api/exif/${encodeAssetId(assetId)}`;
}
/**
* Placeholder data derived from an asset's ThumbHash.
*/
export interface PlaceholderData {
blurDataURL: string;
dominantColor: string;
}
/**
* Generate blur placeholder and dominant color from an asset's ThumbHash.
* Returns null if the asset has no ThumbHash.
*/
export function assetPlaceholder(asset: Pick<ImmichAsset, 'thumbhash'>): PlaceholderData | null {
if (!asset.thumbhash) return null;
try {
return {
blurDataURL: thumbHashToBlurDataUrl(asset.thumbhash),
dominantColor: thumbHashToDominantHex(asset.thumbhash),
};
} catch {
return null;
}
}
/**
* Compact EXIF summary for hover overlays.
*/
export interface ExifSummary {
camera?: string;
lens?: string;
focalLength?: string;
}
/**
* Extract a compact EXIF summary from an asset's exifInfo.
* Returns undefined if no relevant EXIF data is available.
*/
export function assetExifSummary(asset: Pick<ImmichAsset, 'exifInfo'>): ExifSummary | undefined {
const exif = asset.exifInfo;
if (!exif) return undefined;
const camera = exif.model || undefined;
const lens = exif.lensModel || undefined;
const focalLength = exif.focalLength ? `${exif.focalLength}mm` : undefined;
if (!camera && !lens && !focalLength) return undefined;
return { camera, lens, focalLength };
}
/**
* Compute the natural aspect ratio (width / height) from EXIF dimensions.
* Returns undefined if dimensions are not available.
*/
export function assetAspectRatio(asset: Pick<ImmichAsset, 'exifInfo'>): number | undefined {
const w = asset.exifInfo?.exifImageWidth;
const h = asset.exifInfo?.exifImageHeight;
if (w && h && h > 0) return w / h;
return undefined;
}