/**
* Sanitize HTML before v-html.
*
* Two levels:
* - sanitizeHtml() → strict, for user-generated content (chat, profile, writing feedback)
* - sanitizeQuizHtml() → allows style/border on table elements, for trusted admin quiz JSON data
*/
import DOMPurify from 'dompurify'
// Safe CSS properties allowed in quiz content (IELTS table/heading formatting)
const _SAFE_STYLE_PROPS = new Set([
'text-align', 'font-weight', 'font-style', 'color', 'background-color',
'border', 'border-color', 'border-collapse', 'border-style', 'border-width',
'padding', 'padding-left', 'padding-right', 'padding-top', 'padding-bottom',
'margin', 'margin-left', 'margin-right', 'margin-top', 'margin-bottom',
'width', 'max-width', 'height', 'vertical-align',
])
// Strip dangerous CSS values (javascript: protocol, expression(), etc.)
function _sanitizeStyleValue(value) {
if (!value) return ''
if (/javascript\s*:/i.test(value)) return ''
if (/expression\s*\(/i.test(value)) return ''
return value
}
// DOMPurify hook: strips unsafe CSS properties from style attributes
function _addStyleHook() {
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
if (data.attrName !== 'style') return
const declarations = data.attrValue
.split(';')
.map((d) => d.trim())
.filter(Boolean)
.filter((d) => {
const [prop] = d.split(':')
return _SAFE_STYLE_PROPS.has((prop || '').trim().toLowerCase())
})
.map((d) => {
const idx = d.indexOf(':')
const prop = d.slice(0, idx).trim()
const val = _sanitizeStyleValue(d.slice(idx + 1).trim())
return val ? `${prop}: ${val}` : null
})
.filter(Boolean)
data.attrValue = declarations.join('; ')
if (!data.attrValue) data.keepAttr = false
})
}
_addStyleHook()
const _COMMON_TAGS = [
'p', 'br', 'strong', 'em', 'u', 'b', 'i', 'ol', 'ul', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span', 'div',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
'img', 'sub', 'sup', 'blockquote', 'mark', 'code', 'pre',
]
// ── Strict config: user content, chat messages, writing feedback ──────────────
const _STRICT_CONFIG = {
ALLOWED_TAGS: _COMMON_TAGS,
ALLOWED_ATTR: ['class', 'alt', 'colspan', 'rowspan', 'title', 'data-question-id'],
FORBID_ATTR: ['style', 'onerror', 'onload', 'onclick', 'onmouseover'],
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|\/(?:images|audio|media|uploads)\/|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i,
}
// ── Quiz config: trusted admin JSON data (IELTS tables, headings, GAP_FILLING, images) ─
const _QUIZ_CONFIG = {
ALLOWED_TAGS: _COMMON_TAGS,
ALLOWED_ATTR: [
'class', 'style', 'src', 'alt', 'colspan', 'rowspan', 'title',
'data-question-id', 'border', 'width', 'height', 'cellpadding', 'cellspacing',
'align', 'valign', 'loading',
],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'href'],
// Allow https:// external images (cms.youpass.vn) and local data-assets paths
ALLOWED_URI_REGEXP: /^(?:(?:https?):|\/(?:images|audio|media|uploads|data-assets)\/|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i,
}
/**
* Strict sanitizer — use for user-generated content (chat, profile, writing feedback).
* Strips all inline styles.
*/
export function sanitizeHtml(dirty) {
if (dirty == null || dirty === '') return ''
return DOMPurify.sanitize(String(dirty), _STRICT_CONFIG)
}
/**
* Quiz-safe sanitizer — use for IELTS question content, GAP_FILLING tables,
* passage titles, and instruction HTML from trusted admin JSON data.
* Allows safe inline styles (text-align, border-color, etc.) but strips JS.
*/
export function sanitizeQuizHtml(dirty) {
if (dirty == null || dirty === '') return ''
return DOMPurify.sanitize(String(dirty), _QUIZ_CONFIG)
}