Files
YschcmsService/src/main/java/com/yunhai/cms/service/impl/SiteService.java
T
chenxing 19e414ee81 feat(B3): custom_css 站点级自定义样式-服务端黑名单强校验+5主题head内联直出
- validateConfigJson 增 custom_css 分支:≤10240字符;黑名单忽略大小写拒 </style/<script/@import/expression(
- 快照/渲染链路(model customCss)与 themeVarsCss 同构注入;缓存失效复用既有 SiteContentChangedEvent
- 5 套主题 head.html 于 main.css 与微调变量之后 th:utext 直出(安全性完全依赖服务端先行)
- SiteServiceIT+6 例(含 expression 红线项与 10240 边界);PortalSsrIT+3 例(渲染/head内/顺序/缓存失效)
- mvn -o test 258 例全绿(B3 状态,不含 B4 红测)
2026-09-07 20:14:23 +08:00

352 lines
18 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.yunhai.cms.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.cms.config.CmsAuthProperties;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.event.SiteContentChangedEvent;
import com.yunhai.cms.model.dto.SiteSaveRequest;
import com.yunhai.cms.model.vo.SiteVo;
import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.cms.utils.ExtJsonUtils;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 站点与校级权限(方案 §7.2):
* ① 平台超管(BoaUser.type==1)→ 所有站点;② 学校角色(currentRole.schId)→ 仅本校站点(site.sch_id 匹配)。
* 所有管理端写操作入口先过 {@link #requireManageable}。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SiteService {
private final CmsSiteRepository siteRepository;
private final CmsAuthProperties authProperties;
private final ApplicationEventPublisher eventPublisher;
private final com.yunhai.cms.theme.ThemeRegistry themeRegistry;
/**
* B2 附件引用:站点配置 logo/favicon 回写场景引用。
* 字段注入 + @Lazy 打破与 AttachmentService 的构造器循环依赖(AttachmentService → SiteService)。
*/
@org.springframework.beans.factory.annotation.Autowired
@org.springframework.context.annotation.Lazy
private AttachmentService attachmentService;
/** config_json 已知键约束(与前端 utils/siteConfigForm.ts 保持一致,docs/09 §0 键清单) */
private static final ObjectMapper CONFIG_MAPPER = new ObjectMapper();
private static final Pattern HOST_PATTERN = Pattern.compile(
"^(\\*\\.)?[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$");
private static final Pattern URL_PATTERN = Pattern.compile("^https?://\\S+$");
private static final Pattern ASSET_PATTERN = Pattern.compile("^(/.+|https?://\\S+)$");
private static final Pattern CONFIG_KEY_PATTERN = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$");
private static final Set<String> RESERVED_CONFIG_KEYS =
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params")));
private static final int CONFIG_VALUE_MAX_LEN = 500;
/** custom_css 上限与黑名单(B3):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */
private static final int CUSTOM_CSS_MAX_LEN = 10240;
private static final List<String> CUSTOM_CSS_BLACKLIST =
java.util.Collections.unmodifiableList(java.util.Arrays.asList(
"</style", "<script", "@import", "expression("));
/** 当前用户可管理的站点列表(含上级站点名回填,展示"主站/院系子站"层级) */
public List<SiteVo> listFor(BoaUser user) {
List<CmsSite> sites = authProperties.isSuperAdmin(user)
? siteRepository.findAll()
: (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null
? java.util.Collections.emptyList()
: siteRepository.findBySchId(user.getCurrentRole().getSchId()));
Map<Integer, String> nameById = new java.util.HashMap<>();
for (CmsSite site : sites) {
nameById.put(site.getId(), site.getName());
}
List<SiteVo> result = new ArrayList<>();
for (CmsSite site : sites) {
SiteVo vo = SiteVo.of(site);
if (vo.getParentSiteId() != null && vo.getParentSiteId() > 0) {
vo.setParentName(nameById.get(vo.getParentSiteId()));
}
result.add(vo);
}
return result;
}
/** 校验可管理权限并返回站点;越权抛 FORBIDDEN */
public CmsSite requireManageable(BoaUser user, Integer siteId) {
CmsSite site = siteRepository.findById(siteId == null ? -1 : siteId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "站点不存在"));
if (!canManage(user, site)) {
throw new BizException(ErrorCode.FORBIDDEN, "无权管理该站点");
}
return site;
}
public boolean canManage(BoaUser user, CmsSite site) {
if (authProperties.isSuperAdmin(user)) {
return true;
}
Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId();
return schId != null && site.getSchId() != null && schId.equals(site.getSchId());
}
public void requireSuperAdmin(BoaUser user) {
if (!authProperties.isSuperAdmin(user)) {
throw new BizException(ErrorCode.FORBIDDEN, "仅平台超管可执行该操作");
}
}
public CmsSite getByCode(String code) {
return siteRepository.findByCode(code).orElse(null);
}
public String configValue(CmsSite site, String key) {
Map<String, Object> config = ExtJsonUtils.parse(site.getConfigJson());
return ExtJsonUtils.getString(config, key);
}
/** 站点保存(B1 权限拆分:校级可建/编辑本院系子站,顶层站仅超管;MVP 1-2 校以初始化脚本为主) */
@Transactional
public SiteVo save(BoaUser user, SiteSaveRequest req) {
requireSiteSavePermission(user, req);
CmsSite site;
if (req.getId() == null) {
if (siteRepository.findByCode(req.getCode()).isPresent()) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点编码已存在");
}
site = new CmsSite();
site.setCode(req.getCode());
} else {
site = siteRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "站点不存在"));
}
validateParent(req, site);
// B-8:顶层站 schId 必填——canManage 按 currentRole.schId ↔ site.schId 匹配,
// 顶层站空 schId 建成即"校级角色不可见/不可管"(院系子站已在 validateParent 继承主站 schId,不受影响)
if ((site.getParentSiteId() == null || site.getParentSiteId() == 0) && req.getSchId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "顶层站点必须指定学校ID(schId),否则校级角色无法管理");
}
site.setSchId(req.getSchId());
site.setSchName(req.getSchName());
site.setName(req.getName());
String resolvedTheme = StringUtils.hasText(req.getTheme())
? req.getTheme() : com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME;
if (!themeRegistry.contains(resolvedTheme)) {
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + resolvedTheme);
}
site.setTheme(resolvedTheme);
validateConfigJson(req.getConfigJson());
site.setConfigJson(mergeThemeParams(req, resolvedTheme));
site.setStatus(req.getStatus() == null ? 1 : req.getStatus());
site = siteRepository.save(site);
// B2 附件引用:config_json 的 logo/favicon 指向上传文件时回写场景引用(换图/清空自动解引用旧值)
attachmentService.markSiteConfigUsed(site.getId(), ExtJsonUtils.parse(site.getConfigJson()));
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "site"));
return SiteVo.of(site);
}
/**
* 站点保存权限(B1 建站权限拆分,安全边界收敛于本单一方法,前端按钮仅入口显性化):
* 超管直通;非超管仅可新建/编辑「本校主站下的院系子站」,具体拒绝项——
* ① 新建顶层站(parentSiteId≤0)→ 拒(仅平台超管可创建学校主站);
* ② 编辑对象为顶层站(含本校主站)→ 拒;
* ③ 编辑子站时 parentSiteId 传 0/null(提升为顶层)→ 拒;
* ④ 目标主站 schId 与 currentRole.schId 不一致(跨校/改挂接管)→ 拒;
* ⑤ 编辑对象本身跨校 → 拒(复用 canManage 同校语义);
* ⑥ 无校级角色(schId 为空)→ 拒;
* ⑦ 请求体显式 schId 与本人角色不一致(伪造通道)→ 拒(留空则由 validateParent 继承主站)。
*/
private void requireSiteSavePermission(BoaUser user, SiteSaveRequest req) {
if (authProperties.isSuperAdmin(user)) {
return;
}
Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId();
if (schId == null) {
throw new BizException(ErrorCode.FORBIDDEN, "无权管理该站点(需校级角色)");
}
if (req.getId() != null) {
CmsSite target = siteRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "站点不存在"));
boolean targetIsTop = target.getParentSiteId() == null || target.getParentSiteId() == 0;
if (targetIsTop) {
throw new BizException(ErrorCode.FORBIDDEN, "仅平台超管可管理学校主站(顶层站点)");
}
if (!canManage(user, target)) {
throw new BizException(ErrorCode.FORBIDDEN, "无权管理该站点");
}
}
Integer parentSiteId = req.getParentSiteId() == null ? 0 : req.getParentSiteId();
if (parentSiteId <= 0) {
throw new BizException(ErrorCode.FORBIDDEN, "仅平台超管可创建学校主站(顶层站点)");
}
CmsSite parent = siteRepository.findById(parentSiteId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "上级站点不存在"));
if (parent.getSchId() == null || !schId.equals(parent.getSchId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权管理该站点");
}
if (req.getSchId() != null && !schId.equals(req.getSchId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权管理该站点(学校ID与本人角色不符)");
}
}
/**
* themeParams 服务端合并(方案 §5.3,对应评审 R6):仅替换 config_json.theme_params 键,
* 保留 logo/contact/domain 等其余键;未声明 key 与非法值按所选主题 schema 丢弃(不回传错误)。
* SPA 不再手拼 configJson 中的该节点。
*/
private String mergeThemeParams(SiteSaveRequest req, String theme) {
if (req.getThemeParams() == null) {
return req.getConfigJson();
}
Map<String, Object> config = new java.util.LinkedHashMap<>(ExtJsonUtils.parse(req.getConfigJson()));
Map<String, String> filtered = themeRegistry.get(theme).filterValidValues(req.getThemeParams());
if (filtered.isEmpty()) {
config.remove("theme_params");
} else {
config.put("theme_params", filtered);
}
try {
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(config);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点配置序列化失败");
}
}
/**
* 院系子站层级校验(《院系子站方案》):上级必须存在、必须为顶层站(层级固定两层)、
* 与子站同校;子站缺省继承主站的学校信息;已挂子站的顶层站禁止改挂为子站(防锚点悬空)。
*/
private void validateParent(SiteSaveRequest req, CmsSite site) {
Integer parentSiteId = req.getParentSiteId() == null ? 0 : req.getParentSiteId();
if (parentSiteId <= 0) {
site.setParentSiteId(0);
return;
}
if (site.getId() != null && site.getId().equals(parentSiteId)) {
throw new BizException(ErrorCode.PARAM_ERROR, "上级站点不能是自身");
}
CmsSite parent = siteRepository.findById(parentSiteId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "上级站点不存在"));
if (parent.getParentSiteId() != null && parent.getParentSiteId() > 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "院系子站下不允许再挂子站");
}
if (req.getSchId() == null) {
req.setSchId(parent.getSchId());
req.setSchName(parent.getSchName());
} else if (parent.getSchId() != null && !req.getSchId().equals(parent.getSchId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "院系子站与主站不属于同一学校");
}
boolean wasTopLevel = site.getId() == null || site.getParentSiteId() == null
|| site.getParentSiteId() == 0;
if (wasTopLevel && site.getId() != null && siteRepository.existsByParentSiteId(site.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "该站点下已有院系子站,不能改为院系子站");
}
site.setParentSiteId(parentSiteId);
}
/**
* config_json 校验(docs/09 §3.1,替代旧「空 Map 且长度>2」判定):
* ① 必须是 JSON 对象(修复 A-09:带空白空对象误判);② 键名仅字母/数字/下划线;
* ③ 已知键按类型校验(domain 域名 / public_base_url 完整 URL / logo、favicon 资产路径)。
* 系统托管键 theme_params 直通;非字符串值(wechat 等预留对象)不校验原样放行——
* 只拦新脏数据,不做严格 schema 拒绝未知键(D6-2)。
*/
private void validateConfigJson(String configJson) {
if (!StringUtils.hasText(configJson)) {
return;
}
JsonNode node;
try {
node = CONFIG_MAPPER.readTree(configJson);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点配置不是合法的 JSON");
}
if (!node.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点配置必须是 JSON 对象");
}
node.fields().forEachRemaining(entry -> {
String key = entry.getKey();
if (RESERVED_CONFIG_KEYS.contains(key)) {
return;
}
if (!CONFIG_KEY_PATTERN.matcher(key).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"站点配置键名不合法:" + key + "(仅限字母/数字/下划线,且以字母或下划线开头)");
}
JsonNode value = entry.getValue();
if (!value.isTextual()) {
return;
}
String text = value.asText();
if (!StringUtils.hasText(text)) {
return;
}
// custom_css 独立校验(B3):上限 10KB + 黑名单,不走通用 500 字上限
if ("custom_css".equals(key)) {
validateCustomCss(text);
return;
}
if (text.length() > CONFIG_VALUE_MAX_LEN) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点配置值过长:" + key + "(上限 500 字)");
}
switch (key) {
case "domain":
if (!HOST_PATTERN.matcher(text).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"绑定域名格式不正确:" + text + "(示例 www.xxx.edu.cn,不带协议)");
}
break;
case "public_base_url":
if (!URL_PATTERN.matcher(text).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"对外基地址必须是完整的 http(s):// 地址:" + text);
}
break;
case "logo":
case "favicon":
if (!ASSET_PATTERN.matcher(text).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
key + " 必须是站点上传路径(/ 开头)或 http(s) 地址");
}
break;
default:
break;
}
});
}
/**
* custom_css 校验(B3,服务端强校验):长度 ≤10240;黑名单忽略大小写拒绝
* {@code </style}(逃逸内联 style 块)、{@code <script}、{@code @import}(外链样式注入)、
* {@code expression(}(IE 表达式)。门户 5 套主题 head 以 th:utext 直出,安全性完全依赖本方法先行。
*/
private void validateCustomCss(String css) {
if (css.length() > CUSTOM_CSS_MAX_LEN) {
throw new BizException(ErrorCode.PARAM_ERROR,
"custom_css 过长(" + css.length() + " 字符,上限 " + CUSTOM_CSS_MAX_LEN + ")");
}
String lower = css.toLowerCase(java.util.Locale.ROOT);
for (String banned : CUSTOM_CSS_BLACKLIST) {
if (lower.contains(banned)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"custom_css 含禁用内容:" + banned + "(不允许 </style、<script、@import、expression())");
}
}
}
}