/** * Общие роуты для текстовых данных (domains, asns, ip-ranges) * Уменьшают дублирование кода для похожих эндпоинтов */ const { streamPaginatedText, headS3ObjectEtag, writeS3TextObject } = 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 }; 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() }); } /** * Создать 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}`; const cached = getCountOnlyCache(cacheKey); if (cached != null) { return res.json(format === 'std' ? { items: [], total: cached, meta: {} } : { total: cached }); } const { total } = await streamPaginatedText({ key: s3Key, 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: s3Key, q, offset: Number(offset) || 0, limit: Number(limit) || 0, 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; 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 fileContent = await streamToString(data.Body); 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'); } if (format === 'std') { return res.json({ items, total: items.length, meta: {} }); } res.json(items); } } catch (error) { if (error?.name === 'NoSuchKey' || error?.code === 'NoSuchKey' || 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) }); } } }; } /** * Создать 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 payload = Array.isArray(domains) ? domains : Array.isArray(ipRanges) ? ipRanges : Array.isArray(items) ? items : 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++) { const errors = validateItem(payload[i], i); if (errors.length > 0) { validationErrors.push(...errors); } } if (validationErrors.length > 0) { return sendError(res, 400, 'Validation errors', 'E_VALIDATION', { errors: validationErrors.slice(0, 10) }); } } 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; try { current = await headS3ObjectEtag(s3Key); } 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 }); } } 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) }); } }; } /** * Создать роуты для текстовых данных (GET + POST) */ function createTextDataRoutes(config) { const { s3Key, mapLine, formatLine, validate, validateItem, cachePrefix } = config; return { get: createTextDataGET(s3Key, mapLine, validate, cachePrefix), post: createTextDataPOST(s3Key, formatLine, validate, validateItem) }; } module.exports = { createTextDataRoutes, createTextDataGET, createTextDataPOST, };