import type { FastifyPluginAsync } from 'fastify' import { repos } from '@evofw/db' import { createIpListBodySchema, listEntriesBodySchema, deleteListEntryBodySchema, isManualListType, listEntryInputSchema, listImportQuerySchema, listDetailPageQuerySchema, } from '@evofw/shared' import { AppError } from '../plugins/error-handler.js' import { refreshIpList } from '../services/lists/refresh.js' import { addListEntries, addListEntriesSummary, deleteListEntry, deleteListEntrySummary, mapListDetail, mapListDetailPage, } from '../services/lists/entries.js' import type { AppConfig } from '../config.js' import { auditMutation } from '../services/audit.js' import { maskListConfig, sealListConfig } from '../services/secret-cipher.js' import { applyPagination } from '../services/pagination.js' const LIST_IMPORT_BODY_LIMIT = 8 * 1024 * 1024 const MAX_IMPORT_ENTRIES = 100_000 function validateImportBudget(values: string[], structuredCount = 0) { let count = structuredCount if (count > MAX_IMPORT_ENTRIES) throw new AppError('VALIDATION_ERROR', 'At most 100000 list entries per import', 400) for (const value of values) for (const _token of value.matchAll(/[^\n,;\s]+/g)) { if (++count > MAX_IMPORT_ENTRIES) throw new AppError('VALIDATION_ERROR', 'At most 100000 list entries per import', 400) } } export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( app, opts, ) => { const { config } = opts app.get<{ Querystring: { limit?: string; offset?: string; view?: string } }>( '/lists', async (req) => { if (req.query.view !== undefined && req.query.view !== 'summary') throw new AppError('VALIDATION_ERROR', 'Unknown list catalog view', 400) const lists = req.query.view === 'summary' ? repos.listIpListsSummary(app.db) : repos.listIpLists(app.db) const counts = repos.countEntriesByListIds( app.db, lists.map((l) => l.id), ) const items = lists.map((l) => ({ id: l.id, name: l.name, type: l.type, config_json: 'configJson' in l && typeof l.configJson === 'string' ? maskListConfig(l.configJson) : '{}', content_hash: l.contentHash, refreshed_at: l.refreshedAt, last_error: l.lastError, entry_count: counts.get(l.id) ?? 0, created_at: l.createdAt, updated_at: l.updatedAt, })) const paged = applyPagination(items, req.query) return { items: paged.items, total: paged.total } }, ) app.post('/lists', { bodyLimit: LIST_IMPORT_BODY_LIMIT }, async (req) => { const body = createIpListBodySchema.parse(req.body) const type = body.type === 'domains' ? 'static' : body.type const inputConfig = { ...(body.config ?? {}) } validateImportBudget(body.entries ?? [], (Array.isArray(inputConfig.items) ? inputConfig.items.length : 0) + (Array.isArray(inputConfig.domains) ? inputConfig.domains.length : 0)) if (isManualListType(type) && ((inputConfig.items !== undefined && !Array.isArray(inputConfig.items)) || (inputConfig.domains !== undefined && !Array.isArray(inputConfig.domains)))) { throw new AppError('VALIDATION_ERROR', 'Manual list items/domains must be arrays', 400) } const initialItems = isManualListType(type) && Array.isArray(inputConfig.items) ? inputConfig.items.map((item) => listEntryInputSchema.parse(item)) : [] const initialDomains = isManualListType(type) && Array.isArray(inputConfig.domains) ? inputConfig.domains.map((domain) => listEntryInputSchema.parse({ kind: 'hostname', value: domain })) : [] if (isManualListType(type)) { delete inputConfig.items delete inputConfig.domains } const id = crypto.randomUUID() const list = repos.insertIpList(app.db, { id, name: body.name, type, configJson: sealListConfig(inputConfig), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) if (isManualListType(type) && (body.entries?.length || initialItems.length || initialDomains.length)) { try { await addListEntries(app.db, id, { values: body.entries, items: [...initialItems, ...initialDomains] }) } catch (err) { repos.deleteIpList(app.db, id) throw new AppError( 'VALIDATION_ERROR', err instanceof Error ? err.message : String(err), 400, ) } } else if (!isManualListType(type)) { await refreshIpList(app.db, id) } auditMutation(app, config, req, { action: 'list.create', targetType: 'app_resource', targetId: list!.id, summary: `Создан список: ${list!.name}`, details: { list_id: list!.id, type: list!.type }, }) return { id: list!.id, name: list!.name, type: list!.type, config_json: maskListConfig(list!.configJson), created_at: list!.createdAt, updated_at: list!.updatedAt, } }) app.get<{ Params: { id: string } }>('/lists/:id', async (req) => { const detail = Object.keys(req.query as object).length > 0 ? mapListDetailPage(app.db, req.params.id, listDetailPageQuerySchema.parse(req.query)) : mapListDetail(app.db, req.params.id) if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404) return detail }) app.post<{ Params: { id: string } }>( '/lists/:id/entries', { bodyLimit: LIST_IMPORT_BODY_LIMIT }, async (req) => { const l = repos.getIpList(app.db, req.params.id) if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) const body = listEntriesBodySchema.parse(req.body) const summary = listImportQuerySchema.parse(req.query).return === 'summary' validateImportBudget(body.values ?? [], body.items?.length ?? 0) try { const input = { values: body.values, items: body.items } const result = summary ? await addListEntriesSummary(app.db, l.id, input) : await addListEntries(app.db, l.id, input) auditMutation(app, config, req, { action: 'list.entries.add', targetType: 'app_resource', targetId: l.id, summary: `Добавлены записи в список: ${l.name}`, details: { list_id: l.id, entry_count: 'count' in result ? result.count : result.entries.length, }, }) return summary ? result : mapListDetail(app.db, l.id) ?? result } catch (err) { throw new AppError( 'VALIDATION_ERROR', err instanceof Error ? err.message : String(err), 400, ) } }, ) app.delete<{ Params: { id: string } }>( '/lists/:id/entries', async (req) => { const l = repos.getIpList(app.db, req.params.id) if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) const body = deleteListEntryBodySchema.parse(req.body) const summary = listImportQuerySchema.parse(req.query).return === 'summary' try { const result = summary ? await deleteListEntrySummary(app.db, l.id, body.value) : await deleteListEntry(app.db, l.id, body.value) auditMutation(app, config, req, { action: 'list.entries.delete', severity: 'warning', targetType: 'app_resource', targetId: l.id, summary: `Удалена запись из списка: ${l.name}`, details: { list_id: l.id, value: body.value }, }) return summary ? result : mapListDetail(app.db, l.id) } catch (err) { throw new AppError( 'VALIDATION_ERROR', err instanceof Error ? err.message : String(err), 400, ) } }, ) app.post<{ Params: { id: string } }>('/lists/:id/refresh', async (req) => { const l = repos.getIpList(app.db, req.params.id) await refreshIpList(app.db, req.params.id) const detail = mapListDetail(app.db, req.params.id) if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404) auditMutation(app, config, req, { action: 'list.refresh', targetType: 'app_resource', targetId: req.params.id, summary: `Обновлён список: ${l?.name ?? req.params.id}`, details: { list_id: req.params.id }, }) return detail }) app.delete<{ Params: { id: string } }>('/lists/:id', async (req) => { const l = repos.getIpList(app.db, req.params.id) try { repos.deleteIpList(app.db, req.params.id) } catch (err) { throw new AppError('CONFLICT', err instanceof Error ? err.message : 'List is in use', 409) } if (l) { auditMutation(app, config, req, { action: 'list.delete', severity: 'warning', targetType: 'app_resource', targetId: l.id, summary: `Список удалён: ${l.name}`, details: { list_id: l.id }, }) } return { ok: true } }) }