Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
2267 lines
85 KiB
JavaScript
2267 lines
85 KiB
JavaScript
// History endpoints are registered below after app is initialized
|
|
require('dotenv').config();
|
|
const express = require('express');
|
|
const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand, CopyObjectCommand, ListObjectVersionsCommand } = require('@aws-sdk/client-s3');
|
|
const { NodeHttpHandler } = require('@smithy/node-http-handler');
|
|
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 pino = require('pino');
|
|
const pinoHttp = require('pino-http');
|
|
const promClient = require('prom-client');
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const { URL } = require('url');
|
|
|
|
const app = express();
|
|
const port = Number(process.env.PORT) || 3001;
|
|
|
|
// Logger with requestId
|
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
|
app.use(pinoHttp({
|
|
logger,
|
|
genReqId: (req) => req.headers['x-request-id'] || crypto.randomBytes(8).toString('hex'),
|
|
serializers: {
|
|
req(req) { return { id: req.id, method: req.method, url: req.url }; },
|
|
res(res) { return { statusCode: res.statusCode }; },
|
|
},
|
|
}));
|
|
|
|
// CORS: по умолчанию максимально разрешаем, можно сузить через CORS_ORIGINS
|
|
const allowed = (process.env.CORS_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
if (allowed.length > 0) {
|
|
app.use(cors({
|
|
origin: (origin, cb) => {
|
|
if (!origin || allowed.includes(origin)) return cb(null, true);
|
|
return cb(new Error('CORS blocked'));
|
|
},
|
|
credentials: true,
|
|
}));
|
|
} else {
|
|
app.use(cors({ origin: true, credentials: true }));
|
|
}
|
|
// Разрешаем preflight для всех путей
|
|
app.options('*', cors());
|
|
app.use(helmet({
|
|
contentSecurityPolicy: false,
|
|
crossOriginEmbedderPolicy: false,
|
|
crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' },
|
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
}));
|
|
// Если приложение работает за прокси/ингрессом (Docker/NGINX), доверяем первому прокси для корректной работы rate-limit
|
|
app.set('trust proxy', 1);
|
|
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({ limit: process.env.JSON_LIMIT || '1mb' }));
|
|
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();
|
|
});
|
|
|
|
// --- Metrics ---
|
|
promClient.collectDefaultMetrics();
|
|
const httpDuration = new promClient.Histogram({ name: 'http_request_duration_seconds', help: 'HTTP request duration', labelNames: ['method', 'route', 'code'], buckets: [0.05,0.1,0.2,0.5,1,2,5] });
|
|
const httpErrors = new promClient.Counter({ name: 'http_errors_total', help: 'HTTP error count', labelNames: ['route','code'] });
|
|
const s3Duration = new promClient.Histogram({ name: 's3_request_duration_seconds', help: 'S3 request duration', labelNames: ['op'], buckets: [0.01,0.05,0.1,0.2,0.5,1,2] });
|
|
const http304 = new promClient.Counter({ name: 'http_304_total', help: 'HTTP 304 responses' });
|
|
const http412 = new promClient.Counter({ name: 'http_412_total', help: 'HTTP 412 responses' });
|
|
const http423 = new promClient.Counter({ name: 'http_423_total', help: 'HTTP 423 responses' });
|
|
app.use((req, res, next) => {
|
|
const start = process.hrtime.bigint();
|
|
res.on('finish', () => {
|
|
try {
|
|
const dur = Number(process.hrtime.bigint() - start) / 1e9;
|
|
httpDuration.labels(req.method, req.route?.path || req.path, String(res.statusCode)).observe(dur);
|
|
if (res.statusCode >= 400) httpErrors.labels(req.route?.path || req.path, String(res.statusCode)).inc();
|
|
} catch {}
|
|
});
|
|
next();
|
|
});
|
|
|
|
app.get('/health', (req, res) => res.json({ ok: true }));
|
|
app.get('/ready', (req, res) => res.json({ ok: true }));
|
|
app.get('/metrics', async (req, res) => {
|
|
try {
|
|
res.set('Content-Type', promClient.register.contentType);
|
|
res.end(await promClient.register.metrics());
|
|
} catch (e) {
|
|
res.status(500).end(String(e?.message || e));
|
|
}
|
|
});
|
|
|
|
// Version endpoint (static env-based)
|
|
app.get('/api/version', (req, res) => {
|
|
res.json({
|
|
version: process.env.APP_VERSION || null,
|
|
gitSha: process.env.GIT_SHA || null,
|
|
buildAt: process.env.BUILD_AT || null
|
|
});
|
|
});
|
|
|
|
// Централизованный обработчик ошибок (должен быть подключён ПОСЛЕ роутов — см. ниже второе 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.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
|
|
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) {
|
|
const requestId = res.req?.id;
|
|
try { if (status === 304) http304.inc(); if (status === 412) http412.inc(); if (status === 423) http423.inc(); } catch {}
|
|
return res.status(status).json({ code, message, details, requestId });
|
|
}
|
|
|
|
// 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,
|
|
}));
|
|
}
|
|
|
|
// Helpers
|
|
function splitWhitespace(line) {
|
|
return String(line || '').trim().split(/\s+/);
|
|
}
|
|
|
|
// Serve static files from the React app
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
// Configure AWS S3 (SDK v3)
|
|
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,
|
|
}
|
|
});
|
|
// Default Cache-Control for GETs
|
|
const DEFAULT_CACHE_TTL = Math.max(0, Math.min(300, Number(process.env.CACHE_TTL_SECONDS) || 30));
|
|
app.use((req, res, next) => {
|
|
if (req.method === 'GET') {
|
|
res.set('Cache-Control', `private, max-age=${DEFAULT_CACHE_TTL}`);
|
|
}
|
|
next();
|
|
});
|
|
|
|
|
|
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);
|
|
}
|
|
|
|
// In-memory LRU-ish cache for small texts and head meta
|
|
const s3Cache = { text: new Map(), head: new Map(), max: 100, ttlMs: 30_000 };
|
|
const countOnlyCache = { map: new Map(), ttlMs: 10_000 };
|
|
function getCache(map, key) {
|
|
const v = map.get(key);
|
|
if (!v) return null;
|
|
if (Date.now() > v.at + s3Cache.ttlMs) { map.delete(key); return null; }
|
|
return v.value;
|
|
}
|
|
function setCache(map, key, value) {
|
|
if (map.size >= s3Cache.max) { const firstKey = map.keys().next().value; if (firstKey) map.delete(firstKey); }
|
|
map.set(key, { value, at: Date.now() });
|
|
}
|
|
|
|
function invalidateCacheForKey(key) {
|
|
try { s3Cache.text.delete(key); } catch {}
|
|
try { s3Cache.head.delete(key); } catch {}
|
|
}
|
|
|
|
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 checkIfNoneMatch(req, res, etag) {
|
|
const inm = req.headers && (req.headers['if-none-match'] || req.headers['If-None-Match']);
|
|
if (inm && etag && String(inm) === String(etag)) {
|
|
try { http304.inc(); } catch {}
|
|
res.status(304).end();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Helper: read text file from S3 and return { body, etag, lastModified, contentLength }
|
|
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')));
|
|
});
|
|
}
|
|
|
|
async function readS3TextObject(key) {
|
|
const cached = getCache(s3Cache.text, key);
|
|
if (cached) return cached;
|
|
const s3Start = Date.now();
|
|
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
|
|
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(s3Cache.text, key, out);
|
|
return out;
|
|
}
|
|
|
|
// Helper: head object and return current ETag
|
|
async function headS3ObjectEtag(key) {
|
|
const cached = getCache(s3Cache.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 }));
|
|
try { s3Duration.labels('headObject').observe((Date.now() - s3Start)/1000); } catch {}
|
|
setCache(s3Cache.head, key, { etag: head.ETag || undefined });
|
|
return head.ETag || 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());
|
|
};
|
|
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));
|
|
}
|
|
// Не прерываем поток раньше конца, чтобы корректно посчитать total
|
|
}
|
|
});
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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 (supports countOnly/std and If-None-Match)
|
|
app.get('/api/domains', async (req, res) => {
|
|
const { q = '', offset, limit, countOnly, format } = req.query || {};
|
|
try {
|
|
if (countOnly === 'true') {
|
|
const cacheKey = `domains:count:${q}`;
|
|
const cached = getCountOnlyCache(cacheKey);
|
|
if (cached != null) return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached });
|
|
const { total } = await streamPaginatedText({ key: FILE_KEY, q, offset: 0, limit: 0, mapLine: () => ({}) });
|
|
setCountOnlyCache(cacheKey, total);
|
|
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
|
|
} else if (Number(limit) > 0) {
|
|
const { items, total } = await streamPaginatedText({
|
|
key: FILE_KEY,
|
|
q,
|
|
offset: Number(offset) || 0,
|
|
limit: Number(limit) || 0,
|
|
mapLine: (line) => {
|
|
const parts = splitWhitespace(line);
|
|
return { domain: parts[0] || '', type: parts[1] || '' };
|
|
}
|
|
});
|
|
if (!validateAsns(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: FILE_KEY })).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; // 304
|
|
|
|
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: FILE_KEY }));
|
|
const fileContent = await streamToString(data.Body);
|
|
const domains = fileContent.split('\n').filter(line => line).map(line => {
|
|
const parts = splitWhitespace(line);
|
|
const domain = parts[0] || '';
|
|
const type = parts[1] || '';
|
|
return { domain, type };
|
|
});
|
|
if (format === 'std') return res.json({ items: domains, total: domains.length, meta: {} });
|
|
res.json(domains);
|
|
}
|
|
} catch (error) {
|
|
if (error?.name === 'NotFound' || error?.$metadata?.httpStatusCode === 404) {
|
|
res.json([]);
|
|
} 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)) {
|
|
const meta = await headMeta(FILE_KEY);
|
|
return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta });
|
|
}
|
|
}
|
|
} 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 {
|
|
await s3.send(new PutObjectCommand(params));
|
|
invalidateCacheForKey(FILE_KEY);
|
|
const meta = await headMeta(FILE_KEY);
|
|
return sendOk(res, meta);
|
|
} catch (error) {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error writing to S3', 'E_S3');
|
|
}
|
|
});
|
|
|
|
// --- ASNs Routes ---
|
|
|
|
// Get ASNs from S3
|
|
app.get('/api/asns', async (req, res) => {
|
|
const { q = '', offset, limit, countOnly, format } = req.query || {};
|
|
const params = {
|
|
Bucket: BUCKET_NAME,
|
|
Key: 'bgp_data/asns.txt',
|
|
};
|
|
|
|
try {
|
|
if (countOnly === 'true') {
|
|
const cacheKey = `asns:count:${q}`;
|
|
const cached = getCountOnlyCache(cacheKey);
|
|
if (cached != null) return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached });
|
|
const { total } = await streamPaginatedText({ key: 'bgp_data/asns.txt', q, offset: 0, limit: 0, mapLine: () => ({}) });
|
|
setCountOnlyCache(cacheKey, total);
|
|
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
|
|
} else if (Number(limit) > 0) {
|
|
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(format === 'std' ? { items, total, meta: {} } : { items, total });
|
|
} else {
|
|
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/asns.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(params));
|
|
const fileContent = await streamToString(data.Body);
|
|
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 (format === 'std') return res.json({ items: asns, total: asns.length, meta: {} });
|
|
res.json(asns);
|
|
}
|
|
} catch (error) {
|
|
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
|
res.json([]);
|
|
} else {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error reading from S3', 'E_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;
|
|
const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null;
|
|
try { current = await headS3ObjectEtag('bgp_data/asns.txt'); } catch {}
|
|
if (current && (ifMatch || etag) && current !== String(ifMatch || 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 {
|
|
await s3.send(new PutObjectCommand(params));
|
|
invalidateCacheForKey('bgp_data/asns.txt');
|
|
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, format } = req.query || {};
|
|
const params = {
|
|
Bucket: BUCKET_NAME,
|
|
Key: 'bgp_data/domains_community.txt',
|
|
};
|
|
|
|
try {
|
|
if (countOnly === 'true') {
|
|
const cacheKey = `domains-new:count:${q}`;
|
|
const cached = getCountOnlyCache(cacheKey);
|
|
if (cached != null) return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached });
|
|
const { total } = await streamPaginatedText({ key: 'bgp_data/domains_community.txt', q, offset: 0, limit: 0, mapLine: () => ({}) });
|
|
setCountOnlyCache(cacheKey, total);
|
|
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
|
|
} else if (Number(limit) > 0) {
|
|
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(format === 'std' ? { items, total, meta: {} } : { items, total });
|
|
} else {
|
|
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.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(params));
|
|
const fileContent = await streamToString(data.Body);
|
|
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 (format === 'std') return res.json({ items: domains, total: domains.length, meta: {} });
|
|
res.json(domains);
|
|
}
|
|
} catch (error) {
|
|
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
|
res.json([]); // Return empty array if file does not exist
|
|
} else {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error reading from S3', 'E_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;
|
|
const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null;
|
|
try { current = await headS3ObjectEtag('bgp_data/domains_community.txt'); } catch {}
|
|
if (current && (ifMatch || etag) && current !== String(ifMatch || 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 {
|
|
await s3.send(new PutObjectCommand(params));
|
|
invalidateCacheForKey('bgp_data/domains_community.txt');
|
|
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, format } = req.query || {};
|
|
const params = {
|
|
Bucket: BUCKET_NAME,
|
|
Key: 'bgp_data/ips.txt',
|
|
};
|
|
|
|
try {
|
|
if (countOnly === 'true') {
|
|
const cacheKey = `ip-ranges:count:${q}`;
|
|
const cached = getCountOnlyCache(cacheKey);
|
|
if (cached != null) return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached });
|
|
const { total } = await streamPaginatedText({ key: 'bgp_data/ips.txt', q, offset: 0, limit: 0, mapLine: () => ({}) });
|
|
setCountOnlyCache(cacheKey, total);
|
|
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
|
|
} else if (Number(limit) > 0) {
|
|
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(format === 'std' ? { items, total, meta: {} } : { items, total });
|
|
} else {
|
|
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/ips.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(params));
|
|
const fileContent = await streamToString(data.Body);
|
|
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 (format === 'std') return res.json({ items: ipRanges, total: ipRanges.length, meta: {} });
|
|
res.json(ipRanges);
|
|
}
|
|
} catch (error) {
|
|
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
|
res.json([]); // Return empty array if file does not exist
|
|
} else {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error reading from S3', 'E_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;
|
|
const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null;
|
|
try { current = await headS3ObjectEtag('bgp_data/ips.txt'); } catch {}
|
|
if (current && (ifMatch || etag) && current !== String(ifMatch || 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 {
|
|
await s3.send(new PutObjectCommand(params));
|
|
invalidateCacheForKey('bgp_data/ips.txt');
|
|
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 head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/communities.json' })).catch(() => null);
|
|
const etag = head?.ETag || null;
|
|
if (etag) res.set('ETag', String(etag));
|
|
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
|
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
|
if (checkIfNoneMatch(req, res, etag)) return;
|
|
|
|
const data = await s3.send(new GetObjectCommand(params));
|
|
const fileContent = await streamToString(data.Body);
|
|
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);
|
|
return sendError(res, 500, 'Error reading communities from S3', 'E_S3');
|
|
}
|
|
});
|
|
|
|
// Update communities in S3
|
|
app.post('/api/communities', async (req, res) => {
|
|
const { communities } = req.body;
|
|
|
|
if (!Array.isArray(communities)) {
|
|
return sendError(res, 400, 'communities must be an array', 'E_BAD_REQUEST');
|
|
}
|
|
|
|
// 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 sendError(res, 400, `Community at index ${i} is missing required field: value`, 'E_SCHEMA');
|
|
}
|
|
if (seen.has(value)) {
|
|
return sendError(res, 400, `Duplicate community value at index ${i}: ${value}`, 'E_SCHEMA');
|
|
}
|
|
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.send(new PutObjectCommand(params));
|
|
invalidateCacheForKey('bgp_data/communities.json');
|
|
const meta = await headMeta('bgp_data/communities.json');
|
|
return sendOk(res, meta);
|
|
} catch (error) {
|
|
console.error('Error writing communities to S3:', error);
|
|
return sendError(res, 500, 'Error writing communities to S3', 'E_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 head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'servers.json' })).catch(() => null);
|
|
const etag = head?.ETag || null;
|
|
if (etag) res.set('ETag', String(etag));
|
|
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
|
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
|
if (checkIfNoneMatch(req, res, etag)) return;
|
|
|
|
const data = await s3.send(new GetObjectCommand(params));
|
|
const fileContent = await streamToString(data.Body);
|
|
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);
|
|
return sendError(res, 500, 'Error reading from S3', 'E_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 sendError(res, 400, 'Servers must be an array', 'E_BAD_REQUEST');
|
|
}
|
|
|
|
// 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 sendError(res, 400, `Server at index ${i} is missing required fields`, 'E_SCHEMA');
|
|
}
|
|
}
|
|
|
|
const params = {
|
|
Bucket: BUCKET_NAME,
|
|
Key: 'servers.json',
|
|
Body: JSON.stringify(servers, null, 2), // Pretty print JSON
|
|
ContentType: 'application/json',
|
|
};
|
|
|
|
try {
|
|
await s3.send(new PutObjectCommand(params));
|
|
invalidateCacheForKey('servers.json');
|
|
const meta = await headMeta('servers.json');
|
|
return sendOk(res, meta);
|
|
} catch (error) {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error writing to S3', 'E_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 head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'servers-billing.json' })).catch(() => null);
|
|
const etag = head?.ETag || null;
|
|
if (etag) res.set('ETag', String(etag));
|
|
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
|
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
|
if (checkIfNoneMatch(req, res, etag)) return;
|
|
|
|
const data = await s3.send(new GetObjectCommand(params));
|
|
const fileContent = await streamToString(data.Body);
|
|
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);
|
|
return sendError(res, 500, 'Error reading from S3', 'E_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 sendError(res, 400, 'Billing data must be an array', 'E_BAD_REQUEST');
|
|
}
|
|
|
|
// 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 sendError(res, 400, `Billing item at index ${i} is missing required fields: hostName, country, provider`, 'E_SCHEMA');
|
|
}
|
|
}
|
|
|
|
const params = {
|
|
Bucket: BUCKET_NAME,
|
|
Key: 'servers-billing.json',
|
|
Body: JSON.stringify(billingData, null, 2), // Pretty print JSON
|
|
ContentType: 'application/json',
|
|
};
|
|
|
|
try {
|
|
await s3.send(new PutObjectCommand(params));
|
|
invalidateCacheForKey('servers-billing.json');
|
|
const meta = await headMeta('servers-billing.json');
|
|
return sendOk(res, meta);
|
|
} catch (error) {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error writing to S3', 'E_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 head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'filters.json' })).catch(() => null);
|
|
const etag = head?.ETag || null;
|
|
if (etag) res.set('ETag', String(etag));
|
|
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
|
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
|
if (checkIfNoneMatch(req, res, etag)) return;
|
|
|
|
const data = await s3.send(new GetObjectCommand(params));
|
|
const fileContent = await streamToString(data.Body);
|
|
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);
|
|
return sendError(res, 500, 'Error reading from S3', 'E_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 sendError(res, 400, 'Filters must be an array', 'E_BAD_REQUEST');
|
|
}
|
|
|
|
// Validate each filter has required fields
|
|
for (let i = 0; i < filters.length; i++) {
|
|
const filter = filters[i];
|
|
if (!filter.community || !filter.gateway) {
|
|
return sendError(res, 400, `Filter at index ${i} is missing required fields`, 'E_SCHEMA');
|
|
}
|
|
}
|
|
|
|
const params = {
|
|
Bucket: BUCKET_NAME,
|
|
Key: 'filters.json',
|
|
Body: JSON.stringify(filters, null, 2), // Pretty print JSON
|
|
ContentType: 'application/json',
|
|
};
|
|
|
|
try {
|
|
await s3.send(new PutObjectCommand(params));
|
|
invalidateCacheForKey('filters.json');
|
|
const meta = await headMeta('filters.json');
|
|
return sendOk(res, meta);
|
|
} catch (error) {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error writing to S3', 'E_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' },
|
|
{ name: 'uiSettings', key: 'bgp_data/rt_ui_settings.json' }
|
|
];
|
|
const results = await Promise.allSettled(
|
|
keys.map(k => s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: k.key })))
|
|
);
|
|
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);
|
|
return sendError(res, 500, 'Error fetching last modified dates from S3', 'E_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 sendError(res, 423, 'Resource is locked by another user', 'E_RESOURCE_LOCKED', { 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 { countOnly, format } = req.query || {};
|
|
const key = resourceToKey(resource);
|
|
if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE');
|
|
try {
|
|
const out = await s3.send(new ListObjectVersionsCommand({ Bucket: BUCKET_NAME, Prefix: key, MaxKeys: 50 }));
|
|
const versionsAll = (out.Versions || []).filter(v => v.Key === key);
|
|
if (countOnly === 'true') {
|
|
const total = versionsAll.length;
|
|
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
|
|
}
|
|
const versions = versionsAll.slice(0, 10).map(v => ({ versionId: v.VersionId, isLatest: v.IsLatest, lastModified: toIso(v.LastModified), size: v.Size, etag: v.ETag }));
|
|
return res.json(format === 'std' ? { items: versions, total: versionsAll.length, meta: {} } : { items: versions });
|
|
} catch (e) {
|
|
console.error('history error', e);
|
|
return sendError(res, 500, 'Error reading history', 'E_S3', { error: String(e?.message || e) });
|
|
}
|
|
});
|
|
|
|
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.send(new CopyObjectCommand({ Bucket: BUCKET_NAME, CopySource: `/${BUCKET_NAME}/${encodeURIComponent(key)}?versionId=${encodeURIComponent(versionId)}`, Key: key }));
|
|
const meta = await headMeta(key);
|
|
return sendOk(res, meta);
|
|
} catch (e) {
|
|
console.error('rollback error', e);
|
|
return sendError(res, 500, 'Error rollback', 'E_S3', { error: String(e?.message || e) });
|
|
}
|
|
});
|
|
|
|
// Generate MikroTik configuration from filters
|
|
app.get('/api/filters/generate-config', async (req, res) => {
|
|
const params = {
|
|
Bucket: BUCKET_NAME,
|
|
Key: 'filters.json',
|
|
};
|
|
|
|
try {
|
|
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'server-configs.json' })).catch(() => null);
|
|
const etag = head?.ETag || null;
|
|
if (etag) res.set('ETag', String(etag));
|
|
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
|
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
|
if (checkIfNoneMatch(req, res, etag)) return;
|
|
|
|
const data = await s3.send(new GetObjectCommand(params));
|
|
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 = [];
|
|
}
|
|
|
|
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);
|
|
return sendError(res, 500, 'Error generating configuration', 'E_S3');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Export MikroTik configuration to S3
|
|
app.post('/api/filters/export-config', async (req, res) => {
|
|
const params = {
|
|
Bucket: BUCKET_NAME,
|
|
Key: 'filters.json',
|
|
};
|
|
|
|
try {
|
|
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: `filter-manager/server-filters-${serverId}.json` })).catch(() => null);
|
|
const etag = head?.ETag || null;
|
|
if (etag) res.set('ETag', String(etag));
|
|
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
|
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
|
if (checkIfNoneMatch(req, res, etag)) return;
|
|
|
|
const data = await s3.send(new GetObjectCommand(params));
|
|
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 = [];
|
|
}
|
|
|
|
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.send(new PutObjectCommand(exportParams));
|
|
res.json({ success: true, message: 'Конфигурация экспортирована в S3' });
|
|
} catch (error) {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error exporting configuration', 'E_S3');
|
|
}
|
|
});
|
|
|
|
// --- 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 head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'filter-manager/simple-filters.json' })).catch(() => null);
|
|
const etag = head?.ETag || null;
|
|
if (etag) res.set('ETag', String(etag));
|
|
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
|
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
|
if (checkIfNoneMatch(req, res, etag)) return;
|
|
|
|
const data = await s3.send(new GetObjectCommand(params));
|
|
const fileContent = await streamToString(data.Body);
|
|
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);
|
|
return sendError(res, 500, 'Error reading server configs from S3', 'E_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 sendError(res, 400, 'Servers must be an array', 'E_BAD_REQUEST');
|
|
}
|
|
|
|
// Validate each server has required fields
|
|
for (let i = 0; i < servers.length; i++) {
|
|
const server = servers[i];
|
|
if (!server.id || !server.name) {
|
|
return sendError(res, 400, `Server at index ${i} is missing required fields`, 'E_SCHEMA');
|
|
}
|
|
}
|
|
|
|
const params = {
|
|
Bucket: BUCKET_NAME,
|
|
Key: 'server-configs.json',
|
|
Body: JSON.stringify(servers, null, 2), // Pretty print JSON
|
|
ContentType: 'application/json',
|
|
};
|
|
|
|
try {
|
|
await s3.send(new PutObjectCommand(params));
|
|
invalidateCacheForKey('server-configs.json');
|
|
const meta = await headMeta('server-configs.json');
|
|
return sendOk(res, meta);
|
|
} catch (error) {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error writing server configs to S3', 'E_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 head = await s3.send(new HeadObjectCommand(params)).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(params));
|
|
const config = await streamToString(data.Body);
|
|
res.json({ config });
|
|
} catch (error) {
|
|
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
|
res.json({ config: '// Конфигурация не найдена' });
|
|
} else {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error reading server config from S3', 'E_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 sendError(res, 400, 'Config is required', 'E_BAD_REQUEST');
|
|
}
|
|
|
|
const params = {
|
|
Bucket: BUCKET_NAME,
|
|
Key: `filter-manager/config-${serverId}.txt`,
|
|
Body: config,
|
|
ContentType: 'text/plain',
|
|
};
|
|
|
|
try {
|
|
await s3.send(new PutObjectCommand(params));
|
|
invalidateCacheForKey(`filter-manager/config-${serverId}.txt`);
|
|
const meta = await headMeta(`filter-manager/config-${serverId}.txt`);
|
|
return sendOk(res, meta);
|
|
} catch (error) {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error writing server config to S3', 'E_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.send(new DeleteObjectCommand(params));
|
|
invalidateCacheForKey(`filter-manager/config-${serverId}.txt`);
|
|
const meta = await headMeta(`filter-manager/config-${serverId}.txt`);
|
|
return sendOk(res, meta);
|
|
} catch (error) {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error deleting server config from S3', 'E_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.send(new DeleteObjectCommand(configParams)),
|
|
s3.send(new DeleteObjectCommand(filtersParams))
|
|
]);
|
|
invalidateCacheForKey(configParams.Key);
|
|
invalidateCacheForKey(filtersParams.Key);
|
|
return res.json({ ok: true, etag: null, lastModified: null, contentLength: null });
|
|
} catch (error) {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error deleting server files from S3', 'E_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 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(params));
|
|
const fileContent = await streamToString(data.Body);
|
|
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);
|
|
return sendError(res, 500, 'Error reading server filters from S3', 'E_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 sendError(res, 400, 'Filters must be an array', 'E_BAD_REQUEST');
|
|
}
|
|
|
|
// Validate each filter has required fields
|
|
for (let i = 0; i < filters.length; i++) {
|
|
const filter = filters[i];
|
|
if (!filter.community || !filter.gateway) {
|
|
return sendError(res, 400, `Filter at index ${i} is missing required fields`, 'E_SCHEMA');
|
|
}
|
|
}
|
|
|
|
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.send(new PutObjectCommand(params));
|
|
invalidateCacheForKey(`filter-manager/server-filters-${serverId}.json`);
|
|
const meta = await headMeta(`filter-manager/server-filters-${serverId}.json`);
|
|
return sendOk(res, meta);
|
|
} catch (error) {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error writing server filters to S3', 'E_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 head = await s3.send(new HeadObjectCommand(params)).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(params));
|
|
const fileContent = await streamToString(data.Body);
|
|
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' || error?.$metadata?.httpStatusCode === 404) {
|
|
res.json([]); // Return empty array if file does not exist
|
|
} else {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error reading simple filters from S3', 'E_S3');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Update simple filters
|
|
app.post('/api/simple-filters', async (req, res) => {
|
|
const { filters } = req.body;
|
|
|
|
// Validate filters structure
|
|
if (!Array.isArray(filters)) {
|
|
return sendError(res, 400, 'Filters must be an array', 'E_BAD_REQUEST');
|
|
}
|
|
|
|
// Validate each filter has required fields
|
|
for (let i = 0; i < filters.length; i++) {
|
|
const filter = filters[i];
|
|
if (!filter.community || !filter.gateway) {
|
|
return sendError(res, 400, `Filter at index ${i} is missing required fields`, 'E_SCHEMA');
|
|
}
|
|
}
|
|
|
|
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.send(new PutObjectCommand(params));
|
|
invalidateCacheForKey('filter-manager/simple-filters.json');
|
|
const meta = await headMeta('filter-manager/simple-filters.json');
|
|
return sendOk(res, meta);
|
|
} catch (error) {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error writing simple filters to S3', 'E_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 head = await s3.send(new HeadObjectCommand(params)).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(params));
|
|
const fileContent = await streamToString(data.Body);
|
|
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' || error?.$metadata?.httpStatusCode === 404) {
|
|
res.json([]); // Return empty array if file does not exist
|
|
} else {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error reading auto URLs from S3', 'E_S3');
|
|
}
|
|
}
|
|
});
|
|
|
|
// --- UI Settings (rt_ui_settings.json in bgp_data) ---
|
|
// Get UI settings
|
|
app.get('/api/ui-settings', async (req, res) => {
|
|
const params = {
|
|
Bucket: BUCKET_NAME,
|
|
Key: 'bgp_data/rt_ui_settings.json',
|
|
};
|
|
|
|
try {
|
|
const head = await s3.send(new HeadObjectCommand(params)).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(params));
|
|
const jsonText = await streamToString(data.Body);
|
|
let settings = {};
|
|
try {
|
|
const parsed = JSON.parse(jsonText);
|
|
if (parsed && typeof parsed === 'object') settings = parsed;
|
|
} catch (parseError) {
|
|
settings = {};
|
|
}
|
|
return res.json(settings);
|
|
} catch (error) {
|
|
if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
|
return res.json({});
|
|
}
|
|
console.error('Error reading ui settings from S3:', error);
|
|
return sendError(res, 500, 'Error reading UI settings from S3', 'E_S3');
|
|
}
|
|
});
|
|
|
|
// Update UI settings
|
|
app.post('/api/ui-settings', async (req, res) => {
|
|
const { settings, etag } = req.body || {};
|
|
const payload = (settings && typeof settings === 'object') ? settings : {};
|
|
|
|
try {
|
|
// optimistic concurrency if ETag provided (or If-Match header)
|
|
let current = null;
|
|
const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null;
|
|
try { current = await headS3ObjectEtag('bgp_data/rt_ui_settings.json'); } catch {}
|
|
if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) {
|
|
const meta = await headMeta('bgp_data/rt_ui_settings.json');
|
|
return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta });
|
|
}
|
|
} catch {}
|
|
|
|
const params = {
|
|
Bucket: BUCKET_NAME,
|
|
Key: 'bgp_data/rt_ui_settings.json',
|
|
Body: JSON.stringify(payload, null, 2),
|
|
ContentType: 'application/json',
|
|
};
|
|
|
|
try {
|
|
await s3.send(new PutObjectCommand(params));
|
|
const meta = await headMeta('bgp_data/rt_ui_settings.json');
|
|
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) });
|
|
}
|
|
});
|
|
|
|
// --- 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.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'servers.json' }));
|
|
let servers = [];
|
|
try {
|
|
servers = JSON.parse(await streamToString(data.Body));
|
|
if (!Array.isArray(servers)) servers = [];
|
|
} catch {
|
|
servers = [];
|
|
}
|
|
|
|
const checks = await Promise.allSettled(servers.map((s) => checkOneServerFast(s)));
|
|
const statuses = servers.map((s, i) => ({ ip: s.ip, dns: s.dns, online: checks[i].status === 'fulfilled' ? Boolean(checks[i].value) : false }));
|
|
const online = statuses.filter((x) => x.online).length;
|
|
const payload = { online, total: servers.length, statuses };
|
|
availabilityCache.at = Date.now();
|
|
availabilityCache.data = payload;
|
|
res.json(payload);
|
|
} catch (e) {
|
|
console.error('availability error', e);
|
|
res.status(500).json({ online: 0, total: 0, statuses: [] });
|
|
}
|
|
});
|
|
|
|
// 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.send(new PutObjectCommand(params));
|
|
invalidateCacheForKey('bgp_data/auto_url/urls.txt');
|
|
const meta = await headMeta('bgp_data/auto_url/urls.txt');
|
|
return sendOk(res, meta);
|
|
} catch (error) {
|
|
console.error(error);
|
|
return sendError(res, 500, 'Error writing auto URLs to S3', 'E_S3');
|
|
}
|
|
});
|
|
|
|
// Process auto URLs and update IPs
|
|
app.post('/api/auto-urls/process', async (req, res) => {
|
|
const https = require('https');
|
|
const http = require('http');
|
|
|
|
// Helpers
|
|
const isValidIPv4 = (ip) => {
|
|
const octets = String(ip || '').trim().split('.');
|
|
if (octets.length !== 4) return false;
|
|
return octets.every(o => /^\d{1,3}$/.test(o) && Number(o) >= 0 && Number(o) <= 255);
|
|
};
|
|
const isValidCidrV4 = (value) => {
|
|
const v = String(value || '').trim();
|
|
const parts = v.split('/');
|
|
if (parts.length !== 2) return false;
|
|
const [ip, mask] = parts;
|
|
if (!isValidIPv4(ip)) return false;
|
|
if (!/^\d{1,2}$/.test(mask)) return false;
|
|
const m = Number(mask);
|
|
return m >= 0 && m <= 32;
|
|
};
|
|
const isValidDomain = (value) => {
|
|
const v = String(value || '').trim().toLowerCase();
|
|
if (v.startsWith('#')) return false; // comment line
|
|
return /^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(v);
|
|
};
|
|
|
|
try {
|
|
// Load configured auto URLs
|
|
const urlsParams = {
|
|
Bucket: BUCKET_NAME,
|
|
Key: 'bgp_data/auto_url/urls.txt',
|
|
};
|
|
|
|
let urls = [];
|
|
try {
|
|
const urlsData = await s3.send(new GetObjectCommand(urlsParams));
|
|
const urlsContent = await streamToString(urlsData.Body);
|
|
urls = urlsContent.split('\n').filter(line => line).map(line => {
|
|
const parts = line.trim().split(/\s+/);
|
|
return { url: parts[0] || '', community: parts[1] || '' };
|
|
});
|
|
} catch (error) {
|
|
if (error.code !== 'NoSuchKey') {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (urls.length === 0) {
|
|
return sendError(res, 400, 'No URLs to process', 'E_BAD_REQUEST');
|
|
}
|
|
|
|
// Load current IP ranges
|
|
const ipsParams = { Bucket: BUCKET_NAME, Key: 'bgp_data/ips.txt' };
|
|
let currentIps = [];
|
|
try {
|
|
const ipsData = await s3.send(new GetObjectCommand(ipsParams));
|
|
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;
|
|
}
|
|
|
|
// Load current domains
|
|
const domainsParams = { Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' };
|
|
let currentDomains = [];
|
|
try {
|
|
const dData = await s3.send(new GetObjectCommand(domainsParams));
|
|
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;
|
|
}
|
|
|
|
// Process each URL
|
|
const newIps = [];
|
|
const newDomains = [];
|
|
for (const urlData of urls) {
|
|
try {
|
|
const url = String(urlData.url || '').trim();
|
|
const community = String(urlData.community || '').trim();
|
|
if (!url || !community) continue;
|
|
|
|
// Download content
|
|
const content = await new Promise((resolve, reject) => {
|
|
const protocol = url.startsWith('https:') ? https : http;
|
|
const req = protocol.get(url, (r) => {
|
|
let data = '';
|
|
r.on('data', (chunk) => { data += chunk; });
|
|
r.on('end', () => resolve(data));
|
|
});
|
|
req.on('error', reject);
|
|
req.setTimeout(15000, () => req.destroy());
|
|
});
|
|
|
|
const lines = content.split('\n');
|
|
for (const raw of lines) {
|
|
const line = String(raw || '').trim();
|
|
if (!line) continue;
|
|
if (line.startsWith('#') || line.startsWith('//')) continue;
|
|
const token = line.split(/\s+/)[0]?.trim();
|
|
if (!token) continue;
|
|
|
|
// Decide destination
|
|
if (isValidCidrV4(token)) {
|
|
newIps.push({ ipRange: token, community });
|
|
continue;
|
|
}
|
|
if (isValidIPv4(token)) {
|
|
// single IPv4 → normalize to /32
|
|
newIps.push({ ipRange: `${token}/32`, community });
|
|
continue;
|
|
}
|
|
if (isValidDomain(token)) {
|
|
newDomains.push({ domain: token.toLowerCase(), community });
|
|
continue;
|
|
}
|
|
// ignore everything else
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error processing URL ${urlData.url}:`, error);
|
|
}
|
|
}
|
|
|
|
// Merge & deduplicate
|
|
const existingIpRanges = new Set(currentIps.map(i => i.ipRange));
|
|
const uniqueNewIps = newIps.filter(i => !existingIpRanges.has(i.ipRange));
|
|
const allIps = [...currentIps, ...uniqueNewIps];
|
|
|
|
const existingDomains = new Set(currentDomains.map(d => d.domain));
|
|
const uniqueNewDomains = newDomains.filter(d => !existingDomains.has(d.domain));
|
|
const allDomains = [...currentDomains, ...uniqueNewDomains];
|
|
|
|
// Save updated IPs
|
|
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' }));
|
|
|
|
// Save updated Domains
|
|
const updatedDomainsContent = allDomains.map(d => `${d.domain} ${d.community}`).join('\n');
|
|
await s3.send(new PutObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt', Body: updatedDomainsContent, ContentType: 'text/plain' }));
|
|
|
|
const msg = `Обработано ${urls.length} URL, добавлено ${uniqueNewIps.length} IP и ${uniqueNewDomains.length} доменов`;
|
|
return res.json({
|
|
success: true,
|
|
message: msg,
|
|
processedUrls: urls.length,
|
|
newIpsCount: uniqueNewIps.length,
|
|
newDomainsCount: uniqueNewDomains.length,
|
|
totalIpsCount: allIps.length,
|
|
totalDomainsCount: allDomains.length
|
|
});
|
|
} catch (error) {
|
|
console.error('Error processing auto URLs:', error);
|
|
return sendError(res, 500, 'Error processing auto URLs', 'E_S3');
|
|
}
|
|
});
|
|
|
|
// --- Proxy: Background BGP Update (avoids CORS from browser) ---
|
|
app.post('/api/update-bgp/background', async (req, res) => {
|
|
try {
|
|
const targetUrl = process.env.BGP_BACKGROUND_URL;
|
|
if (!targetUrl) {
|
|
return sendError(res, 500, 'BGP_BACKGROUND_URL is not configured', 'E_CONFIG');
|
|
}
|
|
const u = new URL(targetUrl);
|
|
const client = u.protocol === 'https:' ? https : http;
|
|
const options = {
|
|
method: 'POST',
|
|
hostname: u.hostname,
|
|
port: u.port || (u.protocol === 'https:' ? 443 : 80),
|
|
path: `${u.pathname}${u.search || ''}`,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
},
|
|
timeout: 15000,
|
|
};
|
|
|
|
const body = req.body && Object.keys(req.body).length ? JSON.stringify(req.body) : '';
|
|
|
|
const upstream = client.request(options, (r) => {
|
|
let data = '';
|
|
r.setEncoding('utf8');
|
|
r.on('data', (chunk) => { data += chunk; });
|
|
r.on('end', () => {
|
|
const status = r.statusCode || 502;
|
|
// Try to parse JSON; fallback to text
|
|
try {
|
|
const json = data ? JSON.parse(data) : {};
|
|
return res.status(status).json(json);
|
|
} catch (_) {
|
|
return res.status(status).json({ ok: status >= 200 && status < 300, data });
|
|
}
|
|
});
|
|
});
|
|
upstream.on('timeout', () => {
|
|
try { upstream.destroy(); } catch {}
|
|
return sendError(res, 504, 'Upstream timeout', 'E_UPSTREAM_TIMEOUT');
|
|
});
|
|
upstream.on('error', (e) => {
|
|
return sendError(res, 502, 'Upstream error', 'E_UPSTREAM', { error: String(e?.message || e) });
|
|
});
|
|
if (body) upstream.write(body);
|
|
upstream.end();
|
|
} catch (e) {
|
|
return sendError(res, 500, 'Proxy error', 'E_PROXY', { error: String(e?.message || e) });
|
|
}
|
|
});
|
|
|
|
// Provide ws url to UI from settings/env to avoid exposing keys in bundle
|
|
app.get('/api/ws/url', async (req, res) => {
|
|
try {
|
|
const settings = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/rt_ui_settings.json' })).then(async (d) => {
|
|
try { return JSON.parse(await streamToString(d.Body)); } catch { return {}; }
|
|
}).catch(() => ({}));
|
|
const url = settings?.wsUpdateUrl || process.env.WS_UPDATE_URL || '';
|
|
return res.json({ url });
|
|
} catch (e) {
|
|
return res.json({ url: '' });
|
|
}
|
|
});
|
|
|
|
// 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');
|
|
const details = err?.details;
|
|
const requestId = req?.id;
|
|
try { req.log?.error({ err, code, requestId }, 'request error'); } catch {}
|
|
res.status(status).json({ code, message, details, requestId });
|
|
});
|
|
|
|
app.listen(port, () => {
|
|
console.log(`Server is running on http://localhost:${port}`);
|
|
}); |