2026-04-24 22:14:53 +02:00
import crypto from 'crypto' ;
2026-03-08 18:55:45 +01:00
import { env } from '../env' ;
import { loadYaml , validateUuid } from './parser' ;
import { resolveTheme , VALID_LAYOUTS } from './theme' ;
2026-03-12 11:30:45 +00:00
import {
slugify ,
AppConfig ,
SubpageConfig ,
SubpageSectionConfig ,
2026-03-15 07:32:42 +01:00
SubpageObjectValue ,
2026-03-12 11:30:45 +00:00
GalleryYaml ,
SettingsYaml ,
GridConfig ,
} from './schema' ;
2026-03-08 18:55:45 +01:00
export * from './schema' ;
export * from './theme' ;
let _config : AppConfig | null = null ;
2026-04-24 22:14:53 +02:00
let _fallbackSecret : string | null = null ;
2026-03-13 11:56:40 +01:00
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
2026-03-15 07:32:42 +01:00
export function buildSubpageGrid ( raw ? : {
columns? : number ;
gap? : number ;
aspectRatio? : string ;
layout? : string ;
} ) : { grid : Partial < GridConfig > } | Record < string , never > {
2026-03-13 11:56:40 +01:00
if ( ! raw ) return { } ;
return {
grid : {
. . . ( raw . columns != null ? { columns : raw.columns } : { } ) ,
. . . ( raw . gap != null ? { gap : raw.gap } : { } ) ,
. . . ( raw . aspectRatio != null ? { aspectRatio : raw.aspectRatio } : { } ) ,
. . . ( raw . layout != null
? {
layout : ( VALID_LAYOUTS . includes ( raw . layout )
? raw . layout
: 'masonry' ) as GridConfig [ 'layout' ] ,
}
: { } ) ,
} ,
} ;
}
2026-03-08 18:55:45 +01:00
export function getConfig ( ) : AppConfig {
2026-03-12 11:30:45 +00:00
const isDev = process . env . NODE_ENV !== 'production' ;
if ( _config && ! isDev ) return _config ;
2026-03-08 18:55:45 +01:00
2026-03-12 11:30:45 +00:00
const apiUrl = env . IMMICH_API_URL ;
const apiKey = env . IMMICH_API_KEY ;
2026-03-15 07:32:42 +01:00
let { AUTH_SECRET } = env ;
if ( ! AUTH_SECRET ) {
if ( process . env . NODE_ENV === 'production' ) {
throw new Error (
'SECURITY ERROR: AUTH_SECRET is not set in production. Please set a long random string as AUTH_SECRET in your .env.' ,
) ;
}
2026-04-24 22:14:53 +02:00
if ( ! _fallbackSecret ) {
_fallbackSecret = crypto . randomBytes ( 32 ) . toString ( 'hex' ) ;
}
2026-03-12 11:30:45 +00:00
console . warn (
2026-04-24 22:14:53 +02:00
'\n⚠️ SECURITY WARNING: AUTH_SECRET is not set. Generating a temporary random secret for this session.\n Please set a long random string as AUTH_SECRET in your .env for better security.\n' ,
2026-03-12 11:30:45 +00:00
) ;
2026-04-24 22:14:53 +02:00
AUTH_SECRET = _fallbackSecret ;
2026-03-12 11:30:45 +00:00
}
2026-03-15 07:32:42 +01:00
const authSecret = AUTH_SECRET ;
2026-03-08 18:55:45 +01:00
2026-03-12 11:30:45 +00:00
const gallery = loadYaml < GalleryYaml > ( 'gallery.yaml' ) ;
const settings = loadYaml < SettingsYaml > ( 'settings.yaml' ) || { } ;
2026-03-08 21:04:41 +01:00
2026-03-12 11:30:45 +00:00
if ( ! gallery || ! apiKey || ! apiUrl ) {
// Return dummy config if gallery.yaml is missing
_config = {
immich : { apiUrl : ` ${ apiUrl } /api ` , apiKey } ,
authSecret ,
albums : [ ] ,
standaloneAlbums : [ ] ,
subpages : [ ] ,
siteTitle : env.SITE_TITLE || 'Immich Folio' ,
siteSubtitle : env.SITE_SUBTITLE || 'Setup Required' ,
2026-03-15 07:32:42 +01:00
lang : 'en' ,
2026-03-12 11:30:45 +00:00
seo : {
title : 'Setup Required' ,
description : 'Please configure Immich Folio' ,
noIndex : true ,
noFollow : true ,
} ,
heroImages : [ ] ,
exifOnHover : true ,
grid : { columns : 3 , gap : 12 , aspectRatio : '1' , layout : 'masonry' } ,
theme : resolveTheme ( 'studio' ) ,
footer : null ,
legal : { enabled : false , name : '' , address : '' , zipCity : '' , country : '' } ,
map : false ,
transitions : false ,
albumOverrides : { } ,
2026-04-07 23:04:03 +02:00
albumPasswords : { } ,
2026-03-12 11:30:45 +00:00
cacheTtl : env.CACHE_TTL * 1000 ,
rateLimitRpm : env.RATE_LIMIT_RPM ,
2026-04-24 22:14:53 +02:00
trustedProxies : env.TRUSTED_PROXIES ,
2026-03-12 11:30:45 +00:00
needsSetup : true ,
} ;
return _config ;
}
2026-03-08 18:55:45 +01:00
2026-03-12 11:30:45 +00:00
const theme = resolveTheme ( settings . theme ) ;
2026-03-08 18:55:45 +01:00
2026-03-12 11:30:45 +00:00
const albumOverrides : Record < string , string > = { } ;
2026-04-07 23:04:03 +02:00
const albumPasswords : Record < string , string > = { } ;
2026-03-08 18:55:45 +01:00
2026-04-07 23:04:03 +02:00
function processAlbumEntry (
entry : string | Record < string , string | { title : string ; password ? : string } > ,
context : string ,
) : string {
2026-03-12 11:30:45 +00:00
if ( typeof entry === 'string' ) {
return validateUuid ( entry , context ) ;
2026-03-08 18:55:45 +01:00
}
2026-04-07 23:04:03 +02:00
const [ uuid , value ] = Object . entries ( entry ) [ 0 ] ;
2026-03-12 11:30:45 +00:00
const validatedUuid = validateUuid ( uuid , context ) ;
2026-04-07 23:04:03 +02:00
if ( typeof value === 'string' ) {
albumOverrides [ validatedUuid ] = value ;
} else {
if ( value . title ) albumOverrides [ validatedUuid ] = value . title ;
if ( value . password ) albumPasswords [ validatedUuid ] = value . password ;
}
2026-03-12 11:30:45 +00:00
return validatedUuid ;
}
2026-03-08 18:55:45 +01:00
2026-03-12 11:30:45 +00:00
const standaloneAlbumIds = ( gallery . albums ? ? [ ] ) . map ( ( entry ) = >
processAlbumEntry ( entry , 'gallery.yaml albums' ) ,
) ;
if (
standaloneAlbumIds . length === 0 &&
( ! gallery . subpages ||
( Array . isArray ( gallery . subpages )
? gallery . subpages . length === 0
: Object . keys ( gallery . subpages ) . length === 0 ) )
) {
throw new Error ( 'gallery.yaml must define at least one album or subpage' ) ;
}
2026-03-08 18:55:45 +01:00
2026-03-12 11:30:45 +00:00
let subpages : SubpageConfig [ ] = [ ] ;
2026-03-08 23:52:23 +01:00
2026-03-12 11:30:45 +00:00
if ( Array . isArray ( gallery . subpages ) ) {
subpages = gallery . subpages . map ( ( sp ) = > {
if ( ! sp . name ) {
throw new Error ( ` Subpage "(unnamed)" must have a name ` ) ;
}
2026-03-08 23:52:23 +01:00
2026-03-12 11:30:45 +00:00
// Parse sections if present
let sections : SubpageSectionConfig [ ] | undefined ;
let albumIds : string [ ] ;
if ( sp . sections && sp . sections . length > 0 ) {
sections = sp . sections . map ( ( sec ) = > ( {
title : sec.title ,
slug : slugify ( sec . title ) ,
description : sec.description ,
albumIds : sec.albums.map ( ( entry ) = >
processAlbumEntry ( entry , ` subpage " ${ sp . name } " section " ${ sec . title } " ` ) ,
) ,
} ) ) ;
// flat union of all section albums
albumIds = sections . flatMap ( ( s ) = > s . albumIds ) ;
// Also include any top-level albums (outside sections)
const topLevel = ( sp . albums ? ? [ ] ) . map ( ( entry ) = >
processAlbumEntry ( entry , ` subpage " ${ sp . name } " ` ) ,
) ;
albumIds = [ . . . topLevel , . . . albumIds ] ;
} else {
const albums = sp . albums ? ? [ ] ;
if ( albums . length === 0 ) {
throw new Error ( ` Subpage " ${ sp . name } " must have albums or sections ` ) ;
}
albumIds = albums . map ( ( entry ) = > processAlbumEntry ( entry , ` subpage " ${ sp . name } " ` ) ) ;
}
return {
name : sp.name ,
slug : slugify ( sp . name ) ,
title : sp.title ,
subtitle : sp.subtitle ,
albumIds ,
sections ,
password : sp.password ,
2026-03-13 11:56:40 +01:00
. . . buildSubpageGrid ( sp . grid ) ,
2026-03-12 11:30:45 +00:00
} ;
} ) ;
} else if ( gallery . subpages ) {
subpages = Object . entries ( gallery . subpages ) . map ( ( [ name , value ] ) = > {
if ( Array . isArray ( value ) ) {
return {
name ,
slug : slugify ( name ) ,
albumIds : value.map ( ( entry ) = > processAlbumEntry ( entry , ` subpage " ${ name } " ` ) ) ,
} ;
}
const sp = value as SubpageObjectValue ;
const albumEntries = sp . albums || [ ] ;
return {
name ,
slug : slugify ( name ) ,
title : sp.title ,
subtitle : sp.subtitle ,
albumIds : albumEntries.map ( ( entry ) = > processAlbumEntry ( entry , ` subpage " ${ name } " ` ) ) ,
password : sp.password ,
2026-03-13 11:56:40 +01:00
. . . buildSubpageGrid ( sp . grid ) ,
2026-03-12 11:30:45 +00:00
} ;
} ) ;
}
2026-03-08 18:55:45 +01:00
2026-03-12 11:30:45 +00:00
const subpageAlbumIds = new Set ( subpages . flatMap ( ( sp ) = > sp . albumIds ) ) ;
const allAlbumIds = [ . . . new Set ( [ . . . standaloneAlbumIds , . . . subpageAlbumIds ] ) ] ;
2026-03-08 18:55:45 +01:00
2026-03-12 11:30:45 +00:00
_config = {
immich : { apiUrl : ` ${ apiUrl } /api ` , apiKey } ,
authSecret ,
albums : allAlbumIds ,
standaloneAlbums : standaloneAlbumIds.filter ( ( id ) = > ! subpageAlbumIds . has ( id ) ) ,
subpages ,
siteTitle : settings.title ? ? env . SITE_TITLE ,
siteSubtitle : settings.subtitle ? ? env . SITE_SUBTITLE ,
2026-03-15 07:32:42 +01:00
lang : settings.lang ? ? 'en' ,
2026-03-12 11:30:45 +00:00
seo : {
title : settings.seo?.title || settings . title || env . SITE_TITLE || 'Gallery' ,
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' ) ,
)
: [ ] ,
exifOnHover : settings.exifOnHover !== false ,
grid : {
columns : settings.grid?.columns ? ? 3 ,
gap : settings.grid?.gap ? ? 12 ,
aspectRatio : settings.grid?.aspectRatio ? ? '1' ,
layout : ( VALID_LAYOUTS . includes ( settings . grid ? . layout ? ? '' )
? settings . grid ! . layout
: 'masonry' ) as GridConfig [ 'layout' ] ,
} ,
theme ,
footer : settings.footer
? {
name : settings.footer.name ,
instagram : settings.footer.instagram ,
email : settings.footer.email ,
website : settings.footer.website ,
}
: null ,
legal : {
enabled : settings.legal?.enabled === true ,
name : settings.legal?.name || '' ,
address : settings.legal?.address || '' ,
zipCity : settings.legal?.zipCity || '' ,
country : settings.legal?.country || '' ,
email : settings.legal?.email ,
phone : settings.legal?.phone ,
taxId : settings.legal?.taxId ,
vatId : settings.legal?.vatId ,
extraInfo : settings.legal?.extraInfo ,
} ,
map : settings.map === true ,
transitions : settings.transitions !== false ,
albumOverrides ,
2026-04-07 23:04:03 +02:00
albumPasswords ,
2026-03-12 11:30:45 +00:00
cacheTtl : env.CACHE_TTL * 1000 ,
rateLimitRpm : env.RATE_LIMIT_RPM ,
2026-04-24 22:14:53 +02:00
trustedProxies : env.TRUSTED_PROXIES ,
2026-03-12 11:30:45 +00:00
needsSetup : false ,
} ;
return _config ;
2026-03-08 18:55:45 +01:00
}