/** * Разные утилитарные роуты (auto-urls, history, s3 meta, availability, etc.) */ const { writeS3TextObject, writeS3JsonObject, readS3TextObject, headMeta, listBlobVersionsForKey, rollbackBlobVersion, } = require('../services/s3Service'); const evobgpClient = require('../services/evobgpClient'); const filterRulesStorage = require('../services/filterRulesStorage'); const { sendError, sendOk } = require('../middleware/errorHandler'); const { resourceToKey, toIso } = require('../utils/helpers'); const validators = require('../lib/validators'); const net = require('net'); const http = require('http'); const https = require('https'); const { URL } = require('url'); // GET /api/s3/last-modified async function getS3LastModified(req, res) { try { const keys = [ { name: 'domainsNew', key: 'bgp_data/domains_community.txt' }, { name: 'asns', key: 'bgp_data/asns.txt' }, { name: 'servers', key: 'servers.json' }, { name: 'filters', key: null }, { name: 'ipRanges', key: 'bgp_data/ips.txt' }, { name: 'uiSettings', key: 'bgp_data/rt_ui_settings.json' } ]; const out = {}; for (const k of keys) { if (k.name === 'filters') { try { const head = filterRulesStorage.headMetaForScope('global'); out.filters = { etag: head.etag, lastModified: head.lastModified, contentLength: head.contentLength, }; } catch { out.filters = null; } continue; } if (['domainsNew', 'asns', 'ipRanges'].includes(k.name) && evobgpClient.isConfigured()) { try { await evobgpClient.fetchRouterListsCatalog(); out[k.name] = { etag: null, lastModified: new Date().toISOString(), contentLength: null }; } catch { out[k.name] = null; } continue; } out[k.name] = await headMeta(k.key).catch(() => null); } res.json(out); } catch (error) { console.error('Error fetching last modified dates:', error); return sendError(res, 500, 'Error fetching last modified dates', 'E_STORAGE'); } } const EVOBGP_ONLY_HISTORY_RESOURCES = new Set(['domains-new', 'asns', 'ip-ranges', 'filters']); // GET /api/history/:resource async function getHistory(req, res) { const { resource } = req.params; const { countOnly, format } = req.query || {}; if (EVOBGP_ONLY_HISTORY_RESOURCES.has(resource)) { const note = resource === 'filters' ? 'Фильтры в SQL-таблице app_filter_rules; локальной истории отката через этот API нет' : 'Версии хранятся в EvoBGP, локальной истории нет'; if (countOnly === 'true') { return res.json(format === 'std' ? { items: [], total: 0, meta: { noBlobHistory: true } } : { total: 0 }); } return res.json( format === 'std' ? { items: [], total: 0, meta: { noBlobHistory: true, note } } : { items: [], total: 0 }, ); } const key = resourceToKey(resource); if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE'); try { const rows = listBlobVersionsForKey(key, 50); const versionsAll = rows.map((r) => ({ id: r.id, object_key: r.object_key, byte_size: r.byte_size, etag: r.etag, created_at: r.created_at, })); if (countOnly === 'true') { const total = versionsAll.length; return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); } const versions = versionsAll.slice(0, 10).map((v, idx) => ({ versionId: String(v.id), isLatest: idx === 0, lastModified: toIso(v.created_at), size: v.byte_size, etag: v.etag, })); return res.json(format === 'std' ? { items: versions, total: versionsAll.length, meta: {} } : { items: versions }); } catch (e) { console.error('history error', e); return sendError(res, 500, 'Error reading history', 'E_STORAGE', { error: String(e?.message || e) }); } } // POST /api/history/:resource/rollback async function postRollback(req, res) { const { resource } = req.params; const { versionId } = req.body || {}; if (EVOBGP_ONLY_HISTORY_RESOURCES.has(resource)) { const msg = resource === 'filters' ? 'Откат версий для фильтров не поддерживается (данные в SQL, не в blob_versions)' : 'Откат версий для этого справочника не поддерживается (данные в EvoBGP)'; return sendError(res, 400, msg, resource === 'filters' ? 'E_HISTORY_FILTERS_SQL' : 'E_HISTORY_EV'); } const key = resourceToKey(resource); if (!key || !versionId) { return sendError(res, 400, 'Bad request', 'E_BAD_REQUEST'); } try { rollbackBlobVersion(key, versionId); const meta = await headMeta(key); return sendOk(res, meta); } catch (e) { console.error('rollback error', e); return sendError(res, 500, 'Error rollback', 'E_STORAGE', { error: String(e?.message || e) }); } } // GET /api/auto-urls async function getAutoUrls(req, res) { try { if (!evobgpClient.isConfigured()) { return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED'); } const rows = await evobgpClient.fetchAutoUrlsData(); res.json(rows); } catch (error) { console.error(error); return sendError(res, 500, 'Error reading auto URLs', 'E_EVOBGP'); } } // POST /api/auto-urls async function postAutoUrls(req, res) { const { urls } = req.body; if (!Array.isArray(urls)) { return sendError(res, 400, 'urls must be an array', 'E_BAD_REQUEST'); } try { if (!evobgpClient.isConfigured()) { return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED'); } await evobgpClient.saveAutoUrlsData(urls); return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null }); } catch (error) { console.error(error); return sendError(res, 500, 'Error writing auto URLs', 'E_EVOBGP'); } } // POST /api/auto-urls/process async function processAutoUrls(req, res) { try { if (!evobgpClient.isConfigured()) { return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED'); } const urls = await evobgpClient.fetchAutoUrlsData(); if (urls.length === 0) { return sendError(res, 400, 'No URLs to process', 'E_BAD_REQUEST'); } const currentIps = await evobgpClient.fetchIpRangesData(); const currentDomains = await evobgpClient.fetchDomainsData(); // Обработка URL const newIps = []; const newDomains = []; for (const urlData of urls) { try { const url = String(urlData.url || '').trim(); const community = String(urlData.community || '').trim(); if (!url || !community) continue; const content = await new Promise((resolve, reject) => { const protocol = url.startsWith('https:') ? https : http; const req = protocol.get(url, (r) => { let data = ''; r.on('data', (chunk) => { data += chunk; }); r.on('end', () => resolve(data)); }); req.on('error', reject); req.setTimeout(15000, () => req.destroy()); }); const lines = content.split('\n'); for (const raw of lines) { const line = String(raw || '').trim(); if (!line || line.startsWith('#') || line.startsWith('//')) continue; const token = line.split(/\s+/)[0]?.trim(); if (!token) continue; if (validators.isValidCIDRv4(token) || validators.isValidCIDRv6(token)) { newIps.push({ ipRange: token, community }); } else if (validators.isValidIPv4(token)) { newIps.push({ ipRange: `${token}/32`, community }); } else if (validators.isValidDomain(token)) { newDomains.push({ domain: token.toLowerCase(), community }); } } } catch (error) { console.error(`Error processing URL ${urlData.url}:`, error); } } // Объединение и дедупликация const existingIpRanges = new Set(currentIps.map(i => i.ipRange)); const uniqueNewIps = newIps.filter(i => !existingIpRanges.has(i.ipRange)); const allIps = [...currentIps, ...uniqueNewIps]; const existingDomains = new Set(currentDomains.map(d => d.domain)); const uniqueNewDomains = newDomains.filter(d => !existingDomains.has(d.domain)); const allDomains = [...currentDomains, ...uniqueNewDomains]; await evobgpClient.saveIpRangesData(allIps, currentIps); await evobgpClient.saveDomainsData(allDomains, currentDomains); const msg = `Обработано ${urls.length} URL, добавлено ${uniqueNewIps.length} IP и ${uniqueNewDomains.length} доменов`; return res.json({ success: true, message: msg, processedUrls: urls.length, newIpsCount: uniqueNewIps.length, newDomainsCount: uniqueNewDomains.length, totalIpsCount: allIps.length, totalDomainsCount: allDomains.length }); } catch (error) { console.error('Error processing auto URLs:', error); return sendError(res, 500, 'Error processing auto URLs', 'E_EVOBGP'); } } // GET /api/servers/availability const availabilityCache = { at: 0, data: null }; function tcpCheck(host, port, timeoutMs) { return new Promise((resolve) => { const socket = new net.Socket(); let settled = false; const settle = (ok) => { if (!settled) { settled = true; try { socket.destroy(); } catch {} resolve(ok); } }; socket.setTimeout(timeoutMs, () => settle(false)); socket.once('error', () => settle(false)); socket.connect(port, host, () => settle(true)); }); } /** Измерить RTT (мс) до host:port по TCP. Возвращает число мс или null при ошибке/таймауте. */ function measureTcpRtt(host, port = 443, timeoutMs = 6000) { return new Promise((resolve) => { const start = Date.now(); const socket = new net.Socket(); let settled = false; const settle = (ms) => { if (!settled) { settled = true; try { socket.destroy(); } catch (_) {} resolve(ms); } }; socket.setTimeout(timeoutMs, () => settle(null)); socket.once('error', () => settle(null)); socket.connect(port, host, () => { const ms = Math.round(Date.now() - start); settle(ms); }); }); } /** Список целей для пинга по умолчанию (id, name, host, port, icon, color) */ const PING_SERVICES_DEFAULT = [ { id: 'google', name: 'Google', host: '8.8.8.8', port: 443, icon: 'IconBrandGoogle', color: 'blue' }, { id: 'cloudflare', name: 'Cloudflare', host: '1.1.1.1', port: 443, icon: 'IconBrandCloudflare', color: 'orange' }, { id: 'yandex', name: 'Yandex', host: 'ya.ru', port: 443, icon: 'IconBrandYandex', color: 'red' }, { id: 'instagram', name: 'Instagram', host: 'instagram.com', port: 443, icon: 'IconBrandInstagram', color: 'pink' }, ]; function getPingServicesListFromSettings(uiSettings) { const raw = uiSettings.pingServicesList; if (Array.isArray(raw) && raw.length > 0) { return raw.map((s) => ({ id: String(s?.id ?? '').trim() || `svc-${Math.random().toString(36).slice(2, 9)}`, name: String(s?.name ?? '').trim() || 'Сервис', host: String(s?.host ?? '').trim() || '0.0.0.0', port: Math.max(1, parseInt(s?.port, 10) || 443), icon: typeof s?.icon === 'string' ? s.icon : 'IconWorld', color: typeof s?.color === 'string' ? s.color : 'primary', })).filter((s) => s.host !== '0.0.0.0'); } return PING_SERVICES_DEFAULT; } /** Загрузить UI-настройки из S3 (для pingServicesSource и др.) */ async function loadUiSettingsSync() { try { const data = await readS3TextObject('bgp_data/rt_ui_settings.json'); const parsed = JSON.parse(data.body || '{}'); return parsed && typeof parsed === 'object' ? parsed : {}; } catch { return {}; } } const PING_SERVICES_CACHE_KEY_PREFIX = 'ping-services/cache_'; const PING_SERVICES_HISTORY_KEY_PREFIX = 'ping-services/history_'; const PING_HISTORY_MAX = 30; function pingServicesStorageSuffix(viaRouter, serverId, gatewayIp) { return (viaRouter ? 'router' : 'web') + '_' + String(serverId || '').replace(/[^a-zA-Z0-9.-]/g, '_') + '_' + String(gatewayIp || '').replace(/[^a-zA-Z0-9.]/g, '_'); } /** * Прочитать историю замеров пингов из S3. Формат: { byId: { [serviceId]: number[] } }. */ async function readPingServicesHistory(suffix) { const key = PING_SERVICES_HISTORY_KEY_PREFIX + suffix; try { const raw = await readS3TextObject(key).catch(() => null); if (!raw?.body) return {}; const parsed = JSON.parse(raw.body); return parsed?.byId && typeof parsed.byId === 'object' ? parsed.byId : {}; } catch { return {}; } } /** * Записать историю замеров пингов в S3. */ async function writePingServicesHistory(suffix, byId) { const key = PING_SERVICES_HISTORY_KEY_PREFIX + suffix; await writeS3JsonObject(key, { byId }).catch((err) => console.warn('[ping-services] history write failed:', err?.message)); } /** * Обновить кеш пинг-сервисов: выполнить пинги по списку из настроек и записать результат в S3. * Используется планировщиком и при промахе кеша в getPingServices. * @returns {Promise>} byId */ async function refreshPingServicesCache() { const uiSettings = await loadUiSettingsSync(); const servicesList = getPingServicesListFromSettings(uiSettings); const fallbackPayload = {}; servicesList.forEach((s) => { fallbackPayload[s.id] = { id: s.id, name: s.name, host: s.host, ms: null }; }); const viaRouter = String(uiSettings.pingServicesSource || 'web').toLowerCase() === 'router'; const cacheSeconds = Math.max(0, parseInt(uiSettings.pingServicesCacheSeconds, 10) || 0); let serverId = null; let gatewayIp = null; if (viaRouter) { const { readServersFromS3 } = require('./serversRoutes'); const servers = await readServersFromS3(); const routerServers = servers.filter( (s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home') ); serverId = (uiSettings.pingServicesServerId && String(uiSettings.pingServicesServerId).trim()) || (routerServers[0] && (routerServers[0].id || routerServers[0].dns || routerServers[0].ip)); gatewayIp = (uiSettings.pingServicesGatewayIp && String(uiSettings.pingServicesGatewayIp).trim()) || null; } const suffix = pingServicesStorageSuffix(viaRouter, serverId, gatewayIp); const cacheKey = cacheSeconds > 0 ? PING_SERVICES_CACHE_KEY_PREFIX + suffix : null; if (viaRouter) { const { runPingViaRouter } = require('./mikrotikConfigRoutes'); const { readServersFromS3 } = require('./serversRoutes'); const servers = await readServersFromS3(); const routerServers = servers.filter( (s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home') ); let gatewayIpResolved = gatewayIp; if (!gatewayIpResolved && serverId && routerServers.length > 0) { const server = routerServers.find((s) => (s.id || s.dns || s.ip) === serverId) || routerServers[0]; const gateways = Array.isArray(server.gateways) ? server.gateways : []; const primary = gateways.find((g) => g && g.primary) || gateways[0]; gatewayIpResolved = primary && (primary.ip || primary.remoteIp) ? (primary.ip || primary.remoteIp) : null; } if (!serverId) { console.warn('[ping-services] router mode: no serverId (no jumphost/home in settings or in servers list)'); return fallbackPayload; } const results = await Promise.all( servicesList.map(async (svc) => { try { const result = await runPingViaRouter(serverId, gatewayIpResolved || null, svc.host, 3); const ms = typeof result.avgMs === 'number' ? Math.round(result.avgMs) : null; return { id: svc.id, name: svc.name, host: svc.host, ms }; } catch (err) { console.warn('[ping-services] runPingViaRouter failed for', svc.host, err?.message || err); return { id: svc.id, name: svc.name, host: svc.host, ms: null }; } }) ); const byId = {}; results.forEach((r) => { byId[r.id] = r; }); if (cacheKey) { writeS3JsonObject(cacheKey, { byId, cachedAt: Date.now() }).catch((err) => console.warn('[ping-services] cache write failed:', err?.message)); } const history = await readPingServicesHistory(suffix); Object.keys(byId).forEach((id) => { const ms = byId[id]?.ms; if (typeof ms !== 'number') return; const list = Array.isArray(history[id]) ? history[id] : []; history[id] = [...list, ms].slice(-PING_HISTORY_MAX); }); await writePingServicesHistory(suffix, history); return byId; } const results = await Promise.all( servicesList.map(async (svc) => { const ms = await measureTcpRtt(svc.host, svc.port, 6000); return { id: svc.id, name: svc.name, host: svc.host, ms }; }) ); const byId = {}; results.forEach((r) => { byId[r.id] = r; }); if (cacheKey) { writeS3JsonObject(cacheKey, { byId, cachedAt: Date.now() }).catch((err) => console.warn('[ping-services] cache write failed:', err?.message)); } const history = await readPingServicesHistory(suffix); Object.keys(byId).forEach((id) => { const ms = byId[id]?.ms; if (typeof ms !== 'number') return; const list = Array.isArray(history[id]) ? history[id] : []; history[id] = [...list, ms].slice(-PING_HISTORY_MAX); }); await writePingServicesHistory(suffix, history); return byId; } // GET /api/ping-services-list — список сервисов для пинга из настроек (для дашборда и редактора) async function getPingServicesList(req, res) { try { const uiSettings = await loadUiSettingsSync(); const list = getPingServicesListFromSettings(uiSettings); return res.json({ list }); } catch (e) { console.error('ping-services-list error', e); return res.json({ list: PING_SERVICES_DEFAULT }); } } // GET /api/ping-services — RTT до сервисов из списка (веб или через RouterOS по настройке) async function getPingServices(req, res) { try { const uiSettings = await loadUiSettingsSync(); const servicesList = getPingServicesListFromSettings(uiSettings); const fallbackPayload = {}; servicesList.forEach((s) => { fallbackPayload[s.id] = { id: s.id, name: s.name, host: s.host, ms: null }; }); const viaRouter = String(uiSettings.pingServicesSource || 'web').toLowerCase() === 'router'; const cacheSeconds = Math.max(0, parseInt(uiSettings.pingServicesCacheSeconds, 10) || 0); let serverId = null; let gatewayIp = null; if (viaRouter) { const { readServersFromS3 } = require('./serversRoutes'); const servers = await readServersFromS3(); const routerServers = servers.filter( (s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home') ); serverId = (uiSettings.pingServicesServerId && String(uiSettings.pingServicesServerId).trim()) || (routerServers[0] && (routerServers[0].id || routerServers[0].dns || routerServers[0].ip)); gatewayIp = (uiSettings.pingServicesGatewayIp && String(uiSettings.pingServicesGatewayIp).trim()) || null; } const suffix = pingServicesStorageSuffix(viaRouter, serverId, gatewayIp); const cacheKey = cacheSeconds > 0 ? PING_SERVICES_CACHE_KEY_PREFIX + suffix : null; const skipCache = ['1', 'true', 'yes'].includes(String(req.query?.refresh || req.query?.nocache || '').toLowerCase()); if (cacheKey && !skipCache) { try { const raw = await readS3TextObject(cacheKey).catch(() => null); if (raw?.body) { const cached = JSON.parse(raw.body); const cachedAt = typeof cached.cachedAt === 'number' ? cached.cachedAt : 0; const ttlMs = cacheSeconds * 1000; if (cachedAt && Date.now() - cachedAt < ttlMs && cached.byId && typeof cached.byId === 'object') { const history = await readPingServicesHistory(suffix); return res.json({ byId: cached.byId, history }); } } } catch (_) {} } const byId = await refreshPingServicesCache(); const history = await readPingServicesHistory(suffix); return res.json({ byId, history }); } catch (e) { console.error('ping-services error', e); const fallback = {}; PING_SERVICES_DEFAULT.forEach((s) => { fallback[s.id] = { id: s.id, name: s.name, host: s.host, ms: null }; }); res.status(500).json({ byId: fallback, history: {} }); } } function anyTrue(promises) { return new Promise((resolve) => { if (!Array.isArray(promises) || promises.length === 0) return resolve(false); let remaining = promises.length; let resolved = false; for (const p of promises) { Promise.resolve(p).then((v) => { if (v && !resolved) { resolved = true; resolve(true); } }).finally(() => { remaining -= 1; if (remaining === 0 && !resolved) resolve(false); }); } }); } async function checkOneServerFast(srv, perSocketTimeoutMs = 800, perServerBudgetMs = 1000) { const hosts = []; if (srv.ip) hosts.push(String(srv.ip)); if (srv.dns) hosts.push(String(srv.dns)); const tryOneHost = (host) => anyTrue([ tcpCheck(host, 443, perSocketTimeoutMs), tcpCheck(host, 80, perSocketTimeoutMs), ]); const run = anyTrue(hosts.map((h) => tryOneHost(h))); const timeout = new Promise((resolve) => setTimeout(() => resolve(false), perServerBudgetMs)); return Promise.race([run, timeout]); } async function getServersAvailability(req, res) { try { const ttlSeconds = Math.max(0, Math.min(300, Number(req.query.ttlSeconds) || 30)); const now = Date.now(); if (availabilityCache.data && (now - availabilityCache.at) < ttlSeconds * 1000) { return res.json({ ...availabilityCache.data, cached: true }); } let servers = []; try { const data = await readS3TextObject('servers.json'); servers = JSON.parse(data.body); if (!Array.isArray(servers)) servers = []; } catch { servers = []; } const checks = await Promise.allSettled(servers.map((s) => checkOneServerFast(s))); const statuses = servers.map((s, i) => ({ ip: s.ip, dns: s.dns, online: checks[i].status === 'fulfilled' ? Boolean(checks[i].value) : false })); const online = statuses.filter((x) => x.online).length; const payload = { online, total: servers.length, statuses }; availabilityCache.at = Date.now(); availabilityCache.data = payload; res.json(payload); } catch (e) { console.error('availability error', e); res.status(500).json({ online: 0, total: 0, statuses: [] }); } } // POST /api/update-bgp/background async function updateBgpBackground(req, res) { try { const targetUrl = process.env.BGP_BACKGROUND_URL; if (!targetUrl) { return sendError(res, 500, 'BGP_BACKGROUND_URL is not configured', 'E_CONFIG'); } const u = new URL(targetUrl); const client = u.protocol === 'https:' ? https : http; const options = { method: 'POST', hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80), path: `${u.pathname}${u.search || ''}`, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, timeout: 15000, }; const body = req.body && Object.keys(req.body).length ? JSON.stringify(req.body) : ''; const upstream = client.request(options, (r) => { let data = ''; r.setEncoding('utf8'); r.on('data', (chunk) => { data += chunk; }); r.on('end', () => { const status = r.statusCode || 502; try { const json = data ? JSON.parse(data) : {}; return res.status(status).json(json); } catch (_) { return res.status(status).json({ ok: status >= 200 && status < 300, data }); } }); }); upstream.on('timeout', () => { try { upstream.destroy(); } catch {} return sendError(res, 504, 'Upstream timeout', 'E_UPSTREAM_TIMEOUT'); }); upstream.on('error', (e) => { return sendError(res, 502, 'Upstream error', 'E_UPSTREAM', { error: String(e?.message || e) }); }); if (body) upstream.write(body); upstream.end(); } catch (e) { return sendError(res, 500, 'Proxy error', 'E_PROXY', { error: String(e?.message || e) }); } } // GET /api/ws/url async function getWsUrl(req, res) { try { let settings = {}; try { const d = await readS3TextObject('bgp_data/rt_ui_settings.json'); settings = JSON.parse(d.body || '{}'); } catch (_) { settings = {}; } const url = settings?.wsUpdateUrl || process.env.WS_UPDATE_URL || ''; return res.json({ url }); } catch (e) { return res.json({ url: '' }); } } // GET/POST /api/ui-settings async function getUiSettings(req, res) { const { checkIfNoneMatch } = require('../middleware/errorHandler'); try { const head = await headMeta('bgp_data/rt_ui_settings.json'); const etag = head?.etag || null; if (etag) res.set('ETag', String(etag)); if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString()); if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength)); if (checkIfNoneMatch(req, res, etag)) return; let jsonText = '{}'; try { const data = await readS3TextObject('bgp_data/rt_ui_settings.json'); jsonText = data.body; } catch (e) { if (e?.code !== 'NoSuchKey') throw e; } let settings = {}; try { const parsed = JSON.parse(jsonText); if (parsed && typeof parsed === 'object') settings = parsed; } catch (parseError) { settings = {}; } return res.json(settings); } catch (error) { if (error?.code === 'NoSuchKey') { return res.json({}); } console.error('Error reading ui settings:', error); return sendError(res, 500, 'Error reading UI settings', 'E_STORAGE'); } } async function postUiSettings(req, res) { const { settings, etag } = req.body || {}; const payload = (settings && typeof settings === 'object') ? settings : {}; try { const { headS3ObjectEtag } = require('../services/s3Service'); let current = null; const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null; try { current = await headS3ObjectEtag('bgp_data/rt_ui_settings.json'); } catch {} if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) { const meta = await headMeta('bgp_data/rt_ui_settings.json'); return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta }); } } catch {} try { const { writeS3JsonObject } = require('../services/s3Service'); const meta = await writeS3JsonObject('bgp_data/rt_ui_settings.json', payload); return sendOk(res, meta); } catch (error) { console.error('Error writing UI settings:', error); return sendError(res, 500, 'Error writing UI settings', 'E_STORAGE', { error: String(error?.message || error) }); } } module.exports = { getS3LastModified, getHistory, postRollback, getAutoUrls, postAutoUrls, processAutoUrls, getServersAvailability, updateBgpBackground, getWsUrl, getUiSettings, postUiSettings, getPingServicesList, getPingServices, refreshPingServicesCache, checkOneServerFast, loadUiSettingsSync, };