feat: portfolio with subpages, hero homepage, and obfuscated asset URLs

This commit is contained in:
ralksta
2026-02-14 00:59:57 +01:00
parent 688a28c7df
commit 415aef98f2
20 changed files with 2231 additions and 123 deletions

45
lib/cache.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Simple in-memory LRU cache for Immich API responses.
* Avoids hammering the Immich server with identical requests.
*/
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
class MemoryCache {
private store = new Map<string, CacheEntry<unknown>>();
private maxEntries = 200;
get<T>(key: string): T | null {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.data as T;
}
set<T>(key: string, data: T, ttlMs: number): void {
// Evict oldest entries if at capacity
if (this.store.size >= this.maxEntries) {
const firstKey = this.store.keys().next().value;
if (firstKey) this.store.delete(firstKey);
}
this.store.set(key, {
data,
expiresAt: Date.now() + ttlMs,
});
}
clear(): void {
this.store.clear();
}
}
export const cache = new MemoryCache();

123
lib/config.ts Normal file
View File

@@ -0,0 +1,123 @@
/**
* Typed environment configuration with validation.
* Fails fast at startup if required variables are missing.
*/
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
/** A subpage groups multiple Immich albums under one URL path. */
export interface SubpageConfig {
name: string;
slug: string;
albumIds: string[];
}
/**
* 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;
}
/**
* 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 [];
// 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);
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');
}
// Parse subpages (optional)
const subpages = parseSubpages(process.env.LIGHTBOX_SUBPAGES);
// Collect ALL album IDs (standalone + subpage) — deduplicated
const subpageAlbumIds = new Set(subpages.flatMap((sp) => sp.albumIds));
const allAlbumIds = [...new Set([...standaloneAlbumIds, ...subpageAlbumIds])];
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
};
}
/**
* Convert an album name into a URL-friendly slug.
* "Kloster Chorin" → "kloster-chorin"
*/
export function slugify(name: string): string {
return name
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // strip diacritics (ü→u, é→e)
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric → hyphens
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
}
export type AppConfig = ReturnType<typeof getConfig>;

320
lib/immich.ts Normal file
View File

