feat: portfolio with subpages, hero homepage, and obfuscated asset URLs
This commit is contained in:
45
lib/cache.ts
Normal file
45
lib/cache.ts
Normal 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
123
lib/config.ts
Normal 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
320
lib/immich.ts
Normal 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
62
lib/tokens.ts
Normal 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
20
lib/urls.ts
Normal 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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user