From 19e414ee8129d0af2d1ea54cbfa548eb82a47c06 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 7 Sep 2026 20:14:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(B3):=20custom=5Fcss=20=E7=AB=99=E7=82=B9?= =?UTF-8?q?=E7=BA=A7=E8=87=AA=E5=AE=9A=E4=B9=89=E6=A0=B7=E5=BC=8F-?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E9=BB=91=E5=90=8D=E5=8D=95=E5=BC=BA?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C+5=E4=B8=BB=E9=A2=98head=E5=86=85=E8=81=94?= =?UTF-8?q?=E7=9B=B4=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validateConfigJson 增 custom_css 分支:≤10240字符;黑名单忽略大小写拒 > banners = activeBanners(site, "home", url); diff --git a/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java b/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java index 7729879..98e8ff8 100644 --- a/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java +++ b/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java @@ -57,6 +57,9 @@ public class PortalSiteSnapshot { /** 服务端预构建的 :root{...} CSS 变量覆盖串(无有效参数时为空串,方案 §5.3) */ private String themeVarsCss = ""; + /** 站点级自定义样式(B3:config_json.custom_css,保存时经服务端黑名单校验,存量直读) */ + private String customCss = ""; + public static PortalSiteSnapshot of(CmsSite site) { PortalSiteSnapshot snapshot = new PortalSiteSnapshot(); snapshot.setId(site.getId()); @@ -70,6 +73,7 @@ public class PortalSiteSnapshot { snapshot.setConfig(config); snapshot.setDomain(ExtJsonUtils.getString(config, "domain")); snapshot.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url")); + snapshot.setCustomCss(ExtJsonUtils.getString(config, "custom_css")); return snapshot; } @@ -94,6 +98,7 @@ public class PortalSiteSnapshot { copy.setColleges(colleges); copy.setThemeParams(themeParams); copy.setThemeVarsCss(themeVarsCss); + copy.setCustomCss(customCss); return copy; } } diff --git a/src/main/java/com/yunhai/cms/service/impl/SiteService.java b/src/main/java/com/yunhai/cms/service/impl/SiteService.java index 81efbee..4b224f6 100644 --- a/src/main/java/com/yunhai/cms/service/impl/SiteService.java +++ b/src/main/java/com/yunhai/cms/service/impl/SiteService.java @@ -59,6 +59,12 @@ public class SiteService { 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 CUSTOM_CSS_BLACKLIST = + java.util.Collections.unmodifiableList(java.util.Arrays.asList( + " listFor(BoaUser user) { List sites = authProperties.isSuperAdmin(user) @@ -290,6 +296,11 @@ public class SiteService { 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 字)"); } @@ -318,4 +329,23 @@ public class SiteService { } }); } + + /** + * custom_css 校验(B3,服务端强校验):长度 ≤10240;黑名单忽略大小写拒绝 + * {@code 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 + "(不允许 + + diff --git a/src/main/resources/templates/themes/classic-red/fragments/head.html b/src/main/resources/templates/themes/classic-red/fragments/head.html index 9fcb6b4..1809482 100644 --- a/src/main/resources/templates/themes/classic-red/fragments/head.html +++ b/src/main/resources/templates/themes/classic-red/fragments/head.html @@ -15,6 +15,8 @@ + + diff --git a/src/main/resources/templates/themes/default/fragments/head.html b/src/main/resources/templates/themes/default/fragments/head.html index 9fcb6b4..1809482 100644 --- a/src/main/resources/templates/themes/default/fragments/head.html +++ b/src/main/resources/templates/themes/default/fragments/head.html @@ -15,6 +15,8 @@ + + diff --git a/src/main/resources/templates/themes/scholar-blue/fragments/head.html b/src/main/resources/templates/themes/scholar-blue/fragments/head.html index 0197cd1..981c568 100644 --- a/src/main/resources/templates/themes/scholar-blue/fragments/head.html +++ b/src/main/resources/templates/themes/scholar-blue/fragments/head.html @@ -13,6 +13,8 @@ + + diff --git a/src/main/resources/templates/themes/warm-kinder/fragments/head.html b/src/main/resources/templates/themes/warm-kinder/fragments/head.html index 9fcb6b4..1809482 100644 --- a/src/main/resources/templates/themes/warm-kinder/fragments/head.html +++ b/src/main/resources/templates/themes/warm-kinder/fragments/head.html @@ -15,6 +15,8 @@ + + diff --git a/src/test/java/com/yunhai/cms/portal/PortalSsrIT.java b/src/test/java/com/yunhai/cms/portal/PortalSsrIT.java index f21f1dc..eee312c 100644 --- a/src/test/java/com/yunhai/cms/portal/PortalSsrIT.java +++ b/src/test/java/com/yunhai/cms/portal/PortalSsrIT.java @@ -3,6 +3,7 @@ package com.yunhai.cms.portal; import com.yunhai.cms.enums.ArticleStatusEnum; import com.yunhai.cms.enums.CategoryTypeEnum; import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.support.TestUsers; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -360,4 +361,60 @@ class PortalSsrIT extends CmsIntegrationTestBase { .contains("sso.")); mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk()); } + + // ===== B3 custom_css 站点级自定义样式(head 内联输出)===== + + @org.springframework.beans.factory.annotation.Autowired + private com.yunhai.cms.service.impl.SiteService siteService; + + private void setSiteCss(String cssJson) { + siteRepository.findById(siteId).ifPresent(s -> { + s.setConfigJson(cssJson); + siteRepository.saveAndFlush(s); + }); + } + + @Test + @DisplayName("B3: 配置 custom_css 后 SSR 输出内联 style(head 内、皮肤 main.css 之后)") + void customCss_renderedAsInlineStyle() throws Exception { + setSiteCss("{\"custom_css\":\".site-header{padding:16px 0}\"}"); + MvcResult result = mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk()) + .andReturn(); + String html = body(result); + assertThat(html).contains(".site-header{padding:16px 0}"); + assertThat(html).contains(""); + assertThat(html.indexOf(".site-header{padding:16px 0}")).as("内联输出须位于 head 内") + .isLessThan(html.indexOf("")); + assertThat(html.indexOf("css/main.css")).as("覆盖优先级:位于皮肤 main.css 之后") + .isLessThan(html.indexOf(".site-header{padding:16px 0}")); + } + + @Test + @DisplayName("B3: 未配置 custom_css 不输出 style 块(其他站点不受影响)") + void customCss_absent_whenNotConfigured() throws Exception { + MvcResult result = mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk()) + .andReturn(); + assertThat(body(result)).doesNotContain(" siteService.save(TestUsers.superAdmin(), cssReq("css2", big.toString()))) + .isInstanceOf(BizException.class) + .hasMessageContaining("custom_css"); + + // 恰好 10240 放行(边界) + StringBuilder edge = new StringBuilder(); + for (int i = 0; i < 10240; i++) { + edge.append('a'); + } + assertThat(siteService.save(TestUsers.superAdmin(), cssReq("css3", edge.toString()))).isNotNull(); + } + + @Test + @DisplayName("B3: custom_css 含 被拒(忽略大小写,防逃逸内联 style 块)") + void save_customCss_containsStyleClose_rejected() { + assertThatThrownBy(() -> siteService.save( + TestUsers.superAdmin(), cssReq("css4", ".a{color:red}"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("custom_css"); + assertThatThrownBy(() -> siteService.save( + TestUsers.superAdmin(), cssReq("css5", ".a{color:red}"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("custom_css"); + } + + @Test + @DisplayName("B3: custom_css 含 "))) + .isInstanceOf(BizException.class) + .hasMessageContaining("custom_css"); + assertThatThrownBy(() -> siteService.save( + TestUsers.superAdmin(), cssReq("css7", ".a{color:red}