diff --git a/app/[...path]/PhotoGrid.tsx b/app/[...path]/PhotoGrid.tsx index eb0042d..757a0be 100644 --- a/app/[...path]/PhotoGrid.tsx +++ b/app/[...path]/PhotoGrid.tsx @@ -55,6 +55,9 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr useEffect(() => { const idx = parsePhotoHash(window.location.hash); if (idx !== null && idx < assets.length) { + // state from URL hash on mount only (equivalent to useState lazy initializer for + // client-only browser APIs). + // eslint-disable-next-line react-hooks/set-state-in-effect setLightboxIndex(idx); } }, [assets.length]); @@ -139,8 +142,9 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr {assets.map((asset, index) => (
openLightbox(index)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { diff --git a/app/[...path]/page.tsx b/app/[...path]/page.tsx index cb8a5cc..56cb465 100644 --- a/app/[...path]/page.tsx +++ b/app/[...path]/page.tsx @@ -288,11 +288,7 @@ export default async function PathPage({ params }: PathPageProps) { {album.description && ` · ${album.description}`}

- + ); } diff --git a/app/api/image/[id]/route.ts b/app/api/image/[id]/route.ts index 5a1cd5f..9dfdd6e 100644 --- a/app/api/image/[id]/route.ts +++ b/app/api/image/[id]/route.ts @@ -64,8 +64,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Asset not found' }, { status: 404 }); } - console.log(`[Image API] ✅ Serving ${assetId} (${size}) - Type: ${result.contentType}`); - const headers: Record = { 'Content-Type': result.contentType.includes('application/octet-stream') ? 'image/jpeg' @@ -73,7 +71,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ // Images are immutable once uploaded to Immich — cache aggressively 'Cache-Control': 'public, max-age=31536000, immutable', 'X-RateLimit-Remaining': String(remaining), - 'Access-Control-Allow-Origin': '*', // Allow cross-origin for lightbox }; if (result.contentLength) { diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx index 8bebb84..c7ba31a 100644 --- a/app/api/og/route.tsx +++ b/app/api/og/route.tsx @@ -12,8 +12,8 @@ import { getConfig } from '@/lib/config'; export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; - const title = searchParams.get('title') || 'Gallery'; - const subtitle = searchParams.get('subtitle') || ''; + const title = (searchParams.get('title') || 'Gallery').slice(0, 200); + const subtitle = (searchParams.get('subtitle') || '').slice(0, 100); const { theme } = getConfig(); return new ImageResponse( diff --git a/components/DevToolbar.tsx b/components/DevToolbar.tsx index 3ef4c65..92bd4dd 100644 --- a/components/DevToolbar.tsx +++ b/components/DevToolbar.tsx @@ -28,11 +28,11 @@ type Tab = 'theme' | 'grid' | 'server'; const PRESET_FONTS: Record = { studio: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' }, minimal: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' }, - editorial: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'IBM Plex Mono' }, - classic: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'EB Garamond' }, - noir: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'IBM Plex Mono' }, - monograph: { heading: 'Instrument Serif', body: 'Inter', caption: 'JetBrains Mono' }, - botanica: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Lora' }, + editorial: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' }, + classic: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' }, + noir: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Space Mono' }, + monograph: { heading: 'Instrument Serif', body: 'Inter', caption: 'IBM Plex Mono' }, + botanica: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Inconsolata' }, }; const PRESET_ACCENTS: Record = { @@ -307,7 +307,6 @@ exifOnHover: ${exifOnHover}`; {/* ── Tab Content ──────────────────────── */}
- {/* ── THEME TAB ── */} {tab === 'theme' && (
@@ -325,10 +324,7 @@ exifOnHover: ${exifOnHover}`;
- +
@@ -364,7 +360,11 @@ exifOnHover: ${exifOnHover}`;
- +
)} @@ -395,8 +395,8 @@ exifOnHover: ${exifOnHover}`; {copiedYaml === 'layout' && }

- These settings require a server re-render. Click to copy the YAML snippet, then paste into{' '} - content/settings.yaml. + These settings require a server re-render. Click to copy the YAML snippet, then + paste into content/settings.yaml.

)} @@ -416,8 +416,7 @@ exifOnHover: ${exifOnHover}`; padding: '9px 0', borderRadius: 10, border: 'none', - background: - copiedYaml === 'all' ? '#16a34a' : 'rgba(255,255,255,0.92)', + background: copiedYaml === 'all' ? '#16a34a' : 'rgba(255,255,255,0.92)', color: copiedYaml === 'all' ? '#fff' : '#111', fontWeight: 600, fontSize: 12, @@ -465,13 +464,9 @@ function ToggleButton({ on, onClick }: { on: boolean; onClick: () => void }) { fontWeight: 500, cursor: 'pointer', transition: 'all 0.12s ease', - background: on - ? '#166534' - : 'rgba(255,255,255,0.06)', + background: on ? '#166534' : 'rgba(255,255,255,0.06)', color: on ? '#86efac' : '#71717a', - border: on - ? '1px solid #166534' - : '1px solid rgba(255,255,255,0.08)', + border: on ? '1px solid #166534' : '1px solid rgba(255,255,255,0.08)', }} > {on ? '● On' : '○ Off'} @@ -506,10 +501,7 @@ function PillGrid({ onClick={() => onSelect(item)} style={{ ...pillStyle, - background: - item === active - ? 'rgba(255,255,255,0.9)' - : 'rgba(255,255,255,0.06)', + background: item === active ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.06)', color: item === active ? '#111' : '#71717a', border: item === active diff --git a/lib/config.ts b/lib/config.ts index 56e3344..1b276b2 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -136,18 +136,18 @@ interface GalleryYaml { hero?: string | string[]; albums?: string[]; subpages?: - | Record>> - | Array<{ - name: string; - albums: Array>; - password?: string; - grid?: { - columns?: number; - gap?: number; - aspectRatio?: string; - layout?: string; - }; - }>; + | Record>> + | Array<{ + name: string; + albums: Array>; + password?: string; + grid?: { + columns?: number; + gap?: number; + aspectRatio?: string; + layout?: string; + }; + }>; } /** Raw YAML structure for global settings. */ @@ -164,17 +164,17 @@ interface SettingsYaml { map?: boolean; transitions?: boolean; theme?: - | string - | { - preset?: string; - accent?: string; - fonts?: { heading?: string; body?: string; caption?: string }; - radius?: number; - photoFrame?: string; - grain?: boolean; - headerDot?: boolean; - heroStyle?: string; - }; + | string + | { + preset?: string; + accent?: string; + fonts?: { heading?: string; body?: string; caption?: string }; + radius?: number; + photoFrame?: string; + grain?: boolean; + headerDot?: boolean; + heroStyle?: string; + }; grid?: { columns?: number; gap?: number; @@ -195,7 +195,7 @@ function loadYaml(filename: string): T { if (filename === 'gallery.yaml') { throw new Error( `Required 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.`, ); } return {} as T; @@ -357,7 +357,7 @@ export function getConfig(): AppConfig { if (!env.AUTH_SECRET && process.env.NODE_ENV === 'production') { console.warn( '\n⚠️ SECURITY WARNING: AUTH_SECRET is not set. Falling back to IMMICH_API_KEY.\n' + - ' Please set a long random string as AUTH_SECRET in your .env for better security.\n', + ' Please set a long random string as AUTH_SECRET in your .env for better security.\n', ); } @@ -413,19 +413,19 @@ export function getConfig(): AppConfig { 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: (VALID_LAYOUTS.includes(sp.grid.layout) - ? sp.grid.layout - : 'masonry') as GridConfig['layout'], - } - : {}), - }, - } + 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: (VALID_LAYOUTS.includes(sp.grid.layout) + ? sp.grid.layout + : 'masonry') as GridConfig['layout'], + } + : {}), + }, + } : {}), }; }); @@ -442,28 +442,38 @@ export function getConfig(): AppConfig { } // If it's an object, it might have password, grid, albums - const sp = value as any; + interface SubpageObjectValue { + albums?: Array>; + password?: string; + grid?: { + columns?: number; + gap?: number; + aspectRatio?: string; + layout?: string; + }; + } + const sp = value as SubpageObjectValue; const albumEntries = sp.albums || []; return { name, slug: slugify(name), - albumIds: albumEntries.map((entry: any) => processAlbumEntry(entry, `subpage "${name}"`)), + albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${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: (VALID_LAYOUTS.includes(sp.grid.layout) - ? sp.grid.layout - : 'masonry') as GridConfig['layout'], - } - : {}), - }, - } + 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: (VALID_LAYOUTS.includes(sp.grid.layout) + ? sp.grid.layout + : 'masonry') as GridConfig['layout'], + } + : {}), + }, + } : {}), }; }); @@ -486,14 +496,18 @@ export function getConfig(): AppConfig { siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE, seo: { title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery', - description: settings.seo?.description || settings.subtitle || env.SITE_SUBTITLE || 'A curated photography portfolio', + description: + settings.seo?.description || + settings.subtitle || + env.SITE_SUBTITLE || + 'A curated photography portfolio', noIndex: settings.seo?.noIndex === true, noFollow: settings.seo?.noFollow === true, }, heroImages: gallery.hero ? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) => - validateUuid(id, 'gallery.yaml hero'), - ) + validateUuid(id, 'gallery.yaml hero'), + ) : [], exifOnHover: settings.exifOnHover !== false, grid: { @@ -507,11 +521,11 @@ export function getConfig(): AppConfig { theme, footer: settings.footer ? { - name: settings.footer.name, - instagram: settings.footer.instagram, - email: settings.footer.email, - website: settings.footer.website, - } + name: settings.footer.name, + instagram: settings.footer.instagram, + email: settings.footer.email, + website: settings.footer.website, + } : null, legal: { enabled: settings.legal?.enabled === true, diff --git a/lib/immich.ts b/lib/immich.ts index f8c2c04..5534f44 100644 --- a/lib/immich.ts +++ b/lib/immich.ts @@ -77,11 +77,13 @@ export interface SubpageSummary { coverAssetId: string | null; } +// Module-level flag so the album summary is only logged once even if the +// ImmichClient singleton is replaced in hot-reload scenarios. +let _hasLoggedAlbums = false; + // ── API Client ───────────────────────────────────────────────── class ImmichClient { - private hasLoggedAlbums = false; - private get config() { return getConfig(); } @@ -176,8 +178,8 @@ class ImmichClient { }); // Log album summary on first load so admins can see what's published - if (!this.hasLoggedAlbums) { - this.hasLoggedAlbums = true; + if (!_hasLoggedAlbums) { + _hasLoggedAlbums = true; console.log('\n[Lightbox] Published albums:'); console.log('─'.repeat(80)); diff --git a/lib/tokens.ts b/lib/tokens.ts index 8bbde2f..093f76e 100644 --- a/lib/tokens.ts +++ b/lib/tokens.ts @@ -24,8 +24,9 @@ function getKey(): Buffer { */ 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(); + // Deterministic IV from asset ID (same input → same token). + // SHA-256 slice → first 16 bytes. MD5 was deprecated for cryptographic use. + const iv = crypto.createHash('sha256').update(assetId).digest().subarray(0, 16); const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); const encrypted = Buffer.concat([cipher.update(assetId, 'utf8'), cipher.final()]); // Compact: base64url(iv + ciphertext) diff --git a/next.config.ts b/next.config.ts index 7b5b4f1..9bc5a64 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,7 +4,7 @@ import type { NextConfig } from 'next'; // Locks down the public-facing site to only trusted sources. const cspDirectives = [ "default-src 'self'", - "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Added unsafe-eval for some client-side libs if needed + "script-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com", "font-src 'self' https://fonts.gstatic.com", "img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://*.tile.openstreetmap.org https://unpkg.com", // Added blob: and osm