Backend: SQLite storage, EvoBGP integration, filters in SQL
Made-with: Cursor
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,68 @@
|
||||
-- Router-lists local storage (SQLite). EvoBGP holds reference lists; this DB holds config + caches.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
version INTEGER NOT NULL UNIQUE,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blobs (
|
||||
object_key TEXT PRIMARY KEY,
|
||||
namespace TEXT NOT NULL CHECK (namespace IN (
|
||||
'config',
|
||||
'derived',
|
||||
'per_server',
|
||||
'cache'
|
||||
)),
|
||||
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
body BLOB NOT NULL,
|
||||
byte_size INTEGER NOT NULL,
|
||||
etag TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blobs_namespace_updated
|
||||
ON blobs (namespace, updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blobs_prefix
|
||||
ON blobs (object_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blob_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
object_key TEXT NOT NULL,
|
||||
body BLOB NOT NULL,
|
||||
byte_size INTEGER NOT NULL,
|
||||
etag TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (object_key) REFERENCES blobs(object_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blob_versions_key_time
|
||||
ON blob_versions (object_key, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS versioned_keys (
|
||||
object_key TEXT PRIMARY KEY
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO versioned_keys (object_key) VALUES
|
||||
('filters.json'),
|
||||
('servers.json');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cache_entries (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
namespace TEXT NOT NULL DEFAULT 'cache',
|
||||
body BLOB NOT NULL,
|
||||
byte_size INTEGER NOT NULL,
|
||||
etag TEXT,
|
||||
updated_at INTEGER NOT NULL,
|
||||
expires_at INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cache_expires
|
||||
ON cache_entries (expires_at);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Нормализованные глобальные и simple-фильтры (вместо filters.json / simple-filters.json в blobs)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_filter_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('global', 'simple')),
|
||||
position INTEGER NOT NULL,
|
||||
community TEXT NOT NULL,
|
||||
gateway TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
extra_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_filter_rules_scope_pos ON app_filter_rules(scope, position);
|
||||
|
||||
DELETE FROM versioned_keys WHERE object_key = 'filters.json';
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Single-process SQLite connection, migrations, PRAGMAs.
|
||||
* Designed so the same API can later move to a dedicated storage service process.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
let _db = null;
|
||||
let _cachePurgeTimer = null;
|
||||
|
||||
function defaultDbPath() {
|
||||
return path.join(__dirname, '..', 'data', 'router-lists.db');
|
||||
}
|
||||
|
||||
function getDbPath() {
|
||||
const p = process.env.SQLITE_PATH || defaultDbPath();
|
||||
const dir = path.dirname(p);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
function applyMigration001(db) {
|
||||
const sql = fs.readFileSync(path.join(__dirname, 'migrations', '001_init.sql'), 'utf8');
|
||||
db.exec(sql);
|
||||
}
|
||||
|
||||
function applyMigration002(db) {
|
||||
const sql = fs.readFileSync(path.join(__dirname, 'migrations', '002_filter_rules.sql'), 'utf8');
|
||||
db.exec(sql);
|
||||
|
||||
const migrateBlobToRules = (objectKey, scope) => {
|
||||
const row = db.prepare('SELECT body FROM blobs WHERE object_key = ?').get(objectKey);
|
||||
if (!row) return;
|
||||
let bodyStr = '';
|
||||
try {
|
||||
bodyStr = Buffer.isBuffer(row.body) ? row.body.toString('utf8') : String(row.body);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
let arr = [];
|
||||
try {
|
||||
const parsed = JSON.parse(bodyStr);
|
||||
arr = Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const delRules = db.prepare('DELETE FROM app_filter_rules WHERE scope = ?');
|
||||
const ins = db.prepare(
|
||||
`INSERT INTO app_filter_rules (scope, position, community, gateway, description, extra_json, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
const run = () => {
|
||||
delRules.run(scope);
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const it = arr[i] || {};
|
||||
const community = String(it.community || '').trim();
|
||||
const gateway = String(it.gateway || '').trim();
|
||||
const description = String(it.description || '').trim();
|
||||
const extra = { ...it };
|
||||
delete extra.community;
|
||||
delete extra.gateway;
|
||||
delete extra.description;
|
||||
ins.run(scope, i, community, gateway, description, JSON.stringify(extra), now);
|
||||
}
|
||||
db.prepare('INSERT OR REPLACE INTO app_meta (key, value) VALUES (?, ?)').run(
|
||||
`filter_rules_mtime_${scope}`,
|
||||
String(now),
|
||||
);
|
||||
db.prepare('DELETE FROM blob_versions WHERE object_key = ?').run(objectKey);
|
||||
db.prepare('DELETE FROM blobs WHERE object_key = ?').run(objectKey);
|
||||
};
|
||||
db.transaction(run)();
|
||||
};
|
||||
|
||||
migrateBlobToRules('filters.json', 'global');
|
||||
migrateBlobToRules('filter-manager/simple-filters.json', 'simple');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {import('better-sqlite3').Database}
|
||||
*/
|
||||
function openDatabase() {
|
||||
if (_db) return _db;
|
||||
|
||||
const filePath = getDbPath();
|
||||
const db = new Database(filePath);
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('synchronous = NORMAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.pragma(`busy_timeout = ${Number(process.env.SQLITE_BUSY_MS) || 8000}`);
|
||||
const cacheKb = Number(process.env.SQLITE_CACHE_KB);
|
||||
if (Number.isFinite(cacheKb) && cacheKb !== 0) {
|
||||
db.pragma(`cache_size = ${-Math.abs(Math.floor(cacheKb))}`);
|
||||
}
|
||||
|
||||
let userVersion = db.pragma('user_version', { simple: true });
|
||||
if (userVersion < 1) {
|
||||
applyMigration001(db);
|
||||
db.pragma('user_version = 1');
|
||||
const now = Date.now();
|
||||
db.prepare('INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (1, ?)').run(now);
|
||||
userVersion = 1;
|
||||
}
|
||||
if (userVersion < 2) {
|
||||
applyMigration002(db);
|
||||
db.pragma('user_version = 2');
|
||||
const now2 = Date.now();
|
||||
db.prepare('INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (2, ?)').run(now2);
|
||||
}
|
||||
|
||||
_db = db;
|
||||
|
||||
if (!_cachePurgeTimer) {
|
||||
const intervalMs = Math.max(60_000, Number(process.env.SQLITE_CACHE_PURGE_MS) || 300_000);
|
||||
_cachePurgeTimer = setInterval(purgeExpiredCacheEntries, intervalMs);
|
||||
_cachePurgeTimer.unref?.();
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
function purgeExpiredCacheEntries() {
|
||||
const db = openDatabase();
|
||||
const now = Date.now();
|
||||
try {
|
||||
db.prepare('DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?').run(now);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
openDatabase,
|
||||
getDbPath,
|
||||
purgeExpiredCacheEntries,
|
||||
};
|
||||
Generated
+409
-1666
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,8 @@
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.687.0",
|
||||
"ajv": "^8.17.1",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.0.1",
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
/**
|
||||
* Роуты для работы с communities (справочник BGP Community)
|
||||
* Роуты для работы с communities (справочник BGP Community) — данные из EvoBGP API
|
||||
*/
|
||||
|
||||
const { s3, BUCKET_NAME, writeS3JsonObject, invalidateCacheForKey } = require('../services/s3Service');
|
||||
const evobgpClient = require('../services/evobgpClient');
|
||||
const filterRulesStorage = require('../services/filterRulesStorage');
|
||||
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||
const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { streamToString } = require('../services/s3Service');
|
||||
const validators = require('../lib/validators');
|
||||
const { getS3JsonWithHeaders } = require('../utils/s3Helpers');
|
||||
|
||||
const S3_KEY = 'bgp_data/communities.json';
|
||||
function requireEvobgp(res) {
|
||||
if (!evobgpClient.isConfigured()) {
|
||||
return sendError(
|
||||
res,
|
||||
503,
|
||||
'EvoBGP не настроен: задайте EVOBGP_API_URL и EVOBGP_API_TOKEN',
|
||||
'E_EVOBGP_NOT_CONFIGURED',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET /api/communities
|
||||
async function getCommunities(req, res) {
|
||||
await getS3JsonWithHeaders(S3_KEY, req, res, {
|
||||
transform: (communities) => {
|
||||
// Нормализация
|
||||
return communities
|
||||
.filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0)
|
||||
.map((c) => ({
|
||||
value: String(c.value).trim(),
|
||||
name: c.name ? String(c.name) : '',
|
||||
description: c.description ? String(c.description) : '',
|
||||
tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
|
||||
color: c.color ? String(c.color) : '',
|
||||
icon: c.icon ? String(c.icon) : ''
|
||||
}));
|
||||
},
|
||||
defaultValue: []
|
||||
});
|
||||
const err = requireEvobgp(res);
|
||||
if (err) return err;
|
||||
try {
|
||||
const communities = await evobgpClient.listCommunities();
|
||||
const normalized = communities
|
||||
.filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0)
|
||||
.map((c) => ({
|
||||
value: String(c.value).trim(),
|
||||
name: c.name ? String(c.name) : '',
|
||||
description: c.description ? String(c.description) : '',
|
||||
tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
|
||||
color: c.color ? String(c.color) : '',
|
||||
icon: c.icon ? String(c.icon) : '',
|
||||
}));
|
||||
res.json(normalized);
|
||||
} catch (e) {
|
||||
console.error('getCommunities', e);
|
||||
return sendError(res, 500, e.message || 'EvoBGP error', 'E_EVOBGP');
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/communities
|
||||
async function postCommunities(req, res) {
|
||||
const err = requireEvobgp(res);
|
||||
if (err) return err;
|
||||
|
||||
const { communities } = req.body;
|
||||
|
||||
if (!Array.isArray(communities)) {
|
||||
@@ -42,26 +56,26 @@ async function postCommunities(req, res) {
|
||||
const seen = new Set();
|
||||
const normalized = [];
|
||||
const validationErrors = [];
|
||||
|
||||
|
||||
for (let i = 0; i < communities.length; i++) {
|
||||
const entry = communities[i] || {};
|
||||
const value = typeof entry.value === 'string' ? entry.value.trim() : '';
|
||||
|
||||
|
||||
if (!value) {
|
||||
validationErrors.push(`Community at index ${i} is missing required field: value`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (!validators.isValidCommunity(value)) {
|
||||
validationErrors.push(`Community at index ${i} has invalid value: ${value}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (seen.has(value)) {
|
||||
validationErrors.push(`Duplicate community value at index ${i}: ${value}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
seen.add(value);
|
||||
normalized.push({
|
||||
value,
|
||||
@@ -75,97 +89,78 @@ async function postCommunities(req, res) {
|
||||
enabled: typeof entry.enabled === 'boolean' ? entry.enabled : true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', { errors: validationErrors });
|
||||
}
|
||||
|
||||
try {
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: S3_KEY,
|
||||
Body: JSON.stringify(normalized, null, 2),
|
||||
ContentType: 'application/json',
|
||||
}));
|
||||
invalidateCacheForKey(S3_KEY);
|
||||
const { headMeta } = require('../services/s3Service');
|
||||
const meta = await headMeta(S3_KEY);
|
||||
return sendOk(res, meta);
|
||||
await evobgpClient.saveCommunitiesData(normalized);
|
||||
return sendOk(res, {
|
||||
etag: null,
|
||||
lastModified: new Date().toISOString(),
|
||||
contentLength: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error writing communities to S3:', error);
|
||||
return sendError(res, 500, 'Error writing communities to S3', 'E_S3');
|
||||
console.error('Error writing communities to EvoBGP:', error);
|
||||
return sendError(res, 500, error.message || 'EvoBGP write failed', 'E_EVOBGP');
|
||||
}
|
||||
}
|
||||
|
||||
function countCommunityInLines(rows, getCommunity) {
|
||||
const usage = new Map();
|
||||
for (const row of rows || []) {
|
||||
const c = getCommunity(row);
|
||||
if (c) usage.set(c, (usage.get(c) || 0) + 1);
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
// GET /api/communities/stats
|
||||
async function getCommunityStats(req, res) {
|
||||
const err = requireEvobgp(res);
|
||||
if (err) return err;
|
||||
try {
|
||||
const [domainsRes, ipRangesRes, asnsRes, filtersRes] = await Promise.allSettled([
|
||||
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' })),
|
||||
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' })),
|
||||
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/asns.txt' })),
|
||||
s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filter-manager/simple-filters.json' })),
|
||||
const [domains, ipRanges, asns] = await Promise.allSettled([
|
||||
evobgpClient.fetchDomainsData(),
|
||||
evobgpClient.fetchIpRangesData(),
|
||||
evobgpClient.fetchAsnsData(),
|
||||
]);
|
||||
|
||||
|
||||
const communityUsage = new Map();
|
||||
|
||||
// Подсчет использования в доменах
|
||||
if (domainsRes.status === 'fulfilled') {
|
||||
const text = await streamToString(domainsRes.value.Body);
|
||||
text.split('\n').filter(Boolean).forEach(line => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts[1]) {
|
||||
communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
if (domains.status === 'fulfilled') {
|
||||
const m = countCommunityInLines(domains.value, (r) => r.community);
|
||||
for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
|
||||
}
|
||||
|
||||
// Подсчет в IP ranges
|
||||
if (ipRangesRes.status === 'fulfilled') {
|
||||
const text = await streamToString(ipRangesRes.value.Body);
|
||||
text.split('\n').filter(Boolean).forEach(line => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts[1]) {
|
||||
communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
if (ipRanges.status === 'fulfilled') {
|
||||
const m = countCommunityInLines(ipRanges.value, (r) => r.community);
|
||||
for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
|
||||
}
|
||||
|
||||
// Подсчет в ASNs
|
||||
if (asnsRes.status === 'fulfilled') {
|
||||
const text = await streamToString(asnsRes.value.Body);
|
||||
text.split('\n').filter(Boolean).forEach(line => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts[1]) {
|
||||
communityUsage.set(parts[1], (communityUsage.get(parts[1]) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
if (asns.status === 'fulfilled') {
|
||||
const m = countCommunityInLines(asns.value, (r) => r.community);
|
||||
for (const [k, v] of m) communityUsage.set(k, (communityUsage.get(k) || 0) + v);
|
||||
}
|
||||
|
||||
// Подсчет в фильтрах
|
||||
if (filtersRes.status === 'fulfilled') {
|
||||
try {
|
||||
const text = await streamToString(filtersRes.value.Body);
|
||||
const filters = JSON.parse(text);
|
||||
if (Array.isArray(filters)) {
|
||||
filters.forEach(f => {
|
||||
if (f.community) {
|
||||
communityUsage.set(f.community, (communityUsage.get(f.community) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const simpleFilters = filterRulesStorage.listRules('simple');
|
||||
for (const f of simpleFilters) {
|
||||
if (f.community) {
|
||||
communityUsage.set(f.community, (communityUsage.get(f.community) || 0) + 1);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const stats = Array.from(communityUsage.entries()).map(([community, count]) => ({
|
||||
community,
|
||||
count,
|
||||
})).sort((a, b) => b.count - a.count);
|
||||
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const stats = Array.from(communityUsage.entries())
|
||||
.map(([community, count]) => ({ community, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
res.json({ stats, total: stats.reduce((sum, s) => sum + s.count, 0) });
|
||||
} catch (error) {
|
||||
console.error('Error getting community stats:', error);
|
||||
return sendError(res, 500, 'Error getting community stats', 'E_S3');
|
||||
return sendError(res, 500, 'Error getting community stats', 'E_EVOBGP');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,4 +169,3 @@ module.exports = {
|
||||
postCommunities,
|
||||
getCommunityStats,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* GET/POST для справочников из EvoBGP (ранее textDataRoutes + S3).
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const evobgpClient = require('../services/evobgpClient');
|
||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||
|
||||
const countOnlyCache = { map: new Map(), ttlMs: 10_000 };
|
||||
|
||||
function getCountOnlyCache(cacheKey) {
|
||||
const v = countOnlyCache.map.get(cacheKey);
|
||||
if (!v) return null;
|
||||
if (Date.now() > v.at + countOnlyCache.ttlMs) {
|
||||
countOnlyCache.map.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
return v.value;
|
||||
}
|
||||
|
||||
function setCountOnlyCache(cacheKey, value) {
|
||||
countOnlyCache.map.set(cacheKey, { value, at: Date.now() });
|
||||
}
|
||||
|
||||
function etagForJson(obj) {
|
||||
const s = JSON.stringify(obj);
|
||||
const h = crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex');
|
||||
return `"${h}"`;
|
||||
}
|
||||
|
||||
function filterPage(items, q, offset, limit) {
|
||||
const qstr = q ? String(q).toLowerCase() : '';
|
||||
const filtered = !qstr
|
||||
? items
|
||||
: items.filter((it) => JSON.stringify(it).toLowerCase().includes(qstr));
|
||||
const total = filtered.length;
|
||||
if (limit > 0) {
|
||||
return { items: filtered.slice(offset, offset + limit), total };
|
||||
}
|
||||
return { items: filtered, total };
|
||||
}
|
||||
|
||||
function createEvobgpListGET({ fetchItems, cachePrefix, validate }) {
|
||||
return async (req, res) => {
|
||||
const { q = '', offset, limit, countOnly, format } = req.query || {};
|
||||
if (!evobgpClient.isConfigured()) {
|
||||
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
|
||||
}
|
||||
try {
|
||||
if (countOnly === 'true') {
|
||||
const cacheKey = `${cachePrefix}:count:${q}`;
|
||||
const cached = getCountOnlyCache(cacheKey);
|
||||
if (cached != null) {
|
||||
return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached });
|
||||
}
|
||||
const all = await fetchItems();
|
||||
const { total } = filterPage(all, q, 0, 0);
|
||||
setCountOnlyCache(cacheKey, total);
|
||||
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
|
||||
}
|
||||
|
||||
if (Number(limit) > 0) {
|
||||
const all = await fetchItems();
|
||||
if (!validate(all)) {
|
||||
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
|
||||
}
|
||||
const { items, total } = filterPage(all, q, Number(offset) || 0, Number(limit) || 0);
|
||||
return res.json(format === 'std' ? { items, total, meta: {} } : { items, total });
|
||||
}
|
||||
|
||||
const all = await fetchItems();
|
||||
if (!validate(all)) {
|
||||
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
|
||||
}
|
||||
const etag = etagForJson(all);
|
||||
res.set('ETag', etag);
|
||||
res.set('Last-Modified', new Date().toUTCString());
|
||||
res.set('Content-Length-Source', String(Buffer.byteLength(JSON.stringify(all), 'utf8')));
|
||||
if (checkIfNoneMatch(req, res, etag)) return;
|
||||
if (format === 'std') {
|
||||
return res.json({ items: all, total: all.length, meta: {} });
|
||||
}
|
||||
return res.json(all);
|
||||
} catch (e) {
|
||||
console.error(cachePrefix, e);
|
||||
return sendError(res, 500, e.message || 'EvoBGP error', 'E_EVOBGP');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function readIfMatch(req) {
|
||||
const raw = req.headers['if-match'] || req.headers['If-Match'];
|
||||
if (!raw) return null;
|
||||
return String(raw).trim();
|
||||
}
|
||||
|
||||
function createEvobgpDomainsNewPOST({ validate, validateItem }) {
|
||||
return async (req, res) => {
|
||||
const { domains: items, etag: bodyEtag } = req.body || {};
|
||||
if (!Array.isArray(items)) {
|
||||
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
if (!validate(items)) {
|
||||
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
|
||||
}
|
||||
if (validateItem) {
|
||||
const validationErrors = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const errors = validateItem(items[i], i);
|
||||
if (errors.length > 0) validationErrors.push(...errors);
|
||||
}
|
||||
if (validationErrors.length > 0) {
|
||||
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
|
||||
errors: validationErrors.slice(0, 10),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!evobgpClient.isConfigured()) {
|
||||
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
|
||||
}
|
||||
try {
|
||||
const current = await evobgpClient.fetchDomainsData();
|
||||
const currentEtag = etagForJson(current);
|
||||
const ifMatch = readIfMatch(req);
|
||||
const expected = ifMatch || bodyEtag;
|
||||
if (expected && String(expected).trim() !== currentEtag) {
|
||||
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
|
||||
}
|
||||
const normalized = items.map((d) => ({
|
||||
domain: String(d.domain || '').trim().toLowerCase(),
|
||||
community: String(d.community || '').trim(),
|
||||
}));
|
||||
await evobgpClient.saveDomainsData(normalized, current);
|
||||
return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return sendError(res, 500, e.message || 'EvoBGP write failed', 'E_EVOBGP');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createEvobgpAsnsPOST({ validate, validateItem }) {
|
||||
return async (req, res) => {
|
||||
const { domains: items, etag: bodyEtag } = req.body || {};
|
||||
if (!Array.isArray(items)) {
|
||||
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
if (!validate(items)) {
|
||||
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
|
||||
}
|
||||
if (validateItem) {
|
||||
const validationErrors = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const errors = validateItem(items[i], i);
|
||||
if (errors.length > 0) validationErrors.push(...errors);
|
||||
}
|
||||
if (validationErrors.length > 0) {
|
||||
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
|
||||
errors: validationErrors.slice(0, 10),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!evobgpClient.isConfigured()) {
|
||||
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
|
||||
}
|
||||
try {
|
||||
const current = await evobgpClient.fetchAsnsData();
|
||||
const currentEtag = etagForJson(
|
||||
current.map((a) => ({ domain: a.asn, type: a.community })),
|
||||
);
|
||||
const ifMatch = readIfMatch(req);
|
||||
const expected = ifMatch || bodyEtag;
|
||||
if (expected && String(expected).trim() !== currentEtag) {
|
||||
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
|
||||
}
|
||||
const normalized = items.map((a) => ({
|
||||
asn: String(a.domain || a.asn || '').trim(),
|
||||
community: String(a.type || a.community || '').trim(),
|
||||
}));
|
||||
await evobgpClient.saveAsnsData(normalized, current);
|
||||
return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return sendError(res, 500, e.message || 'EvoBGP write failed', 'E_EVOBGP');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createEvobgpIpRangesPOST({ validate, validateItem }) {
|
||||
return async (req, res) => {
|
||||
const payload = Array.isArray(req.body?.domains)
|
||||
? req.body.domains
|
||||
: Array.isArray(req.body?.ipRanges)
|
||||
? req.body.ipRanges
|
||||
: null;
|
||||
const { etag: bodyEtag } = req.body || {};
|
||||
if (!Array.isArray(payload)) {
|
||||
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
if (!validate(payload)) {
|
||||
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
|
||||
}
|
||||
if (validateItem) {
|
||||
const validationErrors = [];
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
const errors = validateItem(payload[i], i);
|
||||
if (errors.length > 0) validationErrors.push(...errors);
|
||||
}
|
||||
if (validationErrors.length > 0) {
|
||||
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
|
||||
errors: validationErrors.slice(0, 10),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!evobgpClient.isConfigured()) {
|
||||
return sendError(res, 503, 'EvoBGP не настроен', 'E_EVOBGP_NOT_CONFIGURED');
|
||||
}
|
||||
try {
|
||||
const current = await evobgpClient.fetchIpRangesData();
|
||||
const currentEtag = etagForJson(current);
|
||||
const ifMatch = readIfMatch(req);
|
||||
const expected = ifMatch || bodyEtag;
|
||||
if (expected && String(expected).trim() !== currentEtag) {
|
||||
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
|
||||
}
|
||||
const normalized = payload.map((ip) => ({
|
||||
ipRange: String(ip.ipRange || '').trim(),
|
||||
community: String(ip.community || '').trim(),
|
||||
}));
|
||||
await evobgpClient.saveIpRangesData(normalized, current);
|
||||
return sendOk(res, { etag: null, lastModified: new Date().toISOString(), contentLength: null });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return sendError(res, 500, e.message || 'EvoBGP write failed', 'E_EVOBGP');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy /api/domains: тот же модуль ASN, ответ в полях domain + type. */
|
||||
function createEvobgpLegacyDomainsGET({ validate }) {
|
||||
return createEvobgpListGET({
|
||||
cachePrefix: 'domains',
|
||||
validate,
|
||||
fetchItems: async () => {
|
||||
const rows = await evobgpClient.fetchAsnsData();
|
||||
return rows.map((r) => ({ domain: r.asn, type: r.community }));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createEvobgpListGET,
|
||||
createEvobgpDomainsNewPOST,
|
||||
createEvobgpAsnsPOST,
|
||||
createEvobgpIpRangesPOST,
|
||||
createEvobgpLegacyDomainsGET,
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* GET/POST /api/filters и /api/simple-filters — данные в app_filter_rules, не в blobs.
|
||||
*/
|
||||
|
||||
const filterRulesStorage = require('../services/filterRulesStorage');
|
||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||
|
||||
/**
|
||||
* @param {'global'|'simple'} scope
|
||||
* @param {(item: any, i: number) => string|null} validateItem
|
||||
*/
|
||||
function createFilterRulesRoutes(scope, validateItem) {
|
||||
return {
|
||||
get: async (req, res) => {
|
||||
try {
|
||||
const head = filterRulesStorage.headMetaForScope(scope);
|
||||
if (head.etag) res.set('ETag', String(head.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, head.etag)) return;
|
||||
res.json(filterRulesStorage.listRules(scope));
|
||||
} catch (e) {
|
||||
console.error('filterRules get', e);
|
||||
return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
|
||||
}
|
||||
},
|
||||
|
||||
post: async (req, res) => {
|
||||
const { domains: items } = req.body;
|
||||
if (!Array.isArray(items)) {
|
||||
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
try {
|
||||
const meta = filterRulesStorage.replaceRules(scope, items, { validateItem });
|
||||
return sendOk(res, meta);
|
||||
} catch (e) {
|
||||
if (e.code === 'E_SCHEMA') {
|
||||
return sendError(res, 400, e.message || 'Validation failed', 'E_SCHEMA');
|
||||
}
|
||||
console.error('filterRules post', e);
|
||||
return sendError(res, 500, 'Error writing storage', 'E_STORAGE');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createFilterRulesRoutes,
|
||||
};
|
||||
@@ -2,12 +2,10 @@
|
||||
* Роуты для фильтров и генерации конфигураций MikroTik
|
||||
*/
|
||||
|
||||
const { s3, BUCKET_NAME, writeS3TextObject, headMeta } = require('../services/s3Service');
|
||||
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { streamToString } = require('../services/s3Service');
|
||||
const { writeS3TextObject } = require('../services/s3Service');
|
||||
const filterRulesStorage = require('../services/filterRulesStorage');
|
||||
const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||
const { generateMikrotikConfig } = require('../utils/mikrotikConfig');
|
||||
const { getS3JsonWithHeaders } = require('../utils/s3Helpers');
|
||||
|
||||
// GET /api/filters/generate-config?format=text|json
|
||||
async function generateConfig(req, res) {
|
||||
@@ -16,13 +14,16 @@ async function generateConfig(req, res) {
|
||||
if (!['text', 'json'].includes(format)) {
|
||||
return sendError(res, 400, 'format must be "text" or "json"', 'E_BAD_REQUEST');
|
||||
}
|
||||
await getS3JsonWithHeaders('filters.json', req, res, {
|
||||
transform: (filters) => {
|
||||
const result = generateMikrotikConfig(filters, { format });
|
||||
return format === 'json' ? result : { config: result };
|
||||
},
|
||||
defaultValue: [],
|
||||
});
|
||||
const head = filterRulesStorage.headMetaForScope('global');
|
||||
if (head.etag) res.set('ETag', String(head.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, head.etag)) return;
|
||||
const filters = filterRulesStorage.listRules('global');
|
||||
const result = generateMikrotikConfig(filters, { format });
|
||||
res.json(format === 'json' ? result : { config: result });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error generating configuration', 'E_S3');
|
||||
@@ -32,17 +33,7 @@ async function generateConfig(req, res) {
|
||||
// POST /api/filters/export-config
|
||||
async function exportConfig(req, res) {
|
||||
try {
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filters.json' }));
|
||||
const fileContent = await streamToString(data.Body);
|
||||
let filters = [];
|
||||
|
||||
try {
|
||||
filters = JSON.parse(fileContent);
|
||||
if (!Array.isArray(filters)) filters = [];
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing filters.json:', parseError);
|
||||
filters = [];
|
||||
}
|
||||
const filters = filterRulesStorage.listRules('global');
|
||||
|
||||
if (filters.length === 0) {
|
||||
return res.json({ success: false, message: 'Нет фильтров для экспорта' });
|
||||
@@ -50,10 +41,10 @@ async function exportConfig(req, res) {
|
||||
|
||||
const config = generateMikrotikConfig(filters);
|
||||
await writeS3TextObject('mikrotik-frouting-config.txt', config);
|
||||
res.json({ success: true, message: 'Конфигурация экспортирована в S3' });
|
||||
res.json({ success: true, message: 'Конфигурация экспортирована в локальное хранилище' });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error exporting configuration', 'E_S3');
|
||||
return sendError(res, 500, 'Error exporting configuration', 'E_STORAGE');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
* Общие роуты для JSON данных (servers, filters, billing и т.д.)
|
||||
*/
|
||||
|
||||
const { s3, BUCKET_NAME, writeS3JsonObject } = require('../services/s3Service');
|
||||
const { writeS3JsonObject, headMeta, readS3TextObject } = require('../services/s3Service');
|
||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { streamToString } = require('../services/s3Service');
|
||||
|
||||
/**
|
||||
* Создать GET эндпоинт для JSON данных
|
||||
* @param {string} s3Key - Ключ в S3
|
||||
* @param {string} s3Key - Ключ объекта (логический, как раньше в S3)
|
||||
* @param {Object} options - Опции
|
||||
* @param {boolean} options.singleObject - Если true, возвращает объект вместо массива
|
||||
* @param {any} options.defaultValue - Значение по умолчанию ([] для массива, {} для объекта)
|
||||
@@ -17,66 +15,63 @@ const { streamToString } = require('../services/s3Service');
|
||||
function createJsonDataGET(s3Key, options = {}) {
|
||||
const { singleObject = false, defaultValue } = options;
|
||||
const fallback = defaultValue !== undefined ? defaultValue : (singleObject ? {} : []);
|
||||
|
||||
|
||||
return async (req, res) => {
|
||||
try {
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
|
||||
const etag = head?.ETag || null;
|
||||
const head = await headMeta(s3Key);
|
||||
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 (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: s3Key }));
|
||||
const fileContent = await streamToString(data.Body);
|
||||
let fileContent;
|
||||
try {
|
||||
const data = await readS3TextObject(s3Key);
|
||||
fileContent = data.body;
|
||||
} catch (e) {
|
||||
if (e?.code === 'NoSuchKey') {
|
||||
return res.json(fallback);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
let items = fallback;
|
||||
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fileContent);
|
||||
if (singleObject) {
|
||||
// Для одиночного объекта
|
||||
items = (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) ? parsed : fallback;
|
||||
items = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : fallback;
|
||||
} else {
|
||||
// Для массива
|
||||
items = Array.isArray(parsed) ? parsed : fallback;
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error(`Error parsing ${s3Key}:`, parseError);
|
||||
items = fallback;
|
||||
}
|
||||
|
||||
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
||||
res.json(fallback);
|
||||
} else {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
||||
}
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать POST эндпоинт для JSON данных
|
||||
* @param {string} s3Key - Ключ в S3
|
||||
* @param {Function} validateItem - Функция валидации (для массива - валидирует каждый элемент, для объекта - весь объект)
|
||||
* @param {Object} options - Опции
|
||||
* @param {boolean} options.singleObject - Если true, ожидает объект вместо массива
|
||||
*/
|
||||
function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
|
||||
const { singleObject = false } = options;
|
||||
|
||||
|
||||
return async (req, res) => {
|
||||
const { domains: items } = req.body; // Используем 'domains' для обратной совместимости
|
||||
|
||||
const { domains: items } = req.body;
|
||||
|
||||
if (singleObject) {
|
||||
// Для одиночного объекта
|
||||
if (typeof items !== 'object' || items === null || Array.isArray(items)) {
|
||||
return sendError(res, 400, 'Data must be an object', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
// Валидация объекта целиком
|
||||
|
||||
if (validateItem) {
|
||||
const error = validateItem(items);
|
||||
if (error) {
|
||||
@@ -84,12 +79,10 @@ function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Для массива
|
||||
if (!Array.isArray(items)) {
|
||||
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
// Валидация каждого элемента массива
|
||||
|
||||
if (validateItem) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const error = validateItem(items[i], i);
|
||||
@@ -105,23 +98,15 @@ function createJsonDataPOST(s3Key, validateItem = null, options = {}) {
|
||||
return sendOk(res, meta);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error writing to S3', 'E_S3');
|
||||
return sendError(res, 500, 'Error writing storage', 'E_STORAGE');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать роуты для JSON данных (GET + POST)
|
||||
* @param {string} s3Key - Ключ в S3
|
||||
* @param {Function} validateItem - Функция валидации
|
||||
* @param {Object} options - Опции
|
||||
* @param {boolean} options.singleObject - Если true, работает с объектом вместо массива
|
||||
* @param {any} options.defaultValue - Значение по умолчанию
|
||||
*/
|
||||
function createJsonDataRoutes(s3Key, validateItem = null, options = {}) {
|
||||
return {
|
||||
get: createJsonDataGET(s3Key, options),
|
||||
post: createJsonDataPOST(s3Key, validateItem, options)
|
||||
post: createJsonDataPOST(s3Key, validateItem, options),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,4 +115,3 @@ module.exports = {
|
||||
createJsonDataGET,
|
||||
createJsonDataPOST,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Роуты для бэкапов конфигурации MikroTik
|
||||
* Хранят сырый текст конфигурации (например, вывод /export) в S3.
|
||||
* Хранят сырый текст конфигурации (например, вывод /export) на локальной ФС.
|
||||
*
|
||||
* Ключи в S3: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc
|
||||
* Логические ключи: backups/mikrotik/{serverId}/{timestamp}-{rand}.rsc
|
||||
*
|
||||
* Задачи:
|
||||
* - создать бэкап (ручной или из планировщика)
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||
const { writeS3TextObject, readS3TextObject, listS3Objects } = require('../services/s3Service');
|
||||
const { writeBackupFile, readBackupFile, listBackupObjects } = require('../services/backupFsService');
|
||||
const { readServersFromS3 } = require('./serversRoutes');
|
||||
const { createRosClient, fetchExportViaFile } = require('../services/mikrotikApplyService');
|
||||
const { encrypt, decrypt } = require('../utils/encryption');
|
||||
@@ -61,7 +61,7 @@ async function saveBackupForServer(serverId, config, { source, comment } = {}) {
|
||||
|
||||
let content = `${headerLines.join('\n')}\n${String(config || '').replace(/^\uFEFF/, '')}`;
|
||||
|
||||
// Шифруем содержимое на S3, если задан ENCRYPTION_KEY
|
||||
// Шифруем содержимое файла, если задан ENCRYPTION_KEY
|
||||
if (process.env.ENCRYPTION_KEY) {
|
||||
try {
|
||||
content = ENCRYPTED_PREFIX + encrypt(content);
|
||||
@@ -71,15 +71,15 @@ async function saveBackupForServer(serverId, config, { source, comment } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const meta = await writeS3TextObject(key, content, 'text/plain');
|
||||
return { key, createdAt: now.toISOString(), s3: meta };
|
||||
const meta = await writeBackupFile(key, content, 'text/plain');
|
||||
return { key, createdAt: now.toISOString(), storage: meta };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mikrotik/backups
|
||||
* Body: { serverId: string, config: string, source?: string, comment?: string }
|
||||
*
|
||||
* Сохраняет текст конфигурации (обычно вывод /export) в S3.
|
||||
* Сохраняет текст конфигурации (обычно вывод /export) в каталог бэкапов на диске.
|
||||
* Автоматический бэкап можно организовать внешним планировщиком, дергающим этот эндпоинт.
|
||||
*/
|
||||
async function createBackup(req, res) {
|
||||
@@ -93,14 +93,14 @@ async function createBackup(req, res) {
|
||||
return sendError(res, 400, 'config (MikroTik export text) is required', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, { source, comment });
|
||||
const { key, createdAt, storage } = await saveBackupForServer(serverId, config, { source, comment });
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
key,
|
||||
serverId,
|
||||
createdAt,
|
||||
s3,
|
||||
storage,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('createBackup error:', error);
|
||||
@@ -120,12 +120,11 @@ async function listBackups(req, res) {
|
||||
return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
const prefix = `${BACKUP_PREFIX}/${String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_')}/`;
|
||||
const objects = await listS3Objects(prefix, { maxKeys: 200 });
|
||||
const safeId = String(serverId).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const objects = await listBackupObjects(safeId, 200);
|
||||
|
||||
const items = objects
|
||||
.filter(o => o.key && o.key.startsWith(prefix))
|
||||
.map(o => ({
|
||||
.map((o) => ({
|
||||
key: o.key,
|
||||
serverId,
|
||||
createdAt: o.lastModified,
|
||||
@@ -156,7 +155,7 @@ async function getBackup(req, res) {
|
||||
return sendError(res, 400, 'Invalid backup key', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
const data = await readS3TextObject(key);
|
||||
const data = await readBackupFile(key);
|
||||
const config = decryptBackupContent(data.body || '');
|
||||
|
||||
return res.json({
|
||||
@@ -191,8 +190,8 @@ async function diffBackups(req, res) {
|
||||
}
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
readS3TextObject(keyA),
|
||||
readS3TextObject(keyB),
|
||||
readBackupFile(keyA),
|
||||
readBackupFile(keyB),
|
||||
]);
|
||||
|
||||
const bodyA = decryptBackupContent(a.body || '');
|
||||
@@ -265,7 +264,7 @@ async function getMikrotikClientForServerId(serverId) {
|
||||
* POST /api/mikrotik/backups/run
|
||||
* Body: { serverId: string }
|
||||
*
|
||||
* Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат в S3.
|
||||
* Немедленно делает /rest/export compact на указанном MikroTik и сохраняет результат на диск.
|
||||
*/
|
||||
async function runBackupNow(req, res) {
|
||||
try {
|
||||
@@ -293,7 +292,7 @@ async function runBackupNow(req, res) {
|
||||
return sendError(res, 502, 'Empty export result from MikroTik', 'E_EMPTY_EXPORT');
|
||||
}
|
||||
|
||||
const { key, createdAt, s3 } = await saveBackupForServer(serverId, config, {
|
||||
const { key, createdAt, storage } = await saveBackupForServer(serverId, config, {
|
||||
source: 'manual',
|
||||
comment: 'Manual backup from UI (/api/mikrotik/backups/run)',
|
||||
});
|
||||
@@ -303,7 +302,7 @@ async function runBackupNow(req, res) {
|
||||
key,
|
||||
serverId,
|
||||
createdAt,
|
||||
s3,
|
||||
storage,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('runBackupNow error:', error?.message, error?.stack, error?.response?.data);
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { s3, BUCKET_NAME, streamToString, readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
|
||||
const { readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
|
||||
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||
const { decrypt } = require('../utils/encryption');
|
||||
const { readServersFromS3 } = require('./serversRoutes');
|
||||
@@ -54,12 +53,11 @@ function speedTestCacheKey(serverId, interfaceName) {
|
||||
/** Загрузить network-config из S3 */
|
||||
async function loadNetworkConfig() {
|
||||
try {
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: NETWORK_CONFIG_KEY }));
|
||||
const body = await streamToString(data.Body);
|
||||
const parsed = JSON.parse(body || '{}');
|
||||
const data = await readS3TextObject(NETWORK_CONFIG_KEY);
|
||||
const parsed = JSON.parse(data.body || '{}');
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch (e) {
|
||||
if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return {};
|
||||
if (e?.code === 'NoSuchKey') return {};
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
+136
-163
@@ -2,10 +2,18 @@
|
||||
* Разные утилитарные роуты (auto-urls, history, s3 meta, availability, etc.)
|
||||
*/
|
||||
|
||||
const { s3, BUCKET_NAME, writeS3TextObject, writeS3JsonObject, readS3TextObject, headMeta, streamToString } = require('../services/s3Service');
|
||||
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 { GetObjectCommand, PutObjectCommand, ListObjectVersionsCommand, CopyObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const validators = require('../lib/validators');
|
||||
const net = require('net');
|
||||
const http = require('http');
|
||||
@@ -19,66 +27,97 @@ async function getS3LastModified(req, res) {
|
||||
{ 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: 'filters', key: null },
|
||||
{ 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;
|
||||
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 from S3:', error);
|
||||
return sendError(res, 500, 'Error fetching last modified dates from S3', 'E_S3');
|
||||
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 out = await s3.send(new ListObjectVersionsCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
Prefix: key,
|
||||
MaxKeys: 50
|
||||
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,
|
||||
}));
|
||||
|
||||
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
|
||||
|
||||
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_S3', { error: String(e?.message || e) });
|
||||
return sendError(res, 500, 'Error reading history', 'E_STORAGE', { error: String(e?.message || e) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,125 +125,76 @@ async function getHistory(req, res) {
|
||||
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 {
|
||||
await s3.send(new CopyObjectCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
CopySource: `/${BUCKET_NAME}/${encodeURIComponent(key)}?versionId=${encodeURIComponent(versionId)}`,
|
||||
Key: key
|
||||
}));
|
||||
|
||||
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_S3', { error: String(e?.message || e) });
|
||||
return sendError(res, 500, 'Error rollback', 'E_STORAGE', { 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');
|
||||
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;
|
||||
const fileContent = urls.map(u => `${u.url} ${u.community}`).join('\n');
|
||||
|
||||
if (!Array.isArray(urls)) {
|
||||
return sendError(res, 400, 'urls must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
try {
|
||||
const meta = await writeS3TextObject('bgp_data/auto_url/urls.txt', fileContent);
|
||||
return sendOk(res, meta);
|
||||
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 to S3', 'E_S3');
|
||||
return sendError(res, 500, 'Error writing auto URLs', 'E_EVOBGP');
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (!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');
|
||||
}
|
||||
|
||||
// Загрузка текущих 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;
|
||||
}
|
||||
const currentIps = await evobgpClient.fetchIpRangesData();
|
||||
const currentDomains = await evobgpClient.fetchDomainsData();
|
||||
|
||||
// Обработка URL
|
||||
const newIps = [];
|
||||
@@ -256,22 +246,8 @@ async function processAutoUrls(req, res) {
|
||||
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'
|
||||
}));
|
||||
await evobgpClient.saveIpRangesData(allIps, currentIps);
|
||||
await evobgpClient.saveDomainsData(allDomains, currentDomains);
|
||||
|
||||
const msg = `Обработано ${urls.length} URL, добавлено ${uniqueNewIps.length} IP и ${uniqueNewDomains.length} доменов`;
|
||||
return res.json({
|
||||
@@ -285,7 +261,7 @@ async function processAutoUrls(req, res) {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error processing auto URLs:', error);
|
||||
return sendError(res, 500, 'Error processing auto URLs', 'E_S3');
|
||||
return sendError(res, 500, 'Error processing auto URLs', 'E_EVOBGP');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,9 +333,8 @@ function getPingServicesListFromSettings(uiSettings) {
|
||||
/** Загрузить 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 || '{}');
|
||||
const data = await readS3TextObject('bgp_data/rt_ui_settings.json');
|
||||
const parsed = JSON.parse(data.body || '{}');
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
@@ -598,10 +573,10 @@ async function getServersAvailability(req, res) {
|
||||
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));
|
||||
const data = await readS3TextObject('servers.json');
|
||||
servers = JSON.parse(data.body);
|
||||
if (!Array.isArray(servers)) servers = [];
|
||||
} catch {
|
||||
servers = [];
|
||||
@@ -683,12 +658,13 @@ async function updateBgpBackground(req, res) {
|
||||
// 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(() => ({}));
|
||||
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 });
|
||||
@@ -700,25 +676,22 @@ async function getWsUrl(req, res) {
|
||||
// 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;
|
||||
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 (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 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);
|
||||
@@ -728,11 +701,11 @@ async function getUiSettings(req, res) {
|
||||
}
|
||||
return res.json(settings);
|
||||
} catch (error) {
|
||||
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
||||
if (error?.code === 'NoSuchKey') {
|
||||
return res.json({});
|
||||
}
|
||||
console.error('Error reading ui settings from S3:', error);
|
||||
return sendError(res, 500, 'Error reading UI settings from S3', 'E_S3');
|
||||
console.error('Error reading ui settings:', error);
|
||||
return sendError(res, 500, 'Error reading UI settings', 'E_STORAGE');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,8 +729,8 @@ async function postUiSettings(req, res) {
|
||||
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) });
|
||||
console.error('Error writing UI settings:', error);
|
||||
return sendError(res, 500, 'Error writing UI settings', 'E_STORAGE', { error: String(error?.message || error) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
const { sendError } = require('../middleware/errorHandler');
|
||||
const { readS3TextObject, listS3Objects } = require('../services/s3Service');
|
||||
const evobgpClient = require('../services/evobgpClient');
|
||||
const { readServersFromS3 } = require('./serversRoutes');
|
||||
|
||||
const NETWORK_MAP_CACHE_KEY = 'network-map-cache/latest.json';
|
||||
@@ -173,9 +174,8 @@ function findPinnedCommunityGateway(pinnedCommunityGateways, jumphost, community
|
||||
|
||||
async function loadCommunitiesIndex() {
|
||||
try {
|
||||
const raw = await readS3TextObject('bgp_data/communities.json').catch(() => null);
|
||||
if (!raw?.body) return new Map();
|
||||
const arr = JSON.parse(raw.body || '[]');
|
||||
if (!evobgpClient.isConfigured()) return new Map();
|
||||
const arr = await evobgpClient.listCommunities();
|
||||
const m = new Map();
|
||||
if (!Array.isArray(arr)) return m;
|
||||
for (const c of arr) {
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
* Это нужно для EasySwitch, который работает только с jumphost серверами
|
||||
*/
|
||||
|
||||
const { s3, BUCKET_NAME, writeS3TextObject, deleteS3Object, headMeta, writeS3JsonObject } = require('../services/s3Service');
|
||||
const { writeS3TextObject, deleteS3Object, headMeta, writeS3JsonObject, readS3TextObject } = require('../services/s3Service');
|
||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { streamToString } = require('../services/s3Service');
|
||||
const { getS3TextWithHeaders, getS3JsonWithHeaders } = require('../utils/s3Helpers');
|
||||
const { isValidServerId } = require('../lib/validators');
|
||||
|
||||
@@ -41,15 +39,20 @@ function validateJumphostServer(server, i) {
|
||||
// GET /api/server-configs (список только jumphost серверов)
|
||||
async function getServerConfigsList(req, res) {
|
||||
try {
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
|
||||
const etag = head?.ETag || null;
|
||||
const head = await headMeta(S3_KEY);
|
||||
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 (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: S3_KEY }));
|
||||
const fileContent = await streamToString(data.Body);
|
||||
let fileContent = '[]';
|
||||
try {
|
||||
const data = await readS3TextObject(S3_KEY);
|
||||
fileContent = data.body;
|
||||
} catch (e) {
|
||||
if (e?.code !== 'NoSuchKey') throw e;
|
||||
}
|
||||
let allServers = [];
|
||||
|
||||
try {
|
||||
@@ -113,12 +116,10 @@ async function postServerConfigsList(req, res) {
|
||||
// Читаем текущие данные
|
||||
let allServers = [];
|
||||
try {
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY }));
|
||||
const fileContent = await streamToString(data.Body);
|
||||
allServers = JSON.parse(fileContent);
|
||||
const data = await readS3TextObject(S3_KEY);
|
||||
allServers = JSON.parse(data.body);
|
||||
if (!Array.isArray(allServers)) allServers = [];
|
||||
} catch (e) {
|
||||
// Файл не существует или пустой - это нормально
|
||||
allServers = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
* Для jumphost и home (входной роутер): mikrotikHost, mikrotikPort, mikrotikUser, encryptedMikrotikPassword
|
||||
*/
|
||||
|
||||
const { GetObjectCommand, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { s3, BUCKET_NAME, streamToString } = require('../services/s3Service');
|
||||
const { readS3TextObject, writeS3JsonObject, headMeta } = require('../services/s3Service');
|
||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||
const { encrypt, decrypt } = require('../utils/encryption');
|
||||
|
||||
@@ -28,7 +27,7 @@ function validateServer(server, i) {
|
||||
if (!Array.isArray(server.gateways) || server.gateways.length === 0) {
|
||||
return `Server at index ${i} must have gateways for type ${normalizedType}`;
|
||||
}
|
||||
const primaries = server.gateways.filter(g => g && g.primary);
|
||||
const primaries = server.gateways.filter((g) => g && g.primary);
|
||||
if (primaries.length !== 1) {
|
||||
return `Server at index ${i} must have exactly one primary gateway`;
|
||||
}
|
||||
@@ -45,24 +44,17 @@ function validateServer(server, i) {
|
||||
|
||||
async function readServersFromS3() {
|
||||
try {
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY }));
|
||||
const body = await streamToString(data.Body);
|
||||
const parsed = JSON.parse(body || '[]');
|
||||
const data = await readS3TextObject(S3_KEY);
|
||||
const parsed = JSON.parse(data.body || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (e) {
|
||||
if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return [];
|
||||
if (e?.code === 'NoSuchKey') return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeServersToS3(servers) {
|
||||
const body = JSON.stringify(servers, null, 2);
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: S3_KEY,
|
||||
Body: body,
|
||||
ContentType: 'application/json',
|
||||
}));
|
||||
await writeS3JsonObject(S3_KEY, servers);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,15 +62,15 @@ async function writeServersToS3(servers) {
|
||||
*/
|
||||
async function getServers(req, res) {
|
||||
try {
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
|
||||
const etag = head?.ETag || null;
|
||||
const head = await headMeta(S3_KEY);
|
||||
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 (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 servers = await readServersFromS3();
|
||||
const sanitized = servers.map(s => {
|
||||
const sanitized = servers.map((s) => {
|
||||
const out = { ...s };
|
||||
const hasMikrotikApi = s.type === 'jumphost' || s.type === 'home';
|
||||
if (hasMikrotikApi && out.encryptedMikrotikPassword) {
|
||||
@@ -91,11 +83,11 @@ async function getServers(req, res) {
|
||||
});
|
||||
res.json(sanitized);
|
||||
} catch (error) {
|
||||
if (error?.name === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
||||
if (error?.code === 'NoSuchKey') {
|
||||
return res.json([]);
|
||||
}
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading servers', 'E_S3');
|
||||
return sendError(res, 500, 'Error reading servers', 'E_STORAGE');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,9 +107,10 @@ async function postServers(req, res) {
|
||||
|
||||
try {
|
||||
const currentServers = await readServersFromS3();
|
||||
const findCurrent = (srv) => currentServers.find(c =>
|
||||
(srv.id && c.id === srv.id) || (srv.dns && c.dns === srv.dns) || (srv.ip && c.ip === srv.ip)
|
||||
);
|
||||
const findCurrent = (srv) =>
|
||||
currentServers.find(
|
||||
(c) => (srv.id && c.id === srv.id) || (srv.dns && c.dns === srv.dns) || (srv.ip && c.ip === srv.ip),
|
||||
);
|
||||
|
||||
const toSave = items.map((srv) => {
|
||||
const current = findCurrent(srv);
|
||||
@@ -140,15 +133,15 @@ async function postServers(req, res) {
|
||||
});
|
||||
|
||||
await writeServersToS3(toSave);
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
|
||||
const meta = await headMeta(S3_KEY);
|
||||
return sendOk(res, {
|
||||
etag: head?.ETag || null,
|
||||
lastModified: head?.LastModified ? head.LastModified.toISOString() : null,
|
||||
contentLength: typeof head?.ContentLength === 'number' ? head.ContentLength : null,
|
||||
etag: meta?.etag || null,
|
||||
lastModified: meta?.lastModified || null,
|
||||
contentLength: typeof meta?.contentLength === 'number' ? meta.contentLength : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return sendError(res, 500, error.message || 'Error saving servers', 'E_S3');
|
||||
return sendError(res, 500, error.message || 'Error saving servers', 'E_STORAGE');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
/**
|
||||
* Общие роуты для текстовых данных (domains, asns, ip-ranges)
|
||||
* Уменьшают дублирование кода для похожих эндпоинтов
|
||||
*/
|
||||
|
||||
const { streamPaginatedText, headS3ObjectEtag, writeS3TextObject } = require('../services/s3Service');
|
||||
const { streamPaginatedText, headS3ObjectEtag, writeS3TextObject, headMeta, readS3TextObject } = require('../services/s3Service');
|
||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||
const { splitWhitespace } = require('../utils/helpers');
|
||||
const { s3, BUCKET_NAME } = require('../services/s3Service');
|
||||
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { streamToString } = require('../services/s3Service');
|
||||
|
||||
// Кэш для countOnly запросов
|
||||
const countOnlyCache = { map: new Map(), ttlMs: 10_000 };
|
||||
@@ -27,17 +23,10 @@ function setCountOnlyCache(cacheKey, value) {
|
||||
countOnlyCache.map.set(cacheKey, { value, at: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать GET эндпоинт для текстовых данных
|
||||
* @param {string} s3Key - Ключ S3 файла
|
||||
* @param {function} mapLine - Функция маппинга строки в объект
|
||||
* @param {function} validate - Функция валидации данных
|
||||
* @param {string} cachePrefix - Префикс для кэша
|
||||
*/
|
||||
function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
|
||||
return async (req, res) => {
|
||||
const { q = '', offset, limit, countOnly, format } = req.query || {};
|
||||
|
||||
|
||||
try {
|
||||
if (countOnly === 'true') {
|
||||
const cacheKey = `${cachePrefix}:count:${q}`;
|
||||
@@ -54,24 +43,32 @@ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
|
||||
q,
|
||||
offset: Number(offset) || 0,
|
||||
limit: Number(limit) || 0,
|
||||
mapLine
|
||||
mapLine,
|
||||
});
|
||||
if (!validate(items)) {
|
||||
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
|
||||
}
|
||||
return res.json(format === 'std' ? { items, total, meta: {} } : { items, total });
|
||||
} else {
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
|
||||
const etag = head?.ETag || null;
|
||||
const head = await headMeta(s3Key);
|
||||
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 (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: s3Key }));
|
||||
const fileContent = await streamToString(data.Body);
|
||||
const items = fileContent.split('\n').filter(line => line).map(line => mapLine(line.trim()));
|
||||
|
||||
let fileContent;
|
||||
try {
|
||||
const data = await readS3TextObject(s3Key);
|
||||
fileContent = data.body;
|
||||
} catch (e) {
|
||||
if (e?.code === 'NoSuchKey') {
|
||||
return res.json([]);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const items = fileContent.split('\n').filter((line) => line).map((line) => mapLine(line.trim()));
|
||||
|
||||
if (!validate(items)) {
|
||||
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
|
||||
}
|
||||
@@ -85,24 +82,16 @@ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
|
||||
res.json([]);
|
||||
} else {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading from S3', 'E_S3', { error: String(error?.message || error) });
|
||||
return sendError(res, 500, 'Error reading storage', 'E_STORAGE', { error: String(error?.message || error) });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать POST эндпоинт для текстовых данных
|
||||
* @param {string} s3Key - Ключ S3 файла
|
||||
* @param {function} formatLine - Функция форматирования объекта в строку
|
||||
* @param {function} validate - Функция валидации данных
|
||||
* @param {function} validateItem - Функция валидации отдельного элемента (опционально)
|
||||
*/
|
||||
function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
|
||||
return async (req, res) => {
|
||||
const { domains, ipRanges, items, etag } = req.body; // 'domains' основной ключ, но поддерживаем и альтернативные
|
||||
|
||||
// Поддержка обратной совместимости: принимаем domains | ipRanges | items
|
||||
const { domains, ipRanges, items, etag } = req.body;
|
||||
|
||||
const payload = Array.isArray(domains)
|
||||
? domains
|
||||
: Array.isArray(ipRanges)
|
||||
@@ -114,13 +103,11 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
|
||||
if (!Array.isArray(payload)) {
|
||||
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
// Валидация с помощью AJV
|
||||
|
||||
if (!validate(payload || [])) {
|
||||
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
|
||||
}
|
||||
|
||||
// Дополнительная валидация элементов, если предоставлена
|
||||
|
||||
if (validateItem) {
|
||||
const validationErrors = [];
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
@@ -129,62 +116,41 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
|
||||
validationErrors.push(...errors);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
|
||||
errors: validationErrors.slice(0, 10)
|
||||
errors: validationErrors.slice(0, 10),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const fileContent = (payload || []).map(formatLine).filter(Boolean).join('\n');
|
||||
|
||||
try {
|
||||
// Проверка ETag для optimistic concurrency
|
||||
let current = null;
|
||||
const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null;
|
||||
const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g, '"') : null;
|
||||
try {
|
||||
current = await headS3ObjectEtag(s3Key);
|
||||
} catch {}
|
||||
|
||||
} catch (_) {}
|
||||
|
||||
if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) {
|
||||
const { headMeta } = require('../services/s3Service');
|
||||
const meta = await headMeta(s3Key);
|
||||
return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', {
|
||||
currentEtag: current,
|
||||
meta
|
||||
});
|
||||
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const meta = await writeS3TextObject(s3Key, fileContent);
|
||||
return sendOk(res, meta);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error writing to S3', 'E_S3', {
|
||||
error: String(error?.message || error)
|
||||
});
|
||||
return sendError(res, 500, 'Error writing storage', 'E_STORAGE');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать роуты для текстовых данных (GET + POST)
|
||||
*/
|
||||
function createTextDataRoutes(config) {
|
||||
const {
|
||||
s3Key,
|
||||
mapLine,
|
||||
formatLine,
|
||||
validate,
|
||||
validateItem,
|
||||
cachePrefix
|
||||
} = config;
|
||||
|
||||
const { s3Key, mapLine, formatLine, validate, validateItem, cachePrefix } = config;
|
||||
return {
|
||||
get: createTextDataGET(s3Key, mapLine, validate, cachePrefix),
|
||||
post: createTextDataPOST(s3Key, formatLine, validate, validateItem)
|
||||
post: createTextDataPOST(s3Key, formatLine, validate, validateItem),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,4 +159,3 @@ module.exports = {
|
||||
createTextDataGET,
|
||||
createTextDataPOST,
|
||||
};
|
||||
|
||||
|
||||
+100
-95
@@ -15,9 +15,16 @@ const crypto = require('crypto');
|
||||
// Импорт модулей
|
||||
const { sendError, sendOk, errorHandler } = require('./middleware/errorHandler');
|
||||
const { getLockStatus, acquireLock, releaseLock } = require('./middleware/lockManager');
|
||||
const { createTextDataRoutes } = require('./routes/textDataRoutes');
|
||||
const { createJsonDataRoutes } = require('./routes/jsonDataRoutes');
|
||||
const { splitWhitespace } = require('./utils/helpers');
|
||||
const { createFilterRulesRoutes } = require('./routes/filterRulesRoutes');
|
||||
const evobgpClient = require('./services/evobgpClient');
|
||||
const {
|
||||
createEvobgpListGET,
|
||||
createEvobgpDomainsNewPOST,
|
||||
createEvobgpAsnsPOST,
|
||||
createEvobgpIpRangesPOST,
|
||||
createEvobgpLegacyDomainsGET,
|
||||
} = require('./routes/evobgpReferenceRoutes');
|
||||
const validators = require('./lib/validators');
|
||||
const mikrotikValidator = require('./lib/mikrotik-validator');
|
||||
|
||||
@@ -231,99 +238,100 @@ const validateDomainsNew = ajv.compile(schemaDomainsNew);
|
||||
const validateAsns = ajv.compile(schemaAsns);
|
||||
const validateIpRanges = ajv.compile(schemaIpRanges);
|
||||
|
||||
// === Domains (старая версия) ===
|
||||
const domainsOldRoutes = createTextDataRoutes({
|
||||
s3Key: 'bgp_data/domains.txt',
|
||||
mapLine: (line) => {
|
||||
const parts = splitWhitespace(line);
|
||||
return { domain: parts[0] || '', type: parts[1] || '' };
|
||||
},
|
||||
formatLine: (d) => `${String(d.domain || '').trim()} ${String(d.type || '').trim()}`.trim(),
|
||||
validate: validateAsns,
|
||||
cachePrefix: 'domains'
|
||||
});
|
||||
// === Domains (старая версия, поля domain+type — тот же источник, что ASNs) ===
|
||||
const asnValidateItem = (asn, i) => {
|
||||
const errors = [];
|
||||
if (!validators.isValidASN(asn.domain)) {
|
||||
errors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`);
|
||||
}
|
||||
if (!validators.isValidCommunity(asn.type)) {
|
||||
errors.push(`Элемент ${i}: неверный community "${asn.type}"`);
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
const domainsOldRoutes = {
|
||||
get: createEvobgpLegacyDomainsGET({ validate: validateAsns }),
|
||||
post: createEvobgpAsnsPOST({ validate: validateAsns, validateItem: asnValidateItem }),
|
||||
};
|
||||
|
||||
app.get('/api/domains', domainsOldRoutes.get);
|
||||
app.post('/api/domains', domainsOldRoutes.post);
|
||||
app.post('/api/domains', writeLimiter, domainsOldRoutes.post);
|
||||
|
||||
// === ASNs ===
|
||||
const asnsRoutes = createTextDataRoutes({
|
||||
s3Key: 'bgp_data/asns.txt',
|
||||
mapLine: (line) => {
|
||||
const parts = splitWhitespace(line);
|
||||
return { domain: parts[0] || '', type: parts[1] || '' };
|
||||
},
|
||||
formatLine: (a) => `${String(a.domain || '').trim().toUpperCase()} ${String(a.type || '').trim()}`.trim(),
|
||||
validate: validateAsns,
|
||||
validateItem: (asn, i) => {
|
||||
const errors = [];
|
||||
if (!validators.isValidASN(asn.domain)) {
|
||||
errors.push(`Элемент ${i}: неверный ASN "${asn.domain}"`);
|
||||
}
|
||||
if (!validators.isValidCommunity(asn.type)) {
|
||||
errors.push(`Элемент ${i}: неверный community "${asn.type}"`);
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
cachePrefix: 'asns'
|
||||
});
|
||||
// === ASNs (EvoBGP) ===
|
||||
const asnsRoutes = {
|
||||
get: createEvobgpListGET({
|
||||
cachePrefix: 'asns',
|
||||
validate: validateAsns,
|
||||
fetchItems: async () => {
|
||||
const rows = await evobgpClient.fetchAsnsData();
|
||||
return rows.map((r) => ({ domain: r.asn, type: r.community }));
|
||||
},
|
||||
}),
|
||||
post: createEvobgpAsnsPOST({ validate: validateAsns, validateItem: asnValidateItem }),
|
||||
};
|
||||
|
||||
app.get('/api/asns', asnsRoutes.get);
|
||||
app.post('/api/asns', writeLimiter, asnsRoutes.post);
|
||||
|
||||
// === Domains New ===
|
||||
const domainsNewRoutes = createTextDataRoutes({
|
||||
s3Key: 'bgp_data/domains_community.txt',
|
||||
mapLine: (line) => {
|
||||
const parts = splitWhitespace(line);
|
||||
return { domain: parts[0] || '', community: parts[1] || '' };
|
||||
},
|
||||
formatLine: (d) => `${String(d.domain || '').trim().toLowerCase()} ${String(d.community || '').trim()}`.trim(),
|
||||
validate: validateDomainsNew,
|
||||
validateItem: (d, i) => {
|
||||
const errors = [];
|
||||
if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) {
|
||||
errors.push(`Элемент ${i}: неверный домен "${d.domain}"`);
|
||||
}
|
||||
if (!validators.isValidCommunity(d.community)) {
|
||||
errors.push(`Элемент ${i}: неверный community "${d.community}"`);
|
||||
}
|
||||
if (!validators.isSafeXSSString(d.domain)) {
|
||||
errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`);
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
cachePrefix: 'domains-new'
|
||||
});
|
||||
// === Domains New (EvoBGP) ===
|
||||
const domainsNewRoutes = {
|
||||
get: createEvobgpListGET({
|
||||
cachePrefix: 'domains-new',
|
||||
validate: validateDomainsNew,
|
||||
fetchItems: async () => {
|
||||
const rows = await evobgpClient.fetchDomainsData();
|
||||
return rows.map(({ domain, community }) => ({ domain, community }));
|
||||
},
|
||||
}),
|
||||
post: createEvobgpDomainsNewPOST({
|
||||
validate: validateDomainsNew,
|
||||
validateItem: (d, i) => {
|
||||
const errors = [];
|
||||
if (!validators.isValidDomain(d.domain) && !validators.isValidWildcardDomain(d.domain)) {
|
||||
errors.push(`Элемент ${i}: неверный домен "${d.domain}"`);
|
||||
}
|
||||
if (!validators.isValidCommunity(d.community)) {
|
||||
errors.push(`Элемент ${i}: неверный community "${d.community}"`);
|
||||
}
|
||||
if (!validators.isSafeXSSString(d.domain)) {
|
||||
errors.push(`Элемент ${i}: домен содержит потенциально опасные символы`);
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
app.get('/api/domains-new', domainsNewRoutes.get);
|
||||
app.post('/api/domains-new', writeLimiter, domainsNewRoutes.post);
|
||||
|
||||
// === IP Ranges ===
|
||||
const ipRangesRoutes = createTextDataRoutes({
|
||||
s3Key: 'bgp_data/ips.txt',
|
||||
mapLine: (line) => {
|
||||
const parts = splitWhitespace(line);
|
||||
return { ipRange: parts[0] || '', community: parts[1] || '' };
|
||||
},
|
||||
formatLine: (ip) => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim(),
|
||||
validate: validateIpRanges,
|
||||
validateItem: (ip, i) => {
|
||||
const errors = [];
|
||||
const ipStr = String(ip.ipRange || '').trim();
|
||||
const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr);
|
||||
const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr);
|
||||
|
||||
if (!isValidCIDR && !isValidIP) {
|
||||
errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`);
|
||||
}
|
||||
if (!validators.isValidCommunity(ip.community)) {
|
||||
errors.push(`Элемент ${i}: неверный community "${ip.community}"`);
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
cachePrefix: 'ip-ranges'
|
||||
});
|
||||
// === IP Ranges (EvoBGP) ===
|
||||
const ipRangesRoutes = {
|
||||
get: createEvobgpListGET({
|
||||
cachePrefix: 'ip-ranges',
|
||||
validate: validateIpRanges,
|
||||
fetchItems: async () => {
|
||||
const rows = await evobgpClient.fetchIpRangesData();
|
||||
return rows.map(({ ipRange, community }) => ({ ipRange, community }));
|
||||
},
|
||||
}),
|
||||
post: createEvobgpIpRangesPOST({
|
||||
validate: validateIpRanges,
|
||||
validateItem: (ip, i) => {
|
||||
const errors = [];
|
||||
const ipStr = String(ip.ipRange || '').trim();
|
||||
const isValidCIDR = validators.isValidCIDRv4(ipStr) || validators.isValidCIDRv6(ipStr);
|
||||
const isValidIP = validators.isValidIPv4(ipStr) || validators.isValidIPv6(ipStr);
|
||||
if (!isValidCIDR && !isValidIP) {
|
||||
errors.push(`Элемент ${i}: неверный IP/CIDR "${ipStr}"`);
|
||||
}
|
||||
if (!validators.isValidCommunity(ip.community)) {
|
||||
errors.push(`Элемент ${i}: неверный community "${ip.community}"`);
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
app.get('/api/ip-ranges', ipRangesRoutes.get);
|
||||
app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post);
|
||||
@@ -360,24 +368,21 @@ const billingRoutes = createJsonDataRoutes('servers-billing.json', (item, i) =>
|
||||
app.get('/api/billing', billingRoutes.get);
|
||||
app.post('/api/billing', billingRoutes.post);
|
||||
|
||||
// Filters
|
||||
const filtersRoutes2 = createJsonDataRoutes('filters.json', (filter, i) => {
|
||||
// Filters (SQL app_filter_rules, scope global)
|
||||
const filterItemValidator = (filter, i) => {
|
||||
if (!filter.community || !filter.gateway) {
|
||||
return `Filter at index ${i} is missing required fields`;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
const filtersRoutes2 = createFilterRulesRoutes('global', filterItemValidator);
|
||||
|
||||
app.get('/api/filters', filtersRoutes2.get);
|
||||
app.post('/api/filters', filtersRoutes2.post);
|
||||
|
||||
// Simple Filters
|
||||
const simpleFiltersRoutes = createJsonDataRoutes('filter-manager/simple-filters.json', (filter, i) => {
|
||||
if (!filter.community || !filter.gateway) {
|
||||
return `Filter at index ${i} is missing required fields`;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
// Simple Filters (app_filter_rules, scope simple)
|
||||
const simpleFiltersRoutes = createFilterRulesRoutes('simple', filterItemValidator);
|
||||
|
||||
app.get('/api/simple-filters', simpleFiltersRoutes.get);
|
||||
app.post('/api/simple-filters', simpleFiltersRoutes.post);
|
||||
@@ -508,7 +513,7 @@ app.get('/api/resources/stats', resourceStatsRoutes.getResourceStats);
|
||||
// === ALERTS (агрегация проблем: серверы, ресурсы роутеров) ===
|
||||
app.get('/api/alerts', alertsRoutes.getAlerts);
|
||||
|
||||
// === MIKROTIK BACKUPS (S3) ===
|
||||
// === MIKROTIK BACKUPS (локальная ФС) ===
|
||||
app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
|
||||
app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups);
|
||||
app.get('/api/mikrotik/backups/item', mikrotikBackupRoutes.getBackup);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Бэкапы MikroTik на локальной ФС (вместо S3).
|
||||
* Логические ключи сохраняют вид backups/mikrotik/{serverId}/{ts}-{rand}.rsc
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const BACKUP_PREFIX = 'backups/mikrotik';
|
||||
|
||||
function backupRootDir() {
|
||||
const raw = process.env.MIKROTIK_BACKUP_DIR || path.join(__dirname, '..', 'data', 'backups', 'mikrotik');
|
||||
if (!fs.existsSync(raw)) {
|
||||
fs.mkdirSync(raw, { recursive: true });
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function parseBackupKey(key) {
|
||||
const m = String(key || '').match(/^backups\/mikrotik\/([^/]+)\/(.+)$/);
|
||||
if (!m) return null;
|
||||
return { safeId: m[1], fileName: m[2] };
|
||||
}
|
||||
|
||||
function fsPathForKey(key) {
|
||||
const p = parseBackupKey(key);
|
||||
if (!p) return null;
|
||||
return path.join(backupRootDir(), p.safeId, p.fileName);
|
||||
}
|
||||
|
||||
function buildBackupKey(serverId, createdAt, suffix) {
|
||||
const safeId = String(serverId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const ts = createdAt.toISOString().replace(/[:-]/g, '').replace(/\.\d+Z$/, 'Z');
|
||||
const rand = suffix || crypto.randomBytes(3).toString('hex');
|
||||
return `${BACKUP_PREFIX}/${safeId}/${ts}-${rand}.rsc`;
|
||||
}
|
||||
|
||||
function etagForContent(content) {
|
||||
const h = crypto.createHash('sha256').update(Buffer.from(String(content), 'utf8')).digest('hex');
|
||||
return `"${h}"`;
|
||||
}
|
||||
|
||||
async function writeBackupFile(key, content, contentType = 'text/plain') {
|
||||
const p = parseBackupKey(key);
|
||||
if (!p) throw new Error('Invalid backup key');
|
||||
const dir = path.join(backupRootDir(), p.safeId);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const fp = path.join(dir, p.fileName);
|
||||
await fs.promises.writeFile(fp, String(content), 'utf8');
|
||||
const st = await fs.promises.stat(fp);
|
||||
const etag = etagForContent(String(content));
|
||||
return {
|
||||
etag,
|
||||
lastModified: st.mtime.toISOString(),
|
||||
contentLength: st.size,
|
||||
};
|
||||
}
|
||||
|
||||
async function readBackupFile(key) {
|
||||
const fp = fsPathForKey(key);
|
||||
if (!fp) throw new Error('Invalid backup key');
|
||||
const body = await fs.promises.readFile(fp, 'utf8');
|
||||
const st = await fs.promises.stat(fp);
|
||||
const etag = etagForContent(body);
|
||||
return {
|
||||
body,
|
||||
etag,
|
||||
lastModified: st.mtime.toISOString(),
|
||||
contentLength: st.size,
|
||||
};
|
||||
}
|
||||
|
||||
async function listBackupObjects(serverId, maxKeys = 200) {
|
||||
const safeId = String(serverId || '').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const dir = path.join(backupRootDir(), safeId);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const names = await fs.promises.readdir(dir);
|
||||
const out = [];
|
||||
for (const name of names) {
|
||||
if (out.length >= maxKeys) break;
|
||||
const fp = path.join(dir, name);
|
||||
const st = await fs.promises.stat(fp);
|
||||
if (!st.isFile()) continue;
|
||||
const key = `${BACKUP_PREFIX}/${safeId}/${name}`;
|
||||
out.push({
|
||||
key,
|
||||
size: st.size,
|
||||
lastModified: st.mtime.toISOString(),
|
||||
etag: etagForContent(await fs.promises.readFile(fp, 'utf8')),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BACKUP_PREFIX,
|
||||
buildBackupKey,
|
||||
writeBackupFile,
|
||||
readBackupFile,
|
||||
listBackupObjects,
|
||||
backupRootDir,
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* SQLite blob store (logical S3 keys → rows in `blobs`).
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { openDatabase } = require('../db/sqliteDb');
|
||||
|
||||
function sha256Etag(buf) {
|
||||
const h = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
return `"${h}"`;
|
||||
}
|
||||
|
||||
function namespaceFromKey(objectKey) {
|
||||
const k = String(objectKey || '');
|
||||
if (k.startsWith('ping-cache/') || k.startsWith('speed-test-cache/') || k.startsWith('network-map-cache/') ||
|
||||
k.startsWith('uptime-monitor-cache/') || k.startsWith('ping-services/')) {
|
||||
return 'cache';
|
||||
}
|
||||
if (k.startsWith('filter-manager/config-') || k.startsWith('filter-manager/server-filters-')) {
|
||||
return 'per_server';
|
||||
}
|
||||
if (k === 'mikrotik-frouting-config.txt') {
|
||||
return 'derived';
|
||||
}
|
||||
return 'config';
|
||||
}
|
||||
|
||||
function isVersionedKey(db, objectKey) {
|
||||
const row = db.prepare('SELECT 1 FROM versioned_keys WHERE object_key = ?').get(objectKey);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
function trimVersions(db, objectKey, keep = 50) {
|
||||
db.prepare(
|
||||
`DELETE FROM blob_versions WHERE object_key = ? AND id NOT IN (
|
||||
SELECT id FROM blob_versions WHERE object_key = ? ORDER BY created_at DESC, id DESC LIMIT ?
|
||||
)`
|
||||
).run(objectKey, objectKey, keep);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer|string} body
|
||||
*/
|
||||
function writeBlobTx(db, objectKey, body, contentType) {
|
||||
const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf8');
|
||||
const ns = namespaceFromKey(objectKey);
|
||||
const etag = sha256Etag(buf);
|
||||
const now = Date.now();
|
||||
const size = buf.length;
|
||||
|
||||
const existing = db.prepare('SELECT body, content_type, etag, updated_at, created_at FROM blobs WHERE object_key = ?').get(objectKey);
|
||||
|
||||
const run = () => {
|
||||
if (existing && isVersionedKey(db, objectKey)) {
|
||||
const oldBuf = existing.body;
|
||||
db.prepare(
|
||||
`INSERT INTO blob_versions (object_key, body, byte_size, etag, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run(objectKey, oldBuf, oldBuf.length, existing.etag, existing.updated_at);
|
||||
trimVersions(db, objectKey);
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
`UPDATE blobs SET namespace = ?, content_type = ?, body = ?, byte_size = ?, etag = ?, updated_at = ?
|
||||
WHERE object_key = ?`
|
||||
).run(ns, contentType, buf, size, etag, now, objectKey);
|
||||
} else {
|
||||
db.prepare(
|
||||
`INSERT INTO blobs (object_key, namespace, content_type, body, byte_size, etag, updated_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(objectKey, ns, contentType, buf, size, etag, now, now);
|
||||
}
|
||||
};
|
||||
|
||||
db.transaction(run)();
|
||||
return {
|
||||
etag,
|
||||
lastModified: new Date(now).toISOString(),
|
||||
contentLength: size,
|
||||
};
|
||||
}
|
||||
|
||||
function readBlob(objectKey) {
|
||||
const db = openDatabase();
|
||||
const row = db.prepare(
|
||||
'SELECT body, etag, updated_at, byte_size FROM blobs WHERE object_key = ?'
|
||||
).get(objectKey);
|
||||
if (!row) return null;
|
||||
const bodyStr = row.body.toString('utf8');
|
||||
return {
|
||||
body: bodyStr,
|
||||
etag: row.etag,
|
||||
lastModified: new Date(row.updated_at).toISOString(),
|
||||
contentLength: row.byte_size,
|
||||
};
|
||||
}
|
||||
|
||||
function headMetaRow(objectKey) {
|
||||
const db = openDatabase();
|
||||
const row = db.prepare('SELECT etag, updated_at, byte_size FROM blobs WHERE object_key = ?').get(objectKey);
|
||||
if (!row) {
|
||||
return { etag: null, lastModified: null, contentLength: null };
|
||||
}
|
||||
return {
|
||||
etag: row.etag,
|
||||
lastModified: new Date(row.updated_at).toISOString(),
|
||||
contentLength: row.byte_size,
|
||||
};
|
||||
}
|
||||
|
||||
function headEtag(objectKey) {
|
||||
const m = headMetaRow(objectKey);
|
||||
return m.etag || undefined;
|
||||
}
|
||||
|
||||
function deleteBlob(objectKey) {
|
||||
const db = openDatabase();
|
||||
db.prepare('DELETE FROM blobs WHERE object_key = ?').run(objectKey);
|
||||
}
|
||||
|
||||
function listByPrefix(prefix, maxKeys = 100) {
|
||||
const db = openDatabase();
|
||||
const rows = db.prepare(
|
||||
`SELECT object_key AS key, byte_size AS size, updated_at, etag FROM blobs
|
||||
WHERE object_key >= ? AND object_key < ? ORDER BY object_key LIMIT ?`
|
||||
).all(prefix, prefix + '\uffff', maxKeys);
|
||||
return rows.map((r) => ({
|
||||
key: r.key,
|
||||
size: r.size,
|
||||
lastModified: new Date(r.updated_at).toISOString(),
|
||||
etag: r.etag,
|
||||
}));
|
||||
}
|
||||
|
||||
function listVersions(objectKey, maxKeys = 50) {
|
||||
const db = openDatabase();
|
||||
return db.prepare(
|
||||
`SELECT id, object_key, byte_size, etag, created_at FROM blob_versions
|
||||
WHERE object_key = ? ORDER BY created_at DESC, id DESC LIMIT ?`
|
||||
).all(objectKey, maxKeys);
|
||||
}
|
||||
|
||||
function rollbackToVersionId(objectKey, versionId) {
|
||||
const db = openDatabase();
|
||||
const vid = Number(versionId);
|
||||
if (!Number.isFinite(vid)) throw new Error('Invalid versionId');
|
||||
const ver = db.prepare('SELECT body, etag, created_at FROM blob_versions WHERE id = ? AND object_key = ?').get(vid, objectKey);
|
||||
if (!ver) throw new Error('Version not found');
|
||||
const buf = ver.body;
|
||||
const contentType = objectKey.endsWith('.json') ? 'application/json' : 'text/plain';
|
||||
writeBlobTx(db, objectKey, buf, contentType);
|
||||
return headMetaRow(objectKey);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
namespaceFromKey,
|
||||
writeBlobTx,
|
||||
readBlob,
|
||||
headMetaRow,
|
||||
headEtag,
|
||||
deleteBlob,
|
||||
listByPrefix,
|
||||
listVersions,
|
||||
rollbackToVersionId,
|
||||
sha256Etag,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Глобальные и simple-фильтры в таблице app_filter_rules (не в blobs JSON).
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { openDatabase } = require('../db/sqliteDb');
|
||||
|
||||
/** @typedef {'global'|'simple'} FilterScope */
|
||||
|
||||
function assertScope(scope) {
|
||||
if (scope !== 'global' && scope !== 'simple') throw new Error('Invalid filter scope');
|
||||
}
|
||||
|
||||
function mtimeMetaKey(scope) {
|
||||
return `filter_rules_mtime_${scope}`;
|
||||
}
|
||||
|
||||
function sha256EtagFromString(s) {
|
||||
const h = crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex');
|
||||
return `"${h}"`;
|
||||
}
|
||||
|
||||
function rowToItem(row) {
|
||||
let extra = {};
|
||||
try {
|
||||
extra = JSON.parse(row.extra_json || '{}');
|
||||
if (!extra || typeof extra !== 'object' || Array.isArray(extra)) extra = {};
|
||||
} catch {
|
||||
extra = {};
|
||||
}
|
||||
const out = {
|
||||
community: row.community,
|
||||
gateway: row.gateway,
|
||||
description: row.description || '',
|
||||
};
|
||||
for (const k of Object.keys(extra).sort()) {
|
||||
if (k === 'community' || k === 'gateway' || k === 'description') continue;
|
||||
out[k] = extra[k];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FilterScope} scope
|
||||
*/
|
||||
function listRules(scope) {
|
||||
assertScope(scope);
|
||||
const db = openDatabase();
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT community, gateway, description, extra_json FROM app_filter_rules WHERE scope = ? ORDER BY position ASC`,
|
||||
)
|
||||
.all(scope);
|
||||
return rows.map(rowToItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FilterScope} scope
|
||||
* @returns {{ etag: string, lastModified: string|null, contentLength: number }}
|
||||
*/
|
||||
function headMetaForScope(scope) {
|
||||
assertScope(scope);
|
||||
const items = listRules(scope);
|
||||
const body = JSON.stringify(items);
|
||||
const etag = sha256EtagFromString(body);
|
||||
const contentLength = Buffer.byteLength(body, 'utf8');
|
||||
const db = openDatabase();
|
||||
const row = db.prepare('SELECT value FROM app_meta WHERE key = ?').get(mtimeMetaKey(scope));
|
||||
const lastModified = row?.value ? new Date(Number(row.value)).toISOString() : null;
|
||||
return { etag, lastModified, contentLength };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FilterScope} scope
|
||||
* @param {any[]} items
|
||||
* @param {{ validateItem?: (item: any, i: number) => string|null }} [opts]
|
||||
*/
|
||||
function replaceRules(scope, items, opts = {}) {
|
||||
assertScope(scope);
|
||||
const { validateItem } = opts;
|
||||
if (!Array.isArray(items)) throw new Error('items must be an array');
|
||||
if (validateItem) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const err = validateItem(items[i], i);
|
||||
if (err) {
|
||||
const e = new Error(err);
|
||||
e.code = 'E_SCHEMA';
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
const db = openDatabase();
|
||||
const now = Date.now();
|
||||
const del = db.prepare('DELETE FROM app_filter_rules WHERE scope = ?');
|
||||
const ins = db.prepare(
|
||||
`INSERT INTO app_filter_rules (scope, position, community, gateway, description, extra_json, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
const metaUpsert = db.prepare('INSERT OR REPLACE INTO app_meta (key, value) VALUES (?, ?)');
|
||||
|
||||
const run = () => {
|
||||
del.run(scope);
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i] || {};
|
||||
const community = String(it.community || '').trim();
|
||||
const gateway = String(it.gateway || '').trim();
|
||||
const description = String(it.description || '').trim();
|
||||
const extra = { ...it };
|
||||
delete extra.community;
|
||||
delete extra.gateway;
|
||||
delete extra.description;
|
||||
ins.run(scope, i, community, gateway, description, JSON.stringify(extra), now);
|
||||
}
|
||||
metaUpsert.run(mtimeMetaKey(scope), String(now));
|
||||
};
|
||||
db.transaction(run)();
|
||||
return headMetaForScope(scope);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listRules,
|
||||
headMetaForScope,
|
||||
replaceRules,
|
||||
};
|
||||
@@ -4,7 +4,7 @@
|
||||
* Работает внутри Node-процесса backend:
|
||||
* - периодически обходит jumphost-сервера из servers.json
|
||||
* - вызывает RouterOS REST API /rest/export (compact)
|
||||
* - сохраняет конфиг в S3 через saveBackupForServer
|
||||
* - сохраняет конфиг на диск через saveBackupForServer
|
||||
*
|
||||
* Управляется через переменные окружения:
|
||||
* - MIKROTIK_BACKUP_ENABLED=true|false (по умолчанию true)
|
||||
@@ -131,7 +131,7 @@ async function runBackupOnce(logger) {
|
||||
|
||||
log.info(
|
||||
{ component: 'mikrotik-backup', serverId: id, key: result.key },
|
||||
'Automatic backup saved to S3',
|
||||
'Automatic backup saved to disk',
|
||||
);
|
||||
} catch (err) {
|
||||
log.error(
|
||||
|
||||
+120
-243
@@ -1,58 +1,35 @@
|
||||
/**
|
||||
* Сервис для работы с S3 (Yandex Object Storage)
|
||||
* Централизованные операции чтения/записи/кэширования
|
||||
* Локальное хранилище объектов (SQLite). Сохранены имена экспортов readS3TextObject / writeS3JsonObject и т.д. для совместимости с роутами.
|
||||
* Справочники EvoBGP не хранятся здесь — см. evobgpClient.js
|
||||
*/
|
||||
|
||||
const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
||||
const { NodeHttpHandler } = require('@smithy/node-http-handler');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { openDatabase } = require('../db/sqliteDb');
|
||||
const blobStorage = require('./blobStorage');
|
||||
|
||||
// S3 Client setup
|
||||
const s3 = new S3Client({
|
||||
endpoint: 'https://storage.yandexcloud.net',
|
||||
region: process.env.AWS_REGION,
|
||||
forcePathStyle: true,
|
||||
maxAttempts: 3,
|
||||
requestHandler: new NodeHttpHandler({
|
||||
httpAgent: new http.Agent({ keepAlive: true }),
|
||||
httpsAgent: new https.Agent({ keepAlive: true })
|
||||
}),
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || process.env.S3_SECRET_ACCESS_KEY,
|
||||
}
|
||||
});
|
||||
openDatabase();
|
||||
|
||||
const BUCKET_NAME = process.env.S3_BUCKET_NAME;
|
||||
const BUCKET_NAME = process.env.SQLITE_BUCKET_LABEL || 'sqlite';
|
||||
|
||||
// In-memory cache
|
||||
const cache = {
|
||||
text: new Map(),
|
||||
head: new Map(),
|
||||
max: 100,
|
||||
ttlMs: 30_000
|
||||
ttlMs: 30_000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Конвертирует stream в строку
|
||||
*/
|
||||
async function streamToString(stream) {
|
||||
if (!stream) return '';
|
||||
if (typeof stream.transformToString === 'function') {
|
||||
return await stream.transformToString();
|
||||
}
|
||||
return await new Promise((resolve, reject) => {
|
||||
let chunks = [];
|
||||
stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))));
|
||||
stream.once('error', reject);
|
||||
stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||
});
|
||||
if (!stream) return '';
|
||||
if (typeof stream.transformToString === 'function') {
|
||||
return await stream.transformToString();
|
||||
}
|
||||
return await new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
stream.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))));
|
||||
stream.once('error', reject);
|
||||
stream.once('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить значение из кэша
|
||||
*/
|
||||
function getCache(map, key) {
|
||||
const v = map.get(key);
|
||||
if (!v) return null;
|
||||
@@ -63,9 +40,6 @@ function getCache(map, key) {
|
||||
return v.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Установить значение в кэш
|
||||
*/
|
||||
function setCache(map, key, value) {
|
||||
if (map.size >= cache.max) {
|
||||
const firstKey = map.keys().next().value;
|
||||
@@ -74,213 +48,115 @@ function setCache(map, key, value) {
|
||||
map.set(key, { value, at: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Инвалидировать кэш для ключа
|
||||
*/
|
||||
function invalidateCacheForKey(key) {
|
||||
try { cache.text.delete(key); } catch {}
|
||||
try { cache.head.delete(key); } catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Прочитать текстовый объект из S3
|
||||
*/
|
||||
async function readS3TextObject(key, s3Duration = null) {
|
||||
const cached = getCache(cache.text, key);
|
||||
if (cached) return cached;
|
||||
|
||||
const s3Start = Date.now();
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
|
||||
if (s3Duration) {
|
||||
try { s3Duration.labels('getObject').observe((Date.now() - s3Start)/1000); } catch {}
|
||||
}
|
||||
|
||||
const out = {
|
||||
body: await streamToString(data.Body),
|
||||
etag: data.ETag || undefined,
|
||||
lastModified: data.LastModified ? data.LastModified.toISOString() : undefined,
|
||||
contentLength: typeof data.ContentLength === 'number' ? data.ContentLength : undefined
|
||||
};
|
||||
setCache(cache.text, key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить ETag объекта из S3
|
||||
*/
|
||||
async function headS3ObjectEtag(key, s3Duration = null) {
|
||||
const cached = getCache(cache.head, key);
|
||||
if (cached && cached.etag) return cached.etag;
|
||||
|
||||
const s3Start = Date.now();
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
|
||||
if (s3Duration) {
|
||||
try { s3Duration.labels('headObject').observe((Date.now() - s3Start)/1000); } catch {}
|
||||
}
|
||||
|
||||
setCache(cache.head, key, { etag: head.ETag || undefined });
|
||||
return head.ETag || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить метаданные объекта (etag, lastModified, contentLength)
|
||||
*/
|
||||
async function headMeta(key) {
|
||||
try {
|
||||
const h = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
|
||||
return {
|
||||
etag: h.ETag || null,
|
||||
lastModified: h.LastModified ? new Date(h.LastModified).toISOString() : null,
|
||||
contentLength: typeof h.ContentLength === 'number' ? h.ContentLength : null,
|
||||
};
|
||||
} catch (e) {
|
||||
return { etag: null, lastModified: null, contentLength: null };
|
||||
cache.text.delete(key);
|
||||
} catch (_) {}
|
||||
try {
|
||||
cache.head.delete(key);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function readS3TextObject(key) {
|
||||
const cached = getCache(cache.text, key);
|
||||
if (cached) return cached;
|
||||
const row = blobStorage.readBlob(key);
|
||||
if (!row) {
|
||||
const err = new Error('NoSuchKey');
|
||||
err.code = 'NoSuchKey';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Записать текстовый объект в S3
|
||||
*/
|
||||
async function writeS3TextObject(key, content, contentType = 'text/plain') {
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: key,
|
||||
Body: content,
|
||||
ContentType: contentType,
|
||||
}));
|
||||
invalidateCacheForKey(key);
|
||||
return await headMeta(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Записать JSON объект в S3
|
||||
*/
|
||||
async function writeS3JsonObject(key, data) {
|
||||
return await writeS3TextObject(key, JSON.stringify(data, null, 2), 'application/json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить объект из S3
|
||||
*/
|
||||
async function deleteS3Object(key) {
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
|
||||
invalidateCacheForKey(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Список объектов в S3 по префиксу
|
||||
* Используется для истории/бэкапов (количество ограничено для безопасности)
|
||||
*/
|
||||
async function listS3Objects(prefix, { maxKeys = 100 } = {}) {
|
||||
const out = [];
|
||||
let continuationToken = undefined;
|
||||
|
||||
while (out.length < maxKeys) {
|
||||
const resp = await s3.send(new ListObjectsV2Command({
|
||||
Bucket: BUCKET_NAME,
|
||||
Prefix: prefix,
|
||||
ContinuationToken: continuationToken,
|
||||
MaxKeys: Math.min(1000, maxKeys - out.length),
|
||||
}));
|
||||
|
||||
const contents = resp.Contents || [];
|
||||
for (const obj of contents) {
|
||||
out.push({
|
||||
key: obj.Key,
|
||||
size: typeof obj.Size === 'number' ? obj.Size : null,
|
||||
lastModified: obj.LastModified ? new Date(obj.LastModified).toISOString() : null,
|
||||
etag: obj.ETag || null,
|
||||
});
|
||||
if (out.length >= maxKeys) break;
|
||||
}
|
||||
|
||||
if (!resp.IsTruncated || !resp.NextContinuationToken || out.length >= maxKeys) {
|
||||
break;
|
||||
}
|
||||
continuationToken = resp.NextContinuationToken;
|
||||
}
|
||||
|
||||
const out = {
|
||||
body: row.body,
|
||||
etag: row.etag,
|
||||
lastModified: row.lastModified,
|
||||
contentLength: row.contentLength,
|
||||
};
|
||||
setCache(cache.text, key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Потоковое чтение с пагинацией больших текстовых файлов
|
||||
*/
|
||||
async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
let total = 0;
|
||||
const items = [];
|
||||
let sent = 0;
|
||||
let buffered = '';
|
||||
|
||||
const matchesQuery = (line) => {
|
||||
if (!q) return true;
|
||||
return line.toLowerCase().includes(String(q).toLowerCase());
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
|
||||
const stream = resp.Body;
|
||||
|
||||
if (!stream || typeof stream.on !== 'function') {
|
||||
const text = await streamToString(resp.Body);
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = String(lines[i] || '').trim();
|
||||
if (!line) continue;
|
||||
if (!matchesQuery(line)) continue;
|
||||
total++;
|
||||
const pos = total - 1;
|
||||
if (limit > 0) {
|
||||
if (pos >= offset && sent < limit) { items.push(mapLine(line)); sent++; }
|
||||
} else {
|
||||
items.push(mapLine(line));
|
||||
}
|
||||
}
|
||||
return resolve({ items, total });
|
||||
}
|
||||
|
||||
stream.on('data', (chunk) => {
|
||||
buffered += chunk.toString('utf-8');
|
||||
let lines = buffered.split('\n');
|
||||
buffered = lines.pop();
|
||||
for (const lnRaw of lines) {
|
||||
const line = lnRaw.trim();
|
||||
if (!line) continue;
|
||||
if (!matchesQuery(line)) continue;
|
||||
total++;
|
||||
const pos = total - 1;
|
||||
if (limit > 0) {
|
||||
if (pos >= offset && sent < limit) { items.push(mapLine(line)); sent++; }
|
||||
} else {
|
||||
items.push(mapLine(line));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
const last = (buffered || '').trim();
|
||||
if (last) {
|
||||
if (!q || last.toLowerCase().includes(String(q).toLowerCase())) {
|
||||
total++;
|
||||
if (limit > 0) {
|
||||
const pos = total - 1;
|
||||
if (pos >= offset && items.length < limit) items.push(mapLine(last));
|
||||
} else {
|
||||
items.push(mapLine(last));
|
||||
}
|
||||
}
|
||||
}
|
||||
resolve({ items, total });
|
||||
});
|
||||
|
||||
stream.on('error', reject);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
async function headS3ObjectEtag(key) {
|
||||
const cached = getCache(cache.head, key);
|
||||
if (cached && cached.etag) return cached.etag;
|
||||
const etag = blobStorage.headEtag(key);
|
||||
if (etag) setCache(cache.head, key, { etag });
|
||||
return etag;
|
||||
}
|
||||
|
||||
async function headMeta(key) {
|
||||
return blobStorage.headMetaRow(key);
|
||||
}
|
||||
|
||||
async function writeS3TextObject(key, content, contentType = 'text/plain') {
|
||||
const db = openDatabase();
|
||||
blobStorage.writeBlobTx(db, key, content, contentType);
|
||||
invalidateCacheForKey(key);
|
||||
return headMeta(key);
|
||||
}
|
||||
|
||||
async function writeS3JsonObject(key, data) {
|
||||
return writeS3TextObject(key, JSON.stringify(data, null, 2), 'application/json');
|
||||
}
|
||||
|
||||
async function deleteS3Object(key) {
|
||||
blobStorage.deleteBlob(key);
|
||||
invalidateCacheForKey(key);
|
||||
}
|
||||
|
||||
async function listS3Objects(prefix, { maxKeys = 100 } = {}) {
|
||||
return blobStorage.listByPrefix(prefix, maxKeys);
|
||||
}
|
||||
|
||||
async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) {
|
||||
let text = '';
|
||||
try {
|
||||
const row = await readS3TextObject(key);
|
||||
text = row.body || '';
|
||||
} catch (e) {
|
||||
if (e?.code === 'NoSuchKey') {
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const lines = text.split('\n');
|
||||
let total = 0;
|
||||
const items = [];
|
||||
const qstr = q ? String(q).toLowerCase() : '';
|
||||
const matchesQuery = (line) => !qstr || line.toLowerCase().includes(qstr);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = String(lines[i] || '').trim();
|
||||
if (!line) continue;
|
||||
if (!matchesQuery(line)) continue;
|
||||
total++;
|
||||
const pos = total - 1;
|
||||
if (limit > 0) {
|
||||
if (pos >= offset && items.length < limit) items.push(mapLine(line));
|
||||
} else {
|
||||
items.push(mapLine(line));
|
||||
}
|
||||
}
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
function listBlobVersionsForKey(key, maxKeys = 50) {
|
||||
return blobStorage.listVersions(key, maxKeys);
|
||||
}
|
||||
|
||||
function rollbackBlobVersion(key, versionId) {
|
||||
openDatabase();
|
||||
return blobStorage.rollbackToVersionId(key, versionId);
|
||||
}
|
||||
|
||||
/** Заглушка: прямой вызов S3 SDK больше не используется. */
|
||||
const s3 = {
|
||||
send() {
|
||||
throw new Error('S3 SDK removed: use readS3TextObject / writeS3TextObject or evobgpClient');
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
s3,
|
||||
BUCKET_NAME,
|
||||
@@ -294,5 +170,6 @@ module.exports = {
|
||||
streamPaginatedText,
|
||||
invalidateCacheForKey,
|
||||
listS3Objects,
|
||||
listBlobVersionsForKey,
|
||||
rollbackBlobVersion,
|
||||
};
|
||||
|
||||
|
||||
@@ -39,14 +39,15 @@ function mapAjvErrors(errors) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Маппинг UI ресурсов на S3 ключи (для истории версий)
|
||||
* Маппинг UI ресурсов на ключи в SQLite blobs (только локально версионируемые объекты).
|
||||
* Справочники EvoBGP (domains-new / asns / ip-ranges) в истории не участвуют.
|
||||
*/
|
||||
function resourceToKey(resource) {
|
||||
switch (resource) {
|
||||
case 'domains-new': return 'bgp_data/domains_community.txt';
|
||||
case 'ip-ranges': return 'bgp_data/ips.txt';
|
||||
case 'asns': return 'bgp_data/asns.txt';
|
||||
default: return null;
|
||||
case 'servers':
|
||||
return 'servers.json';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+45
-59
@@ -1,42 +1,37 @@
|
||||
/**
|
||||
* Универсальные хелперы для работы с S3
|
||||
* Устраняют дублирование кода в routes
|
||||
* Универсальные хелперы для JSON/текста из локального storage (SQLite)
|
||||
*/
|
||||
|
||||
const { s3, BUCKET_NAME } = require('../services/s3Service');
|
||||
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { streamToString } = require('../services/s3Service');
|
||||
const { headMeta, readS3TextObject } = require('../services/s3Service');
|
||||
const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||
|
||||
/**
|
||||
* Универсальный GET для JSON объектов из S3 с заголовками
|
||||
* @param {string} s3Key - Ключ S3
|
||||
* @param {object} req - Express request
|
||||
* @param {object} res - Express response
|
||||
* @param {object} options - Опции
|
||||
* @param {function} options.transform - Функция трансформации данных
|
||||
* @param {*} options.defaultValue - Значение по умолчанию при отсутствии файла
|
||||
* Универсальный GET для JSON объектов с заголовками
|
||||
*/
|
||||
async function getS3JsonWithHeaders(s3Key, req, res, options = {}) {
|
||||
const { transform = (data) => data, defaultValue = [] } = options;
|
||||
|
||||
|
||||
try {
|
||||
// Получаем HEAD для установки заголовков
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
|
||||
const etag = head?.ETag || null;
|
||||
|
||||
// Устанавливаем заголовки
|
||||
const head = await headMeta(s3Key);
|
||||
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-None-Match
|
||||
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: s3Key }));
|
||||
const fileContent = await streamToString(data.Body);
|
||||
|
||||
let fileContent;
|
||||
try {
|
||||
const data = await readS3TextObject(s3Key);
|
||||
fileContent = data.body;
|
||||
} catch (e) {
|
||||
if (e?.code === 'NoSuchKey') {
|
||||
return res.json(transform(defaultValue));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fileContent);
|
||||
@@ -47,55 +42,47 @@ async function getS3JsonWithHeaders(s3Key, req, res, options = {}) {
|
||||
console.error(`Error parsing ${s3Key}:`, parseError);
|
||||
parsed = defaultValue;
|
||||
}
|
||||
|
||||
// Применяем трансформацию
|
||||
|
||||
const result = transform(parsed);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
||||
res.json(defaultValue);
|
||||
} else {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
||||
}
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Универсальный GET для текстовых объектов из S3 с заголовками
|
||||
* @param {string} s3Key - Ключ S3
|
||||
* @param {object} req - Express request
|
||||
* @param {object} res - Express response
|
||||
* @param {object} options - Опции
|
||||
* @param {function} options.transform - Функция трансформации текста
|
||||
* @param {string} options.defaultValue - Значение по умолчанию при отсутствии файла
|
||||
* Универсальный GET для текстовых объектов с заголовками
|
||||
*/
|
||||
async function getS3TextWithHeaders(s3Key, req, res, options = {}) {
|
||||
const { transform = (text) => ({ config: text }), defaultValue = '' } = options;
|
||||
|
||||
|
||||
try {
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
|
||||
const etag = head?.ETag || null;
|
||||
|
||||
const head = await headMeta(s3Key);
|
||||
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 (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: s3Key }));
|
||||
const text = await streamToString(data.Body);
|
||||
|
||||
let text;
|
||||
try {
|
||||
const data = await readS3TextObject(s3Key);
|
||||
text = data.body;
|
||||
} catch (e) {
|
||||
if (e?.code === 'NoSuchKey') {
|
||||
return res.json(transform(defaultValue));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const result = transform(text);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
||||
res.json(transform(defaultValue));
|
||||
} else {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
||||
}
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading storage', 'E_STORAGE');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,4 +90,3 @@ module.exports = {
|
||||
getS3JsonWithHeaders,
|
||||
getS3TextWithHeaders,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user