276 lines
8.1 KiB
TypeScript
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),
|
|
};
|
|
});
|
|
}
|