- 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 红测)
352 lines
18 KiB
Java
352 lines
18 KiB
Java
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())");
|
||
}
|
||
}
|
||
}
|
||
}
|