'use server'; import db from '@/db/db'; import { eq, and, desc, sql, count, ilike, asc, or } from 'drizzle-orm'; import handlebars from 'handlebars'; import i18next from 'i18next'; import resourcesToBackend from 'i18next-resources-to-backend'; import { getOptions } from '@/i18n/settings'; // Schema imports import { templateListView, templateDetailView, type TemplateListView, type TemplateDetailView } from '@/db/schema'; import { templates, templateVariables, templateHistory, type Template, type TemplateVariable, type TemplateHistory } from '@/db/schema/templates'; // Validation imports import { GetEmailTemplateSchema } from './validations'; import { filterColumns } from '../filter-columns'; // =========================================== // Types // =========================================== export interface TemplateWithVariables extends Template { variables: TemplateVariable[]; } interface ValidationResult { isValid: boolean; errors: string[]; warnings: string[]; } // =========================================== // Security Constants & Functions // =========================================== const ALLOWED_HELPERS = ['if', 'unless', 'each', 'with', 'eq', 'ne']; const DANGEROUS_PATTERNS = [ /\{\{\s*(constructor|prototype|__proto__|process|global|require|import|eval|Function)\s*\}\}/gi, /\{\{\s*.*\.(constructor|prototype|__proto__)\s*.*\}\}/gi, /\{\{\s*.*\[\s*['"`]constructor['"`]\s*\]\s*.*\}\}/gi, /\{\{\s*.*require\s*\(.*\)\s*.*\}\}/gi, /\{\{\s*.*process\s*\..*\}\}/gi, /\{\{\s*.*global\s*\..*\}\}/gi, /\{\{\s*.*this\s*\..*\}\}/gi, /\{\{\s*#with\s+.*\.\.\s*\}\}/gi, ]; function validateTemplateContent(content: string, allowedVariables: string[] = []): ValidationResult { const errors: string[] = []; const warnings: string[] = []; // 위험한 패턴 검사 for (const pattern of DANGEROUS_PATTERNS) { if (pattern.test(content)) { errors.push('보안상 위험한 구문이 감지되었습니다.'); break; } } // 허용된 변수 검사 if (allowedVariables.length > 0) { const variableMatches = content.match(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g); if (variableMatches) { const usedVariables = new Set(); for (const match of variableMatches) { const variable = match.replace(/\{\{\s*|\s*\}\}/g, ''); usedVariables.add(variable); } for (const variable of usedVariables) { if (!allowedVariables.includes(variable)) { errors.push(`허용되지 않은 변수 '${variable}'가 사용되었습니다.`); } } } } // 허용된 헬퍼 검사 const helperMatches = content.match(/\{\{\s*#([a-zA-Z_][a-zA-Z0-9_]*)/g); if (helperMatches) { const usedHelpers = new Set(); for (const match of helperMatches) { const helper = match.replace(/\{\{\s*#/, ''); usedHelpers.add(helper); } for (const helper of usedHelpers) { if (!ALLOWED_HELPERS.includes(helper)) { errors.push(`허용되지 않은 헬퍼 '${helper}'가 사용되었습니다.`); } } } // HTML 출력 검사 const htmlOutputMatches = content.match(/\{\{\{[^}]+\}\}\}/g); if (htmlOutputMatches) { warnings.push('HTML 출력 구문이 감지되었습니다. 보안상 일반 출력으로 변환됩니다.'); } return { isValid: errors.length === 0, errors, warnings }; } function sanitizeTripleBraces(content: string): string { return content.replace(/\{\{\{([^}]+)\}\}\}/g, (match, variable) => { return `{{${variable.trim()}}}`; }); } function preprocessTemplate(content: string): string { let processed = sanitizeTripleBraces(content); processed = processed.replace(/[\u200B-\u200D\uFEFF]/g, ''); processed = processed.replace(/\s+/g, ' ').trim(); return processed; } function createSafeContext(data: Record, allowedVariables: string[]): Record { const safeContext: Record = {}; for (const key of allowedVariables) { if (key in data) { const value = data[key]; if (typeof value === 'string') { safeContext[key] = value.replace(/<[^>]*>/g, ''); } else if (typeof value === 'number' || typeof value === 'boolean') { safeContext[key] = value; } else { safeContext[key] = String(value); } } } return safeContext; } // =========================================== // Handlebars Initialization // =========================================== let initialized = false; async function initializeAsync(): Promise { if (initialized) return; try { if (!i18next.isInitialized) { await i18next .use(resourcesToBackend((language: string, namespace: string) => import(`@/i18n/locales/${language}/${namespace}.json`) )) .init(getOptions()); } registerHandlebarsHelpers(); initialized = true; } catch (error) { console.error('Failed to initialize TemplateService:', error); registerHandlebarsHelpers(); initialized = true; } } async function ensureInitialized(): Promise { if (initialized) return; await initializeAsync(); } function registerHandlebarsHelpers(): void { handlebars.registerHelper('t', function(key: string, options: { hash?: Record }) { return i18next.t(key, options.hash || {}); }); handlebars.registerHelper('formatDate', function(date: Date | string, format?: string) { if (!date) return ''; const d = new Date(date); if (isNaN(d.getTime())) return ''; if (!format || format === 'date') { return d.toISOString().split('T')[0]; } if (format === 'datetime') { return d.toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } return d.toLocaleDateString('ko-KR'); }); handlebars.registerHelper('formatNumber', function(number: number | string) { if (typeof number === 'string') { number = parseFloat(number); } if (isNaN(number)) return ''; return number.toLocaleString('ko-KR'); }); handlebars.registerHelper('ifEquals', function(this: unknown, arg1: unknown, arg2: unknown, options: { fn: (context: unknown) => string; inverse: (context: unknown) => string }) { return (arg1 === arg2) ? options.fn(this) : options.inverse(this); }); handlebars.registerHelper('ifArrayLength', function(this: unknown, array: unknown[], length: number, options: { fn: (context: unknown) => string; inverse: (context: unknown) => string }) { return (Array.isArray(array) && array.length === length) ? options.fn(this) : options.inverse(this); }); } // =========================================== // Core Service Functions // =========================================== /** * 템플릿 목록 조회 (View 테이블 사용) */ export async function getTemplateList(input: GetEmailTemplateSchema) { try { const offset = (input.page - 1) * input.perPage; const advancedWhere = filterColumns({ table: templateListView, filters: input.filters, joinOperator: input.joinOperator, }); // 전역 검색 조건 구성 let globalWhere; if (input.search) { try { const s = `%${input.search}%`; globalWhere = or( ilike(templateListView.name, s), ilike(templateListView.slug, s), ilike(templateListView.description, s), ilike(templateListView.category, s) ); } catch (searchErr) { console.error("Error building search where:", searchErr); globalWhere = undefined; } } const conditions = []; if (advancedWhere) conditions.push(advancedWhere); if (globalWhere) conditions.push(globalWhere); let finalWhere; if (conditions.length > 0) { finalWhere = conditions.length > 1 ? and(...conditions) : conditions[0]; } // 아니면 ilike, inArray, gte 등으로 where 절 구성 const where = finalWhere // 정렬 조건 구성 let orderBy; try { orderBy = input.sort.length > 0 ? input.sort .map((item) => { if (!item || !item.id || typeof item.id !== "string" || !(item.id in templateListView)) return null; const col = templateListView[item.id as keyof typeof templateListView]; return item.desc ? desc(col) : asc(col); }) .filter((v): v is Exclude => v !== null) : [desc(templateListView.updatedAt)]; // 기본값: 최신 수정일 순 } catch (orderErr) { console.error("Error building order by:", orderErr); orderBy = [desc(templateListView.updatedAt)]; } // 데이터 조회 const data = await db .select() .from(templateListView) .where(where) .orderBy(...orderBy) .limit(input.perPage) .offset(offset); // 총 개수 조회 const totalResult = await db .select({ count: count() }) .from(templateListView) .where(where); const total = totalResult[0]?.count ?? 0; const pageCount = Math.ceil(total / input.perPage); return { data, pageCount }; } catch (error) { console.error('Error getting template list:', error); return { data: [], pageCount: 0 }; } } /** * 특정 템플릿 조회 (slug 기준, 변수 포함) */ export async function getTemplate(slug: string): Promise { try { // 템플릿 기본 정보 조회 const [template] = await db .select() .from(templates) .where(and(eq(templates.slug, slug), eq(templates.isActive, true))) .limit(1); if (!template) { return null; } // 템플릿 변수들 조회 const variables = await db .select() .from(templateVariables) .where(eq(templateVariables.templateId, template.id)) .orderBy(templateVariables.displayOrder); return { ...template, variables }; } catch (error) { console.error('Error getting template:', error); throw new Error('템플릿을 가져오는데 실패했습니다.'); } } /** * 템플릿 생성 */ export async function createTemplate(data: { name: string; slug: string; subject: string; content: string; category?: string; description?: string; sampleData?: Record; createdBy: number; variables?: Array<{ variableName: string; variableType: string; defaultValue?: string; isRequired?: boolean; description?: string; }>; }): Promise { try { // slug 중복 확인 const existingTemplate = await db .select({ id: templates.id }) .from(templates) .where(eq(templates.slug, data.slug)) .limit(1); if (existingTemplate.length > 0) { throw new Error('이미 존재하는 slug입니다.'); } // 템플릿 생성 const [newTemplate] = await db .insert(templates) .values({ name: data.name, slug: data.slug, subject: data.subject, content: data.content, category: data.category, description: data.description, sampleData: data.sampleData || {}, createdBy: data.createdBy, }) .returning(); // 변수들 생성 if (data.variables && data.variables.length > 0) { const variableData = data.variables.map((variable, index) => ({ templateId: newTemplate.id, variableName: variable.variableName, variableType: variable.variableType, defaultValue: variable.defaultValue, isRequired: variable.isRequired || false, description: variable.description, displayOrder: index, })); await db.insert(templateVariables).values(variableData); } // 생성된 템플릿 조회 및 반환 const result = await getTemplate(data.slug); if (!result) { throw new Error('생성된 템플릿을 조회할 수 없습니다.'); } return result; } catch (error) { console.error('Error creating template:', error); throw error; } } /** * 템플릿 업데이트 */ export async function updateTemplate(slug: string, data: { name?: string; subject?: string; content?: string; description?: string; sampleData?: Record; updatedBy: number; }): Promise { try { const existingTemplate = await getTemplate(slug); if (!existingTemplate) { throw new Error('템플릿을 찾을 수 없습니다.'); } // 버전 히스토리 저장 await db.insert(templateHistory).values({ templateId: existingTemplate.id, version: existingTemplate.version, subject: existingTemplate.subject, content: existingTemplate.content, changeDescription: '템플릿 업데이트', changedBy: data.updatedBy, }); // 템플릿 업데이트 await db .update(templates) .set({ name: data.name || existingTemplate.name, content: data.content || existingTemplate.content, subject: data.subject || existingTemplate.subject, description: data.description || existingTemplate.description, sampleData: data.sampleData || existingTemplate.sampleData, version: existingTemplate.version + 1, updatedAt: new Date(), }) .where(eq(templates.id, existingTemplate.id)); const result = await getTemplate(slug); if (!result) { throw new Error('업데이트된 템플릿을 조회할 수 없습니다.'); } return result; } catch (error) { console.error('Error updating template:', error); throw error; } } /** * 템플릿 변수 추가 */ export async function addTemplateVariable(slug: string, variable: { variableName: string; variableType: string; defaultValue?: string; isRequired?: boolean; description?: string; }): Promise { try { const template = await getTemplate(slug); if (!template) { throw new Error('템플릿을 찾을 수 없습니다.'); } // 변수명 중복 확인 const existingVariable = template.variables.find(v => v.variableName === variable.variableName); if (existingVariable) { throw new Error('이미 존재하는 변수명입니다.'); } // 새 변수 추가 const [newVariable] = await db .insert(templateVariables) .values({ templateId: template.id, variableName: variable.variableName, variableType: variable.variableType, defaultValue: variable.defaultValue, isRequired: variable.isRequired || false, description: variable.description, displayOrder: template.variables.length, }) .returning(); return newVariable; } catch (error) { console.error('Error adding template variable:', error); throw error; } } /** * 템플릿 삭제 (소프트 삭제) */ export async function deleteTemplate(id: string): Promise<{ success: boolean; error?: string }> { try { await db .update(templates) .set({ isActive: false, updatedAt: new Date() }) .where(eq(templates.id, id)); return { success: true }; } catch (error) { console.error('Error deleting template:', error); return { success: false, error: error instanceof Error ? error.message : '템플릿 삭제에 실패했습니다.' }; } } /** * 템플릿 일괄 삭제 */ export async function bulkDeleteTemplates(ids: string[]): Promise<{ success: boolean; error?: string }> { try { await db .update(templates) .set({ isActive: false, updatedAt: new Date() }) .where(sql`${templates.id} = ANY(${ids})`); return { success: true }; } catch (error) { console.error('Error bulk deleting templates:', error); return { success: false, error: error instanceof Error ? error.message : '템플릿 일괄 삭제에 실패했습니다.' }; } } /** * 템플릿 복제 */ export async function duplicateTemplate( id: string, newName: string, newSlug: string, userId: string ): Promise<{ success: boolean; error?: string; data?: any }> { try { // 원본 템플릿 조회 (변수 포함) const [originalTemplate] = await db .select() .from(templates) .where(eq(templates.id, id)) .limit(1); if (!originalTemplate) { return { success: false, error: '원본 템플릿을 찾을 수 없습니다.' }; } // 원본 템플릿의 변수들 조회 const originalVariables = await db .select() .from(templateVariables) .where(eq(templateVariables.templateId, id)) .orderBy(templateVariables.displayOrder); // slug 중복 확인 const [existingTemplate] = await db .select({ id: templates.id }) .from(templates) .where(eq(templates.slug, newSlug)) .limit(1); if (existingTemplate) { return { success: false, error: '이미 존재하는 slug입니다.' }; } // 새 템플릿 생성 const [newTemplate] = await db .insert(templates) .values({ name: newName, slug: newSlug, content: originalTemplate.content, description: originalTemplate.description ? `${originalTemplate.description} (복사본)` : undefined, category: originalTemplate.category, sampleData: originalTemplate.sampleData, createdBy: userId, version: 1 }) .returning(); // 변수들 복제 if (originalVariables.length > 0) { const variableData = originalVariables.map((variable) => ({ templateId: newTemplate.id, variableName: variable.variableName, variableType: variable.variableType, defaultValue: variable.defaultValue, isRequired: variable.isRequired, description: variable.description, validationRule: variable.validationRule, displayOrder: variable.displayOrder, })); await db.insert(templateVariables).values(variableData); } return { success: true, data: { id: newTemplate.id, slug: newTemplate.slug } }; } catch (error) { console.error('Error duplicating template:', error); return { success: false, error: error instanceof Error ? error.message : '템플릿 복제에 실패했습니다.' }; } } /** * 템플릿 미리보기 */ export async function previewTemplate(slug: string, data?: Record, customContent?: string): Promise { try { await ensureInitialized(); const template = await getTemplate(slug); if (!template) { throw new Error('템플릿을 찾을 수 없습니다.'); } const content = customContent || template.content; const allowedVariables = template.variables.map(v => v.variableName); // 보안 검증 const validation = validateTemplateContent(content, allowedVariables); if (!validation.isValid) { throw new Error(`보안 검증 실패: ${validation.errors.join(', ')}`); } // 템플릿 전처리 const processedContent = preprocessTemplate(content); // 안전한 컨텍스트 생성 const contextData = data || template.sampleData || {}; const safeContext = createSafeContext(contextData, allowedVariables); // 템플릿 컴파일 및 렌더링 const compiledTemplate = handlebars.compile(processedContent, { noEscape: false, strict: true, }); return compiledTemplate(safeContext); } catch (error) { console.error('Error previewing template:', error); throw error; } } // =========================================== // Server Actions // =========================================== export async function getTemplatesAction(input: GetEmailTemplateSchema) { try { const result = await getTemplateList(input); return { success: true, data: result.data, pageCount: result.pageCount }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : '템플릿 목록을 가져오는데 실패했습니다.', data: [], pageCount: 0 }; } } export async function getTemplateAction(slug: string) { try { const template = await getTemplate(slug); if (!template) { return { success: false, error: '템플릿을 찾을 수 없습니다.' }; } return { success: true, data: template }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : '템플릿을 가져오는데 실패했습니다.' }; } } export async function createTemplateAction(data: { name: string; slug: string; subject: string; content: string; category?: string; description?: string; sampleData?: Record; createdBy: number; variables?: Array<{ variableName: string; variableType: string; defaultValue?: string; isRequired?: boolean; description?: string; }>; }) { try { // 보안 검증 const allowedVariables = data.variables?.map(v => v.variableName) || []; const validation = validateTemplateContent(data.content, allowedVariables); if (!validation.isValid) { return { success: false, error: `보안 검증 실패: ${validation.errors.join(', ')}` }; } // 전처리 const processedContent = preprocessTemplate(data.content); const template = await createTemplate({ ...data, content: processedContent }); return { success: true, data: { slug: template.slug } }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : '템플릿 생성에 실패했습니다.' }; } } export async function updateTemplateAction(slug: string, data: { name?: string; subject?: string; content?: string; description?: string; sampleData?: Record; updatedBy: number; }) { try { if (data.content) { // 기존 템플릿의 허용 변수 조회 const existingTemplate = await getTemplate(slug); const allowedVariables = existingTemplate?.variables.map(v => v.variableName) || []; // 보안 검증 const validation = validateTemplateContent(data.content, allowedVariables); if (!validation.isValid) { return { success: false, error: `보안 검증 실패: ${validation.errors.join(', ')}` }; } // 전처리 data.content = preprocessTemplate(data.content); } const template = await updateTemplate(slug, data); return { success: true, data: template }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : '템플릿 수정에 실패했습니다.' }; } } export async function addTemplateVariableAction(slug: string, variable: { variableName: string; variableType: string; defaultValue?: string; isRequired?: boolean; description?: string; }) { try { const newVariable = await addTemplateVariable(slug, variable); return { success: true, data: newVariable }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : '변수 추가에 실패했습니다.' }; } } export async function previewTemplateAction( slug: string, data: Record, customContent?: string, subject?: string ) { try { const html = await previewTemplate(slug, data, customContent); const subjectHtml = await previewTemplate(slug, data, subject); return { success: true, data: { html , subjectHtml} }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : '미리보기 생성에 실패했습니다.' }; } } export async function getTemplateSchemaAction(slug: string) { try { const template = await getTemplate(slug); if (!template) { return { success: false, error: '템플릿을 찾을 수 없습니다.' }; } const schema = { allowedVariables: template.variables.map(v => v.variableName), allowedHelpers: ALLOWED_HELPERS, templateType: template.category || 'general', sampleData: template.sampleData || {}, variables: template.variables }; return { success: true, data: schema }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : '스키마 조회에 실패했습니다.' }; } }