Backend: SQLite storage, EvoBGP integration, filters in SQL
Made-with: Cursor
This commit is contained in:
@@ -1,14 +1,10 @@
|
||||
/**
|
||||
* Общие роуты для текстовых данных (domains, asns, ip-ranges)
|
||||
* Уменьшают дублирование кода для похожих эндпоинтов
|
||||
*/
|
||||
|
||||
const { streamPaginatedText, headS3ObjectEtag, writeS3TextObject } = require('../services/s3Service');
|
||||
const { streamPaginatedText, headS3ObjectEtag, writeS3TextObject, headMeta, readS3TextObject } = require('../services/s3Service');
|
||||
const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
|
||||
const { splitWhitespace } = require('../utils/helpers');
|
||||
const { s3, BUCKET_NAME } = require('../services/s3Service');
|
||||
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { streamToString } = require('../services/s3Service');
|
||||
|
||||
// Кэш для countOnly запросов
|
||||
const countOnlyCache = { map: new Map(), ttlMs: 10_000 };
|
||||
@@ -27,17 +23,10 @@ function setCountOnlyCache(cacheKey, value) {
|
||||
countOnlyCache.map.set(cacheKey, { value, at: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать GET эндпоинт для текстовых данных
|
||||
* @param {string} s3Key - Ключ S3 файла
|
||||
* @param {function} mapLine - Функция маппинга строки в объект
|
||||
* @param {function} validate - Функция валидации данных
|
||||
* @param {string} cachePrefix - Префикс для кэша
|
||||
*/
|
||||
function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
|
||||
return async (req, res) => {
|
||||
const { q = '', offset, limit, countOnly, format } = req.query || {};
|
||||
|
||||
|
||||
try {
|
||||
if (countOnly === 'true') {
|
||||
const cacheKey = `${cachePrefix}:count:${q}`;
|
||||
@@ -54,24 +43,32 @@ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
|
||||
q,
|
||||
offset: Number(offset) || 0,
|
||||
limit: Number(limit) || 0,
|
||||
mapLine
|
||||
mapLine,
|
||||
});
|
||||
if (!validate(items)) {
|
||||
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
|
||||
}
|
||||
return res.json(format === 'std' ? { items, total, meta: {} } : { items, total });
|
||||
} else {
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
|
||||
const etag = head?.ETag || null;
|
||||
const head = await headMeta(s3Key);
|
||||
const etag = head?.etag || null;
|
||||
if (etag) res.set('ETag', String(etag));
|
||||
if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
|
||||
if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
|
||||
if (head?.lastModified) res.set('Last-Modified', new Date(head.lastModified).toUTCString());
|
||||
if (typeof head?.contentLength === 'number') res.set('Content-Length-Source', String(head.contentLength));
|
||||
if (checkIfNoneMatch(req, res, etag)) return;
|
||||
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
|
||||
const fileContent = await streamToString(data.Body);
|
||||
const items = fileContent.split('\n').filter(line => line).map(line => mapLine(line.trim()));
|
||||
|
||||
let fileContent;
|
||||
try {
|
||||
const data = await readS3TextObject(s3Key);
|
||||
fileContent = data.body;
|
||||
} catch (e) {
|
||||
if (e?.code === 'NoSuchKey') {
|
||||
return res.json([]);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const items = fileContent.split('\n').filter((line) => line).map((line) => mapLine(line.trim()));
|
||||
|
||||
if (!validate(items)) {
|
||||
return sendError(res, 500, 'Invalid data format', 'E_SCHEMA');
|
||||
}
|
||||
@@ -85,24 +82,16 @@ function createTextDataGET(s3Key, mapLine, validate, cachePrefix) {
|
||||
res.json([]);
|
||||
} else {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error reading from S3', 'E_S3', { error: String(error?.message || error) });
|
||||
return sendError(res, 500, 'Error reading storage', 'E_STORAGE', { error: String(error?.message || error) });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать POST эндпоинт для текстовых данных
|
||||
* @param {string} s3Key - Ключ S3 файла
|
||||
* @param {function} formatLine - Функция форматирования объекта в строку
|
||||
* @param {function} validate - Функция валидации данных
|
||||
* @param {function} validateItem - Функция валидации отдельного элемента (опционально)
|
||||
*/
|
||||
function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
|
||||
return async (req, res) => {
|
||||
const { domains, ipRanges, items, etag } = req.body; // 'domains' основной ключ, но поддерживаем и альтернативные
|
||||
|
||||
// Поддержка обратной совместимости: принимаем domains | ipRanges | items
|
||||
const { domains, ipRanges, items, etag } = req.body;
|
||||
|
||||
const payload = Array.isArray(domains)
|
||||
? domains
|
||||
: Array.isArray(ipRanges)
|
||||
@@ -114,13 +103,11 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
|
||||
if (!Array.isArray(payload)) {
|
||||
return sendError(res, 400, 'Data must be an array', 'E_BAD_REQUEST');
|
||||
}
|
||||
|
||||
// Валидация с помощью AJV
|
||||
|
||||
if (!validate(payload || [])) {
|
||||
return sendError(res, 400, 'Invalid payload format', 'E_SCHEMA');
|
||||
}
|
||||
|
||||
// Дополнительная валидация элементов, если предоставлена
|
||||
|
||||
if (validateItem) {
|
||||
const validationErrors = [];
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
@@ -129,62 +116,41 @@ function createTextDataPOST(s3Key, formatLine, validate, validateItem = null) {
|
||||
validationErrors.push(...errors);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return sendError(res, 400, 'Validation errors', 'E_VALIDATION', {
|
||||
errors: validationErrors.slice(0, 10)
|
||||
errors: validationErrors.slice(0, 10),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const fileContent = (payload || []).map(formatLine).filter(Boolean).join('\n');
|
||||
|
||||
try {
|
||||
// Проверка ETag для optimistic concurrency
|
||||
let current = null;
|
||||
const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null;
|
||||
const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g, '"') : null;
|
||||
try {
|
||||
current = await headS3ObjectEtag(s3Key);
|
||||
} catch {}
|
||||
|
||||
} catch (_) {}
|
||||
|
||||
if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) {
|
||||
const { headMeta } = require('../services/s3Service');
|
||||
const meta = await headMeta(s3Key);
|
||||
return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', {
|
||||
currentEtag: current,
|
||||
meta
|
||||
});
|
||||
return sendError(res, 412, 'ETag mismatch', 'E_ETAG_MISMATCH');
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const meta = await writeS3TextObject(s3Key, fileContent);
|
||||
return sendOk(res, meta);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return sendError(res, 500, 'Error writing to S3', 'E_S3', {
|
||||
error: String(error?.message || error)
|
||||
});
|
||||
return sendError(res, 500, 'Error writing storage', 'E_STORAGE');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать роуты для текстовых данных (GET + POST)
|
||||
*/
|
||||
function createTextDataRoutes(config) {
|
||||
const {
|
||||
s3Key,
|
||||
mapLine,
|
||||
formatLine,
|
||||
validate,
|
||||
validateItem,
|
||||
cachePrefix
|
||||
} = config;
|
||||
|
||||
const { s3Key, mapLine, formatLine, validate, validateItem, cachePrefix } = config;
|
||||
return {
|
||||
get: createTextDataGET(s3Key, mapLine, validate, cachePrefix),
|
||||
post: createTextDataPOST(s3Key, formatLine, validate, validateItem)
|
||||
post: createTextDataPOST(s3Key, formatLine, validate, validateItem),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,4 +159,3 @@ module.exports = {
|
||||
createTextDataGET,
|
||||
createTextDataPOST,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user