@@ -0,0 +1,320 @@
/**
* Server-only Immich API client.
* Inspired by immich-public-proxy's approach but uses API key auth
* to access specific albums rather than shared link keys.
*
* API key never leaves the server — all client-facing image
* requests go through our proxy route.
*/
import { getConfig, slugify, type SubpageConfig } from './config';
import { cache } from './cache';
// ── Types ──────────────────────────────────────────────────────
export interface ImmichAlbum {
id: string;
slug: string;
albumName: string;
description: string;
albumThumbnailAssetId: string | null;
assetCount: number;
assets: ImmichAsset[];
createdAt: string;
updatedAt: string;
order: 'asc' | 'desc';
}
export interface ImmichAsset {
id: string;
type: 'IMAGE' | 'VIDEO';
originalFileName: string;
originalMimeType: string;
thumbhash: string | null;
fileCreatedAt: string;
exifInfo?: ImmichExifInfo;
isTrashed: boolean;
}
export interface ImmichExifInfo {
make: string | null;
model: string | null;
lensModel: string | null;
focalLength: number | null;
fNumber: number | null;
exposureTime: string | null;
iso: number | null;
city: string | null;
state: string | null;
country: string | null;
dateTimeOriginal: string | null;
description: string | null;
}
export type ImageSize = 'thumbnail' | 'preview' | 'original';
/** Enriched subpage with album metadata (for rendering cards). */
export interface SubpageSummary {
name: string;
slug: string;
albumCount: number;
totalAssetCount: number;
coverAssetId: string | null;
}
// ── API Client ─────────────────────────────────────────────────
class ImmichClient {
private hasLoggedAlbums = false;
private get config() {
return getConfig();
}
/**
* Make an authenticated request to the Immich API.
*/
private async request<T>(endpoint: string): Promise<T | null> {
const url = `${this.config.immich.apiUrl}${endpoint}`;
try {
const res = await fetch(url, {
headers: {
'x-api-key': this.config.immich.apiKey,
Accept: 'application/json',
},
});
if (!res.ok) {
console.error(`[Immich] ${res.status} ${res.statusText} for ${endpoint}`);
return null;
}
const contentType = res.headers.get('Content-Type') || '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
}
return null;
} catch (error) {
console.error(`[Immich] Failed to reach ${url}:`, error);
return null;
}
}
/**
* Stream a binary response from Immich (for image proxying).
*/
async streamAsset(
assetId: string,
size: ImageSize = 'preview',
): Promise<{ stream: ReadableStream; contentType: string; contentLength: string | null } | null> {
const endpoint =
size === 'original'
? `/assets/${encodeURIComponent(assetId)}/original`
: `/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`;
const url = `${this.config.immich.apiUrl}${endpoint}`;
try {
const res = await fetch(url, {
headers: {
'x-api-key': this.config.immich.apiKey,
},
});
if (!res.ok || !res.body) {
console.error(`[Immich] Failed to stream ${assetId}: ${res.status}`);
return null;
}
return {
stream: res.body,
contentType: res.headers.get('Content-Type') || 'application/octet-stream',
contentLength: res.headers.get('Content-Length'),
};
} catch (error) {
console.error(`[Immich] Stream error for ${assetId}:`, error);
return null;
}
}
/**
* Get ALL configured albums (filtered by the full allowlist).
* Uses ?shared=true to only fetch albums that have been shared in Immich.
*/
async getAlbums(): Promise<ImmichAlbum[]> {
const cacheKey = 'albums-list';
const cached = cache.get<ImmichAlbum[]>(cacheKey);
if (cached) return cached;
const all = await this.request<ImmichAlbum[]>('/albums?shared=true');
if (!all) return [];
const allowedIds = new Set(this.config.albums);
const filtered = all
.filter((album) => allowedIds.has(album.id))
.map((album) => ({
...album,
slug: slugify(album.albumName),
}));
// Log album summary on first load so admins can see what's published
if (!this.hasLoggedAlbums) {
this.hasLoggedAlbums = true;
console.log('\n[Lightbox] Published albums:');
console.log('─'.repeat(80));
// Log standalone albums
const standaloneIds = new Set(this.config.standaloneAlbums);
const standalone = filtered.filter((a) => standaloneIds.has(a.id));
if (standalone.length > 0) {
console.log(' Standalone:');
for (const a of standalone) {
console.log(` 📷 ${a.albumName}`);
console.log(` URL: /${a.slug} • ${a.assetCount} photos • ID: ${a.id}`);
}
}
// Log subpage groupings
for (const sp of this.config.subpages) {
const spAlbums = filtered.filter((a) => sp.albumIds.includes(a.id));
console.log(` 📁 ${sp.name} (/${sp.slug}):`);
for (const a of spAlbums) {
console.log(` 📷 ${a.albumName}`);
console.log(` URL: /${sp.slug}/${a.slug} • ${a.assetCount} photos`);
}
}
const missing = this.config.albums.filter((id) => !all.some((a) => a.id === id));
if (missing.length > 0) {
console.warn(` ⚠️ Unknown album IDs: ${missing.join(', ')}`);
}
console.log('─'.repeat(80) + '\n');
}
cache.set(cacheKey, filtered, this.config.cacheTtl);
return filtered;
}
/**
* Get only standalone albums (not in any subpage) — for the homepage.
*/
async getStandaloneAlbums(): Promise<ImmichAlbum[]> {
const albums = await this.getAlbums();
const standaloneIds = new Set(this.config.standaloneAlbums);
return albums.filter((a) => standaloneIds.has(a.id));
}
/**
* Get enriched subpage summaries for the homepage.
*/
async getSubpages(): Promise<SubpageSummary[]> {
if (this.config.subpages.length === 0) return [];
const albums = await this.getAlbums();
const albumMap = new Map(albums.map((a) => [a.id, a]));
return this.config.subpages.map((sp) => {
const spAlbums = sp.albumIds
.map((id) => albumMap.get(id))
.filter((a): a is ImmichAlbum => a !== undefined);
return {
name: sp.name,
slug: sp.slug,
albumCount: spAlbums.length,
totalAssetCount: spAlbums.reduce((sum, a) => sum + a.assetCount, 0),
coverAssetId: spAlbums[0]?.albumThumbnailAssetId ?? null,
};
});
}
/**
* Get albums belonging to a specific subpage.
*/
async getSubpageAlbums(subpageSlug: string): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> {
const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug);
if (!subpage) return null;
const allAlbums = await this.getAlbums();
const subpageAlbumIds = new Set(subpage.albumIds);
const albums = allAlbums.filter((a) => subpageAlbumIds.has(a.id));
return { subpage, albums };
}
/**
* Get a single album with its assets.
*/
async getAlbum(albumId: string): Promise<ImmichAlbum | null> {
// Security: only serve configured albums
if (!this.config.albums.includes(albumId)) {
console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`);
return null;
}
const cacheKey = `album-${albumId}`;
const cached = cache.get<ImmichAlbum>(cacheKey);
if (cached) return cached;
const album = await this.request<ImmichAlbum>(`/albums/${encodeURIComponent(albumId)}`);
if (!album) return null;
// Filter out trashed assets
album.assets = (album.assets || []).filter((a) => !a.isTrashed);
album.slug = slugify(album.albumName);
cache.set(cacheKey, album, this.config.cacheTtl);
return album;
}
/**
* Find an album by its URL slug.
* If subpageSlug is provided, only search within that subpage's albums.
*/
async getAlbumBySlug(slug: string, subpageSlug?: string): Promise<ImmichAlbum | null> {
const albums = await this.getAlbums();
let searchSet = albums;
if (subpageSlug) {
const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug);
if (!subpage) return null;
const subpageIds = new Set(subpage.albumIds);
searchSet = albums.filter((a) => subpageIds.has(a.id));
}
const match = searchSet.find((a) => a.slug === slug);
if (!match) return null;
return this.getAlbum(match.id);
}
/**
* Check if a slug corresponds to a subpage.
*/
isSubpageSlug(slug: string): boolean {
return this.config.subpages.some((sp) => sp.slug === slug);
}
/**
* Get asset info including EXIF data.
*/
async getAssetInfo(assetId: string): Promise<ImmichAsset | null> {
const cacheKey = `asset-${assetId}`;
const cached = cache.get<ImmichAsset>(cacheKey);
if (cached) return cached;
const asset = await this.request<ImmichAsset>(`/assets/${encodeURIComponent(assetId)}`);
if (!asset) return null;
cache.set(cacheKey, asset, this.config.cacheTtl);
return asset;
}
/**
* Check if the Immich server is reachable.
*/
async ping(): Promise<boolean> {
const res = await this.request<{ res: string }>('/server/ping');
return !!res;
}
}
export const immich = new ImmichClient();

62
lib/tokens.ts Normal file
View File

@@ -0,0 +1,62 @@
/**
* Token encoding/decoding for asset IDs.
* Obfuscates Immich UUIDs in public-facing URLs using AES encryption.
*
* Uses deterministic encryption (IV derived from asset ID) so the same
* asset always produces the same token — essential for browser caching.
*/
import crypto from 'crypto';
import { getConfig } from './config';
let _key: Buffer | null = null;
function getKey(): Buffer {
if (!_key) {
const apiKey = getConfig().immich.apiKey;
_key = crypto.createHash('sha256').update(apiKey).digest();
}
return _key;
}
/**
* Encode an asset ID into an opaque URL-safe token.
*/
export function encodeAssetId(assetId: string): string {
const key = getKey();
// Deterministic IV from asset ID (same input → same token)
const iv = crypto.createHash('md5').update(assetId).digest();
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
const encrypted = Buffer.concat([
cipher.update(assetId, 'utf8'),
cipher.final(),
]);
// Compact: base64url(iv + ciphertext)
return Buffer.concat([iv, encrypted]).toString('base64url');
}
/**
* Decode a token back into an asset ID.
* Returns null if the token is invalid.
*/
export function decodeAssetId(token: string): string | null {
try {
const key = getKey();
const data = Buffer.from(token, 'base64url');
if (data.length < 17) return null; // iv(16) + at least 1 byte
const iv = data.subarray(0, 16);
const encrypted = data.subarray(16);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]).toString('utf8');
// Validate it looks like a UUID
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(decrypted) ? decrypted : null;
} catch {
return null;
}
}

20
lib/urls.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Server-side URL helpers for generating public-facing asset URLs.
* These use encoded tokens instead of raw Immich UUIDs.
*/
import { encodeAssetId } from './tokens';
/**
* Generate a public image proxy URL for an asset.
*/
export function imageUrl(assetId: string, size: 'thumbnail' | 'preview' | 'original' = 'preview'): string {
return `/api/image/${encodeAssetId(assetId)}?size=${size}`;
}
/**
* Generate a public EXIF API URL for an asset.
*/
export function exifUrl(assetId: string): string {
return `/api/exif/${encodeAssetId(assetId)}`;
}