chore: production-readiness audit fixes and linting cleanup

This commit is contained in:
ralksta
2026-03-08 16:26:05 +01:00
parent 270768d330
commit ea72d720df
9 changed files with 111 additions and 105 deletions

View File

@@ -55,6 +55,9 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
useEffect(() => { useEffect(() => {
const idx = parsePhotoHash(window.location.hash); const idx = parsePhotoHash(window.location.hash);
if (idx !== null && idx < assets.length) { 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); setLightboxIndex(idx);
} }
}, [assets.length]); }, [assets.length]);
@@ -139,8 +142,9 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
{assets.map((asset, index) => ( {assets.map((asset, index) => (
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}> <FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}>
<div <div
className={`photo-grid__item${layout === 'showcase' && index === 0 ? ' photo-grid__featured' : '' className={`photo-grid__item${
}`} layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''
}`}
onClick={() => openLightbox(index)} onClick={() => openLightbox(index)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {

View File

@@ -288,11 +288,7 @@ export default async function PathPage({ params }: PathPageProps) {
{album.description && ` · ${album.description}`} {album.description && ` · ${album.description}`}
</p> </p>
</div> </div>
<PhotoGrid <PhotoGrid assets={images} layout={resolveLayout()} gridStyle={buildGridStyle()} />
assets={images}
layout={resolveLayout()}
gridStyle={buildGridStyle()}
/>
</> </>
); );
} }

View File

@@ -64,8 +64,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Asset not found' }, { status: 404 }); return NextResponse.json({ error: 'Asset not found' }, { status: 404 });
} }
console.log(`[Image API] ✅ Serving ${assetId} (${size}) - Type: ${result.contentType}`);
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': result.contentType.includes('application/octet-stream') 'Content-Type': result.contentType.includes('application/octet-stream')
? 'image/jpeg' ? 'image/jpeg'
@@ -73,7 +71,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
// Images are immutable once uploaded to Immich — cache aggressively // Images are immutable once uploaded to Immich — cache aggressively
'Cache-Control': 'public, max-age=31536000, immutable', 'Cache-Control': 'public, max-age=31536000, immutable',
'X-RateLimit-Remaining': String(remaining), 'X-RateLimit-Remaining': String(remaining),
'Access-Control-Allow-Origin': '*', // Allow cross-origin for lightbox
}; };
if (result.contentLength) { if (result.contentLength) {

View File

@@ -12,8 +12,8 @@ import { getConfig } from '@/lib/config';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl; const { searchParams } = request.nextUrl;
const title = searchParams.get('title') || 'Gallery'; const title = (searchParams.get('title') || 'Gallery').slice(0, 200);
const subtitle = searchParams.get('subtitle') || ''; const subtitle = (searchParams.get('subtitle') || '').slice(0, 100);
const { theme } = getConfig(); const { theme } = getConfig();
return new ImageResponse( return new ImageResponse(

View File

@@ -28,11 +28,11 @@ type Tab = 'theme' | 'grid' | 'server';
const PRESET_FONTS: Record<string, { heading: string; body: string; caption: string }> = { const PRESET_FONTS: Record<string, { heading: string; body: string; caption: string }> = {
studio: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' }, studio: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
minimal: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' }, minimal: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' },
editorial: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'IBM Plex Mono' }, editorial: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' },
classic: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'EB Garamond' }, classic: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' },
noir: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'IBM Plex Mono' }, noir: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Space Mono' },
monograph: { heading: 'Instrument Serif', body: 'Inter', caption: 'JetBrains Mono' }, monograph: { heading: 'Instrument Serif', body: 'Inter', caption: 'IBM Plex Mono' },
botanica: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Lora' }, botanica: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Inconsolata' },
}; };
const PRESET_ACCENTS: Record<string, string> = { const PRESET_ACCENTS: Record<string, string> = {
@@ -307,7 +307,6 @@ exifOnHover: ${exifOnHover}`;
{/* ── Tab Content ──────────────────────── */} {/* ── Tab Content ──────────────────────── */}
<div style={{ padding: '12px 16px' }}> <div style={{ padding: '12px 16px' }}>
{/* ── THEME TAB ── */} {/* ── THEME TAB ── */}
{tab === 'theme' && ( {tab === 'theme' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
@@ -325,10 +324,7 @@ exifOnHover: ${exifOnHover}`;
</div> </div>
<div> <div>
<Label>Film Grain</Label> <Label>Film Grain</Label>
<ToggleButton <ToggleButton on={grain === 'true'} onClick={toggleGrain} />
on={grain === 'true'}
onClick={toggleGrain}
/>
</div> </div>
<div> <div>
<Label>EXIF on Hover</Label> <Label>EXIF on Hover</Label>
@@ -364,7 +360,11 @@ exifOnHover: ${exifOnHover}`;
</div> </div>
<div> <div>
<Label>Aspect Ratio</Label> <Label>Aspect Ratio</Label>
<PillGrid items={ASPECT_RATIOS} active={aspectRatio} onSelect={updateAspectRatio} /> <PillGrid
items={ASPECT_RATIOS}
active={aspectRatio}
onSelect={updateAspectRatio}
/>
</div> </div>
</div> </div>
)} )}
@@ -395,8 +395,8 @@ exifOnHover: ${exifOnHover}`;
{copiedYaml === 'layout' && <CopyBadge />} {copiedYaml === 'layout' && <CopyBadge />}
</div> </div>
<p style={{ color: '#52525b', fontSize: 10, margin: 0, lineHeight: 1.5 }}> <p style={{ color: '#52525b', fontSize: 10, margin: 0, lineHeight: 1.5 }}>
These settings require a server re-render. Click to copy the YAML snippet, then paste into{' '} These settings require a server re-render. Click to copy the YAML snippet, then
<code style={{ color: '#a1a1aa' }}>content/settings.yaml</code>. paste into <code style={{ color: '#a1a1aa' }}>content/settings.yaml</code>.
</p> </p>
</div> </div>
)} )}
@@ -416,8 +416,7 @@ exifOnHover: ${exifOnHover}`;
padding: '9px 0', padding: '9px 0',
borderRadius: 10, borderRadius: 10,
border: 'none', border: 'none',
background: background: copiedYaml === 'all' ? '#16a34a' : 'rgba(255,255,255,0.92)',
copiedYaml === 'all' ? '#16a34a' : 'rgba(255,255,255,0.92)',
color: copiedYaml === 'all' ? '#fff' : '#111', color: copiedYaml === 'all' ? '#fff' : '#111',
fontWeight: 600, fontWeight: 600,
fontSize: 12, fontSize: 12,
@@ -465,13 +464,9 @@ function ToggleButton({ on, onClick }: { on: boolean; onClick: () => void }) {
fontWeight: 500, fontWeight: 500,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.12s ease', transition: 'all 0.12s ease',
background: on background: on ? '#166534' : 'rgba(255,255,255,0.06)',
? '#166534'
: 'rgba(255,255,255,0.06)',
color: on ? '#86efac' : '#71717a', color: on ? '#86efac' : '#71717a',
border: on border: on ? '1px solid #166534' : '1px solid rgba(255,255,255,0.08)',
? '1px solid #166534'
: '1px solid rgba(255,255,255,0.08)',
}} }}
> >
{on ? '● On' : '○ Off'} {on ? '● On' : '○ Off'}
@@ -506,10 +501,7 @@ function PillGrid({
onClick={() => onSelect(item)} onClick={() => onSelect(item)}
style={{ style={{
...pillStyle, ...pillStyle,
background: background: item === active ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.06)',
item === active
? 'rgba(255,255,255,0.9)'
: 'rgba(255,255,255,0.06)',
color: item === active ? '#111' : '#71717a', color: item === active ? '#111' : '#71717a',
border: border:
item === active item === active

View File

@@ -136,18 +136,18 @@ interface GalleryYaml {
hero?: string | string[]; hero?: string | string[];
albums?: string[]; albums?: string[];
subpages?: subpages?:
| Record<string, string[] | Array<string | Record<string, string>>> | Record<string, string[] | Array<string | Record<string, string>>>
| Array<{ | Array<{
name: string; name: string;
albums: Array<string | Record<string, string>>; albums: Array<string | Record<string, string>>;
password?: string; password?: string;
grid?: { grid?: {
columns?: number; columns?: number;
gap?: number; gap?: number;
aspectRatio?: string; aspectRatio?: string;
layout?: string; layout?: string;
}; };
}>; }>;
} }
/** Raw YAML structure for global settings. */ /** Raw YAML structure for global settings. */
@@ -164,17 +164,17 @@ interface SettingsYaml {
map?: boolean; map?: boolean;
transitions?: boolean; transitions?: boolean;
theme?: theme?:
| string | string
| { | {
preset?: string; preset?: string;
accent?: string; accent?: string;
fonts?: { heading?: string; body?: string; caption?: string }; fonts?: { heading?: string; body?: string; caption?: string };
radius?: number; radius?: number;
photoFrame?: string; photoFrame?: string;
grain?: boolean; grain?: boolean;
headerDot?: boolean; headerDot?: boolean;
heroStyle?: string; heroStyle?: string;
}; };
grid?: { grid?: {
columns?: number; columns?: number;
gap?: number; gap?: number;
@@ -195,7 +195,7 @@ function loadYaml<T>(filename: string): T {
if (filename === 'gallery.yaml') { if (filename === 'gallery.yaml') {
throw new Error( throw new Error(
`Required config not found: ${yamlPath}\n` + `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; return {} as T;
@@ -357,7 +357,7 @@ export function getConfig(): AppConfig {
if (!env.AUTH_SECRET && process.env.NODE_ENV === 'production') { if (!env.AUTH_SECRET && process.env.NODE_ENV === 'production') {
console.warn( console.warn(
'\n⚠️ SECURITY WARNING: AUTH_SECRET is not set. Falling back to IMMICH_API_KEY.\n' + '\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, password: sp.password,
...(sp.grid ...(sp.grid
? { ? {
grid: { grid: {
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}), ...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}), ...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}), ...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
...(sp.grid.layout != null ...(sp.grid.layout != null
? { ? {
layout: (VALID_LAYOUTS.includes(sp.grid.layout) layout: (VALID_LAYOUTS.includes(sp.grid.layout)
? sp.grid.layout ? sp.grid.layout
: 'masonry') as GridConfig['layout'], : 'masonry') as GridConfig['layout'],
} }
: {}), : {}),
}, },
} }
: {}), : {}),
}; };
}); });
@@ -442,28 +442,38 @@ export function getConfig(): AppConfig {
} }
// If it's an object, it might have password, grid, albums // If it's an object, it might have password, grid, albums
const sp = value as any; interface SubpageObjectValue {
albums?: Array<string | Record<string, string>>;
password?: string;
grid?: {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
};
}
const sp = value as SubpageObjectValue;
const albumEntries = sp.albums || []; const albumEntries = sp.albums || [];
return { return {
name, name,
slug: slugify(name), slug: slugify(name),
albumIds: albumEntries.map((entry: any) => processAlbumEntry(entry, `subpage "${name}"`)), albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
password: sp.password, password: sp.password,
...(sp.grid ...(sp.grid
? { ? {
grid: { grid: {
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}), ...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}), ...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}), ...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
...(sp.grid.layout != null ...(sp.grid.layout != null
? { ? {
layout: (VALID_LAYOUTS.includes(sp.grid.layout) layout: (VALID_LAYOUTS.includes(sp.grid.layout)
? sp.grid.layout ? sp.grid.layout
: 'masonry') as GridConfig['layout'], : 'masonry') as GridConfig['layout'],
} }
: {}), : {}),
}, },
} }
: {}), : {}),
}; };
}); });
@@ -486,14 +496,18 @@ export function getConfig(): AppConfig {
siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE, siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
seo: { seo: {
title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery', 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, noIndex: settings.seo?.noIndex === true,
noFollow: settings.seo?.noFollow === true, noFollow: settings.seo?.noFollow === true,
}, },
heroImages: gallery.hero heroImages: gallery.hero
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) => ? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
validateUuid(id, 'gallery.yaml hero'), validateUuid(id, 'gallery.yaml hero'),
) )
: [], : [],
exifOnHover: settings.exifOnHover !== false, exifOnHover: settings.exifOnHover !== false,
grid: { grid: {
@@ -507,11 +521,11 @@ export function getConfig(): AppConfig {
theme, theme,
footer: settings.footer footer: settings.footer
? { ? {
name: settings.footer.name, name: settings.footer.name,
instagram: settings.footer.instagram, instagram: settings.footer.instagram,
email: settings.footer.email, email: settings.footer.email,
website: settings.footer.website, website: settings.footer.website,
} }
: null, : null,
legal: { legal: {
enabled: settings.legal?.enabled === true, enabled: settings.legal?.enabled === true,

View File

@@ -77,11 +77,13 @@ export interface SubpageSummary {
coverAssetId: string | null; 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 ───────────────────────────────────────────────── // ── API Client ─────────────────────────────────────────────────
class ImmichClient { class ImmichClient {
private hasLoggedAlbums = false;
private get config() { private get config() {
return getConfig(); return getConfig();
} }
@@ -176,8 +178,8 @@ class ImmichClient {
}); });
// Log album summary on first load so admins can see what's published // Log album summary on first load so admins can see what's published
if (!this.hasLoggedAlbums) { if (!_hasLoggedAlbums) {
this.hasLoggedAlbums = true; _hasLoggedAlbums = true;
console.log('\n[Lightbox] Published albums:'); console.log('\n[Lightbox] Published albums:');
console.log('─'.repeat(80)); console.log('─'.repeat(80));

View File

@@ -24,8 +24,9 @@ function getKey(): Buffer {
*/ */
export function encodeAssetId(assetId: string): string { export function encodeAssetId(assetId: string): string {
const key = getKey(); const key = getKey();
// Deterministic IV from asset ID (same input → same token) // Deterministic IV from asset ID (same input → same token).
const iv = crypto.createHash('md5').update(assetId).digest(); // 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 cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
const encrypted = Buffer.concat([cipher.update(assetId, 'utf8'), cipher.final()]); const encrypted = Buffer.concat([cipher.update(assetId, 'utf8'), cipher.final()]);
// Compact: base64url(iv + ciphertext) // Compact: base64url(iv + ciphertext)

View File

@@ -4,7 +4,7 @@ import type { NextConfig } from 'next';
// Locks down the public-facing site to only trusted sources. // Locks down the public-facing site to only trusted sources.
const cspDirectives = [ const cspDirectives = [
"default-src 'self'", "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", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com",
"font-src 'self' https://fonts.gstatic.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 "img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://*.tile.openstreetmap.org https://unpkg.com", // Added blob: and osm