Files
ai-code-app/etc/servers/work-server/src/routes/crud.ts

276 lines
8.1 KiB
TypeScript

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<string, unknown>) {
if (!loginFieldPattern.test(fieldName)) {
return false;
}
return Object.keys(row).some((candidateField) => secretFieldPattern.test(candidateField));
}
export function maskCrudRowSensitiveFields<T>(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<string, unknown>;
const result: Record<string, unknown> = {};
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<typeof filterSchema>[] = []) {
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<typeof updateSchema>) {
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),
};
});
}