import type { Knex } from 'knex'; import type { FastifyInstance, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { db } from '../db/client.js'; import { assertIdentifier } from '../lib/identifier.js'; const filterSchema = z.object({ field: z.string(), operator: z .enum(['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'like', 'in', 'null', 'notNull']) .default('eq'), value: z.any().optional(), }); const orderBySchema = z.object({ field: z.string(), direction: z.enum(['asc', 'desc']).default('asc'), }); const selectSchema = z.object({ columns: z.array(z.string()).optional(), where: z.array(filterSchema).optional(), orderBy: z.array(orderBySchema).optional(), limit: z.number().int().positive().max(500).optional(), offset: z.number().int().min(0).optional(), }); const insertSchema = z.object({ data: z.record(z.string(), z.any()).or(z.array(z.record(z.string(), z.any()))), }); const updateSchema = z.object({ data: z.record(z.string(), z.any()), where: z.array(filterSchema).default([]), }); const deleteSchema = z.object({ where: z.array(filterSchema).default([]), }); const protectedBoardPostAutomationFields = new Set(['automation_plan_item_id', 'automation_received_at']); const secretFieldPattern = /(?:^|_|-)(?:password|passwd|pwd|passcode|secret|token|api[_-]?key|access[_-]?key|private[_-]?key)(?:$|_|-)/i; const loginFieldPattern = /(?:^|_|-)(?:login[_-]?id|login[_-]?name|username|user[_-]?name|user[_-]?id|account[_-]?id|account[_-]?name|admin[_-]?id|admin[_-]?name|member[_-]?id|member[_-]?name|email)(?:$|_|-)/i; function maskCredentialValue(value: string) { const trimmed = value.trim(); if (!trimmed) { return value; } if (trimmed.length <= 2) { return '*'.repeat(trimmed.length); } if (trimmed.length <= 4) { return `${trimmed[0]}${'*'.repeat(trimmed.length - 2)}${trimmed.at(-1) ?? ''}`; } return `${trimmed.slice(0, 2)}${'*'.repeat(Math.max(2, trimmed.length - 4))}${trimmed.slice(-2)}`; } function shouldMaskLoginField(fieldName: string, row: Record) { if (!loginFieldPattern.test(fieldName)) { return false; } return Object.keys(row).some((candidateField) => secretFieldPattern.test(candidateField)); } export function maskCrudRowSensitiveFields(value: T): T { if (Array.isArray(value)) { return value.map((item) => maskCrudRowSensitiveFields(item)) as T; } if (!value || typeof value !== 'object') { return value; } const row = value as Record; const result: Record = {}; Object.entries(row).forEach(([fieldName, fieldValue]) => { if (typeof fieldValue === 'string') { if (secretFieldPattern.test(fieldName) || shouldMaskLoginField(fieldName, row)) { result[fieldName] = maskCredentialValue(fieldValue); return; } } result[fieldName] = maskCrudRowSensitiveFields(fieldValue); }); return result as T; } function applyFilters(query: Knex.QueryBuilder, filters: z.infer[] = []) { filters.forEach((filter) => { const field = assertIdentifier(filter.field, 'field'); switch (filter.operator) { case 'eq': query.where(field, filter.value); break; case 'ne': query.whereNot(field, filter.value); break; case 'gt': query.where(field, '>', filter.value); break; case 'gte': query.where(field, '>=', filter.value); break; case 'lt': query.where(field, '<', filter.value); break; case 'lte': query.where(field, '<=', filter.value); break; case 'like': query.where(field, 'like', filter.value); break; case 'in': query.whereIn(field, Array.isArray(filter.value) ? filter.value : [filter.value]); break; case 'null': query.whereNull(field); break; case 'notNull': query.whereNotNull(field); break; default: break; } }); } export async function registerCrudRoutes(app: FastifyInstance) { function getRequestTraceContext(request: FastifyRequest) { return { ip: request.ip, remoteAddress: request.raw.socket.remoteAddress, host: request.headers.host, origin: request.headers.origin, referer: request.headers.referer, userAgent: request.headers['user-agent'], clientId: request.headers['x-client-id'], }; } function summarizeCrudUpdatePayload(table: string, payload: z.infer) { if (table !== 'board_posts') { return { dataKeys: Object.keys(payload.data), where: payload.where, }; } return { dataKeys: Object.keys(payload.data), where: payload.where, automationPlanItemId: payload.data.automation_plan_item_id ?? null, automationReceivedAt: payload.data.automation_received_at ?? null, title: typeof payload.data.title === 'string' ? payload.data.title : undefined, contentLength: typeof payload.data.content === 'string' ? payload.data.content.length : undefined, }; } app.post('/api/crud/:table/select', async (request) => { const table = assertIdentifier((request.params as { table: string }).table, 'table name'); const payload = selectSchema.parse(request.body ?? {}); const columns = payload.columns?.map((column) => assertIdentifier(column, 'column')) ?? ['*']; const query = db(table).select(columns); applyFilters(query, payload.where); payload.orderBy?.forEach((order) => { query.orderBy(assertIdentifier(order.field, 'order field'), order.direction); }); if (payload.limit) { query.limit(payload.limit); } if (payload.offset) { query.offset(payload.offset); } const rows = await query; return { ok: true, table, count: rows.length, rows: maskCrudRowSensitiveFields(rows), }; }); app.post('/api/crud/:table/insert', async (request) => { const table = assertIdentifier((request.params as { table: string }).table, 'table name'); const payload = insertSchema.parse(request.body); const inserted = await db(table).insert(payload.data).returning('*'); return { ok: true, table, rows: maskCrudRowSensitiveFields(inserted), }; }); app.patch('/api/crud/:table/update', async (request, reply) => { const table = assertIdentifier((request.params as { table: string }).table, 'table name'); const payload = updateSchema.parse(request.body); const query = db(table); applyFilters(query, payload.where); if (table === 'board_posts') { request.log.warn( { table, payload: summarizeCrudUpdatePayload(table, payload), trace: getRequestTraceContext(request), }, 'Board post CRUD update requested', ); const protectedFields = Object.keys(payload.data).filter((field) => protectedBoardPostAutomationFields.has(field)); if (protectedFields.length) { request.log.warn( { table, protectedFields, payload: summarizeCrudUpdatePayload(table, payload), trace: getRequestTraceContext(request), }, 'Board post CRUD update blocked from changing automation link fields', ); return reply.code(409).send({ message: '자동화 접수 연결 필드는 일반 CRUD 수정으로 변경할 수 없습니다.', protectedFields, }); } } const rows = await query.update(payload.data).returning('*'); return { ok: true, table, count: rows.length, rows: maskCrudRowSensitiveFields(rows), }; }); app.delete('/api/crud/:table/delete', async (request) => { const table = assertIdentifier((request.params as { table: string }).table, 'table name'); const payload = deleteSchema.parse(request.body ?? {}); const query = db(table); applyFilters(query, payload.where); const rows = await query.delete().returning('*'); return { ok: true, table, count: rows.length, rows: maskCrudRowSensitiveFields(rows), }; }); }