Files
router-lists-ui/backend/server.js
T

1772 lines
60 KiB
JavaScript

// History endpoints are registered below after app is initialized
require('dotenv').config();
const express = require('express');
const AWS = require('aws-sdk');
const cors = require('cors');
const path = require('path');
const compression = require('compression');
const Ajv = require('ajv');
const net = require('net');
const crypto = require('crypto');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const app = express();
const port = Number(process.env.PORT) || 3001;
// CORS: при необходимости сузьте до списка доменов (пример ниже)
// const allowed = (process.env.CORS_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean);
// app.use(cors({ origin: (origin, cb) => { if (!origin || allowed.length===0 || allowed.includes(origin)) return cb(null, true); cb(new Error('CORS blocked')); }, credentials: true }));
app.use(cors());
app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
}));
app.disable('x-powered-by');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 1000,
standardHeaders: true,
legacyHeaders: false,
});
app.use(limiter);
app.use(express.json());
app.use(compression());
// Disable Express auto-ETag to avoid weak ETags on JSON bodies
app.set('etag', false);
// Expose important headers to browser JS (for CORS)
app.use((req, res, next) => {
res.setHeader('Access-Control-Expose-Headers', 'ETag, Last-Modified, Content-Length-Source');
next();
});
// Централизованный обработчик ошибок (должен быть подключён ПОСЛЕ роутов — см. ниже второе use)
// Helpers: meta and responses
function toIso(x) {
try { return new Date(x).toISOString(); } catch { return null; }
}
async function headMeta(key) {
try {
const h = await s3.headObject({ Bucket: BUCKET_NAME, Key: key }).promise();
return {
etag: h.ETag || null,
lastModified: h.LastModified ? toIso(h.LastModified) : null,
contentLength: typeof h.ContentLength === 'number' ? h.ContentLength : null,
};
} catch (e) {
return { etag: null, lastModified: null, contentLength: null };
}
}
function sendOk(res, meta) {
if (meta?.etag) res.set('ETag', String(meta.etag));
if (meta?.lastModified) res.set('Last-Modified', new Date(meta.lastModified).toUTCString());
if (typeof meta?.contentLength === 'number') res.set('Content-Length-Source', String(meta.contentLength));
return res.json({ ok: true, etag: meta?.etag || null, lastModified: meta?.lastModified || null, contentLength: meta?.contentLength ?? null });
}
function sendError(res, status, message, code, details) {
return res.status(status).json({ message, code, details });
}
// Map UI resource -> S3 key (for history endpoints)
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;
}
}
// Compute sha256 of string
function sha256OfString(s) {
return crypto.createHash('sha256').update(Buffer.from(String(s), 'utf-8')).digest('hex');
}
function mapAjvErrors(errors) {
if (!Array.isArray(errors)) return [];
return errors.map((e) => ({
message: e.message,
instancePath: e.instancePath,
keyword: e.keyword,
params: e.params,
}));
}
// Serve static files from the React app
app.use(express.static(path.join(__dirname, 'public')));
// Configure AWS S3
const s3 = new AWS.S3({
endpoint: 'https://storage.yandexcloud.net',
region: process.env.AWS_REGION,
s3ForcePathStyle: true,
signatureVersion: 'v4',
httpOptions: { timeout: 15000 },
maxRetries: 3,
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,
}
});
const BUCKET_NAME = process.env.S3_BUCKET_NAME;
const FILE_KEY = 'bgp_data/domains.txt';
// AJV setup and schemas
const ajv = new Ajv({ allErrors: true, removeAdditional: 'failing' });
const schemaDomainsNew = {
type: 'array',
items: {
type: 'object',
required: ['domain', 'community'],
additionalProperties: false,
properties: {
domain: { type: 'string' },
community: { type: 'string' }
}
}
};
const schemaAsns = {
type: 'array',
items: {
type: 'object',
required: ['domain', 'type'],
additionalProperties: false,
properties: {
domain: { type: 'string' },
type: { type: 'string' }
}
}
};
const schemaIpRanges = {
type: 'array',
items: {
type: 'object',
required: ['ipRange', 'community'],
additionalProperties: false,
properties: {
ipRange: { type: 'string' },
community: { type: 'string' }
}
}
};
const schemaFilters = {
type: 'array',
items: {
type: 'object',
required: ['community', 'gateway'],
additionalProperties: true,
properties: {
community: { type: 'string' },
gateway: { type: 'string' },
description: { type: 'string' }
}
}
};
const schemaServers = {
type: 'array',
items: {
type: 'object',
required: ['ip', 'dns', 'country', 'provider', 'tunnel'],
additionalProperties: true,
properties: {
ip: { type: 'string' },
dns: { type: 'string' },
country: { type: 'string' },
provider: { type: 'string' },
tunnel: { type: 'string' },
gateway: { type: 'string' }
}
}
};
const schemaBilling = {
type: 'array',
items: {
type: 'object',
required: ['hostName', 'country', 'provider'],
additionalProperties: true,
properties: {
hostName: { type: 'string' },
country: { type: 'string' },
provider: { type: 'string' }
}
}
};
const validateDomainsNew = ajv.compile(schemaDomainsNew);
const validateAsns = ajv.compile(schemaAsns);
const validateIpRanges = ajv.compile(schemaIpRanges);
const validateFilters = ajv.compile(schemaFilters);
const validateServers = ajv.compile(schemaServers);
const validateBilling = ajv.compile(schemaBilling);
// Helper: build MikroTik nested if/else blocks (RouterOS v7 filter language does not support 'else if')
function buildNestedGatewayBlocks(gatewayGroups, baseIndentSpaces = 4) {
const indent = (n) => ' '.repeat(n);
const entries = Object.entries(gatewayGroups);
if (entries.length === 0) return '';
function buildAt(index, pad) {
const [gateway, communities] = entries[index];
let s = '';
s += `${indent(pad)}if (\n`;
communities.forEach((community, i) => {
s += `${indent(pad + 4)}(bgp-communities includes ${community})`;
if (i < communities.length - 1) s += ' or \n';
});
s += `\n${indent(pad)})\n`;
s += `${indent(pad)}{\n${indent(pad + 8 - 4)}set gw ${gateway}; accept;\n${indent(pad)}}\n`;
if (index < entries.length - 1) {
s += `${indent(pad)}else\n${indent(pad)}{\n`;
s += buildAt(index + 1, pad + 4);
s += `\n${indent(pad)}}`;
} else {
s += `${indent(pad)}else\n${indent(pad)}{\n${indent(pad + 4)}reject;\n${indent(pad)}}`;
}
return s;
}
return buildAt(0, baseIndentSpaces);
}
// Helper: read text file from S3 and return { body, etag, lastModified, contentLength }
async function readS3TextObject(key) {
const params = { Bucket: BUCKET_NAME, Key: key };
const data = await s3.getObject(params).promise();
return {
body: data.Body.toString('utf-8'),
etag: data.ETag ? String(data.ETag).replace(/\"/g, '"') : undefined,
lastModified: data.LastModified ? data.LastModified.toISOString() : undefined,
contentLength: typeof data.ContentLength === 'number' ? data.ContentLength : undefined
};
}
// Helper: head object and return current ETag
async function headS3ObjectEtag(key) {
const head = await s3.headObject({ Bucket: BUCKET_NAME, Key: key }).promise();
return head.ETag ? String(head.ETag).replace(/\"/g, '"') : undefined;
}
// Helper: stream and paginate big text files (line-based)
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());
};
const stream = s3.getObject({ Bucket: BUCKET_NAME, Key: key }).createReadStream();
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; // position among matches
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);
});
}
// Simple in-memory soft locks with TTL
const locks = new Map(); // key -> { owner, expiresAt }
function cleanupExpiredLocks() {
const now = Date.now();
for (const [k, v] of locks.entries()) {
if (!v || typeof v.expiresAt !== 'number' || v.expiresAt <= now) {
locks.delete(k);
}
}
}
setInterval(cleanupExpiredLocks, 30_000);
// Get domains from S3
app.get('/api/domains', async (req, res) => {
const params = {
Bucket: BUCKET_NAME,
Key: FILE_KEY,
};
try {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
const domains = fileContent.split('\n').filter(line => line).map(line => {
// Trim the line to remove any leading/trailing whitespace, including \r
// And split by any whitespace to be more robust
const parts = line.trim().split(/\s+/);
const domain = parts[0] || '';
const type = parts[1] || '';
return { domain, type };
});
if (data.ETag) {
res.set('ETag', String(data.ETag));
}
if (data.LastModified) {
res.set('Last-Modified', new Date(data.LastModified).toUTCString());
}
if (typeof data.ContentLength === 'number') {
res.set('Content-Length-Source', String(data.ContentLength));
}
res.json(domains);
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json([]); // Return empty array if file does not exist
} else {
console.error(error);
return sendError(res, 500, 'Error reading from S3', 'E_S3', { error: String(error?.message || error) });
}
}
});
// Update domains in S3 with optimistic concurrency via ETag check
app.post('/api/domains', async (req, res) => {
const { domains, etag } = req.body;
const fileContent = (domains || []).map(d => `${String(d.domain || '').trim()} ${String(d.type || '').trim()}`.trim()).filter(Boolean).join('\n');
// Concurrency guard: if client sent etag, ensure current ETag matches
try {
if (etag) {
const current = await headS3ObjectEtag(FILE_KEY).catch(() => undefined);
if (current && current.replace(/\"/g, '"') !== String(etag)) {
return res.status(412).json({ message: 'Precondition Failed: ETag mismatch' });
}
}
} catch (e) {
// ignore if head fails due to NoSuchKey; proceed to create
}
const params = {
Bucket: BUCKET_NAME,
Key: FILE_KEY,
Body: fileContent,
ContentType: 'text/plain',
};
try {
const put = await s3.putObject(params).promise();
res.set('ETag', put.ETag || '');
res.send('File updated successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error writing to S3');
}
});
// --- ASNs Routes ---
// Get ASNs from S3
app.get('/api/asns', async (req, res) => {
const { q = '', offset, limit, countOnly } = req.query || {};
const params = {
Bucket: BUCKET_NAME,
Key: 'bgp_data/asns.txt',
};
try {
if (countOnly === 'true') {
// только количество
const { total } = await streamPaginatedText({
key: 'bgp_data/asns.txt', q, offset: 0, limit: 0,
mapLine: (line) => ({})
});
return res.json({ total });
} else if (limit !== undefined) {
const { items, total } = await streamPaginatedText({
key: 'bgp_data/asns.txt',
q,
offset: Number(offset) || 0,
limit: Number(limit) || 0,
mapLine: (line) => {
const parts = line.trim().split(/\s+/);
return { domain: parts[0] || '', type: parts[1] || '' };
}
});
if (!validateAsns(items)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
return res.json({ items, total });
} else {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
const asns = fileContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
const domain = parts[0] || '';
const type = parts[1] || '';
return { domain, type };
});
if (!validateAsns(asns)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
if (data.ETag) res.set('ETag', String(data.ETag));
if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString());
if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength));
res.json(asns);
}
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json([]);
} else {
console.error(error);
res.status(500).send('Error reading from S3');
}
}
});
// Update ASNs in S3
app.post('/api/asns', async (req, res) => {
const { domains: asns, etag } = req.body; // Keep name 'domains' for consistency
const fileContent = (asns || []).map(a => `${String(a.domain || '').trim()} ${String(a.type || '').trim()}`.trim()).filter(Boolean).join('\n');
try {
if (!validateAsns(asns || [])) {
return sendError(res, 400, 'Invalid payload format for asns', 'E_SCHEMA');
}
let current = null;
try { current = await headS3ObjectEtag('bgp_data/asns.txt'); } catch {}
if (current && etag && current !== String(etag)) {
const meta = await headMeta('bgp_data/asns.txt');
return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta });
}
} catch {}
const params = {
Bucket: BUCKET_NAME,
Key: 'bgp_data/asns.txt',
Body: fileContent,
ContentType: 'text/plain',
};
try {
const put = await s3.putObject(params).promise();
const meta = await headMeta('bgp_data/asns.txt');
return sendOk(res, meta);
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error writing to S3', 'E_S3', { error: String(error?.message || error) });
}
});
// --- Domains New Routes ---
// Get domains-new from S3
app.get('/api/domains-new', async (req, res) => {
const { q = '', offset, limit, countOnly } = req.query || {};
const params = {
Bucket: BUCKET_NAME,
Key: 'bgp_data/domains_community.txt',
};
try {
if (countOnly === 'true') {
const { total } = await streamPaginatedText({
key: 'bgp_data/domains_community.txt', q, offset: 0, limit: 0,
mapLine: (line) => ({})
});
return res.json({ total });
} else if (limit !== undefined) {
const { items, total } = await streamPaginatedText({
key: 'bgp_data/domains_community.txt',
q,
offset: Number(offset) || 0,
limit: Number(limit) || 0,
mapLine: (line) => {
const parts = line.trim().split(/\s+/);
return { domain: parts[0] || '', community: parts[1] || '' };
}
});
if (!validateDomainsNew(items)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
return res.json({ items, total });
} else {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
const domains = fileContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
const domain = parts[0] || '';
const community = parts[1] || '';
return { domain, community };
});
if (!validateDomainsNew(domains)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
if (data.ETag) res.set('ETag', String(data.ETag));
if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString());
if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength));
res.json(domains);
}
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json([]); // Return empty array if file does not exist
} else {
console.error(error);
res.status(500).send('Error reading from S3');
}
}
});
// Update domains-new in S3
app.post('/api/domains-new', async (req, res) => {
const { domains, etag } = req.body;
const fileContent = (domains || []).map(d => `${String(d.domain || '').trim()} ${String(d.community || '').trim()}`.trim()).filter(Boolean).join('\n');
try {
if (!validateDomainsNew(domains || [])) {
return sendError(res, 400, 'Invalid payload format for domains-new', 'E_SCHEMA');
}
let current = null;
try { current = await headS3ObjectEtag('bgp_data/domains_community.txt'); } catch {}
if (current && etag && current !== String(etag)) {
const meta = await headMeta('bgp_data/domains_community.txt');
return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta });
}
} catch {}
const params = {
Bucket: BUCKET_NAME,
Key: 'bgp_data/domains_community.txt',
Body: fileContent,
ContentType: 'text/plain',
};
try {
const put = await s3.putObject(params).promise();
const meta = await headMeta('bgp_data/domains_community.txt');
return sendOk(res, meta);
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error writing to S3', 'E_S3', { error: String(error?.message || error) });
}
});
// --- IP Ranges Routes ---
// Get IP ranges from S3
app.get('/api/ip-ranges', async (req, res) => {
const { q = '', offset, limit, countOnly } = req.query || {};
const params = {
Bucket: BUCKET_NAME,
Key: 'bgp_data/ips.txt',
};
try {
if (countOnly === 'true') {
const { total } = await streamPaginatedText({
key: 'bgp_data/ips.txt', q, offset: 0, limit: 0,
mapLine: (line) => ({})
});
return res.json({ total });
} else if (limit !== undefined) {
const { items, total } = await streamPaginatedText({
key: 'bgp_data/ips.txt',
q,
offset: Number(offset) || 0,
limit: Number(limit) || 0,
mapLine: (line) => {
const parts = line.trim().split(/\s+/);
return { ipRange: parts[0] || '', community: parts[1] || '' };
}
});
if (!validateIpRanges(items)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
return res.json({ items, total });
} else {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
const ipRanges = fileContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
const ipRange = parts[0] || '';
const community = parts[1] || '';
return { ipRange, community };
});
if (!validateIpRanges(ipRanges)) return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
if (data.ETag) res.set('ETag', String(data.ETag));
if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString());
if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength));
res.json(ipRanges);
}
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json([]); // Return empty array if file does not exist
} else {
console.error(error);
res.status(500).send('Error reading from S3');
}
}
});
// Update IP ranges in S3
app.post('/api/ip-ranges', async (req, res) => {
const { ipRanges, etag } = req.body;
const fileContent = (ipRanges || []).map(ip => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim()).filter(Boolean).join('\n');
try {
if (!validateIpRanges(ipRanges || [])) {
return sendError(res, 400, 'Invalid payload format for ip-ranges', 'E_SCHEMA');
}
let current = null;
try { current = await headS3ObjectEtag('bgp_data/ips.txt'); } catch {}
if (current && etag && current !== String(etag)) {
const meta = await headMeta('bgp_data/ips.txt');
return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta });
}
} catch {}
const params = {
Bucket: BUCKET_NAME,
Key: 'bgp_data/ips.txt',
Body: fileContent,
ContentType: 'text/plain',
};
try {
const put = await s3.putObject(params).promise();
const meta = await headMeta('bgp_data/ips.txt');
return sendOk(res, meta);
} catch (error) {
console.error(error);
return sendError(res, 500, 'Error writing to S3', 'E_S3', { error: String(error?.message || error) });
}
});
// --- Communities Directory Routes ---
// Get communities from S3
app.get('/api/communities', async (req, res) => {
const params = {
Bucket: BUCKET_NAME,
Key: 'bgp_data/communities.json',
};
try {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
let communities = [];
try {
const parsed = JSON.parse(fileContent);
communities = Array.isArray(parsed) ? parsed : [];
} catch (parseError) {
console.error('Error parsing communities.json:', parseError);
communities = [];
}
// Basic normalization
communities = 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) : [],
gatewayDefault: c.gatewayDefault ? String(c.gatewayDefault) : '',
color: c.color ? String(c.color) : ''
}));
res.json(communities);
} catch (error) {
if (error.code === 'NoSuchKey') {
// If file missing, start with empty list
return res.json([]);
}
console.error('Error reading communities from S3:', error);
res.status(500).send('Error reading communities from S3');
}
});
// Update communities in S3
app.post('/api/communities', async (req, res) => {
const { communities } = req.body;
if (!Array.isArray(communities)) {
return res.status(400).send('communities must be an array');
}
// Validate entries and ensure unique values
const seen = new Set();
const normalized = [];
for (let i = 0; i < communities.length; i++) {
const entry = communities[i] || {};
const value = typeof entry.value === 'string' ? entry.value.trim() : '';
if (!value) {
return res.status(400).send(`Community at index ${i} is missing required field: value`);
}
if (seen.has(value)) {
return res.status(400).send(`Duplicate community value at index ${i}: ${value}`);
}
seen.add(value);
normalized.push({
value,
name: entry.name ? String(entry.name) : '',
description: entry.description ? String(entry.description) : '',
tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
gatewayDefault: entry.gatewayDefault ? String(entry.gatewayDefault) : '',
color: entry.color ? String(entry.color) : ''
});
}
const params = {
Bucket: BUCKET_NAME,
Key: 'bgp_data/communities.json',
Body: JSON.stringify(normalized, null, 2),
ContentType: 'application/json',
};
try {
await s3.putObject(params).promise();
res.send('Communities updated successfully');
} catch (error) {
console.error('Error writing communities to S3:', error);
res.status(500).send('Error writing communities to S3');
}
});
// --- Servers Routes (JSON format) ---
// Get servers from S3
app.get('/api/servers', async (req, res) => {
const params = {
Bucket: BUCKET_NAME,
Key: 'servers.json',
};
try {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
let servers = [];
try {
servers = JSON.parse(fileContent);
// Ensure it's an array
if (!Array.isArray(servers)) {
servers = [];
}
} catch (parseError) {
console.error('Error parsing servers.json:', parseError);
servers = [];
}
res.json(servers);
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json([]); // Return empty array if file does not exist
} else {
console.error(error);
res.status(500).send('Error reading from S3');
}
}
});
// Update servers in S3
app.post('/api/servers', async (req, res) => {
const { domains: servers } = req.body; // Keep name 'domains' for consistency
// Validate servers structure
if (!Array.isArray(servers)) {
return res.status(400).send('Servers must be an array');
}
// Validate each server has required fields
for (let i = 0; i < servers.length; i++) {
const server = servers[i];
if (!server.ip || !server.dns || !server.country || !server.provider || !server.tunnel) {
return res.status(400).send(`Server at index ${i} is missing required fields`);
}
}
const params = {
Bucket: BUCKET_NAME,
Key: 'servers.json',
Body: JSON.stringify(servers, null, 2), // Pretty print JSON
ContentType: 'application/json',
};
try {
await s3.putObject(params).promise();
res.send('File updated successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error writing to S3');
}
});
// --- Billing Routes ---
// Get billing data from S3
app.get('/api/billing', async (req, res) => {
const params = {
Bucket: BUCKET_NAME,
Key: 'servers-billing.json',
};
try {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
let billingData = [];
try {
billingData = JSON.parse(fileContent);
// Ensure it's an array
if (!Array.isArray(billingData)) {
billingData = [];
}
} catch (parseError) {
console.error('Error parsing servers-billing.json:', parseError);
billingData = [];
}
res.json(billingData);
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json([]); // Return empty array if file does not exist
} else {
console.error(error);
res.status(500).send('Error reading from S3');
}
}
});
// Update billing data in S3
app.post('/api/billing', async (req, res) => {
const { domains: billingData } = req.body; // Keep name 'domains' for consistency
// Validate billing data structure
if (!Array.isArray(billingData)) {
return res.status(400).send('Billing data must be an array');
}
// Validate each billing item has required fields
for (let i = 0; i < billingData.length; i++) {
const item = billingData[i];
if (!item.hostName || !item.country || !item.provider) {
return res.status(400).send(`Billing item at index ${i} is missing required fields: hostName, country, provider`);
}
}
const params = {
Bucket: BUCKET_NAME,
Key: 'servers-billing.json',
Body: JSON.stringify(billingData, null, 2), // Pretty print JSON
ContentType: 'application/json',
};
try {
await s3.putObject(params).promise();
res.send('File updated successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error writing to S3');
}
});
// --- Filters Routes (JSON format) ---
// Get filters from S3
app.get('/api/filters', async (req, res) => {
const params = {
Bucket: BUCKET_NAME,
Key: 'filters.json',
};
try {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
let filters = [];
try {
filters = JSON.parse(fileContent);
// Ensure it's an array
if (!Array.isArray(filters)) {
filters = [];
}
} catch (parseError) {
console.error('Error parsing filters.json:', parseError);
filters = [];
}
res.json(filters);
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json([]); // Return empty array if file does not exist
} else {
console.error(error);
res.status(500).send('Error reading from S3');
}
}
});
// Update filters in S3
app.post('/api/filters', async (req, res) => {
const { domains: filters } = req.body; // Keep name 'domains' for consistency
// Validate filters structure
if (!Array.isArray(filters)) {
return res.status(400).send('Filters must be an array');
}
// Validate each filter has required fields
for (let i = 0; i < filters.length; i++) {
const filter = filters[i];
if (!filter.community || !filter.gateway) {
return res.status(400).send(`Filter at index ${i} is missing required fields`);
}
}
const params = {
Bucket: BUCKET_NAME,
Key: 'filters.json',
Body: JSON.stringify(filters, null, 2), // Pretty print JSON
ContentType: 'application/json',
};
try {
await s3.putObject(params).promise();
res.send('File updated successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error writing to S3');
}
});
// Эндпоинт метаданных S3 по ключевым файлам (Last-Modified, ETag, Content-Length)
app.get('/api/s3/last-modified', async (req, res) => {
try {
const keys = [
{ name: 'domainsNew', key: 'bgp_data/domains_community.txt' },
{ name: 'asns', key: 'bgp_data/asns.txt' },
{ name: 'servers', key: 'servers.json' },
{ name: 'filters', key: 'filters.json' },
{ name: 'ipRanges', key: 'bgp_data/ips.txt' }
];
const results = await Promise.allSettled(
keys.map(k => s3.headObject({ Bucket: BUCKET_NAME, Key: k.key }).promise())
);
const out = {};
results.forEach((r, idx) => {
const name = keys[idx].name;
if (r.status === 'fulfilled') {
out[name] = {
lastModified: r.value.LastModified ? r.value.LastModified.toISOString() : null,
etag: r.value.ETag || null,
contentLength: typeof r.value.ContentLength === 'number' ? r.value.ContentLength : null
};
} else {
out[name] = null;
}
});
res.json(out);
} catch (error) {
console.error('Error fetching last modified dates from S3:', error);
res.status(500).send('Error fetching last modified dates from S3');
}
});
// Soft-lock endpoints
// GET lock status
app.get('/api/locks/:resource', (req, res) => {
cleanupExpiredLocks();
const { resource } = req.params;
const info = locks.get(resource);
if (!info) return res.json({ locked: false });
res.json({ locked: true, owner: info.owner, expiresAt: info.expiresAt });
});
// POST acquire/refresh lock
app.post('/api/locks/:resource', (req, res) => {
cleanupExpiredLocks();
const { resource } = req.params;
const { owner = 'anonymous', ttlSeconds = 120 } = req.body || {};
const now = Date.now();
const existing = locks.get(resource);
if (existing && existing.expiresAt > now && existing.owner !== owner) {
return res.status(423).json({ message: 'Resource is locked by another user', owner: existing.owner, expiresAt: existing.expiresAt });
}
const expiresAt = now + Math.max(30, Math.min(600, Number(ttlSeconds) || 120)) * 1000;
locks.set(resource, { owner, expiresAt });
res.json({ locked: true, owner, expiresAt });
});
// DELETE release lock
app.delete('/api/locks/:resource', (req, res) => {
const { resource } = req.params;
locks.delete(resource);
res.json({ released: true });
});
// History endpoints (require bucket versioning to be enabled). If versioning disabled, best effort.
app.get('/api/history/:resource', async (req, res) => {
const { resource } = req.params;
const key = resourceToKey(resource);
if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE');
try {
if (!s3.listObjectVersions) {
return sendError(res, 501, 'S3 listObjectVersions not available', 'E_NOT_SUPPORTED');
}
const out = await s3.listObjectVersions({ Bucket: BUCKET_NAME, Prefix: key, MaxKeys: 20 }).promise();
const versions = (out.Versions || [])
.filter(v => v.Key === key)
.slice(0, 10)
.map(v => ({ versionId: v.VersionId, isLatest: v.IsLatest, lastModified: toIso(v.LastModified), size: v.Size, etag: v.ETag }));
return res.json({ items: versions });
} catch (e) {
console.error('history error', e);
return sendError(res, 500, 'Error reading history', 'E_S3', { error: String(e?.message || e) });
}
});
app.post('/api/history/:resource/rollback', async (req, res) => {
const { resource } = req.params;
const { versionId } = req.body || {};
const key = resourceToKey(resource);
if (!key || !versionId) return sendError(res, 400, 'Bad request', 'E_BAD_REQUEST');
try {
// Copy specific version over same key to rollback
await s3.copyObject({ Bucket: BUCKET_NAME, CopySource: `/${BUCKET_NAME}/${encodeURIComponent(key)}?versionId=${encodeURIComponent(versionId)}`, Key: key }).promise();
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) });
}
});
// Generate MikroTik configuration from filters
app.get('/api/filters/generate-config', async (req, res) => {
const params = {
Bucket: BUCKET_NAME,
Key: 'filters.json',
};
try {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
let filters = [];
try {
filters = JSON.parse(fileContent);
if (!Array.isArray(filters)) {
filters = [];
}
} catch (parseError) {
console.error('Error parsing filters.json:', parseError);
filters = [];
}
if (filters.length === 0) {
return res.json({ config: '// No filters to generate configuration' });
}
// Group filters by gateway
const gatewayGroups = {};
filters.forEach(filter => {
if (!gatewayGroups[filter.gateway]) {
gatewayGroups[filter.gateway] = [];
}
gatewayGroups[filter.gateway].push(filter.community);
});
let config = '// Frouting filter configuration for MikroTik 7.14+\n';
config += '// Generated automatically\n';
config += `// Date: ${new Date().toISOString()}\n\n`;
config += '/routing filter bgp-in-tmp {\n';
// Строим вложенные if/else (RouterOS не поддерживает else if в данном контексте)
config += buildNestedGatewayBlocks(gatewayGroups, 4);
config += '}\n';
res.json({ config });
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json({ config: '// filters.json file not found' });
} else {
console.error(error);
res.status(500).send('Error generating configuration');
}
}
});
// Export MikroTik configuration to S3
app.post('/api/filters/export-config', async (req, res) => {
const params = {
Bucket: BUCKET_NAME,
Key: 'filters.json',
};
try {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
let filters = [];
try {
filters = JSON.parse(fileContent);
if (!Array.isArray(filters)) {
filters = [];
}
} catch (parseError) {
console.error('Error parsing filters.json:', parseError);
filters = [];
}
if (filters.length === 0) {
return res.json({ success: false, message: 'Нет фильтров для экспорта' });
}
// Group filters by gateway
const gatewayGroups = {};
filters.forEach(filter => {
if (!gatewayGroups[filter.gateway]) {
gatewayGroups[filter.gateway] = [];
}
gatewayGroups[filter.gateway].push(filter.community);
});
let config = '// Frouting filter configuration for MikroTik 7.14+\n';
config += '// Generated automatically\n';
config += `// Date: ${new Date().toISOString()}\n\n`;
config += '/routing filter bgp-in-tmp {\n';
// Строим вложенные if/else (RouterOS не поддерживает else if в данном контексте)
config += buildNestedGatewayBlocks(gatewayGroups, 4);
config += '}\n';
// Save configuration to S3
const exportParams = {
Bucket: BUCKET_NAME,
Key: 'mikrotik-frouting-config.txt',
Body: config,
ContentType: 'text/plain',
};
await s3.putObject(exportParams).promise();
res.json({ success: true, message: 'Конфигурация экспортирована в S3' });
} catch (error) {
console.error(error);
res.status(500).send('Error exporting configuration');
}
});
// --- Server Configs Routes ---
// Get server configs list
app.get('/api/server-configs', async (req, res) => {
const params = {
Bucket: BUCKET_NAME,
Key: 'server-configs.json',
};
try {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
let servers = [];
try {
servers = JSON.parse(fileContent);
if (!Array.isArray(servers)) {
servers = [];
}
} catch (parseError) {
console.error('Error parsing server-configs.json:', parseError);
servers = [];
}
res.json(servers);
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json([]); // Return empty array if file does not exist
} else {
console.error(error);
res.status(500).send('Error reading server configs from S3');
}
}
});
// Update server configs list
app.post('/api/server-configs', async (req, res) => {
const { servers } = req.body;
// Validate servers structure
if (!Array.isArray(servers)) {
return res.status(400).send('Servers must be an array');
}
// Validate each server has required fields
for (let i = 0; i < servers.length; i++) {
const server = servers[i];
if (!server.id || !server.name) {
return res.status(400).send(`Server at index ${i} is missing required fields`);
}
}
const params = {
Bucket: BUCKET_NAME,
Key: 'server-configs.json',
Body: JSON.stringify(servers, null, 2), // Pretty print JSON
ContentType: 'application/json',
};
try {
await s3.putObject(params).promise();
res.send('Server configs updated successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error writing server configs to S3');
}
});
// Get specific server config
app.get('/api/server-configs/:serverId', async (req, res) => {
const { serverId } = req.params;
const params = {
Bucket: BUCKET_NAME,
Key: `filter-manager/config-${serverId}.txt`,
};
try {
const data = await s3.getObject(params).promise();
const config = data.Body.toString('utf-8');
res.json({ config });
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json({ config: '// Конфигурация не найдена' });
} else {
console.error(error);
res.status(500).send('Error reading server config from S3');
}
}
});
// Save specific server config
app.post('/api/server-configs/:serverId', async (req, res) => {
const { serverId } = req.params;
const { config } = req.body;
if (!config) {
return res.status(400).send('Config is required');
}
const params = {
Bucket: BUCKET_NAME,
Key: `filter-manager/config-${serverId}.txt`,
Body: config,
ContentType: 'text/plain',
};
try {
await s3.putObject(params).promise();
res.send('Server config saved successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error writing server config to S3');
}
});
// Delete specific server config
app.delete('/api/server-configs/:serverId', async (req, res) => {
const { serverId } = req.params;
const params = {
Bucket: BUCKET_NAME,
Key: `filter-manager/config-${serverId}.txt`,
};
try {
await s3.deleteObject(params).promise();
res.send('Server config deleted successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error deleting server config from S3');
}
});
// Delete server completely (config + filters)
app.delete('/api/server-configs/:serverId/complete', async (req, res) => {
const { serverId } = req.params;
try {
// Удаляем конфигурацию сервера
const configParams = {
Bucket: BUCKET_NAME,
Key: `filter-manager/config-${serverId}.txt`,
};
// Удаляем фильтры сервера
const filtersParams = {
Bucket: BUCKET_NAME,
Key: `filter-manager/server-filters-${serverId}.json`,
};
// Удаляем оба файла параллельно
await Promise.allSettled([
s3.deleteObject(configParams).promise(),
s3.deleteObject(filtersParams).promise()
]);
res.send('Server and all associated files deleted successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error deleting server files from S3');
}
});
// --- Server Filters Routes ---
// Get server filters
app.get('/api/server-filters/:serverId', async (req, res) => {
const { serverId } = req.params;
const params = {
Bucket: BUCKET_NAME,
Key: `filter-manager/server-filters-${serverId}.json`,
};
try {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
let filters = [];
try {
filters = JSON.parse(fileContent);
if (!Array.isArray(filters)) {
filters = [];
}
} catch (parseError) {
console.error('Error parsing server filters:', parseError);
filters = [];
}
res.json(filters);
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json([]); // Return empty array if file does not exist
} else {
console.error(error);
res.status(500).send('Error reading server filters from S3');
}
}
});
// Generate MikroTik configuration from server filters
app.post('/api/server-filters/generate-config', async (req, res) => {
console.log('Received generate-config request with filters:', req.body);
const { filters } = req.body;
if (!Array.isArray(filters) || filters.length === 0) {
console.log('No filters provided, returning empty config');
return res.json({ config: '// No filters to generate configuration' });
}
// Group filters by gateway
const gatewayGroups = {};
filters.forEach(filter => {
if (!gatewayGroups[filter.gateway]) {
gatewayGroups[filter.gateway] = [];
}
gatewayGroups[filter.gateway].push(filter.community);
});
console.log('Grouped filters by gateway:', gatewayGroups);
let config = '// Frouting filter configuration for MikroTik 7.14+\n';
config += '// Generated automatically\n';
config += `// Date: ${new Date().toISOString()}\n\n`;
config += '/routing filter bgp-in-tmp {\n';
// Строим вложенные if/else (RouterOS не поддерживает else if в данном контексте)
config += buildNestedGatewayBlocks(gatewayGroups, 4);
config += '}\n';
console.log('Generated config:', config);
console.log('Sending response:', { config });
res.json({ config });
});
// Update server filters
app.post('/api/server-filters/:serverId', async (req, res) => {
const { serverId } = req.params;
const { filters } = req.body;
// Validate filters structure
if (!Array.isArray(filters)) {
return res.status(400).send('Filters must be an array');
}
// Validate each filter has required fields
for (let i = 0; i < filters.length; i++) {
const filter = filters[i];
if (!filter.community || !filter.gateway) {
return res.status(400).send(`Filter at index ${i} is missing required fields`);
}
}
const params = {
Bucket: BUCKET_NAME,
Key: `filter-manager/server-filters-${serverId}.json`,
Body: JSON.stringify(filters, null, 2), // Pretty print JSON
ContentType: 'application/json',
};
try {
await s3.putObject(params).promise();
res.send('Server filters updated successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error writing server filters to S3');
}
});
// --- Simple Filters Routes ---
// Get simple filters
app.get('/api/simple-filters', async (req, res) => {
const params = {
Bucket: BUCKET_NAME,
Key: 'filter-manager/simple-filters.json',
};
try {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
let filters = [];
try {
filters = JSON.parse(fileContent);
if (!Array.isArray(filters)) {
filters = [];
}
} catch (parseError) {
console.error('Error parsing simple filters:', parseError);
filters = [];
}
res.json(filters);
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json([]); // Return empty array if file does not exist
} else {
console.error(error);
res.status(500).send('Error reading simple filters from S3');
}
}
});
// Update simple filters
app.post('/api/simple-filters', async (req, res) => {
const { filters } = req.body;
// Validate filters structure
if (!Array.isArray(filters)) {
return res.status(400).send('Filters must be an array');
}
// Validate each filter has required fields
for (let i = 0; i < filters.length; i++) {
const filter = filters[i];
if (!filter.community || !filter.gateway) {
return res.status(400).send(`Filter at index ${i} is missing required fields`);
}
}
const params = {
Bucket: BUCKET_NAME,
Key: 'filter-manager/simple-filters.json',
Body: JSON.stringify(filters, null, 2), // Pretty print JSON
ContentType: 'application/json',
};
try {
await s3.putObject(params).promise();
res.send('Simple filters updated successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error writing simple filters to S3');
}
});
// --- Auto URL Routes ---
// Get auto URLs from S3
app.get('/api/auto-urls', async (req, res) => {
const params = {
Bucket: BUCKET_NAME,
Key: 'bgp_data/auto_url/urls.txt',
};
try {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
const urls = fileContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
const url = parts[0] || '';
const community = parts[1] || '';
return { url, community };
});
res.json(urls);
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json([]); // Return empty array if file does not exist
} else {
console.error(error);
res.status(500).send('Error reading auto URLs from S3');
}
}
});
// --- Servers availability check ---
// Optimized TCP check: parallelize ports and hosts, cap per-host time, add in-memory TTL cache
function tcpCheck(host, port, timeoutMs) {
return new Promise((resolve) => {
const socket = new net.Socket();
let settled = false;
const settle = (ok) => { if (!settled) { settled = true; try { socket.destroy(); } catch {} resolve(ok); } };
socket.setTimeout(timeoutMs, () => settle(false));
socket.once('error', () => settle(false));
socket.connect(port, host, () => settle(true));
});
}
function anyTrue(promises) {
return new Promise((resolve) => {
if (!Array.isArray(promises) || promises.length === 0) return resolve(false);
let remaining = promises.length;
let resolved = false;
for (const p of promises) {
Promise.resolve(p).then((v) => {
if (v && !resolved) { resolved = true; resolve(true); }
}).finally(() => {
remaining -= 1;
if (remaining === 0 && !resolved) resolve(false);
});
}
});
}
async function checkOneServerFast(srv, perSocketTimeoutMs = 800, perServerBudgetMs = 1000) {
const hosts = [];
if (srv.ip) hosts.push(String(srv.ip));
if (srv.dns) hosts.push(String(srv.dns));
const tryOneHost = (host) => anyTrue([
// try common ports simultaneously
tcpCheck(host, 443, perSocketTimeoutMs),
tcpCheck(host, 80, perSocketTimeoutMs),
]);
const run = anyTrue(hosts.map((h) => tryOneHost(h)));
// Per-server overall budget
const timeout = new Promise((resolve) => setTimeout(() => resolve(false), perServerBudgetMs));
return Promise.race([run, timeout]);
}
const availabilityCache = { at: 0, data: null };
app.get('/api/servers/availability', async (req, res) => {
try {
const ttlSeconds = Math.max(0, Math.min(300, Number(req.query.ttlSeconds) || 30));
const now = Date.now();
if (availabilityCache.data && (now - availabilityCache.at) < ttlSeconds * 1000) {
return res.json({ ...availabilityCache.data, cached: true });
}
const data = await s3.getObject({ Bucket: BUCKET_NAME, Key: 'servers.json' }).promise();
let servers = [];
try {
servers = JSON.parse(data.Body.toString('utf-8'));
if (!Array.isArray(servers)) servers = [];
} catch {
servers = [];
}
const checks = await Promise.allSettled(servers.map((s) => checkOneServerFast(s)));
const statuses = servers.map((s, i) => ({ ip: s.ip, dns: s.dns, online: checks[i].status === 'fulfilled' ? Boolean(checks[i].value) : false }));
const online = statuses.filter((x) => x.online).length;
const payload = { online, total: servers.length, statuses };
availabilityCache.at = Date.now();
availabilityCache.data = payload;
res.json(payload);
} catch (e) {
console.error('availability error', e);
res.status(500).json({ online: 0, total: 0, statuses: [] });
}
});
// Update auto URLs in S3
app.post('/api/auto-urls', async (req, res) => {
const { urls } = req.body;
const fileContent = urls.map(u => `${u.url} ${u.community}`).join('\n');
const params = {
Bucket: BUCKET_NAME,
Key: 'bgp_data/auto_url/urls.txt',
Body: fileContent,
ContentType: 'text/plain',
};
try {
await s3.putObject(params).promise();
res.send('Auto URLs updated successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error writing auto URLs to S3');
}
});
// Process auto URLs and update IPs
app.post('/api/auto-urls/process', async (req, res) => {
const https = require('https');
const http = require('http');
try {
// Get current auto URLs
const urlsParams = {
Bucket: BUCKET_NAME,
Key: 'bgp_data/auto_url/urls.txt',
};
let urls = [];
try {
const urlsData = await s3.getObject(urlsParams).promise();
const urlsContent = urlsData.Body.toString('utf-8');
urls = urlsContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
return { url: parts[0] || '', community: parts[1] || '' };
});
} catch (error) {
if (error.code !== 'NoSuchKey') {
throw error;
}
}
if (urls.length === 0) {
return res.status(400).send('No URLs to process');
}
// Get current IPs
const ipsParams = {
Bucket: BUCKET_NAME,
Key: 'bgp_data/ips.txt',
};
let currentIps = [];
try {
const ipsData = await s3.getObject(ipsParams).promise();
const ipsContent = ipsData.Body.toString('utf-8');
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;
}
}
// Process each URL
const newIps = [];
for (const urlData of urls) {
try {
const url = urlData.url.trim();
const community = urlData.community.trim();
if (!url || !community) continue;
// Download content from URL
const content = await new Promise((resolve, reject) => {
const protocol = url.startsWith('https:') ? https : http;
const req = protocol.get(url, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => resolve(data));
});
req.on('error', reject);
req.setTimeout(10000, () => req.destroy());
});
// Parse IPs from content
const lines = content.split('\n');
for (const line of lines) {
const ip = line.trim();
if (ip && (ip.includes('.') || ip.includes(':'))) {
newIps.push({ ipRange: ip, community });
}
}
} catch (error) {
console.error(`Error processing URL ${urlData.url}:`, error);
}
}
// Merge with existing IPs (avoid duplicates)
const existingIpRanges = new Set(currentIps.map(ip => ip.ipRange));
const uniqueNewIps = newIps.filter(ip => !existingIpRanges.has(ip.ipRange));
const allIps = [...currentIps, ...uniqueNewIps];
// Save updated IPs
const updatedIpsContent = allIps.map(ip => `${ip.ipRange} ${ip.community}`).join('\n');
const updateParams = {
Bucket: BUCKET_NAME,
Key: 'bgp_data/ips.txt',
Body: updatedIpsContent,
ContentType: 'text/plain',
};
await s3.putObject(updateParams).promise();
res.json({
success: true,
message: `Обработано ${urls.length} URL, добавлено ${uniqueNewIps.length} новых IP-адресов`,
processedUrls: urls.length,
newIpsCount: uniqueNewIps.length,
totalIpsCount: allIps.length
});
} catch (error) {
console.error('Error processing auto URLs:', error);
res.status(500).send('Error processing auto URLs');
}
});
// The "catchall" handler: for any request that doesn't
// match one above, send back React's index.html file.
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Централизованный error handler (последний middleware)
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
const status = typeof err?.status === 'number' ? err.status : 500;
const code = err?.code || 'E_INTERNAL';
const message = status === 500 && process.env.NODE_ENV === 'production' ? 'Internal Server Error' : (err?.message || 'Error');
try { console.error('error:', err); } catch {}
res.status(status).json({ message, code });
});
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});