feat: Оптимизация маршрутов для получения данных из S3 с использованием вспомогательных функций getS3JsonWithHeaders и getS3TextWithHeaders для улучшения читаемости и обработки ошибок. Упрощение кода и улучшение обработки конфигураций серверов и фильтров.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m34s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m34s
This commit is contained in:
@@ -3,55 +3,32 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const { s3, BUCKET_NAME, writeS3JsonObject, invalidateCacheForKey } = require('../services/s3Service');
|
const { s3, BUCKET_NAME, writeS3JsonObject, invalidateCacheForKey } = require('../services/s3Service');
|
||||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||||
const { GetObjectCommand, HeadObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
|
const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
const { streamToString } = require('../services/s3Service');
|
const { streamToString } = require('../services/s3Service');
|
||||||
const validators = require('../lib/validators');
|
const validators = require('../lib/validators');
|
||||||
|
const { getS3JsonWithHeaders } = require('../utils/s3Helpers');
|
||||||
|
|
||||||
const S3_KEY = 'bgp_data/communities.json';
|
const S3_KEY = 'bgp_data/communities.json';
|
||||||
|
|
||||||
// GET /api/communities
|
// GET /api/communities
|
||||||
async function getCommunities(req, res) {
|
async function getCommunities(req, res) {
|
||||||
try {
|
await getS3JsonWithHeaders(S3_KEY, req, res, {
|
||||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
|
transform: (communities) => {
|
||||||
const etag = head?.ETag || null;
|
// Нормализация
|
||||||
if (etag) res.set('ETag', String(etag));
|
return communities
|
||||||
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
.filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0)
|
||||||
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
.map((c) => ({
|
||||||
if (checkIfNoneMatch(req, res, etag)) return;
|
value: String(c.value).trim(),
|
||||||
|
name: c.name ? String(c.name) : '',
|
||||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY }));
|
description: c.description ? String(c.description) : '',
|
||||||
const fileContent = await streamToString(data.Body);
|
tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
|
||||||
let communities = [];
|
gatewayDefault: c.gatewayDefault ? String(c.gatewayDefault) : '',
|
||||||
|
color: c.color ? String(c.color) : ''
|
||||||
try {
|
}));
|
||||||
const parsed = JSON.parse(fileContent);
|
},
|
||||||
communities = Array.isArray(parsed) ? parsed : [];
|
defaultValue: []
|
||||||
} catch (parseError) {
|
});
|
||||||
console.error('Error parsing communities.json:', parseError);
|
|
||||||
communities = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Нормализация
|
|
||||||
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') {
|
|
||||||
return res.json([]);
|
|
||||||
}
|
|
||||||
console.error('Error reading communities from S3:', error);
|
|
||||||
return sendError(res, 500, 'Error reading communities from S3', 'E_S3');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/communities
|
// POST /api/communities
|
||||||
|
|||||||
@@ -3,61 +3,25 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const { s3, BUCKET_NAME, writeS3TextObject, headMeta } = require('../services/s3Service');
|
const { s3, BUCKET_NAME, writeS3TextObject, headMeta } = require('../services/s3Service');
|
||||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||||
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
const { streamToString } = require('../services/s3Service');
|
const { streamToString } = require('../services/s3Service');
|
||||||
const { buildNestedGatewayBlocks } = require('../utils/helpers');
|
const { generateMikrotikConfig } = require('../utils/mikrotikConfig');
|
||||||
|
const { getS3JsonWithHeaders } = require('../utils/s3Helpers');
|
||||||
|
|
||||||
// GET /api/filters/generate-config
|
// GET /api/filters/generate-config
|
||||||
async function generateConfig(req, res) {
|
async function generateConfig(req, res) {
|
||||||
try {
|
try {
|
||||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'filters.json' })).catch(() => null);
|
await getS3JsonWithHeaders('filters.json', req, res, {
|
||||||
const etag = head?.ETag || null;
|
transform: (filters) => {
|
||||||
if (etag) res.set('ETag', String(etag));
|
const config = generateMikrotikConfig(filters);
|
||||||
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
return { config };
|
||||||
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
},
|
||||||
if (checkIfNoneMatch(req, res, etag)) return;
|
defaultValue: [],
|
||||||
|
|
||||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filters.json' }));
|
|
||||||
const fileContent = await streamToString(data.Body);
|
|
||||||
let filters = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
filters = JSON.parse(fileContent);
|
|
||||||
if (!Array.isArray(filters)) filters = [];
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error('Error parsing filters.json:', parseError);
|
|
||||||
filters = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
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';
|
|
||||||
config += buildNestedGatewayBlocks(gatewayGroups, 4);
|
|
||||||
config += '}\n';
|
|
||||||
|
|
||||||
res.json({ config });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'NoSuchKey') {
|
console.error(error);
|
||||||
res.json({ config: '// filters.json file not found' });
|
return sendError(res, 500, 'Error generating configuration', 'E_S3');
|
||||||
} else {
|
|
||||||
console.error(error);
|
|
||||||
return sendError(res, 500, 'Error generating configuration', 'E_S3');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,21 +44,7 @@ async function exportConfig(req, res) {
|
|||||||
return res.json({ success: false, message: 'Нет фильтров для экспорта' });
|
return res.json({ success: false, message: 'Нет фильтров для экспорта' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const gatewayGroups = {};
|
const config = generateMikrotikConfig(filters);
|
||||||
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';
|
|
||||||
config += buildNestedGatewayBlocks(gatewayGroups, 4);
|
|
||||||
config += '}\n';
|
|
||||||
|
|
||||||
await writeS3TextObject('mikrotik-frouting-config.txt', config);
|
await writeS3TextObject('mikrotik-frouting-config.txt', config);
|
||||||
res.json({ success: true, message: 'Конфигурация экспортирована в S3' });
|
res.json({ success: true, message: 'Конфигурация экспортирована в S3' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -106,26 +56,7 @@ async function exportConfig(req, res) {
|
|||||||
// POST /api/server-filters/generate-config
|
// POST /api/server-filters/generate-config
|
||||||
async function generateServerFilterConfig(req, res) {
|
async function generateServerFilterConfig(req, res) {
|
||||||
const { filters } = req.body;
|
const { filters } = req.body;
|
||||||
|
const config = generateMikrotikConfig(filters);
|
||||||
if (!Array.isArray(filters) || filters.length === 0) {
|
|
||||||
return res.json({ config: '// No filters to generate configuration' });
|
|
||||||
}
|
|
||||||
|
|
||||||
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';
|
|
||||||
config += buildNestedGatewayBlocks(gatewayGroups, 4);
|
|
||||||
config += '}\n';
|
|
||||||
|
|
||||||
res.json({ config });
|
res.json({ config });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const { s3, BUCKET_NAME, writeS3TextObject, deleteS3Object, headMeta } = require('../services/s3Service');
|
const { s3, BUCKET_NAME, writeS3TextObject, deleteS3Object, headMeta } = require('../services/s3Service');
|
||||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
const { sendError, sendOk } = require('../middleware/errorHandler');
|
||||||
const { GetObjectCommand, HeadObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
const { GetObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
const { streamToString } = require('../services/s3Service');
|
const { streamToString } = require('../services/s3Service');
|
||||||
const { createJsonDataRoutes } = require('./jsonDataRoutes');
|
const { createJsonDataRoutes } = require('./jsonDataRoutes');
|
||||||
|
const { getS3TextWithHeaders, getS3JsonWithHeaders } = require('../utils/s3Helpers');
|
||||||
|
|
||||||
// GET /api/server-configs (список серверов)
|
// GET /api/server-configs (список серверов)
|
||||||
const serverConfigsListRoutes = createJsonDataRoutes('server-configs.json', (server, i) => {
|
const serverConfigsListRoutes = createJsonDataRoutes('server-configs.json', (server, i) => {
|
||||||
@@ -19,33 +20,15 @@ const serverConfigsListRoutes = createJsonDataRoutes('server-configs.json', (ser
|
|||||||
// GET /api/server-configs/:serverId (конкретная конфигурация)
|
// GET /api/server-configs/:serverId (конкретная конфигурация)
|
||||||
async function getServerConfig(req, res) {
|
async function getServerConfig(req, res) {
|
||||||
const { serverId } = req.params;
|
const { serverId } = req.params;
|
||||||
|
await getS3TextWithHeaders(
|
||||||
try {
|
`filter-manager/config-${serverId}.txt`,
|
||||||
const head = await s3.send(new HeadObjectCommand({
|
req,
|
||||||
Bucket: BUCKET_NAME,
|
res,
|
||||||
Key: `filter-manager/config-${serverId}.txt`
|
{
|
||||||
})).catch(() => null);
|
transform: (text) => ({ config: text }),
|
||||||
|
defaultValue: '// Конфигурация не найдена'
|
||||||
const etag = head?.ETag || null;
|
|
||||||
if (etag) res.set('ETag', String(etag));
|
|
||||||
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
|
||||||
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
|
||||||
if (checkIfNoneMatch(req, res, etag)) return;
|
|
||||||
|
|
||||||
const data = await s3.send(new GetObjectCommand({
|
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
Key: `filter-manager/config-${serverId}.txt`
|
|
||||||
}));
|
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/server-configs/:serverId (сохранить конфигурацию)
|
// POST /api/server-configs/:serverId (сохранить конфигурацию)
|
||||||
@@ -100,43 +83,12 @@ async function deleteServerComplete(req, res) {
|
|||||||
// GET /api/server-filters/:serverId
|
// GET /api/server-filters/:serverId
|
||||||
async function getServerFilters(req, res) {
|
async function getServerFilters(req, res) {
|
||||||
const { serverId } = req.params;
|
const { serverId } = req.params;
|
||||||
|
await getS3JsonWithHeaders(
|
||||||
try {
|
`filter-manager/server-filters-${serverId}.json`,
|
||||||
const head = await s3.send(new HeadObjectCommand({
|
req,
|
||||||
Bucket: BUCKET_NAME,
|
res,
|
||||||
Key: `filter-manager/server-filters-${serverId}.json`
|
{ defaultValue: [] }
|
||||||
})).catch(() => null);
|
);
|
||||||
|
|
||||||
const etag = head?.ETag || null;
|
|
||||||
if (etag) res.set('ETag', String(etag));
|
|
||||||
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
|
||||||
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
|
||||||
if (checkIfNoneMatch(req, res, etag)) return;
|
|
||||||
|
|
||||||
const data = await s3.send(new GetObjectCommand({
|
|
||||||
Bucket: BUCKET_NAME,
|
|
||||||
Key: `filter-manager/server-filters-${serverId}.json`
|
|
||||||
}));
|
|
||||||
const fileContent = await streamToString(data.Body);
|
|
||||||
let filters = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
filters = JSON.parse(fileContent);
|
|
||||||
if (!Array.isArray(filters)) filters = [];
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error('Error parsing server filters:', parseError);
|
|
||||||
filters = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(filters);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code === 'NoSuchKey') {
|
|
||||||
res.json([]);
|
|
||||||
} else {
|
|
||||||
console.error(error);
|
|
||||||
return sendError(res, 500, 'Error reading server filters from S3', 'E_S3');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/server-filters/:serverId
|
// POST /api/server-filters/:serverId
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Утилиты для генерации конфигурации MikroTik
|
||||||
|
* Устраняют дублирование кода генерации конфигурации
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { buildNestedGatewayBlocks } = require('./helpers');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерация конфигурации MikroTik из массива фильтров
|
||||||
|
* @param {Array} filters - Массив фильтров с полями {community, gateway}
|
||||||
|
* @returns {string} Сгенерированная конфигурация
|
||||||
|
*/
|
||||||
|
function generateMikrotikConfig(filters) {
|
||||||
|
if (!Array.isArray(filters) || filters.length === 0) {
|
||||||
|
return '// No filters to generate configuration';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группировка фильтров по 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';
|
||||||
|
config += buildNestedGatewayBlocks(gatewayGroups, 4);
|
||||||
|
config += '}\n';
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateMikrotikConfig,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Универсальные хелперы для работы с S3
|
||||||
|
* Устраняют дублирование кода в routes
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { s3, BUCKET_NAME } = require('../services/s3Service');
|
||||||
|
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
|
const { streamToString } = require('../services/s3Service');
|
||||||
|
const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Универсальный GET для JSON объектов из S3 с заголовками
|
||||||
|
* @param {string} s3Key - Ключ S3
|
||||||
|
* @param {object} req - Express request
|
||||||
|
* @param {object} res - Express response
|
||||||
|
* @param {object} options - Опции
|
||||||
|
* @param {function} options.transform - Функция трансформации данных
|
||||||
|
* @param {*} options.defaultValue - Значение по умолчанию при отсутствии файла
|
||||||
|
*/
|
||||||
|
async function getS3JsonWithHeaders(s3Key, req, res, options = {}) {
|
||||||
|
const { transform = (data) => data, defaultValue = [] } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Получаем HEAD для установки заголовков
|
||||||
|
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
|
||||||
|
const etag = head?.ETag || null;
|
||||||
|
|
||||||
|
// Устанавливаем заголовки
|
||||||
|
if (etag) res.set('ETag', String(etag));
|
||||||
|
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
||||||
|
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
||||||
|
|
||||||
|
// Проверяем If-None-Match
|
||||||
|
if (checkIfNoneMatch(req, res, etag)) return;
|
||||||
|
|
||||||
|
// Получаем объект
|
||||||
|
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
|
||||||
|
const fileContent = await streamToString(data.Body);
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(fileContent);
|
||||||
|
if (!Array.isArray(parsed) && Array.isArray(defaultValue)) {
|
||||||
|
parsed = defaultValue;
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error(`Error parsing ${s3Key}:`, parseError);
|
||||||
|
parsed = defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применяем трансформацию
|
||||||
|
const result = transform(parsed);
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
||||||
|
res.json(defaultValue);
|
||||||
|
} else {
|
||||||
|
console.error(error);
|
||||||
|
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Универсальный GET для текстовых объектов из S3 с заголовками
|
||||||
|
* @param {string} s3Key - Ключ S3
|
||||||
|
* @param {object} req - Express request
|
||||||
|
* @param {object} res - Express response
|
||||||
|
* @param {object} options - Опции
|
||||||
|
* @param {function} options.transform - Функция трансформации текста
|
||||||
|
* @param {string} options.defaultValue - Значение по умолчанию при отсутствии файла
|
||||||
|
*/
|
||||||
|
async function getS3TextWithHeaders(s3Key, req, res, options = {}) {
|
||||||
|
const { transform = (text) => ({ config: text }), defaultValue = '' } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
|
||||||
|
const etag = head?.ETag || null;
|
||||||
|
|
||||||
|
if (etag) res.set('ETag', String(etag));
|
||||||
|
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
||||||
|
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
||||||
|
|
||||||
|
if (checkIfNoneMatch(req, res, etag)) return;
|
||||||
|
|
||||||
|
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
|
||||||
|
const text = await streamToString(data.Body);
|
||||||
|
|
||||||
|
const result = transform(text);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
|
||||||
|
res.json(transform(defaultValue));
|
||||||
|
} else {
|
||||||
|
console.error(error);
|
||||||
|
return sendError(res, 500, 'Error reading from S3', 'E_S3');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getS3JsonWithHeaders,
|
||||||
|
getS3TextWithHeaders,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Универсальный компонент пагинации
|
||||||
|
* Устраняет дублирование кода пагинации во всех менеджерах
|
||||||
|
*/
|
||||||
|
|
||||||
|
function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChange }) {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
const renderPages = () => {
|
||||||
|
const pages = [];
|
||||||
|
let start = Math.max(1, currentPage - 2);
|
||||||
|
let end = Math.min(totalPages, currentPage + 2);
|
||||||
|
|
||||||
|
if (currentPage <= 3) {
|
||||||
|
end = Math.min(totalPages, 5);
|
||||||
|
}
|
||||||
|
if (currentPage >= totalPages - 2) {
|
||||||
|
start = Math.max(1, totalPages - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start > 1) pages.push('start-ellipsis');
|
||||||
|
for (let p = start; p <= end; p++) pages.push(p);
|
||||||
|
if (end < totalPages) pages.push('end-ellipsis');
|
||||||
|
|
||||||
|
return pages.map((p) => {
|
||||||
|
if (p === 'start-ellipsis' || p === 'end-ellipsis') {
|
||||||
|
return (
|
||||||
|
<li key={p} className="page-item disabled">
|
||||||
|
<span className="page-link">…</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
||||||
|
<button className="page-link" onClick={() => onPageChange(p)}>{p}</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startItem = (currentPage - 1) * pageSize + 1;
|
||||||
|
const endItem = Math.min(currentPage * pageSize, totalItems);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-footer d-flex align-items-center justify-content-between">
|
||||||
|
<div className="text-muted">
|
||||||
|
Показано {startItem} - {endItem} из {totalItems}
|
||||||
|
</div>
|
||||||
|
<ul className="pagination m-0">
|
||||||
|
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="page-link"
|
||||||
|
onClick={() => onPageChange(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
Первая
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="page-link"
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{renderPages()}
|
||||||
|
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="page-link"
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
Вперед
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="page-link"
|
||||||
|
onClick={() => onPageChange(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
Последняя
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Pagination;
|
||||||
|
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
/**
|
||||||
|
* Универсальный хук для управления данными с CRUD операциями
|
||||||
|
* Устраняет дублирование кода в DomainsNewManager, ASNsNewManager, IPRangesManager
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import api from '../lib/api.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} config - Конфигурация хука
|
||||||
|
* @param {string} config.apiEndpoint - API endpoint (например '/domains-new')
|
||||||
|
* @param {string} config.itemKey - Ключ элемента для идентификации (например 'domain', 'asn', 'ipRange')
|
||||||
|
* @param {function} config.validateItem - Функция валидации элемента (item) => boolean
|
||||||
|
* @param {function} config.validateCommunity - Функция валидации community (value) => boolean
|
||||||
|
* @param {function} config.transformResponse - Функция трансформации ответа API
|
||||||
|
* @param {function} config.transformForSave - Функция трансформации данных перед сохранением
|
||||||
|
* @param {string} config.saveBodyKey - Ключ для body при сохранении (по умолчанию 'domains')
|
||||||
|
*/
|
||||||
|
export function useDataManager(config) {
|
||||||
|
const {
|
||||||
|
apiEndpoint,
|
||||||
|
itemKey,
|
||||||
|
validateItem,
|
||||||
|
validateCommunity,
|
||||||
|
transformResponse = (data) => data,
|
||||||
|
transformForSave = (data) => data,
|
||||||
|
saveBodyKey = 'domains',
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [originalItems, setOriginalItems] = useState([]);
|
||||||
|
const [etag, setEtag] = useState('');
|
||||||
|
const [lastModified, setLastModified] = useState('');
|
||||||
|
const [contentLength, setContentLength] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
const [sortField, setSortField] = useState(itemKey);
|
||||||
|
const [sortOrder, setSortOrder] = useState('asc');
|
||||||
|
const [filterCommunity, setFilterCommunity] = useState('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
|
const [editingValue, setEditingValue] = useState('');
|
||||||
|
|
||||||
|
const didInit = useRef(false);
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
|
// Загрузка данных
|
||||||
|
const fetchItems = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get(apiEndpoint, { params: { offset: 0, limit: 0, format: 'std' } });
|
||||||
|
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||||
|
const transformed = transformResponse(payload);
|
||||||
|
const total = transformed.length;
|
||||||
|
|
||||||
|
setItems(transformed);
|
||||||
|
setOriginalItems(transformed);
|
||||||
|
setTotalItems(Number.isFinite(total) ? total : 0);
|
||||||
|
setEtag(response.headers?.etag || '');
|
||||||
|
setLastModified(response.headers?.['last-modified'] || '');
|
||||||
|
const lengthHeader = response.headers?.['content-length-source'];
|
||||||
|
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching ${apiEndpoint}:`, error);
|
||||||
|
return { success: false, error };
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInit.current) {
|
||||||
|
didInit.current = true;
|
||||||
|
fetchItems();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Добавление элемента
|
||||||
|
const addItem = (newItem) => {
|
||||||
|
const key = newItem[itemKey];
|
||||||
|
if (!key) return { success: false, error: 'Invalid item key' };
|
||||||
|
|
||||||
|
setItems(prev => [...prev, newItem]);
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Редактирование элемента
|
||||||
|
const updateItem = (itemKeyValue, updates) => {
|
||||||
|
setItems(prev => prev.map(item =>
|
||||||
|
item[itemKey] === itemKeyValue ? { ...item, ...updates } : item
|
||||||
|
));
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Удаление элемента
|
||||||
|
const deleteItem = (itemKeyValue) => {
|
||||||
|
setItems(prev => prev.filter(item => item[itemKey] !== itemKeyValue));
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Дедупликация
|
||||||
|
const deduplicate = (arr) => {
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const item of arr) {
|
||||||
|
const key = String(item[itemKey]).trim().toLowerCase();
|
||||||
|
if (!key) continue;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push({ ...item, [itemKey]: key });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Вычисление diff
|
||||||
|
const computeDiff = (before, after) => {
|
||||||
|
const mapBefore = new Map(before.map(i => [i[itemKey], i]));
|
||||||
|
const mapAfter = new Map(after.map(i => [i[itemKey], i]));
|
||||||
|
const added = [];
|
||||||
|
const removed = [];
|
||||||
|
const changed = [];
|
||||||
|
|
||||||
|
for (const [k, v] of mapAfter) {
|
||||||
|
if (!mapBefore.has(k)) {
|
||||||
|
added.push(v);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const prev = mapBefore.get(k);
|
||||||
|
if (String(prev.community) !== String(v.community)) {
|
||||||
|
changed.push({ from: prev, to: v });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [k, v] of mapBefore) {
|
||||||
|
if (!mapAfter.has(k)) removed.push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { added, removed, changed };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сохранение изменений
|
||||||
|
const saveChanges = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Валидируем и дедуплицируем
|
||||||
|
const valid = items.filter(item => {
|
||||||
|
const itemValid = validateItem ? validateItem(item[itemKey]) : true;
|
||||||
|
const communityValid = validateCommunity ? validateCommunity(item.community) : true;
|
||||||
|
return itemValid && communityValid;
|
||||||
|
});
|
||||||
|
const unique = deduplicate(valid);
|
||||||
|
|
||||||
|
// Загружаем полный список, мержим и сохраняем
|
||||||
|
const fullRes = await api.get(apiEndpoint, { params: { offset: 0, limit: 0, format: 'std' } });
|
||||||
|
const fullPayload = Array.isArray(fullRes.data?.items) ? fullRes.data.items : [];
|
||||||
|
const full = transformResponse(fullPayload);
|
||||||
|
const fullMap = new Map(full.map(i => [String(i[itemKey]).trim().toLowerCase(), i]));
|
||||||
|
const originalPageMap = new Map(originalItems.map(i => [String(i[itemKey]).trim().toLowerCase(), true]));
|
||||||
|
const uniqueMap = new Map(unique.map(i => [String(i[itemKey]).trim().toLowerCase(), i]));
|
||||||
|
|
||||||
|
// Удаляем то, что было на странице но отсутствует в изменённом наборе
|
||||||
|
for (const key of originalPageMap.keys()) {
|
||||||
|
if (!uniqueMap.has(key)) fullMap.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем/обновляем
|
||||||
|
for (const [key, val] of uniqueMap.entries()) {
|
||||||
|
fullMap.set(key, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullToSave = Array.from(fullMap.values());
|
||||||
|
const transformed = transformForSave(fullToSave);
|
||||||
|
|
||||||
|
const et = fullRes?.headers?.etag || etag;
|
||||||
|
const response = await api.post(apiEndpoint, { [saveBodyKey]: transformed, etag: et }, { validateStatus: () => true });
|
||||||
|
|
||||||
|
if (response.status === 412) {
|
||||||
|
return { success: false, error: 'ETag mismatch', code: 412 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status >= 400) {
|
||||||
|
return { success: false, error: `Save failed with status ${response.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
setEtag(response.headers?.etag || et);
|
||||||
|
await fetchItems();
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving changes:', error);
|
||||||
|
return { success: false, error };
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сортировка
|
||||||
|
const sortedItems = [...items].sort((a, b) => {
|
||||||
|
let valA = a[sortField] || '';
|
||||||
|
let valB = b[sortField] || '';
|
||||||
|
if (typeof valA === 'string') valA = valA.toLowerCase();
|
||||||
|
if (typeof valB === 'string') valB = valB.toLowerCase();
|
||||||
|
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
|
||||||
|
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Фильтрация
|
||||||
|
const filtered = sortedItems.filter(item => {
|
||||||
|
const byCommunity = !filterCommunity || item.community === filterCommunity;
|
||||||
|
const term = String(searchTerm || '').toLowerCase();
|
||||||
|
const bySearch = !term || String(item[itemKey]).toLowerCase().includes(term);
|
||||||
|
return byCommunity && bySearch;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Пагинация
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||||
|
const paginatedItems = filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||||
|
|
||||||
|
// Уникальные community для фильтра
|
||||||
|
const allCommunities = Array.from(new Set(items.map(i => i.community)));
|
||||||
|
|
||||||
|
// Управление сортировкой
|
||||||
|
const handleSort = (field) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortOrder('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Инлайн редактирование
|
||||||
|
const startEdit = (item) => {
|
||||||
|
setEditingItem(item[itemKey]);
|
||||||
|
setEditingValue(item.community);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = () => {
|
||||||
|
if (!editingItem) return { success: false };
|
||||||
|
updateItem(editingItem, { community: editingValue.trim() });
|
||||||
|
setEditingItem(null);
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingItem(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Состояние
|
||||||
|
items,
|
||||||
|
originalItems,
|
||||||
|
setItems,
|
||||||
|
loading,
|
||||||
|
etag,
|
||||||
|
lastModified,
|
||||||
|
contentLength,
|
||||||
|
|
||||||
|
// Пагинация
|
||||||
|
currentPage,
|
||||||
|
setCurrentPage,
|
||||||
|
totalItems,
|
||||||
|
totalPages,
|
||||||
|
pageSize,
|
||||||
|
paginatedItems,
|
||||||
|
|
||||||
|
// Сортировка
|
||||||
|
sortField,
|
||||||
|
sortOrder,
|
||||||
|
handleSort,
|
||||||
|
|
||||||
|
// Фильтрация
|
||||||
|
filterCommunity,
|
||||||
|
setFilterCommunity,
|
||||||
|
searchTerm,
|
||||||
|
setSearchTerm,
|
||||||
|
filtered,
|
||||||
|
allCommunities,
|
||||||
|
|
||||||
|
// CRUD
|
||||||
|
fetchItems,
|
||||||
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
deleteItem,
|
||||||
|
saveChanges,
|
||||||
|
deduplicate,
|
||||||
|
computeDiff,
|
||||||
|
|
||||||
|
// Инлайн редактирование
|
||||||
|
editingItem,
|
||||||
|
editingValue,
|
||||||
|
setEditingValue,
|
||||||
|
startEdit,
|
||||||
|
saveEdit,
|
||||||
|
cancelEdit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Утилиты для экспорта данных в CSV/TXT
|
||||||
|
* Устраняют дублирование кода экспорта
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экспорт массива объектов в CSV
|
||||||
|
* @param {Array} items - Массив данных
|
||||||
|
* @param {Array} headers - Массив заголовков ['field1', 'field2']
|
||||||
|
* @param {string} filename - Имя файла
|
||||||
|
*/
|
||||||
|
export function exportToCSV(items, headers, filename) {
|
||||||
|
const csvContent = [
|
||||||
|
headers,
|
||||||
|
...items.map(item => headers.map(h => item[h] || ''))
|
||||||
|
]
|
||||||
|
.map(row => row.map(cell => `"${String(cell || '').replace(/"/g, '""')}"`).join(','))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
downloadFile(csvContent, filename, 'text/csv;charset=utf-8;');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экспорт данных в текстовый файл
|
||||||
|
* @param {string} content - Текстовое содержимое
|
||||||
|
* @param {string} filename - Имя файла
|
||||||
|
*/
|
||||||
|
export function exportToText(content, filename) {
|
||||||
|
downloadFile(content, filename, 'text/plain;charset=utf-8;');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скачивание файла
|
||||||
|
* @param {string} content - Содержимое
|
||||||
|
* @param {string} filename - Имя файла
|
||||||
|
* @param {string} mimeType - MIME тип
|
||||||
|
*/
|
||||||
|
function downloadFile(content, filename, mimeType) {
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерация имени файла с датой
|
||||||
|
* @param {string} prefix - Префикс имени файла
|
||||||
|
* @param {string} extension - Расширение файла
|
||||||
|
* @returns {string} Имя файла с датой
|
||||||
|
*/
|
||||||
|
export function generateFilename(prefix, extension = 'csv') {
|
||||||
|
const date = new Date().toISOString().split('T')[0];
|
||||||
|
return `${prefix}_${date}.${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* Утилиты для работы с IP адресами и CIDR
|
||||||
|
* Вынесено из IPRangesManager для переиспользования
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсинг маски из CIDR
|
||||||
|
*/
|
||||||
|
export function parseMask(cidr) {
|
||||||
|
const parts = String(cidr || '').split('/');
|
||||||
|
const mask = Number(parts[1]);
|
||||||
|
return Number.isFinite(mask) ? mask : NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подсчет адресов в CIDR
|
||||||
|
*/
|
||||||
|
export function countAddresses(mask) {
|
||||||
|
if (!Number.isFinite(mask) || mask < 0 || mask > 32) return 0n;
|
||||||
|
return 1n << BigInt(32 - mask);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсинг октетов IPv4
|
||||||
|
*/
|
||||||
|
export function parseOctets(ip) {
|
||||||
|
const [a, b, c, d] = String(ip || '').split('.').map(x => Number(x));
|
||||||
|
return [a, b, c, d];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка приватного IPv4
|
||||||
|
*/
|
||||||
|
export function isPrivateIPv4(ip) {
|
||||||
|
const [a, b] = parseOctets(ip);
|
||||||
|
if (a === 10) return true; // 10.0.0.0/8
|
||||||
|
if (a === 192 && b === 168) return true; // 192.168.0.0/16
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка CGNAT IPv4
|
||||||
|
*/
|
||||||
|
export function isCGNATIPv4(ip) {
|
||||||
|
const [a, b] = parseOctets(ip);
|
||||||
|
return a === 100 && b >= 64 && b <= 127; // 100.64.0.0/10
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение категории IP
|
||||||
|
*/
|
||||||
|
export function getIpCategory(cidr) {
|
||||||
|
const baseIp = String(cidr || '').split('/')[0];
|
||||||
|
if (isCGNATIPv4(baseIp)) return 'cgnat';
|
||||||
|
if (isPrivateIPv4(baseIp)) return 'private';
|
||||||
|
return 'public';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирование BigInt с разделителями тысяч
|
||||||
|
*/
|
||||||
|
export function formatBigInt(n) {
|
||||||
|
try {
|
||||||
|
const s = (n ?? 0n).toString();
|
||||||
|
return s.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||||
|
} catch {
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертация IP в BigInt
|
||||||
|
*/
|
||||||
|
export function ipToInt(ip) {
|
||||||
|
const [a, b, c, d] = parseOctets(ip);
|
||||||
|
return (BigInt(a) << 24n) | (BigInt(b) << 16n) | (BigInt(c) << 8n) | BigInt(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертация BigInt в IP
|
||||||
|
*/
|
||||||
|
export function intToIp(num) {
|
||||||
|
const a = Number((num >> 24n) & 255n);
|
||||||
|
const b = Number((num >> 16n) & 255n);
|
||||||
|
const c = Number((num >> 8n) & 255n);
|
||||||
|
const d = Number(num & 255n);
|
||||||
|
return `${a}.${b}.${c}.${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертация CIDR в диапазон
|
||||||
|
*/
|
||||||
|
export function cidrToRange(cidr) {
|
||||||
|
const [ip, maskStr] = String(cidr).split('/');
|
||||||
|
const mask = Number(maskStr);
|
||||||
|
const ipNum = ipToInt(ip);
|
||||||
|
const hostBits = 32 - mask;
|
||||||
|
const start = ipNum & (~0n << BigInt(hostBits));
|
||||||
|
const end = start | ((1n << BigInt(hostBits)) - 1n);
|
||||||
|
return [start, end];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подсчет trailing zeros для 32-битного числа
|
||||||
|
*/
|
||||||
|
export function countTrailingZeros32(n) {
|
||||||
|
if (n === 0n) return 32;
|
||||||
|
let zeros = 0;
|
||||||
|
for (let i = 0; i < 32; i++) {
|
||||||
|
if ((n & (1n << BigInt(i))) !== 0n) {
|
||||||
|
zeros = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zeros;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floor log2 для BigInt
|
||||||
|
*/
|
||||||
|
export function floorLog2(n) {
|
||||||
|
let p = -1;
|
||||||
|
while (n > 0n) {
|
||||||
|
n >>= 1n;
|
||||||
|
p++;
|
||||||
|
}
|
||||||
|
return p < 0 ? 0 : p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертация диапазона в CIDR блоки
|
||||||
|
*/
|
||||||
|
export function rangeToCidrs(start, end) {
|
||||||
|
const res = [];
|
||||||
|
let cur = start;
|
||||||
|
while (cur <= end) {
|
||||||
|
const maxAlignPow = countTrailingZeros32(cur);
|
||||||
|
const remaining = end - cur + 1n;
|
||||||
|
const remainingPow = floorLog2(remaining);
|
||||||
|
const blockPow = Math.min(maxAlignPow, remainingPow);
|
||||||
|
const mask = 32 - blockPow;
|
||||||
|
res.push(`${intToIp(cur)}/${mask}`);
|
||||||
|
cur += 1n << BigInt(blockPow);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Слияние интервалов
|
||||||
|
*/
|
||||||
|
export function mergeIntervals(intervals) {
|
||||||
|
if (intervals.length === 0) return [];
|
||||||
|
intervals.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||||||
|
const merged = [];
|
||||||
|
let [cs, ce] = intervals[0];
|
||||||
|
for (let i = 1; i < intervals.length; i++) {
|
||||||
|
const [s, e] = intervals[i];
|
||||||
|
if (s <= ce + 1n) {
|
||||||
|
if (e > ce) ce = e;
|
||||||
|
} else {
|
||||||
|
merged.push([cs, ce]);
|
||||||
|
cs = s;
|
||||||
|
ce = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged.push([cs, ce]);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Суммаризация CIDR блоков
|
||||||
|
*/
|
||||||
|
export function summarizeCidrs(cidrs) {
|
||||||
|
const ranges = cidrs.map(cidrToRange);
|
||||||
|
const merged = mergeIntervals(ranges);
|
||||||
|
const out = [];
|
||||||
|
for (const [s, e] of merged) {
|
||||||
|
out.push(...rangeToCidrs(s, e));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение базового /16 для CIDR
|
||||||
|
*/
|
||||||
|
export function get16BaseCidr(cidr) {
|
||||||
|
const ip = String(cidr).split('/')[0];
|
||||||
|
const ipNum = ipToInt(ip);
|
||||||
|
const start = ipNum & (~0n << 16n);
|
||||||
|
return `${intToIp(start)}/16`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применение агрессивной агрегации /16
|
||||||
|
*/
|
||||||
|
export function applySupernet16(cidrs) {
|
||||||
|
const buckets = new Map();
|
||||||
|
for (const c of cidrs) {
|
||||||
|
const base16 = get16BaseCidr(c);
|
||||||
|
if (!buckets.has(base16)) buckets.set(base16, []);
|
||||||
|
buckets.get(base16).push(c);
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const [base16, list] of buckets) {
|
||||||
|
if (list.length > 2) out.push(base16);
|
||||||
|
else out.push(...list);
|
||||||
|
}
|
||||||
|
return Array.from(new Set(out));
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Утилиты валидации
|
||||||
|
* Централизация всех функций валидации
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидация FQDN домена
|
||||||
|
*/
|
||||||
|
export function isValidDomain(value) {
|
||||||
|
const v = String(value).trim().toLowerCase();
|
||||||
|
return /^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидация ASN
|
||||||
|
*/
|
||||||
|
export function isValidAsn(value) {
|
||||||
|
return /^[0-9]+$/.test(String(value).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидация IPv4 адреса
|
||||||
|
*/
|
||||||
|
export function isValidIPv4(ip) {
|
||||||
|
const octets = ip.split('.');
|
||||||
|
if (octets.length !== 4) return false;
|
||||||
|
return octets.every(o => /^\d{1,3}$/.test(o) && Number(o) >= 0 && Number(o) <= 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидация CIDR
|
||||||
|
*/
|
||||||
|
export function isValidCidr(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидация BGP Community (поддержка числового и extended формата)
|
||||||
|
*/
|
||||||
|
export function isValidCommunity(value) {
|
||||||
|
return /^(\d+|\d+:\d+)$/.test(String(value).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидация IP адреса (поддержка IPv4 и IPv6)
|
||||||
|
*/
|
||||||
|
export function isValidIP(value) {
|
||||||
|
// IPv4
|
||||||
|
if (isValidIPv4(value)) return true;
|
||||||
|
|
||||||
|
// Упрощенная проверка IPv6
|
||||||
|
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::)$/;
|
||||||
|
return ipv6Regex.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user