quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / openapi (push) Successful in 30s
quality / web (push) Successful in 1m3s
quality / api (push) Successful in 1m15s
quality / linux-agent-smoke (push) Failing after 27s
quality / docker-check (push) Failing after 2m28s
CD / quality (push) Failing after 5m52s
CD / publish (push) Skipped
Implement reliable agent apply, telemetry v2, analytics retention, guarded rollouts, enrollment, labels and webhooks. Improve ReUI frontend data safety and bounded list loading, and add Docker smoke, recovery and resource checks. Document local verification and pending native acceptance.
230 lines
8.7 KiB
TypeScript
230 lines
8.7 KiB
TypeScript
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 }
|
|
})
|
|
}
|