mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(server): legacy services read env via app-config
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user