init local code
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
package com.yunhai.cms.utils;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* JSON 扩展列(cms_site.config_json / cms_article.ext_json)解析工具。
|
||||
* 解析失败按空 Map 处理(扩展列不允许影响主流程)。
|
||||
* 保留键见方案 §5.2:site 侧 domain/public_base_url/wechat;article 侧 wechat/albums。
|
||||
*/
|
||||
public final class ExtJsonUtils {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private ExtJsonUtils() {
|
||||
}
|
||||
|
||||
public static Map<String, Object> parse(String json) {
|
||||
if (json == null || json.trim().isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
try {
|
||||
Map<String, Object> map = MAPPER.readValue(json, new TypeReference<LinkedHashMap<String, Object>>() { });
|
||||
return map == null ? Collections.emptyMap() : map;
|
||||
} catch (Exception e) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
|
||||
public static String getString(Map<String, Object> map, String key) {
|
||||
Object value = map == null ? null : map.get(key);
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.yunhai.cms.utils;
|
||||
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.safety.Safelist;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 富文本白名单清洗(方案 §8.5 内容安全):wangEditor 产出的正文 HTML 入库前执行。
|
||||
*
|
||||
* <p>策略:jsoup Safelist.relaxed() 为基础,补 figure/figcaption(编辑器图片段落)、
|
||||
* 全局受限 style(仅排版类属性白名单),a/img 协议收紧;script/iframe/on* 一律剥除。
|
||||
* 列表页等纯文本字段由 Thymeleaf th:text 转义输出,th:utext 仅限本工具清洗过的内容。
|
||||
*/
|
||||
public final class HtmlSanitizer {
|
||||
|
||||
/** 允许保留的内联样式属性(排版用途),其余 style 值丢弃 */
|
||||
private static final Pattern SAFE_STYLE =
|
||||
Pattern.compile("^(\\s*(text-align|color|background-color|font-size|font-weight"
|
||||
+ "|line-height|width|height|max-width|float|margin|padding)\\s*:\\s*[^;()]+;?)*\\s*$",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final Safelist WHITELIST = Safelist.relaxed()
|
||||
.addTags("figure", "figcaption", "span")
|
||||
.addAttributes(":all", "style")
|
||||
.addAttributes("img", "style", "alt", "width", "height")
|
||||
.addProtocols("a", "href", "http", "https", "mailto", "ftp", "#")
|
||||
.addProtocols("img", "src", "http", "https", "data")
|
||||
// 相对链接保留(day3 验收 B-9 根因修复):上传地址约定相对路径入库(B-1,
|
||||
// FILE_WEB_PATH=/cms/upload/**),编辑器贴图的 img src 即相对 URL;jsoup 默认
|
||||
// preserveRelativeLinks=false 时相对 src/href 无法解析协议 → 整个属性被剥除,
|
||||
// 保存后贴图全部断图。开启后相对 URL 原样放行(不参与协议校验),
|
||||
// 绝对 URL 仍受上方协议白名单约束。
|
||||
.preserveRelativeLinks(true);
|
||||
|
||||
private HtmlSanitizer() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗富文本;空入参原样返回。
|
||||
*
|
||||
* <p>baseUri 用占位 https 域(B-9 根因修复配套):jsoup 对带协议白名单的属性先做
|
||||
* absUrl 解析再校验协议,空 baseUri 下相对 URL 解析必失败 → 属性整体剥除(编辑器
|
||||
* 贴图保存后断图)。给占位 base 后相对 URL 解析为 https 形态通过校验,再由
|
||||
* preserveRelativeLinks(true) 回写原始相对值——绝对 URL 的协议白名单约束不变。
|
||||
*/
|
||||
public static String clean(String html) {
|
||||
if (html == null || html.isEmpty()) {
|
||||
return html;
|
||||
}
|
||||
String cleaned = Jsoup.clean(html, "https://sanitizer.base.invalid/", WHITELIST);
|
||||
return stripUnsafeStyle(cleaned);
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯文本提取(摘要兜底、搜索片段用):去标签 + 折叠空白。
|
||||
*/
|
||||
public static String toText(String html, int maxLength) {
|
||||
if (html == null || html.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim();
|
||||
if (maxLength > 0 && text.length() > maxLength) {
|
||||
return text.substring(0, maxLength);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* jsoup 原生不支持按值过滤属性,二次遍历按 SAFE_STYLE 白名单过滤 style:
|
||||
* 不匹配白名单的 style 整个属性丢弃(A-07 修复——原先只删含 url()/expression 的,
|
||||
* 与类注释"其余 style 值丢弃"不符);jsoup 清洗阶段已保证 style 值不含引号逃逸。
|
||||
*/
|
||||
private static String stripUnsafeStyle(String html) {
|
||||
org.jsoup.nodes.Document doc = org.jsoup.Jsoup.parseBodyFragment(html);
|
||||
for (org.jsoup.nodes.Element el : doc.select("[style]")) {
|
||||
String style = el.attr("style");
|
||||
if (style == null || !SAFE_STYLE.matcher(style).matches()) {
|
||||
el.removeAttr("style");
|
||||
}
|
||||
}
|
||||
return doc.body().html();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.yunhai.cms.utils;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 门户 URL 生成器(方案 §8.6 前置约定2):所有链接、主题资产、canonical 统一经此生成,
|
||||
* 禁止模板硬编码绝对地址——base 域子路径与学校独立域名两种暴露形态共用同一套渲染。
|
||||
*
|
||||
* <p>基准 = 站点 config_json.public_base_url(绑学校域名后为学校域),
|
||||
* 未配置时从当前请求派生(预览/平台内跳转形态)。
|
||||
*/
|
||||
public class PortalUrlHelper {
|
||||
|
||||
/** 正文/单页内容里出现的上传资源地址:可选历史绝对前缀 + 相对路径(B-1 渲染期统一补基准) */
|
||||
private static final Pattern UPLOAD_URL_IN_CONTENT =
|
||||
Pattern.compile("(?:https?://[^/\\s\"']+)?((?:/cms)?/upload/[^\\s\"']+)");
|
||||
|
||||
/** 不带尾斜杠的门户基地址,形如 {scheme}://{host}{context}/portal/{siteCode} 或 https://www.xxx.edu.cn */
|
||||
private final String baseUrl;
|
||||
|
||||
private final String siteCode;
|
||||
|
||||
/** 当前皮肤包名:进入资产 URL(B-3,换肤即换 URL 使浏览器/CDN 缓存天然失效) */
|
||||
private final String theme;
|
||||
|
||||
/**
|
||||
* base 域对外基地址(cms.portal.base-origin,含 context-path,如 https://base.yunhaixingtong.com/cms)。
|
||||
* C-6:站点未配 public_base_url 时的规范派生基准;B-4:siteHome 无段形态退化目标。空 = 未配置。
|
||||
*/
|
||||
private final String baseOrigin;
|
||||
|
||||
/** 预览模式资产前缀(非 null 时 asset() 改走管理端代理,方案 §5.5 防资产串包) */
|
||||
private final String assetProxyPrefix;
|
||||
|
||||
public PortalUrlHelper(String baseUrl, String siteCode, String theme) {
|
||||
this(baseUrl, siteCode, theme, null, null);
|
||||
}
|
||||
|
||||
public PortalUrlHelper(String baseUrl, String siteCode, String theme, String baseOrigin) {
|
||||
this(baseUrl, siteCode, theme, baseOrigin, null);
|
||||
}
|
||||
|
||||
private PortalUrlHelper(String baseUrl, String siteCode, String theme, String baseOrigin,
|
||||
String assetProxyPrefix) {
|
||||
this.baseUrl = trimTail(baseUrl);
|
||||
this.siteCode = siteCode;
|
||||
this.theme = theme;
|
||||
this.baseOrigin = trimTail(baseOrigin);
|
||||
this.assetProxyPrefix = assetProxyPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览模式工厂(方案 §5.5):资产 URL 指向管理端主题资产代理 /api/theme/{theme}/asset/**,
|
||||
* 其余站内链接仍指向真实门户(点击回真实门户是已接受的预览局限)。
|
||||
*/
|
||||
public static PortalUrlHelper forPreview(HttpServletRequest request, String siteCode, String theme) {
|
||||
PortalUrlHelper base = fromRequest(request, siteCode, theme);
|
||||
// 显式走 5 参构造:第 4 参是 base-origin,代理前缀必须落第 5 参(避免误绑 4 参重载)
|
||||
return new PortalUrlHelper(base.baseUrl, siteCode, theme, null, "/api/theme/" + theme + "/asset/");
|
||||
}
|
||||
|
||||
/**
|
||||
* 从当前请求派生门户基地址(public_base_url / base-origin 均未配置时的兜底;
|
||||
* 反代形态经 ForwardedHeaderFilter 修正后的 scheme/host/port 已反映在请求上,B-2)。
|
||||
*/
|
||||
public static PortalUrlHelper fromRequest(HttpServletRequest request, String siteCode, String theme) {
|
||||
String scheme = request.getScheme();
|
||||
String host = request.getServerName();
|
||||
int port = request.getServerPort();
|
||||
String context = request.getContextPath();
|
||||
StringBuilder sb = new StringBuilder(scheme).append("://").append(host);
|
||||
boolean defaultPort = "http".equals(scheme) && port == 80 || "https".equals(scheme) && port == 443;
|
||||
if (!defaultPort) {
|
||||
sb.append(':').append(port);
|
||||
}
|
||||
sb.append(context).append("/portal/").append(siteCode);
|
||||
return new PortalUrlHelper(sb.toString(), siteCode, theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* base 域规范形态工厂(C-6):站点未配 public_base_url 且平台配了 cms.portal.base-origin 时,
|
||||
* 以 base 域规范路径为基准(base-origin + /portal/{siteCode}),不随访问 host 漂移——
|
||||
* 方案 §8.6「默认派生 https://base.yunhaixingtong.com/cms/portal/{code}」的落地。
|
||||
*/
|
||||
public static PortalUrlHelper fromBaseOrigin(String baseOrigin, String siteCode, String theme) {
|
||||
return new PortalUrlHelper(trimTail(baseOrigin) + "/portal/" + siteCode, siteCode, theme, baseOrigin);
|
||||
}
|
||||
|
||||
/** 首页 */
|
||||
public String home() {
|
||||
return baseUrl + "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* 其他站点首页("专业院系"下拉用,《院系子站方案》):目标站配置了 public_base_url 时以它为准;
|
||||
* 否则把当前基准里的站点段替换为目标站点(base 域子路径形态);
|
||||
* 学校域形态(当前基准无 /portal/ 段)时退化为 base 域规范路径(B-4,base-origin 未配置才回 "/")。
|
||||
*/
|
||||
public String siteHome(String targetCode, String targetPublicBaseUrl) {
|
||||
if (targetPublicBaseUrl != null && !targetPublicBaseUrl.trim().isEmpty()) {
|
||||
return trimTail(targetPublicBaseUrl) + "/";
|
||||
}
|
||||
int idx = baseUrl.lastIndexOf("/portal/");
|
||||
if (idx >= 0) {
|
||||
return baseUrl.substring(0, idx + "/portal/".length()) + targetCode + "/";
|
||||
}
|
||||
if (baseOrigin != null && !baseOrigin.isEmpty()) {
|
||||
return baseOrigin + "/portal/" + targetCode + "/";
|
||||
}
|
||||
return "/";
|
||||
}
|
||||
|
||||
/** 栏目列表/单页(type=3 外链不在此渲染,直接取 linkUrl) */
|
||||
public String category(Object categoryId) {
|
||||
return baseUrl + "/c/" + categoryId;
|
||||
}
|
||||
|
||||
/** 文章详情 */
|
||||
public String article(Object articleId) {
|
||||
return baseUrl + "/a/" + articleId;
|
||||
}
|
||||
|
||||
/** 站内搜索 */
|
||||
public String search(String q, Integer page) {
|
||||
String url = baseUrl + "/search?q=" + urlEncode(q == null ? "" : q);
|
||||
return page != null && page > 1 ? url + "&page=" + page : url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题资产(css/图片):常规经 PortalAssetController 按站点皮肤解析;预览模式走管理端代理。
|
||||
* URL 含主题段(B-3):换肤即换 URL,浏览器/CDN 的 max-age 缓存天然失效。
|
||||
*/
|
||||
public String asset(String path) {
|
||||
if (assetProxyPrefix != null) {
|
||||
return assetProxyPrefix + trimLead(path);
|
||||
}
|
||||
String themeSeg = theme == null || theme.isEmpty() ? "default" : theme;
|
||||
return baseUrl + "/assets/" + themeSeg + "/" + trimLead(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传资源地址补基准(B-1):入库的 webUrl 一律为相对路径(FILE_WEB_PATH 相对形态),
|
||||
* 渲染时按当前基准补 origin 成完整 URL——base 域/学校域/任意外部域访问不断图。
|
||||
* 已是完整 http(s) 地址(外链图片等)原样返回;空值原样返回。
|
||||
*/
|
||||
public String upload(String storedUrl) {
|
||||
if (storedUrl == null || storedUrl.trim().isEmpty()) {
|
||||
return storedUrl;
|
||||
}
|
||||
String s = storedUrl.trim();
|
||||
if (s.startsWith("http://") || s.startsWith("https://")) {
|
||||
return s;
|
||||
}
|
||||
return originOf(baseUrl) + (s.startsWith("/") ? s : "/" + s);
|
||||
}
|
||||
|
||||
/**
|
||||
* 富文本内容后处理(B-1):把正文里历史绝对前缀(http://localhost:8072 等)
|
||||
* 与相对形态的上传地址统一重写为当前基准下的完整 URL。外链图片不受影响。
|
||||
*/
|
||||
public String rewriteUploadUrls(String html) {
|
||||
if (html == null || html.isEmpty()) {
|
||||
return html;
|
||||
}
|
||||
Matcher matcher = UPLOAD_URL_IN_CONTENT.matcher(html);
|
||||
StringBuffer sb = new StringBuffer(html.length());
|
||||
while (matcher.find()) {
|
||||
matcher.appendReplacement(sb, Matcher.quoteReplacement(upload(matcher.group(1))));
|
||||
}
|
||||
matcher.appendTail(sb);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public String getSiteCode() {
|
||||
return siteCode;
|
||||
}
|
||||
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/** 从基地址截取 scheme://host[:port](去掉 /portal 段及任何路径),upload() 补基准用 */
|
||||
private static String originOf(String baseUrl) {
|
||||
if (baseUrl == null) {
|
||||
return "";
|
||||
}
|
||||
int schemeEnd = baseUrl.indexOf("://");
|
||||
if (schemeEnd < 0) {
|
||||
return "";
|
||||
}
|
||||
int pathStart = baseUrl.indexOf('/', schemeEnd + 3);
|
||||
return pathStart < 0 ? baseUrl : baseUrl.substring(0, pathStart);
|
||||
}
|
||||
|
||||
private static String trimTail(String url) {
|
||||
String s = url == null ? "" : url.trim();
|
||||
while (s.endsWith("/")) {
|
||||
s = s.substring(0, s.length() - 1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private static String trimLead(String path) {
|
||||
String s = path == null ? "" : path;
|
||||
while (s.startsWith("/")) {
|
||||
s = s.substring(1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private static String urlEncode(String value) {
|
||||
try {
|
||||
return java.net.URLEncoder.encode(value, "UTF-8");
|
||||
} catch (java.io.UnsupportedEncodingException e) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user