@things-factory/board-ai 10.0.0-beta.65 → 10.0.0-beta.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/components/markdown.test.ts +78 -0
- package/client/components/markdown.ts +87 -4
- package/client/utils/board-edit-patch.ts +68 -6
- package/dist-client/client/components/markdown.d.ts +8 -0
- package/dist-client/client/components/markdown.js +83 -4
- package/dist-client/client/components/markdown.js.map +1 -1
- package/dist-client/client/components/markdown.test.js +65 -1
- package/dist-client/client/components/markdown.test.js.map +1 -1
- package/dist-client/client/utils/board-edit-patch.js +68 -4
- package/dist-client/client/utils/board-edit-patch.js.map +1 -1
- package/dist-client/server/service/agentic-loop.d.ts +87 -0
- package/dist-client/server/service/agentic-loop.js +274 -0
- package/dist-client/server/service/agentic-loop.js.map +1 -0
- package/dist-client/server/service/assistant.d.ts +8 -0
- package/dist-client/server/service/assistant.js +138 -428
- package/dist-client/server/service/assistant.js.map +1 -1
- package/dist-client/server/service/component-tree.d.ts +68 -0
- package/dist-client/server/service/component-tree.js +68 -0
- package/dist-client/server/service/component-tree.js.map +1 -0
- package/dist-client/server/service/styling/effect-tools.d.ts +7 -1
- package/dist-client/server/service/styling/effect-tools.js +10 -9
- package/dist-client/server/service/styling/effect-tools.js.map +1 -1
- package/dist-client/server/service/styling/fill-tools.d.ts +2 -2
- package/dist-client/server/service/styling/fill-tools.js +4 -4
- package/dist-client/server/service/styling/fill-tools.js.map +1 -1
- package/dist-client/server/service/styling/registry.d.ts +91 -0
- package/dist-client/server/service/styling/registry.js +345 -0
- package/dist-client/server/service/styling/registry.js.map +1 -0
- package/dist-client/server/service/styling/stroke-tools.d.ts +5 -2
- package/dist-client/server/service/styling/stroke-tools.js +13 -13
- package/dist-client/server/service/styling/stroke-tools.js.map +1 -1
- package/dist-client/server/service/validation/board-model-schema.d.ts +6076 -895
- package/dist-client/server/service/validation/board-model-schema.js +122 -4
- package/dist-client/server/service/validation/board-model-schema.js.map +1 -1
- package/dist-client/server/service/validation/tool-validation.js +43 -6
- package/dist-client/server/service/validation/tool-validation.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/agentic-loop.d.ts +87 -0
- package/dist-server/service/agentic-loop.js +278 -0
- package/dist-server/service/agentic-loop.js.map +1 -0
- package/dist-server/service/apply-patch.js +83 -7
- package/dist-server/service/apply-patch.js.map +1 -1
- package/dist-server/service/assistant.d.ts +8 -0
- package/dist-server/service/assistant.js +139 -428
- package/dist-server/service/assistant.js.map +1 -1
- package/dist-server/service/component-tree.d.ts +68 -0
- package/dist-server/service/component-tree.js +73 -0
- package/dist-server/service/component-tree.js.map +1 -0
- package/dist-server/service/styling/effect-tools.d.ts +7 -1
- package/dist-server/service/styling/effect-tools.js +10 -9
- package/dist-server/service/styling/effect-tools.js.map +1 -1
- package/dist-server/service/styling/fill-tools.d.ts +2 -2
- package/dist-server/service/styling/fill-tools.js +4 -4
- package/dist-server/service/styling/fill-tools.js.map +1 -1
- package/dist-server/service/styling/registry.d.ts +91 -0
- package/dist-server/service/styling/registry.js +352 -0
- package/dist-server/service/styling/registry.js.map +1 -0
- package/dist-server/service/styling/stroke-tools.d.ts +5 -2
- package/dist-server/service/styling/stroke-tools.js +13 -13
- package/dist-server/service/styling/stroke-tools.js.map +1 -1
- package/dist-server/service/validation/board-model-schema.d.ts +6149 -968
- package/dist-server/service/validation/board-model-schema.js +124 -5
- package/dist-server/service/validation/board-model-schema.js.map +1 -1
- package/dist-server/service/validation/tool-validation.js +42 -5
- package/dist-server/service/validation/tool-validation.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -4
- package/server/service/agentic-loop.test.ts +705 -0
- package/server/service/agentic-loop.ts +428 -0
- package/server/service/apply-patch-drift.test.ts +516 -0
- package/server/service/apply-patch.ts +90 -9
- package/server/service/assistant-integration.test.ts +4 -4
- package/server/service/assistant.test.ts +14 -3
- package/server/service/assistant.ts +153 -444
- package/server/service/component-tree.test.ts +248 -0
- package/server/service/component-tree.ts +135 -0
- package/server/service/styling/clear-tools.test.ts +178 -0
- package/server/service/styling/effect-tools.test.ts +259 -0
- package/server/service/styling/effect-tools.ts +17 -10
- package/server/service/styling/fill-tools.test.ts +7 -7
- package/server/service/styling/fill-tools.ts +6 -6
- package/server/service/styling/material-tools.test.ts +294 -0
- package/server/service/styling/registry.test.ts +203 -0
- package/server/service/styling/registry.ts +423 -0
- package/server/service/styling/stroke-tools.test.ts +231 -0
- package/server/service/styling/stroke-tools.ts +17 -14
- package/server/service/styling/text-tools.test.ts +235 -0
- package/server/service/styling/tier1-tools.test.ts +19 -18
- package/server/service/validation/board-model-schema.test.ts +478 -15
- package/server/service/validation/board-model-schema.ts +138 -4
- package/server/service/validation/tool-validation.ts +46 -5
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import {
|
|
11
11
|
renderMarkdown,
|
|
12
|
+
sanitizeHtml,
|
|
12
13
|
escapeHtml,
|
|
13
14
|
extractCjkStrongs,
|
|
14
15
|
extractMentions,
|
|
@@ -95,6 +96,83 @@ describe('renderMarkdown — 보안', () => {
|
|
|
95
96
|
})
|
|
96
97
|
})
|
|
97
98
|
|
|
99
|
+
describe('sanitizeHtml — DOMPurify defense-in-depth', () => {
|
|
100
|
+
// Node 환경 (jest) 에선 window 부재 → fallback 동작 검증.
|
|
101
|
+
// 브라우저에서의 실제 정화는 통합 테스트 영역.
|
|
102
|
+
|
|
103
|
+
test('window 부재 시 입력 그대로 통과 (fallback)', () => {
|
|
104
|
+
// 직접 호출 — window undefined 환경에서 DOMPurify 안 도는 게 정상
|
|
105
|
+
const html = '<p>safe</p><script>bad</script>'
|
|
106
|
+
const out = sanitizeHtml(html)
|
|
107
|
+
// 노드 환경에서 fallback 이라 그대로 통과 — 브라우저 환경에선 script 제거됨
|
|
108
|
+
expect(typeof out).toBe('string')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('window 있을 때 (jsdom mock) — script 차단 확인', () => {
|
|
112
|
+
// jsdom 은 jest 의 testEnvironment 가 jsdom 일 때만 동작 — 본 setup 은 node.
|
|
113
|
+
// 본 test 는 sanitizeHtml 의 *contract* 만 확인 (string in → string out).
|
|
114
|
+
const out = sanitizeHtml('<p>hello</p>')
|
|
115
|
+
expect(out).toContain('hello')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('빈 문자열 안전 처리', () => {
|
|
119
|
+
expect(sanitizeHtml('')).toBe('')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('null/undefined fallback', () => {
|
|
123
|
+
// 타입상 string 만 받지만 방어적으로 — 빈 문자열로 처리
|
|
124
|
+
expect(sanitizeHtml('' as any)).toBe('')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('renderMarkdown — XSS 우회 시도 차단 (defense-in-depth)', () => {
|
|
129
|
+
// 본 테스트들은 marked 의 1차 차단 + DOMPurify 의 2차 차단이 모두 작동하는지
|
|
130
|
+
// 회귀 방지. node 환경에선 sanitizeHtml 이 fallback 이지만, marked 의 1차
|
|
131
|
+
// renderer.html → '' 차단만으로도 대부분 케이스 차단 검증 가능.
|
|
132
|
+
|
|
133
|
+
test('이미지 onerror 핸들러 시도 — img 태그 자체에 onerror attribute 없음', () => {
|
|
134
|
+
const out = renderMarkdown(')')
|
|
135
|
+
// 결과 텍스트에 escape 된 형태로 onerror= 라는 글자 자체는 남을 수 있지만,
|
|
136
|
+
// *실행 가능한 attribute* 형태 (속성 이름으로) 는 없어야 한다.
|
|
137
|
+
// 정밀 검사: <img 태그 안에 onerror=" 형태가 없는지
|
|
138
|
+
expect(out).not.toMatch(/<img\b[^>]*\bonerror\s*=/i)
|
|
139
|
+
// 또한 escape 안 된 따옴표 + onerror 조합 자체도 없어야
|
|
140
|
+
expect(out).not.toMatch(/"\s+onerror=/i)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('img src=javascript: 차단', () => {
|
|
144
|
+
const out = renderMarkdown(')')
|
|
145
|
+
expect(out).not.toMatch(/src="javascript:/i)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('SVG xss 패턴 차단', () => {
|
|
149
|
+
const out = renderMarkdown('<svg onload=alert(1)>')
|
|
150
|
+
expect(out).not.toContain('<svg')
|
|
151
|
+
expect(out).not.toMatch(/onload/i)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('style="javascript:..." 차단', () => {
|
|
155
|
+
const out = renderMarkdown('[click](#){:style="javascript:alert(1)"}')
|
|
156
|
+
expect(out).not.toMatch(/style="javascript:/i)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('mention span 안의 사용자 token escape', () => {
|
|
160
|
+
const out = renderMarkdown('#"><script>alert(1)</script>')
|
|
161
|
+
expect(out).not.toMatch(/<script>/i)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('event handler attribute 차단 (onmouseover 등)', () => {
|
|
165
|
+
// raw HTML 이 marked.renderer.html 에서 빈 문자열로 → onmouseover 자체가 출력 안됨
|
|
166
|
+
const out = renderMarkdown('<a href="#" onmouseover="alert(1)">x</a>')
|
|
167
|
+
expect(out).not.toMatch(/onmouseover/i)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('vbscript: 스킴 차단 (legacy IE 잔재)', () => {
|
|
171
|
+
const out = renderMarkdown('[x](vbscript:msgbox(1))')
|
|
172
|
+
expect(out).not.toMatch(/href="vbscript:/i)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
98
176
|
describe('renderMarkdown — CJK + 구두점 인접 (회귀 방지)', () => {
|
|
99
177
|
// 이 그룹은 사용자 보고 사례. CommonMark flanking 규칙으로 인해 ** 가
|
|
100
178
|
// 닫히지 않던 케이스들을 모두 회복해야 한다.
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Board AI 채팅 메시지의 Markdown 렌더링.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 보안 (defense-in-depth):
|
|
5
|
+
* 1. raw HTML 차단 — marked 의 renderer.html → '' 로 block-level HTML 무력화.
|
|
6
|
+
* 2. 위험 link 스킴 차단 — javascript: / data: / vbscript: 등 거부.
|
|
7
|
+
* 3. DOMPurify 후처리 — marked 출력 전체를 sanitize. 우회되는 inline HTML
|
|
8
|
+
* (이미지 onerror, span style="javascript:..." 등) 까지 차단.
|
|
9
|
+
*
|
|
10
|
+
* 1·2 만으로도 표면이 좁지만, 다음의 *알려진 우회 경로* 가 존재하므로 3 이 필수:
|
|
11
|
+
* - marked 가 토큰화 실패한 일부 입력 (특히 코드블록 안 백틱 escape 미스) 에서
|
|
12
|
+
* 생 HTML 이 escape 안 된 채 흘러나갈 수 있음
|
|
13
|
+
* - mention span (`<span class="mention" ...>`) 이 우리 손으로 만드는 element 라
|
|
14
|
+
* allowlist 기반 정화 필요
|
|
15
|
+
* - 멀티유저 환경 (다른 사용자 메시지 echo) 시 표면 ↑
|
|
6
16
|
*
|
|
7
17
|
* CJK 보강:
|
|
8
18
|
* - CommonMark의 left/right-flanking 규칙 때문에 한국어/일본어/중국어 텍스트와
|
|
@@ -13,19 +23,69 @@
|
|
|
13
23
|
* - 이 우회는 사용자가 의도적으로 넣은 ** 만 대상으로 하므로 보안적으로 안전하다 (내용은
|
|
14
24
|
* escapeHtml 로 항상 escape).
|
|
15
25
|
*/
|
|
26
|
+
import DOMPurify from 'dompurify'
|
|
16
27
|
import { marked } from 'marked'
|
|
17
28
|
|
|
29
|
+
/**
|
|
30
|
+
* DOMPurify 정화 정책.
|
|
31
|
+
*
|
|
32
|
+
* 우리가 markdown 출력에서 의도적으로 사용하는 element / attribute 만 명시 허용:
|
|
33
|
+
* - 표준 markdown elements (p, h1~6, ul/ol/li, strong/em, a, img, code, pre, blockquote, hr, br, table 계열)
|
|
34
|
+
* - mention span (class + data-refid + data-userid)
|
|
35
|
+
*
|
|
36
|
+
* 그 외는 모두 strip — 이벤트 핸들러 (onerror/onclick), inline style, javascript:
|
|
37
|
+
* URL 등 자동 차단. 우리 구조에 없는 element (script/iframe/object/embed 등) 도 모두 제거.
|
|
38
|
+
*/
|
|
39
|
+
const PURIFY_CONFIG: any = {
|
|
40
|
+
ALLOWED_TAGS: [
|
|
41
|
+
'p', 'br', 'hr',
|
|
42
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
43
|
+
'strong', 'em', 'del', 's', 'u',
|
|
44
|
+
'ul', 'ol', 'li',
|
|
45
|
+
'a', 'img',
|
|
46
|
+
'code', 'pre',
|
|
47
|
+
'blockquote',
|
|
48
|
+
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
49
|
+
'span'
|
|
50
|
+
],
|
|
51
|
+
ALLOWED_ATTR: [
|
|
52
|
+
'href', 'title', 'alt', 'src', 'class',
|
|
53
|
+
'data-refid', 'data-userid'
|
|
54
|
+
],
|
|
55
|
+
// javascript: / data: / vbscript: 등 위험 스킴 자동 차단
|
|
56
|
+
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|ftp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
|
57
|
+
// target="_blank" 같이 우리가 명시하는 link 외엔 추가 attr 안 붙이도록
|
|
58
|
+
ADD_ATTR: ['target', 'rel'],
|
|
59
|
+
// raw HTML 입력은 marked.renderer.html → '' 로 이미 막혀있지만,
|
|
60
|
+
// dompurify 가 전체 input 을 한 번 더 정화 — 우회 차단
|
|
61
|
+
KEEP_CONTENT: true
|
|
62
|
+
}
|
|
63
|
+
|
|
18
64
|
const markdownRenderer = new marked.Renderer()
|
|
19
65
|
markdownRenderer.html = () => ''
|
|
66
|
+
|
|
67
|
+
const DANGEROUS_SCHEME_RE = /^(javascript|data|vbscript|file):/i
|
|
68
|
+
|
|
20
69
|
const origLink = markdownRenderer.link.bind(markdownRenderer)
|
|
21
70
|
markdownRenderer.link = (token: any) => {
|
|
22
71
|
const href = typeof token?.href === 'string' ? token.href.trim() : ''
|
|
23
|
-
if (
|
|
72
|
+
if (DANGEROUS_SCHEME_RE.test(href)) {
|
|
24
73
|
return token?.text ?? ''
|
|
25
74
|
}
|
|
26
75
|
return origLink(token)
|
|
27
76
|
}
|
|
28
77
|
|
|
78
|
+
// 이미지 renderer — src scheme 검사 + alt 의 inline HTML escape
|
|
79
|
+
const origImage = markdownRenderer.image.bind(markdownRenderer)
|
|
80
|
+
markdownRenderer.image = (token: any) => {
|
|
81
|
+
const src = typeof token?.href === 'string' ? token.href.trim() : ''
|
|
82
|
+
if (DANGEROUS_SCHEME_RE.test(src)) {
|
|
83
|
+
// 위험 src — 이미지 자체 제거. alt 텍스트는 escape 해서 평문 노출
|
|
84
|
+
return escapeHtml(token?.text ?? '')
|
|
85
|
+
}
|
|
86
|
+
return origImage(token)
|
|
87
|
+
}
|
|
88
|
+
|
|
29
89
|
marked.setOptions({
|
|
30
90
|
gfm: true,
|
|
31
91
|
breaks: true,
|
|
@@ -418,8 +478,31 @@ export function renderMarkdown(text: string): string {
|
|
|
418
478
|
return escapeHtml(stripMentionRefids(text)).replace(/\n/g, '<br>')
|
|
419
479
|
}
|
|
420
480
|
// 3) 역순 복원 — CJK strong → mention span. maps 로 data-refid / data-userid embed.
|
|
421
|
-
|
|
481
|
+
const restored = restoreMentions(restoreCjkStrongs(out, c.stored), m.stored, maps)
|
|
482
|
+
// 4) DOMPurify 후처리 — defense-in-depth. marked / 우리 mention span 둘 다 한 번 더 정화.
|
|
483
|
+
return sanitizeHtml(restored)
|
|
422
484
|
} catch {
|
|
423
485
|
return escapeHtml(stripMentionRefids(text)).replace(/\n/g, '<br>')
|
|
424
486
|
}
|
|
425
487
|
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* DOMPurify wrapper. Node 환경 (테스트) 에서도 안전하게 동작하도록 fallback.
|
|
491
|
+
*
|
|
492
|
+
* 브라우저: window.DOMParser 가 있으므로 DOMPurify 가 직접 정화.
|
|
493
|
+
* Node (테스트): jsdom 없이 DOMPurify 호출은 실패 — 실패 시 escapeHtml fallback
|
|
494
|
+
* (테스트에서 sanitize 동작은 별도 검증, 본 fallback 은 회귀 안전).
|
|
495
|
+
*/
|
|
496
|
+
export function sanitizeHtml(html: string): string {
|
|
497
|
+
try {
|
|
498
|
+
if (typeof window === 'undefined' || !DOMPurify.sanitize) {
|
|
499
|
+
return html
|
|
500
|
+
}
|
|
501
|
+
const result = DOMPurify.sanitize(html, PURIFY_CONFIG)
|
|
502
|
+
return typeof result === 'string' ? result : String(result)
|
|
503
|
+
} catch {
|
|
504
|
+
// DOM 환경 부재 또는 dompurify 초기화 실패 — 입력 그대로 통과
|
|
505
|
+
// (브라우저에선 정상 동작 — Node 테스트에선 sanitize 의 효과를 직접 검증)
|
|
506
|
+
return html
|
|
507
|
+
}
|
|
508
|
+
}
|
|
@@ -160,16 +160,16 @@ function applyOp(board: any, op: BoardEditOp): any {
|
|
|
160
160
|
case 'add':
|
|
161
161
|
return { ...board, components: [...(board.components || []), op.component] }
|
|
162
162
|
case 'remove':
|
|
163
|
+
// 자식 (group/container 안) refid 도 매칭 — deep search.
|
|
163
164
|
return {
|
|
164
165
|
...board,
|
|
165
|
-
components: (board.components || []
|
|
166
|
+
components: removeComponentDeep(board.components || [], op.refid)
|
|
166
167
|
}
|
|
167
168
|
case 'modify':
|
|
169
|
+
// 자식 refid 도 매칭 — group/container 안의 컴포넌트 수정 가능.
|
|
168
170
|
return {
|
|
169
171
|
...board,
|
|
170
|
-
components: (board.components || []
|
|
171
|
-
c?.refid === op.refid ? mergeComponent(c, op.patch) : c
|
|
172
|
-
)
|
|
172
|
+
components: modifyComponentDeep(board.components || [], op.refid, op.patch)
|
|
173
173
|
}
|
|
174
174
|
case 'modifyBoard': {
|
|
175
175
|
const patch = { ...(op.patch || {}) }
|
|
@@ -207,13 +207,15 @@ export function computeInverseOp(board: any, op: BoardEditOp): BoardEditOp | nul
|
|
|
207
207
|
return null
|
|
208
208
|
|
|
209
209
|
case 'remove': {
|
|
210
|
-
|
|
210
|
+
// 자식 refid 도 매칭 (deep search)
|
|
211
|
+
const target = findComponentDeep(components, op.refid)
|
|
211
212
|
if (!target) return null
|
|
212
213
|
return { op: 'add', component: JSON.parse(JSON.stringify(target)) }
|
|
213
214
|
}
|
|
214
215
|
|
|
215
216
|
case 'modify': {
|
|
216
|
-
|
|
217
|
+
// 자식 refid 도 매칭 (deep search)
|
|
218
|
+
const target = findComponentDeep(components, op.refid)
|
|
217
219
|
if (!target) return null
|
|
218
220
|
const oldValues: any = {}
|
|
219
221
|
for (const k of Object.keys(op.patch || {})) {
|
|
@@ -310,3 +312,63 @@ function deepMerge(a: any, b: any): any {
|
|
|
310
312
|
function isPlainObject(v: any): boolean {
|
|
311
313
|
return v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
312
314
|
}
|
|
315
|
+
|
|
316
|
+
// ── 자식 컴포넌트 (group/container 안) 까지 매칭 — deep search ─
|
|
317
|
+
// server/service/apply-patch.ts 와 동일 로직. drift test 가 동등성 보장.
|
|
318
|
+
|
|
319
|
+
function findComponentDeep(components: any[], refid: number): any | undefined {
|
|
320
|
+
for (const c of components) {
|
|
321
|
+
if (!c) continue
|
|
322
|
+
if (c.refid === refid) return c
|
|
323
|
+
if (Array.isArray(c.components) && c.components.length > 0) {
|
|
324
|
+
const sub = findComponentDeep(c.components, refid)
|
|
325
|
+
if (sub) return sub
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return undefined
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function modifyComponentDeep(components: any[], refid: number, patch: any): any[] {
|
|
332
|
+
let changed = false
|
|
333
|
+
const out = components.map(c => {
|
|
334
|
+
if (!c) return c
|
|
335
|
+
if (c.refid === refid) {
|
|
336
|
+
changed = true
|
|
337
|
+
return mergeComponent(c, patch)
|
|
338
|
+
}
|
|
339
|
+
if (Array.isArray(c.components) && c.components.length > 0) {
|
|
340
|
+
const updated = modifyComponentDeep(c.components, refid, patch)
|
|
341
|
+
if (updated !== c.components) {
|
|
342
|
+
changed = true
|
|
343
|
+
return { ...c, components: updated }
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return c
|
|
347
|
+
})
|
|
348
|
+
return changed ? out : components
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function removeComponentDeep(components: any[], refid: number): any[] {
|
|
352
|
+
let changed = false
|
|
353
|
+
const out: any[] = []
|
|
354
|
+
for (const c of components) {
|
|
355
|
+
if (!c) {
|
|
356
|
+
out.push(c)
|
|
357
|
+
continue
|
|
358
|
+
}
|
|
359
|
+
if (c.refid === refid) {
|
|
360
|
+
changed = true
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
if (Array.isArray(c.components) && c.components.length > 0) {
|
|
364
|
+
const updated = removeComponentDeep(c.components, refid)
|
|
365
|
+
if (updated !== c.components) {
|
|
366
|
+
changed = true
|
|
367
|
+
out.push({ ...c, components: updated })
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
out.push(c)
|
|
372
|
+
}
|
|
373
|
+
return changed ? out : components
|
|
374
|
+
}
|
|
@@ -49,4 +49,12 @@ export declare function injectMentionUserIds(text: string, picks: ReadonlyArray<
|
|
|
49
49
|
userId: string;
|
|
50
50
|
}> | null | undefined): string;
|
|
51
51
|
export declare function renderMarkdown(text: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* DOMPurify wrapper. Node 환경 (테스트) 에서도 안전하게 동작하도록 fallback.
|
|
54
|
+
*
|
|
55
|
+
* 브라우저: window.DOMParser 가 있으므로 DOMPurify 가 직접 정화.
|
|
56
|
+
* Node (테스트): jsdom 없이 DOMPurify 호출은 실패 — 실패 시 escapeHtml fallback
|
|
57
|
+
* (테스트에서 sanitize 동작은 별도 검증, 본 fallback 은 회귀 안전).
|
|
58
|
+
*/
|
|
59
|
+
export declare function sanitizeHtml(html: string): string;
|
|
52
60
|
export {};
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Board AI 채팅 메시지의 Markdown 렌더링.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 보안 (defense-in-depth):
|
|
5
|
+
* 1. raw HTML 차단 — marked 의 renderer.html → '' 로 block-level HTML 무력화.
|
|
6
|
+
* 2. 위험 link 스킴 차단 — javascript: / data: / vbscript: 등 거부.
|
|
7
|
+
* 3. DOMPurify 후처리 — marked 출력 전체를 sanitize. 우회되는 inline HTML
|
|
8
|
+
* (이미지 onerror, span style="javascript:..." 등) 까지 차단.
|
|
9
|
+
*
|
|
10
|
+
* 1·2 만으로도 표면이 좁지만, 다음의 *알려진 우회 경로* 가 존재하므로 3 이 필수:
|
|
11
|
+
* - marked 가 토큰화 실패한 일부 입력 (특히 코드블록 안 백틱 escape 미스) 에서
|
|
12
|
+
* 생 HTML 이 escape 안 된 채 흘러나갈 수 있음
|
|
13
|
+
* - mention span (`<span class="mention" ...>`) 이 우리 손으로 만드는 element 라
|
|
14
|
+
* allowlist 기반 정화 필요
|
|
15
|
+
* - 멀티유저 환경 (다른 사용자 메시지 echo) 시 표면 ↑
|
|
6
16
|
*
|
|
7
17
|
* CJK 보강:
|
|
8
18
|
* - CommonMark의 left/right-flanking 규칙 때문에 한국어/일본어/중국어 텍스트와
|
|
@@ -13,17 +23,63 @@
|
|
|
13
23
|
* - 이 우회는 사용자가 의도적으로 넣은 ** 만 대상으로 하므로 보안적으로 안전하다 (내용은
|
|
14
24
|
* escapeHtml 로 항상 escape).
|
|
15
25
|
*/
|
|
26
|
+
import DOMPurify from 'dompurify';
|
|
16
27
|
import { marked } from 'marked';
|
|
28
|
+
/**
|
|
29
|
+
* DOMPurify 정화 정책.
|
|
30
|
+
*
|
|
31
|
+
* 우리가 markdown 출력에서 의도적으로 사용하는 element / attribute 만 명시 허용:
|
|
32
|
+
* - 표준 markdown elements (p, h1~6, ul/ol/li, strong/em, a, img, code, pre, blockquote, hr, br, table 계열)
|
|
33
|
+
* - mention span (class + data-refid + data-userid)
|
|
34
|
+
*
|
|
35
|
+
* 그 외는 모두 strip — 이벤트 핸들러 (onerror/onclick), inline style, javascript:
|
|
36
|
+
* URL 등 자동 차단. 우리 구조에 없는 element (script/iframe/object/embed 등) 도 모두 제거.
|
|
37
|
+
*/
|
|
38
|
+
const PURIFY_CONFIG = {
|
|
39
|
+
ALLOWED_TAGS: [
|
|
40
|
+
'p', 'br', 'hr',
|
|
41
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
42
|
+
'strong', 'em', 'del', 's', 'u',
|
|
43
|
+
'ul', 'ol', 'li',
|
|
44
|
+
'a', 'img',
|
|
45
|
+
'code', 'pre',
|
|
46
|
+
'blockquote',
|
|
47
|
+
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
48
|
+
'span'
|
|
49
|
+
],
|
|
50
|
+
ALLOWED_ATTR: [
|
|
51
|
+
'href', 'title', 'alt', 'src', 'class',
|
|
52
|
+
'data-refid', 'data-userid'
|
|
53
|
+
],
|
|
54
|
+
// javascript: / data: / vbscript: 등 위험 스킴 자동 차단
|
|
55
|
+
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|ftp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
|
56
|
+
// target="_blank" 같이 우리가 명시하는 link 외엔 추가 attr 안 붙이도록
|
|
57
|
+
ADD_ATTR: ['target', 'rel'],
|
|
58
|
+
// raw HTML 입력은 marked.renderer.html → '' 로 이미 막혀있지만,
|
|
59
|
+
// dompurify 가 전체 input 을 한 번 더 정화 — 우회 차단
|
|
60
|
+
KEEP_CONTENT: true
|
|
61
|
+
};
|
|
17
62
|
const markdownRenderer = new marked.Renderer();
|
|
18
63
|
markdownRenderer.html = () => '';
|
|
64
|
+
const DANGEROUS_SCHEME_RE = /^(javascript|data|vbscript|file):/i;
|
|
19
65
|
const origLink = markdownRenderer.link.bind(markdownRenderer);
|
|
20
66
|
markdownRenderer.link = (token) => {
|
|
21
67
|
const href = typeof token?.href === 'string' ? token.href.trim() : '';
|
|
22
|
-
if (
|
|
68
|
+
if (DANGEROUS_SCHEME_RE.test(href)) {
|
|
23
69
|
return token?.text ?? '';
|
|
24
70
|
}
|
|
25
71
|
return origLink(token);
|
|
26
72
|
};
|
|
73
|
+
// 이미지 renderer — src scheme 검사 + alt 의 inline HTML escape
|
|
74
|
+
const origImage = markdownRenderer.image.bind(markdownRenderer);
|
|
75
|
+
markdownRenderer.image = (token) => {
|
|
76
|
+
const src = typeof token?.href === 'string' ? token.href.trim() : '';
|
|
77
|
+
if (DANGEROUS_SCHEME_RE.test(src)) {
|
|
78
|
+
// 위험 src — 이미지 자체 제거. alt 텍스트는 escape 해서 평문 노출
|
|
79
|
+
return escapeHtml(token?.text ?? '');
|
|
80
|
+
}
|
|
81
|
+
return origImage(token);
|
|
82
|
+
};
|
|
27
83
|
marked.setOptions({
|
|
28
84
|
gfm: true,
|
|
29
85
|
breaks: true,
|
|
@@ -334,10 +390,33 @@ export function renderMarkdown(text) {
|
|
|
334
390
|
return escapeHtml(stripMentionRefids(text)).replace(/\n/g, '<br>');
|
|
335
391
|
}
|
|
336
392
|
// 3) 역순 복원 — CJK strong → mention span. maps 로 data-refid / data-userid embed.
|
|
337
|
-
|
|
393
|
+
const restored = restoreMentions(restoreCjkStrongs(out, c.stored), m.stored, maps);
|
|
394
|
+
// 4) DOMPurify 후처리 — defense-in-depth. marked / 우리 mention span 둘 다 한 번 더 정화.
|
|
395
|
+
return sanitizeHtml(restored);
|
|
338
396
|
}
|
|
339
397
|
catch {
|
|
340
398
|
return escapeHtml(stripMentionRefids(text)).replace(/\n/g, '<br>');
|
|
341
399
|
}
|
|
342
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* DOMPurify wrapper. Node 환경 (테스트) 에서도 안전하게 동작하도록 fallback.
|
|
403
|
+
*
|
|
404
|
+
* 브라우저: window.DOMParser 가 있으므로 DOMPurify 가 직접 정화.
|
|
405
|
+
* Node (테스트): jsdom 없이 DOMPurify 호출은 실패 — 실패 시 escapeHtml fallback
|
|
406
|
+
* (테스트에서 sanitize 동작은 별도 검증, 본 fallback 은 회귀 안전).
|
|
407
|
+
*/
|
|
408
|
+
export function sanitizeHtml(html) {
|
|
409
|
+
try {
|
|
410
|
+
if (typeof window === 'undefined' || !DOMPurify.sanitize) {
|
|
411
|
+
return html;
|
|
412
|
+
}
|
|
413
|
+
const result = DOMPurify.sanitize(html, PURIFY_CONFIG);
|
|
414
|
+
return typeof result === 'string' ? result : String(result);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// DOM 환경 부재 또는 dompurify 초기화 실패 — 입력 그대로 통과
|
|
418
|
+
// (브라우저에선 정상 동작 — Node 테스트에선 sanitize 의 효과를 직접 검증)
|
|
419
|
+
return html;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
343
422
|
//# sourceMappingURL=markdown.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"markdown.js","sourceRoot":"","sources":["../../../client/components/markdown.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAE/B,MAAM,gBAAgB,GAAG,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAA;AAC9C,gBAAgB,CAAC,IAAI,GAAG,GAAG,EAAE,CAAC,EAAE,CAAA;AAChC,MAAM,QAAQ,GAAG,gBAAgB,CAAC,IAAI,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAA;AAC7D,gBAAgB,CAAC,IAAI,GAAG,CAAC,KAAU,EAAE,EAAE;IACrC,MAAM,IAAI,GAAG,OAAO,KAAK,EAAE,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAA;IACrE,IAAI,+BAA+B,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/C,OAAO,KAAK,EAAE,IAAI,IAAI,EAAE,CAAA;IAC1B,CAAC;IACD,OAAO,QAAQ,CAAC,KAAK,CAAC,CAAA;AACxB,CAAC,CAAA;AAED,MAAM,CAAC,UAAU,CAAC;IAChB,GAAG,EAAE,IAAI;IACT,MAAM,EAAE,IAAI;IACZ,QAAQ,EAAE,KAAK;IACf,QAAQ,EAAE,gBAAgB;IAC1B,KAAK,EAAE,KAAK;CACN,CAAC,CAAA;AAET,MAAM,UAAU,UAAU,CAAC,IAAY;IACrC,OAAO,IAAI;SACR,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC;SACvB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAA;AAC5B,CAAC;AAED,kFAAkF;AAClF,MAAM,MAAM,GAAG,mBAAmB,CAAA;AAClC,MAAM,QAAQ,GAAG,QAAQ,CAAA;AAEzB,mCAAmC;AACnC,MAAM,kBAAkB,GAAG,cAAc,CAAA;AACzC,MAAM,kBAAkB,GAAG,SAAS,CAAA;AACpC,MAAM,0BAA0B,GAAG,YAAY,CAAA;AAC/C,MAAM,0BAA0B,GAAG,YAAY,CAAA;AAE/C,+DAA+D;AAC/D,MAAM,eAAe,GAAG,4EAA4E,CAAA;AAYpG;;;;;;;GAOG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAY;IAC5C,MAAM,MAAM,GAAa,EAAE,CAAA;IAC3B,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IAC9B,MAAM,QAAQ,GAAa,EAAE,CAAA;IAC7B,IAAI,OAAO,GAAG,KAAK,CAAA;IAEnB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;YAC7B,OAAO,GAAG,CAAC,OAAO,CAAA;YAClB,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;YACnB,SAAQ;QACV,CAAC;QACD,IAAI,OAAO,EAAE,CAAC;YACZ,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;YACnB,SAAQ;QACV,CAAC;QACD,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAA;IACrF,CAAC;IAED,OAAO,EAAE,SAAS,EAAE,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;AACnD,CAAC;AAED,SAAS,WAAW,CAAC,IAAY,EAAE,MAAgB,EAAE,QAAsB;IACzE,IAAI,CAAC,GAAG,CAAC,CAAA;IACT,IAAI,MAAM,GAAG,EAAE,CAAA;IACf,OAAO,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC;QACvB,MAAM,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,CAAA;QAElB,IAAI,EAAE,KAAK,GAAG,EAAE,CAAC;YACf,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAA;YACpC,IAAI,GAAG,KAAK,CAAC,CAAC,EAAE,CAAC;gBACf,MAAM,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;gBACvB,MAAK;YACP,CAAC;YACD,MAAM,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,GAAG,CAAC,CAAC,CAAA;YAChC,CAAC,GAAG,GAAG,GAAG,CAAC,CAAA;YACX,SAAQ;QACV,CAAC;QAED,IAAI,EAAE,KAAK,GAAG,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,GAAG,EAAE,CAAC;YACtC,MAAM,KAAK,GAAG,eAAe,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC,CAAA;YAC1C,IAAI,KAAK,KAAK,CAAC,CAAC,EAAE,CAAC;gBACjB,MAAM,IAAI,IAAI,CAAA;gBACd,CAAC,IAAI,CAAC,CAAA;gBACN,SAAQ;YACV,CAAC;YACD,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,CAAA;YACtC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBACvB,MAAM,IAAI,IAAI,CAAA;gBACd,CAAC,IAAI,CAAC,CAAA;gBACN,SAAQ;YACV,CAAC;YACD,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAA;YAC1E,MAAM,KAAK,GAAG,KAAK,GAAG,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAA;YAC5D,IAAI,WAAW,CAAC,MAAM,EAAE,KAAK,EAAE,KAAK,CAAC,EAAE,CAAC;gBACtC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;gBAClB,MAAM,IAAI,GAAG,kBAAkB,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,GAAG,kBAAkB,EAAE,CAAA;YAC5E,CAAC;iBAAM,CAAC;gBACN,MAAM,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,GAAG,CAAC,CAAC,CAAA;YACpC,CAAC;YACD,CAAC,GAAG,KAAK,GAAG,CAAC,CAAA;YACb,SAAQ;QACV,CAAC;QAED,MAAM,IAAI,EAAE,CAAA;QACZ,CAAC,EAAE,CAAA;IACL,CAAC;IACD,OAAO,MAAM,CAAA;AACf,CAAC;AAED,SAAS,eAAe,CAAC,IAAY,EAAE,KAAa;IAClD,IAAI,CAAC,GAAG,KAAK,CAAA;IACb,OAAO,CAAC,GAAG,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC3B,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,GAAG,EAAE,CAAC;YACpB,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAA;YACpC,IAAI,GAAG,KAAK,CAAC,CAAC;gBAAE,OAAO,CAAC,CAAC,CAAA;YACzB,CAAC,GAAG,GAAG,GAAG,CAAC,CAAA;YACX,SAAQ;QACV,CAAC;QACD,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,GAAG,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,GAAG;YAAE,OAAO,CAAC,CAAA;QACpD,CAAC,EAAE,CAAA;IACL,CAAC;IACD,OAAO,CAAC,CAAC,CAAA;AACX,CAAC;AAED,SAAS,WAAW,CAAC,MAAc,EAAE,KAAa,EAAE,KAAa;IAC/D,MAAM,UAAU,GAAG,KAAK,CAAC,CAAC,CAAC,CAAA;IAC3B,MAAM,QAAQ,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;IACxC,OAAO,CACL,CAAC,MAAM,KAAK,EAAE,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACnE,CAAC,KAAK,KAAK,EAAE,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAChE,CAAA;AACH,CAAC;AAED,SAAS,iBAAiB,CAAC,IAAY,EAAE,MAAgB;IACvD,IAAI,GAAG,GAAG,IAAI,CAAA;IACd,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACvC,MAAM,KAAK,GAAG,GAAG,kBAAkB,GAAG,CAAC,GAAG,kBAAkB,EAAE,CAAA;QAC9D,MAAM,WAAW,GAAG,WAAW,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,CAAA;QAC/D,GAAG,GAAG,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;IAC1C,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,eAAe,CAAC,IAAY;IAC1C,MAAM,MAAM,GAAoB,EAAE,CAAA;IAClC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IAC9B,MAAM,QAAQ,GAAa,EAAE,CAAA;IAC7B,IAAI,OAAO,GAAG,KAAK,CAAA;IAEnB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;YAC7B,OAAO,GAAG,CAAC,OAAO,CAAA;YAClB,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;YACnB,SAAQ;QACV,CAAC;QACD,IAAI,OAAO,EAAE,CAAC;YACZ,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;YACnB,SAAQ;QACV,CAAC;QACD,QAAQ,CAAC,IAAI,CAAC,kBAAkB,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAA;IACjD,CAAC;IAED,OAAO,EAAE,SAAS,EAAE,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;AACnD,CAAC;AAQD,SAAS,kBAAkB,CAAC,IAAY,EAAE,MAAuB;IAC/D,IAAI,MAAM,GAAG,EAAE,CAAA;IACf,IAAI,CAAC,GAAG,CAAC,CAAA;IACT,OAAO,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC;QACvB,MAAM,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,CAAA;QAClB,IAAI,EAAE,KAAK,GAAG,EAAE,CAAC;YACf,iCAAiC;YACjC,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAA;YACpC,IAAI,GAAG,KAAK,CAAC,CAAC,EAAE,CAAC;gBACf,MAAM,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;gBACvB,MAAK;YACP,CAAC;YACD,MAAM,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,GAAG,CAAC,CAAC,CAAA;YAChC,CAAC,GAAG,GAAG,GAAG,CAAC,CAAA;YACX,SAAQ;QACV,CAAC;QACD,IAAI,EAAE,KAAK,GAAG,IAAI,EAAE,KAAK,GAAG,EAAE,CAAC;YAC7B,MAAM,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAA;YACrC,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAA;YAC9B,IACE,CAAC,IAAI,KAAK,EAAE,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBAC5C,IAAI,KAAK,EAAE;gBACX,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,EAC1B,CAAC;gBACD,aAAa;gBACb,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;gBACb,OAAO,CAAC,GAAG,IAAI,CAAC,MAAM,IAAI,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;oBAAE,CAAC,EAAE,CAAA;gBAC5D,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAA;gBAClC,MAAM,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,EAAe,EAAE,KAAK,EAAE,CAAC,CAAA;gBAChD,MAAM,IAAI,GAAG,0BAA0B,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,GAAG,0BAA0B,EAAE,CAAA;gBAC1F,CAAC,GAAG,CAAC,CAAA;gBACL,SAAQ;YACV,CAAC;QACH,CAAC;QACD,MAAM,IAAI,EAAE,CAAA;QACZ,CAAC,EAAE,CAAA;IACL,CAAC;IACD,OAAO,MAAM,CAAA;AACf,CAAC;AAED;;;;;GAKG;AACH,SAAS,eAAe,CACtB,IAAY,EACZ,MAAuB,EACvB,IAAuB;IAEvB,IAAI,GAAG,GAAG,IAAI,CAAA;IACd,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACvC,MAAM,WAAW,GAAG,GAAG,0BAA0B,GAAG,CAAC,GAAG,0BAA0B,EAAE,CAAA;QACpF,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;QACpC,IAAI,WAAmB,CAAA;QACvB,IAAI,OAAO,KAAK,GAAG,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;YAC1C,MAAM,IAAI,GACR,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;gBACjD,CAAC,CAAC,gBAAgB,KAAK,GAAG;gBAC1B,CAAC,CAAC,EAAE,CAAA;YACR,WAAW,GAAG,wBAAwB,IAAI,KAAK,UAAU,CAAC,KAAK,CAAC,SAAS,CAAA;QAC3E,CAAC;aAAM,CAAC;YACN,MAAM,MAAM,GAAG,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;YAC5C,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,iBAAiB,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAA;YACjE,WAAW,GAAG,6BAA6B,IAAI,KAAK,UAAU,CAAC,KAAK,CAAC,SAAS,CAAA;QAChF,CAAC;QACD,GAAG,GAAG,GAAG,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;IAChD,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED;;;;;GAKG;AACH,MAAM,iBAAiB,GAAG,mCAAmC,CAAA;AAE7D,MAAM,UAAU,GACd,8EAA8E,CAAA;AAEhF;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAA+B;IAChE,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,CAAA;IACpB,OAAO,IAAI,CAAC,OAAO,CAAC,iBAAiB,EAAE,EAAE,CAAC,CAAA;AAC5C,CAAC;AASD;;;GAGG;AACH,SAAS,qBAAqB,CAAC,IAAY;IAIzC,MAAM,YAAY,GAAG,IAAI,GAAG,EAAkB,CAAA;IAC9C,MAAM,aAAa,GAAG,IAAI,GAAG,EAAkB,CAAA;IAE/C,MAAM,WAAW,GAAG,IAAI,MAAM,CAC5B,QAAQ,UAAU,QAAQ,UAAU,wBAAwB,EAC5D,IAAI,CACL,CAAA;IACD,MAAM,MAAM,GAAG,IAAI,MAAM,CACvB,QAAQ,UAAU,QAAQ,UAAU,6BAA6B,EACjE,IAAI,CACL,CAAA;IAED,IAAI,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE;QACvE,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAA;QAC9B,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC;YAAE,YAAY,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,CAAC,CAAA;QACtF,OAAO,GAAG,OAAO,IAAI,KAAK,EAAE,CAAA;IAC9B,CAAC,CAAC,CAAA;IACF,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE;QAC/D,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,KAAK,CAAC;YAAE,aAAa,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;QAC/D,OAAO,GAAG,OAAO,IAAI,KAAK,EAAE,CAAA;IAC9B,CAAC,CAAC,CAAA;IACF,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,YAAY,EAAE,aAAa,EAAE,EAAE,CAAA;AAC3D,CAAC;AAED;;;;GAIG;AACH,SAAS,mBAAmB,CAC1B,IAAY,EACZ,OAAe,EACf,SAAiB,EACjB,KAAkF;IAElF,IAAI,CAAC,IAAI,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,IAAI,EAAE,CAAA;IAC5D,MAAM,GAAG,GAAG,IAAI,GAAG,EAA2B,CAAA;IAC9C,KAAK,MAAM,CAAC,IAAI,KAAK;QAAE,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,CAAA;IAChD,MAAM,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAA;IAClE,MAAM,EAAE,GAAG,IAAI,MAAM,CACnB,QAAQ,UAAU,MAAM,WAAW,MAAM,UAAU,WAAW,SAAS,gBAAgB,EACvF,IAAI,CACL,CAAA;IACD,OAAO,IAAI,CAAC,OAAO,CAAC,EAAE,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,cAAc,EAAE,EAAE;QACtE,IAAI,cAAc;YAAE,OAAO,KAAK,CAAA;QAChC,MAAM,KAAK,GAAG,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;QAC5B,IAAI,KAAK,KAAK,SAAS;YAAE,OAAO,KAAK,CAAA;QACrC,OAAO,GAAG,OAAO,GAAG,IAAI,GAAG,KAAK,IAAI,SAAS,IAAI,KAAK,GAAG,CAAA;IAC3D,CAAC,CAAC,CAAA;AACJ,CAAC;AAED,yDAAyD;AACzD,MAAM,UAAU,mBAAmB,CACjC,IAAY,EACZ,KAAyE;IAEzE,OAAO,mBAAmB,CACxB,IAAI,EACJ,GAAG,EACH,OAAO,EACP,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CACtD,CAAA;AACH,CAAC;AAED,gDAAgD;AAChD,MAAM,UAAU,oBAAoB,CAClC,IAAY,EACZ,KAA0E;IAE1E,OAAO,mBAAmB,CACxB,IAAI,EACJ,GAAG,EACH,QAAQ,EACR,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CACvD,CAAA;AACH,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,CAAA;IACpB,IAAI,CAAC;QACH,kEAAkE;QAClE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,qBAAqB,CAAC,IAAI,CAAC,CAAA;QACrD,sEAAsE;QACtE,oCAAoC;QACpC,MAAM,CAAC,GAAG,eAAe,CAAC,OAAO,CAAC,CAAA;QAClC,4BAA4B;QAC5B,MAAM,CAAC,GAAG,iBAAiB,CAAC,CAAC,CAAC,SAAS,CAAC,CAAA;QACxC,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,CAAA;QACrC,IAAI,OAAO,GAAG,KAAK,QAAQ,EAAE,CAAC;YAC5B,wDAAwD;YACxD,OAAO,UAAU,CAAC,kBAAkB,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;QACpE,CAAC;QACD,+EAA+E;QAC/E,OAAO,eAAe,CAAC,iBAAiB,CAAC,GAAG,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,MAAM,EAAE,IAAI,CAAC,CAAA;IAC1E,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,UAAU,CAAC,kBAAkB,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;IACpE,CAAC;AACH,CAAC","sourcesContent":["/**\n * Board AI 채팅 메시지의 Markdown 렌더링.\n *\n * 보안:\n * - raw HTML 차단 (renderer.html → ''), 위험 link 스킴 (javascript:, data: 등) 차단.\n *\n * CJK 보강:\n * - CommonMark의 left/right-flanking 규칙 때문에 한국어/일본어/중국어 텍스트와\n * ** 강조 마커 사이에 구두점이 끼면 마커가 닫히지 않는 케이스가 발생한다.\n * 예) \"**'rect'**의\" → 닫는 ** 가 [구두점] + [CJK] 사이에 위치하므로 right-flanking 실패\n * - 이런 케이스는 marked 에 넘기기 전에 자체적으로 추출 → 자리표시자로 치환 → marked\n * 수행 → 후처리에서 <strong>...</strong> 로 복원하는 방식으로 처리한다.\n * - 이 우회는 사용자가 의도적으로 넣은 ** 만 대상으로 하므로 보안적으로 안전하다 (내용은\n * escapeHtml 로 항상 escape).\n */\nimport { marked } from 'marked'\n\nconst markdownRenderer = new marked.Renderer()\nmarkdownRenderer.html = () => ''\nconst origLink = markdownRenderer.link.bind(markdownRenderer)\nmarkdownRenderer.link = (token: any) => {\n const href = typeof token?.href === 'string' ? token.href.trim() : ''\n if (/^(javascript|data|vbscript):/i.test(href)) {\n return token?.text ?? ''\n }\n return origLink(token)\n}\n\nmarked.setOptions({\n gfm: true,\n breaks: true,\n pedantic: false,\n renderer: markdownRenderer,\n async: false\n} as any)\n\nexport function escapeHtml(text: string): string {\n return text\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n}\n\n// CJK 문자 범위: Hiragana, Katakana, CJK Unified Ideographs (Ext A 포함), Hangul, 전각/반각\nconst CJK_RE = /[-ヿ㐀-䶿一-鿿가--]/\nconst PUNCT_RE = /\\p{P}/u\n\n// marked 가 절대 변형하지 않는 토큰 (영숫자만 사용)\nconst PLACEHOLDER_PREFIX = 'BAICJKSTRONG'\nconst PLACEHOLDER_SUFFIX = 'ENDMARK'\nconst MENTION_PLACEHOLDER_PREFIX = 'BAIMENTION'\nconst MENTION_PLACEHOLDER_SUFFIX = 'ENDMENTION'\n\n// mention 토큰에 허용되는 문자 — alphanumeric + 한글/일본/중국어. 공백/구두점에서 끊김.\nconst MENTION_WORD_RE = /[\\w\\p{Script=Hangul}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Han}]/u\n\ninterface ExtractResult {\n processed: string\n stored: string[]\n}\n\ninterface MentionExtractResult {\n processed: string\n stored: StoredMention[]\n}\n\n/**\n * `**X**` 패턴 중 CJK + 구두점 인접 때문에 marked 의 flanking 규칙에서 누락되는\n * 케이스를 추출. 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않는다.\n *\n * 검출 기준 (CommonMark right-flanking 실패 ↔ left-flanking 실패):\n * - 닫는 ** 직전 문자가 구두점 AND 직후 문자가 CJK letter\n * - 여는 ** 직전 문자가 CJK letter AND 직후 문자가 구두점\n */\nexport function extractCjkStrongs(text: string): ExtractResult {\n const stored: string[] = []\n const lines = text.split('\\n')\n const outLines: string[] = []\n let inFence = false\n\n for (const line of lines) {\n if (/^\\s{0,3}```/.test(line)) {\n inFence = !inFence\n outLines.push(line)\n continue\n }\n if (inFence) {\n outLines.push(line)\n continue\n }\n outLines.push(processLine(line, stored, () => outLines[outLines.length - 1] ?? ''))\n }\n\n return { processed: outLines.join('\\n'), stored }\n}\n\nfunction processLine(line: string, stored: string[], prevLine: () => string): string {\n let i = 0\n let result = ''\n while (i < line.length) {\n const ch = line[i]\n\n if (ch === '`') {\n const end = line.indexOf('`', i + 1)\n if (end === -1) {\n result += line.slice(i)\n break\n }\n result += line.slice(i, end + 1)\n i = end + 1\n continue\n }\n\n if (ch === '*' && line[i + 1] === '*') {\n const close = findCloseStrong(line, i + 2)\n if (close === -1) {\n result += '**'\n i += 2\n continue\n }\n const inner = line.slice(i + 2, close)\n if (inner.length === 0) {\n result += '**'\n i += 2\n continue\n }\n const before = result.length > 0 ? result.slice(-1) : prevLine().slice(-1)\n const after = close + 2 < line.length ? line[close + 2] : ''\n if (needsCjkFix(before, after, inner)) {\n stored.push(inner)\n result += `${PLACEHOLDER_PREFIX}${stored.length - 1}${PLACEHOLDER_SUFFIX}`\n } else {\n result += line.slice(i, close + 2)\n }\n i = close + 2\n continue\n }\n\n result += ch\n i++\n }\n return result\n}\n\nfunction findCloseStrong(line: string, start: number): number {\n let i = start\n while (i < line.length - 1) {\n if (line[i] === '`') {\n const end = line.indexOf('`', i + 1)\n if (end === -1) return -1\n i = end + 1\n continue\n }\n if (line[i] === '*' && line[i + 1] === '*') return i\n i++\n }\n return -1\n}\n\nfunction needsCjkFix(before: string, after: string, inner: string): boolean {\n const innerStart = inner[0]\n const innerEnd = inner[inner.length - 1]\n return (\n (before !== '' && CJK_RE.test(before) && PUNCT_RE.test(innerStart)) ||\n (after !== '' && CJK_RE.test(after) && PUNCT_RE.test(innerEnd))\n )\n}\n\nfunction restoreCjkStrongs(html: string, stored: string[]): string {\n let out = html\n for (let n = 0; n < stored.length; n++) {\n const token = `${PLACEHOLDER_PREFIX}${n}${PLACEHOLDER_SUFFIX}`\n const replacement = `<strong>${escapeHtml(stored[n])}</strong>`\n out = out.split(token).join(replacement)\n }\n return out\n}\n\n/**\n * `#token` mention 패턴을 추출 → placeholder 로 치환.\n *\n * 활성 조건 (chat textarea 의 extractMentionContext 와 일관):\n * - `#` 직전이 문자열 시작 또는 word char 가 아님 (공백/구두점/줄 시작)\n * - `#` 직후가 word char (영숫자/한글/일본/중국어)\n * - 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않음\n *\n * Markdown ATX 헤딩 (`# Title`) 과 충돌하지 않음 — 헤딩은 `#` 다음에 공백이 오므로\n * 본 정규식 (직후가 word char) 매칭 안 됨.\n */\nexport function extractMentions(text: string): MentionExtractResult {\n const stored: StoredMention[] = []\n const lines = text.split('\\n')\n const outLines: string[] = []\n let inFence = false\n\n for (const line of lines) {\n if (/^\\s{0,3}```/.test(line)) {\n inFence = !inFence\n outLines.push(line)\n continue\n }\n if (inFence) {\n outLines.push(line)\n continue\n }\n outLines.push(processMentionLine(line, stored))\n }\n\n return { processed: outLines.join('\\n'), stored }\n}\n\n/** 추출된 mention — trigger 와 함께 보존해 restore 시 trigger 별 다른 span 생성. */\ninterface StoredMention {\n trigger: '#' | '@'\n token: string\n}\n\nfunction processMentionLine(line: string, stored: StoredMention[]): string {\n let result = ''\n let i = 0\n while (i < line.length) {\n const ch = line[i]\n if (ch === '`') {\n // 인라인 코드 — 닫는 backtick 까지 그대로 통과\n const end = line.indexOf('`', i + 1)\n if (end === -1) {\n result += line.slice(i)\n break\n }\n result += line.slice(i, end + 1)\n i = end + 1\n continue\n }\n if (ch === '#' || ch === '@') {\n const prev = i > 0 ? line[i - 1] : ''\n const next = line[i + 1] ?? ''\n if (\n (prev === '' || !MENTION_WORD_RE.test(prev)) &&\n next !== '' &&\n MENTION_WORD_RE.test(next)\n ) {\n // 토큰 끝 위치 찾기\n let j = i + 1\n while (j < line.length && MENTION_WORD_RE.test(line[j])) j++\n const token = line.slice(i + 1, j)\n stored.push({ trigger: ch as '#' | '@', token })\n result += `${MENTION_PLACEHOLDER_PREFIX}${stored.length - 1}${MENTION_PLACEHOLDER_SUFFIX}`\n i = j\n continue\n }\n }\n result += ch\n i++\n }\n return result\n}\n\n/**\n * placeholder 를 mention span 으로 복원. trigger 별로 다른 span:\n * - `#component` → `<span class=\"mention\" data-refid=\"N\">#token</span>`\n * - `@user` → `<span class=\"user-mention\" data-userid=\"UUID\">@token</span>`\n * 매핑 없으면 data-* 속성 없이 단순 span.\n */\nfunction restoreMentions(\n html: string,\n stored: StoredMention[],\n maps: InlineMentionMaps\n): string {\n let out = html\n for (let n = 0; n < stored.length; n++) {\n const placeholder = `${MENTION_PLACEHOLDER_PREFIX}${n}${MENTION_PLACEHOLDER_SUFFIX}`\n const { trigger, token } = stored[n]\n let replacement: string\n if (trigger === '#') {\n const refid = maps.refidByToken.get(token)\n const attr =\n typeof refid === 'number' && Number.isFinite(refid)\n ? ` data-refid=\"${refid}\"`\n : ''\n replacement = `<span class=\"mention\"${attr}>#${escapeHtml(token)}</span>`\n } else {\n const userId = maps.userIdByToken.get(token)\n const attr = userId ? ` data-userid=\"${escapeHtml(userId)}\"` : ''\n replacement = `<span class=\"user-mention\"${attr}>@${escapeHtml(token)}</span>`\n }\n out = out.split(placeholder).join(replacement)\n }\n return out\n}\n\n/**\n * 인라인 마커 정규식 — `{key:value}` 형태. 현재 사용:\n * - `{refid:N}` — `#component` 멘션\n * - `{userId:UUID}` — `@user` 멘션\n * 향후 추가 종류 (agentId / threadId / 외부 시스템 id 등) 도 동일 형식.\n */\nconst MENTION_MARKER_RE = /\\{[a-zA-Z][a-zA-Z0-9]*:[^}\\s]+\\}/g\n\nconst WORD_CLASS =\n '\\\\w\\\\p{Script=Hangul}\\\\p{Script=Hiragana}\\\\p{Script=Katakana}\\\\p{Script=Han}'\n\n/**\n * 본문에서 모든 `{key:value}` 마커 제거 — textarea 편집 / LLM 입력 / 평문 표출 등.\n * 토큰 (#component / @user) 자체는 보존.\n */\nexport function stripMentionRefids(text: string | null | undefined): string {\n if (!text) return ''\n return text.replace(MENTION_MARKER_RE, '')\n}\n\ninterface InlineMentionMaps {\n /** trigger='#' 의 token → refid (component) */\n refidByToken: Map<string, number>\n /** trigger='@' 의 token → userId (user) */\n userIdByToken: Map<string, string>\n}\n\n/**\n * `#token{refid:N}` / `@user{userId:UUID}` 마커를 추출해 매핑 + 마커 제거된 본문 반환.\n * 렌더링 직전에 호출 — 마킹 정보 분리 후 markdown 처리에 깨끗한 텍스트만.\n */\nfunction extractInlineMentions(text: string): {\n cleaned: string\n maps: InlineMentionMaps\n} {\n const refidByToken = new Map<string, number>()\n const userIdByToken = new Map<string, string>()\n\n const componentRe = new RegExp(\n `(^|[^${WORD_CLASS}])#([${WORD_CLASS}-]+)\\\\{refid:(\\\\d+)\\\\}`,\n 'gu'\n )\n const userRe = new RegExp(\n `(^|[^${WORD_CLASS}])@([${WORD_CLASS}-]+)\\\\{userId:([^}\\\\s]+)\\\\}`,\n 'gu'\n )\n\n let cleaned = text.replace(componentRe, (_m, leading, token, refidStr) => {\n const refid = Number(refidStr)\n if (Number.isFinite(refid) && !refidByToken.has(token)) refidByToken.set(token, refid)\n return `${leading}#${token}`\n })\n cleaned = cleaned.replace(userRe, (_m, leading, token, userId) => {\n if (!userIdByToken.has(token)) userIdByToken.set(token, userId)\n return `${leading}@${token}`\n })\n return { cleaned, maps: { refidByToken, userIdByToken } }\n}\n\n/**\n * 일반화된 inline marker 주입.\n * injectInlineMarkers(text, '#', 'refid', [{token, value: 35}])\n * injectInlineMarkers(text, '@', 'userId', [{token, value: 'uuid'}])\n */\nfunction injectInlineMarkers(\n text: string,\n trigger: string,\n markerKey: string,\n picks: ReadonlyArray<{ token: string; value: string | number }> | null | undefined\n): string {\n if (!text || !picks || picks.length === 0) return text ?? ''\n const map = new Map<string, string | number>()\n for (const p of picks) map.set(p.token, p.value)\n const escapedTrig = trigger.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n const re = new RegExp(\n `(^|[^${WORD_CLASS}])(${escapedTrig})([${WORD_CLASS}-]+)(\\\\{${markerKey}:[^}\\\\s]+\\\\})?`,\n 'gu'\n )\n return text.replace(re, (match, leading, trig, token, existingMarker) => {\n if (existingMarker) return match\n const value = map.get(token)\n if (value === undefined) return match\n return `${leading}${trig}${token}{${markerKey}:${value}}`\n })\n}\n\n/** `#component` 멘션 picks → `{refid:N}` 마커 주입 (호환 유지). */\nexport function injectMentionRefids(\n text: string,\n picks: ReadonlyArray<{ token: string; refid: number }> | null | undefined\n): string {\n return injectInlineMarkers(\n text,\n '#',\n 'refid',\n picks?.map(p => ({ token: p.token, value: p.refid }))\n )\n}\n\n/** `@user` 멘션 picks → `{userId:UUID}` 마커 주입. */\nexport function injectMentionUserIds(\n text: string,\n picks: ReadonlyArray<{ token: string; userId: string }> | null | undefined\n): string {\n return injectInlineMarkers(\n text,\n '@',\n 'userId',\n picks?.map(p => ({ token: p.token, value: p.userId }))\n )\n}\n\nexport function renderMarkdown(text: string): string {\n if (!text) return ''\n try {\n // 0) inline 마커 추출 (`{refid:N}` / `{userId:UUID}`) → 매핑 분리 + 본문 정화\n const { cleaned, maps } = extractInlineMentions(text)\n // 1) mention placeholder — `#abc` / `@abc` 가 marked 의 ATX 헤딩 / 이메일 등에\n // 휩쓸리지 않도록 영숫자 placeholder 로 치환.\n const m = extractMentions(cleaned)\n // 2) CJK strong placeholder\n const c = extractCjkStrongs(m.processed)\n const out = marked.parse(c.processed)\n if (typeof out !== 'string') {\n // marked 가 Promise 를 반환했다면 (async lexer 등) 안전한 fallback\n return escapeHtml(stripMentionRefids(text)).replace(/\\n/g, '<br>')\n }\n // 3) 역순 복원 — CJK strong → mention span. maps 로 data-refid / data-userid embed.\n return restoreMentions(restoreCjkStrongs(out, c.stored), m.stored, maps)\n } catch {\n return escapeHtml(stripMentionRefids(text)).replace(/\\n/g, '<br>')\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"markdown.js","sourceRoot":"","sources":["../../../client/components/markdown.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Board AI 채팅 메시지의 Markdown 렌더링.\n *\n * 보안 (defense-in-depth):\n * 1. raw HTML 차단 — marked 의 renderer.html → '' 로 block-level HTML 무력화.\n * 2. 위험 link 스킴 차단 — javascript: / data: / vbscript: 등 거부.\n * 3. DOMPurify 후처리 — marked 출력 전체를 sanitize. 우회되는 inline HTML\n * (이미지 onerror, span style=\"javascript:...\" 등) 까지 차단.\n *\n * 1·2 만으로도 표면이 좁지만, 다음의 *알려진 우회 경로* 가 존재하므로 3 이 필수:\n * - marked 가 토큰화 실패한 일부 입력 (특히 코드블록 안 백틱 escape 미스) 에서\n * 생 HTML 이 escape 안 된 채 흘러나갈 수 있음\n * - mention span (`<span class=\"mention\" ...>`) 이 우리 손으로 만드는 element 라\n * allowlist 기반 정화 필요\n * - 멀티유저 환경 (다른 사용자 메시지 echo) 시 표면 ↑\n *\n * CJK 보강:\n * - CommonMark의 left/right-flanking 규칙 때문에 한국어/일본어/중국어 텍스트와\n * ** 강조 마커 사이에 구두점이 끼면 마커가 닫히지 않는 케이스가 발생한다.\n * 예) \"**'rect'**의\" → 닫는 ** 가 [구두점] + [CJK] 사이에 위치하므로 right-flanking 실패\n * - 이런 케이스는 marked 에 넘기기 전에 자체적으로 추출 → 자리표시자로 치환 → marked\n * 수행 → 후처리에서 <strong>...</strong> 로 복원하는 방식으로 처리한다.\n * - 이 우회는 사용자가 의도적으로 넣은 ** 만 대상으로 하므로 보안적으로 안전하다 (내용은\n * escapeHtml 로 항상 escape).\n */\nimport DOMPurify from 'dompurify'\nimport { marked } from 'marked'\n\n/**\n * DOMPurify 정화 정책.\n *\n * 우리가 markdown 출력에서 의도적으로 사용하는 element / attribute 만 명시 허용:\n * - 표준 markdown elements (p, h1~6, ul/ol/li, strong/em, a, img, code, pre, blockquote, hr, br, table 계열)\n * - mention span (class + data-refid + data-userid)\n *\n * 그 외는 모두 strip — 이벤트 핸들러 (onerror/onclick), inline style, javascript:\n * URL 등 자동 차단. 우리 구조에 없는 element (script/iframe/object/embed 등) 도 모두 제거.\n */\nconst PURIFY_CONFIG: any = {\n ALLOWED_TAGS: [\n 'p', 'br', 'hr',\n 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',\n 'strong', 'em', 'del', 's', 'u',\n 'ul', 'ol', 'li',\n 'a', 'img',\n 'code', 'pre',\n 'blockquote',\n 'table', 'thead', 'tbody', 'tr', 'th', 'td',\n 'span'\n ],\n ALLOWED_ATTR: [\n 'href', 'title', 'alt', 'src', 'class',\n 'data-refid', 'data-userid'\n ],\n // javascript: / data: / vbscript: 등 위험 스킴 자동 차단\n ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|ftp):|[^a-z]|[a-z+.\\-]+(?:[^a-z+.\\-:]|$))/i,\n // target=\"_blank\" 같이 우리가 명시하는 link 외엔 추가 attr 안 붙이도록\n ADD_ATTR: ['target', 'rel'],\n // raw HTML 입력은 marked.renderer.html → '' 로 이미 막혀있지만,\n // dompurify 가 전체 input 을 한 번 더 정화 — 우회 차단\n KEEP_CONTENT: true\n}\n\nconst markdownRenderer = new marked.Renderer()\nmarkdownRenderer.html = () => ''\n\nconst DANGEROUS_SCHEME_RE = /^(javascript|data|vbscript|file):/i\n\nconst origLink = markdownRenderer.link.bind(markdownRenderer)\nmarkdownRenderer.link = (token: any) => {\n const href = typeof token?.href === 'string' ? token.href.trim() : ''\n if (DANGEROUS_SCHEME_RE.test(href)) {\n return token?.text ?? ''\n }\n return origLink(token)\n}\n\n// 이미지 renderer — src scheme 검사 + alt 의 inline HTML escape\nconst origImage = markdownRenderer.image.bind(markdownRenderer)\nmarkdownRenderer.image = (token: any) => {\n const src = typeof token?.href === 'string' ? token.href.trim() : ''\n if (DANGEROUS_SCHEME_RE.test(src)) {\n // 위험 src — 이미지 자체 제거. alt 텍스트는 escape 해서 평문 노출\n return escapeHtml(token?.text ?? '')\n }\n return origImage(token)\n}\n\nmarked.setOptions({\n gfm: true,\n breaks: true,\n pedantic: false,\n renderer: markdownRenderer,\n async: false\n} as any)\n\nexport function escapeHtml(text: string): string {\n return text\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n}\n\n// CJK 문자 범위: Hiragana, Katakana, CJK Unified Ideographs (Ext A 포함), Hangul, 전각/반각\nconst CJK_RE = /[-ヿ㐀-䶿一-鿿가--]/\nconst PUNCT_RE = /\\p{P}/u\n\n// marked 가 절대 변형하지 않는 토큰 (영숫자만 사용)\nconst PLACEHOLDER_PREFIX = 'BAICJKSTRONG'\nconst PLACEHOLDER_SUFFIX = 'ENDMARK'\nconst MENTION_PLACEHOLDER_PREFIX = 'BAIMENTION'\nconst MENTION_PLACEHOLDER_SUFFIX = 'ENDMENTION'\n\n// mention 토큰에 허용되는 문자 — alphanumeric + 한글/일본/중국어. 공백/구두점에서 끊김.\nconst MENTION_WORD_RE = /[\\w\\p{Script=Hangul}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Han}]/u\n\ninterface ExtractResult {\n processed: string\n stored: string[]\n}\n\ninterface MentionExtractResult {\n processed: string\n stored: StoredMention[]\n}\n\n/**\n * `**X**` 패턴 중 CJK + 구두점 인접 때문에 marked 의 flanking 규칙에서 누락되는\n * 케이스를 추출. 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않는다.\n *\n * 검출 기준 (CommonMark right-flanking 실패 ↔ left-flanking 실패):\n * - 닫는 ** 직전 문자가 구두점 AND 직후 문자가 CJK letter\n * - 여는 ** 직전 문자가 CJK letter AND 직후 문자가 구두점\n */\nexport function extractCjkStrongs(text: string): ExtractResult {\n const stored: string[] = []\n const lines = text.split('\\n')\n const outLines: string[] = []\n let inFence = false\n\n for (const line of lines) {\n if (/^\\s{0,3}```/.test(line)) {\n inFence = !inFence\n outLines.push(line)\n continue\n }\n if (inFence) {\n outLines.push(line)\n continue\n }\n outLines.push(processLine(line, stored, () => outLines[outLines.length - 1] ?? ''))\n }\n\n return { processed: outLines.join('\\n'), stored }\n}\n\nfunction processLine(line: string, stored: string[], prevLine: () => string): string {\n let i = 0\n let result = ''\n while (i < line.length) {\n const ch = line[i]\n\n if (ch === '`') {\n const end = line.indexOf('`', i + 1)\n if (end === -1) {\n result += line.slice(i)\n break\n }\n result += line.slice(i, end + 1)\n i = end + 1\n continue\n }\n\n if (ch === '*' && line[i + 1] === '*') {\n const close = findCloseStrong(line, i + 2)\n if (close === -1) {\n result += '**'\n i += 2\n continue\n }\n const inner = line.slice(i + 2, close)\n if (inner.length === 0) {\n result += '**'\n i += 2\n continue\n }\n const before = result.length > 0 ? result.slice(-1) : prevLine().slice(-1)\n const after = close + 2 < line.length ? line[close + 2] : ''\n if (needsCjkFix(before, after, inner)) {\n stored.push(inner)\n result += `${PLACEHOLDER_PREFIX}${stored.length - 1}${PLACEHOLDER_SUFFIX}`\n } else {\n result += line.slice(i, close + 2)\n }\n i = close + 2\n continue\n }\n\n result += ch\n i++\n }\n return result\n}\n\nfunction findCloseStrong(line: string, start: number): number {\n let i = start\n while (i < line.length - 1) {\n if (line[i] === '`') {\n const end = line.indexOf('`', i + 1)\n if (end === -1) return -1\n i = end + 1\n continue\n }\n if (line[i] === '*' && line[i + 1] === '*') return i\n i++\n }\n return -1\n}\n\nfunction needsCjkFix(before: string, after: string, inner: string): boolean {\n const innerStart = inner[0]\n const innerEnd = inner[inner.length - 1]\n return (\n (before !== '' && CJK_RE.test(before) && PUNCT_RE.test(innerStart)) ||\n (after !== '' && CJK_RE.test(after) && PUNCT_RE.test(innerEnd))\n )\n}\n\nfunction restoreCjkStrongs(html: string, stored: string[]): string {\n let out = html\n for (let n = 0; n < stored.length; n++) {\n const token = `${PLACEHOLDER_PREFIX}${n}${PLACEHOLDER_SUFFIX}`\n const replacement = `<strong>${escapeHtml(stored[n])}</strong>`\n out = out.split(token).join(replacement)\n }\n return out\n}\n\n/**\n * `#token` mention 패턴을 추출 → placeholder 로 치환.\n *\n * 활성 조건 (chat textarea 의 extractMentionContext 와 일관):\n * - `#` 직전이 문자열 시작 또는 word char 가 아님 (공백/구두점/줄 시작)\n * - `#` 직후가 word char (영숫자/한글/일본/중국어)\n * - 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않음\n *\n * Markdown ATX 헤딩 (`# Title`) 과 충돌하지 않음 — 헤딩은 `#` 다음에 공백이 오므로\n * 본 정규식 (직후가 word char) 매칭 안 됨.\n */\nexport function extractMentions(text: string): MentionExtractResult {\n const stored: StoredMention[] = []\n const lines = text.split('\\n')\n const outLines: string[] = []\n let inFence = false\n\n for (const line of lines) {\n if (/^\\s{0,3}```/.test(line)) {\n inFence = !inFence\n outLines.push(line)\n continue\n }\n if (inFence) {\n outLines.push(line)\n continue\n }\n outLines.push(processMentionLine(line, stored))\n }\n\n return { processed: outLines.join('\\n'), stored }\n}\n\n/** 추출된 mention — trigger 와 함께 보존해 restore 시 trigger 별 다른 span 생성. */\ninterface StoredMention {\n trigger: '#' | '@'\n token: string\n}\n\nfunction processMentionLine(line: string, stored: StoredMention[]): string {\n let result = ''\n let i = 0\n while (i < line.length) {\n const ch = line[i]\n if (ch === '`') {\n // 인라인 코드 — 닫는 backtick 까지 그대로 통과\n const end = line.indexOf('`', i + 1)\n if (end === -1) {\n result += line.slice(i)\n break\n }\n result += line.slice(i, end + 1)\n i = end + 1\n continue\n }\n if (ch === '#' || ch === '@') {\n const prev = i > 0 ? line[i - 1] : ''\n const next = line[i + 1] ?? ''\n if (\n (prev === '' || !MENTION_WORD_RE.test(prev)) &&\n next !== '' &&\n MENTION_WORD_RE.test(next)\n ) {\n // 토큰 끝 위치 찾기\n let j = i + 1\n while (j < line.length && MENTION_WORD_RE.test(line[j])) j++\n const token = line.slice(i + 1, j)\n stored.push({ trigger: ch as '#' | '@', token })\n result += `${MENTION_PLACEHOLDER_PREFIX}${stored.length - 1}${MENTION_PLACEHOLDER_SUFFIX}`\n i = j\n continue\n }\n }\n result += ch\n i++\n }\n return result\n}\n\n/**\n * placeholder 를 mention span 으로 복원. trigger 별로 다른 span:\n * - `#component` → `<span class=\"mention\" data-refid=\"N\">#token</span>`\n * - `@user` → `<span class=\"user-mention\" data-userid=\"UUID\">@token</span>`\n * 매핑 없으면 data-* 속성 없이 단순 span.\n */\nfunction restoreMentions(\n html: string,\n stored: StoredMention[],\n maps: InlineMentionMaps\n): string {\n let out = html\n for (let n = 0; n < stored.length; n++) {\n const placeholder = `${MENTION_PLACEHOLDER_PREFIX}${n}${MENTION_PLACEHOLDER_SUFFIX}`\n const { trigger, token } = stored[n]\n let replacement: string\n if (trigger === '#') {\n const refid = maps.refidByToken.get(token)\n const attr =\n typeof refid === 'number' && Number.isFinite(refid)\n ? ` data-refid=\"${refid}\"`\n : ''\n replacement = `<span class=\"mention\"${attr}>#${escapeHtml(token)}</span>`\n } else {\n const userId = maps.userIdByToken.get(token)\n const attr = userId ? ` data-userid=\"${escapeHtml(userId)}\"` : ''\n replacement = `<span class=\"user-mention\"${attr}>@${escapeHtml(token)}</span>`\n }\n out = out.split(placeholder).join(replacement)\n }\n return out\n}\n\n/**\n * 인라인 마커 정규식 — `{key:value}` 형태. 현재 사용:\n * - `{refid:N}` — `#component` 멘션\n * - `{userId:UUID}` — `@user` 멘션\n * 향후 추가 종류 (agentId / threadId / 외부 시스템 id 등) 도 동일 형식.\n */\nconst MENTION_MARKER_RE = /\\{[a-zA-Z][a-zA-Z0-9]*:[^}\\s]+\\}/g\n\nconst WORD_CLASS =\n '\\\\w\\\\p{Script=Hangul}\\\\p{Script=Hiragana}\\\\p{Script=Katakana}\\\\p{Script=Han}'\n\n/**\n * 본문에서 모든 `{key:value}` 마커 제거 — textarea 편집 / LLM 입력 / 평문 표출 등.\n * 토큰 (#component / @user) 자체는 보존.\n */\nexport function stripMentionRefids(text: string | null | undefined): string {\n if (!text) return ''\n return text.replace(MENTION_MARKER_RE, '')\n}\n\ninterface InlineMentionMaps {\n /** trigger='#' 의 token → refid (component) */\n refidByToken: Map<string, number>\n /** trigger='@' 의 token → userId (user) */\n userIdByToken: Map<string, string>\n}\n\n/**\n * `#token{refid:N}` / `@user{userId:UUID}` 마커를 추출해 매핑 + 마커 제거된 본문 반환.\n * 렌더링 직전에 호출 — 마킹 정보 분리 후 markdown 처리에 깨끗한 텍스트만.\n */\nfunction extractInlineMentions(text: string): {\n cleaned: string\n maps: InlineMentionMaps\n} {\n const refidByToken = new Map<string, number>()\n const userIdByToken = new Map<string, string>()\n\n const componentRe = new RegExp(\n `(^|[^${WORD_CLASS}])#([${WORD_CLASS}-]+)\\\\{refid:(\\\\d+)\\\\}`,\n 'gu'\n )\n const userRe = new RegExp(\n `(^|[^${WORD_CLASS}])@([${WORD_CLASS}-]+)\\\\{userId:([^}\\\\s]+)\\\\}`,\n 'gu'\n )\n\n let cleaned = text.replace(componentRe, (_m, leading, token, refidStr) => {\n const refid = Number(refidStr)\n if (Number.isFinite(refid) && !refidByToken.has(token)) refidByToken.set(token, refid)\n return `${leading}#${token}`\n })\n cleaned = cleaned.replace(userRe, (_m, leading, token, userId) => {\n if (!userIdByToken.has(token)) userIdByToken.set(token, userId)\n return `${leading}@${token}`\n })\n return { cleaned, maps: { refidByToken, userIdByToken } }\n}\n\n/**\n * 일반화된 inline marker 주입.\n * injectInlineMarkers(text, '#', 'refid', [{token, value: 35}])\n * injectInlineMarkers(text, '@', 'userId', [{token, value: 'uuid'}])\n */\nfunction injectInlineMarkers(\n text: string,\n trigger: string,\n markerKey: string,\n picks: ReadonlyArray<{ token: string; value: string | number }> | null | undefined\n): string {\n if (!text || !picks || picks.length === 0) return text ?? ''\n const map = new Map<string, string | number>()\n for (const p of picks) map.set(p.token, p.value)\n const escapedTrig = trigger.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n const re = new RegExp(\n `(^|[^${WORD_CLASS}])(${escapedTrig})([${WORD_CLASS}-]+)(\\\\{${markerKey}:[^}\\\\s]+\\\\})?`,\n 'gu'\n )\n return text.replace(re, (match, leading, trig, token, existingMarker) => {\n if (existingMarker) return match\n const value = map.get(token)\n if (value === undefined) return match\n return `${leading}${trig}${token}{${markerKey}:${value}}`\n })\n}\n\n/** `#component` 멘션 picks → `{refid:N}` 마커 주입 (호환 유지). */\nexport function injectMentionRefids(\n text: string,\n picks: ReadonlyArray<{ token: string; refid: number }> | null | undefined\n): string {\n return injectInlineMarkers(\n text,\n '#',\n 'refid',\n picks?.map(p => ({ token: p.token, value: p.refid }))\n )\n}\n\n/** `@user` 멘션 picks → `{userId:UUID}` 마커 주입. */\nexport function injectMentionUserIds(\n text: string,\n picks: ReadonlyArray<{ token: string; userId: string }> | null | undefined\n): string {\n return injectInlineMarkers(\n text,\n '@',\n 'userId',\n picks?.map(p => ({ token: p.token, value: p.userId }))\n )\n}\n\nexport function renderMarkdown(text: string): string {\n if (!text) return ''\n try {\n // 0) inline 마커 추출 (`{refid:N}` / `{userId:UUID}`) → 매핑 분리 + 본문 정화\n const { cleaned, maps } = extractInlineMentions(text)\n // 1) mention placeholder — `#abc` / `@abc` 가 marked 의 ATX 헤딩 / 이메일 등에\n // 휩쓸리지 않도록 영숫자 placeholder 로 치환.\n const m = extractMentions(cleaned)\n // 2) CJK strong placeholder\n const c = extractCjkStrongs(m.processed)\n const out = marked.parse(c.processed)\n if (typeof out !== 'string') {\n // marked 가 Promise 를 반환했다면 (async lexer 등) 안전한 fallback\n return escapeHtml(stripMentionRefids(text)).replace(/\\n/g, '<br>')\n }\n // 3) 역순 복원 — CJK strong → mention span. maps 로 data-refid / data-userid embed.\n const restored = restoreMentions(restoreCjkStrongs(out, c.stored), m.stored, maps)\n // 4) DOMPurify 후처리 — defense-in-depth. marked / 우리 mention span 둘 다 한 번 더 정화.\n return sanitizeHtml(restored)\n } catch {\n return escapeHtml(stripMentionRefids(text)).replace(/\\n/g, '<br>')\n }\n}\n\n/**\n * DOMPurify wrapper. Node 환경 (테스트) 에서도 안전하게 동작하도록 fallback.\n *\n * 브라우저: window.DOMParser 가 있으므로 DOMPurify 가 직접 정화.\n * Node (테스트): jsdom 없이 DOMPurify 호출은 실패 — 실패 시 escapeHtml fallback\n * (테스트에서 sanitize 동작은 별도 검증, 본 fallback 은 회귀 안전).\n */\nexport function sanitizeHtml(html: string): string {\n try {\n if (typeof window === 'undefined' || !DOMPurify.sanitize) {\n return html\n }\n const result = DOMPurify.sanitize(html, PURIFY_CONFIG)\n return typeof result === 'string' ? result : String(result)\n } catch {\n // DOM 환경 부재 또는 dompurify 초기화 실패 — 입력 그대로 통과\n // (브라우저에선 정상 동작 — Node 테스트에선 sanitize 의 효과를 직접 검증)\n return html\n }\n}\n"]}
|