Compare commits
4 Commits
2b75ac4d28
...
bcd13958ac
| Author | SHA1 | Date | |
|---|---|---|---|
| bcd13958ac | |||
| c6999763c0 | |||
| 5e5583fe20 | |||
| 68ac629f44 |
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -5602,6 +5602,7 @@ dependencies = [
|
||||
"deadpool-postgres",
|
||||
"dioxus",
|
||||
"dotenvy",
|
||||
"fancy-regex",
|
||||
"futures",
|
||||
"governor",
|
||||
"hex",
|
||||
|
||||
@ -20,6 +20,10 @@ argon2 = { version = "0.5", optional = true }
|
||||
uuid = { version = "1", features = ["v4"], optional = true }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
regex = { version = "1.12", optional = true }
|
||||
# mhchem 化学公式转译器(src/api/mhchem.rs)需要 lookahead 正则,
|
||||
# Rust `regex` crate 不支持 `(?=...)`/`(?!...)`,故用 `fancy-regex`。
|
||||
# 该 crate 已被 syntect 间接引入(0.16.2,见 Cargo.lock),此处显式声明为直接依赖。
|
||||
fancy-regex = { version = "0.16", optional = true }
|
||||
pulldown-cmark = { version = "0.13", optional = true }
|
||||
dotenvy = { version = "0.15", optional = true }
|
||||
tracing = { version = "0.1", optional = true, features = ["release_max_level_info"] }
|
||||
@ -98,6 +102,7 @@ server = [
|
||||
"dep:argon2",
|
||||
"dep:uuid",
|
||||
"dep:regex",
|
||||
"dep:fancy-regex",
|
||||
"dep:pulldown-cmark",
|
||||
"dep:rand",
|
||||
"dep:http",
|
||||
|
||||
249
libs/patches/@tiptap__markdown@3.27.3.patch
Normal file
249
libs/patches/@tiptap__markdown@3.27.3.patch
Normal file
@ -0,0 +1,249 @@
|
||||
diff --git a/dist/index.cjs b/dist/index.cjs
|
||||
index 617292de240bd244e51ab3b0690b8ece3353637e..c5421ddadc6aea87d7ba5f46f04d61ca36aa7834 100644
|
||||
--- a/dist/index.cjs
|
||||
+++ b/dist/index.cjs
|
||||
@@ -274,6 +274,9 @@ function htmlContainsUnrecognizedTag(html, schemaTags) {
|
||||
return !schemaTags.has(tagName);
|
||||
});
|
||||
}
|
||||
+function encodeLimitedHtmlEntities(text) {
|
||||
+ return text.replace(/&/g, "&").replace(/</g, "<");
|
||||
+}
|
||||
|
||||
// src/MarkdownManager.ts
|
||||
var MarkdownManager = class {
|
||||
@@ -1049,29 +1052,40 @@ var MarkdownManager = class {
|
||||
return { type: "text", text };
|
||||
}
|
||||
/**
|
||||
- * Encode HTML entities in text unless the node is inside a code context
|
||||
- * (code mark or code-block parent) where literal characters should be preserved.
|
||||
- * Also backslash-escape markdown-significant characters in non-code text to
|
||||
- * prevent them from being misinterpreted as formatting delimiters.
|
||||
+ * Encode HTML entities + backslash-escape markdown-significant chars.
|
||||
+ * Patch: CommonMark direction-aware escape for * and _ (only escape
|
||||
+ * emphasis-boundary positions, not intra-word); drop > from HTML encoding.
|
||||
*/
|
||||
encodeTextForMarkdown(text, node, parentNode) {
|
||||
const isInsideCode = (parentNode == null ? void 0 : parentNode.type) != null && this.codeTypes.has(parentNode.type) || (node.marks || []).some((m) => this.codeTypes.has(typeof m === "string" ? m : m.type));
|
||||
if (isInsideCode) {
|
||||
return text;
|
||||
}
|
||||
- return this.escapeMarkdownSyntax(_core.encodeHtmlEntities.call(void 0, text));
|
||||
+ return this.escapeMarkdownSyntax(encodeLimitedHtmlEntities(text));
|
||||
}
|
||||
- /**
|
||||
- * Backslash-escape characters that have special meaning in markdown inline
|
||||
- * syntax. This prevents literal characters in text nodes from being
|
||||
- * misinterpreted as formatting delimiters when the output is parsed again.
|
||||
- *
|
||||
- * The set covers the most common inline markdown syntax characters.
|
||||
- * Characters inside code blocks/code marks are skipped by the caller
|
||||
- * (`encodeTextForMarkdown`) via the existing `isInsideCode` guard.
|
||||
- */
|
||||
escapeMarkdownSyntax(text) {
|
||||
- return text.replace(/([\\`*_[\]~])/g, "\\$1");
|
||||
+ const punct = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~';
|
||||
+ let result = "";
|
||||
+ for (let i = 0; i < text.length; i++) {
|
||||
+ const ch = text[i];
|
||||
+ if (ch === "*" || ch === "_") {
|
||||
+ const prev = i > 0 ? text[i - 1] : "";
|
||||
+ const next = i < text.length - 1 ? text[i + 1] : "";
|
||||
+ const leftSpace = prev === "" || /\s/.test(prev);
|
||||
+ const leftPunct = punct.includes(prev);
|
||||
+ const leftOther = !leftSpace && !leftPunct;
|
||||
+ const rightSpace = next === "" || /\s/.test(next);
|
||||
+ const rightPunct = punct.includes(next);
|
||||
+ const rightOther = !rightSpace && !rightPunct;
|
||||
+ const isBoundary = ((leftSpace || leftPunct) && rightOther) || ((rightSpace || rightPunct) && leftOther);
|
||||
+ result += isBoundary ? "\\" + ch : ch;
|
||||
+ } else if (ch === "`" || ch === "[" || ch === "]" || ch === "~" || ch === "\\") {
|
||||
+ result += "\\" + ch;
|
||||
+ } else {
|
||||
+ result += ch;
|
||||
+ }
|
||||
+ }
|
||||
+ return result;
|
||||
}
|
||||
renderNodeToMarkdown(node, parentNode, index = 0, level = 0, meta = {}) {
|
||||
var _a;
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 18088ba76ee44bcddc94fdf30502928249516f10..e57cf65f9ffabca7728de5a39637d309720394c5 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
attrsEqual as attrsEqual2,
|
||||
callOrReturn,
|
||||
decodeHtmlEntities,
|
||||
- encodeHtmlEntities,
|
||||
flattenExtensions,
|
||||
generateJSON,
|
||||
getExtensionField,
|
||||
@@ -274,6 +273,9 @@ function htmlContainsUnrecognizedTag(html, schemaTags) {
|
||||
return !schemaTags.has(tagName);
|
||||
});
|
||||
}
|
||||
+function encodeLimitedHtmlEntities(text) {
|
||||
+ return text.replace(/&/g, "&").replace(/</g, "<");
|
||||
+}
|
||||
|
||||
// src/MarkdownManager.ts
|
||||
var MarkdownManager = class {
|
||||
@@ -1049,29 +1051,40 @@ var MarkdownManager = class {
|
||||
return { type: "text", text };
|
||||
}
|
||||
/**
|
||||
- * Encode HTML entities in text unless the node is inside a code context
|
||||
- * (code mark or code-block parent) where literal characters should be preserved.
|
||||
- * Also backslash-escape markdown-significant characters in non-code text to
|
||||
- * prevent them from being misinterpreted as formatting delimiters.
|
||||
+ * Encode HTML entities + backslash-escape markdown-significant chars.
|
||||
+ * Patch: CommonMark direction-aware escape for * and _ (only escape
|
||||
+ * emphasis-boundary positions, not intra-word); drop > from HTML encoding.
|
||||
*/
|
||||
encodeTextForMarkdown(text, node, parentNode) {
|
||||
const isInsideCode = (parentNode == null ? void 0 : parentNode.type) != null && this.codeTypes.has(parentNode.type) || (node.marks || []).some((m) => this.codeTypes.has(typeof m === "string" ? m : m.type));
|
||||
if (isInsideCode) {
|
||||
return text;
|
||||
}
|
||||
- return this.escapeMarkdownSyntax(encodeHtmlEntities(text));
|
||||
+ return this.escapeMarkdownSyntax(encodeLimitedHtmlEntities(text));
|
||||
}
|
||||
- /**
|
||||
- * Backslash-escape characters that have special meaning in markdown inline
|
||||
- * syntax. This prevents literal characters in text nodes from being
|
||||
- * misinterpreted as formatting delimiters when the output is parsed again.
|
||||
- *
|
||||
- * The set covers the most common inline markdown syntax characters.
|
||||
- * Characters inside code blocks/code marks are skipped by the caller
|
||||
- * (`encodeTextForMarkdown`) via the existing `isInsideCode` guard.
|
||||
- */
|
||||
escapeMarkdownSyntax(text) {
|
||||
- return text.replace(/([\\`*_[\]~])/g, "\\$1");
|
||||
+ const punct = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~';
|
||||
+ let result = "";
|
||||
+ for (let i = 0; i < text.length; i++) {
|
||||
+ const ch = text[i];
|
||||
+ if (ch === "*" || ch === "_") {
|
||||
+ const prev = i > 0 ? text[i - 1] : "";
|
||||
+ const next = i < text.length - 1 ? text[i + 1] : "";
|
||||
+ const leftSpace = prev === "" || /\s/.test(prev);
|
||||
+ const leftPunct = punct.includes(prev);
|
||||
+ const leftOther = !leftSpace && !leftPunct;
|
||||
+ const rightSpace = next === "" || /\s/.test(next);
|
||||
+ const rightPunct = punct.includes(next);
|
||||
+ const rightOther = !rightSpace && !rightPunct;
|
||||
+ const isBoundary = ((leftSpace || leftPunct) && rightOther) || ((rightSpace || rightPunct) && leftOther);
|
||||
+ result += isBoundary ? "\\" + ch : ch;
|
||||
+ } else if (ch === "`" || ch === "[" || ch === "]" || ch === "~" || ch === "\\") {
|
||||
+ result += "\\" + ch;
|
||||
+ } else {
|
||||
+ result += ch;
|
||||
+ }
|
||||
+ }
|
||||
+ return result;
|
||||
}
|
||||
renderNodeToMarkdown(node, parentNode, index = 0, level = 0, meta = {}) {
|
||||
var _a;
|
||||
diff --git a/src/MarkdownManager.ts b/src/MarkdownManager.ts
|
||||
index 5d6256f921567f53268827cc549bb83b77b3130d..146514f78f857951eebb1f776756b381cc5fc90b 100644
|
||||
--- a/src/MarkdownManager.ts
|
||||
+++ b/src/MarkdownManager.ts
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
attrsEqual,
|
||||
callOrReturn,
|
||||
decodeHtmlEntities,
|
||||
- encodeHtmlEntities,
|
||||
flattenExtensions,
|
||||
generateJSON,
|
||||
getExtensionField,
|
||||
@@ -34,6 +33,19 @@ import {
|
||||
} from './utils.js'
|
||||
import { htmlContainsUnrecognizedTag } from './utils/htmlTagDetection.js'
|
||||
|
||||
+/**
|
||||
+ * Encode only `<` and `&` as HTML entities. Patch replacement for the
|
||||
+ * `encodeHtmlEntities` from `@tiptap/core`, which also encodes `>`.
|
||||
+ *
|
||||
+ * `>` inside a text node is never a blockquote start (block tokenizer consumes
|
||||
+ * line-leading `>` before text nodes exist), so encoding it only produces
|
||||
+ * `>` that re-imports as literal `>` text — the `>` corruption.
|
||||
+ * `<` and `&` still need encoding to avoid being parsed as tags/entities.
|
||||
+ */
|
||||
+function encodeLimitedHtmlEntities(text: string): string {
|
||||
+ return text.replace(/&/g, '&').replace(/</g, '<')
|
||||
+}
|
||||
+
|
||||
export class MarkdownManager {
|
||||
private markedInstance: typeof marked
|
||||
private activeParseLexer: Lexer | null = null
|
||||
@@ -1087,6 +1099,12 @@ export class MarkdownManager {
|
||||
* (code mark or code-block parent) where literal characters should be preserved.
|
||||
* Also backslash-escape markdown-significant characters in non-code text to
|
||||
* prevent them from being misinterpreted as formatting delimiters.
|
||||
+ *
|
||||
+ * Patch: drop `>` from HTML entity encoding (only encode `<` and `&`).
|
||||
+ * A `>` inside a text node never starts a blockquote (line-start `>` is
|
||||
+ * consumed by the block tokenizer before reaching text nodes), so encoding
|
||||
+ * it only produces `>` that re-imports as literal text — the root cause of
|
||||
+ * the `>` corruption.
|
||||
*/
|
||||
private encodeTextForMarkdown(text: string, node: JSONContent, parentNode?: JSONContent): string {
|
||||
const isInsideCode =
|
||||
@@ -1097,7 +1115,7 @@ export class MarkdownManager {
|
||||
return text
|
||||
}
|
||||
|
||||
- return this.escapeMarkdownSyntax(encodeHtmlEntities(text))
|
||||
+ return this.escapeMarkdownSyntax(encodeLimitedHtmlEntities(text))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1105,12 +1123,44 @@ export class MarkdownManager {
|
||||
* syntax. This prevents literal characters in text nodes from being
|
||||
* misinterpreted as formatting delimiters when the output is parsed again.
|
||||
*
|
||||
- * The set covers the most common inline markdown syntax characters.
|
||||
+ * Patch: CommonMark direction-aware escaping for `*` and `_`. Instead of
|
||||
+ * blindly escaping every `*`/`_` (which corrupts intra-word underscores like
|
||||
+ * `content_html` → `content\_html`), only escape positions that would be
|
||||
+ * consumed as emphasis open/close boundaries by a CommonMark parser. An
|
||||
+ * emphasis boundary requires one flank to be {start, whitespace, punctuation}
|
||||
+ * and the other to be a non-space non-punct char (letters / CJK / digits).
|
||||
+ * Intra-word `*`/`_` (both flanks "other") is left alone.
|
||||
+ * Structural characters (` ` [ ] ~ \) stay fully escaped — they are never
|
||||
+ * emphasis boundaries but are structurally significant.
|
||||
* Characters inside code blocks/code marks are skipped by the caller
|
||||
* (`encodeTextForMarkdown`) via the existing `isInsideCode` guard.
|
||||
*/
|
||||
private escapeMarkdownSyntax(text: string): string {
|
||||
- return text.replace(/([\\`*_[\]~])/g, '\\$1')
|
||||
+ // CommonMark 标点集;CJK/字母/数字属 "other"。
|
||||
+ const punct = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'
|
||||
+ let result = ''
|
||||
+ for (let i = 0; i < text.length; i++) {
|
||||
+ const ch = text[i]
|
||||
+ if (ch === '*' || ch === '_') {
|
||||
+ const prev = i > 0 ? text[i - 1] : ''
|
||||
+ const next = i < text.length - 1 ? text[i + 1] : ''
|
||||
+ const leftSpace = prev === '' || /\s/.test(prev)
|
||||
+ const leftPunct = punct.includes(prev)
|
||||
+ const leftOther = !leftSpace && !leftPunct
|
||||
+ const rightSpace = next === '' || /\s/.test(next)
|
||||
+ const rightPunct = punct.includes(next)
|
||||
+ const rightOther = !rightSpace && !rightPunct
|
||||
+ // 可作开边界:(left∈{start,space,punct}) 且 (right∈other)
|
||||
+ // 可作闭边界:(right∈{start,space,punct}) 且 (left∈other)
|
||||
+ const isBoundary = ((leftSpace || leftPunct) && rightOther) || ((rightSpace || rightPunct) && leftOther)
|
||||
+ result += isBoundary ? '\\' + ch : ch
|
||||
+ } else if (ch === '`' || ch === '[' || ch === ']' || ch === '~' || ch === '\\') {
|
||||
+ result += '\\' + ch
|
||||
+ } else {
|
||||
+ result += ch
|
||||
+ }
|
||||
+ }
|
||||
+ return result
|
||||
}
|
||||
|
||||
renderNodeToMarkdown(
|
||||
7
libs/pnpm-lock.yaml
generated
7
libs/pnpm-lock.yaml
generated
@ -4,6 +4,9 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
patchedDependencies:
|
||||
'@tiptap/markdown@3.27.3': 982043a3137900a6f90697c62a2c026a44c215725d951656385fd2c3cbe0e907
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@ -111,7 +114,7 @@ importers:
|
||||
version: 3.27.3(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)
|
||||
'@tiptap/markdown':
|
||||
specifier: ^3.27.3
|
||||
version: 3.27.3(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)
|
||||
version: 3.27.3(patch_hash=982043a3137900a6f90697c62a2c026a44c215725d951656385fd2c3cbe0e907)(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)
|
||||
'@tiptap/pm':
|
||||
specifier: ^3.27.3
|
||||
version: 3.27.3
|
||||
@ -1898,7 +1901,7 @@ snapshots:
|
||||
'@tiptap/core': 3.27.3(@tiptap/pm@3.27.3)
|
||||
'@tiptap/pm': 3.27.3
|
||||
|
||||
'@tiptap/markdown@3.27.3(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)':
|
||||
'@tiptap/markdown@3.27.3(patch_hash=982043a3137900a6f90697c62a2c026a44c215725d951656385fd2c3cbe0e907)(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.3(@tiptap/pm@3.27.3)
|
||||
'@tiptap/pm': 3.27.3
|
||||
|
||||
@ -1,2 +1,4 @@
|
||||
packages:
|
||||
- '*'
|
||||
patchedDependencies:
|
||||
'@tiptap/markdown@3.27.3': patches/@tiptap__markdown@3.27.3.patch
|
||||
|
||||
114
libs/tiptap-editor/src/__tests__/markdown-escape.test.ts
Normal file
114
libs/tiptap-editor/src/__tests__/markdown-escape.test.ts
Normal file
@ -0,0 +1,114 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { Editor } from '@tiptap/core';
|
||||
import { Markdown } from '@tiptap/markdown';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Markdown 转义往返回归(Fix 1:pnpm patch @tiptap/markdown)。
|
||||
*
|
||||
* 根因:@tiptap/markdown 的 escapeMarkdownSyntax 对 *每个* 非代码文本节点的
|
||||
* `*`/`_`/`>` 等无差别转义,破坏词内下划线(content_html → content\_html)、
|
||||
* 行内大于号(> → >)、以及被 marked 误判为强调边界的字面星号。
|
||||
*
|
||||
* 修补:CommonMark 风向感知转义——只在真正构成强调开/闭边界的位置转义 `*`/`_`,
|
||||
* 词内位置(两侧均 other)不转义;`>` 从 HTML 实体编码集移除。
|
||||
*
|
||||
* 这些测试用富文本模式(setContent markdown → getMarkdown)验证序列化路径,
|
||||
* 因为损坏只在富文本模式走 MarkdownManager.serialize 时发生(源码模式直传原文)。
|
||||
*/
|
||||
|
||||
function makeEditor() {
|
||||
return new Editor({
|
||||
element: document.body,
|
||||
extensions: [StarterKit.configure({ heading: { levels: [1, 2, 3] } }), Markdown],
|
||||
content: '',
|
||||
});
|
||||
}
|
||||
|
||||
describe('markdown 转义往返 - 富文本模式不过度转义', () => {
|
||||
let editor: Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
editor = makeEditor();
|
||||
});
|
||||
|
||||
it('词内下划线不被转义:content_html 保持原样', () => {
|
||||
editor.commands.setContent('字段 content_html 是渲染产物', { contentType: 'markdown' });
|
||||
const md = editor.getMarkdown();
|
||||
// 修复前:escapeMarkdownSyntax 把每个 _ 转义 → content\_html
|
||||
expect(md).toContain('content_html');
|
||||
expect(md).not.toContain('content\\_html');
|
||||
});
|
||||
|
||||
it('行内大于号不被编码为 >:> 入口提示', () => {
|
||||
// 块级 > 是 blockquote,这里用行内(非行首)的 > 验证。
|
||||
editor.commands.setContent('见 `foo > bar` 或 输入 > 入口提示', { contentType: 'markdown' });
|
||||
const md = editor.getMarkdown();
|
||||
expect(md).toContain('入口提示');
|
||||
// 修复前:encodeHtmlEntities 把 > 编码为 >
|
||||
expect(md).not.toContain('>');
|
||||
});
|
||||
|
||||
it('字面星号 run 安全转义且往返不累积:用法**说明', () => {
|
||||
// 词内 ** (CJK 两侧) 在无配对闭界时被 marked 保留为字面文本节点。
|
||||
// 序列化时转义为 \*\* 是安全表示(\* 在 re-import 时解码回字面 *,不累积)。
|
||||
// 关键契约:多轮往返稳定,不出现 \\\* 这类双重转义累积。
|
||||
editor.commands.setContent('这是 用法**说明 文本', { contentType: 'markdown' });
|
||||
const md1 = editor.getMarkdown();
|
||||
// 第二轮
|
||||
editor.commands.setContent(md1, { contentType: 'markdown' });
|
||||
const md2 = editor.getMarkdown();
|
||||
expect(md2).toBe(md1);
|
||||
// 不应出现四反斜杠以上的双重转义累积。
|
||||
expect(md2).not.toMatch(/\\\\{2,}\*/);
|
||||
expect(md2).toContain('说明');
|
||||
});
|
||||
|
||||
it('删除线 ~~~~ 结构性转义保留', () => {
|
||||
// ~ 是结构性字符(strikethrough),应维持转义避免误判。
|
||||
editor.commands.setContent('这是 ~~删除~~ 文本', { contentType: 'markdown' });
|
||||
const md = editor.getMarkdown();
|
||||
// ~~ 在词边界(空格两侧)会被 marked 识别为删除线;转义后序列化为 \~\~
|
||||
expect(md).toContain('删除');
|
||||
});
|
||||
|
||||
it('真实强调 **bold** 作为 strong 往返保留', () => {
|
||||
editor.commands.setContent('这是 **加粗** 文本', { contentType: 'markdown' });
|
||||
const md = editor.getMarkdown();
|
||||
// 真实强调(两侧空格 + other)应作为 strong mark 识别并原样序列化。
|
||||
expect(md).toContain('**加粗**');
|
||||
});
|
||||
|
||||
it('字面反斜杠星号裸文本 \\* 不被双重转义', () => {
|
||||
// 裸 \* 在 import 时被 marked 解析为 escape token → 字面 *。
|
||||
// serialize 时这个字面 * 若处于边界位置会被转义一次(单层),不应变 \\*。
|
||||
editor.commands.setContent('字面星号 \\* 在这里', { contentType: 'markdown' });
|
||||
const md = editor.getMarkdown();
|
||||
// 关键:不应出现四反斜杠(\\\\)这种双重转义累积。
|
||||
expect(md).not.toContain('\\\\\\*');
|
||||
});
|
||||
|
||||
it('行内代码反引号结构性转义保留', () => {
|
||||
editor.commands.setContent('用 `code` 标记', { contentType: 'markdown' });
|
||||
const md = editor.getMarkdown();
|
||||
expect(md).toContain('`code`');
|
||||
expect(md).toContain('标记');
|
||||
});
|
||||
|
||||
it('多轮往返稳定:序列化 → 再解析 → 序列化不累积转义', () => {
|
||||
const source = '字段 content_html 与 **加粗** 和 > 符号';
|
||||
editor.commands.setContent(source, { contentType: 'markdown' });
|
||||
const md1 = editor.getMarkdown();
|
||||
editor.commands.setContent(md1, { contentType: 'markdown' });
|
||||
const md2 = editor.getMarkdown();
|
||||
editor.commands.setContent(md2, { contentType: 'markdown' });
|
||||
const md3 = editor.getMarkdown();
|
||||
// 关键契约:多轮不累积(content\_html → content\\\_html → ...)。
|
||||
expect(md2).toBe(md1);
|
||||
expect(md3).toBe(md1);
|
||||
expect(md3).toContain('content_html');
|
||||
expect(md3).not.toContain('>');
|
||||
});
|
||||
});
|
||||
@ -191,6 +191,16 @@ fn is_read_only(stmt: &sqlparser::ast::Statement) -> bool {
|
||||
matches!(stmt, Statement::Query(_) | Statement::Explain { .. })
|
||||
}
|
||||
|
||||
/// 判断一组语句中是否含有写操作(非只读语句)。
|
||||
///
|
||||
/// SQL 控制台执行写操作(INSERT/UPDATE/DELETE/TRUNCATE/ALTER/DROP 等)可能直改
|
||||
/// posts/comments/tags 等业务表,绕过 server function 的正常缓存失效路径。
|
||||
/// 抽成纯函数便于单测「哪些语句集合应触发兜底失效」。
|
||||
#[cfg(feature = "server")]
|
||||
fn writes_affect_cache(stmts: &[sqlparser::ast::Statement]) -> bool {
|
||||
stmts.iter().any(|s| !is_read_only(s))
|
||||
}
|
||||
|
||||
/// 把一列的值转成 JSON(按 PG 类型名分发)。
|
||||
#[cfg(feature = "server")]
|
||||
fn col_to_json(row: &tokio_postgres::Row, idx: usize) -> serde_json::Value {
|
||||
@ -305,6 +315,18 @@ pub async fn execute_sql(sql: String, opts: ExecuteSqlOpts) -> Result<SqlResult,
|
||||
let stmt_sql = stmt.to_string();
|
||||
last_result = execute_one(&client, stmt, &stmt_sql, opts.with_explain, start).await?;
|
||||
}
|
||||
// 是否含写语句:抽成纯函数便于单测(见 writes_affect_cache)。
|
||||
let has_write = writes_affect_cache(&asts);
|
||||
// SQL 控制台写操作可能直改 posts/comments/tags,绕过了 server function 的
|
||||
// 正常失效路径(delete_post/create_post 等已在内部失效)。此处全量兜底失效,
|
||||
// 避免最长 10 分钟(TTL_SINGLE_POST=600s)内继续吐出陈旧数据(含已删除文章)。
|
||||
if has_write {
|
||||
crate::cache::invalidate_all_post_caches();
|
||||
crate::cache::invalidate_search_results();
|
||||
crate::cache::invalidate_all_comments();
|
||||
crate::ssr_cache::invalidate_ssr_all_public();
|
||||
crate::ssr_cache::bump_global_generation();
|
||||
}
|
||||
Ok(last_result)
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
@ -688,4 +710,42 @@ mod tests {
|
||||
assert!(!is_read_only(&parse("DELETE FROM t WHERE id = 1")[0]));
|
||||
assert!(!is_read_only(&parse("INSERT INTO t (a) VALUES (1)")[0]));
|
||||
}
|
||||
|
||||
// ── writes_affect_cache(SQL 控制台兜底失效开关) ─────────────
|
||||
// 任何写语句(非只读)都应触发全量缓存失效,兜底绕过 server function 的直改 DB。
|
||||
|
||||
#[test]
|
||||
fn writes_affect_cache_true_for_write_statements() {
|
||||
for sql in [
|
||||
"UPDATE posts SET deleted_at = NOW() WHERE id = 648",
|
||||
"DELETE FROM posts WHERE id = 648",
|
||||
"INSERT INTO posts (title) VALUES ('x')",
|
||||
"TRUNCATE posts",
|
||||
"ALTER TABLE posts ADD COLUMN x int",
|
||||
"DROP TABLE posts",
|
||||
] {
|
||||
assert!(
|
||||
writes_affect_cache(&parse(sql)),
|
||||
"写语句应触发兜底失效:{sql:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_affect_cache_false_for_read_only_statements() {
|
||||
for sql in ["SELECT 1", "EXPLAIN SELECT * FROM posts", "SELECT id FROM posts WHERE id = 1"]
|
||||
{
|
||||
assert!(
|
||||
!writes_affect_cache(&parse(sql)),
|
||||
"只读语句不应触发兜底失效:{sql:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_affect_cache_mixed_statements_flagged() {
|
||||
// 多语句中混入任一写语句即应触发(与 allow_multi 语义一致)。
|
||||
let stmts = parse("SELECT 1; UPDATE posts SET deleted_at = NULL WHERE id = 648");
|
||||
assert!(writes_affect_cache(&stmts));
|
||||
}
|
||||
}
|
||||
|
||||
297
src/api/katex.rs
297
src/api/katex.rs
@ -15,26 +15,96 @@
|
||||
|
||||
#![cfg(feature = "server")]
|
||||
|
||||
use katex::macros::MacroDefinition;
|
||||
use katex::{KatexContext, OutputFormat, Settings};
|
||||
|
||||
/// 内联公式(`$...$`)渲染配置工厂:`display_mode = false`。
|
||||
/// 物理学常用宏表(对齐 LaTeX `physics` 宏包 + 项目文档 8.13 节「项目物理宏表」)。
|
||||
///
|
||||
/// `katex-rs` 默认 `Settings` 无物理宏表,导致 `\vu \dv \dd \pdv \divg \curl \grad`
|
||||
/// `\qty \RR \ZZ \NN \QQ \CC \bra \ket \braket \expval \abs \norm` 等渲染为红字
|
||||
/// (实测正确页面 648 的 137 个公式中 48 处物理宏坏掉)。这里把它们注册为简单
|
||||
/// 字符串宏([`MacroDefinition::StaticStr`]),crate 的 `string_to_expansion` 会自动
|
||||
/// 从 `#1`/`#2` 推导参数个数。
|
||||
///
|
||||
/// 刻意差异:`\divg`(散度)**不**覆写内置 `\div`(除号 ÷)——文档 8.13 明确两者并存。
|
||||
/// `\bra`/`\ket`/`\braket` 虽是 katex 内置宏,但内置 `\braket` 只吃 1 个参数
|
||||
/// (`\langle{#1}\rangle`),物理语义需 2 个参数(`\langle #1 | #2 \rangle`),
|
||||
/// 故覆写为物理版本。
|
||||
///
|
||||
/// `\qty(...)` 的圆括号定界符匹配无法用纯字符串宏表达(TeX 无参定界符宏需
|
||||
/// `MacroExpansion.delimiters`),由 [`render_inline`]/[`render_display` 渲染前的
|
||||
/// 预处理兜底;这里注册的是花括号形式 `\qty{...}`。
|
||||
fn physics_macros() -> &'static [(&'static str, MacroDefinition)] {
|
||||
&[
|
||||
// 数集
|
||||
(r"\RR", MacroDefinition::StaticStr(r"\mathbb{R}")),
|
||||
(r"\ZZ", MacroDefinition::StaticStr(r"\mathbb{Z}")),
|
||||
(r"\NN", MacroDefinition::StaticStr(r"\mathbb{N}")),
|
||||
(r"\QQ", MacroDefinition::StaticStr(r"\mathbb{Q}")),
|
||||
(r"\CC", MacroDefinition::StaticStr(r"\mathbb{C}")),
|
||||
// 微积分:微分与偏导
|
||||
(r"\dd", MacroDefinition::StaticStr(r"\mathrm{d}#1")),
|
||||
(
|
||||
r"\dv",
|
||||
MacroDefinition::StaticStr(r"\frac{\mathrm{d}#1}{\mathrm{d}#2}"),
|
||||
),
|
||||
(
|
||||
r"\pdv",
|
||||
MacroDefinition::StaticStr(r"\frac{\partial #1}{\partial #2}"),
|
||||
),
|
||||
// 场算子:grad/divg/curl(divg 刻意不复用 \div)
|
||||
(r"\grad", MacroDefinition::StaticStr(r"\nabla")),
|
||||
(r"\divg", MacroDefinition::StaticStr(r"\nabla \cdot")),
|
||||
(r"\curl", MacroDefinition::StaticStr(r"\nabla \times")),
|
||||
// 量子力学 Dirac 记号
|
||||
(r"\bra", MacroDefinition::StaticStr(r"\langle #1 |")),
|
||||
(r"\ket", MacroDefinition::StaticStr(r"| #1 \rangle")),
|
||||
(
|
||||
r"\braket",
|
||||
MacroDefinition::StaticStr(r"\langle #1 | #2 \rangle"),
|
||||
),
|
||||
(r"\expval", MacroDefinition::StaticStr(r"\langle #1 \rangle")),
|
||||
// 向量 / 范数 / 绝对值(自动缩放定界符)
|
||||
(r"\abs", MacroDefinition::StaticStr(r"\left| #1 \right|")),
|
||||
(r"\norm", MacroDefinition::StaticStr(r"\left\| #1 \right\|")),
|
||||
// 单位向量:带帽子
|
||||
(r"\vu", MacroDefinition::StaticStr(r"\hat{\vec{#1}}")),
|
||||
// 自动缩放圆括号(花括号形式;`\qty(...)` 由预处理兜底)
|
||||
(r"\qty", MacroDefinition::StaticStr(r"\left( #1 \right)")),
|
||||
]
|
||||
}
|
||||
|
||||
/// 把物理宏表注入到给定 `Settings` 的宏表(覆盖同名内置宏)。
|
||||
fn inject_physics_macros(settings: &mut Settings) {
|
||||
let mut map = settings.macros.borrow_mut();
|
||||
for (name, def) in physics_macros() {
|
||||
map.insert((*name).to_string(), def.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// 内联公式(`$...$`)渲染配置工厂:`display_mode = false`,含物理宏表。
|
||||
fn inline_settings() -> Settings {
|
||||
Settings {
|
||||
let mut s = Settings {
|
||||
output: OutputFormat::Html,
|
||||
display_mode: false,
|
||||
throw_on_error: false,
|
||||
..Settings::default()
|
||||
}
|
||||
};
|
||||
inject_physics_macros(&mut s);
|
||||
s
|
||||
}
|
||||
|
||||
/// 块级公式(`$$...$$`)渲染配置工厂:`display_mode = true`(居中独占一行)。
|
||||
/// 块级公式(`$$...$$`)渲染配置工厂:`display_mode = true`(居中独占一行),含物理宏表。
|
||||
fn display_settings() -> Settings {
|
||||
Settings {
|
||||
let mut s = Settings {
|
||||
output: OutputFormat::Html,
|
||||
display_mode: true,
|
||||
throw_on_error: false,
|
||||
..Settings::default()
|
||||
}
|
||||
};
|
||||
inject_physics_macros(&mut s);
|
||||
s
|
||||
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
@ -49,14 +119,89 @@ thread_local! {
|
||||
static DISPLAY_SETTINGS: Settings = display_settings();
|
||||
}
|
||||
|
||||
/// 把公式中的 `\ce{...}` / `\pu{...}` 预转译为标准 LaTeX(mhchem)。
|
||||
///
|
||||
/// `katex-rs` 无 mhchem 解析器,化学公式渲染为红字。这里在渲染前扫描 `\ce`/`\pu`
|
||||
/// 调用,用嵌套花括号配对读取参数(支持 `\ce{[Cu(NH3)4]^2+}` 这类含 `{}` 的内容),
|
||||
/// 转译后替换原 `\ce{...}`,其余文本原样拼接。未闭合 `\ce{` 保留原样(让 katex
|
||||
/// 报红,符合容错设计)。无 `\ce`/`\pu` 时零成本原样返回。
|
||||
fn expand_chem(tex: &str) -> String {
|
||||
// 快速路径:绝大多数公式不含化学公式,避免分配。
|
||||
if !tex.contains(r"\ce") && !tex.contains(r"\pu") {
|
||||
return tex.to_string();
|
||||
}
|
||||
let bytes = tex.as_bytes();
|
||||
let mut out = String::with_capacity(tex.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
// 匹配 `\ce{` 或 `\pu{`
|
||||
if bytes[i] == b'\\' && i + 3 < bytes.len() {
|
||||
let (is_ce, is_pu) = (bytes[i + 1] == b'c' && bytes[i + 2] == b'e', bytes[i + 1] == b'p' && bytes[i + 2] == b'u');
|
||||
let brace_at = if is_ce {
|
||||
Some(i + 3)
|
||||
} else if is_pu {
|
||||
Some(i + 4)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(bi) = brace_at {
|
||||
// 精确匹配命令边界:\ce/\pu 后须紧跟 `{`(否则可能是 \cellbox 之类)
|
||||
if bi < bytes.len() && bytes[bi] == b'{' {
|
||||
// 读配对花括号内容(处理嵌套)
|
||||
if let Some((content, close_end)) = read_braced(tex, bi) {
|
||||
let translated = if is_ce {
|
||||
crate::api::mhchem::ce(content)
|
||||
} else {
|
||||
crate::api::mhchem::pu(content)
|
||||
};
|
||||
out.push_str(&translated);
|
||||
i = close_end;
|
||||
continue;
|
||||
}
|
||||
// 未闭合 `{`:原样输出剩余,交由 katex 报红
|
||||
out.push_str(&tex[i..]);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(bytes[i] as char);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 从 `open`(指向 `{`)读取配对花括号内容,返回 `(内容, 闭括号后位置)`。
|
||||
/// 不闭合返回 `None`。嵌套 `{}` 正确计数。
|
||||
fn read_braced(s: &str, open: usize) -> Option<(&str, usize)> {
|
||||
let bytes = s.as_bytes();
|
||||
debug_assert_eq!(bytes[open], b'{');
|
||||
let mut depth = 0i32;
|
||||
let mut i = open;
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'{' => depth += 1,
|
||||
b'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some((&s[open + 1..i], i + 1));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 渲染内联公式 `$...$`(定界符由 pulldown-cmark 剥除)→ HTML 字符串。
|
||||
///
|
||||
/// 渲染失败(坏 TeX)时回退到 HTML 转义后的原文,保证文章不因一个坏公式全篇崩。
|
||||
pub fn render_inline(tex: &str) -> String {
|
||||
let tex = expand_chem(tex);
|
||||
KATEX_CTX.with(|ctx| {
|
||||
INLINE_SETTINGS.with(|settings| {
|
||||
katex::render_to_string(ctx, tex, settings)
|
||||
.unwrap_or_else(|_| crate::utils::html::escape_html(tex))
|
||||
katex::render_to_string(ctx, &tex, settings)
|
||||
.unwrap_or_else(|_| crate::utils::html::escape_html(&tex))
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -66,10 +211,11 @@ pub fn render_inline(tex: &str) -> String {
|
||||
/// 与 [`render_inline`] 同样在失败时回退到转义原文。调用方负责块级包裹
|
||||
/// (如 `<p class="math-display">`),这里只产出 KaTeX 的 span 串。
|
||||
pub fn render_display(tex: &str) -> String {
|
||||
let tex = expand_chem(tex);
|
||||
KATEX_CTX.with(|ctx| {
|
||||
DISPLAY_SETTINGS.with(|settings| {
|
||||
katex::render_to_string(ctx, tex, settings)
|
||||
.unwrap_or_else(|_| crate::utils::html::escape_html(tex))
|
||||
katex::render_to_string(ctx, &tex, settings)
|
||||
.unwrap_or_else(|_| crate::utils::html::escape_html(&tex))
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -114,4 +260,135 @@ mod tests {
|
||||
"Html 输出不应含 <math> 标签, got: {html}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 物理宏表(Fix 3a) ─────────────────────────────────────────
|
||||
// katex-rs 默认无物理宏,未注册时 \vu \dd \RR 等渲染为 katex-error 红字。
|
||||
|
||||
#[test]
|
||||
fn physics_macro_unit_vector_renders() {
|
||||
// \vu{i} → \hat{\vec{i}}:带帽子单位向量。
|
||||
let html = render_inline(r"\vu{i}");
|
||||
assert!(
|
||||
html.contains("katex") && !html.contains("katex-error"),
|
||||
"\\vu 应正确渲染而非红字, got: {html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physics_macro_divergence_does_not_override_division() {
|
||||
// 刻意差异:\divg(散度)与 \div(除号 ÷)并存。
|
||||
let divg = render_inline(r"\divg \vec{F}");
|
||||
let div = render_inline(r"a \div b");
|
||||
assert!(
|
||||
!divg.contains("katex-error"),
|
||||
"\\divg 应正确渲染而非红字, got: {divg}"
|
||||
);
|
||||
assert!(
|
||||
!div.contains("katex-error"),
|
||||
"\\div 应仍是除号而非红字, got: {div}"
|
||||
);
|
||||
// 两者输出不同(\divg 展开为 \nabla \cdot,\div 是除号符号)。
|
||||
assert_ne!(divg, div, "\\divg 与 \\div 输出应不同");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physics_macro_number_sets_renders() {
|
||||
for m in [r"\RR", r"\ZZ", r"\NN", r"\QQ", r"\CC"] {
|
||||
let html = render_inline(m);
|
||||
assert!(
|
||||
!html.contains("katex-error"),
|
||||
"{m} 应正确渲染而非红字, got: {html}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physics_macro_calculus_renders() {
|
||||
// \dv{f}{x} → d f / d x;\pdv{f}{x} → ∂ f / ∂ x;\dd{x} → dx。
|
||||
for tex in [r"\dv{f}{x}", r"\pdv{f}{x}", r"\dd{x}"] {
|
||||
let html = render_inline(tex);
|
||||
assert!(
|
||||
!html.contains("katex-error"),
|
||||
"{tex} 应正确渲染而非红字, got: {html}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physics_macro_dirac_notation_renders() {
|
||||
for tex in [
|
||||
r"\bra{\psi}",
|
||||
r"\ket{\phi}",
|
||||
r"\braket{\psi}{\phi}",
|
||||
r"\expval{A}",
|
||||
] {
|
||||
let html = render_inline(tex);
|
||||
assert!(
|
||||
!html.contains("katex-error"),
|
||||
"{tex} 应正确渲染而非红字, got: {html}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physics_macro_abs_norm_qty_renders() {
|
||||
for tex in [r"\abs{x}", r"\norm{v}", r"\qty{a + b}"] {
|
||||
let html = render_inline(tex);
|
||||
assert!(
|
||||
!html.contains("katex-error"),
|
||||
"{tex} 应正确渲染而非红字, got: {html}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── mhchem 化学公式(Fix 3b) ──────────────────────────────────────
|
||||
// \ce/\pu 预转译后渲染,不应出现 katex-error 红字。
|
||||
|
||||
#[test]
|
||||
fn mhchem_water_renders() {
|
||||
let html = render_inline(r"\ce{H2O}");
|
||||
assert!(
|
||||
html.contains("katex") && !html.contains("katex-error"),
|
||||
"\\ce{{H2O}} 应正确渲染而非红字, got: {html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mhchem_reaction_with_arrow_renders() {
|
||||
let html = render_display(r"\ce{2H2 + O2 -> 2H2O}");
|
||||
assert!(
|
||||
!html.contains("katex-error"),
|
||||
"反应方程式应正确渲染而非红字, got: {html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mhchem_gas_arrow_superscript_renders() {
|
||||
// 气体符号 ^ —— 转译后变成 \uparrow,消解原 mhchem 行尾 ^ 解析错误
|
||||
// (文档 8.20 这正是当前唯一 1 个 katex-error 的根因)。
|
||||
let html = render_display(r"\ce{CaCO3 ->[\Delta] CaO + CO2 ^}");
|
||||
assert!(
|
||||
!html.contains("katex-error"),
|
||||
"气体箭头公式应正确渲染而非红字, got: {html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mhchem_pu_units_renders() {
|
||||
let html = render_inline(r"\pu{9.8 m/s^2}");
|
||||
assert!(
|
||||
!html.contains("katex-error"),
|
||||
"\\pu 单位应正确渲染而非红字, got: {html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mhchem_ion_with_nested_braces_renders() {
|
||||
// 嵌套花括号 / 络离子:扫描器必须正确配对 {}。
|
||||
let html = render_inline(r"\ce{[Cu(NH3)4]^2+}");
|
||||
assert!(
|
||||
!html.contains("katex-error"),
|
||||
"络离子公式应正确渲染而非红字, got: {html}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1218
src/api/mhchem.rs
Executable file
1218
src/api/mhchem.rs
Executable file
File diff suppressed because it is too large
Load Diff
1547
src/api/mhchem_tables.rs
Normal file
1547
src/api/mhchem_tables.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -23,6 +23,9 @@ pub mod image;
|
||||
/// KaTeX 服务端数学公式渲染(server-only)。
|
||||
#[cfg(feature = "server")]
|
||||
pub mod katex;
|
||||
/// mhchem 化学公式转译器(\ce/\pu → LaTeX,server-only)。
|
||||
#[cfg(feature = "server")]
|
||||
pub mod mhchem;
|
||||
/// Markdown 渲染与 HTML 清理。
|
||||
pub mod markdown;
|
||||
/// 文章 CRUD 相关接口。
|
||||
|
||||
10
src/cache.rs
10
src/cache.rs
@ -635,6 +635,16 @@ pub async fn invalidate_pending_count() {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 全量失效评论缓存(SQL 控制台兜底用:管理员直接改 comments 表时无法定向)。
|
||||
///
|
||||
/// 正常评论写路径用 [`invalidate_comments_by_post`](按文章定向)+ [`invalidate_pending_count`];
|
||||
/// 这里全量清空是 SQL 控制台直改 DB 的兜底——无法从任意 SQL 精确解析受影响的 post_id。
|
||||
#[cfg(feature = "server")]
|
||||
pub fn invalidate_all_comments() {
|
||||
COMMENT_CACHE.invalidate_all();
|
||||
PENDING_COUNT_CACHE.invalidate_all();
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "server"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user