diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index b0c33cdc5..44caf186d 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -1,4 +1,5 @@ import { ADDON_IDS } from '../addons'; +import { readEnv } from '../app-config'; import { updateJwtSecret } from '../config'; import { db } from '../db/database'; import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp'; @@ -349,7 +350,7 @@ export function updateOidcSettings(data: { // ── Demo Baseline ────────────────────────────────────────────────────────── export function saveDemoBaseline(): { error?: string; status?: number; message?: string } { - if (process.env.DEMO_MODE?.toLowerCase() !== 'true') { + if (!readEnv().demo.enabled) { return { error: 'Not found', status: 404 }; } try { @@ -399,7 +400,7 @@ export async function checkVersion(): Promise { return _versionCache.data; } - const currentVersion: string = process.env.APP_VERSION || require('../../package.json').version; + const currentVersion: string = readEnv().app.appVersion || require('../../package.json').version; const isPrerelease = currentVersion.includes('-pre.'); const fallback: VersionInfo = { current: currentVersion, diff --git a/server/src/services/auditLog.ts b/server/src/services/auditLog.ts index 7636618b5..e8d5e09e9 100644 --- a/server/src/services/auditLog.ts +++ b/server/src/services/auditLog.ts @@ -1,9 +1,11 @@ import { Request } from 'express'; +import { readEnv } from '../app-config'; import { db } from '../db/database'; import fs from 'fs'; import path from 'path'; -const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase(); +// Frozen at import on purpose (legacy timing; tests/setup.ts sets it pre-import). +const LOG_LEVEL = (readEnv().app.logLevel || 'info').toLowerCase(); const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB const MAX_LOG_FILES = 5; @@ -45,7 +47,7 @@ function writeToFile(line: string): void { // ── Public log helpers ──────────────────────────────────────────────────── function formatTs(): string { - const tz = process.env.TZ || 'UTC'; + const tz = readEnv().app.tz || 'UTC'; return new Date().toLocaleString('sv-SE', { timeZone: tz }).replace(' ', 'T'); } diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 500ab559d..1c9e59f19 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -1,6 +1,7 @@ import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; +import { readEnv } from '../app-config'; import path from 'path'; import fs from 'fs'; import { authenticator } from 'otplib'; @@ -155,7 +156,7 @@ export function resolveAuthToggles(): { oidc_registration: get('oidc_registration') !== 'false', passkey_login, }; - if (process.env.OIDC_ONLY?.toLowerCase() === 'true') { + if (readEnv().oidc.only) { result.password_login = false; result.password_registration = false; } @@ -163,10 +164,10 @@ export function resolveAuthToggles(): { } // Legacy fallback - const oidcOnlyEnabled = process.env.OIDC_ONLY?.toLowerCase() === 'true' || get('oidc_only') === 'true'; + const oidcOnlyEnabled = readEnv().oidc.only || get('oidc_only') === 'true'; const oidcConfigured = !!( - (process.env.OIDC_ISSUER || get('oidc_issuer')) && - (process.env.OIDC_CLIENT_ID || get('oidc_client_id')) + (readEnv().oidc.issuer || get('oidc_issuer')) && + (readEnv().oidc.clientId || get('oidc_client_id')) ); const oidcOnly = oidcOnlyEnabled && oidcConfigured; const allowReg = (get('allow_registration') ?? 'true') === 'true'; @@ -281,20 +282,20 @@ export function getPendingMfaSecret(userId: number): string | null { export function getAppConfig(authenticatedUser: { id: number } | null) { const userCount = (db.prepare('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0').get() as { count: number }).count; - const isDemo = process.env.DEMO_MODE?.toLowerCase() === 'true'; + const isDemo = readEnv().demo.enabled; const toggles = resolveAuthToggles(); - const version: string = process.env.APP_VERSION ?? require('../../package.json').version; + const version: string = readEnv().app.appVersion ?? require('../../package.json').version; const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get(); - const oidcDisplayName = process.env.OIDC_DISPLAY_NAME || + const oidcDisplayName = readEnv().oidc.displayName || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get() as { value: string } | undefined)?.value || null; const oidcConfigured = !!( - (process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) && - (process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value) + (readEnv().oidc.issuer || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) && + (readEnv().oidc.clientId || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value) ); const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined; const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none'; const tripReminderSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'").get() as { value: string } | undefined)?.value; - const hasSmtpHost = !!(process.env.SMTP_HOST || (db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_host'").get() as { value: string } | undefined)?.value); + const hasSmtpHost = !!(readEnv().smtp.host || (db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_host'").get() as { value: string } | undefined)?.value); const notifChannelsRaw = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channels'").get() as { value: string } | undefined)?.value || notifChannel; const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean); const hasWebhookEnabled = activeChannels.includes('webhook'); @@ -322,7 +323,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) { // resolved RP ID / origin / APP_URL on this unauthenticated endpoint. passkey_login: toggles.passkey_login, passkey_configured: isPasskeyConfigured(), - env_override_oidc_only: process.env.OIDC_ONLY === 'true', + env_override_oidc_only: readEnv().oidc.only, has_users: userCount > 0, setup_complete: setupComplete, version, @@ -335,7 +336,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) { demo_mode: isDemo, demo_email: isDemo ? DEMO_EMAIL_PRIMARY : undefined, demo_password: isDemo ? 'demo12345' : undefined, - timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + timezone: readEnv().app.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', notification_channel: notifChannel, notification_channels: activeChannels, available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true }, @@ -344,7 +345,8 @@ export function getAppConfig(authenticatedUser: { id: number } | null) { places_autocomplete_enabled: placesAutocompleteEnabled, places_details_enabled: placesDetailsEnabled, permissions: authenticatedUser ? getAllPermissions() : undefined, - dev_mode: process.env.NODE_ENV === 'development', + // Case-sensitive on purpose (legacy parity). + dev_mode: readEnv().app.nodeEnv === 'development', }; } @@ -353,7 +355,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) { // --------------------------------------------------------------------------- export function demoLogin(): { error?: string; status?: number; token?: string; user?: Record } { - if (process.env.DEMO_MODE !== 'true') { + if (!readEnv().demo.enabled) { return { error: 'Not found', status: 404 }; } const user = db.prepare('SELECT * FROM users WHERE email = ?').get(DEMO_EMAIL_PRIMARY) as User | undefined; @@ -422,7 +424,7 @@ export function registerUser(body: { try { const result = db.prepare( 'INSERT INTO users (username, email, password_hash, role, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, 0)' - ).run(username, email, password_hash, role, process.env.APP_VERSION || '0.0.0'); + ).run(username, email, password_hash, role, readEnv().app.appVersion || '0.0.0'); const user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false }; const token = generateToken(user); @@ -557,7 +559,7 @@ export function changePassword( if (isOidcOnlyMode()) { return { error: 'Password authentication is disabled.', status: 403 }; } - if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) { + if (readEnv().demo.enabled && isDemoEmail(userEmail)) { return { error: 'Password change is disabled in demo mode.', status: 403 }; } @@ -597,7 +599,7 @@ export function changePassword( } export function deleteAccount(userId: number, userEmail: string, userRole: string): { error?: string; status?: number; success?: boolean } { - if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) { + if (readEnv().demo.enabled && isDemoEmail(userEmail)) { return { error: 'Account deletion is disabled in demo mode.', status: 403 }; } if (userRole === 'admin') { @@ -890,8 +892,8 @@ export function updateAppSettings( if (body.password_login !== undefined || body.oidc_login !== undefined) { const current = resolveAuthToggles(); const oidcConfigured = !!( - (process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) && - (process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value) + (readEnv().oidc.issuer || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) && + (readEnv().oidc.clientId || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value) ); const nextPasswordLogin = body.password_login !== undefined ? (String(body.password_login) === 'true') : current.password_login; const nextOidcLogin = body.oidc_login !== undefined ? (String(body.oidc_login) === 'true') : current.oidc_login; @@ -1036,7 +1038,7 @@ export function getTravelStats(userId: number) { // --------------------------------------------------------------------------- export function setupMfa(userId: number, userEmail: string): { error?: string; status?: number; secret?: string; otpauth_url?: string; qrPromise?: Promise } { - if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) { + if (readEnv().demo.enabled && isDemoEmail(userEmail)) { return { error: 'MFA is not available in demo mode.', status: 403 }; } const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as { mfa_enabled: number } | undefined; @@ -1085,7 +1087,7 @@ export function disableMfa( userEmail: string, body: { password?: string; code?: string } ): { error?: string; status?: number; success?: boolean; mfa_enabled?: boolean } { - if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) { + if (readEnv().demo.enabled && isDemoEmail(userEmail)) { return { error: 'MFA cannot be changed in demo mode.', status: 403 }; } const policy = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined; @@ -1458,7 +1460,7 @@ export function createResourceToken(userId: number, purpose?: string): { error?: // --------------------------------------------------------------------------- export function isDemoUser(userId: number): boolean { - if (process.env.DEMO_MODE !== 'true') return false; + if (!readEnv().demo.enabled) return false; const user = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined; return isDemoEmail(user?.email); } diff --git a/server/src/services/backupService.ts b/server/src/services/backupService.ts index 639a8a6bf..e94791e50 100644 --- a/server/src/services/backupService.ts +++ b/server/src/services/backupService.ts @@ -1,6 +1,7 @@ import archiver from 'archiver'; import unzipper from 'unzipper'; import path from 'path'; +import { readEnv } from '../app-config'; import fs from 'fs'; import Database from 'better-sqlite3'; import { db, closeDb, reinitialize } from '../db/database'; @@ -20,36 +21,16 @@ const uploadsDir = path.join(__dirname, '../../uploads'); // Compressed upload cap for restore archives. Defaults to 500 MB, raisable via // BACKUP_UPLOAD_LIMIT_MB for instances whose backups (uploads/ included) grow -// past that. Invalid values warn and fall back to the default. -const DEFAULT_BACKUP_UPLOAD_LIMIT_MB = 500; -const rawBackupUploadLimit = process.env.BACKUP_UPLOAD_LIMIT_MB?.trim(); -let backupUploadLimitMb = DEFAULT_BACKUP_UPLOAD_LIMIT_MB; -if (rawBackupUploadLimit) { - const parsed = Number(rawBackupUploadLimit); - if (Number.isFinite(parsed) && parsed > 0) { - backupUploadLimitMb = parsed; - } else { - console.warn(`BACKUP_UPLOAD_LIMIT_MB="${rawBackupUploadLimit}" is not a positive number. Falling back to ${DEFAULT_BACKUP_UPLOAD_LIMIT_MB} MB.`); - } -} -export const MAX_BACKUP_UPLOAD_SIZE = backupUploadLimitMb * 1024 * 1024; // compressed +// past that. Malformed values abort boot (app-config fail-fast validation); +// frozen at import on purpose (legacy timing). +const backupEnv = readEnv().backup; +export const MAX_BACKUP_UPLOAD_SIZE = backupEnv.uploadLimitMb * 1024 * 1024; // compressed // Upper bound on the TOTAL decompressed size of a restore archive (the upload // limit only caps the compressed bytes). Default 5 GB, raisable via // BACKUP_MAX_DECOMPRESSED_MB for an instance whose own backups (now including the // plugin trees) legitimately grow past it — otherwise its own backups become -// unrestorable. Invalid values warn and fall back to the default. -const DEFAULT_BACKUP_DECOMPRESSED_MB = 5 * 1024; -const rawDecompressedLimit = process.env.BACKUP_MAX_DECOMPRESSED_MB?.trim(); -let backupDecompressedMb = DEFAULT_BACKUP_DECOMPRESSED_MB; -if (rawDecompressedLimit) { - const parsed = Number(rawDecompressedLimit); - if (Number.isFinite(parsed) && parsed > 0) { - backupDecompressedMb = parsed; - } else { - console.warn(`BACKUP_MAX_DECOMPRESSED_MB="${rawDecompressedLimit}" is not a positive number. Falling back to ${DEFAULT_BACKUP_DECOMPRESSED_MB} MB.`); - } -} -export const MAX_BACKUP_DECOMPRESSED_SIZE = backupDecompressedMb * 1024 * 1024; +// unrestorable. +export const MAX_BACKUP_DECOMPRESSED_SIZE = backupEnv.maxDecompressedMb * 1024 * 1024; // --------------------------------------------------------------------------- // Helpers @@ -209,7 +190,7 @@ export async function createBackup(): Promise { // store/transfer it securely. Skipped when ENCRYPTION_KEY is provided via // env, since in that case the file is not the source of truth. const encKeyPath = path.join(dataDir, '.encryption_key'); - if (!process.env.ENCRYPTION_KEY && fs.existsSync(encKeyPath)) { + if (!readEnv().backup.encryptionKeyFromEnv && fs.existsSync(encKeyPath)) { archive.file(encKeyPath, { name: '.encryption_key' }); } @@ -491,7 +472,7 @@ export async function restoreFromZip(zipPath: string): Promise { // --------------------------------------------------------------------------- export function getAutoSettings(): { settings: ReturnType; timezone: string } { - const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + const tz = readEnv().app.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; return { settings: scheduler.loadSettings(), timezone: tz }; } diff --git a/server/src/services/cookie.ts b/server/src/services/cookie.ts index 1158c4706..abb586c2b 100644 --- a/server/src/services/cookie.ts +++ b/server/src/services/cookie.ts @@ -1,4 +1,5 @@ import { Request, Response } from 'express'; +import { readEnv } from '../app-config'; import { SESSION_DURATION_MS, SESSION_DURATION_REMEMBER_MS } from '../config'; const COOKIE_NAME = 'trek_session'; @@ -28,10 +29,10 @@ export type RememberOption = boolean | undefined; * remains the explicit escape hatch for plain-HTTP LAN testing. */ export function cookieOptions(clear = false, req?: Request, remember?: RememberOption) { - if (process.env.COOKIE_SECURE?.toLowerCase() === 'false') { + if (readEnv().http.cookieSecureDisabled) { return buildOptions(clear, false, remember); } - const envSecure = process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.FORCE_HTTPS?.toLowerCase() === 'true'; + const envSecure = readEnv().app.isProduction || readEnv().http.forceHttps; const requestSecure = req?.secure === true; return buildOptions(clear, envSecure || requestSecure, remember); } @@ -62,12 +63,9 @@ function buildOptions(clear: boolean, secure: boolean, remember?: RememberOption * concrete fix (use HTTPS or set COOKIE_SECURE=false) instead of a bare 401. */ export function willDropSecureCookie(req?: Request): boolean { - if (process.env.COOKIE_SECURE?.toLowerCase() === 'false') return false; + if (readEnv().http.cookieSecureDisabled) return false; if (req?.secure === true) return false; - return ( - process.env.NODE_ENV?.toLowerCase() === 'production' || - process.env.FORCE_HTTPS?.toLowerCase() === 'true' - ); + return readEnv().app.isProduction || readEnv().http.forceHttps; } export function setAuthCookie(res: Response, token: string, req?: Request, remember?: RememberOption): void { diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts index 074c91d63..e5f243019 100644 --- a/server/src/services/mapsService.ts +++ b/server/src/services/mapsService.ts @@ -1,3 +1,4 @@ +import { readEnv } from '../app-config'; import { db } from '../db/database'; import { safeFetchFollow, SsrfBlockedError } from '../utils/ssrfGuard'; import { decrypt_api_key } from './apiKeyCrypto'; @@ -12,7 +13,7 @@ let googleApiCallCount = 0; function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise { googleApiCallCount++; console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`); - const referer = process.env.APP_URL ? getAppUrl() : undefined; + const referer = readEnv().app.appUrl ? getAppUrl() : undefined; return fetch(endpoint, { ...init, headers: { ...(referer ? { Referer: referer } : {}), ...((init?.headers as Record) ?? {}) }, @@ -333,7 +334,7 @@ const DEFAULT_OVERPASS_MIRRORS = [ // at one or more custom endpoints via OVERPASS_URL (comma-separated). When set it // REPLACES the public mirrors, so a firewalled cluster never reaches out to them and a // self-hosted instance is used exclusively (see #1309). Non-http(s) entries are dropped. -export function resolveOverpassEndpoints(raw: string | undefined = process.env.OVERPASS_URL): string[] { +export function resolveOverpassEndpoints(raw: string | undefined = readEnv().integrations.overpassUrl): string[] { const custom = (raw ?? '') .split(',') .map((s) => s.trim()) @@ -354,7 +355,8 @@ const OVERPASS_MIRRORS = resolveOverpassEndpoints(); // slow self-hosted endpoint can raise it via OVERPASS_TIMEOUT_MS. A non-positive or // non-numeric value falls back to the default — a 0/negative cap would abort every // request immediately and 502 the search. -export function resolveOverpassTimeoutMs(raw: string | undefined = process.env.OVERPASS_TIMEOUT_MS): number { +export function resolveOverpassTimeoutMs(raw?: string): number { + if (raw === undefined) return readEnv().integrations.overpassTimeoutMs; const n = Number(raw); return Number.isFinite(n) && n > 0 ? n : 12000; } diff --git a/server/src/services/notificationService.ts b/server/src/services/notificationService.ts index 1fbe348d8..b5fb2cae2 100644 --- a/server/src/services/notificationService.ts +++ b/server/src/services/notificationService.ts @@ -1,3 +1,4 @@ +import { readEnv } from '../app-config'; import { db } from '../db/database'; import { logDebug, logError } from './auditLog'; import { @@ -215,7 +216,7 @@ export async function send(payload: NotificationPayload): Promise { const configEntry = EVENT_NOTIFICATION_CONFIG[event]; if (!configEntry) { logDebug(`notificationService.send: unknown event type "${event}", using fallback`); - if (process.env.NODE_ENV?.toLowerCase() === 'development' && actorId != null) { + if (readEnv().app.isDevelopment && actorId != null) { const devSender = (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(actorId) as { username: string; avatar: string | null } | undefined) ?? null; createNotificationForRecipient({ type: 'simple', diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index 0162ccf49..ddf5e7c8f 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -1,3 +1,4 @@ +import { readEnv } from '../app-config'; import { db } from '../db/database'; import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard'; import { decrypt_api_key } from './apiKeyCrypto'; @@ -50,11 +51,12 @@ function getAppSetting(key: string): string | null { } function getSmtpConfig(): SmtpConfig | null { - const host = process.env.SMTP_HOST || getAppSetting('smtp_host'); - const port = process.env.SMTP_PORT || getAppSetting('smtp_port'); - const user = process.env.SMTP_USER || getAppSetting('smtp_user'); - const pass = process.env.SMTP_PASS || decrypt_api_key(getAppSetting('smtp_pass')) || ''; - const from = process.env.SMTP_FROM || getAppSetting('smtp_from'); + const smtpEnv = readEnv().smtp; + const host = smtpEnv.host || getAppSetting('smtp_host'); + const port = smtpEnv.port || getAppSetting('smtp_port'); + const user = smtpEnv.user || getAppSetting('smtp_user'); + const pass = smtpEnv.pass || decrypt_api_key(getAppSetting('smtp_pass')) || ''; + const from = smtpEnv.from || getAppSetting('smtp_from'); if (!host || !port || !from) return null; return { host, @@ -68,13 +70,14 @@ function getSmtpConfig(): SmtpConfig | null { // Exported for use by notificationService export function getAppUrl(): string { - if (process.env.APP_URL) { + const appUrl = readEnv().app.appUrl; + if (appUrl) { try { - const _ = new URL(process.env.APP_URL); - return process.env.APP_URL.replace(/\/+$/, ''); + const _ = new URL(appUrl); + return appUrl.replace(/\/+$/, ''); } catch (_ignored) {} } - const origins = process.env.ALLOWED_ORIGINS; + const origins = readEnv().http.allowedOriginsRaw; if (origins) { const first = origins.split(',')[0]?.trim(); if (first) { @@ -84,7 +87,7 @@ export function getAppUrl(): string { } catch (_ignored) {} } } - const port = Number(process.env.PORT) || 3001; + const port = readEnv().app.port; return `http://localhost:${port}`; } @@ -101,13 +104,13 @@ export function getMcpSafeUrl(): string { } catch { // candidate was somehow invalid — fall through to localhost } - const port = Number(process.env.PORT) || 3001; + const port = readEnv().app.port; return `http://localhost:${port}`; } /** Is SMTP configured at the instance level? (Independent of any one user's address.) */ export function isSmtpConfigured(): boolean { - return !!(process.env.SMTP_HOST || getAppSetting('smtp_host')); + return !!(readEnv().smtp.host || getAppSetting('smtp_host')); } export function getUserEmail(userId: number): string | null { @@ -277,7 +280,7 @@ export async function sendPasswordResetEmail( } try { - const skipTls = process.env.SMTP_SKIP_TLS_VERIFY === 'true' || getAppSetting('smtp_skip_tls_verify') === 'true'; + const skipTls = readEnv().smtp.skipTlsVerify || getAppSetting('smtp_skip_tls_verify') === 'true'; const transporter = nodemailer.createTransport({ host: smtpCfg.host, port: smtpCfg.port, @@ -313,7 +316,7 @@ export async function sendEmail( const lang = userId ? getUserLanguage(userId) : 'en'; try { - const skipTls = process.env.SMTP_SKIP_TLS_VERIFY === 'true' || getAppSetting('smtp_skip_tls_verify') === 'true'; + const skipTls = readEnv().smtp.skipTlsVerify || getAppSetting('smtp_skip_tls_verify') === 'true'; const transporter = nodemailer.createTransport({ host: config.host, port: config.port, @@ -411,7 +414,7 @@ export async function testSmtp(to: string): Promise<{ success: boolean; error?: if (!getSmtpConfig()) return { success: false, error: 'SMTP not configured' }; try { const config = getSmtpConfig()!; - const skipTls = process.env.SMTP_SKIP_TLS_VERIFY === 'true' || getAppSetting('smtp_skip_tls_verify') === 'true'; + const skipTls = readEnv().smtp.skipTlsVerify || getAppSetting('smtp_skip_tls_verify') === 'true'; const transporter = nodemailer.createTransport({ host: config.host, port: config.port, diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts index 6eb6a9be7..8fbdcc474 100644 --- a/server/src/services/oidcService.ts +++ b/server/src/services/oidcService.ts @@ -1,5 +1,6 @@ import crypto from 'crypto'; import jwt from 'jsonwebtoken'; +import { readEnv } from '../app-config'; import { db } from '../db/database'; import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config'; import { User } from '../types'; @@ -129,11 +130,12 @@ export function getOidcConfig(): OidcConfig | null { const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; - const issuer = process.env.OIDC_ISSUER || get('oidc_issuer'); - const clientId = process.env.OIDC_CLIENT_ID || get('oidc_client_id'); - const clientSecret = process.env.OIDC_CLIENT_SECRET || decrypt_api_key(get('oidc_client_secret')); - const displayName = process.env.OIDC_DISPLAY_NAME || get('oidc_display_name') || 'SSO'; - const discoveryUrl = process.env.OIDC_DISCOVERY_URL || get('oidc_discovery_url') || null; + const oidcEnv = readEnv().oidc; + const issuer = oidcEnv.issuer || get('oidc_issuer'); + const clientId = oidcEnv.clientId || get('oidc_client_id'); + const clientSecret = oidcEnv.clientSecret || decrypt_api_key(get('oidc_client_secret')); + const displayName = oidcEnv.displayName || get('oidc_display_name') || 'SSO'; + const discoveryUrl = oidcEnv.discoveryUrl || get('oidc_discovery_url') || null; if (!issuer || !clientId || !clientSecret) return null; return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName, discoveryUrl }; @@ -182,9 +184,9 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr export function resolveOidcRole(userInfo: OidcUserInfo, isFirstUser: boolean): 'admin' | 'user' { if (isFirstUser) return 'admin'; - const adminValue = process.env.OIDC_ADMIN_VALUE; + const adminValue = readEnv().oidc.adminValue; if (!adminValue) return 'user'; - const claimKey = process.env.OIDC_ADMIN_CLAIM || 'groups'; + const claimKey = readEnv().oidc.adminClaim; const claimData = userInfo[claimKey]; if (Array.isArray(claimData)) { return claimData.some((v) => String(v) === adminValue) ? 'admin' : 'user'; @@ -200,7 +202,8 @@ export function resolveOidcRole(userInfo: OidcUserInfo, isFirstUser: boolean): ' // --------------------------------------------------------------------------- export function frontendUrl(path: string): string { - const base = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5173'; + // Case-sensitive on purpose (legacy parity). + const base = readEnv().app.nodeEnv === 'production' ? '' : 'http://localhost:5173'; return base + path; } @@ -398,7 +401,7 @@ export function findOrCreateUser( db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id); } // Update role based on OIDC claims on every login (if claim mapping is configured) - if (process.env.OIDC_ADMIN_VALUE) { + if (readEnv().oidc.adminValue) { const newRole = resolveOidcRole(userInfo, false); if (user.role !== newRole) { // Never let the claim-based downgrade strip the last admin. The bootstrap @@ -476,7 +479,7 @@ export function findOrCreateUser( } const ins = db.prepare( 'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, avatar, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)', - ).run(username, email, hash, role, sub, config.issuer, picture, process.env.APP_VERSION || '0.0.0'); + ).run(username, email, hash, role, sub, config.issuer, picture, readEnv().app.appVersion || '0.0.0'); // Trip-bound invite (#1402): auto-add the new SSO user to the trip inside the // same atomic step as the invite consume. Idempotent + owner-safe. if (validInvite?.trip_id) { diff --git a/server/src/services/placePhotoCache.ts b/server/src/services/placePhotoCache.ts index d16484152..7a738b9b4 100644 --- a/server/src/services/placePhotoCache.ts +++ b/server/src/services/placePhotoCache.ts @@ -1,3 +1,4 @@ +import { readEnv } from '../app-config'; import { db } from '../db/database'; import { Jimp, JimpMime } from 'jimp'; @@ -8,7 +9,7 @@ import path from 'node:path'; // Overridable for tests (mirrors the TREK_DB_FILE seam) so the suite never touches // the real uploads tree. -const GOOGLE_PHOTO_DIR = process.env.TREK_PLACE_PHOTO_DIR || path.join(__dirname, '../../uploads/photos/google'); +const GOOGLE_PHOTO_DIR = readEnv().paths.placePhotoDir || path.join(__dirname, '../../uploads/photos/google'); const ERROR_TTL = 5 * 60 * 1000; // Marker photos are displayed tiny — cap stored images so an oversized source diff --git a/server/src/services/transitService.ts b/server/src/services/transitService.ts index 028814332..905c5f35f 100644 --- a/server/src/services/transitService.ts +++ b/server/src/services/transitService.ts @@ -1,3 +1,4 @@ +import { readEnv } from '../app-config'; import { buildUserAgent } from './mapsService'; import { getAppUrl } from './notifications'; @@ -13,7 +14,8 @@ import { getAppUrl } from './notifications'; * to a compact shape so the client isn't coupled to the MOTIS schema. */ -const TRANSIT_API_BASE = (process.env.TRANSIT_API_URL || 'https://api.transitous.org').replace(/\/+$/, ''); +// Frozen at import on purpose (legacy timing). +const TRANSIT_API_BASE = readEnv().integrations.transitApiBase; let userAgent: string | null = null; function getUserAgent(): string { diff --git a/server/src/services/unsplashService.ts b/server/src/services/unsplashService.ts index 4ba2bf5b5..d1062b0a7 100644 --- a/server/src/services/unsplashService.ts +++ b/server/src/services/unsplashService.ts @@ -1,6 +1,7 @@ import path from 'path'; import fs from 'fs'; import { v4 as uuidv4 } from 'uuid'; +import { readEnv } from '../app-config'; import { safeFetch } from '../utils/ssrfGuard'; import { db } from '../db/database'; import { decrypt_api_key } from './apiKeyCrypto'; @@ -35,7 +36,7 @@ export interface UnsplashPhoto { * in which case the search falls back to the unauthenticated endpoint. */ export function getUnsplashKey(userId: number): string | null { - const env_key = process.env.UNSPLASH_ACCESS_KEY?.trim(); + const env_key = readEnv().integrations.unsplashAccessKey; if (env_key) return env_key; const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(userId) as { unsplash_api_key: string | null } | undefined; const user_key = decrypt_api_key(user?.unsplash_api_key); diff --git a/server/src/services/webauthnConfig.ts b/server/src/services/webauthnConfig.ts index d6fcf924d..2c4271833 100644 --- a/server/src/services/webauthnConfig.ts +++ b/server/src/services/webauthnConfig.ts @@ -1,3 +1,4 @@ +import { readEnv } from '../app-config'; import { db } from '../db/database'; import { getAppUrl } from './notifications'; @@ -47,8 +48,8 @@ export interface WebauthnConfig { export function resolveWebauthnConfig(): WebauthnConfig | null { // 1. Explicit operator config always wins. - const explicitRpId = (process.env.WEBAUTHN_RP_ID || getSetting('webauthn_rp_id'))?.trim() || null; - const explicitOrigins = (process.env.WEBAUTHN_ORIGINS || getSetting('webauthn_origins') || '') + const explicitRpId = (readEnv().webauthn.rpId || getSetting('webauthn_rp_id'))?.trim() || null; + const explicitOrigins = (readEnv().webauthn.origins || getSetting('webauthn_origins') || '') .split(',') .map((o) => o.trim().replace(/\/+$/, '')) .filter(Boolean); diff --git a/server/src/services/wikiService.ts b/server/src/services/wikiService.ts index cd1e80aac..b35c344e4 100644 --- a/server/src/services/wikiService.ts +++ b/server/src/services/wikiService.ts @@ -1,5 +1,6 @@ import { existsSync, promises as fs } from 'fs'; import path from 'path'; +import { readEnv } from '../app-config'; /** * In-app Help/Wiki content, sourced from the `wiki/**` directory that ships with @@ -24,7 +25,7 @@ const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; * the Dockerfile copies `wiki/` to /app/wiki). `process.cwd()` would not — Docker * runs the server from /app/server. */ -const WIKI_DIR = process.env.TREK_WIKI_DIR ?? path.join(__dirname, '..', '..', '..', 'wiki'); +const WIKI_DIR = readEnv().paths.wikiDir ?? path.join(__dirname, '..', '..', '..', 'wiki'); /** * Probe for the sidebar rather than the bare directory: an empty or half-copied diff --git a/server/src/utils/ssrfGuard.ts b/server/src/utils/ssrfGuard.ts index 4614ba2e3..a51cf1e61 100644 --- a/server/src/utils/ssrfGuard.ts +++ b/server/src/utils/ssrfGuard.ts @@ -1,8 +1,10 @@ import dns from 'node:dns/promises'; import { Agent } from 'undici'; +import { readEnv } from '../app-config'; import { embeddedTransitionIpv4 } from './ipv6'; -const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK?.toLowerCase() === 'true'; +// Frozen at import on purpose (legacy timing; tests reload the module to change it). +const ALLOW_INTERNAL_NETWORK = readEnv().net.allowInternalNetwork; export interface SsrfResult { allowed: boolean;