From eb27ec2234a944cc9e1254315495c2ea7ada5a9b Mon Sep 17 00:00:00 2001 From: perf3ct Date: Thu, 21 Aug 2025 02:15:02 +0000 Subject: [PATCH] feat(config): fix previously documented env var formula not working asdf --- apps/server/src/services/config.spec.ts | 348 +++++++++++++ apps/server/src/services/config.ts | 628 ++++++++++++++++++++---- 2 files changed, 893 insertions(+), 83 deletions(-) create mode 100644 apps/server/src/services/config.spec.ts diff --git a/apps/server/src/services/config.spec.ts b/apps/server/src/services/config.spec.ts new file mode 100644 index 000000000..ec067fc4c --- /dev/null +++ b/apps/server/src/services/config.spec.ts @@ -0,0 +1,348 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs"; +import ini from "ini"; + +// Mock dependencies +vi.mock("fs"); +vi.mock("./data_dir.js", () => ({ + default: { + CONFIG_INI_PATH: "/test/config.ini" + } +})); +vi.mock("./resource_dir.js", () => ({ + default: { + RESOURCE_DIR: "/test/resources" + } +})); + +describe("Config Service", () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + // Save original environment + originalEnv = { ...process.env }; + + // Clear all TRILIUM env vars + Object.keys(process.env).forEach(key => { + if (key.startsWith("TRILIUM_")) { + delete process.env[key]; + } + }); + + // Mock fs to return empty config + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation((path) => { + if (String(path).includes("config-sample.ini")) { + return "" as any; // Return string for INI parsing + } + // Return empty INI config as string + return ` +[General] +[Network] +[Session] +[Sync] +[MultiFactorAuthentication] +[Logging] + ` as any; + }); + + // Clear module cache to reload config with new env vars + vi.resetModules(); + }); + + afterEach(() => { + // Restore original environment + process.env = originalEnv; + vi.clearAllMocks(); + }); + + describe("Environment Variable Naming", () => { + it("should use standard environment variables following TRILIUM_[SECTION]_[KEY] pattern", async () => { + // Set standard env vars + process.env.TRILIUM_GENERAL_INSTANCENAME = "test-instance"; + process.env.TRILIUM_NETWORK_CORSALLOWORIGIN = "https://example.com"; + process.env.TRILIUM_SYNC_SYNCSERVERHOST = "sync.example.com"; + process.env.TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHBASEURL = "https://auth.example.com"; + process.env.TRILIUM_LOGGING_RETENTIONDAYS = "30"; + + const { default: config } = await import("./config.js"); + + expect(config.General.instanceName).toBe("test-instance"); + expect(config.Network.corsAllowOrigin).toBe("https://example.com"); + expect(config.Sync.syncServerHost).toBe("sync.example.com"); + expect(config.MultiFactorAuthentication.oauthBaseUrl).toBe("https://auth.example.com"); + expect(config.Logging.retentionDays).toBe(30); + }); + + it("should maintain backward compatibility with alias environment variables", async () => { + // Set alias/legacy env vars + process.env.TRILIUM_NETWORK_CORS_ALLOW_ORIGIN = "https://legacy.com"; + process.env.TRILIUM_SYNC_SERVER_HOST = "legacy-sync.com"; + process.env.TRILIUM_OAUTH_BASE_URL = "https://legacy-auth.com"; + process.env.TRILIUM_LOGGING_RETENTION_DAYS = "60"; + + const { default: config } = await import("./config.js"); + + expect(config.Network.corsAllowOrigin).toBe("https://legacy.com"); + expect(config.Sync.syncServerHost).toBe("legacy-sync.com"); + expect(config.MultiFactorAuthentication.oauthBaseUrl).toBe("https://legacy-auth.com"); + expect(config.Logging.retentionDays).toBe(60); + }); + + it("should prioritize standard env vars over aliases when both are set", async () => { + // Set both standard and alias env vars - standard should win + process.env.TRILIUM_NETWORK_CORSALLOWORIGIN = "standard-cors.com"; + process.env.TRILIUM_NETWORK_CORS_ALLOW_ORIGIN = "alias-cors.com"; + + process.env.TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHBASEURL = "standard-auth.com"; + process.env.TRILIUM_OAUTH_BASE_URL = "alias-auth.com"; + + const { default: config } = await import("./config.js"); + + expect(config.Network.corsAllowOrigin).toBe("standard-cors.com"); + expect(config.MultiFactorAuthentication.oauthBaseUrl).toBe("standard-auth.com"); + }); + + it("should handle all CORS environment variables correctly", async () => { + // Test with standard naming + process.env.TRILIUM_NETWORK_CORSALLOWORIGIN = "*"; + process.env.TRILIUM_NETWORK_CORSALLOWMETHODS = "GET,POST,PUT"; + process.env.TRILIUM_NETWORK_CORSALLOWHEADERS = "Content-Type,Authorization"; + + let { default: config } = await import("./config.js"); + + expect(config.Network.corsAllowOrigin).toBe("*"); + expect(config.Network.corsAllowMethods).toBe("GET,POST,PUT"); + expect(config.Network.corsAllowHeaders).toBe("Content-Type,Authorization"); + + // Clear and test with alias naming + delete process.env.TRILIUM_NETWORK_CORSALLOWORIGIN; + delete process.env.TRILIUM_NETWORK_CORSALLOWMETHODS; + delete process.env.TRILIUM_NETWORK_CORSALLOWHEADERS; + + process.env.TRILIUM_NETWORK_CORS_ALLOW_ORIGIN = "https://app.com"; + process.env.TRILIUM_NETWORK_CORS_ALLOW_METHODS = "GET,POST"; + process.env.TRILIUM_NETWORK_CORS_ALLOW_HEADERS = "X-Custom-Header"; + + vi.resetModules(); + config = (await import("./config.js")).default; + + expect(config.Network.corsAllowOrigin).toBe("https://app.com"); + expect(config.Network.corsAllowMethods).toBe("GET,POST"); + expect(config.Network.corsAllowHeaders).toBe("X-Custom-Header"); + }); + + it("should handle all OAuth/MFA environment variables correctly", async () => { + // Test with standard naming + process.env.TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHBASEURL = "https://oauth.standard.com"; + process.env.TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHCLIENTID = "standard-client-id"; + process.env.TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHCLIENTSECRET = "standard-secret"; + process.env.TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERBASEURL = "https://issuer.standard.com"; + process.env.TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERNAME = "Standard Auth"; + process.env.TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERICON = "standard-icon.png"; + + let { default: config } = await import("./config.js"); + + expect(config.MultiFactorAuthentication.oauthBaseUrl).toBe("https://oauth.standard.com"); + expect(config.MultiFactorAuthentication.oauthClientId).toBe("standard-client-id"); + expect(config.MultiFactorAuthentication.oauthClientSecret).toBe("standard-secret"); + expect(config.MultiFactorAuthentication.oauthIssuerBaseUrl).toBe("https://issuer.standard.com"); + expect(config.MultiFactorAuthentication.oauthIssuerName).toBe("Standard Auth"); + expect(config.MultiFactorAuthentication.oauthIssuerIcon).toBe("standard-icon.png"); + + // Clear and test with alias naming + Object.keys(process.env).forEach(key => { + if (key.startsWith("TRILIUM_MULTIFACTORAUTHENTICATION_")) { + delete process.env[key]; + } + }); + + process.env.TRILIUM_OAUTH_BASE_URL = "https://oauth.alias.com"; + process.env.TRILIUM_OAUTH_CLIENT_ID = "alias-client-id"; + process.env.TRILIUM_OAUTH_CLIENT_SECRET = "alias-secret"; + process.env.TRILIUM_OAUTH_ISSUER_BASE_URL = "https://issuer.alias.com"; + process.env.TRILIUM_OAUTH_ISSUER_NAME = "Alias Auth"; + process.env.TRILIUM_OAUTH_ISSUER_ICON = "alias-icon.png"; + + vi.resetModules(); + config = (await import("./config.js")).default; + + expect(config.MultiFactorAuthentication.oauthBaseUrl).toBe("https://oauth.alias.com"); + expect(config.MultiFactorAuthentication.oauthClientId).toBe("alias-client-id"); + expect(config.MultiFactorAuthentication.oauthClientSecret).toBe("alias-secret"); + expect(config.MultiFactorAuthentication.oauthIssuerBaseUrl).toBe("https://issuer.alias.com"); + expect(config.MultiFactorAuthentication.oauthIssuerName).toBe("Alias Auth"); + expect(config.MultiFactorAuthentication.oauthIssuerIcon).toBe("alias-icon.png"); + }); + + it("should handle all Sync environment variables correctly", async () => { + // Test with standard naming + process.env.TRILIUM_SYNC_SYNCSERVERHOST = "sync-standard.com"; + process.env.TRILIUM_SYNC_SYNCSERVERTIMEOUT = "60000"; + process.env.TRILIUM_SYNC_SYNCPROXY = "proxy-standard.com"; + + let { default: config } = await import("./config.js"); + + expect(config.Sync.syncServerHost).toBe("sync-standard.com"); + expect(config.Sync.syncServerTimeout).toBe("60000"); + expect(config.Sync.syncProxy).toBe("proxy-standard.com"); + + // Clear and test with alias naming + delete process.env.TRILIUM_SYNC_SYNCSERVERHOST; + delete process.env.TRILIUM_SYNC_SYNCSERVERTIMEOUT; + delete process.env.TRILIUM_SYNC_SYNCPROXY; + + process.env.TRILIUM_SYNC_SERVER_HOST = "sync-alias.com"; + process.env.TRILIUM_SYNC_SERVER_TIMEOUT = "30000"; + process.env.TRILIUM_SYNC_SERVER_PROXY = "proxy-alias.com"; + + vi.resetModules(); + config = (await import("./config.js")).default; + + expect(config.Sync.syncServerHost).toBe("sync-alias.com"); + expect(config.Sync.syncServerTimeout).toBe("30000"); + expect(config.Sync.syncProxy).toBe("proxy-alias.com"); + }); + }); + + describe("INI Config Integration", () => { + it("should fall back to INI config when no env vars are set", async () => { + // Mock INI config with values + vi.mocked(fs.readFileSync).mockImplementation((path) => { + if (String(path).includes("config-sample.ini")) { + return "" as any; + } + return ` +[General] +instanceName=ini-instance + +[Network] +corsAllowOrigin=https://ini-cors.com +port=9000 + +[Sync] +syncServerHost=ini-sync.com + +[MultiFactorAuthentication] +oauthBaseUrl=https://ini-oauth.com + +[Logging] +retentionDays=45 + ` as any; + }); + + const { default: config } = await import("./config.js"); + + expect(config.General.instanceName).toBe("ini-instance"); + expect(config.Network.corsAllowOrigin).toBe("https://ini-cors.com"); + expect(config.Network.port).toBe("9000"); + expect(config.Sync.syncServerHost).toBe("ini-sync.com"); + expect(config.MultiFactorAuthentication.oauthBaseUrl).toBe("https://ini-oauth.com"); + expect(config.Logging.retentionDays).toBe(45); + }); + + it("should prioritize env vars over INI config", async () => { + // Mock INI config with values + vi.mocked(fs.readFileSync).mockImplementation((path) => { + if (String(path).includes("config-sample.ini")) { + return "" as any; + } + return ` +[General] +instanceName=ini-instance + +[Network] +corsAllowOrigin=https://ini-cors.com + ` as any; + }); + + // Set env vars that should override INI + process.env.TRILIUM_GENERAL_INSTANCENAME = "env-instance"; + process.env.TRILIUM_NETWORK_CORS_ALLOW_ORIGIN = "https://env-cors.com"; // Using alias + + const { default: config } = await import("./config.js"); + + expect(config.General.instanceName).toBe("env-instance"); + expect(config.Network.corsAllowOrigin).toBe("https://env-cors.com"); + }); + }); + + describe("Type Transformations", () => { + it("should correctly transform boolean values", async () => { + process.env.TRILIUM_GENERAL_NOAUTHENTICATION = "true"; + process.env.TRILIUM_GENERAL_NOBACKUP = "1"; + process.env.TRILIUM_GENERAL_READONLY = "false"; + process.env.TRILIUM_NETWORK_HTTPS = "0"; + + const { default: config } = await import("./config.js"); + + expect(config.General.noAuthentication).toBe(true); + expect(config.General.noBackup).toBe(true); + expect(config.General.readOnly).toBe(false); + expect(config.Network.https).toBe(false); + }); + + it("should correctly transform integer values", async () => { + process.env.TRILIUM_SESSION_COOKIEMAXAGE = "3600"; + process.env.TRILIUM_LOGGING_RETENTIONDAYS = "7"; + + const { default: config } = await import("./config.js"); + + expect(config.Session.cookieMaxAge).toBe(3600); + expect(config.Logging.retentionDays).toBe(7); + }); + + it("should use default values for invalid integers", async () => { + process.env.TRILIUM_SESSION_COOKIEMAXAGE = "invalid"; + process.env.TRILIUM_LOGGING_RETENTION_DAYS = "not-a-number"; // Using alias + + const { default: config } = await import("./config.js"); + + expect(config.Session.cookieMaxAge).toBe(21 * 24 * 60 * 60); // Default + expect(config.Logging.retentionDays).toBe(90); // Default + }); + }); + + describe("Default Values", () => { + it("should use correct default values when no config is provided", async () => { + const { default: config } = await import("./config.js"); + + // General defaults + expect(config.General.instanceName).toBe(""); + expect(config.General.noAuthentication).toBe(false); + expect(config.General.noBackup).toBe(false); + expect(config.General.noDesktopIcon).toBe(false); + expect(config.General.readOnly).toBe(false); + + // Network defaults + expect(config.Network.host).toBe("0.0.0.0"); + expect(config.Network.port).toBe("3000"); + expect(config.Network.https).toBe(false); + expect(config.Network.certPath).toBe(""); + expect(config.Network.keyPath).toBe(""); + expect(config.Network.trustedReverseProxy).toBe(false); + expect(config.Network.corsAllowOrigin).toBe(""); + expect(config.Network.corsAllowMethods).toBe(""); + expect(config.Network.corsAllowHeaders).toBe(""); + + // Session defaults + expect(config.Session.cookieMaxAge).toBe(21 * 24 * 60 * 60); + + // Sync defaults + expect(config.Sync.syncServerHost).toBe(""); + expect(config.Sync.syncServerTimeout).toBe("120000"); + expect(config.Sync.syncProxy).toBe(""); + + // OAuth defaults + expect(config.MultiFactorAuthentication.oauthBaseUrl).toBe(""); + expect(config.MultiFactorAuthentication.oauthClientId).toBe(""); + expect(config.MultiFactorAuthentication.oauthClientSecret).toBe(""); + expect(config.MultiFactorAuthentication.oauthIssuerBaseUrl).toBe("https://accounts.google.com"); + expect(config.MultiFactorAuthentication.oauthIssuerName).toBe("Google"); + expect(config.MultiFactorAuthentication.oauthIssuerIcon).toBe(""); + + // Logging defaults + expect(config.Logging.retentionDays).toBe(90); + }); + }); +}); \ No newline at end of file diff --git a/apps/server/src/services/config.ts b/apps/server/src/services/config.ts index 84e859a68..e08b7264e 100644 --- a/apps/server/src/services/config.ts +++ b/apps/server/src/services/config.ts @@ -1,3 +1,24 @@ +/** + * ╔════════════════════════════════════════════════════════════════════════════╗ + * ║ TRILIUM CONFIGURATION RESOLUTION ORDER ║ + * ╠════════════════════════════════════════════════════════════════════════════╣ + * ║ ║ + * ║ Priority │ Source │ Example ║ + * ║ ─────────┼─────────────────────────────────┼─────────────────────────────║ + * ║ 1 │ Environment Variables │ TRILIUM_NETWORK_PORT=8080 ║ + * ║ ↓ │ (Highest Priority - Overrides all) ║ + * ║ │ ║ + * ║ 2 │ config.ini File │ [Network] ║ + * ║ ↓ │ (User Configuration) │ port=8080 ║ + * ║ │ ║ + * ║ 3 │ Default Values │ port='3000' ║ + * ║ │ (Lowest Priority - Fallback) │ (hardcoded defaults) ║ + * ║ ║ + * ╠════════════════════════════════════════════════════════════════════════════╣ + * ║ IMPORTANT: Environment variables ALWAYS override config.ini values! ║ + * ╚════════════════════════════════════════════════════════════════════════════╝ + */ + import ini from "ini"; import fs from "fs"; import dataDir from "./data_dir.js"; @@ -5,153 +26,594 @@ import path from "path"; import resourceDir from "./resource_dir.js"; import { envToBoolean, stringToInt } from "./utils.js"; +/** + * Path to the sample configuration file that serves as a template for new installations. + * This file contains all available configuration options with documentation. + */ const configSampleFilePath = path.resolve(resourceDir.RESOURCE_DIR, "config-sample.ini"); +/** + * Initialize config.ini file if it doesn't exist. + * On first run, copies the sample configuration to the data directory, + * allowing users to customize their settings. + */ if (!fs.existsSync(dataDir.CONFIG_INI_PATH)) { const configSample = fs.readFileSync(configSampleFilePath).toString("utf8"); - fs.writeFileSync(dataDir.CONFIG_INI_PATH, configSample); } -const iniConfig = ini.parse(fs.readFileSync(dataDir.CONFIG_INI_PATH, "utf-8")); +/** + * Type definition for the parsed INI configuration structure. + * The ini parser returns an object with string keys and values that can be + * strings, booleans, numbers, or nested objects. + */ +type IniConfigValue = string | number | boolean | null | undefined; +type IniConfigSection = Record; +type IniConfig = Record; +/** + * Parse the config.ini file into a JavaScript object. + * This object contains all user-defined configuration from the INI file, + * which will be merged with environment variables and defaults. + */ +const iniConfig = ini.parse(fs.readFileSync(dataDir.CONFIG_INI_PATH, "utf-8")) as IniConfig; + +/** + * Complete type-safe configuration interface for Trilium. + * This interface defines all configuration options available through + * environment variables, config.ini, or defaults. + */ export interface TriliumConfig { + /** General application settings */ General: { + /** Custom instance name for identifying this Trilium instance */ instanceName: string; + /** Whether to disable authentication (useful for local-only instances) */ noAuthentication: boolean; + /** Whether to disable automatic backups */ noBackup: boolean; + /** Whether to prevent desktop icon creation (desktop app only) */ noDesktopIcon: boolean; + /** Whether to run in read-only mode (prevents all data modifications) */ readOnly: boolean; }; + /** Network and server configuration */ Network: { + /** Host/IP address to bind the server to (e.g., '0.0.0.0' for all interfaces) */ host: string; + /** Port number for the HTTP/HTTPS server */ port: string; + /** Whether to enable HTTPS (requires certPath and keyPath) */ https: boolean; + /** Path to SSL certificate file (required when https=true) */ certPath: string; + /** Path to SSL private key file (required when https=true) */ keyPath: string; + /** Trust reverse proxy headers (boolean or specific IP/subnet string) */ trustedReverseProxy: boolean | string; + /** CORS allowed origins (comma-separated list or '*' for all) */ corsAllowOrigin: string; + /** CORS allowed methods (comma-separated HTTP methods) */ corsAllowMethods: string; + /** CORS allowed headers (comma-separated header names) */ corsAllowHeaders: string; }; + /** Session management configuration */ Session: { + /** Maximum age of session cookies in seconds (default: 21 days) */ cookieMaxAge: number; }; + /** Synchronization settings for multi-instance setups */ Sync: { + /** URL of the sync server to connect to */ syncServerHost: string; + /** Timeout for sync operations in milliseconds */ syncServerTimeout: string; + /** Proxy URL for sync connections (if behind corporate proxy) */ syncProxy: string; }; + /** Multi-factor authentication and OAuth/OpenID configuration */ MultiFactorAuthentication: { + /** Base URL for OAuth authentication endpoint */ oauthBaseUrl: string; + /** OAuth client ID from your identity provider */ oauthClientId: string; + /** OAuth client secret from your identity provider */ oauthClientSecret: string; + /** Base URL of the OAuth issuer (e.g., 'https://accounts.google.com') */ oauthIssuerBaseUrl: string; + /** Display name of the OAuth provider (shown in UI) */ oauthIssuerName: string; + /** URL to the OAuth provider's icon/logo */ oauthIssuerIcon: string; }; + /** Logging configuration */ Logging: { /** - * The number of days to keep the log files around. When rotating the logs, log files created by Trilium older than the specified amount of time will be deleted. + * The number of days to keep the log files around. When rotating the logs, + * log files created by Trilium older than the specified amount of time will be deleted. */ retentionDays: number; } } +/** + * Default retention period for log files in days. + * After this period, old log files are automatically deleted during rotation. + */ export const LOGGING_DEFAULT_RETENTION_DAYS = 90; -//prettier-ignore -const config: TriliumConfig = { +/** + * Configuration value source with precedence handling. + * This interface defines how each configuration value is resolved from multiple sources. + */ +interface ConfigValue { + /** + * Standard environment variable name following TRILIUM_[SECTION]_[KEY] pattern. + * This is the primary way to override configuration via environment. + */ + standardEnvVar?: string; + /** + * Alternative environment variable names for additional flexibility. + * These provide shorter or more intuitive names for common settings. + */ + aliasEnvVars?: string[]; + /** + * Function to retrieve the value from the parsed INI configuration. + * Returns undefined if the value is not set in config.ini. + */ + iniGetter: () => IniConfigValue | IniConfigSection; + /** + * Default value used when no environment variable or INI value is found. + * This ensures every configuration has a sensible default. + */ + defaultValue: T; + /** + * Optional transformer function to convert string values to the correct type. + * Common transformers handle boolean and integer conversions. + */ + transformer?: (value: unknown) => T; +} +/** + * Core configuration resolution function. + * + * Resolves configuration values using a clear precedence order: + * 1. Standard environment variable (highest priority) - Follows TRILIUM_[SECTION]_[KEY] pattern + * 2. Alias environment variables - Alternative names for convenience and compatibility + * 3. INI config file value - User-defined settings in config.ini + * 4. Default value (lowest priority) - Fallback to ensure valid configuration + * + * This precedence allows for flexible configuration management: + * - Environment variables for container/cloud deployments + * - config.ini for traditional installations + * - Defaults ensure the application always has valid settings + * + * @param config - Configuration value definition with sources and transformers + * @returns The resolved configuration value with appropriate type + */ +function getConfigValue(config: ConfigValue): T { + // Check standard env var first + if (config.standardEnvVar && process.env[config.standardEnvVar] !== undefined) { + const value = process.env[config.standardEnvVar]; + return config.transformer ? config.transformer(value) : value as T; + } + + // Check alternative env vars for additional flexibility + if (config.aliasEnvVars) { + for (const aliasVar of config.aliasEnvVars) { + if (process.env[aliasVar] !== undefined) { + const value = process.env[aliasVar]; + return config.transformer ? config.transformer(value) : value as T; + } + } + } + + // Check INI config + const iniValue = config.iniGetter(); + if (iniValue !== undefined && iniValue !== null && iniValue !== '') { + return config.transformer ? config.transformer(iniValue) : iniValue as T; + } + + // Return default + return config.defaultValue; +} + +/** + * Helper function to safely access INI config sections. + * The ini parser can return either a section object or a primitive value, + * so we need to check the type before accessing nested properties. + * + * @param sectionName - The name of the INI section to access + * @returns The section object or undefined if not found or not an object + */ +function getIniSection(sectionName: string): IniConfigSection | undefined { + const section = iniConfig[sectionName]; + if (section && typeof section === 'object' && !Array.isArray(section)) { + return section as IniConfigSection; + } + return undefined; +} + +/** + * Transform a value to boolean, handling various input formats. + * + * This function provides flexible boolean parsing to handle different + * configuration sources (environment variables, INI files, etc.): + * - String "true"/"false" (case-insensitive) + * - String "1"/"0" + * - Numeric 1/0 + * - Actual boolean values + * - Any other value defaults to false + * + * @param value - The value to transform (string, number, boolean, etc.) + * @returns The boolean value or false as default + */ +function transformBoolean(value: unknown): boolean { + // First try the standard envToBoolean function which handles "true"/"false" strings + const result = envToBoolean(String(value)); + if (result !== undefined) return result; + + // Handle numeric boolean values (both string and number types) + if (value === "1" || value === 1) return true; + if (value === "0" || value === 0) return false; + + // Default to false for any other value + return false; +} + +/** + * Complete configuration mapping that defines how each setting is resolved. + * + * This mapping structure: + * 1. Mirrors the INI file sections for consistency + * 2. Defines multiple sources for each configuration value + * 3. Provides type transformers where needed + * 4. Maintains compatibility with various environment variable formats + * + * Environment Variable Patterns: + * - Standard: TRILIUM_[SECTION]_[KEY] (e.g., TRILIUM_GENERAL_INSTANCENAME) + * - Aliases: Shorter alternatives (e.g., TRILIUM_OAUTH_BASE_URL) + * + * Both patterns are equally valid and can be used based on preference. + * The standard pattern provides consistency, while aliases offer convenience. + */ +const configMapping = { General: { - instanceName: - process.env.TRILIUM_GENERAL_INSTANCENAME || iniConfig.General.instanceName || "", - - noAuthentication: - envToBoolean(process.env.TRILIUM_GENERAL_NOAUTHENTICATION) || iniConfig.General.noAuthentication || false, - - noBackup: - envToBoolean(process.env.TRILIUM_GENERAL_NOBACKUP) || iniConfig.General.noBackup || false, - - noDesktopIcon: - envToBoolean(process.env.TRILIUM_GENERAL_NODESKTOPICON) || iniConfig.General.noDesktopIcon || false, - - readOnly: - envToBoolean(process.env.TRILIUM_GENERAL_READONLY) || iniConfig.General.readOnly || false + instanceName: { + standardEnvVar: 'TRILIUM_GENERAL_INSTANCENAME', + iniGetter: () => getIniSection("General")?.instanceName, + defaultValue: '' + }, + noAuthentication: { + standardEnvVar: 'TRILIUM_GENERAL_NOAUTHENTICATION', + iniGetter: () => getIniSection("General")?.noAuthentication, + defaultValue: false, + transformer: transformBoolean + }, + noBackup: { + standardEnvVar: 'TRILIUM_GENERAL_NOBACKUP', + iniGetter: () => getIniSection("General")?.noBackup, + defaultValue: false, + transformer: transformBoolean + }, + noDesktopIcon: { + standardEnvVar: 'TRILIUM_GENERAL_NODESKTOPICON', + iniGetter: () => getIniSection("General")?.noDesktopIcon, + defaultValue: false, + transformer: transformBoolean + }, + readOnly: { + standardEnvVar: 'TRILIUM_GENERAL_READONLY', + iniGetter: () => getIniSection("General")?.readOnly, + defaultValue: false, + transformer: transformBoolean + } }, - Network: { - host: - process.env.TRILIUM_NETWORK_HOST || iniConfig.Network.host || "0.0.0.0", - - port: - process.env.TRILIUM_NETWORK_PORT || iniConfig.Network.port || "3000", - - https: - envToBoolean(process.env.TRILIUM_NETWORK_HTTPS) || iniConfig.Network.https || false, - - certPath: - process.env.TRILIUM_NETWORK_CERTPATH || iniConfig.Network.certPath || "", - - keyPath: - process.env.TRILIUM_NETWORK_KEYPATH || iniConfig.Network.keyPath || "", - - trustedReverseProxy: - process.env.TRILIUM_NETWORK_TRUSTEDREVERSEPROXY || iniConfig.Network.trustedReverseProxy || false, - - corsAllowOrigin: - process.env.TRILIUM_NETWORK_CORS_ALLOW_ORIGIN || iniConfig.Network.corsAllowOrigin || "", - - corsAllowMethods: - process.env.TRILIUM_NETWORK_CORS_ALLOW_METHODS || iniConfig.Network.corsAllowMethods || "", - - corsAllowHeaders: - process.env.TRILIUM_NETWORK_CORS_ALLOW_HEADERS || iniConfig.Network.corsAllowHeaders || "" + host: { + standardEnvVar: 'TRILIUM_NETWORK_HOST', + iniGetter: () => getIniSection("Network")?.host, + defaultValue: '0.0.0.0' + }, + port: { + standardEnvVar: 'TRILIUM_NETWORK_PORT', + iniGetter: () => getIniSection("Network")?.port, + defaultValue: '3000' + }, + https: { + standardEnvVar: 'TRILIUM_NETWORK_HTTPS', + iniGetter: () => getIniSection("Network")?.https, + defaultValue: false, + transformer: transformBoolean + }, + certPath: { + standardEnvVar: 'TRILIUM_NETWORK_CERTPATH', + iniGetter: () => getIniSection("Network")?.certPath, + defaultValue: '' + }, + keyPath: { + standardEnvVar: 'TRILIUM_NETWORK_KEYPATH', + iniGetter: () => getIniSection("Network")?.keyPath, + defaultValue: '' + }, + trustedReverseProxy: { + standardEnvVar: 'TRILIUM_NETWORK_TRUSTEDREVERSEPROXY', + iniGetter: () => getIniSection("Network")?.trustedReverseProxy, + defaultValue: false as boolean | string + }, + corsAllowOrigin: { + standardEnvVar: 'TRILIUM_NETWORK_CORSALLOWORIGIN', + // alternative with underscore format + aliasEnvVars: ['TRILIUM_NETWORK_CORS_ALLOW_ORIGIN'], + iniGetter: () => getIniSection("Network")?.corsAllowOrigin, + defaultValue: '' + }, + corsAllowMethods: { + standardEnvVar: 'TRILIUM_NETWORK_CORSALLOWMETHODS', + // alternative with underscore format + aliasEnvVars: ['TRILIUM_NETWORK_CORS_ALLOW_METHODS'], + iniGetter: () => getIniSection("Network")?.corsAllowMethods, + defaultValue: '' + }, + corsAllowHeaders: { + standardEnvVar: 'TRILIUM_NETWORK_CORSALLOWHEADERS', + // alternative with underscore format + aliasEnvVars: ['TRILIUM_NETWORK_CORS_ALLOW_HEADERS'], + iniGetter: () => getIniSection("Network")?.corsAllowHeaders, + defaultValue: '' + } }, - Session: { - cookieMaxAge: - parseInt(String(process.env.TRILIUM_SESSION_COOKIEMAXAGE)) || parseInt(iniConfig?.Session?.cookieMaxAge) || 21 * 24 * 60 * 60 // 21 Days in Seconds + cookieMaxAge: { + standardEnvVar: 'TRILIUM_SESSION_COOKIEMAXAGE', + iniGetter: () => getIniSection("Session")?.cookieMaxAge, + defaultValue: 21 * 24 * 60 * 60, // 21 Days in Seconds + transformer: (value: unknown) => parseInt(String(value)) || 21 * 24 * 60 * 60 + } }, - Sync: { - syncServerHost: - process.env.TRILIUM_SYNC_SERVER_HOST || iniConfig?.Sync?.syncServerHost || "", - - syncServerTimeout: - process.env.TRILIUM_SYNC_SERVER_TIMEOUT || iniConfig?.Sync?.syncServerTimeout || "120000", - - syncProxy: - // additionally checking in iniConfig for inconsistently named syncProxy for backwards compatibility - process.env.TRILIUM_SYNC_SERVER_PROXY || iniConfig?.Sync?.syncProxy || iniConfig?.Sync?.syncServerProxy || "" + syncServerHost: { + standardEnvVar: 'TRILIUM_SYNC_SYNCSERVERHOST', + // alternative format + aliasEnvVars: ['TRILIUM_SYNC_SERVER_HOST'], + iniGetter: () => getIniSection("Sync")?.syncServerHost, + defaultValue: '' + }, + syncServerTimeout: { + standardEnvVar: 'TRILIUM_SYNC_SYNCSERVERTIMEOUT', + // alternative format + aliasEnvVars: ['TRILIUM_SYNC_SERVER_TIMEOUT'], + iniGetter: () => getIniSection("Sync")?.syncServerTimeout, + defaultValue: '120000' + }, + syncProxy: { + standardEnvVar: 'TRILIUM_SYNC_SYNCPROXY', + // alternative shorter formats + aliasEnvVars: ['TRILIUM_SYNC_SERVER_PROXY'], + // The INI config uses 'syncServerProxy' key for historical reasons (see config-sample.ini) + // We check both 'syncProxy' and 'syncServerProxy' for backward compatibility with old configs + iniGetter: () => getIniSection("Sync")?.syncProxy || getIniSection("Sync")?.syncServerProxy, + defaultValue: '' + } }, - MultiFactorAuthentication: { - oauthBaseUrl: - process.env.TRILIUM_OAUTH_BASE_URL || iniConfig?.MultiFactorAuthentication?.oauthBaseUrl || "", - - oauthClientId: - process.env.TRILIUM_OAUTH_CLIENT_ID || iniConfig?.MultiFactorAuthentication?.oauthClientId || "", - - oauthClientSecret: - process.env.TRILIUM_OAUTH_CLIENT_SECRET || iniConfig?.MultiFactorAuthentication?.oauthClientSecret || "", - - oauthIssuerBaseUrl: - process.env.TRILIUM_OAUTH_ISSUER_BASE_URL || iniConfig?.MultiFactorAuthentication?.oauthIssuerBaseUrl || "https://accounts.google.com", - - oauthIssuerName: - process.env.TRILIUM_OAUTH_ISSUER_NAME || iniConfig?.MultiFactorAuthentication?.oauthIssuerName || "Google", - - oauthIssuerIcon: - process.env.TRILIUM_OAUTH_ISSUER_ICON || iniConfig?.MultiFactorAuthentication?.oauthIssuerIcon || "" + oauthBaseUrl: { + standardEnvVar: 'TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHBASEURL', + // alternative shorter format (commonly used) + aliasEnvVars: ['TRILIUM_OAUTH_BASE_URL'], + iniGetter: () => getIniSection("MultiFactorAuthentication")?.oauthBaseUrl, + defaultValue: '' + }, + oauthClientId: { + standardEnvVar: 'TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHCLIENTID', + // alternative format + aliasEnvVars: ['TRILIUM_OAUTH_CLIENT_ID'], + iniGetter: () => getIniSection("MultiFactorAuthentication")?.oauthClientId, + defaultValue: '' + }, + oauthClientSecret: { + standardEnvVar: 'TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHCLIENTSECRET', + // alternative format + aliasEnvVars: ['TRILIUM_OAUTH_CLIENT_SECRET'], + iniGetter: () => getIniSection("MultiFactorAuthentication")?.oauthClientSecret, + defaultValue: '' + }, + oauthIssuerBaseUrl: { + standardEnvVar: 'TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERBASEURL', + // alternative format + aliasEnvVars: ['TRILIUM_OAUTH_ISSUER_BASE_URL'], + iniGetter: () => getIniSection("MultiFactorAuthentication")?.oauthIssuerBaseUrl, + defaultValue: 'https://accounts.google.com' + }, + oauthIssuerName: { + standardEnvVar: 'TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERNAME', + // alternative format + aliasEnvVars: ['TRILIUM_OAUTH_ISSUER_NAME'], + iniGetter: () => getIniSection("MultiFactorAuthentication")?.oauthIssuerName, + defaultValue: 'Google' + }, + oauthIssuerIcon: { + standardEnvVar: 'TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERICON', + // alternative format + aliasEnvVars: ['TRILIUM_OAUTH_ISSUER_ICON'], + iniGetter: () => getIniSection("MultiFactorAuthentication")?.oauthIssuerIcon, + defaultValue: '' + } }, - Logging: { - retentionDays: - stringToInt(process.env.TRILIUM_LOGGING_RETENTION_DAYS) ?? - stringToInt(iniConfig?.Logging?.retentionDays) ?? - LOGGING_DEFAULT_RETENTION_DAYS + retentionDays: { + standardEnvVar: 'TRILIUM_LOGGING_RETENTIONDAYS', + // alternative with underscore format + aliasEnvVars: ['TRILIUM_LOGGING_RETENTION_DAYS'], + iniGetter: () => getIniSection("Logging")?.retentionDays, + defaultValue: LOGGING_DEFAULT_RETENTION_DAYS, + transformer: (value: unknown) => stringToInt(String(value)) ?? LOGGING_DEFAULT_RETENTION_DAYS + } } }; -export default config; +/** + * Build the final configuration object by resolving all values through the mapping. + * + * This creates the runtime configuration used throughout the application by: + * 1. Iterating through each section and key in the mapping + * 2. Calling getConfigValue() to resolve each setting with proper precedence + * 3. Applying type transformers where needed (booleans, integers) + * 4. Returning a fully typed TriliumConfig object + * + * The resulting config object is immutable at runtime and represents + * the complete application configuration. + */ +const config: TriliumConfig = { + General: { + instanceName: getConfigValue(configMapping.General.instanceName), + noAuthentication: getConfigValue(configMapping.General.noAuthentication), + noBackup: getConfigValue(configMapping.General.noBackup), + noDesktopIcon: getConfigValue(configMapping.General.noDesktopIcon), + readOnly: getConfigValue(configMapping.General.readOnly) + }, + Network: { + host: getConfigValue(configMapping.Network.host), + port: getConfigValue(configMapping.Network.port), + https: getConfigValue(configMapping.Network.https), + certPath: getConfigValue(configMapping.Network.certPath), + keyPath: getConfigValue(configMapping.Network.keyPath), + trustedReverseProxy: getConfigValue(configMapping.Network.trustedReverseProxy), + corsAllowOrigin: getConfigValue(configMapping.Network.corsAllowOrigin), + corsAllowMethods: getConfigValue(configMapping.Network.corsAllowMethods), + corsAllowHeaders: getConfigValue(configMapping.Network.corsAllowHeaders) + }, + Session: { + cookieMaxAge: getConfigValue(configMapping.Session.cookieMaxAge) + }, + Sync: { + syncServerHost: getConfigValue(configMapping.Sync.syncServerHost), + syncServerTimeout: getConfigValue(configMapping.Sync.syncServerTimeout), + syncProxy: getConfigValue(configMapping.Sync.syncProxy) + }, + MultiFactorAuthentication: { + oauthBaseUrl: getConfigValue(configMapping.MultiFactorAuthentication.oauthBaseUrl), + oauthClientId: getConfigValue(configMapping.MultiFactorAuthentication.oauthClientId), + oauthClientSecret: getConfigValue(configMapping.MultiFactorAuthentication.oauthClientSecret), + oauthIssuerBaseUrl: getConfigValue(configMapping.MultiFactorAuthentication.oauthIssuerBaseUrl), + oauthIssuerName: getConfigValue(configMapping.MultiFactorAuthentication.oauthIssuerName), + oauthIssuerIcon: getConfigValue(configMapping.MultiFactorAuthentication.oauthIssuerIcon) + }, + Logging: { + retentionDays: getConfigValue(configMapping.Logging.retentionDays) + } +}; + +/** + * ===================================================================== + * ENVIRONMENT VARIABLE REFERENCE + * ===================================================================== + * + * Trilium supports flexible environment variable configuration with multiple + * naming patterns. Both formats below are equally valid and can be used + * based on your preference. + * + * CONFIGURATION PRECEDENCE: + * 1. Environment variables (highest priority) + * 2. config.ini file values + * 3. Default values (lowest priority) + * + * FULL FORMAT VARIABLES (following TRILIUM_[SECTION]_[KEY] pattern): + * ==================================================================== + * + * General Section: + * - TRILIUM_GENERAL_INSTANCENAME : Custom instance identifier + * - TRILIUM_GENERAL_NOAUTHENTICATION : Disable auth (true/false) + * - TRILIUM_GENERAL_NOBACKUP : Disable backups (true/false) + * - TRILIUM_GENERAL_NODESKTOPICON : No desktop icon (true/false) + * - TRILIUM_GENERAL_READONLY : Read-only mode (true/false) + * + * Network Section: + * - TRILIUM_NETWORK_HOST : Bind address (e.g., "0.0.0.0") + * - TRILIUM_NETWORK_PORT : Server port (e.g., "8080") + * - TRILIUM_NETWORK_HTTPS : Enable HTTPS (true/false) + * - TRILIUM_NETWORK_CERTPATH : SSL certificate file path + * - TRILIUM_NETWORK_KEYPATH : SSL private key file path + * - TRILIUM_NETWORK_TRUSTEDREVERSEPROXY : Trust proxy headers (true/false/IP) + * - TRILIUM_NETWORK_CORSALLOWORIGIN : CORS allowed origins + * - TRILIUM_NETWORK_CORSALLOWMETHODS : CORS allowed HTTP methods + * - TRILIUM_NETWORK_CORSALLOWHEADERS : CORS allowed headers + * + * Session Section: + * - TRILIUM_SESSION_COOKIEMAXAGE : Cookie lifetime in seconds + * + * Sync Section: + * - TRILIUM_SYNC_SYNCSERVERHOST : Sync server URL + * - TRILIUM_SYNC_SYNCSERVERTIMEOUT : Sync timeout in milliseconds + * - TRILIUM_SYNC_SYNCPROXY : Proxy URL for sync + * + * Multi-Factor Authentication Section: + * - TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHBASEURL : OAuth base URL + * - TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHCLIENTID : OAuth client ID + * - TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHCLIENTSECRET : OAuth client secret + * - TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERBASEURL : OAuth issuer URL + * - TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERNAME : OAuth provider name + * - TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERICON : OAuth provider icon + * + * Logging Section: + * - TRILIUM_LOGGING_RETENTIONDAYS : Log retention period in days + * + * SHORTER ALTERNATIVE VARIABLES (equally valid, for convenience): + * ================================================================ + * + * Network CORS (with underscores): + * - TRILIUM_NETWORK_CORS_ALLOW_ORIGIN : Same as TRILIUM_NETWORK_CORSALLOWORIGIN + * - TRILIUM_NETWORK_CORS_ALLOW_METHODS : Same as TRILIUM_NETWORK_CORSALLOWMETHODS + * - TRILIUM_NETWORK_CORS_ALLOW_HEADERS : Same as TRILIUM_NETWORK_CORSALLOWHEADERS + * + * Sync (with SERVER prefix): + * - TRILIUM_SYNC_SERVER_HOST : Same as TRILIUM_SYNC_SYNCSERVERHOST + * - TRILIUM_SYNC_SERVER_TIMEOUT : Same as TRILIUM_SYNC_SYNCSERVERTIMEOUT + * - TRILIUM_SYNC_SERVER_PROXY : Same as TRILIUM_SYNC_SYNCPROXY + * + * OAuth (simplified without section name): + * - TRILIUM_OAUTH_BASE_URL : Same as TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHBASEURL + * - TRILIUM_OAUTH_CLIENT_ID : Same as TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHCLIENTID + * - TRILIUM_OAUTH_CLIENT_SECRET : Same as TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHCLIENTSECRET + * - TRILIUM_OAUTH_ISSUER_BASE_URL : Same as TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERBASEURL + * - TRILIUM_OAUTH_ISSUER_NAME : Same as TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERNAME + * - TRILIUM_OAUTH_ISSUER_ICON : Same as TRILIUM_MULTIFACTORAUTHENTICATION_OAUTHISSUERICON + * + * Logging (with underscore): + * - TRILIUM_LOGGING_RETENTION_DAYS : Same as TRILIUM_LOGGING_RETENTIONDAYS + * + * BOOLEAN VALUES: + * - Accept: "true", "false", "1", "0", 1, 0 + * - Default to false for invalid values + * + * EXAMPLES: + * export TRILIUM_NETWORK_PORT="8080" # Using full format + * export TRILIUM_OAUTH_CLIENT_ID="my-client-id" # Using shorter alternative + * export TRILIUM_GENERAL_NOAUTHENTICATION="true" # Boolean value + * export TRILIUM_SYNC_SERVER_HOST="https://sync.example.com" # Using alternative with SERVER + */ + +/** + * The exported configuration object used throughout the Trilium application. + * This object is resolved once at startup and remains immutable during runtime. + * + * To override any setting: + * 1. Set an environment variable (see documentation above) + * 2. Edit config.ini in your data directory + * 3. Defaults will be used if neither is provided + * + * @example + * // Accessing configuration in other modules: + * import config from './services/config.js'; + * + * if (config.General.noAuthentication) { + * // Skip authentication checks + * } + * + * const server = https.createServer({ + * cert: fs.readFileSync(config.Network.certPath), + * key: fs.readFileSync(config.Network.keyPath) + * }); + */ +export default config; \ No newline at end of file