/** * Разные утилитарные роуты (auto-urls, history, s3 meta, availability, etc.) */ const { s3, BUCKET_NAME, writeS3TextObject, writeS3JsonObject, readS3TextObject, headMeta, streamToString } = require('../services/s3Service'); const { sendError, sendOk } = require('../middleware/errorHandler'); const { resourceToKey, toIso } = require('../utils/helpers'); const { GetObjectCommand, PutObjectCommand, ListObjectVersionsCommand, CopyObjectCommand } = require('@aws-sdk/client-s3'); 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: 'filters.json' }, { name: 'ipRanges', key: 'bgp_data/ips.txt' }, { name: 'uiSettings', key: 'bgp_data/rt_ui_settings.json' } ]; const results = await Promise.allSettled( keys.map(k => headMeta(k.key)) ); const out = {}; results.forEach((r, idx) => { const name = keys[idx].name; if (r.status === 'fulfilled' && r.value) { out[name] = r.value; } else { out[name] = null; } }); res.json(out); } catch (error) { console.error('Error fetching last modified dates from S3:', error); return sendError(res, 500, 'Error fetching last modified dates from S3', 'E_S3'); } } // GET /api/history/:resource async function getHistory(req, res) { const { resource } = req.params; const { countOnly, format } = req.query || {}; const key = resourceToKey(resource); if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE'); try { const out = await s3.send(new ListObjectVersionsCommand({ Bucket: BUCKET_NAME, Prefix: key, MaxKeys: 50 })); const versionsAll = (out.Versions || []).filter(v => v.Key === key); if (countOnly === 'true') { const total = versionsAll.length; return res.json(format === 'std' ? { items: [], total, meta: {} } : { total }); } const versions = versionsAll.slice(0, 10).map(v => ({ versionId: v.VersionId, isLatest: v.IsLatest, lastModified: toIso(v.LastModified), size: v.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_S3', { error: String(e?.message || e) }); } } // POST /api/history/:resource/rollback async function postRollback(req, res) { const { resource } = req.params; const { versionId } = req.body || {}; const key = resourceToKey(resource); if (!key || !versionId) { return sendError(res, 400, 'Bad request', 'E_BAD_REQUEST'); } try { await s3.send(new CopyObjectCommand({ Bucket: BUCKET_NAME, CopySource: `/${BUCKET_NAME}/${encodeURIComponent(key)}?versionId=${encodeURIComponent(versionId)}`, Key: key })); const meta = await headMeta(key); return sendOk(res, meta); } catch (e) { console.error('rollback error', e); return sendError(res, 500, 'Error rollback', 'E_S3', { error: String(e?.message || e) }); } } // GET /api/auto-urls async function getAutoUrls(req, res) { const { checkIfNoneMatch } = require('../middleware/errorHandler'); try { const { HeadObjectCommand } = require('@aws-sdk/client-s3'); const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/auto_url/urls.txt' })).catch(() => null); 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; const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/auto_url/urls.txt' })); const fileContent = await streamToString(data.Body); const urls = fileContent.split('\n').filter(line => line).map(line => { const parts = line.trim().split(/\s+/); return { url: parts[0] || '', community: parts[1] || '' }; }); res.json(urls); } catch (error) { if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { res.json([]); } else { console.error(error); return sendError(res, 500, 'Error reading auto URLs from S3', 'E_S3'); } } } // POST /api/auto-urls async function postAutoUrls(req, res) { const { urls } = req.body; const fileContent = urls.map(u => `${u.url} ${u.community}`).join('\n'); try { const meta = await writeS3TextObject('bgp_data/auto_url/urls.txt', fileContent); return sendOk(res, meta); } catch (error) { console.error(error); return sendError(res, 500, 'Error writing auto URLs to S3', 'E_S3'); } } // POST /api/auto-urls/process async function processAutoUrls(req, res) { try { // Загрузка URL списков let urls = []; try { const urlsData = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/auto_url/urls.txt' })); const urlsContent = await streamToString(urlsData.Body); urls = urlsContent.split('\n').filter(line => line).map(line => { const parts = line.trim().split(/\s+/); return { url: parts[0] || '', community: parts[1] || '' }; }); } catch (error) { if (error.code !== 'NoSuchKey') throw error; } if (urls.length === 0) { return sendError(res, 400, 'No URLs to process', 'E_BAD_REQUEST'); } // Загрузка текущих IP и доменов let currentIps = []; try { const ipsData = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' })); const ipsContent = await streamToString(ipsData.Body); currentIps = ipsContent.split('\n').filter(line => line).map(line => { const parts = line.trim().split(/\s+/); return { ipRange: parts[0] || '', community: parts[1] || '' }; }); } catch (error) { if (error.code !== 'NoSuchKey') throw error; } let currentDomains = []; try { const dData = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' })); const dContent = await streamToString(dData.Body); currentDomains = dContent.split('\n').filter(line => line).map(line => { const parts = line.trim().split(/\s+/); return { domain: (parts[0] || '').toLowerCase(), community: parts[1] || '' }; }); } catch (error) { if (error.code !== 'NoSuchKey') throw error; } // Обработка 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]; // Сохранение const updatedIpsContent = allIps.map(i => `${i.ipRange} ${i.community}`).join('\n'); await s3.send(new PutObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt', Body: updatedIpsContent, ContentType: 'text/plain' })); const updatedDomainsContent = allDomains.map(d => `${d.domain} ${d.community}`).join('\n'); await s3.send(new PutObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt', Body: updatedDomainsContent, ContentType: 'text/plain' })); 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_S3'); } } // 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 s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/rt_ui_settings.json' })); const jsonText = await streamToString(data.Body); const parsed = JSON.parse(jsonText || '{}'); 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 }); } const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'servers.json' })); let servers = []; try { servers = JSON.parse(await streamToString(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 { const settings = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/rt_ui_settings.json' })).then(async (d) => { try { return JSON.parse(await streamToString(d.Body)); } catch { return {}; } }).catch(() => ({})); 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 { HeadObjectCommand } = require('@aws-sdk/client-s3'); const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/rt_ui_settings.json' })).catch(() => null); 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; const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/rt_ui_settings.json' })); const jsonText = await streamToString(data.Body); 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' || error?.$metadata?.httpStatusCode === 404) { return res.json({}); } console.error('Error reading ui settings from S3:', error); return sendError(res, 500, 'Error reading UI settings from S3', 'E_S3'); } } 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 to S3:', error); return sendError(res, 500, 'Error writing UI settings to S3', 'E_S3', { error: String(error?.message || error) }); } } module.exports = { getS3LastModified, getHistory, postRollback, getAutoUrls, postAutoUrls, processAutoUrls, getServersAvailability, updateBgpBackground, getWsUrl, getUiSettings, postUiSettings, getPingServicesList, getPingServices, refreshPingServicesCache, };