summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:19:52 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:19:52 +0000
commit9da494b0e3bbe7b513521d0915510fe9ee376b8b (patch)
treef936f69626bf2808ac409ce7cad97433465b3672 /components
parente275618ff8a1ce6977d3e2567d943edb941897f9 (diff)
(대표님, 최겸) 작업사항 - 이메일 템플릿, 메일링, 기술영업 요구사항 반영
Diffstat (limited to 'components')
-rw-r--r--components/mail/mail-template-editor-client.tsx205
-rw-r--r--components/template-editor/html-code-editor.tsx194
2 files changed, 367 insertions, 32 deletions
diff --git a/components/mail/mail-template-editor-client.tsx b/components/mail/mail-template-editor-client.tsx
index dfbeb4e0..c9770aec 100644
--- a/components/mail/mail-template-editor-client.tsx
+++ b/components/mail/mail-template-editor-client.tsx
@@ -5,7 +5,7 @@ import { useRouter, useParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
-import { Save, Eye } from 'lucide-react';
+import { Save, Eye, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
import Link from 'next/link';
import { getTemplateAction, updateTemplateAction, previewTemplateAction, TemplateFile } from '@/lib/mail/service';
@@ -17,13 +17,81 @@ interface MailTemplateEditorClientProps {
initialTemplate?: Template | null;
}
-export default function MailTemplateEditorClient({
- templateName,
- initialTemplate
+// 보안: 허용된 Handlebars 헬퍼와 변수만 정의
+const ALLOWED_VARIABLES = [
+ 'userName', 'companyName', 'email', 'date', 'projectName',
+ 'message', 'currentYear', 'language', 'name', 'loginUrl'
+];
+
+const ALLOWED_HELPERS = ['if', 'unless', 'each', 'with'];
+
+// 보안: 위험한 패턴 탐지
+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, // path traversal
+];
+
+// 보안: 템플릿 내용 검증
+const validateTemplateContent = (content: string): { isValid: boolean; errors: string[] } => {
+ const errors: string[] = [];
+
+ // 위험한 패턴 검사
+ for (const pattern of DANGEROUS_PATTERNS) {
+ if (pattern.test(content)) {
+ errors.push('보안상 위험한 구문이 감지되었습니다.');
+ break;
+ }
+ }
+
+ // 허용되지 않은 변수 검사
+ const variableMatches = content.match(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g);
+ if (variableMatches) {
+ for (const match of variableMatches) {
+ const variable = match.replace(/\{\{\s*|\s*\}\}/g, '');
+ if (!ALLOWED_VARIABLES.includes(variable)) {
+ errors.push(`허용되지 않은 변수 '${variable}'가 사용되었습니다.`);
+ }
+ }
+ }
+
+ // 허용되지 않은 헬퍼 검사
+ const helperMatches = content.match(/\{\{\s*#([a-zA-Z_][a-zA-Z0-9_]*)/g);
+ if (helperMatches) {
+ for (const match of helperMatches) {
+ const helper = match.replace(/\{\{\s*#/, '');
+ if (!ALLOWED_HELPERS.includes(helper)) {
+ errors.push(`허용되지 않은 헬퍼 '${helper}'가 사용되었습니다.`);
+ }
+ }
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors
+ };
+};
+
+// 보안: HTML 출력 무력화 ({{{html}}} 형태 방지)
+const sanitizeTripleBraces = (content: string): string => {
+ return content.replace(/\{\{\{([^}]+)\}\}\}/g, (match, variable) => {
+ // HTML 출력을 일반 변수 출력으로 변환
+ return `{{${variable.trim()}}}`;
+ });
+};
+
+export default function MailTemplateEditorClient({
+ templateName,
+ initialTemplate
}: MailTemplateEditorClientProps) {
const router = useRouter();
const params = useParams();
-
+
const lng = (params?.lng as string) || 'ko';
const [template, setTemplate] = useState<Template | null>(initialTemplate || null);
@@ -32,8 +100,15 @@ export default function MailTemplateEditorClient({
const [saving, setSaving] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
+ const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [, startTransition] = useTransition();
+ // 보안: 실시간 검증
+ useEffect(() => {
+ const validation = validateTemplateContent(content);
+ setValidationErrors(validation.errors);
+ }, [content]);
+
// 템플릿 조회
const fetchTemplate = async () => {
if (!templateName) {
@@ -71,10 +146,19 @@ export default function MailTemplateEditorClient({
return;
}
+ // 보안: 저장 전 검증
+ const validation = validateTemplateContent(content);
+ if (!validation.isValid) {
+ toast.error('보안 검증에 실패했습니다. 오류를 확인해주세요.');
+ return;
+ }
+
try {
setSaving(true);
startTransition(async () => {
- const result = await updateTemplateAction(templateName, content);
+ // 보안: HTML 출력 방지 처리
+ const sanitizedContent = sanitizeTripleBraces(content);
+ const result = await updateTemplateAction(templateName, sanitizedContent);
if (result.success && result.data) {
toast.success('템플릿이 성공적으로 저장되었습니다.');
@@ -93,9 +177,19 @@ export default function MailTemplateEditorClient({
// 미리보기 생성
const handlePreview = async () => {
+ // 보안: 미리보기 전 검증
+ const validation = validateTemplateContent(content);
+ if (!validation.isValid) {
+ toast.error('보안 검증에 실패했습니다. 오류를 확인해주세요.');
+ return;
+ }
+
try {
setPreviewLoading(true);
startTransition(async () => {
+ // 보안: HTML 출력 방지 처리
+ const sanitizedContent = sanitizeTripleBraces(content);
+
const result = await previewTemplateAction(
templateName,
{
@@ -109,7 +203,8 @@ export default function MailTemplateEditorClient({
language: 'ko',
name: '홍길동',
loginUrl: 'https://example.com/login'
- }
+ },
+ sanitizedContent // 보안: 검증된 내용만 전달
);
if (result.success && result.data) {
@@ -152,6 +247,8 @@ export default function MailTemplateEditorClient({
);
}
+ const hasValidationErrors = validationErrors.length > 0;
+
return (
<div className="space-y-8">
{/* 헤더 */}
@@ -169,12 +266,29 @@ export default function MailTemplateEditorClient({
</div>
</div>
+ {/* 보안 경고 */}
+ {hasValidationErrors && (
+ <div className="bg-red-50 border border-red-200 rounded-lg p-4">
+ <div className="flex items-start gap-3">
+ <AlertTriangle className="h-5 w-5 text-red-600 mt-0.5" />
+ <div>
+ <h3 className="font-semibold text-red-800">보안 검증 오류</h3>
+ <ul className="mt-2 text-sm text-red-700 space-y-1">
+ {validationErrors.map((error, index) => (
+ <li key={index}>• {error}</li>
+ ))}
+ </ul>
+ </div>
+ </div>
+ </div>
+ )}
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 편집 영역 */}
<div className="space-y-6">
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">템플릿 내용</h2>
-
+
<div className="space-y-4">
<div>
<Label htmlFor="content">Handlebars 템플릿</Label>
@@ -182,16 +296,27 @@ export default function MailTemplateEditorClient({
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
- className="min-h-[500px] font-mono text-sm"
+ className={`min-h-[500px] font-mono text-sm ${
+ hasValidationErrors ? 'border-red-300 focus:border-red-500' : ''
+ }`}
/>
</div>
<div className="flex gap-2">
- <Button onClick={handleSave} disabled={saving}>
+ <Button
+ onClick={handleSave}
+ disabled={saving || hasValidationErrors}
+ className={hasValidationErrors ? 'opacity-50 cursor-not-allowed' : ''}
+ >
<Save className="h-4 w-4 mr-2" />
{saving ? '저장 중...' : '저장'}
</Button>
- <Button variant="outline" onClick={handlePreview} disabled={previewLoading}>
+ <Button
+ variant="outline"
+ onClick={handlePreview}
+ disabled={previewLoading || hasValidationErrors}
+ className={hasValidationErrors ? 'opacity-50 cursor-not-allowed' : ''}
+ >
<Eye className="h-4 w-4 mr-2" />
{previewLoading ? '생성 중...' : '미리보기'}
</Button>
@@ -204,13 +329,18 @@ export default function MailTemplateEditorClient({
<div className="space-y-6">
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">빠른 미리보기</h2>
-
+
<div className="border rounded-lg p-4 min-h-[500px] bg-gray-50 overflow-auto">
{previewHtml ? (
- <div
- className="preview-content"
- dangerouslySetInnerHTML={{ __html: previewHtml }}
- />
+ <div className="preview-content">
+ {/* 보안: 더 안전한 HTML 렌더링 */}
+ <iframe
+ srcDoc={previewHtml}
+ sandbox="allow-same-origin"
+ className="w-full h-96 border-0"
+ title="Template Preview"
+ />
+ </div>
) : (
<div className="text-center text-gray-500 py-20">
미리보기 버튼을 클릭하여 결과를 확인하세요.
@@ -219,14 +349,25 @@ export default function MailTemplateEditorClient({
</div>
</div>
- {/* 도움말 */}
+ {/* 보안 가이드라인 */}
+ <div className="bg-amber-50 border border-amber-200 p-4 rounded-lg">
+ <h3 className="font-semibold text-amber-900 mb-2">보안 가이드라인</h3>
+ <div className="text-sm text-amber-800 space-y-1">
+ <p>• 허용된 변수만 사용하세요: {ALLOWED_VARIABLES.join(', ')}</p>
+ <p>• 허용된 헬퍼만 사용하세요: {ALLOWED_HELPERS.join(', ')}</p>
+ <p>• HTML 출력({`{{{}}}`})은 자동으로 일반 출력으로 변환됩니다</p>
+ <p>• 시스템 관련 변수나 함수 접근은 차단됩니다</p>
+ </div>
+ </div>
+
+ {/* Handlebars 문법 도움말 */}
<div className="bg-blue-50 p-4 rounded-lg">
<h3 className="font-semibold text-blue-900 mb-2">Handlebars 문법 도움말</h3>
<div className="text-sm text-blue-800 space-y-1">
- <p><code>{`{{variable}}`}</code> - 변수 출력</p>
- <p><code>{`{{{html}}}`}</code> - HTML 출력 (이스케이프 없음)</p>
+ <p><code>{`{{variable}}`}</code> - 변수 출력 (자동 이스케이프)</p>
<p><code>{`{{#if condition}}`}</code> - 조건문</p>
<p><code>{`{{#each items}}`}</code> - 반복문</p>
+ <p><code>{`{{#unless condition}}`}</code> - 부정 조건문</p>
</div>
</div>
@@ -234,22 +375,22 @@ export default function MailTemplateEditorClient({
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-semibold text-gray-900 mb-2">미리보기 샘플 데이터</h3>
<pre className="text-xs text-gray-600 overflow-auto">
-{JSON.stringify({
- userName: '홍길동',
- companyName: 'EVCP',
- email: 'user@example.com',
- date: new Date().toLocaleDateString('ko-KR'),
- projectName: '샘플 프로젝트',
- message: '이것은 샘플 메시지입니다.',
- currentYear: new Date().getFullYear(),
- language: 'ko',
- name: '홍길동',
- loginUrl: 'https://example.com/login'
-}, null, 2)}
+ {JSON.stringify({
+ userName: '홍길동',
+ companyName: 'EVCP',
+ email: 'user@example.com',
+ date: new Date().toLocaleDateString('ko-KR'),
+ projectName: '샘플 프로젝트',
+ message: '이것은 샘플 메시지입니다.',
+ currentYear: new Date().getFullYear(),
+ language: 'ko',
+ name: '홍길동',
+ loginUrl: 'https://example.com/login'
+ }, null, 2)}
</pre>
</div>
</div>
</div>
</div>
);
-} \ No newline at end of file
+} \ No newline at end of file
diff --git a/components/template-editor/html-code-editor.tsx b/components/template-editor/html-code-editor.tsx
new file mode 100644
index 00000000..de34ab96
--- /dev/null
+++ b/components/template-editor/html-code-editor.tsx
@@ -0,0 +1,194 @@
+// components/template-editor/html-code-editor.tsx
+"use client"
+
+import React from 'react'
+import { EditorView, basicSetup } from 'codemirror'
+import { EditorState } from '@codemirror/state'
+import { html } from '@codemirror/lang-html'
+import { oneDark } from '@codemirror/theme-one-dark'
+import { bracketMatching } from '@codemirror/language'
+import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
+import { keymap } from '@codemirror/view'
+import { indentWithTab } from '@codemirror/commands'
+
+interface HtmlCodeEditorProps {
+ value: string
+ onChange: (value: string) => void
+ placeholder?: string
+ height?: string
+ darkMode?: boolean
+ readOnly?: boolean
+}
+
+interface EditorHandle {
+ focus: () => void
+ insertText: (text: string) => void
+ getEditor: () => EditorView | null
+}
+
+export const HtmlCodeEditor = React.forwardRef<EditorHandle, HtmlCodeEditorProps>(
+ ({
+ value,
+ onChange,
+ placeholder: placeholderText = "HTML 템플릿을 입력하세요...",
+ height = "500px",
+ darkMode = false,
+ readOnly = false
+ }, ref) => {
+ const editorRef = React.useRef<HTMLDivElement>(null)
+ const viewRef = React.useRef<EditorView | null>(null)
+
+ React.useEffect(() => {
+ if (!editorRef.current) return
+
+ // 간단한 CSS 기반 Handlebars 하이라이팅
+ const handlebarsTheme = EditorView.theme({
+ '.cm-line': {
+ lineHeight: '1.6'
+ },
+ '.cm-content': {
+ fontSize: '14px',
+ fontFamily: '"Fira Code", "JetBrains Mono", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
+ },
+ '.cm-editor': {
+ borderRadius: '8px',
+ border: darkMode ? '1px solid #374151' : '1px solid #e2e8f0'
+ },
+ '.cm-focused': {
+ outline: 'none',
+ borderColor: darkMode ? '#60a5fa' : '#3b82f6'
+ },
+ '.cm-scroller': {
+ fontFamily: 'inherit'
+ },
+ // CSS 기반 placeholder 구현
+ '.cm-editor.cm-focused .cm-line': {
+ '&:first-child:only-child:before': {
+ content: value === '' ? `"${placeholderText}"` : '""',
+ color: darkMode ? '#6b7280' : '#9ca3af',
+ fontStyle: 'italic',
+ pointerEvents: 'none',
+ position: 'absolute'
+ }
+ },
+ '.cm-editor:not(.cm-focused) .cm-line': {
+ '&:first-child:only-child:before': {
+ content: value === '' ? `"${placeholderText}"` : '""',
+ color: darkMode ? '#6b7280' : '#9ca3af',
+ fontStyle: 'italic',
+ pointerEvents: 'none',
+ position: 'absolute'
+ }
+ }
+ })
+
+ const startState = EditorState.create({
+ doc: value,
+ extensions: [
+ basicSetup,
+ html(),
+ bracketMatching(),
+ highlightSelectionMatches(),
+ keymap.of([
+ indentWithTab,
+ ...searchKeymap,
+ {
+ key: 'Ctrl-s',
+ preventDefault: true,
+ run: () => true
+ }
+ ]),
+ handlebarsTheme,
+ darkMode ? oneDark : [],
+ EditorView.theme({
+ '&': {
+ height: height,
+ fontSize: '14px'
+ },
+ '.cm-content': {
+ minHeight: height,
+ padding: '12px'
+ }
+ }),
+ EditorView.updateListener.of((update) => {
+ if (update.docChanged) {
+ onChange(update.state.doc.toString())
+ }
+ }),
+ EditorState.readOnly.of(readOnly)
+ ]
+ })
+
+ const view = new EditorView({
+ state: startState,
+ parent: editorRef.current
+ })
+
+ viewRef.current = view
+
+ return () => {
+ view.destroy()
+ }
+ }, [darkMode, height, readOnly, placeholderText, value])
+
+ // value 변경 시 에디터 업데이트 (외부에서 변경된 경우)
+ React.useEffect(() => {
+ if (viewRef.current && viewRef.current.state.doc.toString() !== value) {
+ viewRef.current.dispatch({
+ changes: {
+ from: 0,
+ to: viewRef.current.state.doc.length,
+ insert: value
+ }
+ })
+ }
+ }, [value])
+
+ // ref로 노출할 메서드들
+ React.useImperativeHandle(ref, () => ({
+ focus: () => {
+ viewRef.current?.focus()
+ },
+ insertText: (text: string) => {
+ if (!viewRef.current) return
+
+ const view = viewRef.current
+ const { from, to } = view.state.selection.main
+
+ view.dispatch({
+ changes: { from, to, insert: text },
+ selection: { anchor: from + text.length }
+ })
+
+ view.focus()
+ },
+ getEditor: () => viewRef.current
+ }))
+
+ return (
+ <div className="relative">
+ <div ref={editorRef} className="overflow-hidden" />
+
+ {/* 에디터 하단 정보 바 */}
+ <div className="flex items-center justify-between mt-2 px-2 py-1 bg-gray-50 dark:bg-gray-800 rounded text-xs text-gray-500 dark:text-gray-400">
+ <div className="flex items-center gap-4">
+ <span className="flex items-center gap-1">
+ <span className="w-2 h-2 bg-green-500 rounded-full"></span>
+ HTML + Handlebars
+ </span>
+ <span>라인: {value.split('\n').length}</span>
+ <span>문자: {value.length}</span>
+ {value.match(/\{\{[^}]+\}\}/g) && (
+ <span>변수: {value.match(/\{\{[^}]+\}\}/g)?.length}</span>
+ )}
+ </div>
+ <div className="flex items-center gap-3">
+ <span>Ctrl+F: 찾기</span>
+ <span>Tab: 들여쓰기</span>
+ <span>Ctrl+/: 주석</span>
+ </div>
+ </div>
+ </div>
+ )
+ }
+) \ No newline at end of file