From 2e2558e5ce266fab52ad6b8405d35cdaae29d1da Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 18 Oct 2026 14:53:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(vip):=20render=20=E5=BC=80=E9=80=9A?= =?UTF-8?q?=E4=BC=9A=E5=91=98=E6=9B=B4=E5=A4=9A=E5=86=85=E5=AE=B9=20as=20H?= =?UTF-8?q?TML=20in=20the=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three layers were dropping the markup: the save path was already relaxed to wp_kses_post via schema sanitize_type html, but the runtime output re-filtered more through the narrow equity whitelist (h/img/hr/table stripped), and pay-vip.js inserted it with node() whose textContent showed the tags as literal text. The card-pass desc had the same output-side strip despite its save-side fix. - PaymentRuntime: more and pay_vip_pass_charge_desc now output via wp_kses_post, matching the save-time level (repo precedent: revenue note, after-sale desc) - pay-vip.js: new htmlNode() helper (innerHTML) for server-sanitized rich text; more and cardPass.desc use it, plain-text fields keep node() Verification: 3 new contract tests cover output-level kses and innerHTML rendering; node --test tests/**/*.test.cjs 640/640 pass; php -l and node --check clean. --- assets/js/pay-vip.js | 13 +++++++++++-- src/Settings/PaymentRuntime.php | 8 ++++++-- .../widget-system/vip-modal-copy-fields.test.cjs | 16 ++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/assets/js/pay-vip.js b/assets/js/pay-vip.js index 894f917e..9c261d9d 100644 --- a/assets/js/pay-vip.js +++ b/assets/js/pay-vip.js @@ -17,6 +17,15 @@ return el; } + // 服务端已按 post 级 HTML 净化的富文本字段(more / 卡密说明)走 innerHTML; + // node() 的 textContent 会把标签当字面文本显示。 + function htmlNode(tag, className, value) { + var el = document.createElement(tag); + if (className) el.className = className; + if (value !== undefined && value !== null) el.innerHTML = text(value); + return el; + } + function imageNode(src, className) { if (!src) return null; var image = document.createElement("img"); @@ -179,7 +188,7 @@ box.setAttribute("data-condition", "=="); box.setAttribute("data-value", "card_pass"); if (defaultMethod !== "card_pass") box.style.display = "none"; - if (cardPass.desc) append(box, node("div", "muted-box muted-2-color padding-10 mb10 em09", cardPass.desc)); + if (cardPass.desc) append(box, htmlNode("div", "muted-box muted-2-color padding-10 mb10 em09", cardPass.desc)); append(box, node("div", "muted-2-color em09 mb6", cardPass.only_password ? "请输入卡密" : "请输入卡号和密码")); if (!cardPass.only_password) { var cardInput = node("input", "form-control mb6"); @@ -492,7 +501,7 @@ + ''; append(products, pointsLink); } - if (catalog.more) append(products, node("div", "muted-2-color mb10 em09 muted-box padding-10 mini-radius", catalog.more)); + if (catalog.more) append(products, htmlNode("div", "muted-2-color mb10 em09 muted-box padding-10 mini-radius", catalog.more)); renderMethods(products, methods, cardPass, wallet); var actionName = actionKey(action); var actionAllowed = allowed[actionName] === true; diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php index c5e4d7be..9bf3e887 100644 --- a/src/Settings/PaymentRuntime.php +++ b/src/Settings/PaymentRuntime.php @@ -121,7 +121,9 @@ class PaymentRuntime { ], 'vip' => [ 'enabled' => !empty($settings['pay_vip_pass_charge_s']), - 'desc' => sanitize_textarea_field((string) ($settings['pay_vip_pass_charge_desc'] ?? '')), + // 保存端已按 post 级 HTML 放行(schema sanitize_type html), + // 输出端不能再被 textarea 净化剥掉标签。 + 'desc' => wp_kses_post((string) ($settings['pay_vip_pass_charge_desc'] ?? '')), // 默认非单密码(卡号+密码),对齐参考主题 pay_vip_pass_charge_only_password 默认 false。 'only_password' => !empty($settings['pay_vip_pass_charge_only_password']), ], @@ -218,7 +220,9 @@ class PaymentRuntime { 'name' => $vip1_enabled ? $name1 : $badge, 'badge_text' => $badge, 'desc' => $desc, - 'more' => wp_kses($more, $allowed_equity + ['div' => ['class' => true], 'p' => ['class' => true]]), + // 管理员编辑的富文本说明:与保存端同为 post 级 HTML(wp_kses_post), + // 不再用权益列表的窄白名单二次剥标签(h/img/hr/table 等)。 + 'more' => wp_kses_post($more), 'levels' => [ 1 => [ 'enabled' => $vip1_enabled, diff --git a/tests/widget-system/vip-modal-copy-fields.test.cjs b/tests/widget-system/vip-modal-copy-fields.test.cjs index 87196c9d..6051b538 100644 --- a/tests/widget-system/vip-modal-copy-fields.test.cjs +++ b/tests/widget-system/vip-modal-copy-fields.test.cjs @@ -156,3 +156,19 @@ test('开通会员更多内容只在弹窗内渲染,不裸露在用户中心 assert.match(modalSource, /'more'\s+=>/); assert.match(payVipJs, /catalog\.more/); }); + +test('more 与卡密说明输出按 post 级 HTML 放行', () => { + // 保存端已 wp_kses_post,输出端不能再窄白名单/textarea 净化二次剥标签。 + const vipSettings = slice(paymentRuntime, 'public static function get_payment_vip_settings', 'public static function get_payment_revenue_settings'); + assert.match(vipSettings, /'more'\s+=> wp_kses_post\(\$more\)/); + assert.doesNotMatch(vipSettings, /\$allowed_equity \+ \['div'/); + const rechargeSettings = slice(paymentRuntime, 'public static function get_payment_recharge_settings', 'public static function get_payment_vip_settings'); + assert.match(rechargeSettings, /'desc'\s+=> wp_kses_post\(\(string\) \(\$settings\['pay_vip_pass_charge_desc'\]/); +}); + +test('弹窗内 more 与卡密说明按 HTML 渲染而非纯文本', () => { + // node() 走 textContent 会把标签显示为字面文本;富文本字段必须走 innerHTML。 + assert.match(payVipJs, /function htmlNode\(tag, className, value\) \{[\s\S]*?el\.innerHTML = text\(value\);/); + assert.ok(payVipJs.includes('htmlNode("div", "muted-2-color mb10 em09 muted-box padding-10 mini-radius", catalog.more)')); + assert.ok(payVipJs.includes('htmlNode("div", "muted-box muted-2-color padding-10 mb10 em09", cardPass.desc)')); +});