From 1830f10e241c7d147bfbde315221775e2efa47d3 Mon Sep 17 00:00:00 2001 From: Victor_Jay Date: Wed, 27 May 2026 18:45:15 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20WYSIWYG=20=E6=B8=85?= =?UTF-8?q?=E7=A9=BA=E5=86=85=E5=AE=B9=20+=20=E5=88=86=E5=B1=8F=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=A0=8F=20+=20XSS=20=E9=98=B2=E5=BE=A1(bulemondy?= =?UTF-8?q?=E6=B6=88=E6=AF=92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 3 + go.sum | 6 + internal/controller/post_controller.go | 10 +- internal/service/post_service.go | 9 +- templates/MetaLab-2026/html/posts/show.html | 4 +- templates/MetaLab-2026/static/js/editor.js | 187 ++++++++++---------- 6 files changed, 113 insertions(+), 106 deletions(-) diff --git a/go.mod b/go.mod index dee3038..eb61687 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( ) require ( + github.com/aymerick/douceur v0.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect @@ -26,6 +27,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect @@ -36,6 +38,7 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect diff --git a/go.sum b/go.sum index a56cb82..9107546 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -40,6 +42,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -64,6 +68,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/internal/controller/post_controller.go b/internal/controller/post_controller.go index a007eba..1fbd409 100644 --- a/internal/controller/post_controller.go +++ b/internal/controller/post_controller.go @@ -1,6 +1,7 @@ package controller import ( + "html/template" "net/http" "strconv" @@ -102,10 +103,11 @@ func (ctrl *PostController) ShowPage(c *gin.Context) { } c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{ - "Title": post.Title, - "Post": post, - "UID": uid, - "ExtraCSS": "/static/css/posts.css", + "Title": post.Title, + "Post": post, + "PostBodyHTML": template.HTML(post.BodyHTML), + "UID": uid, + "ExtraCSS": "/static/css/posts.css", })) } diff --git a/internal/service/post_service.go b/internal/service/post_service.go index 60e1406..fc0929f 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -8,9 +8,14 @@ import ( "metazone.cc/metalab/internal/config" "metazone.cc/metalab/internal/model" + "github.com/microcosm-cc/bluemonday" "github.com/yuin/goldmark" ) +// ucgPolicy 对 Goldmark 输出的 HTML 做安全消毒 +// 移除 script / iframe / javscript: / data: 等危险内容 +var ucgPolicy = bluemonday.UGCPolicy() + // postStore 帖子服务所需的最小仓储接口(ISP) type postStore interface { Create(post *model.Post) error @@ -44,13 +49,13 @@ func (s *PostService) auditEnabled() bool { return s.ss.IsAuditEnabled() } -// renderMarkdown 将 Markdown 渲染为 HTML +// renderMarkdown 将 Markdown 渲染为安全 HTML(Goldmark + bluemonday 消毒) func (s *PostService) renderMarkdown(body string) (string, error) { var buf bytes.Buffer if err := s.md.Convert([]byte(body), &buf); err != nil { return "", err } - return buf.String(), nil + return ucgPolicy.Sanitize(buf.String()), nil } // Create 创建帖子 diff --git a/templates/MetaLab-2026/html/posts/show.html b/templates/MetaLab-2026/html/posts/show.html index 57baa13..557184b 100644 --- a/templates/MetaLab-2026/html/posts/show.html +++ b/templates/MetaLab-2026/html/posts/show.html @@ -17,10 +17,10 @@
- {{.Post.BodyHTML}} + {{.PostBodyHTML}} {{/* 如果 BodyHTML 为空,直接显示原文 */}}
- {{if not .Post.BodyHTML}} + {{if not .PostBodyHTML}}
{{.Post.Body}}
{{end}} diff --git a/templates/MetaLab-2026/static/js/editor.js b/templates/MetaLab-2026/static/js/editor.js index ad0c427..a813e64 100644 --- a/templates/MetaLab-2026/static/js/editor.js +++ b/templates/MetaLab-2026/static/js/editor.js @@ -4,7 +4,7 @@ var postId = document.getElementById('postId'); var titleEl = document.getElementById('postTitle'); - var bodyEl = document.getElementById('postBody'); // textarea (source of truth) + var bodyEl = document.getElementById('postBody'); // textarea (唯一数据源) var wysiwygEl = document.getElementById('wysiwygEditor'); // contenteditable var splitPrev = document.getElementById('splitPreview'); var splitCont = splitPrev ? splitPrev.querySelector('.split-preview-content') : null; @@ -13,6 +13,7 @@ var isEdit = postId && postId.value; var currentMode = 'wysiwyg'; + var wysiwygDirty = false; // WYSIWYG 是否已被用户编辑过(首次进入时不覆盖 textarea) var syncTimer = null; // ===================== HELPERS ===================== @@ -22,19 +23,9 @@ return meta ? meta.getAttribute('content') : ''; } - function getBodyValue() { - return currentMode === 'wysiwyg' ? bodyEl.value : bodyEl.value; - } - - // 保存到 textarea 并同步 WYSIWYG - function saveToTextarea(md) { - bodyEl.value = md; - if (currentMode === 'wysiwyg') syncMarkdownToWysiwyg(); - } - // ================== MARKDOWN ↔ HTML ================== - // 通过 API 渲染 markdown 为 HTML + // 通过 API 渲染 markdown 为 HTML(服务端 Goldmark + bluemonday 消毒) function renderMarkdown(md, callback) { fetch('/api/posts/preview', { method: 'POST', @@ -55,10 +46,7 @@ el.innerHTML = html; function walk(node) { - var md = ''; - if (node.nodeType === 3) { // text - return node.textContent; - } + if (node.nodeType === 3) return node.textContent; if (node.nodeType !== 1) return ''; var tag = node.tagName.toLowerCase(); var children = ''; @@ -82,8 +70,7 @@ case 'pre': var code = node.querySelector('code'); var lang = code ? (code.className.replace('language-','') || '') : ''; - var text = node.textContent; - return '\n```' + lang + '\n' + text + '\n```\n'; + return '\n```' + lang + '\n' + node.textContent + '\n```\n'; case 'code': if (node.parentNode.tagName.toLowerCase() !== 'pre') return '`' + children + '`'; @@ -97,9 +84,7 @@ return '> ' + children.replace(/\n$/, '').replace(/\n/g, '\n> ') + '\n\n'; case 'hr': return '\n---\n'; case 'img': - var src = node.getAttribute('src') || ''; - var alt = node.getAttribute('alt') || ''; - return '![' + alt + '](' + src + ')'; + return '![' + (node.getAttribute('alt') || '') + '](' + (node.getAttribute('src') || '') + ')'; case 'div': case 'section': return children; @@ -110,87 +95,36 @@ return walk(el).replace(/\n{3,}/g, '\n\n').trim(); } - // 将 markdown 渲染到 WYSIWYG + // 将 markdown 从 textarea 渲染到 WYSIWYG function syncMarkdownToWysiwyg() { if (!bodyEl.value.trim()) { wysiwygEl.innerHTML = ''; return; } renderMarkdown(bodyEl.value, function(html) { - var cur = wysiwygEl.innerHTML; - // 只在真的变化时更新,避免闪烁 - if (cur !== html) { - wysiwygEl.innerHTML = html; - } + wysiwygEl.innerHTML = html; }); } - // WYSIWYG 编辑后延迟同步回 textarea + // WYSIWYG 编辑后延迟将 HTML 转为 markdown 保存回 textarea function debounceSync() { clearTimeout(syncTimer); syncTimer = setTimeout(function() { var md = htmlToMarkdown(wysiwygEl.innerHTML); - bodyEl.value = md; - }, 500); + if (md !== bodyEl.value) { + bodyEl.value = md; + } + }, 600); } - // ==================== MODE SWITCHING ==================== - - function switchMode(mode) { - // 先同步当前状态 - if (currentMode === 'wysiwyg') { - var md = htmlToMarkdown(wysiwygEl.innerHTML); - bodyEl.value = md; - } - - currentMode = mode; - - // 更新标签 - modeTabs.forEach(function(tab) { - tab.classList.toggle('active', tab.dataset.mode === mode); - }); - - // 切换布局 class - var editorBody = document.querySelector('.editor-body'); - if (editorBody) { - editorBody.classList.toggle('split-mode', mode === 'split'); - } - - // 切换面板 - if (mode === 'wysiwyg') { - bodyEl.style.display = 'none'; - wysiwygEl.style.display = ''; - if (splitPrev) splitPrev.style.display = 'none'; - if (toolbar) toolbar.style.display = ''; - syncMarkdownToWysiwyg(); - } else if (mode === 'source') { - bodyEl.style.display = ''; - wysiwygEl.style.display = 'none'; - if (splitPrev) splitPrev.style.display = 'none'; - if (toolbar) toolbar.style.display = ''; - } else if (mode === 'split') { - bodyEl.style.display = ''; - wysiwygEl.style.display = 'none'; - if (splitPrev) splitPrev.style.display = ''; - if (toolbar) toolbar.style.display = 'none'; - updateSplitPreview(); - } - } - - modeTabs.forEach(function(tab) { - tab.addEventListener('click', function() { - switchMode(tab.dataset.mode); - }); - }); - - // 分屏实时预览(防抖) - var splitTimer = null; + // markdown 保存回 textarea 后触发分屏预览更新 bodyEl.addEventListener('input', function() { if (currentMode === 'split') { clearTimeout(splitTimer); splitTimer = setTimeout(updateSplitPreview, 300); } }); + var splitTimer = null; function updateSplitPreview() { if (!splitCont || !bodyEl.value.trim()) { @@ -204,7 +138,65 @@ // WYSIWYG 输入实时同步 wysiwygEl.addEventListener('input', function() { - if (currentMode === 'wysiwyg') debounceSync(); + if (currentMode === 'wysiwyg') { + wysiwygDirty = true; + debounceSync(); + } + }); + + // ==================== MODE SWITCHING ==================== + + function switchMode(mode) { + // 仅在离开 WYSIWYG 时同步 WYSIWYG → textarea + if (currentMode === 'wysiwyg' && mode !== 'wysiwyg' && wysiwygDirty) { + var md = htmlToMarkdown(wysiwygEl.innerHTML); + bodyEl.value = md; + } + + currentMode = mode; + wysiwygDirty = false; // 切换到新模式下重置 dirty 标记 + + // 更新标签 + modeTabs.forEach(function(tab) { + tab.classList.toggle('active', tab.dataset.mode === mode); + }); + + // 切换布局 class + var editorBody = document.querySelector('.editor-body'); + if (editorBody) { + editorBody.classList.toggle('split-mode', mode === 'split'); + } + + // 工具栏在 split 模式下也显示(源码风格操作) + if (toolbar) { + toolbar.style.display = (mode === 'source' || mode === 'split') ? '' : ''; + } + + // 切换面板 + if (mode === 'wysiwyg') { + bodyEl.style.display = 'none'; + wysiwygEl.style.display = ''; + if (splitPrev) splitPrev.style.display = 'none'; + syncMarkdownToWysiwyg(); + wysiwygEl.focus(); + } else if (mode === 'source') { + bodyEl.style.display = ''; + wysiwygEl.style.display = 'none'; + if (splitPrev) splitPrev.style.display = 'none'; + bodyEl.focus(); + } else if (mode === 'split') { + bodyEl.style.display = ''; + wysiwygEl.style.display = 'none'; + if (splitPrev) splitPrev.style.display = ''; + bodyEl.focus(); + updateSplitPreview(); + } + } + + modeTabs.forEach(function(tab) { + tab.addEventListener('click', function() { + switchMode(tab.dataset.mode); + }); }); // ==================== TOOLBAR ==================== @@ -216,7 +208,8 @@ var action = btn.dataset.action; if (currentMode === 'wysiwyg') { execWysiwygAction(action); - } else if (currentMode === 'source') { + } else { + // source 和 split 模式都用源码工具栏(操作 textarea 中的 markdown) execSourceAction(action); } }); @@ -264,7 +257,7 @@ document.execCommand('insertHorizontalRule', false, null); break; } - debounceSync(); + wysiwygDirty = true; } // WYSIWYG 中包裹选中文本 @@ -273,18 +266,13 @@ if (!sel.rangeCount) return; var range = sel.getRangeAt(0); var text = range.toString(); - if (!text) { - text = 'code'; - range.deleteContents(); - range.insertNode(document.createTextNode(before + text + after)); - } else { - range.deleteContents(); - range.insertNode(document.createTextNode(before + text + after)); - } - debounceSync(); + if (!text) text = 'code'; + range.deleteContents(); + range.insertNode(document.createTextNode(before + text + after)); + wysiwygDirty = true; } - // 源码模式下插入 markdown 语法 + // 源码/分屏模式下插入 markdown 语法 function execSourceAction(action) { var start = bodyEl.selectionStart; var end = bodyEl.selectionEnd; @@ -311,7 +299,10 @@ bodyEl.value = bodyEl.value.substring(0, start) + replacement + bodyEl.value.substring(end); bodyEl.focus(); var cursor = start + wrap.b.length + (text ? text.length : 0); - bodyEl.setSelectionRange(cursor, cursor + (text ? wrap.a.length : 0)); + bodyEl.setSelectionRange(cursor, cursor + (text ? 0 : replacement.length - wrap.b.length - wrap.a.length)); + + // 分屏模式下立刻更新预览 + if (currentMode === 'split') updateSplitPreview(); } } @@ -320,11 +311,10 @@ form.addEventListener('submit', function(e) { e.preventDefault(); - // WYSIWYG 模式下先同步 + // WYSIWYG 模式下先同步到 textarea if (currentMode === 'wysiwyg') { var md = htmlToMarkdown(wysiwygEl.innerHTML); bodyEl.value = md; - currentMode = 'source'; } var title = titleEl.value.trim(); @@ -364,6 +354,7 @@ // ==================== INIT ==================== if (bodyEl.value.trim()) { - switchMode('wysiwyg'); + // 如果已有内容(编辑帖子),渲染到 WYSIWYG 但不写入 textarea + syncMarkdownToWysiwyg(); } })();