import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { createHash } from 'node:crypto'; function numberSetting(env, key, fallback, min, max) { const value = Number(env[key] ?? fallback); if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${key} must be an integer between ${min} and ${max}`); return value; } function checkedUrl(value) { const url = new URL(value); if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('Service URLs must use HTTP(S), without embedded credentials'); return url.toString(); } export function loadConfig(env = process.env) { const file = resolve(env.STATUS_SERVICES_FILE || './config/services.json'); const definitions = JSON.parse(readFileSync(file, 'utf8')); if (!Array.isArray(definitions) || !definitions.length || definitions.length > 30) throw new Error('Configure between 1 and 30 services'); const ids = new Set(); const services = definitions.map(definition => { if (!/^[a-z][a-z0-9-]{0,39}$/.test(definition.id) || ids.has(definition.id)) throw new Error('Service IDs must be unique lowercase slugs'); ids.add(definition.id); if (!definition.name?.ru || !definition.name?.en || !definition.description?.ru || !definition.description?.en) throw new Error(`Missing translations for ${definition.id}`); if (!['html', 'json', 'http'].includes(definition.kind)) throw new Error(`Invalid check kind for ${definition.id}`); const override = env[`STATUS_${definition.id.toUpperCase().replaceAll('-', '_')}_URL`]; const url = override === 'off' ? null : checkedUrl(override || definition.url); const headers = {}; for (const [header, envKey] of Object.entries(definition.headerEnv || {})) { if (env[envKey]) headers[header] = env[envKey]; } const probes = url ? [{ url, kind: definition.kind, expectedJson: definition.expectedJson, headers }] : []; if (definition.id === 'api' && url && env.RIDEBUS_API_KEY) { probes.push({ url: checkedUrl(env.STATUS_API_DATA_URL || 'https://api.ridebus.by/api/v1/cities'), kind: 'json', headers: { 'X-API-Key': env.RIDEBUS_API_KEY }, requireDataArray: true }); } // Hash the target and rules so a changed probe starts a separate history. const signature = createHash('sha256').update(JSON.stringify(probes.map(({ headers: _headers, ...probe }) => probe))).digest('hex').slice(0, 24); const publicUrl = checkedUrl(definition.publicUrl); if (new URL(publicUrl).search || new URL(publicUrl).hash) throw new Error('Public links must not include query parameters or fragments'); if (definition.scenario && !['api', 'pwa', 'board'].includes(definition.scenario)) throw new Error('Unsupported scenario'); const scenario = definition.scenario && url ? { type: definition.scenario, baseUrl: checkedUrl(env[`STATUS_${definition.id.toUpperCase()}_SCENARIO_BASE_URL`] || new URL(url).origin), apiKey: definition.scenario === 'api' ? env.RIDEBUS_API_KEY || null : null, cookie: definition.scenario === 'board' ? env.STATUS_BOARD_TEST_COOKIE || null : null, cityId: env.STATUS_TEST_CITY_ID ? numberSetting(env, 'STATUS_TEST_CITY_ID', 1, 1, 2147483647) : null, stopId: env[`STATUS_${definition.id.toUpperCase()}_TEST_STOP_ID`] ? numberSetting(env, `STATUS_${definition.id.toUpperCase()}_TEST_STOP_ID`, 1, 1, 2147483647) : null, } : null; if (scenario) scenario.signature = createHash('sha256').update(JSON.stringify({ ...scenario, apiKey: Boolean(scenario.apiKey), cookie: Boolean(scenario.cookie), ...(scenario.type === 'board' ? { settingsSource: 'paired-device-v1' } : {}) })).digest('hex').slice(0, 24); const impactPolicy = definition.impact || null; if (impactPolicy) { for (const key of ['availability', 'scenario', 'advice']) if (impactPolicy[key] && ['ru', 'en'].some(locale => typeof impactPolicy[key][locale] !== 'string' || !impactPolicy[key][locale].trim() || impactPolicy[key][locale].length > 2000)) throw new Error(`Invalid impact translations for ${definition.id}`); if (impactPolicy.fallbacks && (!Array.isArray(impactPolicy.fallbacks) || impactPolicy.fallbacks.some(id => !definitions.some(item => item.id === id)))) throw new Error('Unknown fallback service'); } return { id: definition.id, icon: definition.icon || definition.id, name: definition.name, description: definition.description, publicUrl, kind: definition.kind, functional: probes.length > 1 || Boolean(scenario), probes, signature, scenario, impactPolicy }; }); const intervalSeconds = numberSetting(env, 'STATUS_INTERVAL_SECONDS', 60, 10, 3600); return { services, intervalSeconds, staleAfterSeconds: intervalSeconds * 3, publicUrl: env.STATUS_PUBLIC_URL ? checkedUrl(env.STATUS_PUBLIC_URL) : null, timeoutMs: numberSetting(env, 'STATUS_TIMEOUT_MS', 5000, 100, 30000), slowMs: numberSetting(env, 'STATUS_SLOW_MS', 1500, 50, 30000), failureThreshold: numberSetting(env, 'STATUS_FAILURE_THRESHOLD', 3, 1, 10), recoveryThreshold: numberSetting(env, 'STATUS_RECOVERY_THRESHOLD', 2, 1, 10), retentionDays: numberSetting(env, 'STATUS_RETENTION_DAYS', 90, 30, 365), scenarioIntervalSeconds: numberSetting(env, 'STATUS_SCENARIO_INTERVAL_SECONDS', 300, 10, 3600), scenarioTimeoutMs: numberSetting(env, 'STATUS_SCENARIO_TIMEOUT_MS', 20000, 1000, 60000), scenarioSlowMs: numberSetting(env, 'STATUS_SCENARIO_SLOW_MS', 30000, 1000, 120000), browser: { enabled: !['off', 'false', '0'].includes(env.STATUS_BROWSER_CHECKS || ''), executablePath: env.STATUS_BROWSER_EXECUTABLE_PATH || null }, telegram: { enabled: Boolean(env.TELEGRAM_BOT_TOKEN && env.TELEGRAM_CHAT_ID), token: env.TELEGRAM_BOT_TOKEN || '', chatId: env.TELEGRAM_CHAT_ID || '', locale: env.STATUS_NOTIFY_LANG === 'en' ? 'en' : 'ru', publicUrl: env.STATUS_PUBLIC_URL ? checkedUrl(env.STATUS_PUBLIC_URL) : null, timezone: env.STATUS_TIMEZONE || 'Europe/Minsk', }, dbPath: env.STATUS_DB_PATH || './data/status.sqlite', port: numberSetting(env, 'PORT', 5188, 0, 65535), host: env.HOST || '0.0.0.0', }; }