refactor(server): legacy services read env via app-config

This commit is contained in:
jubnl
2026-07-25 15:37:37 +02:00
parent d13758221e
commit 7f795a2199
15 changed files with 98 additions and 97 deletions
+3 -2
View File
@@ -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<VersionInfo> {
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,
+4 -2
View File
@@ -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');
}
+24 -22
View File
@@ -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<string, unknown> } {
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<string> } {
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);
}
+9 -28
View File
@@ -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<BackupInfo> {
// 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<RestoreResult> {
// ---------------------------------------------------------------------------
export function getAutoSettings(): { settings: ReturnType<typeof scheduler.loadSettings>; 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 };
}
+5 -7
View File
@@ -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 {
+5 -3
View File
@@ -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<Response> {
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<string, string>) ?? {}) },
@@ -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;
}
+2 -1
View File
@@ -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<void> {
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',
+18 -15
View File
@@ -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,
+13 -10
View File
@@ -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) {
+2 -1
View File
@@ -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
+3 -1
View File
@@ -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 {
+2 -1
View File
@@ -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);
+3 -2
View File
@@ -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);
+2 -1
View File
@@ -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
+3 -1
View File
@@ -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;