Backend: SQLite storage, EvoBGP integration, filters in SQL
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* Серверный клиент EvoBGP API (Bearer, base /v1). Логика выборки совместима с frontend/src/lib/evobgpData.js
|
||||
*/
|
||||
|
||||
const { normalizeEvobgpBaseUrl } = require('../routes/evobgpProxyRoutes');
|
||||
|
||||
const PAGE_LIMIT = 500;
|
||||
const DEFAULT_TIMEOUT_MS = Number(process.env.EVOBGP_CLIENT_TIMEOUT_MS) || 30000;
|
||||
|
||||
function getBaseAndToken() {
|
||||
const base = normalizeEvobgpBaseUrl(process.env.EVOBGP_API_URL || '');
|
||||
const token = String(process.env.EVOBGP_API_TOKEN || '').trim();
|
||||
return { base, token };
|
||||
}
|
||||
|
||||
function isConfigured() {
|
||||
const { base, token } = getBaseAndToken();
|
||||
return Boolean(base && token);
|
||||
}
|
||||
|
||||
async function evobgpRequest(method, pathname, { query, body } = {}) {
|
||||
const { base, token } = getBaseAndToken();
|
||||
if (!base || !token) {
|
||||
const err = new Error('EvoBGP not configured');
|
||||
err.code = 'E_EVOBGP_NOT_CONFIGURED';
|
||||
throw err;
|
||||
}
|
||||
const u = new URL(pathname.startsWith('/') ? pathname.slice(1) : pathname, base.endsWith('/') ? base : `${base}/`);
|
||||
if (query && typeof query === 'object') {
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== undefined && v !== null) u.searchParams.set(k, String(v));
|
||||
}
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), DEFAULT_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(u.toString(), {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch (_) {
|
||||
data = text;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = new Error(typeof data === 'object' && data?.message ? data.message : res.statusText);
|
||||
err.code = 'E_EVOBGP_HTTP';
|
||||
err.status = res.status;
|
||||
err.body = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
async function paginate(path, params = {}) {
|
||||
const items = [];
|
||||
let cursor = null;
|
||||
while (true) {
|
||||
const res = await evobgpRequest('GET', path, {
|
||||
query: { ...params, limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
|
||||
});
|
||||
const chunk = Array.isArray(res?.items) ? res.items : [];
|
||||
items.push(...chunk);
|
||||
if (!res?.has_more || !res?.next_cursor) break;
|
||||
cursor = res.next_cursor;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async function fetchRouterListsCatalog() {
|
||||
return evobgpRequest('GET', '/router-lists/catalog');
|
||||
}
|
||||
|
||||
function getCatalogItems(section) {
|
||||
if (!section || typeof section !== 'object') return [];
|
||||
return Array.isArray(section.items) ? section.items : [];
|
||||
}
|
||||
|
||||
async function listCommunities() {
|
||||
let communities = [];
|
||||
try {
|
||||
const catalog = await fetchRouterListsCatalog();
|
||||
communities = getCatalogItems(catalog.communities);
|
||||
} catch {
|
||||
communities = await paginate('/communities');
|
||||
}
|
||||
return communities.map((c) => ({
|
||||
id: c.id,
|
||||
value: String(c.community || ''),
|
||||
name: String(c.title || ''),
|
||||
description: '',
|
||||
tags: [],
|
||||
color: '',
|
||||
icon: '',
|
||||
}));
|
||||
}
|
||||
|
||||
async function getOrCreateModule(moduleType, defaultName) {
|
||||
const modules = await paginate('/modules', { type: moduleType });
|
||||
if (modules.length > 0) return modules[0];
|
||||
const created = await evobgpRequest('POST', '/modules', {
|
||||
body: { type: moduleType, name: defaultName, enabled: true },
|
||||
});
|
||||
return created?.data || created;
|
||||
}
|
||||
|
||||
function normalizeList(items, keyName) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const item of items || []) {
|
||||
const key = String(item?.[keyName] || '').trim().toLowerCase();
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ ...item, [keyName]: key });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function buildCommunityMaps() {
|
||||
const communities = await listCommunities();
|
||||
const byId = new Map(communities.map((c) => [c.id, c.value]));
|
||||
const byValue = new Map(communities.map((c) => [c.value, c.id]));
|
||||
return { communities, byId, byValue };
|
||||
}
|
||||
|
||||
function mapCommunityToValue(communityId, byId) {
|
||||
if (!communityId) return '';
|
||||
return byId.get(communityId) || String(communityId);
|
||||
}
|
||||
|
||||
async function syncEntries({
|
||||
moduleType,
|
||||
moduleName,
|
||||
listPath,
|
||||
keyName,
|
||||
createBody,
|
||||
patchBody,
|
||||
currentItems,
|
||||
originalItems,
|
||||
requireCommunity = false,
|
||||
}) {
|
||||
const module = await getOrCreateModule(moduleType, moduleName);
|
||||
const moduleId = module.id;
|
||||
const { byId, byValue } = await buildCommunityMaps();
|
||||
const existing = await paginate(`/modules/${moduleId}/${listPath}`);
|
||||
|
||||
const existingByKey = new Map();
|
||||
for (const row of existing) {
|
||||
const entryKey = String(row?.[keyName] || '').trim().toLowerCase();
|
||||
if (!entryKey) continue;
|
||||
existingByKey.set(entryKey, row);
|
||||
}
|
||||
|
||||
const normalizedCurrent = normalizeList(currentItems, keyName);
|
||||
const normalizedOriginal = normalizeList(originalItems, keyName);
|
||||
const currentMap = new Map(normalizedCurrent.map((i) => [String(i[keyName]).toLowerCase(), i]));
|
||||
const originalKeys = new Set(normalizedOriginal.map((i) => String(i[keyName]).toLowerCase()));
|
||||
|
||||
for (const key of originalKeys) {
|
||||
if (!currentMap.has(key) && existingByKey.has(key)) {
|
||||
const row = existingByKey.get(key);
|
||||
await evobgpRequest('DELETE', `/modules/${moduleId}/${listPath}/${row.id}`);
|
||||
existingByKey.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, item] of currentMap.entries()) {
|
||||
const rawCommunity = String(item.community || '').trim();
|
||||
const communityId = byValue.get(rawCommunity) || null;
|
||||
if (requireCommunity && !communityId) {
|
||||
throw new Error(`Community "${rawCommunity}" не найдена в справочнике`);
|
||||
}
|
||||
|
||||
const existingRow = existingByKey.get(key);
|
||||
if (!existingRow) {
|
||||
await evobgpRequest('POST', `/modules/${moduleId}/${listPath}`, { body: createBody(item, communityId) });
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentCommunity = mapCommunityToValue(existingRow.community_id, byId);
|
||||
if (currentCommunity !== rawCommunity) {
|
||||
await evobgpRequest('PATCH', `/modules/${moduleId}/${listPath}/${existingRow.id}`, {
|
||||
body: patchBody(item, communityId),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDomainsData() {
|
||||
try {
|
||||
const catalog = await fetchRouterListsCatalog();
|
||||
const communities = getCatalogItems(catalog.communities);
|
||||
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
|
||||
const rows = getCatalogItems(catalog.domains);
|
||||
return rows
|
||||
.map((row) => ({
|
||||
id: row?.entry?.id || row?.id || '',
|
||||
domain: String(row?.entry?.fqdn || row?.fqdn || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId),
|
||||
}))
|
||||
.filter((row) => row.domain);
|
||||
} catch {
|
||||
const module = await getOrCreateModule('DOMAINS', 'Domains');
|
||||
const { byId } = await buildCommunityMaps();
|
||||
const rows = await paginate(`/modules/${module.id}/domain-entries`);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
domain: String(row.fqdn || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
community: mapCommunityToValue(row.community_id, byId),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchIpRangesData() {
|
||||
try {
|
||||
const catalog = await fetchRouterListsCatalog();
|
||||
const communities = getCatalogItems(catalog.communities);
|
||||
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
|
||||
const rows = getCatalogItems(catalog.ip_ranges);
|
||||
return rows
|
||||
.map((row) => ({
|
||||
id: row?.entry?.id || row?.id || '',
|
||||
ipRange: String(row?.entry?.prefix || row?.prefix || '').trim(),
|
||||
community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId),
|
||||
}))
|
||||
.filter((row) => row.ipRange);
|
||||
} catch {
|
||||
const module = await getOrCreateModule('IP_RANGES', 'IP ranges');
|
||||
const { byId } = await buildCommunityMaps();
|
||||
const rows = await paginate(`/modules/${module.id}/ip-range-entries`);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
ipRange: String(row.prefix || '').trim(),
|
||||
community: mapCommunityToValue(row.community_id, byId),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAsnsData() {
|
||||
try {
|
||||
const catalog = await fetchRouterListsCatalog();
|
||||
const communities = getCatalogItems(catalog.communities);
|
||||
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
|
||||
const rows = getCatalogItems(catalog.asns);
|
||||
return rows
|
||||
.map((row) => ({
|
||||
id: row?.entry?.id || row?.id || '',
|
||||
asn: String(row?.entry?.asn || row?.asn || '').trim(),
|
||||
community: mapCommunityToValue(row?.entry?.community_id || row?.community_id || null, byId),
|
||||
}))
|
||||
.filter((row) => row.asn);
|
||||
} catch {
|
||||
const module = await getOrCreateModule('AS_PREFIXES', 'ASNs');
|
||||
const { byId } = await buildCommunityMaps();
|
||||
const rows = await paginate(`/modules/${module.id}/as-entries`);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
asn: String(row.asn || '').trim(),
|
||||
community: mapCommunityToValue(row.community_id, byId),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-URL: секция каталога или модуль AUTO_URLS / URL_LIST (настраивается env). */
|
||||
async function fetchAutoUrlsData() {
|
||||
try {
|
||||
const catalog = await fetchRouterListsCatalog();
|
||||
for (const name of ['auto_urls', 'auto_url', 'urls', 'url_list']) {
|
||||
const sec = catalog[name];
|
||||
const items = getCatalogItems(sec);
|
||||
if (items.length) {
|
||||
const communities = getCatalogItems(catalog.communities);
|
||||
const byId = new Map(communities.map((c) => [c.id, String(c.community || '')]));
|
||||
return items.map((row) => {
|
||||
const url = String(row?.entry?.url || row?.entry?.fqdn || row?.url || row?.fqdn || '').trim();
|
||||
const community = mapCommunityToValue(
|
||||
row?.entry?.community_id || row?.community_id || null,
|
||||
byId,
|
||||
);
|
||||
return { url, community };
|
||||
}).filter((r) => r.url);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
const modType = process.env.EVOBGP_AUTO_URL_MODULE_TYPE || 'AUTO_URLS';
|
||||
try {
|
||||
const module = await getOrCreateModule(modType, 'Auto URLs');
|
||||
const listPath = process.env.EVOBGP_AUTO_URL_LIST_PATH || 'url-entries';
|
||||
const rows = await paginate(`/modules/${module.id}/${listPath}`);
|
||||
const { byId } = await buildCommunityMaps();
|
||||
return rows.map((row) => ({
|
||||
url: String(row.url || row.fqdn || '').trim(),
|
||||
community: mapCommunityToValue(row.community_id, byId),
|
||||
})).filter((r) => r.url);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDomainsData(currentItems, originalItems) {
|
||||
await syncEntries({
|
||||
moduleType: 'DOMAINS',
|
||||
moduleName: 'Domains',
|
||||
listPath: 'domain-entries',
|
||||
keyName: 'domain',
|
||||
currentItems,
|
||||
originalItems,
|
||||
createBody: (item, communityId) => ({ fqdn: item.domain, community_id: communityId }),
|
||||
patchBody: (item, communityId) => ({ fqdn: item.domain, community_id: communityId }),
|
||||
});
|
||||
}
|
||||
|
||||
async function saveIpRangesData(currentItems, originalItems) {
|
||||
await syncEntries({
|
||||
moduleType: 'IP_RANGES',
|
||||
moduleName: 'IP ranges',
|
||||
listPath: 'ip-range-entries',
|
||||
keyName: 'ipRange',
|
||||
currentItems,
|
||||
originalItems,
|
||||
requireCommunity: true,
|
||||
createBody: (item, communityId) => ({ prefix: item.ipRange, community_id: communityId }),
|
||||
patchBody: (item, communityId) => ({ prefix: item.ipRange, community_id: communityId }),
|
||||
});
|
||||
}
|
||||
|
||||
async function saveAsnsData(currentItems, originalItems) {
|
||||
await syncEntries({
|
||||
moduleType: 'AS_PREFIXES',
|
||||
moduleName: 'ASNs',
|
||||
listPath: 'as-entries',
|
||||
keyName: 'asn',
|
||||
currentItems,
|
||||
originalItems,
|
||||
createBody: (item, communityId) => ({ asn: Number(item.asn), community_id: communityId }),
|
||||
patchBody: (item, communityId) => ({ asn: Number(item.asn), community_id: communityId }),
|
||||
});
|
||||
}
|
||||
|
||||
async function saveCommunitiesData(currentItems) {
|
||||
const existing = await paginate('/communities');
|
||||
const existingByValue = new Map(existing.map((c) => [String(c.community || ''), c]));
|
||||
const nextByValue = new Map();
|
||||
for (const item of currentItems || []) {
|
||||
const value = String(item?.value || '').trim();
|
||||
if (!value) continue;
|
||||
nextByValue.set(value, item);
|
||||
}
|
||||
|
||||
for (const [value, row] of existingByValue.entries()) {
|
||||
if (!nextByValue.has(value)) {
|
||||
await evobgpRequest('DELETE', `/communities/${row.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [value, item] of nextByValue.entries()) {
|
||||
const title = String(item?.name || '').trim();
|
||||
const row = existingByValue.get(value);
|
||||
if (!row) {
|
||||
await evobgpRequest('POST', '/communities', { body: { community: value, title } });
|
||||
continue;
|
||||
}
|
||||
if (String(row.title || '') !== title) {
|
||||
await evobgpRequest('PATCH', `/communities/${row.id}`, { body: { community: value, title } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAutoUrlsData(urls) {
|
||||
const originalItems = await fetchAutoUrlsData();
|
||||
const list = (urls || []).map((u) => ({
|
||||
url: String(u?.url || '').trim(),
|
||||
community: String(u?.community || '').trim(),
|
||||
})).filter((u) => u.url && u.community);
|
||||
const modType = process.env.EVOBGP_AUTO_URL_MODULE_TYPE || 'AUTO_URLS';
|
||||
const listPath = process.env.EVOBGP_AUTO_URL_LIST_PATH || 'url-entries';
|
||||
const keyName = process.env.EVOBGP_AUTO_URL_KEY_FIELD || 'url';
|
||||
const currentItems = list.map((u) => ({ [keyName]: u.url, community: u.community }));
|
||||
const origMapped = originalItems.map((u) => ({ [keyName]: u.url, community: u.community }));
|
||||
await syncEntries({
|
||||
moduleType: modType,
|
||||
moduleName: 'Auto URLs',
|
||||
listPath,
|
||||
keyName,
|
||||
currentItems,
|
||||
originalItems: origMapped,
|
||||
requireCommunity: true,
|
||||
createBody: (item, communityId) => ({ url: item[keyName], community_id: communityId }),
|
||||
patchBody: (item, communityId) => ({ url: item[keyName], community_id: communityId }),
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isConfigured,
|
||||
evobgpRequest,
|
||||
paginate,
|
||||
fetchRouterListsCatalog,
|
||||
listCommunities,
|
||||
fetchDomainsData,
|
||||
fetchIpRangesData,
|
||||
fetchAsnsData,
|
||||
fetchAutoUrlsData,
|
||||
saveCommunitiesData,
|
||||
saveDomainsData,
|
||||
saveIpRangesData,
|
||||
saveAsnsData,
|
||||
saveAutoUrlsData,
|
||||
buildCommunityMaps,
|
||||
mapCommunityToValue,
|
||||
};
|
||||
Reference in New Issue
Block a user