Files
2026-04-20 01:17:00 +07:00

756 lines
28 KiB
JavaScript

/**
* Разные утилитарные роуты (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<Record<string, { id: string, name: string, host: string, ms: number|null }>>} 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,
};