package cn.iocoder.yudao.module.qcreport.engine.render;
|
|
import cn.hutool.core.util.StrUtil;
|
|
import java.util.ArrayList;
|
import java.util.List;
|
import java.util.Locale;
|
import java.util.Map;
|
import java.util.Set;
|
import java.util.regex.Pattern;
|
|
/**
|
* 画布安全性校验:模板保存时把「渲染时会原样拼进 HTML 的内容」先卡一遍。
|
* <p>
|
* 报告产物是拼字符串拼出来的({@link HtmlRenderer}),模板画布又是用户可编辑数据,
|
* 所以画布里凡是<b>未经转义就进入产物</b>的内容都是注入面。逐条列明它们,改动渲染或本类时两边一起看:
|
* <ul>
|
* <li>{@code resolveTag} —— 节点的 {@code tagName} 直接拼成 {@code <tag>},本类按<b>白名单</b>卡。</li>
|
* <li>{@code renderAttributes} —— 只对属性<b>值</b>做转义,属性<b>名</b>是直接拼上去的,
|
* 一个含引号的属性名就能把后面的内容顶成新属性,本类按字符集卡。</li>
|
* <li>{@code buildCss} —— {@code styles} 若是字符串会被<b>原样返回</b>(整个 {@code <style>} 的内容),
|
* 而 {@code <style>} 块里出现 {@code </style} 就能提前闭合、把后面变成真 HTML,
|
* 本类要求 {@code styles} 必须是规则数组,并禁止选择器/属性名/属性值/mediaText 里出现 {@code <}。</li>
|
* </ul>
|
* <p>
|
* <b>为什么在保存时卡而不是渲染时卡</b>:渲染链路上的 {@link HtmlRenderer} 与前端
|
* {@code engine/render.ts} 必须逐字一致,改它就要重算对拍产物、并让存量报告的 {@code regenerate}
|
* 不再逐字复现;而「入站内容是否可信」本来就是写入侧的问题,在数据进库前拦掉,渲染侧可以继续
|
* 保持与前端完全对称。代价是<b>对校验上线前就已入库的脏数据没有兜底</b>——存量版本需要另行核查。
|
* <p>
|
* <b>为什么走的是白名单而不是过滤危险标签</b>:黑名单永远漏,而报告排版用得到的标签是有限的、
|
* 可枚举的。正常模板不会因为这条白名单被拒(已对库里全部存量版本实测过)。
|
*/
|
public final class CanvasSafety {
|
|
/**
|
* 允许出现在画布里的标签。
|
* <p>
|
* 只收「排版与文本」类标签:组件注册表能产出的({@code div/p/span/hr/img/h1~h6} 与表格一族)
|
* 加上 GrapesJS 基础组件与常见行内语义标签。刻意排除四类——
|
* 可执行({@code script})、可注入样式({@code style})、可嵌入外部内容({@code iframe/object/embed})、
|
* 可提交数据({@code form} 及其控件),以及 {@code svg/math} 这类自带脚本能力的命名空间标签。
|
* <p>
|
* GrapesJS 会在组件上挂 {@code docEl: {tagName: "html"}}、{@code head: {type: "head"}} 这类
|
* <b>不会渲染</b>的元数据,所以本类只沿 {@code components} 走(与 {@code RenderNode.children()} 一致),
|
* 白名单里也就不需要 {@code html}/{@code head}。
|
*/
|
private static final Set<String> ALLOWED_TAGS = Set.of(
|
// 区块与容器
|
"div", "span", "section", "article", "header", "footer", "main", "aside", "nav",
|
"figure", "figcaption", "blockquote", "pre", "address", "center",
|
// 文本与标题
|
"p", "br", "hr", "h1", "h2", "h3", "h4", "h5", "h6",
|
"b", "i", "u", "s", "strong", "em", "small", "big", "sub", "sup", "mark",
|
"code", "kbd", "samp", "var", "tt", "strike", "font", "wbr", "bdi", "bdo", "ruby", "rt", "rp",
|
"abbr", "cite", "q", "time", "del", "ins",
|
// 列表
|
"ul", "ol", "li", "dl", "dt", "dd",
|
// 表格
|
"table", "thead", "tbody", "tfoot", "tr", "td", "th", "caption", "col", "colgroup",
|
// 图片与链接
|
"img", "a", "label");
|
|
/** 合法的属性名:字母/下划线/冒号开头,其后字母数字与 {@code - _ : .}。含引号或空格的属性名一律拒绝 */
|
private static final Pattern ATTRIBUTE_NAME = Pattern.compile("^[A-Za-z_:][-A-Za-z0-9_:.]*$");
|
|
/** 最多报几条:一条报错信息里堆几十条没人看得下去,改完再存一次就能看到下一批 */
|
private static final int MAX_PROBLEMS = 8;
|
|
/** 路径展示的最大层数,再深就折叠成 …,避免一条提示里出现一长串「第 N 个组件」 */
|
private static final int MAX_PATH_DEPTH = 6;
|
|
private static final String UNSAFE_LT = "<";
|
|
private CanvasSafety() {
|
}
|
|
/**
|
* 校验一份画布。
|
*
|
* @param grapes GradesJS 项目数据(模板 Schema 的 {@code grapes} 字段),可为 null
|
* @return 问题清单,空列表代表通过。文案已可直接拼给用户看
|
*/
|
public static List<String> validate(Map<String, Object> grapes) {
|
List<String> problems = new ArrayList<>();
|
if (grapes == null) {
|
return problems;
|
}
|
validateComponentTree(grapes, problems);
|
validateStyles(grapes.get("styles"), problems);
|
return problems;
|
}
|
|
/* ------------------------------ 组件树 ------------------------------ */
|
|
private static void validateComponentTree(Map<String, Object> grapes, List<String> problems) {
|
for (Map<String, Object> frameComponent : frameComponents(grapes)) {
|
walk(frameComponent, "根", 0, problems);
|
}
|
}
|
|
/** 取所有页所有 frame 的根组件。渲染器只读 {@code pages[0].frames[0]},这里全查一遍:多出来的部分也不能藏脏东西 */
|
private static List<Map<String, Object>> frameComponents(Map<String, Object> grapes) {
|
List<Map<String, Object>> result = new ArrayList<>();
|
collectFrames(grapes.get("pages"), result);
|
return result;
|
}
|
|
@SuppressWarnings("unchecked")
|
private static void collectFrames(Object pages, List<Map<String, Object>> result) {
|
if (!(pages instanceof List<?> pageList)) {
|
return;
|
}
|
for (Object rawPage : pageList) {
|
if (!(rawPage instanceof Map<?, ?> page)) {
|
continue;
|
}
|
Object frames = ((Map<String, Object>) page).get("frames");
|
if (!(frames instanceof List<?> frameList)) {
|
continue;
|
}
|
for (Object rawFrame : frameList) {
|
if (rawFrame instanceof Map<?, ?> frame
|
&& ((Map<String, Object>) frame).get("component") instanceof Map<?, ?> component) {
|
result.add((Map<String, Object>) component);
|
}
|
}
|
}
|
}
|
|
@SuppressWarnings("unchecked")
|
private static void walk(Map<String, Object> node, String path, int depth, List<String> problems) {
|
if (problems.size() >= MAX_PROBLEMS) {
|
return;
|
}
|
validateNodeTag(node, path, problems);
|
validateNodeAttributes(node, path, problems);
|
|
if (!(node.get("components") instanceof List<?> children)) {
|
return;
|
}
|
int index = 0;
|
for (Object raw : children) {
|
if (!(raw instanceof Map<?, ?> child)) {
|
continue;
|
}
|
index++;
|
walk((Map<String, Object>) child, path + " > 第 " + index + " 个组件", depth + 1, problems);
|
if (problems.size() >= MAX_PROBLEMS) {
|
return;
|
}
|
}
|
}
|
|
private static void validateNodeTag(Map<String, Object> node, String path, List<String> problems) {
|
Object raw = node.get("tagName");
|
String tagName = raw == null ? null : String.valueOf(raw).trim();
|
// textnode 不产生标签(HtmlRenderer 对它单独处理),它身上的 tagName 渲染时被忽略,不必拦
|
if (StrUtil.isBlank(tagName) || "textnode".equals(node.get("type"))) {
|
return;
|
}
|
if (ALLOWED_TAGS.contains(tagName.toLowerCase(Locale.ROOT))) {
|
return;
|
}
|
problems.add(StrUtil.format(
|
"{} 的标签是「{}」,报告不支持该标签。可用的只有常规排版标签"
|
+ "(div/p/span/h1~h6/img/hr 与表格、列表、行内文本标签),"
|
+ "脚本、样式、内嵌页面、表单类标签一律不允许",
|
displayPath(path), tagName));
|
}
|
|
@SuppressWarnings("unchecked")
|
private static void validateNodeAttributes(Map<String, Object> node, String path, List<String> problems) {
|
if (!(node.get("attributes") instanceof Map<?, ?> attributes)) {
|
return;
|
}
|
for (Object key : ((Map<String, Object>) attributes).keySet()) {
|
String name = String.valueOf(key);
|
if (!ATTRIBUTE_NAME.matcher(name).matches()) {
|
problems.add(StrUtil.format(
|
"{} 的属性名「{}」不是合法的属性名(只能由字母、数字、- _ : . 组成,且不能以数字开头),"
|
+ "该属性会让报告产物结构错乱",
|
displayPath(path), name));
|
continue;
|
}
|
if (name.toLowerCase(Locale.ROOT).startsWith("on")) {
|
problems.add(StrUtil.format(
|
"{} 的属性「{}」是事件属性,报告不支持",
|
displayPath(path), name));
|
}
|
}
|
}
|
|
/* ------------------------------ 样式 ------------------------------ */
|
|
@SuppressWarnings("unchecked")
|
private static void validateStyles(Object styles, List<String> problems) {
|
if (styles == null) {
|
return;
|
}
|
if (styles instanceof String text) {
|
if (StrUtil.isNotBlank(text)) {
|
// buildCss 会把它原样塞进 <style>,等于让模板自带一整段不受控的 CSS(还能提前闭合 <style>)
|
problems.add("画布的 styles 是一段 CSS 文本,报告不接受整段 CSS,"
|
+ "请改用设计器的样式面板逐条设置样式(保存后是样式规则数组)");
|
}
|
return;
|
}
|
if (!(styles instanceof List<?> rules)) {
|
problems.add("画布的 styles 既不是样式规则数组也不是 CSS 文本,无法识别,请重新保存模板");
|
return;
|
}
|
for (int i = 0; i < rules.size() && problems.size() < MAX_PROBLEMS; i++) {
|
if (!(rules.get(i) instanceof Map<?, ?> rule)) {
|
continue;
|
}
|
Map<String, Object> ruleMap = (Map<String, Object>) rule;
|
validateStyleFragments(ruleMap.get("selectors"), "样式规则 " + (i + 1) + " 的选择器", problems);
|
validateStyleFragments(ruleMap.get("mediaText"), "样式规则 " + (i + 1) + " 的媒体查询条件", problems);
|
Object style = ruleMap.get("style");
|
if (style instanceof Map<?, ?> styleMap) {
|
for (Map.Entry<?, ?> entry : ((Map<String, Object>) styleMap).entrySet()) {
|
String where = StrUtil.format("样式规则 {} 的属性「{}」",
|
i + 1, String.valueOf(entry.getKey()));
|
if (String.valueOf(entry.getKey()).contains(UNSAFE_LT)) {
|
problems.add(where + "名字里含有「<」,报告不支持");
|
continue;
|
}
|
validateStyleFragments(entry.getValue(), where, problems);
|
}
|
}
|
}
|
}
|
|
/** 选择器/媒体查询/样式值都进 {@code <style>},出现 {@code <} 就可能闭合 style 块 */
|
private static void validateStyleFragments(Object raw, String where, List<String> problems) {
|
if (raw instanceof List<?> list) {
|
for (Object item : list) {
|
validateStyleFragments(item, where, problems);
|
}
|
return;
|
}
|
if (raw == null || problems.size() >= MAX_PROBLEMS) {
|
return;
|
}
|
String text = String.valueOf(raw);
|
if (text.contains(UNSAFE_LT)) {
|
problems.add(StrUtil.format("{}「{}」里含有「<」,报告不支持,请在设计器里改掉",
|
where, StrUtil.maxLength(text, 60)));
|
}
|
}
|
|
/* ------------------------------ 展示 ------------------------------ */
|
|
/** 路径太深就折叠中段,报错信息里出现一长串「第 N 个组件」对定位没有帮助 */
|
private static String displayPath(String path) {
|
String[] segments = path.split(" > ");
|
if (segments.length <= MAX_PATH_DEPTH) {
|
return "画布「" + path + "」";
|
}
|
List<String> shown = new ArrayList<>(List.of(segments[0], segments[1], "…"));
|
for (int i = segments.length - 3; i < segments.length; i++) {
|
shown.add(segments[i]);
|
}
|
return "画布「" + String.join(" > ", shown) + "」";
|
}
|
|
}
|