feat: visual polish & UX — fade-in, theme toggle, scroll-to-top, footer, cursor, badge
- Add FadeIn component (IntersectionObserver scroll-reveal + load animation) - Add dark/light theme toggle (useSyncExternalStore + localStorage) - Add scroll-to-top button (appears after 300px scroll) - Add configurable footer with social links (gallery.yaml) - Add crosshair cursor on photo grid items - Add 'X photos' count badge on subpage cover images - Add light theme color palette in tokens.css
This commit is contained in:
53
lib/__tests__/config.test.ts
Normal file
53
lib/__tests__/config.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock env.ts so importing config.ts doesn't trigger Zod validation
|
||||
vi.mock('@/lib/env', () => ({
|
||||
env: {
|
||||
IMMICH_API_URL: 'http://localhost:2283',
|
||||
IMMICH_API_KEY: 'test-key',
|
||||
SITE_TITLE: 'Test Gallery',
|
||||
SITE_SUBTITLE: '',
|
||||
CACHE_TTL: 300,
|
||||
RATE_LIMIT_RPM: 120,
|
||||
},
|
||||
}));
|
||||
|
||||
import { slugify } from '@/lib/config';
|
||||
|
||||
describe('slugify', () => {
|
||||
it('converts a simple name to lowercase with hyphens', () => {
|
||||
expect(slugify('Kloster Chorin')).toBe('kloster-chorin');
|
||||
});
|
||||
|
||||
it('strips diacritics', () => {
|
||||
expect(slugify('Schöne Aussicht')).toBe('schone-aussicht');
|
||||
expect(slugify('Café Müller')).toBe('cafe-muller');
|
||||
});
|
||||
|
||||
it('replaces special characters with hyphens', () => {
|
||||
expect(slugify('Hello, World!')).toBe('hello-world');
|
||||
expect(slugify('foo & bar (baz)')).toBe('foo-bar-baz');
|
||||
});
|
||||
|
||||
it('trims leading and trailing hyphens', () => {
|
||||
expect(slugify('--leading')).toBe('leading');
|
||||
expect(slugify('trailing---')).toBe('trailing');
|
||||
expect(slugify('---both---')).toBe('both');
|
||||
});
|
||||
|
||||
it('collapses consecutive hyphens', () => {
|
||||
expect(slugify('a b c')).toBe('a-b-c');
|
||||
});
|
||||
|
||||
it('handles an empty string', () => {
|
||||
expect(slugify('')).toBe('');
|
||||
});
|
||||
|
||||
it('handles all-special-character input', () => {
|
||||
expect(slugify('!@#$%')).toBe('');
|
||||
});
|
||||
|
||||
it('preserves numbers', () => {
|
||||
expect(slugify('Album 2024')).toBe('album-2024');
|
||||
});
|
||||
});
|
||||
27
lib/__tests__/thumbhash.test.ts
Normal file
27
lib/__tests__/thumbhash.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { thumbHashToBlurDataUrl, thumbHashToDominantHex } from '@/lib/thumbhash';
|
||||
|
||||
// A real ThumbHash captured from an Immich asset (13 bytes, valid for the library)
|
||||
// This is a minimal valid ThumbHash that produces a tiny image.
|
||||
// Generate one via: thumbhash.rgbaToThumbHash(w, h, rgba)
|
||||
// For testing, we use a known-good base64 string that the library accepts.
|
||||
const VALID_THUMBHASH = 'IQgSFQJ3d2d2eHiIh4h3d3c';
|
||||
|
||||
describe('thumbHashToBlurDataUrl', () => {
|
||||
it('produces a valid data URL', () => {
|
||||
const result = thumbHashToBlurDataUrl(VALID_THUMBHASH);
|
||||
expect(result).toMatch(/^data:image\/png;base64,/);
|
||||
});
|
||||
|
||||
it('produces a non-trivial string (has actual image data)', () => {
|
||||
const result = thumbHashToBlurDataUrl(VALID_THUMBHASH);
|
||||
expect(result.length).toBeGreaterThan(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('thumbHashToDominantHex', () => {
|
||||
it('returns a valid hex color string', () => {
|
||||
const result = thumbHashToDominantHex(VALID_THUMBHASH);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
});
|
||||
});
|
||||
62
lib/__tests__/tokens.test.ts
Normal file
62
lib/__tests__/tokens.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the config module so tokens.ts can resolve getConfig().immich.apiKey
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => ({
|
||||
immich: { apiKey: 'test-api-key-for-vitest-unit-tests' },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { encodeAssetId, decodeAssetId } from '@/lib/tokens';
|
||||
|
||||
const SAMPLE_UUID = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
describe('tokens', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('encodeAssetId', () => {
|
||||
it('produces a non-empty base64url string', () => {
|
||||
const token = encodeAssetId(SAMPLE_UUID);
|
||||
expect(token).toBeTruthy();
|
||||
expect(typeof token).toBe('string');
|
||||
// base64url characters only
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it('is deterministic — same input yields same token', () => {
|
||||
const t1 = encodeAssetId(SAMPLE_UUID);
|
||||
const t2 = encodeAssetId(SAMPLE_UUID);
|
||||
expect(t1).toBe(t2);
|
||||
});
|
||||
|
||||
it('produces different tokens for different UUIDs', () => {
|
||||
const t1 = encodeAssetId(SAMPLE_UUID);
|
||||
const t2 = encodeAssetId('12345678-aaaa-bbbb-cccc-123456789abc');
|
||||
expect(t1).not.toBe(t2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeAssetId', () => {
|
||||
it('round-trips a valid UUID', () => {
|
||||
const token = encodeAssetId(SAMPLE_UUID);
|
||||
const decoded = decodeAssetId(token);
|
||||
expect(decoded).toBe(SAMPLE_UUID);
|
||||
});
|
||||
|
||||
it('returns null for a garbage string', () => {
|
||||
expect(decodeAssetId('not-a-valid-token')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an empty string', () => {
|
||||
expect(decodeAssetId('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a token that is too short', () => {
|
||||
// less than 17 bytes when decoded
|
||||
const short = Buffer.from('shortdata').toString('base64url');
|
||||
expect(decodeAssetId(short)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
162
lib/__tests__/urls.test.ts
Normal file
162
lib/__tests__/urls.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock tokens so we get predictable URL output
|
||||
vi.mock('@/lib/tokens', () => ({
|
||||
encodeAssetId: (id: string) => `ENCODED_${id}`,
|
||||
}));
|
||||
|
||||
// Mock thumbhash helpers
|
||||
vi.mock('@/lib/thumbhash', () => ({
|
||||
thumbHashToBlurDataUrl: () => 'data:image/png;base64,mockblur',
|
||||
thumbHashToDominantHex: () => '#aabbcc',
|
||||
}));
|
||||
|
||||
import {
|
||||
imageUrl,
|
||||
exifUrl,
|
||||
assetPlaceholder,
|
||||
assetExifSummary,
|
||||
assetAspectRatio,
|
||||
} from '@/lib/urls';
|
||||
|
||||
describe('imageUrl', () => {
|
||||
it('generates a proxy URL with encoded token and default size', () => {
|
||||
const url = imageUrl('some-uuid');
|
||||
expect(url).toBe('/api/image/ENCODED_some-uuid?size=preview');
|
||||
});
|
||||
|
||||
it('uses the specified size parameter', () => {
|
||||
expect(imageUrl('id', 'thumbnail')).toBe('/api/image/ENCODED_id?size=thumbnail');
|
||||
expect(imageUrl('id', 'original')).toBe('/api/image/ENCODED_id?size=original');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exifUrl', () => {
|
||||
it('generates an EXIF API URL with encoded token', () => {
|
||||
expect(exifUrl('my-asset')).toBe('/api/exif/ENCODED_my-asset');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetPlaceholder', () => {
|
||||
it('returns blur data and dominant color for an asset with thumbhash', () => {
|
||||
const result = assetPlaceholder({ thumbhash: 'abc123' });
|
||||
expect(result).toEqual({
|
||||
blurDataURL: 'data:image/png;base64,mockblur',
|
||||
dominantColor: '#aabbcc',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when thumbhash is null', () => {
|
||||
expect(assetPlaceholder({ thumbhash: null })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when thumbhash is empty string', () => {
|
||||
expect(assetPlaceholder({ thumbhash: '' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetExifSummary', () => {
|
||||
it('returns camera, lens, and focal length from exifInfo', () => {
|
||||
const result = assetExifSummary({
|
||||
exifInfo: {
|
||||
make: 'Leica',
|
||||
model: 'M11-P',
|
||||
lensModel: 'Summilux-M 50mm',
|
||||
focalLength: 50,
|
||||
fNumber: null,
|
||||
exposureTime: null,
|
||||
iso: null,
|
||||
exifImageWidth: null,
|
||||
exifImageHeight: null,
|
||||
city: null,
|
||||
state: null,
|
||||
country: null,
|
||||
dateTimeOriginal: null,
|
||||
description: null,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
camera: 'M11-P',
|
||||
lens: 'Summilux-M 50mm',
|
||||
focalLength: '50mm',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when no exifInfo exists', () => {
|
||||
expect(assetExifSummary({ exifInfo: undefined })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when all relevant fields are null', () => {
|
||||
expect(
|
||||
assetExifSummary({
|
||||
exifInfo: {
|
||||
make: null,
|
||||
model: null,
|
||||
lensModel: null,
|
||||
focalLength: null,
|
||||
fNumber: null,
|
||||
exposureTime: null,
|
||||
iso: null,
|
||||
exifImageWidth: null,
|
||||
exifImageHeight: null,
|
||||
city: null,
|
||||
state: null,
|
||||
country: null,
|
||||
dateTimeOriginal: null,
|
||||
description: null,
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetAspectRatio', () => {
|
||||
it('computes width / height ratio', () => {
|
||||
const result = assetAspectRatio({
|
||||
exifInfo: {
|
||||
make: null,
|
||||
model: null,
|
||||
lensModel: null,
|
||||
focalLength: null,
|
||||
fNumber: null,
|
||||
exposureTime: null,
|
||||
iso: null,
|
||||
exifImageWidth: 3000,
|
||||
exifImageHeight: 2000,
|
||||
city: null,
|
||||
state: null,
|
||||
country: null,
|
||||
dateTimeOriginal: null,
|
||||
description: null,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(1.5);
|
||||
});
|
||||
|
||||
it('returns undefined when dimensions are missing', () => {
|
||||
expect(assetAspectRatio({ exifInfo: undefined })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when height is 0', () => {
|
||||
expect(
|
||||
assetAspectRatio({
|
||||
exifInfo: {
|
||||
make: null,
|
||||
model: null,
|
||||
lensModel: null,
|
||||
focalLength: null,
|
||||
fNumber: null,
|
||||
exposureTime: null,
|
||||
iso: null,
|
||||
exifImageWidth: 100,
|
||||
exifImageHeight: 0,
|
||||
city: null,
|
||||
state: null,
|
||||
country: null,
|
||||
dateTimeOriginal: null,
|
||||
description: null,
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
61
lib/auth.ts
61
lib/auth.ts
@@ -12,52 +12,46 @@ 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');
|
||||
return crypto.createHmac('sha256', getConfig().immich.apiKey).update(data).digest('hex');
|
||||
}
|
||||
|
||||
function authToken(slug: string, password: string): string {
|
||||
return hmac(`${slug}:${password}`);
|
||||
return hmac(`${slug}:${password}`);
|
||||
}
|
||||
|
||||
function cookieName(slug: string): string {
|
||||
return `lb_auth_${slug}`;
|
||||
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);
|
||||
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;
|
||||
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;
|
||||
export function authenticate(slug: string, password: string): string | null {
|
||||
const sp = findSubpageBySlug(slug);
|
||||
if (!sp?.password) return null;
|
||||
|
||||
if (password !== sp.password) return null;
|
||||
if (password !== sp.password) return null;
|
||||
|
||||
const token = authToken(slug, password);
|
||||
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
|
||||
const token = authToken(slug, password);
|
||||
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
|
||||
|
||||
return `${cookieName(slug)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict`;
|
||||
return `${cookieName(slug)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,23 +59,20 @@ export function authenticate(
|
||||
* Works with the cookies() API from Next.js server components.
|
||||
*/
|
||||
export function isAuthenticated(
|
||||
slug: string,
|
||||
getCookie: (name: string) => string | undefined,
|
||||
slug: string,
|
||||
getCookie: (name: string) => string | undefined,
|
||||
): boolean {
|
||||
const sp = findSubpageBySlug(slug);
|
||||
if (!sp?.password) return true; // not protected
|
||||
const sp = findSubpageBySlug(slug);
|
||||
if (!sp?.password) return true; // not protected
|
||||
|
||||
const cookie = getCookie(cookieName(slug));
|
||||
if (!cookie) return false;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
52
lib/cache.ts
52
lib/cache.ts
@@ -4,42 +4,42 @@
|
||||
*/
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class MemoryCache {
|
||||
private store = new Map<string, CacheEntry<unknown>>();
|
||||
private maxEntries = 200;
|
||||
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;
|
||||
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;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return entry.data as T;
|
||||
}
|
||||
|
||||
this.store.set(key, {
|
||||
data,
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
this.store.set(key, {
|
||||
data,
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const cache = new MemoryCache();
|
||||
|
||||
@@ -6,16 +6,9 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import { env } from './env';
|
||||
|
||||
// ── Env helpers ────────────────────────────────────────────────
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
// ── Env helpers (now handled by lib/env.ts via Zod) ───────────
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -30,6 +23,14 @@ export interface SubpageConfig {
|
||||
grid?: Partial<GridConfig>;
|
||||
}
|
||||
|
||||
/** Footer configuration for social links. */
|
||||
export interface FooterConfig {
|
||||
name?: string;
|
||||
instagram?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
}
|
||||
|
||||
/** Raw YAML structure (before validation). */
|
||||
interface GalleryYaml {
|
||||
hero?: string | string[];
|
||||
@@ -41,6 +42,12 @@ interface GalleryYaml {
|
||||
aspectRatio?: string;
|
||||
layout?: string;
|
||||
};
|
||||
footer?: {
|
||||
name?: string;
|
||||
instagram?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
};
|
||||
subpages?: Array<{
|
||||
name: string;
|
||||
albums: string[];
|
||||
@@ -62,7 +69,7 @@ function loadGalleryYaml(): GalleryYaml {
|
||||
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.`,
|
||||
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,8 +106,8 @@ export function slugify(name: string): string {
|
||||
.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
|
||||
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric → hyphens
|
||||
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
|
||||
}
|
||||
|
||||
// ── Config type ────────────────────────────────────────────────
|
||||
@@ -126,6 +133,8 @@ export interface AppConfig {
|
||||
exifOnHover: boolean;
|
||||
/** Photo grid layout configuration. */
|
||||
grid: GridConfig;
|
||||
/** Footer social links config. */
|
||||
footer: FooterConfig | null;
|
||||
cacheTtl: number;
|
||||
rateLimitRpm: number;
|
||||
}
|
||||
@@ -137,13 +146,14 @@ 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 apiUrl = env.IMMICH_API_URL;
|
||||
const apiKey = env.IMMICH_API_KEY;
|
||||
const gallery = loadGalleryYaml();
|
||||
|
||||
// Parse standalone albums
|
||||
const standaloneAlbumIds = (gallery.albums ?? [])
|
||||
.map((id) => validateUuid(id, 'gallery.yaml 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');
|
||||
@@ -152,21 +162,31 @@ export function getConfig(): AppConfig {
|
||||
// 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`);
|
||||
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'] } : {}),
|
||||
},
|
||||
} : {}),
|
||||
...(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'],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -182,11 +202,12 @@ export function getConfig(): AppConfig {
|
||||
albums: allAlbumIds,
|
||||
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
|
||||
subpages,
|
||||
siteTitle: process.env.SITE_TITLE || 'Gallery',
|
||||
siteSubtitle: process.env.SITE_SUBTITLE || '',
|
||||
siteTitle: env.SITE_TITLE,
|
||||
siteSubtitle: env.SITE_SUBTITLE,
|
||||
heroImages: gallery.hero
|
||||
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero])
|
||||
.map((id) => validateUuid(id, 'gallery.yaml hero'))
|
||||
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
|
||||
validateUuid(id, 'gallery.yaml hero'),
|
||||
)
|
||||
: [],
|
||||
exifOnHover: gallery.exifOnHover !== false,
|
||||
grid: {
|
||||
@@ -195,8 +216,16 @@ export function getConfig(): AppConfig {
|
||||
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),
|
||||
footer: gallery.footer
|
||||
? {
|
||||
name: gallery.footer.name,
|
||||
instagram: gallery.footer.instagram,
|
||||
email: gallery.footer.email,
|
||||
website: gallery.footer.website,
|
||||
}
|
||||
: null,
|
||||
cacheTtl: env.CACHE_TTL * 1000,
|
||||
rateLimitRpm: env.RATE_LIMIT_RPM,
|
||||
};
|
||||
|
||||
return _config;
|
||||
|
||||
45
lib/env.ts
Normal file
45
lib/env.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Zod-validated environment variables.
|
||||
* Parsed once at startup — all env access should go through this module.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
/** Base URL of your Immich instance, e.g. https://immich.example.com */
|
||||
IMMICH_API_URL: z
|
||||
.string()
|
||||
.url('IMMICH_API_URL must be a valid URL')
|
||||
.transform((url) => url.replace(/\/+$/, '')),
|
||||
|
||||
/** Immich API key (from Admin → API Keys) */
|
||||
IMMICH_API_KEY: z.string().min(1, 'IMMICH_API_KEY is required'),
|
||||
|
||||
/** Public site title shown in the header */
|
||||
SITE_TITLE: z.string().default('Gallery'),
|
||||
|
||||
/** Subtitle displayed below the title */
|
||||
SITE_SUBTITLE: z.string().default(''),
|
||||
|
||||
/** Cache TTL in seconds */
|
||||
CACHE_TTL: z.coerce.number().int().min(0).default(300),
|
||||
|
||||
/** Rate limit: max requests per minute per IP */
|
||||
RATE_LIMIT_RPM: z.coerce.number().int().min(1).default(120),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
function parseEnv(): Env {
|
||||
const result = envSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
const formatted = result.error.issues
|
||||
.map((issue) => ` ✗ ${issue.path.join('.')}: ${issue.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`\n❌ Environment validation failed:\n${formatted}\n`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/** Validated, typed environment variables. */
|
||||
export const env = parseEnv();
|
||||
524
lib/immich.ts
524
lib/immich.ts
@@ -13,310 +13,312 @@ 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';
|
||||
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;
|
||||
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;
|
||||
exifImageWidth: number | null;
|
||||
exifImageHeight: number | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
country: string | null;
|
||||
dateTimeOriginal: string | null;
|
||||
description: string | null;
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
lensModel: string | null;
|
||||
focalLength: number | null;
|
||||
fNumber: number | null;
|
||||
exposureTime: string | null;
|
||||
iso: number | null;
|
||||
exifImageWidth: number | null;
|
||||
exifImageHeight: 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;
|
||||
name: string;
|
||||
slug: string;
|
||||
albumCount: number;
|
||||
totalAssetCount: number;
|
||||
coverAssetId: string | null;
|
||||
}
|
||||
|
||||
// ── API Client ─────────────────────────────────────────────────
|
||||
|
||||
class ImmichClient {
|
||||
private hasLoggedAlbums = false;
|
||||
private hasLoggedAlbums = false;
|
||||
|
||||
private get config() {
|
||||
return getConfig();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
},
|
||||
});
|
||||
/**
|
||||
* 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}`;
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`[Immich] ${res.status} ${res.statusText} for ${endpoint}`);
|
||||
return null;
|
||||
}
|
||||
const url = `${this.config.immich.apiUrl}${endpoint}`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'x-api-key': this.config.immich.apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
// 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
cache.set(cacheKey, filtered, this.config.cacheTtl);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
const all = await this.request<ImmichAlbum[]>('/albums?shared=true');
|
||||
if (!all) return [];
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
const allowedIds = new Set(this.config.albums);
|
||||
const filtered = all
|
||||
.filter((album) => allowedIds.has(album.id))
|
||||
.map((album) => ({
|
||||
...album,
|
||||
slug: slugify(album.albumName),
|
||||
}));
|
||||
/**
|
||||
* Get enriched subpage summaries for the homepage.
|
||||
*/
|
||||
async getSubpages(): Promise<SubpageSummary[]> {
|
||||
if (this.config.subpages.length === 0) return [];
|
||||
|
||||
// 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));
|
||||
const albums = await this.getAlbums();
|
||||
const albumMap = new Map(albums.map((a) => [a.id, a]));
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
return this.config.subpages.map((sp) => {
|
||||
const spAlbums = sp.albumIds
|
||||
.map((id) => albumMap.get(id))
|
||||
.filter((a): a is ImmichAlbum => a !== undefined);
|
||||
|
||||
// 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`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: sp.name,
|
||||
slug: sp.slug,
|
||||
albumCount: spAlbums.length,
|
||||
totalAssetCount: spAlbums.reduce((sum, a) => sum + a.assetCount, 0),
|
||||
coverAssetId: spAlbums[0]?.albumThumbnailAssetId ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
|
||||
cache.set(cacheKey, filtered, this.config.cacheTtl);
|
||||
return filtered;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enriched subpage summaries for the homepage.
|
||||
*/
|
||||
async getSubpages(): Promise<SubpageSummary[]> {
|
||||
if (this.config.subpages.length === 0) return [];
|
||||
const match = searchSet.find((a) => a.slug === slug);
|
||||
if (!match) return null;
|
||||
return this.getAlbum(match.id);
|
||||
}
|
||||
|
||||
const albums = await this.getAlbums();
|
||||
const albumMap = new Map(albums.map((a) => [a.id, a]));
|
||||
/**
|
||||
* Check if a slug corresponds to a subpage.
|
||||
*/
|
||||
isSubpageSlug(slug: string): boolean {
|
||||
return this.config.subpages.some((sp) => sp.slug === slug);
|
||||
}
|
||||
|
||||
return this.config.subpages.map((sp) => {
|
||||
const spAlbums = sp.albumIds
|
||||
.map((id) => albumMap.get(id))
|
||||
.filter((a): a is ImmichAlbum => a !== undefined);
|
||||
/**
|
||||
* 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;
|
||||
|
||||
return {
|
||||
name: sp.name,
|
||||
slug: sp.slug,
|
||||
albumCount: spAlbums.length,
|
||||
totalAssetCount: spAlbums.reduce((sum, a) => sum + a.assetCount, 0),
|
||||
coverAssetId: spAlbums[0]?.albumThumbnailAssetId ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
const asset = await this.request<ImmichAsset>(`/assets/${encodeURIComponent(assetId)}`);
|
||||
if (!asset) return 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;
|
||||
cache.set(cacheKey, asset, this.config.cacheTtl);
|
||||
return asset;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
/**
|
||||
* 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();
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
'use client';
|
||||
|
||||
interface ImageLoaderParams {
|
||||
src: string;
|
||||
width: number;
|
||||
quality?: number;
|
||||
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}`;
|
||||
const separator = src.includes('?') ? '&' : '?';
|
||||
return `${src}${separator}w=${width}&q=${quality || 75}`;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
expiresAt: number;
|
||||
count: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, RateLimitEntry>();
|
||||
@@ -16,12 +16,12 @@ 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,29 +31,29 @@ function cleanup() {
|
||||
* @returns { success, remaining, resetAt (epoch ms) }
|
||||
*/
|
||||
export function checkRateLimit(
|
||||
ip: string,
|
||||
maxRpm: number,
|
||||
ip: string,
|
||||
maxRpm: number,
|
||||
): { success: boolean; remaining: number; resetAt: number } {
|
||||
cleanup();
|
||||
cleanup();
|
||||
|
||||
const now = Date.now();
|
||||
const windowMs = 60_000;
|
||||
const key = `rl:${ip}`;
|
||||
const now = Date.now();
|
||||
const windowMs = 60_000;
|
||||
const key = `rl:${ip}`;
|
||||
|
||||
const entry = store.get(key);
|
||||
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 };
|
||||
}
|
||||
// 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 };
|
||||
}
|
||||
// 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 };
|
||||
return { success: true, remaining: maxRpm - entry.count, resetAt: entry.expiresAt };
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import { thumbHashToRGBA, thumbHashToDataURL } from 'thumbhash';
|
||||
* 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);
|
||||
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
||||
return thumbHashToDataURL(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,23 +20,29 @@ export function thumbHashToBlurDataUrl(base64: string): 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);
|
||||
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;
|
||||
}
|
||||
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';
|
||||
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)}`;
|
||||
const avg = (v: number) =>
|
||||
Math.round(v / total)
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
return `#${avg(r)}${avg(g)}${avg(b)}`;
|
||||
}
|
||||
|
||||
@@ -12,27 +12,24 @@ 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;
|
||||
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');
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,23 +37,22 @@ export function encodeAssetId(assetId: string): string {
|
||||
* 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
|
||||
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');
|
||||
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;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
59
lib/urls.ts
59
lib/urls.ts
@@ -10,23 +10,26 @@ import type { ImmichAsset } from './immich';
|
||||
/**
|
||||
* 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}`;
|
||||
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)}`;
|
||||
return `/api/exif/${encodeAssetId(assetId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder data derived from an asset's ThumbHash.
|
||||
*/
|
||||
export interface PlaceholderData {
|
||||
blurDataURL: string;
|
||||
dominantColor: string;
|
||||
blurDataURL: string;
|
||||
dominantColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,24 +37,24 @@ export interface PlaceholderData {
|
||||
* 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;
|
||||
}
|
||||
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;
|
||||
camera?: string;
|
||||
lens?: string;
|
||||
focalLength?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,15 +62,15 @@ export interface ExifSummary {
|
||||
* 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 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;
|
||||
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 };
|
||||
if (!camera && !lens && !focalLength) return undefined;
|
||||
return { camera, lens, focalLength };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,8 +78,8 @@ export function assetExifSummary(asset: Pick<ImmichAsset, 'exifInfo'>): ExifSumm
|
||||
* 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;
|
||||
const w = asset.exifInfo?.exifImageWidth;
|
||||
const h = asset.exifInfo?.exifImageHeight;
|
||||
if (w && h && h > 0) return w / h;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user