@jx3box/jx3box-editor 2.2.41 → 2.2.43
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/assets/js/a.js +140 -42
- package/assets/js/iframe.js +115 -33
- package/assets/js/img.js +64 -19
- package/assets/js/xss.js +72 -0
- package/package.json +4 -2
- package/public/tinymce.html +4 -1
- package/src/Article.vue +44 -13
- package/vue.config.js +13 -0
package/assets/js/a.js
CHANGED
|
@@ -1,43 +1,141 @@
|
|
|
1
|
-
//
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
1
|
+
// formatLink.js
|
|
2
|
+
import { load } from "cheerio";
|
|
3
|
+
|
|
4
|
+
/** 规范化 whitelist:统一小写、去空格、允许传入带协议的域名 */
|
|
5
|
+
function normalizeWhitelist(list = []) {
|
|
6
|
+
return (Array.isArray(list) ? list : [])
|
|
7
|
+
.map((x) => String(x || "").trim().toLowerCase())
|
|
8
|
+
.filter(Boolean)
|
|
9
|
+
.map((x) => {
|
|
10
|
+
// 允许用户写 https://xxx.com
|
|
11
|
+
try {
|
|
12
|
+
if (/^https?:\/\//i.test(x)) return new URL(x).hostname.toLowerCase();
|
|
13
|
+
} catch {
|
|
14
|
+
console.warn(`formatLink: 无法解析 whitelist 域名 "${x}",已忽略该项`);
|
|
15
|
+
}
|
|
16
|
+
return x;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 是否相对链接(默认认为站内,视为白名单) */
|
|
21
|
+
function isRelativeHref(href = "") {
|
|
22
|
+
const s = href.trim();
|
|
23
|
+
if (!s) return true;
|
|
24
|
+
if (s.startsWith("#") || s.startsWith("?") || s.startsWith("/")) return true;
|
|
25
|
+
if (s.startsWith("./") || s.startsWith("../")) return true;
|
|
26
|
+
// 没有 scheme 且不是 //xxx,通常是相对路径(如 a/b)
|
|
27
|
+
return !/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(s) && !s.startsWith("//");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 从 href 里提取 hostname(只处理 http/https/协议相对 //) */
|
|
31
|
+
function getHostnameFromHref(href = "") {
|
|
32
|
+
const s = href.trim();
|
|
33
|
+
if (!s) return "";
|
|
34
|
+
|
|
35
|
+
if (s.startsWith("//")) {
|
|
36
|
+
try {
|
|
37
|
+
return new URL("https:" + s).hostname.toLowerCase();
|
|
38
|
+
} catch {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (/^https?:\/\//i.test(s)) {
|
|
44
|
+
try {
|
|
45
|
+
return new URL(s).hostname.toLowerCase();
|
|
46
|
+
} catch {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* whitelist 规则:
|
|
56
|
+
* - "xxx.com" 只匹配根域
|
|
57
|
+
* - "*.xxx.com" 只匹配子域(a.xxx.com),不匹配根域(xxx.com)
|
|
58
|
+
*/
|
|
59
|
+
function matchHost(hostname, whitelist) {
|
|
60
|
+
const host = (hostname || "").toLowerCase();
|
|
61
|
+
if (!host) return false;
|
|
62
|
+
|
|
63
|
+
for (const rule of whitelist) {
|
|
64
|
+
if (!rule) continue;
|
|
65
|
+
|
|
66
|
+
if (rule.startsWith("*.")) {
|
|
67
|
+
const root = rule.slice(2);
|
|
68
|
+
if (root && host.endsWith("." + root)) return true;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (host === rule) return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} html - HTML 字符串(可为片段)
|
|
79
|
+
* @param {string[]} whitelist - 例如 ["xxx.com", "*.xxx.com"]
|
|
80
|
+
* @param {boolean} strict - true 时,非白名单链接 href 强制置空
|
|
81
|
+
*/
|
|
82
|
+
export default function formatLink(html, whitelist = ["*.jx3box.com"], strict = false) {
|
|
83
|
+
if (!html) return html;
|
|
84
|
+
|
|
85
|
+
const wl = normalizeWhitelist(whitelist);
|
|
86
|
+
const hasWhitelist = wl.length > 0;
|
|
87
|
+
|
|
88
|
+
// cheerio 用 root 包一下,保证片段也能正常选取
|
|
89
|
+
const $ = load(`<root>${html}</root>`, { decodeEntities: false });
|
|
90
|
+
|
|
91
|
+
$("a[href]").each((_, el) => {
|
|
92
|
+
const a = $(el);
|
|
93
|
+
const hrefRaw = a.attr("href") ?? "";
|
|
94
|
+
const href = String(hrefRaw).trim();
|
|
95
|
+
if (!href) return;
|
|
96
|
+
|
|
97
|
+
// markdown 锚点:强制 _self
|
|
98
|
+
if (href.startsWith("#")) {
|
|
99
|
+
a.attr("target", "_self");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 安全兜底:javascript: 一律干掉
|
|
104
|
+
if (/^javascript:/i.test(href)) {
|
|
105
|
+
a.attr("href", "");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// mailto/tel 等不参与白名单判断(你也可以按 strict 强行置空,这里先按常见做法放行)
|
|
110
|
+
if (/^(mailto:|tel:|sms:)/i.test(href)) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 相对链接默认视为站内
|
|
115
|
+
let isWhite = true;
|
|
116
|
+
|
|
117
|
+
if (!isRelativeHref(href)) {
|
|
118
|
+
const host = getHostnameFromHref(href);
|
|
119
|
+
isWhite = matchHost(host, wl);
|
|
120
|
+
} else {
|
|
121
|
+
isWhite = true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 只有设置了 whitelist 才触发“非白名单处理”
|
|
125
|
+
if (hasWhitelist && !isWhite) {
|
|
126
|
+
if (strict) {
|
|
127
|
+
a.attr("href", "");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 如果本身就是 _blank,就不管;否则强制 _blank
|
|
132
|
+
const target = String(a.attr("target") || "").toLowerCase();
|
|
133
|
+
if (target !== "_blank") {
|
|
134
|
+
a.attr("target", "_blank");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// 取出 root 里原始内容
|
|
140
|
+
return $("root").html() || "";
|
|
42
141
|
}
|
|
43
|
-
export default formatLink
|
package/assets/js/iframe.js
CHANGED
|
@@ -1,38 +1,120 @@
|
|
|
1
|
-
//
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
1
|
+
// iframeFilter.js
|
|
2
|
+
import { load } from "cheerio";
|
|
3
|
+
|
|
4
|
+
function normalizeWhitelist(list = []) {
|
|
5
|
+
return (Array.isArray(list) ? list : [])
|
|
6
|
+
.map((x) => String(x || "").trim().toLowerCase())
|
|
7
|
+
.filter(Boolean)
|
|
8
|
+
.map((x) => {
|
|
9
|
+
// 允许传入带协议的,如 https://docs.qq.com
|
|
10
|
+
try {
|
|
11
|
+
if (/^https?:\/\//i.test(x)) return new URL(x).hostname.toLowerCase();
|
|
12
|
+
} catch {
|
|
13
|
+
console.warn(`iframeFilter: 无法解析 whitelist 域名 "${x}",已忽略该项`);
|
|
14
|
+
}
|
|
15
|
+
return x;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 规则:
|
|
21
|
+
* - "docs.qq.com" 精确匹配
|
|
22
|
+
* - "*.qq.com" 只匹配子域(a.qq.com),不匹配根域(qq.com)
|
|
23
|
+
*/
|
|
24
|
+
function matchHost(hostname, whitelist) {
|
|
25
|
+
const host = (hostname || "").toLowerCase();
|
|
26
|
+
if (!host) return false;
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
for (const rule of whitelist) {
|
|
29
|
+
if (!rule) continue;
|
|
30
|
+
|
|
31
|
+
if (rule.startsWith("*.")) {
|
|
32
|
+
const root = rule.slice(2);
|
|
33
|
+
if (root && host.endsWith("." + root)) return true;
|
|
34
|
+
continue;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
let output = `<iframe ${prefix} src="${item[2]}" ${suffix}></iframe>`
|
|
32
|
-
str = str.replace(origin,output)
|
|
33
|
-
// console.log(origin,output)
|
|
37
|
+
if (host === rule) return true;
|
|
34
38
|
}
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeSrcForParse(src = "") {
|
|
43
|
+
const s = src.trim();
|
|
44
|
+
if (!s) return "";
|
|
45
|
+
// 协议相对://xxx.com/...
|
|
46
|
+
if (s.startsWith("//")) return "https:" + s;
|
|
47
|
+
return s;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getHostnameFromSrc(src = "") {
|
|
51
|
+
const s = normalizeSrcForParse(src);
|
|
52
|
+
if (!s) return "";
|
|
53
|
+
|
|
54
|
+
// 只对白名单校验 http/https 或 // 这种
|
|
55
|
+
if (/^https?:\/\//i.test(s)) {
|
|
56
|
+
try {
|
|
57
|
+
return new URL(s).hostname.toLowerCase();
|
|
58
|
+
} catch {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} html - 含 iframe 的 HTML 字符串(片段也行)
|
|
67
|
+
* @param {string[]} whitelist - 域名白名单,支持 "*.qq.com"
|
|
68
|
+
* @param {object} [options]
|
|
69
|
+
* @param {boolean} [options.keepNonHttpSrc=false] - src 不是 http/https(如 about:blank、data:)是否保留
|
|
70
|
+
*/
|
|
71
|
+
export default function iframeFilter(html, whitelist, options = {}) {
|
|
72
|
+
if (!html) return html;
|
|
73
|
+
|
|
74
|
+
const { keepNonHttpSrc = false } = options;
|
|
75
|
+
|
|
76
|
+
// 没传 whitelist 或传空数组,都视为“不启用白名单过滤”
|
|
77
|
+
const wl = normalizeWhitelist(whitelist || []);
|
|
78
|
+
const enableWhitelist = wl.length > 0;
|
|
79
|
+
|
|
80
|
+
const $ = load(`<root>${html}</root>`, { decodeEntities: false });
|
|
81
|
+
|
|
82
|
+
$("iframe").each((_, el) => {
|
|
83
|
+
const iframe = $(el);
|
|
84
|
+
const srcRaw = iframe.attr("src") ?? "";
|
|
85
|
+
const src = String(srcRaw).trim();
|
|
86
|
+
if (!src) return;
|
|
87
|
+
|
|
88
|
+
// 不启用过滤:直接放行(可选:仍然处理非 http src)
|
|
89
|
+
if (!enableWhitelist) {
|
|
90
|
+
if (!keepNonHttpSrc) {
|
|
91
|
+
const isHttpLike = src.startsWith("//") || /^https?:\/\//i.test(src);
|
|
92
|
+
if (!isHttpLike) iframe.attr("src", "");
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ======= 启用白名单过滤的逻辑(和之前一样) =======
|
|
98
|
+
const isHttpLike = src.startsWith("//") || /^https?:\/\//i.test(src);
|
|
99
|
+
if (!isHttpLike) {
|
|
100
|
+
if (!keepNonHttpSrc) iframe.attr("src", "");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const host = getHostnameFromSrc(src);
|
|
105
|
+
const ok = matchHost(host, wl);
|
|
106
|
+
|
|
107
|
+
if (!ok) {
|
|
108
|
+
iframe.attr("src", "");
|
|
109
|
+
} else {
|
|
110
|
+
// 安全处理:将协议相对 URL 转换为 https://
|
|
111
|
+
let finalSrc = src;
|
|
112
|
+
if (src.startsWith("//")) {
|
|
113
|
+
finalSrc = "https:" + src;
|
|
114
|
+
}
|
|
115
|
+
iframe.attr("src", finalSrc);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return $("root").html() || "";
|
|
37
120
|
}
|
|
38
|
-
export default iframeFilter
|
package/assets/js/img.js
CHANGED
|
@@ -1,22 +1,67 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
import { load } from "cheerio";
|
|
2
|
+
|
|
3
|
+
function joinDomain(domain, path) {
|
|
4
|
+
const d = String(domain || "").replace(/\/+$/, "");
|
|
5
|
+
const p = String(path || "").replace(/^\/+/, "/");
|
|
6
|
+
return d ? d + p : path;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isAbsoluteLike(src) {
|
|
10
|
+
// 不需要拼 domain 的情况
|
|
11
|
+
return (
|
|
12
|
+
/^(https?:)?\/\//i.test(src) ||
|
|
13
|
+
/^(data:|blob:|mailto:|tel:|javascript:)/i.test(src)
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeImgSrc(src, domain) {
|
|
18
|
+
const s = String(src || "").trim();
|
|
19
|
+
if (!s || !domain) return s;
|
|
20
|
+
if (isAbsoluteLike(s)) return s;
|
|
21
|
+
|
|
22
|
+
// 最常见:/uploads/xxx
|
|
23
|
+
if (s.startsWith("/")) return joinDomain(domain, s);
|
|
24
|
+
|
|
25
|
+
// 其它相对路径:uploads/a.png、./a.png
|
|
26
|
+
// 用 URL 做更准确的拼接(domain 需要是带协议的,如 https://cdn.xxx.com)
|
|
27
|
+
try {
|
|
28
|
+
if (/^https?:\/\//i.test(domain)) {
|
|
29
|
+
const base = domain.endsWith("/") ? domain : domain + "/";
|
|
30
|
+
return new URL(s, base).toString();
|
|
15
31
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
str = str.replace(origin, output);
|
|
32
|
+
} catch {
|
|
33
|
+
console.warn("URL 拼接图片路径失败:", domain, s);
|
|
19
34
|
}
|
|
20
|
-
|
|
35
|
+
|
|
36
|
+
// 兜底:简单拼到根路径
|
|
37
|
+
return joinDomain(domain, "/" + s.replace(/^\.\/+/, ""));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} html 富文本HTML
|
|
42
|
+
* @param {string} domain 可选:CDN/站点域名(如 https://cdn.xxx.com)
|
|
43
|
+
*/
|
|
44
|
+
export default function lazyLoad(html, domain = "") {
|
|
45
|
+
if (!html) return html;
|
|
46
|
+
|
|
47
|
+
const $ = load(`<root>${html}</root>`, { decodeEntities: false });
|
|
48
|
+
|
|
49
|
+
$("img").each((_, el) => {
|
|
50
|
+
const img = $(el);
|
|
51
|
+
|
|
52
|
+
// 固定懒加载:无论如何都加/覆盖
|
|
53
|
+
img.attr("loading", "lazy");
|
|
54
|
+
|
|
55
|
+
// 只有传了 domain 才处理 src 拼接
|
|
56
|
+
if (domain) {
|
|
57
|
+
const src = img.attr("src");
|
|
58
|
+
if (src) img.attr("src", normalizeImgSrc(src, domain));
|
|
59
|
+
|
|
60
|
+
// 如果你有用 data-src 之类,也可以一起处理(可删)
|
|
61
|
+
const dataSrc = img.attr("data-src");
|
|
62
|
+
if (dataSrc) img.attr("data-src", normalizeImgSrc(dataSrc, domain));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return $("root").html() || "";
|
|
21
67
|
}
|
|
22
|
-
export default lazyLoad;
|
package/assets/js/xss.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import sanitizeHtml from "sanitize-html";
|
|
2
|
+
|
|
3
|
+
const FORBID = new Set(["script", "object", "embed", "applet", "base", "meta", "link"]);
|
|
4
|
+
|
|
5
|
+
const EXTRA_TAGS = [
|
|
6
|
+
"img",
|
|
7
|
+
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
8
|
+
"table", "thead", "tbody", "tr", "th", "td",
|
|
9
|
+
"blockquote", "pre", "code", "hr",
|
|
10
|
+
"video", "source",
|
|
11
|
+
"iframe",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export default function sanitizeRichText(html) {
|
|
15
|
+
if (!html) return html;
|
|
16
|
+
|
|
17
|
+
const allowedTags = sanitizeHtml.defaults.allowedTags
|
|
18
|
+
.concat(EXTRA_TAGS)
|
|
19
|
+
.filter((t, i, arr) => arr.indexOf(t) === i) // 去重
|
|
20
|
+
.filter(t => !FORBID.has(t));
|
|
21
|
+
|
|
22
|
+
return sanitizeHtml(html, {
|
|
23
|
+
disallowedTagsMode: "discard",
|
|
24
|
+
allowedTags,
|
|
25
|
+
|
|
26
|
+
allowedAttributes: {
|
|
27
|
+
"*": ["class", "style", "title", "id", "data-*"],
|
|
28
|
+
a: ["href", "target", "rel", "title", "class", "style"],
|
|
29
|
+
img: ["src", "alt", "title", "width", "height", "class", "style", "loading", "decoding"],
|
|
30
|
+
video: ["controls", "width", "height", "class", "style"],
|
|
31
|
+
source: ["src", "type"],
|
|
32
|
+
iframe: ["src","width","height","frameborder","scrolling","allowfullscreen","sandbox","referrerpolicy","class","style"],
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
allowedSchemes: ["http", "https", "mailto", "tel"],
|
|
36
|
+
allowProtocolRelative: true,
|
|
37
|
+
allowedSchemesByTag: { img: ["http", "https", "data"],iframe: ["http", "https"], },
|
|
38
|
+
|
|
39
|
+
transformTags: {
|
|
40
|
+
"*": (tagName, attribs) => {
|
|
41
|
+
const out = { ...attribs };
|
|
42
|
+
|
|
43
|
+
// 移除 on*
|
|
44
|
+
for (const k of Object.keys(out)) if (/^on/i.test(k)) delete out[k];
|
|
45
|
+
|
|
46
|
+
// style 禁 @import / url(
|
|
47
|
+
if (typeof out.style === "string" && out.style) {
|
|
48
|
+
let s = out.style;
|
|
49
|
+
s = s.replace(/@import\s+[^;]+;?/gi, "");
|
|
50
|
+
s = s.replace(/url\s*\(\s*[^)]+\s*\)/gi, "");
|
|
51
|
+
s = s.replace(/;;+/g, ";").trim();
|
|
52
|
+
if (!s) delete out.style;
|
|
53
|
+
else out.style = s;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 兜底禁 javascript:
|
|
57
|
+
for (const key of ["href", "src"]) {
|
|
58
|
+
if (out[key] && /^\s*javascript:/i.test(out[key])) out[key] = "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 仅允许 data:image/*
|
|
62
|
+
if (tagName === "img" && typeof out.src === "string" && out.src.startsWith("data:")) {
|
|
63
|
+
if (!/^data:image\/(png|jpe?g|gif|webp|avif|bmp|svg\+xml);/i.test(out.src)) {
|
|
64
|
+
out.src = "";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { tagName, attribs: out };
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jx3box/jx3box-editor",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.43",
|
|
4
4
|
"description": "JX3BOX Article & Editor",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -36,10 +36,11 @@
|
|
|
36
36
|
"@jx3box/jx3box-data": "^3.8.9",
|
|
37
37
|
"@jx3box/jx3box-emotion": "^1.2.14",
|
|
38
38
|
"@jx3box/jx3box-macro": "^1.0.1",
|
|
39
|
-
"@jx3box/jx3box-talent": "^1.3.
|
|
39
|
+
"@jx3box/jx3box-talent": "^1.3.11",
|
|
40
40
|
"@jx3box/markdown": "^0.1.5",
|
|
41
41
|
"@tinymce/tinymce-vue": "^3.2.2",
|
|
42
42
|
"axios": "^0.19.2",
|
|
43
|
+
"cheerio": "^1.1.2",
|
|
43
44
|
"core-js": "^3.6.5",
|
|
44
45
|
"csslab": "^4.0.3",
|
|
45
46
|
"dayjs": "^1.10.7",
|
|
@@ -52,6 +53,7 @@
|
|
|
52
53
|
"lodash": "^4.17.15",
|
|
53
54
|
"photoswipe": "^4.1.2",
|
|
54
55
|
"prismjs": "^1.20.0",
|
|
56
|
+
"sanitize-html": "^2.17.0",
|
|
55
57
|
"sortablejs": "^1.15.0",
|
|
56
58
|
"vue": "^2.6.11",
|
|
57
59
|
"vue-contextmenujs": "^1.4.9",
|
package/public/tinymce.html
CHANGED
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
<meta name="renderer" content="webkit" />
|
|
8
8
|
<title><%= htmlWebpackPlugin.options.title %></title>
|
|
9
9
|
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
|
|
10
|
-
<script
|
|
10
|
+
<script>
|
|
11
|
+
window.RX_TINYMCE_ROOT = "https://cdn.jx3box.com/static/tinymce";
|
|
12
|
+
</script>
|
|
13
|
+
<script src="https://cdn.jx3box.com/static/tinymce/tinymce.min.js?v=1.9.0"></script>
|
|
11
14
|
<!-- TODO: -->
|
|
12
15
|
<!-- <script src="http://localhost:3000/tinymce.min.js"></script> -->
|
|
13
16
|
</head>
|
package/src/Article.vue
CHANGED
|
@@ -43,11 +43,11 @@ import hevueImgPreview from "hevue-img-preview";
|
|
|
43
43
|
Vue.use(hevueImgPreview);
|
|
44
44
|
|
|
45
45
|
// XSS
|
|
46
|
-
import execFilterXSS from "../assets/js/
|
|
46
|
+
import execFilterXSS from "../assets/js/xss";
|
|
47
47
|
// const execFilterXSS = require("xss");
|
|
48
|
-
const xss_options = {
|
|
49
|
-
|
|
50
|
-
};
|
|
48
|
+
// const xss_options = {
|
|
49
|
+
// allowCommentTag: true,
|
|
50
|
+
// };
|
|
51
51
|
|
|
52
52
|
// 基本文本
|
|
53
53
|
import execLazyload from "../assets/js/img";
|
|
@@ -61,7 +61,7 @@ import renderDirectory from "../assets/js/directory";
|
|
|
61
61
|
import renderMacro from "../assets/js/macro";
|
|
62
62
|
import renderTalent from "../assets/js/qixue";
|
|
63
63
|
import renderTalent2 from "../assets/js/talent2";
|
|
64
|
-
import {renderKatexAll} from "../assets/js/katex";
|
|
64
|
+
import { renderKatexAll } from "../assets/js/katex";
|
|
65
65
|
import renderCode from "../assets/js/code";
|
|
66
66
|
import renderImgPreview from "../assets/js/renderImgPreview";
|
|
67
67
|
import renderPzIframe from "../assets/js/pz_iframe";
|
|
@@ -80,6 +80,30 @@ export default {
|
|
|
80
80
|
name: "Article",
|
|
81
81
|
props: {
|
|
82
82
|
content: String,
|
|
83
|
+
// 拼接相对路径地址的图片,需要自带协议
|
|
84
|
+
cdnDomain: {
|
|
85
|
+
type: String,
|
|
86
|
+
default: "https://cdn.jx3box.com",
|
|
87
|
+
},
|
|
88
|
+
// 链接白名单检查,不在白名单,使用新窗跳转
|
|
89
|
+
linkWhitelist: {
|
|
90
|
+
type: Array,
|
|
91
|
+
default: function () {
|
|
92
|
+
return [];
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
// 链接白名单强制模式,开启后不在白名单的链接一律置空,不允许跳转
|
|
96
|
+
linkStrict: {
|
|
97
|
+
type: Boolean,
|
|
98
|
+
default: false,
|
|
99
|
+
},
|
|
100
|
+
// iframe白名单检查,不在白名单,移除iframe
|
|
101
|
+
iframeWhitelist: {
|
|
102
|
+
type: Array,
|
|
103
|
+
default: function () {
|
|
104
|
+
return [];
|
|
105
|
+
},
|
|
106
|
+
},
|
|
83
107
|
directorybox: String,
|
|
84
108
|
pageable: {
|
|
85
109
|
type: Boolean,
|
|
@@ -154,11 +178,18 @@ export default {
|
|
|
154
178
|
methods: {
|
|
155
179
|
doReg: function (data) {
|
|
156
180
|
if (data) {
|
|
157
|
-
//
|
|
158
|
-
data =
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
data =
|
|
181
|
+
// 1. 先执行 XSS 过滤(xss.js 已包含所有配置)
|
|
182
|
+
data = execFilterXSS(data);
|
|
183
|
+
|
|
184
|
+
// 2. 然后执行 iframe 白名单过滤
|
|
185
|
+
data = execFilterIframe(data, ["player.bilibili.com", "docs.qq.com", "open.douyu.com", ...this.iframeWhitelist]);
|
|
186
|
+
|
|
187
|
+
// 3. 处理图片懒加载
|
|
188
|
+
data = execLazyload(data, this.cdnDomain);
|
|
189
|
+
|
|
190
|
+
// 4. 最后处理链接
|
|
191
|
+
data = execFilterLink(data, this.linkWhitelist, this.linkStrict);
|
|
192
|
+
|
|
162
193
|
return data;
|
|
163
194
|
} else {
|
|
164
195
|
return "";
|
|
@@ -199,10 +230,10 @@ export default {
|
|
|
199
230
|
let dir = renderDirectory(target, this.directorybox);
|
|
200
231
|
this.$emit("directoryRendered", dir);
|
|
201
232
|
|
|
202
|
-
if(window.location.hash?.includes(
|
|
203
|
-
let id = window.location.hash
|
|
233
|
+
if (window.location.hash?.includes("directory")) {
|
|
234
|
+
let id = window.location.hash;
|
|
204
235
|
let target = $(`${id}`).offset().top;
|
|
205
|
-
console.log(target)
|
|
236
|
+
console.log(target);
|
|
206
237
|
$(document).scrollTop(target - HEADER_HEIGHT);
|
|
207
238
|
}
|
|
208
239
|
|
package/vue.config.js
CHANGED
|
@@ -84,6 +84,19 @@ module.exports = {
|
|
|
84
84
|
}
|
|
85
85
|
},
|
|
86
86
|
|
|
87
|
+
//❤️ 配置需要被 Babel 转译的 node_modules 依赖 ~
|
|
88
|
+
transpileDependencies: [
|
|
89
|
+
'htmlparser2',
|
|
90
|
+
'cheerio',
|
|
91
|
+
'dom-serializer',
|
|
92
|
+
'domelementtype',
|
|
93
|
+
'domhandler',
|
|
94
|
+
'domutils',
|
|
95
|
+
'entities',
|
|
96
|
+
'parse5',
|
|
97
|
+
'parse5-htmlparser2-tree-adapter'
|
|
98
|
+
],
|
|
99
|
+
|
|
87
100
|
chainWebpack: config => {
|
|
88
101
|
|
|
89
102
|
//💘 html-webpack-plugin ~
|