Files
ridebus-status/server/config.mjs
2026-10-13 02:24:39 +03:00

79 lines
6.0 KiB
JavaScript

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',
};
}