init local code

This commit is contained in:
chenxing
2026-09-07 09:09:39 +08:00
commit 99cd26b3d2
259 changed files with 20554 additions and 0 deletions
@@ -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;
}
}
}