diff --git a/packages/sync-server/migrations/1694360000000-create-folders.js b/packages/sync-server/migrations/1694360000000-create-folders.js index fd5043783e6..b86a7c89b25 100644 --- a/packages/sync-server/migrations/1694360000000-create-folders.js +++ b/packages/sync-server/migrations/1694360000000-create-folders.js @@ -15,11 +15,11 @@ async function ensureExists(path) { } export const up = async function () { - await ensureExists(config.serverFiles); - await ensureExists(config.userFiles); + await ensureExists(config.get('serverFiles')); + await ensureExists(config.get('userFiles')); }; export const down = async function () { - await fs.rm(config.serverFiles, { recursive: true, force: true }); - await fs.rm(config.userFiles, { recursive: true, force: true }); + await fs.rm(config.get('serverFiles'), { recursive: true, force: true }); + await fs.rm(config.get('userFiles'), { recursive: true, force: true }); }; diff --git a/packages/sync-server/package.json b/packages/sync-server/package.json index 8de0b4ef5a1..c9e9479ae8a 100644 --- a/packages/sync-server/package.json +++ b/packages/sync-server/package.json @@ -47,6 +47,7 @@ "@babel/preset-typescript": "^7.20.2", "@types/bcrypt": "^5.0.2", "@types/better-sqlite3": "^7.6.12", + "@types/convict": "^6", "@types/cors": "^2.8.13", "@types/express": "^5.0.0", "@types/express-actuator": "^1.8.3", @@ -56,6 +57,7 @@ "@types/uuid": "^9.0.0", "@typescript-eslint/eslint-plugin": "^5.51.0", "@typescript-eslint/parser": "^5.51.0", + "convict": "^6.2.4", "eslint": "^8.33.0", "eslint-plugin-prettier": "^4.2.1", "http-proxy-middleware": "^3.0.3", diff --git a/packages/sync-server/src/account-db.js b/packages/sync-server/src/account-db.js index b94d4131c61..ec8897d5f0c 100644 --- a/packages/sync-server/src/account-db.js +++ b/packages/sync-server/src/account-db.js @@ -11,7 +11,7 @@ let _accountDb; export function getAccountDb() { if (_accountDb === undefined) { - const dbPath = join(config.serverFiles, 'account.sqlite'); + const dbPath = join(config.get('serverFiles'), 'account.sqlite'); _accountDb = openDatabase(dbPath); } @@ -29,7 +29,9 @@ export function listLoginMethods() { const rows = accountDb.all('SELECT method, display_name, active FROM auth'); return rows .filter(f => - rows.length > 1 && config.enforceOpenId ? f.method === 'openid' : true, + rows.length > 1 && config.get('enforceOpenId') + ? f.method === 'openid' + : true, ) .map(r => ({ method: r.method, @@ -55,13 +57,13 @@ export function getLoginMethod(req) { if ( typeof req !== 'undefined' && (req.body || { loginMethod: null }).loginMethod && - config.allowedLoginMethods.includes(req.body.loginMethod) + config.get('allowedLoginMethods').includes(req.body.loginMethod) ) { return req.body.loginMethod; } - if (config.loginMethod) { - return config.loginMethod; + if (config.get('loginMethod')) { + return config.get('loginMethod'); } const activeMethod = getActiveLoginMethod(); diff --git a/packages/sync-server/src/accounts/openid.js b/packages/sync-server/src/accounts/openid.js index 508e85d00c1..ba734f30828 100644 --- a/packages/sync-server/src/accounts/openid.js +++ b/packages/sync-server/src/accounts/openid.js @@ -2,29 +2,29 @@ import { generators, Issuer } from 'openid-client'; import { v4 as uuidv4 } from 'uuid'; import { clearExpiredSessions, getAccountDb } from '../account-db.js'; -import { config as finalConfig } from '../load-config.js'; +import { config } from '../load-config.js'; import { getUserByUsername, transferAllFilesFromUser, } from '../services/user-service.js'; import { TOKEN_EXPIRATION_NEVER } from '../util/validate-user.js'; -export async function bootstrapOpenId(config) { - if (!('issuer' in config)) { - return { error: 'missing-issuer' }; +export async function bootstrapOpenId(configParameter) { + if (!('issuer' in configParameter) || !('discoveryURL' in configParameter)) { + return { error: 'missing-issuer-or-discoveryURL' }; } - if (!('client_id' in config)) { + if (!('client_id' in configParameter)) { return { error: 'missing-client-id' }; } - if (!('client_secret' in config)) { + if (!('client_secret' in configParameter)) { return { error: 'missing-client-secret' }; } - if (!('server_hostname' in config)) { + if (!('server_hostname' in configParameter)) { return { error: 'missing-server-hostname' }; } try { - await setupOpenIdClient(config); + await setupOpenIdClient(configParameter); } catch (err) { console.error('Error setting up OpenID client:', err); return { error: 'configuration-error' }; @@ -37,7 +37,7 @@ export async function bootstrapOpenId(config) { accountDb.mutate('UPDATE auth SET active = 0'); accountDb.mutate( "INSERT INTO auth (method, display_name, extra_data, active) VALUES ('openid', 'OpenID', ?, 1)", - [JSON.stringify(config)], + [JSON.stringify(configParameter)], ); }); } catch (err) { @@ -48,23 +48,22 @@ export async function bootstrapOpenId(config) { return {}; } -async function setupOpenIdClient(config) { - const issuer = - typeof config.issuer === 'string' - ? await Issuer.discover(config.issuer) - : new Issuer({ - issuer: config.issuer.name, - authorization_endpoint: config.issuer.authorization_endpoint, - token_endpoint: config.issuer.token_endpoint, - userinfo_endpoint: config.issuer.userinfo_endpoint, - }); +async function setupOpenIdClient(configParameter) { + const issuer = configParameter.discoveryURL + ? await Issuer.discover(configParameter.discoveryURL) + : new Issuer({ + issuer: configParameter.issuer.name, + authorization_endpoint: configParameter.issuer.authorization_endpoint, + token_endpoint: configParameter.issuer.token_endpoint, + userinfo_endpoint: configParameter.issuer.userinfo_endpoint, + }); const client = new issuer.Client({ - client_id: config.client_id, - client_secret: config.client_secret, + client_id: configParameter.client_id, + client_secret: configParameter.client_secret, redirect_uri: new URL( '/openid/callback', - config.server_hostname, + configParameter.server_hostname, ).toString(), validate_id_token: true, }); @@ -139,21 +138,21 @@ export async function loginWithOpenIdFinalize(body) { } const accountDb = getAccountDb(); - let config = accountDb.first( + let configFromDb = accountDb.first( "SELECT extra_data FROM auth WHERE method = 'openid' AND active = 1", ); - if (!config) { + if (!configFromDb) { return { error: 'openid-not-configured' }; } try { - config = JSON.parse(config['extra_data']); + configFromDb = JSON.parse(configFromDb['extra_data']); } catch (err) { console.error('Error parsing OpenID configuration:', err); return { error: 'openid-setup-failed' }; } let client; try { - client = await setupOpenIdClient(config); + client = await setupOpenIdClient(configFromDb); } catch (err) { console.error('Error setting up OpenID client:', err); return { error: 'openid-setup-failed' }; @@ -173,7 +172,7 @@ export async function loginWithOpenIdFinalize(body) { try { let tokenSet = null; - if (!config.authMethod || config.authMethod === 'openid') { + if (!configFromDb.authMethod || configFromDb.authMethod === 'openid') { const params = { code: body.code, state: body.state }; tokenSet = await client.callback(client.redirect_uris[0], params, { code_verifier, @@ -264,13 +263,13 @@ export async function loginWithOpenIdFinalize(body) { const token = uuidv4(); let expiration; - if (finalConfig.token_expiration === 'openid-provider') { + if (config.get('token_expiration') === 'openid-provider') { expiration = tokenSet.expires_at ?? TOKEN_EXPIRATION_NEVER; - } else if (finalConfig.token_expiration === 'never') { + } else if (config.get('token_expiration') === 'never') { expiration = TOKEN_EXPIRATION_NEVER; - } else if (typeof finalConfig.token_expiration === 'number') { + } else if (typeof config.get('token_expiration') === 'number') { expiration = - Math.floor(Date.now() / 1000) + finalConfig.token_expiration * 60; + Math.floor(Date.now() / 1000) + config.get('token_expiration') * 60; } else { expiration = Math.floor(Date.now() / 1000) + 10 * 60; } diff --git a/packages/sync-server/src/accounts/password.js b/packages/sync-server/src/accounts/password.js index 57a0e46e3c5..6fa7762a3ea 100644 --- a/packages/sync-server/src/accounts/password.js +++ b/packages/sync-server/src/accounts/password.js @@ -85,11 +85,12 @@ export function loginWithPassword(password) { let expiration = TOKEN_EXPIRATION_NEVER; if ( - config.token_expiration !== 'never' && - config.token_expiration !== 'openid-provider' && - typeof config.token_expiration === 'number' + config.get('token_expiration') !== 'never' && + config.get('token_expiration') !== 'openid-provider' && + typeof config.get('token_expiration') === 'number' ) { - expiration = Math.floor(Date.now() / 1000) + config.token_expiration * 60; + expiration = + Math.floor(Date.now() / 1000) + config.get('token_expiration') * 60; } if (!sessionRow) { diff --git a/packages/sync-server/src/app.js b/packages/sync-server/src/app.js index 73c258bb705..3d2af1d26d8 100644 --- a/packages/sync-server/src/app.js +++ b/packages/sync-server/src/app.js @@ -23,7 +23,7 @@ process.on('unhandledRejection', reason => { app.disable('x-powered-by'); app.use(cors()); -app.set('trust proxy', config.trustedProxies); +app.set('trust proxy', config.get('trustedProxies')); if (process.env.NODE_ENV !== 'development') { app.use( rateLimit({ @@ -35,17 +35,19 @@ if (process.env.NODE_ENV !== 'development') { ); } -app.use(bodyParser.json({ limit: `${config.upload.fileSizeLimitMB}mb` })); +app.use( + bodyParser.json({ limit: `${config.get('upload.fileSizeLimitMB')}mb` }), +); app.use( bodyParser.raw({ type: 'application/actual-sync', - limit: `${config.upload.fileSizeSyncLimitMB}mb`, + limit: `${config.get('upload.fileSizeSyncLimitMB')}mb`, }), ); app.use( bodyParser.raw({ type: 'application/encrypted-file', - limit: `${config.upload.syncEncryptedFileSizeLimitMB}mb`, + limit: `${config.get('upload.syncEncryptedFileSizeLimitMB')}mb`, }), ); @@ -59,7 +61,7 @@ app.use('/admin', adminApp.handlers); app.use('/openid', openidApp.handlers); app.get('/mode', (req, res) => { - res.send(config.mode); + res.send(config.get('mode')); }); app.use(actuator()); // Provides /health, /metrics, /info @@ -89,8 +91,10 @@ if (process.env.NODE_ENV === 'development') { } else { console.log('Running in production mode - Serving static React app'); - app.use(express.static(config.webRoot, { index: false })); - app.get('/*', (req, res) => res.sendFile(config.webRoot + '/index.html')); + app.use(express.static(config.get('webRoot'), { index: false })); + app.get('/*', (req, res) => + res.sendFile(config.get('webRoot') + '/index.html'), + ); } function parseHTTPSConfig(value) { @@ -101,17 +105,21 @@ function parseHTTPSConfig(value) { } export async function run() { - if (config.https) { + if (config.get('https.key') && config.get('https.cert')) { const https = await import('node:https'); const httpsOptions = { ...config.https, - key: parseHTTPSConfig(config.https.key), - cert: parseHTTPSConfig(config.https.cert), + key: parseHTTPSConfig(config.get('https.key')), + cert: parseHTTPSConfig(config.get('https.cert')), }; - https.createServer(httpsOptions, app).listen(config.port, config.hostname); + https + .createServer(httpsOptions, app) + .listen(config.get('port'), config.get('hostname')); } else { - app.listen(config.port, config.hostname); + app.listen(config.get('port'), config.get('hostname')); } - console.log('Listening on ' + config.hostname + ':' + config.port + '...'); + console.log( + 'Listening on ' + config.get('hostname') + ':' + config.get('port') + '...', + ); } diff --git a/packages/sync-server/src/config-types.ts b/packages/sync-server/src/config-types.ts index 957f3c8b38c..3e9e76d1365 100644 --- a/packages/sync-server/src/config-types.ts +++ b/packages/sync-server/src/config-types.ts @@ -38,7 +38,6 @@ export interface Config { server_hostname: string; authMethod?: 'openid' | 'oauth2'; }; - multiuser: boolean; token_expiration?: 'never' | 'openid-provider' | number; enforceOpenId: boolean; } diff --git a/packages/sync-server/src/load-config.js b/packages/sync-server/src/load-config.js index c6d8820c469..f39c6492b08 100644 --- a/packages/sync-server/src/load-config.js +++ b/packages/sync-server/src/load-config.js @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import convict from 'convict'; import createDebug from 'debug'; const require = createRequire(import.meta.url); @@ -10,257 +11,295 @@ const debug = createDebug('actual:config'); const debugSensitive = createDebug('actual-sensitive:config'); const projectRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); -debug(`project root: '${projectRoot}'`); +const defaultDataDir = fs.existsSync('./data') ? './data' : projectRoot; + +debug(`Project root: '${projectRoot}'`); + export const sqlDir = path.join(projectRoot, 'src', 'sql'); -let defaultDataDir = fs.existsSync('/data') ? '/data' : projectRoot; +const actualAppWebBuildPath = path.join( + path.dirname(require.resolve('@actual-app/web/package.json')), + 'build', +); +debug(`Actual web build path: '${actualAppWebBuildPath}'`); -if (process.env.ACTUAL_DATA_DIR) { - defaultDataDir = process.env.ACTUAL_DATA_DIR; -} +// Custom formats +convict.addFormat({ + name: 'tokenExpiration', + validate(val) { + if (val === 'never' || val === 'openid-provider') return; + if (typeof val === 'number' && Number.isFinite(val) && val >= 0) return; + throw new Error(`Invalid token_expiration value: ${val}`); + }, +}); -debug(`default data directory: '${defaultDataDir}'`); +// Main config schema +const configSchema = convict({ + env: { + doc: 'The application environment.', + format: ['production', 'development', 'test'], + default: 'development', + env: 'NODE_ENV', + }, + mode: { + doc: 'Application mode.', + format: ['test', 'development'], + default: process.env.NODE_ENV === 'test' ? 'test' : 'development', + }, + projectRoot: { + doc: 'Project root directory.', + format: String, + default: projectRoot, + }, + dataDir: { + doc: 'Default data directory.', + format: String, + default: defaultDataDir, + env: 'ACTUAL_DATA_DIR', + }, + port: { + doc: 'Port to run the server on.', + format: 'port', + default: 5006, + env: ['ACTUAL_PORT', 'PORT'], + }, + hostname: { + doc: 'Server hostname.', + format: String, + default: '::', + env: 'ACTUAL_HOSTNAME', + }, + serverFiles: { + doc: 'Path to server files.', + format: String, + default: + process.env.NODE_ENV === 'test' + ? path.join(projectRoot, 'test-server-files') + : path.join(projectRoot, 'server-files'), + env: 'ACTUAL_SERVER_FILES', + }, + userFiles: { + doc: 'Path to user files.', + format: String, + default: + process.env.NODE_ENV === 'test' + ? path.join(projectRoot, 'test-user-files') + : path.join(projectRoot, 'user-files'), + env: 'ACTUAL_USER_FILES', + }, + webRoot: { + doc: 'Web root directory.', + format: String, + default: actualAppWebBuildPath, + env: 'ACTUAL_WEB_ROOT', + }, + loginMethod: { + doc: 'Authentication method.', + format: ['password', 'header', 'openid'], + default: 'password', + env: 'ACTUAL_LOGIN_METHOD', + }, + allowedLoginMethods: { + doc: 'Allowed authentication methods.', + format: Array, + default: ['password', 'header', 'openid'], + env: 'ACTUAL_ALLOWED_LOGIN_METHODS', + }, + trustedProxies: { + doc: 'List of trusted proxies.', + format: Array, + default: [ + '10.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16', + 'fc00::/7', + '::1/128', + ], + env: 'ACTUAL_TRUSTED_PROXIES', + }, + trustedAuthProxies: { + doc: 'List of trusted auth proxies.', + format: Array, + default: [], + env: 'ACTUAL_TRUSTED_AUTH_PROXIES', + }, -function parseJSON(path, allowMissing = false) { - let text; - try { - text = fs.readFileSync(path, 'utf8'); - } catch (e) { - if (allowMissing) { - debug(`config file '${path}' not found, ignoring.`); - return {}; - } - throw e; - } - return JSON.parse(text); -} + https: { + doc: 'HTTPS configuration.', + format: Object, + default: { + key: '', + cert: '', + }, + + key: { + doc: 'HTTPS Certificate key', + format: String, + default: '', + }, + + cert: { + doc: 'HTTPS Certificate', + format: String, + default: '', + }, + }, + + upload: { + doc: 'Upload configuration.', + format: Object, + default: { + fileSizeSyncLimitMB: 20, + syncEncryptedFileSizeLimitMB: 50, + fileSizeLimitMB: 20, + }, + + fileSizeSyncLimitMB: { + doc: 'Sync file size limit (in MB)', + format: 'nat', + default: 20, + env: 'ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB', + }, + + syncEncryptedFileSizeLimitMB: { + doc: 'Encrypted Sync file size limit (in MB)', + format: 'nat', + default: 50, + env: 'ACTUAL_UPLOAD_SYNC_ENCRYPTED_FILE_SYNC_SIZE_LIMIT_MB', + }, + + fileSizeLimitMB: { + doc: 'General file size limit (in MB)', + format: 'nat', + default: 20, + env: 'ACTUAL_UPLOAD_FILE_SIZE_LIMIT_MB', + }, + }, + + openId: { + doc: 'OpenID authentication settings.', + + discoveryURL: { + doc: 'OpenID Provider discovery URL.', + format: String, + default: '', + env: 'ACTUAL_OPENID_DISCOVERY_URL', + }, + issuer: { + doc: 'OpenID issuer', + format: Object, + default: {}, + name: { + doc: 'Name of the provider', + default: '', + format: String, + env: 'ACTUAL_OPENID_PROVIDER_NAME', + }, + authorization_endpoint: { + doc: 'Authorization endpoint', + default: '', + format: String, + env: 'ACTUAL_OPENID_AUTHORIZATION_ENDPOINT', + }, + token_endpoint: { + doc: 'Token endpoint', + default: '', + format: String, + env: 'ACTUAL_OPENID_TOKEN_ENDPOINT', + }, + userinfo_endpoint: { + doc: 'Userinfo endpoint', + default: '', + format: String, + env: 'ACTUAL_OPENID_USERINFO_ENDPOINT', + }, + }, + client_id: { + doc: 'OpenID client ID.', + format: String, + default: '', + env: 'ACTUAL_OPENID_CLIENT_ID', + }, + client_secret: { + doc: 'OpenID client secret.', + format: String, + default: '', + env: 'ACTUAL_OPENID_CLIENT_SECRET', + }, + server_hostname: { + doc: 'OpenID server hostname.', + format: String, + default: '', + env: 'ACTUAL_OPENID_SERVER_HOSTNAME', + }, + authMethod: { + doc: 'OpenID authentication method.', + format: ['openid', 'oauth2'], + default: 'openid', + env: 'ACTUAL_OPENID_AUTH_METHOD', + }, + }, + + token_expiration: { + doc: 'Token expiration time.', + format: 'tokenExpiration', + default: 'never', + env: 'ACTUAL_TOKEN_EXPIRATION', + }, + + enforceOpenId: { + doc: 'Enforce OpenID authentication.', + format: Boolean, + default: false, + env: 'ACTUAL_OPENID_ENFORCE', + }, +}); + +let configPath = null; -let userConfig; if (process.env.ACTUAL_CONFIG_PATH) { debug( `loading config from ACTUAL_CONFIG_PATH: '${process.env.ACTUAL_CONFIG_PATH}'`, ); - userConfig = parseJSON(process.env.ACTUAL_CONFIG_PATH); - - defaultDataDir = userConfig.dataDir ?? defaultDataDir; + configPath = process.env.ACTUAL_CONFIG_PATH; } else { - let configFile = path.join(projectRoot, 'config.json'); + configPath = path.join(projectRoot, 'config.json'); - if (!fs.existsSync(configFile)) { - configFile = path.join(defaultDataDir, 'config.json'); + if (!fs.existsSync(configPath)) { + configPath = path.join(defaultDataDir, 'config.json'); } - debug(`loading config from default path: '${configFile}'`); - userConfig = parseJSON(configFile, true); + debug(`loading config from default path: '${configPath}'`); } -const actualAppWebBuildPath = path.join( - // require.resolve is used to recursively search up the workspace to find the node_modules directory - path.dirname(require.resolve('@actual-app/web/package.json')), - 'build', -); - -debug(`Actual web build path: '${actualAppWebBuildPath}'`); - -/** @type {Omit} */ -const defaultConfig = { - loginMethod: 'password', - allowedLoginMethods: ['password', 'header', 'openid'], - // assume local networks are trusted - trustedProxies: [ - '10.0.0.0/8', - '172.16.0.0/12', - '192.168.0.0/16', - 'fc00::/7', - '::1/128', - ], - // fallback to trustedProxies, but in the future trustedProxies will only be used for express trust - // and trustedAuthProxies will just be for header auth - trustedAuthProxies: null, - port: 5006, - hostname: '::', - webRoot: actualAppWebBuildPath, - upload: { - fileSizeSyncLimitMB: 20, - syncEncryptedFileSizeLimitMB: 50, - fileSizeLimitMB: 20, - }, - projectRoot, - multiuser: false, - token_expiration: 'never', - enforceOpenId: false, -}; - -/** @type {import('./config-types.js').Config} */ -let config; -if (process.env.NODE_ENV === 'test') { - config = { - mode: 'test', - dataDir: projectRoot, - serverFiles: path.join(projectRoot, 'test-server-files'), - userFiles: path.join(projectRoot, 'test-user-files'), - ...defaultConfig, - }; -} else { - config = { - mode: 'development', - ...defaultConfig, - dataDir: defaultDataDir, - serverFiles: path.join(defaultDataDir, 'server-files'), - userFiles: path.join(defaultDataDir, 'user-files'), - ...(userConfig || {}), - }; +if (fs.existsSync(configPath)) { + configSchema.loadFile(configPath); + debug(`Config loaded`); } -const finalConfig = { - ...config, - loginMethod: process.env.ACTUAL_LOGIN_METHOD - ? process.env.ACTUAL_LOGIN_METHOD.toLowerCase() - : config.loginMethod, - multiuser: process.env.ACTUAL_MULTIUSER - ? (() => { - const value = process.env.ACTUAL_MULTIUSER.toLowerCase(); - if (!['true', 'false'].includes(value)) { - throw new Error('ACTUAL_MULTIUSER must be either "true" or "false"'); - } - return value === 'true'; - })() - : config.multiuser, - allowedLoginMethods: process.env.ACTUAL_ALLOWED_LOGIN_METHODS - ? process.env.ACTUAL_ALLOWED_LOGIN_METHODS.split(',') - .map(q => q.trim().toLowerCase()) - .filter(Boolean) - : config.allowedLoginMethods, - trustedProxies: process.env.ACTUAL_TRUSTED_PROXIES - ? process.env.ACTUAL_TRUSTED_PROXIES.split(',') - .map(q => q.trim()) - .filter(Boolean) - : config.trustedProxies, - trustedAuthProxies: process.env.ACTUAL_TRUSTED_AUTH_PROXIES - ? process.env.ACTUAL_TRUSTED_AUTH_PROXIES.split(',') - .map(q => q.trim()) - .filter(Boolean) - : config.trustedAuthProxies, - port: +process.env.ACTUAL_PORT || +process.env.PORT || config.port, - hostname: process.env.ACTUAL_HOSTNAME || config.hostname, - serverFiles: process.env.ACTUAL_SERVER_FILES || config.serverFiles, - userFiles: process.env.ACTUAL_USER_FILES || config.userFiles, - webRoot: process.env.ACTUAL_WEB_ROOT || config.webRoot, - https: - process.env.ACTUAL_HTTPS_KEY && process.env.ACTUAL_HTTPS_CERT - ? { - key: process.env.ACTUAL_HTTPS_KEY.replace(/\\n/g, '\n'), - cert: process.env.ACTUAL_HTTPS_CERT.replace(/\\n/g, '\n'), - ...(config.https || {}), - } - : config.https, - upload: - process.env.ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB || - process.env.ACTUAL_UPLOAD_SYNC_ENCRYPTED_FILE_SYNC_SIZE_LIMIT_MB || - process.env.ACTUAL_UPLOAD_FILE_SIZE_LIMIT_MB - ? { - fileSizeSyncLimitMB: - +process.env.ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB || - +process.env.ACTUAL_UPLOAD_FILE_SIZE_LIMIT_MB || - config.upload.fileSizeSyncLimitMB, - syncEncryptedFileSizeLimitMB: - +process.env.ACTUAL_UPLOAD_SYNC_ENCRYPTED_FILE_SYNC_SIZE_LIMIT_MB || - +process.env.ACTUAL_UPLOAD_FILE_SIZE_LIMIT_MB || - config.upload.syncEncryptedFileSizeLimitMB, - fileSizeLimitMB: - +process.env.ACTUAL_UPLOAD_FILE_SIZE_LIMIT_MB || - config.upload.fileSizeLimitMB, - } - : config.upload, - openId: (() => { - if ( - !process.env.ACTUAL_OPENID_DISCOVERY_URL && - !process.env.ACTUAL_OPENID_AUTHORIZATION_ENDPOINT - ) { - return config.openId; - } - const baseConfig = process.env.ACTUAL_OPENID_DISCOVERY_URL - ? { issuer: process.env.ACTUAL_OPENID_DISCOVERY_URL } - : { - ...(() => { - const required = { - authorization_endpoint: - process.env.ACTUAL_OPENID_AUTHORIZATION_ENDPOINT, - token_endpoint: process.env.ACTUAL_OPENID_TOKEN_ENDPOINT, - userinfo_endpoint: process.env.ACTUAL_OPENID_USERINFO_ENDPOINT, - }; - const missing = Object.entries(required) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - .filter(([_, value]) => !value) - .map(([key]) => key); - if (missing.length > 0) { - throw new Error( - `Missing required OpenID configuration: ${missing.join(', ')}`, - ); - } - return {}; - })(), - issuer: { - name: process.env.ACTUAL_OPENID_PROVIDER_NAME, - authorization_endpoint: - process.env.ACTUAL_OPENID_AUTHORIZATION_ENDPOINT, - token_endpoint: process.env.ACTUAL_OPENID_TOKEN_ENDPOINT, - userinfo_endpoint: process.env.ACTUAL_OPENID_USERINFO_ENDPOINT, - }, - }; - return { - ...baseConfig, - client_id: - process.env.ACTUAL_OPENID_CLIENT_ID ?? config.openId?.client_id, - client_secret: - process.env.ACTUAL_OPENID_CLIENT_SECRET ?? config.openId?.client_secret, - server_hostname: - process.env.ACTUAL_OPENID_SERVER_HOSTNAME ?? - config.openId?.server_hostname, - }; - })(), - token_expiration: process.env.ACTUAL_TOKEN_EXPIRATION - ? process.env.ACTUAL_TOKEN_EXPIRATION - : config.token_expiration, - enforceOpenId: process.env.ACTUAL_OPENID_ENFORCE - ? (() => { - const value = process.env.ACTUAL_OPENID_ENFORCE.toLowerCase(); - if (!['true', 'false'].includes(value)) { - throw new Error( - 'ACTUAL_OPENID_ENFORCE must be either "true" or "false"', - ); - } - return value === 'true'; - })() - : config.enforceOpenId, -}; -debug(`using port ${finalConfig.port}`); -debug(`using hostname ${finalConfig.hostname}`); -debug(`using data directory ${finalConfig.dataDir}`); -debug(`using server files directory ${finalConfig.serverFiles}`); -debug(`using user files directory ${finalConfig.userFiles}`); -debug(`using web root directory ${finalConfig.webRoot}`); -debug(`using login method ${finalConfig.loginMethod}`); -debug(`using trusted proxies ${finalConfig.trustedProxies.join(', ')}`); -debug( - `using trusted auth proxies ${ - finalConfig.trustedAuthProxies?.join(', ') ?? 'same as trusted proxies' - }`, -); +debug(`Validating config`); +configSchema.validate({ allowed: 'strict' }); -if (finalConfig.https) { - debug(`using https key: ${'*'.repeat(finalConfig.https.key.length)}`); - debugSensitive(`using https key ${finalConfig.https.key}`); - debug(`using https cert: ${'*'.repeat(finalConfig.https.cert.length)}`); - debugSensitive(`using https cert ${finalConfig.https.cert}`); +debug(`Project root: ${configSchema.get('projectRoot')}`); +debug(`Port: ${configSchema.get('port')}`); +debug(`Hostname: ${configSchema.get('hostname')}`); +debug(`Data directory: ${configSchema.get('dataDir')}`); +debug(`Server files: ${configSchema.get('serverFiles')}`); +debug(`User files: ${configSchema.get('userFiles')}`); +debug(`Web root: ${configSchema.get('webRoot')}`); +debug(`Login method: ${configSchema.get('loginMethod')}`); +debug(`Allowed methods: ${configSchema.get('allowedLoginMethods').join(', ')}`); + +const httpsKey = configSchema.get('https.key'); +if (httpsKey) { + debug(`HTTPS Key: ${'*'.repeat(httpsKey.length)}`); + debugSensitive(`HTTPS Key: ${httpsKey}`); } -if (finalConfig.upload) { - debug(`using file sync limit ${finalConfig.upload.fileSizeSyncLimitMB}mb`); - debug( - `using sync encrypted file limit ${finalConfig.upload.syncEncryptedFileSizeLimitMB}mb`, - ); - debug(`using file limit ${finalConfig.upload.fileSizeLimitMB}mb`); +const httpsCert = configSchema.get('https.cert'); +if (httpsCert) { + debug(`HTTPS Cert: ${'*'.repeat(httpsCert.length)}`); + debugSensitive(`HTTPS Cert: ${httpsCert}`); } -export { finalConfig as config }; +export { configSchema as config }; diff --git a/packages/sync-server/src/migrations.js b/packages/sync-server/src/migrations.js index 1795f0e65ef..9160c5ce506 100644 --- a/packages/sync-server/src/migrations.js +++ b/packages/sync-server/src/migrations.js @@ -12,10 +12,10 @@ export function run(direction = 'up') { return new Promise(resolve => migrate.load( { - stateStore: `${path.join(config.dataDir, '.migrate')}${ - config.mode === 'test' ? '-test' : '' + stateStore: `${path.join(config.get('dataDir'), '.migrate')}${ + config.get('mode') === 'test' ? '-test' : '' }`, - migrationsDirectory: `${path.join(config.projectRoot, 'migrations')}`, + migrationsDirectory: `${path.join(config.get('projectRoot'), 'migrations')}`, }, (err, set) => { if (err) { diff --git a/packages/sync-server/src/scripts/enable-openid.js b/packages/sync-server/src/scripts/enable-openid.js index 22f877aaf23..25e1b9e0295 100644 --- a/packages/sync-server/src/scripts/enable-openid.js +++ b/packages/sync-server/src/scripts/enable-openid.js @@ -21,7 +21,7 @@ if (needsBootstrap()) { console.log('OpenID already enabled.'); process.exit(0); } - const { error } = (await enableOpenID(config)) || {}; + const { error } = (await enableOpenID(config.getProperties())) || {}; if (error) { console.log('Error enabling openid:', error); diff --git a/packages/sync-server/src/scripts/health-check.js b/packages/sync-server/src/scripts/health-check.js index dfbec31525d..ada40d8d951 100644 --- a/packages/sync-server/src/scripts/health-check.js +++ b/packages/sync-server/src/scripts/health-check.js @@ -2,10 +2,12 @@ import fetch from 'node-fetch'; import { config } from '../load-config.js'; -const protocol = config.https ? 'https' : 'http'; -const hostname = config.hostname === '::' ? 'localhost' : config.hostname; +const protocol = + config.get('https.key') && config.get('https.cert') ? 'https' : 'http'; +const hostname = + config.get('hostname') === '::' ? 'localhost' : config.get('hostname'); -fetch(`${protocol}://${hostname}:${config.port}/health`) +fetch(`${protocol}://${hostname}:${config.get('port')}/health`) .then(res => res.json()) .then(res => { if (res.status !== 'UP') { diff --git a/packages/sync-server/src/util/paths.js b/packages/sync-server/src/util/paths.js index 59f54cd3548..47cdf4faf7b 100644 --- a/packages/sync-server/src/util/paths.js +++ b/packages/sync-server/src/util/paths.js @@ -4,10 +4,10 @@ import { config } from '../load-config.js'; /** @param {string} fileId */ export function getPathForUserFile(fileId) { - return join(config.userFiles, `file-${fileId}.blob`); + return join(config.get('userFiles'), `file-${fileId}.blob`); } /** @param {string} groupId */ export function getPathForGroupFile(groupId) { - return join(config.userFiles, `group-${groupId}.sqlite`); + return join(config.get('userFiles'), `group-${groupId}.sqlite`); } diff --git a/packages/sync-server/src/util/validate-user.js b/packages/sync-server/src/util/validate-user.js index 89ca19d21e4..4075a6b4d69 100644 --- a/packages/sync-server/src/util/validate-user.js +++ b/packages/sync-server/src/util/validate-user.js @@ -46,7 +46,8 @@ export function validateSession(req, res) { export function validateAuthHeader(req) { // fallback to trustedProxies when trustedAuthProxies not set - const trustedAuthProxies = config.trustedAuthProxies ?? config.trustedProxies; + const trustedAuthProxies = + config.get('trustedAuthProxies') ?? config.get('trustedProxies'); // ensure the first hop from our server is trusted const peer = req.socket.remoteAddress; const peerIp = ipaddr.process(peer); diff --git a/upcoming-release-notes/4440.md b/upcoming-release-notes/4440.md new file mode 100644 index 00000000000..acbf27fe384 --- /dev/null +++ b/upcoming-release-notes/4440.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [lelemm] +--- + +Refactoring Sync Server's configuration file and Environmental Variables diff --git a/yarn.lock b/yarn.lock index 319d30ea0d9..ceec39d184f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -88,6 +88,7 @@ __metadata: "@babel/preset-typescript": "npm:^7.20.2" "@types/bcrypt": "npm:^5.0.2" "@types/better-sqlite3": "npm:^7.6.12" + "@types/convict": "npm:^6" "@types/cors": "npm:^2.8.13" "@types/express": "npm:^5.0.0" "@types/express-actuator": "npm:^1.8.3" @@ -100,6 +101,7 @@ __metadata: bcrypt: "npm:^5.1.1" better-sqlite3: "npm:^11.7.0" body-parser: "npm:^1.20.3" + convict: "npm:^6.2.4" cors: "npm:^2.8.5" date-fns: "npm:^2.30.0" debug: "npm:^4.3.4" @@ -6111,6 +6113,15 @@ __metadata: languageName: node linkType: hard +"@types/convict@npm:^6": + version: 6.1.6 + resolution: "@types/convict@npm:6.1.6" + dependencies: + "@types/node": "npm:*" + checksum: 10/680e6ec527545d4bf3a4a7368c71510b67a09f131a058c2c4616a3a445e9f2be58b42a21535a8078739439b98b1326130f270fff80940465dc40b83b9bf22f4c + languageName: node + linkType: hard + "@types/cookiejar@npm:^2.1.5": version: 2.1.5 resolution: "@types/cookiejar@npm:2.1.5" @@ -9375,6 +9386,16 @@ __metadata: languageName: node linkType: hard +"convict@npm:^6.2.4": + version: 6.2.4 + resolution: "convict@npm:6.2.4" + dependencies: + lodash.clonedeep: "npm:^4.5.0" + yargs-parser: "npm:^20.2.7" + checksum: 10/d4b9c50dcddf4b5da7a80c1d99d1cfae8a47d78d291f0cc11637ab25b6b4515f5f0e9029abd45bcc30cc3e33032aa8814ead22142b4563c4e4959d2e56bdf1ae + languageName: node + linkType: hard + "cookie-signature@npm:1.0.6": version: 1.0.6 resolution: "cookie-signature@npm:1.0.6" @@ -15939,6 +15960,13 @@ __metadata: languageName: node linkType: hard +"lodash.clonedeep@npm:^4.5.0": + version: 4.5.0 + resolution: "lodash.clonedeep@npm:4.5.0" + checksum: 10/957ed243f84ba6791d4992d5c222ffffca339a3b79dbe81d2eaf0c90504160b500641c5a0f56e27630030b18b8e971ea10b44f928a977d5ced3c8948841b555f + languageName: node + linkType: hard + "lodash.debounce@npm:^4.0.8": version: 4.0.8 resolution: "lodash.debounce@npm:4.0.8" @@ -23459,7 +23487,7 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:^20.2.2": +"yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.7": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" checksum: 10/0188f430a0f496551d09df6719a9132a3469e47fe2747208b1dd0ab2bb0c512a95d0b081628bbca5400fb20dbf2fabe63d22badb346cecadffdd948b049f3fcc