From 1f821e046ee6f6cd66e3d9547e5879f2bd338f20 Mon Sep 17 00:00:00 2001 From: MayTekayaa <100131287+MayTekayaa@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:50:34 +0100 Subject: [PATCH 1/4] feat: Icon row in the page, application and site section editors EXO-90337 (#576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Why** eXIP 7.3.0.31 US04 (EXO-90337, note 51586 §4 *Access*, *Renderers*, §2 *Security*): the Edit Page drawer and the application editor offer the Icon row of the Text section, and saving applies the colour to that page's icons or to that app's icons only. The site banner, sidebar and application drawers render the same shared input, so they get the row too. **How** - The five drawers pass `custom-icon` to the shared text input; the row stays empty when the section is switched on and stores a value only when the user picks one (D6). - `LayoutModel` and `EntityMapper` carry `iconColor` like the text colour; a container whose only custom value is the icon colour still gets a `css-style`. - `ApplicationUtils.getStyle` emits `--appIconColor` where it emits `--appTextColor`, so a page value is inherited by its applications and an application value wins; `LayoutUtils.applyContainerStyle` copies the field. - `LayoutStyleValidator` (new): the page save's style validation, moved as is and shared with `SiteLayoutService.updateSiteLayout`, which validated nothing before; the icon colour is hex-only (D7), and a sticky section's `top@middle` scroll colours are checked half by half. `SiteLayoutRest` maps the refusal to a 400 as the page endpoint does. - Tests: the model round trip, the hex-only refusal on page and site, the sticky colours accepted, the 400 on the site endpoint. Depends on Meeds-io/portal's `ModelStyle.iconColor` under the same task id. Knowledge: owed by the eXIP's knowledge update (EXO-90341), gated at the `feature/mips` integration PR. This change is classified N1 at bd41ff78: the validator between editor-supplied style values and the inline CSS every viewer receives is rewritten and widened to the site save path. Its approver must be an Archi/Dev who knows it is N1, not an approval on AI review alone, and must not be the author. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5.1 (cherry picked from commit 4a857b61bd84ef0754541c7184bb1a705379d563) --- .../io/meeds/layout/model/LayoutModel.java | 3 + .../io/meeds/layout/rest/SiteLayoutRest.java | 2 + .../layout/service/PageLayoutService.java | 55 +------ .../layout/service/SiteLayoutService.java | 3 + .../io/meeds/layout/util/EntityMapper.java | 2 + .../layout/util/LayoutStyleValidator.java | 134 ++++++++++++++++++ .../meeds/layout/model/LayoutModelTest.java | 33 +++++ .../meeds/layout/rest/SiteLayoutRestTest.java | 15 ++ .../layout/service/PageLayoutServiceTest.java | 16 +++ .../layout/service/SiteLayoutServiceTest.java | 61 ++++++++ .../drawer/EditApplicationDrawer.vue | 2 + .../components/drawer/EditPageDrawer.vue | 1 + .../vue-app/common-layout/js/LayoutUtils.js | 3 + .../vue-app/common/js/ApplicationUtils.js | 3 + .../drawer/EditApplicationDrawer.vue | 2 + .../drawer/EditSiteBannerDrawer.vue | 1 + .../drawer/EditSiteSidebarDrawer.vue | 1 + 17 files changed, 285 insertions(+), 52 deletions(-) create mode 100644 layout-service/src/main/java/io/meeds/layout/util/LayoutStyleValidator.java diff --git a/layout-service/src/main/java/io/meeds/layout/model/LayoutModel.java b/layout-service/src/main/java/io/meeds/layout/model/LayoutModel.java index 975c61950..06b8f3edc 100644 --- a/layout-service/src/main/java/io/meeds/layout/model/LayoutModel.java +++ b/layout-service/src/main/java/io/meeds/layout/model/LayoutModel.java @@ -176,6 +176,8 @@ public class LayoutModel { private String textSubtitleFontStyle; + private String iconColor; + private String textTitleBackgroundColor; private String textTitleBackgroundImage; @@ -360,6 +362,7 @@ private void init(ModelObject model, PortletInstanceService portletInstanceServi this.textSubtitleFontSize = cssStyle.getTextSubtitleFontSize(); this.textSubtitleFontWeight = cssStyle.getTextSubtitleFontWeight(); this.textSubtitleFontStyle = cssStyle.getTextSubtitleFontStyle(); + this.iconColor = cssStyle.getIconColor(); this.textTitleBackgroundColor = cssStyle.getTextTitleBackgroundColor(); this.textTitleBackgroundImage = cssStyle.getTextTitleBackgroundImage(); this.textTitleBackgroundEffect = cssStyle.getTextTitleBackgroundEffect(); diff --git a/layout-service/src/main/java/io/meeds/layout/rest/SiteLayoutRest.java b/layout-service/src/main/java/io/meeds/layout/rest/SiteLayoutRest.java index e1c2c0eca..c61744c5d 100644 --- a/layout-service/src/main/java/io/meeds/layout/rest/SiteLayoutRest.java +++ b/layout-service/src/main/java/io/meeds/layout/rest/SiteLayoutRest.java @@ -396,6 +396,8 @@ public ResponseEntity updateSiteLayout( publish.orElse(false).booleanValue(), request.getRemoteUser()); return getSiteLayout(webRequest, request, siteType, siteName, expand); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); } catch (ObjectNotFoundException e) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage()); } catch (IllegalAccessException e) { diff --git a/layout-service/src/main/java/io/meeds/layout/service/PageLayoutService.java b/layout-service/src/main/java/io/meeds/layout/service/PageLayoutService.java index 9382eace3..819101f61 100644 --- a/layout-service/src/main/java/io/meeds/layout/service/PageLayoutService.java +++ b/layout-service/src/main/java/io/meeds/layout/service/PageLayoutService.java @@ -19,7 +19,6 @@ package io.meeds.layout.service; import java.util.ArrayList; -import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Objects; @@ -32,8 +31,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import com.google.javascript.jscomp.jarjar.com.google.re2j.Pattern; - import org.exoplatform.commons.addons.AddOnService; import org.exoplatform.commons.exception.ObjectNotFoundException; import org.exoplatform.portal.config.UserPortalConfigService; @@ -59,6 +56,7 @@ import io.meeds.layout.model.PortletInstancePreference; import io.meeds.layout.util.EntityMapper; import io.meeds.layout.util.JsonUtils; +import io.meeds.layout.util.LayoutStyleValidator; import lombok.SneakyThrows; @@ -75,8 +73,6 @@ public class PageLayoutService { private static final Log LOG = ExoLogger.getLogger(PageLayoutService.class); - private static final Pattern GENERIC_STYLE_MATCHER_VALIDATOR = Pattern.compile("[#0-9a-zA-Z\\(\\),\\./\"'\\-%_ ]+"); - private static final String PAGE_NOT_EXISTS_MESSAGE = "Page with key %s doesn't exist"; private static final String PAGE_NOT_ACCESSIBLE_MESSAGE = "Page with ref %s isn't accessible for user %s"; @@ -248,7 +244,7 @@ public PageContext createPage(PageCreateModel pageModel, String username) throws portalConfig.getEditPermission() : pageModel.getEditPermission(); page.setEditPermission(editPermission); - validateCSSInputs(page); + LayoutStyleValidator.validate(page); layoutService.save(new PageContext(page.getPageKey(), Utils.toPageState(page)), page); listenerService.broadcast(PAGE_UPDATED_EVENT, username, page.getPageKey().format()); return layoutService.getPageContext(page.getPageKey()); @@ -359,7 +355,7 @@ public PageContext updatePageLayout(String pageRef, LOG.debug("Error while accessing page applications storage information", e); throw new IllegalStateException("layout.pageOutdatedError"); } - validateCSSInputs(page); + LayoutStyleValidator.validate(page); existingPage.setChildren(page.getChildren()); layoutService.save(existingPage); listenerService.broadcast(PAGE_UPDATED_EVENT, username, pageKey.format()); @@ -544,51 +540,6 @@ private boolean replaceAddonContainerChildren(Container container) { return replaced; } - private void validateCSSInputs(ModelObject modelObject) { // NOSONAR - ModelStyle cssStyle = modelObject.getCssStyle(); - Arrays.asList(modelObject.getHeight(), - modelObject.getWidth(), - cssStyle == null ? null : cssStyle.getBorderColor(), - cssStyle == null ? null : cssStyle.getBorderSize(), - cssStyle == null ? null : cssStyle.getBoxShadow(), - cssStyle == null ? null : cssStyle.getBackgroundColor(), - cssStyle == null ? null : cssStyle.getBackgroundImage(), - cssStyle == null ? null : cssStyle.getBackgroundEffect(), - cssStyle == null ? null : cssStyle.getBackgroundPosition(), - cssStyle == null ? null : cssStyle.getBackgroundSize(), - cssStyle == null ? null : cssStyle.getBackgroundRepeat(), - cssStyle == null ? null : cssStyle.getTextTitleColor(), - cssStyle == null ? null : cssStyle.getTextTitleFontSize(), - cssStyle == null ? null : cssStyle.getTextTitleFontWeight(), - cssStyle == null ? null : cssStyle.getTextTitleFontStyle(), - cssStyle == null ? null : cssStyle.getTextColor(), - cssStyle == null ? null : cssStyle.getTextFontSize(), - cssStyle == null ? null : cssStyle.getTextFontWeight(), - cssStyle == null ? null : cssStyle.getTextFontStyle(), - cssStyle == null ? null : cssStyle.getTextHeaderColor(), - cssStyle == null ? null : cssStyle.getTextHeaderFontSize(), - cssStyle == null ? null : cssStyle.getTextHeaderFontWeight(), - cssStyle == null ? null : cssStyle.getTextHeaderFontStyle(), - cssStyle == null ? null : cssStyle.getTextSubtitleColor(), - cssStyle == null ? null : cssStyle.getTextSubtitleFontSize(), - cssStyle == null ? null : cssStyle.getTextSubtitleFontWeight(), - cssStyle == null ? null : cssStyle.getTextSubtitleFontStyle()) - .forEach(this::validateCSSStyleValue); - if (modelObject instanceof Container container && !CollectionUtils.isEmpty(container.getChildren())) { - container.getChildren().forEach(this::validateCSSInputs); - } - } - - private void validateCSSStyleValue(String value) { - if (StringUtils.isNotBlank(value) - && (!GENERIC_STYLE_MATCHER_VALIDATOR.matches(value) - || value.contains("javascript") - || value.contains("eval"))) { - throw new IllegalArgumentException(String.format("Invalid css value input %s", - value)); - } - } - private ModelObject filterByPermission(ModelObject modelObject, String username) { if (!aclService.hasAccessPermission(modelObject, username)) { return null; diff --git a/layout-service/src/main/java/io/meeds/layout/service/SiteLayoutService.java b/layout-service/src/main/java/io/meeds/layout/service/SiteLayoutService.java index c83adffce..f8f067cf5 100644 --- a/layout-service/src/main/java/io/meeds/layout/service/SiteLayoutService.java +++ b/layout-service/src/main/java/io/meeds/layout/service/SiteLayoutService.java @@ -48,6 +48,8 @@ import org.exoplatform.services.resources.LocaleConfigService; import org.exoplatform.services.resources.LocaleContextInfo; +import io.meeds.layout.util.LayoutStyleValidator; + import lombok.SneakyThrows; @Service @@ -264,6 +266,7 @@ public void updateSiteLayout(SiteKey siteKey, siteKey, username)); } + LayoutStyleValidator.validate(site.getPortalLayout()); portalConfig.setPortalLayout(site.getPortalLayout()); try { if (publish) { diff --git a/layout-service/src/main/java/io/meeds/layout/util/EntityMapper.java b/layout-service/src/main/java/io/meeds/layout/util/EntityMapper.java index a2eea0aca..e9c102907 100644 --- a/layout-service/src/main/java/io/meeds/layout/util/EntityMapper.java +++ b/layout-service/src/main/java/io/meeds/layout/util/EntityMapper.java @@ -168,6 +168,7 @@ private static ModelStyle mapToStyle(LayoutModel layoutModel) { || StringUtils.isNotBlank(layoutModel.getTextColor()) || StringUtils.isNotBlank(layoutModel.getTextHeaderColor()) || StringUtils.isNotBlank(layoutModel.getTextSubtitleColor()) + || StringUtils.isNotBlank(layoutModel.getIconColor()) || StringUtils.isNotBlank(layoutModel.getTextTitleBackgroundColor()) || StringUtils.isNotBlank(layoutModel.getTextTitleBackgroundImage()) || StringUtils.isNotBlank(layoutModel.getTextHeaderBackgroundColor()) @@ -212,6 +213,7 @@ private static ModelStyle mapToStyle(LayoutModel layoutModel) { cssStyle.setTextSubtitleFontSize(layoutModel.getTextSubtitleFontSize()); cssStyle.setTextSubtitleFontWeight(layoutModel.getTextSubtitleFontWeight()); cssStyle.setTextSubtitleFontStyle(layoutModel.getTextSubtitleFontStyle()); + cssStyle.setIconColor(layoutModel.getIconColor()); cssStyle.setTextTitleBackgroundColor(layoutModel.getTextTitleBackgroundColor()); cssStyle.setTextTitleBackgroundImage(layoutModel.getTextTitleBackgroundImage()); cssStyle.setTextTitleBackgroundEffect(layoutModel.getTextTitleBackgroundEffect()); diff --git a/layout-service/src/main/java/io/meeds/layout/util/LayoutStyleValidator.java b/layout-service/src/main/java/io/meeds/layout/util/LayoutStyleValidator.java new file mode 100644 index 000000000..90f9dfd81 --- /dev/null +++ b/layout-service/src/main/java/io/meeds/layout/util/LayoutStyleValidator.java @@ -0,0 +1,134 @@ +/** + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package io.meeds.layout.util; + +import java.util.Arrays; +import java.util.regex.Pattern; + +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import org.exoplatform.portal.config.model.Container; +import org.exoplatform.portal.config.model.ModelObject; +import org.exoplatform.portal.config.model.ModelStyle; + +/** + * Validates the style values a page or a site layout carries before it is + * saved, on every editor save path (page layout and site layout alike). A + * refused value fails the save with an {@link IllegalArgumentException}. + */ +public final class LayoutStyleValidator { + + private static final Pattern GENERIC_STYLE_VALIDATOR = Pattern.compile("[#0-9a-zA-Z\\(\\),\\./\"'\\-%_ ]+"); + + /** + * The page, application and site icon colour is a hex colour only: no + * keyword, since "not set" at this level is the absent field, and no + * function, since the value is written by page editors and site editors. + */ + private static final Pattern ICON_COLOR_VALIDATOR = Pattern.compile("#[0-9a-fA-F]{3,8}"); + + /** + * A sticky site section (Topbar, Sidebar) stores its two on-scroll colours as + * one background value, "<top>@<middle>", which the renderer splits + * back; each half meets the generic check on its own. + */ + private static final String SCROLL_COLOR_SEPARATOR = "@"; + + private LayoutStyleValidator() { + // Static utility + } + + /** + * Validates the style values of the given layout object and of every + * descendant container. + * + * @param modelObject the page, site layout or container to validate, null + * accepted (nothing to validate) + * @throws IllegalArgumentException when a style value is refused + */ + public static void validate(ModelObject modelObject) { // NOSONAR + if (modelObject == null) { + return; + } + ModelStyle cssStyle = modelObject.getCssStyle(); + Arrays.asList(modelObject.getHeight(), + modelObject.getWidth(), + cssStyle == null ? null : cssStyle.getBorderColor(), + cssStyle == null ? null : cssStyle.getBorderSize(), + cssStyle == null ? null : cssStyle.getBoxShadow(), + cssStyle == null ? null : cssStyle.getBackgroundImage(), + cssStyle == null ? null : cssStyle.getBackgroundEffect(), + cssStyle == null ? null : cssStyle.getBackgroundPosition(), + cssStyle == null ? null : cssStyle.getBackgroundSize(), + cssStyle == null ? null : cssStyle.getBackgroundRepeat(), + cssStyle == null ? null : cssStyle.getTextTitleColor(), + cssStyle == null ? null : cssStyle.getTextTitleFontSize(), + cssStyle == null ? null : cssStyle.getTextTitleFontWeight(), + cssStyle == null ? null : cssStyle.getTextTitleFontStyle(), + cssStyle == null ? null : cssStyle.getTextColor(), + cssStyle == null ? null : cssStyle.getTextFontSize(), + cssStyle == null ? null : cssStyle.getTextFontWeight(), + cssStyle == null ? null : cssStyle.getTextFontStyle(), + cssStyle == null ? null : cssStyle.getTextHeaderColor(), + cssStyle == null ? null : cssStyle.getTextHeaderFontSize(), + cssStyle == null ? null : cssStyle.getTextHeaderFontWeight(), + cssStyle == null ? null : cssStyle.getTextHeaderFontStyle(), + cssStyle == null ? null : cssStyle.getTextSubtitleColor(), + cssStyle == null ? null : cssStyle.getTextSubtitleFontSize(), + cssStyle == null ? null : cssStyle.getTextSubtitleFontWeight(), + cssStyle == null ? null : cssStyle.getTextSubtitleFontStyle()) + .forEach(LayoutStyleValidator::validateStyleValue); + if (cssStyle != null) { + validateBackgroundColor(cssStyle.getBackgroundColor()); + validateIconColor(cssStyle.getIconColor()); + } + if (modelObject instanceof Container container && !CollectionUtils.isEmpty(container.getChildren())) { + container.getChildren().forEach(LayoutStyleValidator::validate); + } + } + + private static void validateStyleValue(String value) { + if (StringUtils.isNotBlank(value) + && (!GENERIC_STYLE_VALIDATOR.matcher(value).matches() + || value.contains("javascript") + || value.contains("eval"))) { + throw new IllegalArgumentException(String.format("Invalid css value input %s", value)); + } + } + + private static void validateBackgroundColor(String value) { + if (StringUtils.isNotBlank(value)) { + String[] colors = StringUtils.split(value, SCROLL_COLOR_SEPARATOR); + if (colors.length > 2) { + throw new IllegalArgumentException(String.format("Invalid css value input %s", value)); + } + for (String color : colors) { + validateStyleValue(color); + } + } + } + + private static void validateIconColor(String value) { + if (StringUtils.isNotBlank(value) && !ICON_COLOR_VALIDATOR.matcher(value).matches()) { + throw new IllegalArgumentException(String.format("Invalid icon color input %s", value)); + } + } + +} diff --git a/layout-service/src/test/java/io/meeds/layout/model/LayoutModelTest.java b/layout-service/src/test/java/io/meeds/layout/model/LayoutModelTest.java index 7e0d1bcd8..a33a1b922 100644 --- a/layout-service/src/test/java/io/meeds/layout/model/LayoutModelTest.java +++ b/layout-service/src/test/java/io/meeds/layout/model/LayoutModelTest.java @@ -19,14 +19,18 @@ package io.meeds.layout.model; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import org.junit.jupiter.api.Test; import org.exoplatform.portal.config.model.Application; +import org.exoplatform.portal.config.model.ModelObject; import org.exoplatform.portal.config.model.ModelStyle; import org.exoplatform.portal.config.model.TransientApplicationState; +import io.meeds.layout.util.EntityMapper; + /** * Legacy application margins are read once, server-side, from * the Vuetify spacing tokens still stored in the cssClass (value = N x 4 + 20 @@ -77,6 +81,35 @@ public void shouldReadPositiveAndBreakpointTokens() { assertNull(model.getCssClass()); } + @Test + public void shouldRoundTripIconColorThroughModelAndMapper() { + // eXIP 7.3.0.31: the page/app icon colour rides ModelStyle -> LayoutModel -> ModelStyle like the text colour + Application application = application(null, null, null, null, null); + application.getCssStyle().setTextColor("#20282C"); + application.getCssStyle().setIconColor("#AABBCC"); + + LayoutModel model = new LayoutModel(application); + assertEquals("#20282C", model.getTextColor()); + assertEquals("#AABBCC", model.getIconColor()); + + ModelObject mapped = EntityMapper.toModelObject(model); + assertNotNull(mapped.getCssStyle()); + assertEquals("#20282C", mapped.getCssStyle().getTextColor()); + assertEquals("#AABBCC", mapped.getCssStyle().getIconColor()); + } + + @Test + public void shouldMapStyleWhenIconColorIsTheOnlyStyledValue() { + // an application whose only custom value is the icon colour still gets a css-style, else the colour is dropped on save + LayoutModel model = new LayoutModel(application(null, null, null, null, null)); + model.setIconColor("#AABBCC"); + assertNull(EntityMapper.toModelObject(new LayoutModel(application(null, null, null, null, null))).getCssStyle()); + + ModelObject mapped = EntityMapper.toModelObject(model); + assertNotNull(mapped.getCssStyle()); + assertEquals("#AABBCC", mapped.getCssStyle().getIconColor()); + } + private Application application(Integer top, Integer bottom, Integer right, Integer left, String cssClass) { Application application = new Application("storageId"); application.setState(new TransientApplicationState("layout/Test")); diff --git a/layout-service/src/test/java/io/meeds/layout/rest/SiteLayoutRestTest.java b/layout-service/src/test/java/io/meeds/layout/rest/SiteLayoutRestTest.java index bb3a3f336..7b18f9510 100644 --- a/layout-service/src/test/java/io/meeds/layout/rest/SiteLayoutRestTest.java +++ b/layout-service/src/test/java/io/meeds/layout/rest/SiteLayoutRestTest.java @@ -392,6 +392,21 @@ void updateSiteLayoutWhenNotFound() { response.andExpect(status().isNotFound()); } + @Test + @SneakyThrows + void updateSiteLayoutWhenInvalidStyle() { + // a refused style value (the icon colour hex check, the generic css check) is a 400, never a 500 + doThrow(new IllegalArgumentException("Invalid icon color input red")).when(siteLayoutService) + .updateSiteLayout(eq(SITE_KEY), + any(), + anyBoolean(), + eq(SIMPLE_USER)); + ResultActions response = mockMvc.perform(put(LAYOUT_REST_PATH_WITH_PARAMS).content("{}") + .contentType(MediaType.APPLICATION_JSON) + .with(testSimpleUser())); + response.andExpect(status().isBadRequest()); + } + @Test @SneakyThrows void updateSiteLayoutWhenIllegalAccess() { diff --git a/layout-service/src/test/java/io/meeds/layout/service/PageLayoutServiceTest.java b/layout-service/src/test/java/io/meeds/layout/service/PageLayoutServiceTest.java index ad8688e69..b56630446 100644 --- a/layout-service/src/test/java/io/meeds/layout/service/PageLayoutServiceTest.java +++ b/layout-service/src/test/java/io/meeds/layout/service/PageLayoutServiceTest.java @@ -489,6 +489,22 @@ public void updatePageLayout() throws IllegalAccessException, ObjectNotFoundExce when(applicationCssStyle.getBorderColor()).thenReturn(borderColor); assertDoesNotThrow(() -> pageLayoutService.updatePageLayout(PAGE_KEY.format(), page, true, TEST_USER)); + + // eXIP 7.3.0.31: the icon colour passes a hex-only check, stricter than the generic character class + when(cssStyle.getIconColor()).thenReturn("rgb(1, 2, 3)"); + assertThrows(IllegalArgumentException.class, + () -> pageLayoutService.updatePageLayout(PAGE_KEY.format(), page, true, TEST_USER)); + when(cssStyle.getIconColor()).thenReturn("initial"); + assertThrows(IllegalArgumentException.class, + () -> pageLayoutService.updatePageLayout(PAGE_KEY.format(), page, true, TEST_USER)); + when(cssStyle.getIconColor()).thenReturn("#AABBCCDD"); + assertDoesNotThrow(() -> pageLayoutService.updatePageLayout(PAGE_KEY.format(), page, true, TEST_USER)); + + when(applicationCssStyle.getIconColor()).thenReturn("#GGHHII"); + assertThrows(IllegalArgumentException.class, + () -> pageLayoutService.updatePageLayout(PAGE_KEY.format(), page, true, TEST_USER)); + when(applicationCssStyle.getIconColor()).thenReturn("#abc"); + assertDoesNotThrow(() -> pageLayoutService.updatePageLayout(PAGE_KEY.format(), page, true, TEST_USER)); } @Test diff --git a/layout-service/src/test/java/io/meeds/layout/service/SiteLayoutServiceTest.java b/layout-service/src/test/java/io/meeds/layout/service/SiteLayoutServiceTest.java index 40a82552c..efe33acb7 100644 --- a/layout-service/src/test/java/io/meeds/layout/service/SiteLayoutServiceTest.java +++ b/layout-service/src/test/java/io/meeds/layout/service/SiteLayoutServiceTest.java @@ -31,6 +31,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Locale; @@ -47,7 +48,10 @@ import org.exoplatform.commons.exception.ObjectNotFoundException; import org.exoplatform.portal.config.UserPortalConfig; import org.exoplatform.portal.config.UserPortalConfigService; +import org.exoplatform.portal.config.model.Container; +import org.exoplatform.portal.config.model.ModelStyle; import org.exoplatform.portal.config.model.PortalConfig; +import org.exoplatform.portal.config.serialize.model.SiteLayout; import org.exoplatform.portal.mop.SiteKey; import org.exoplatform.portal.mop.importer.ImportMode; import org.exoplatform.portal.mop.service.LayoutService; @@ -250,6 +254,63 @@ public void restoreSite() { true); } + @Test + @SneakyThrows + public void updateSiteLayoutValidatesStyles() { + // eXIP 7.3.0.31: the site layout save path validates its style values like the page one, icon colour hex-only + PortalConfig site = mock(PortalConfig.class); + SiteLayout siteLayout = new SiteLayout(); + ModelStyle style = new ModelStyle(); + siteLayout.setCssStyle(style); + when(site.getPortalLayout()).thenReturn(siteLayout); + + when(layoutService.getPortalConfig(SITE_KEY)).thenReturn(portalConfig); + when(aclService.canEditSite(SITE_KEY, TEST_USER)).thenReturn(true); + + style.setTextColor("eval('alert(`XSS in site text CSS style`)')"); + assertThrows(IllegalArgumentException.class, + () -> siteLayoutService.updateSiteLayout(SITE_KEY, site, false, TEST_USER)); + style.setTextColor("#20282C"); + + style.setIconColor("rgb(1, 2, 3)"); + assertThrows(IllegalArgumentException.class, + () -> siteLayoutService.updateSiteLayout(SITE_KEY, site, false, TEST_USER)); + verify(layoutService, never()).save(any(PortalConfig.class)); + + style.setIconColor("#AABBCC"); + siteLayoutService.updateSiteLayout(SITE_KEY, site, false, TEST_USER); + verify(portalConfig).setPortalLayout(siteLayout); + verify(layoutService).save(portalConfig); + } + + @Test + @SneakyThrows + public void updateSiteLayoutAcceptsStickySectionScrollColors() { + // a sticky Topbar or Sidebar stores its two on-scroll colours as one "@" background value + PortalConfig site = mock(PortalConfig.class); + SiteLayout siteLayout = new SiteLayout(); + Container stickySection = new Container(); + ModelStyle style = new ModelStyle(); + stickySection.setCssStyle(style); + siteLayout.setChildren(new ArrayList<>(List.of(stickySection))); + when(site.getPortalLayout()).thenReturn(siteLayout); + + when(layoutService.getPortalConfig(SITE_KEY)).thenReturn(portalConfig); + when(aclService.canEditSite(SITE_KEY, TEST_USER)).thenReturn(true); + + style.setBackgroundColor("#FFF@eval(1)"); + assertThrows(IllegalArgumentException.class, + () -> siteLayoutService.updateSiteLayout(SITE_KEY, site, false, TEST_USER)); + style.setBackgroundColor("#FFFFFF@#20282C@#000000"); + assertThrows(IllegalArgumentException.class, + () -> siteLayoutService.updateSiteLayout(SITE_KEY, site, false, TEST_USER)); + verify(layoutService, never()).save(any(PortalConfig.class)); + + style.setBackgroundColor("#FFFFFF@#20282C"); + siteLayoutService.updateSiteLayout(SITE_KEY, site, false, TEST_USER); + verify(layoutService).save(portalConfig); + } + @Test @SneakyThrows public void deleteSite() { diff --git a/layout-webapp/src/main/webapp/vue-app/common-layout/components/drawer/EditApplicationDrawer.vue b/layout-webapp/src/main/webapp/vue-app/common-layout/components/drawer/EditApplicationDrawer.vue index 9d31d2868..f9630e6d0 100644 --- a/layout-webapp/src/main/webapp/vue-app/common-layout/components/drawer/EditApplicationDrawer.vue +++ b/layout-webapp/src/main/webapp/vue-app/common-layout/components/drawer/EditApplicationDrawer.vue @@ -78,6 +78,7 @@ ref="textInput" v-model="container" class="mt-4" + custom-icon @refresh="refresh++" />
@@ -268,6 +269,7 @@ export default { textSubtitleFontSize: this.container?.textSubtitleFontSize || null, textSubtitleFontWeight: this.container?.textSubtitleFontWeight || null, textSubtitleFontStyle: this.container?.textSubtitleFontStyle || null, + iconColor: this.container?.iconColor || null, textTitleBackgroundColor: this.container?.textTitleBackgroundColor || null, textTitleBackgroundImage: this.container?.textTitleBackgroundImage || null, textTitleBackgroundEffect: this.container?.textTitleBackgroundEffect || null, diff --git a/layout-webapp/src/main/webapp/vue-app/common-layout/components/drawer/EditPageDrawer.vue b/layout-webapp/src/main/webapp/vue-app/common-layout/components/drawer/EditPageDrawer.vue index dc782a350..d88687e46 100644 --- a/layout-webapp/src/main/webapp/vue-app/common-layout/components/drawer/EditPageDrawer.vue +++ b/layout-webapp/src/main/webapp/vue-app/common-layout/components/drawer/EditPageDrawer.vue @@ -167,6 +167,7 @@ ref="appTextInput" v-model="pageContainer" class="mt-4" + custom-icon page-style /> diff --git a/layout-webapp/src/main/webapp/vue-app/common-layout/js/LayoutUtils.js b/layout-webapp/src/main/webapp/vue-app/common-layout/js/LayoutUtils.js index 758d6aac6..7045a794f 100644 --- a/layout-webapp/src/main/webapp/vue-app/common-layout/js/LayoutUtils.js +++ b/layout-webapp/src/main/webapp/vue-app/common-layout/js/LayoutUtils.js @@ -115,6 +115,7 @@ export const containerModel = { textSubtitleFontSize: null, textSubtitleFontWeight: null, textSubtitleFontStyle: null, + iconColor: null, textTitleBackgroundColor: null, textTitleBackgroundImage: null, textTitleBackgroundEffect: null, @@ -209,6 +210,7 @@ export const applicationModel = { textSubtitleFontSize: null, textSubtitleFontWeight: null, textSubtitleFontStyle: null, + iconColor: null, textTitleBackgroundColor: null, textTitleBackgroundImage: null, textTitleBackgroundEffect: null, @@ -458,6 +460,7 @@ export function applyContainerStyle(container, containerStyle) { Vue.set(container, 'textSubtitleFontSize', containerStyle.textSubtitleFontSize || null); Vue.set(container, 'textSubtitleFontWeight', containerStyle.textSubtitleFontWeight || null); Vue.set(container, 'textSubtitleFontStyle', containerStyle.textSubtitleFontStyle || null); + Vue.set(container, 'iconColor', containerStyle.iconColor || null); Vue.set(container, 'textTitleBackgroundColor', containerStyle.textTitleBackgroundColor || null); Vue.set(container, 'textTitleBackgroundImage', containerStyle.textTitleBackgroundImage || null); Vue.set(container, 'textTitleBackgroundEffect', containerStyle.textTitleBackgroundEffect || null); diff --git a/layout-webapp/src/main/webapp/vue-app/common/js/ApplicationUtils.js b/layout-webapp/src/main/webapp/vue-app/common/js/ApplicationUtils.js index 4810860f3..40a0608fe 100644 --- a/layout-webapp/src/main/webapp/vue-app/common/js/ApplicationUtils.js +++ b/layout-webapp/src/main/webapp/vue-app/common/js/ApplicationUtils.js @@ -120,6 +120,9 @@ export function getStyle(container, options) { if (container.textColor) { style['--appTextColor'] = container.textColor; } + if (container.iconColor) { + style['--appIconColor'] = container.iconColor; + } if (container.textFontSize) { style['--appTextFontSize'] = container.textFontSize; } diff --git a/layout-webapp/src/main/webapp/vue-app/site-layout-editor/components/drawer/EditApplicationDrawer.vue b/layout-webapp/src/main/webapp/vue-app/site-layout-editor/components/drawer/EditApplicationDrawer.vue index 3bb997e5a..430146904 100644 --- a/layout-webapp/src/main/webapp/vue-app/site-layout-editor/components/drawer/EditApplicationDrawer.vue +++ b/layout-webapp/src/main/webapp/vue-app/site-layout-editor/components/drawer/EditApplicationDrawer.vue @@ -82,6 +82,7 @@ ref="textInput" v-model="container" class="mt-4" + custom-icon @refresh="refresh++" />
@@ -404,6 +405,7 @@ export default { textSubtitleFontSize: this.container?.textSubtitleFontSize || null, textSubtitleFontWeight: this.container?.textSubtitleFontWeight || null, textSubtitleFontStyle: this.container?.textSubtitleFontStyle || null, + iconColor: this.container?.iconColor || null, textTitleBackgroundColor: this.container?.textTitleBackgroundColor || null, textTitleBackgroundImage: this.container?.textTitleBackgroundImage || null, textTitleBackgroundEffect: this.container?.textTitleBackgroundEffect || null, diff --git a/layout-webapp/src/main/webapp/vue-app/site-layout-editor/components/drawer/EditSiteBannerDrawer.vue b/layout-webapp/src/main/webapp/vue-app/site-layout-editor/components/drawer/EditSiteBannerDrawer.vue index 26629288e..a0139b167 100644 --- a/layout-webapp/src/main/webapp/vue-app/site-layout-editor/components/drawer/EditSiteBannerDrawer.vue +++ b/layout-webapp/src/main/webapp/vue-app/site-layout-editor/components/drawer/EditSiteBannerDrawer.vue @@ -172,6 +172,7 @@ ref="textInput" v-model="container" class="mb-2" + custom-icon text-bold /> Date: Tue, 6 Oct 2026 11:37:59 +0100 Subject: [PATCH 2/4] feat: Site navigation visibility icons follow the configured icon colour EXO-90340 (#577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Why** eXIP 7.3.0.31 US07 (EXO-90340): every icon honours the configured icon colour. The site-navigation icons that still carried a neutral hard-coded colour are rebased on platform-ui's `@iconColor` chain, `var(--appIconColor, var(--allPagesBaseIconColor, ))`, so they follow the container keys of US02 and US03 and keep the platform grey when none is set. **How** - `NodeItem.vue`: the visibility and access icons lose `color="grey"` and `dark` (which would have painted them white). Left as they are: `disabled--text` on system items, success, error, white and primary icons. Verified: Less compiles and the war builds; deployed on a local server. Knowledge: owed at the `feature/mips` integration PR — the US07 audit's two lessons (a text helper class painting an icon outside the chain, an SVG image used as an icon) go to the eng-standards PR the delivery owes. This change is classified N3 at c66a3cb29f: no REST/DAO/schema/ACL surface touched (`classification.md` §3), stylesheet and template changes only; the delivery is N1 by max-severity at the `feature/mips` integration PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5.1 (cherry picked from commit 2278e4b99ea9c14e61e74f77f937db85f05253c8) --- .../common-sites/components/site-navigation/NodeItem.vue | 4 ---- 1 file changed, 4 deletions(-) diff --git a/layout-webapp/src/main/webapp/vue-app/common-sites/components/site-navigation/NodeItem.vue b/layout-webapp/src/main/webapp/vue-app/common-sites/components/site-navigation/NodeItem.vue index 4b0fd7b16..c8c1c9b02 100644 --- a/layout-webapp/src/main/webapp/vue-app/common-sites/components/site-navigation/NodeItem.vue +++ b/layout-webapp/src/main/webapp/vue-app/common-sites/components/site-navigation/NodeItem.vue @@ -113,8 +113,6 @@ {{ visibilityIcon.icon }} @@ -129,8 +127,6 @@ {{ accessIcon.icon }} From 31911763ffecc9a123df049bbc538b13aeed2587 Mon Sep 17 00:00:00 2001 From: MayTekayaa <100131287+MayTekayaa@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:26:48 +0100 Subject: [PATCH 3/4] fix: Icon row placeholder shows the colour the page or application inherits EXO-90337 (#578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Symptom** With a Page & Apps icon colour set in Branding, the Edit Page drawer's Icon row shows its dimmed `Inherited` swatch in the built-in grey, not the configured colour; the application editor shows the grey too once the page stores an icon colour (EXO-90337, PO feedback on US04). **Cause** The shared text input (Meeds-io/social, `styling-inputs/TextInput.vue`) passed the built-in `#707070` as the Icon row's placeholder at every level, by design of note 51586 §4 (D6). The PO ruled on EXO-90337 that the page-level row shows the platform's customised colour and the application-level row shows the page's; nothing is stored until the user picks (D6 kept). The editors never resolved that colour. **Fix** - `ApplicationUtils.getThemeColor(name)`: the value of a branding theme variable as computed at the document root, null when the branding stylesheet emits `initial`. - `EditPageDrawer.vue`: the platform's `--allPagesAppIconColor`. - `common-layout/EditApplicationDrawer.vue`: the page root container's stored icon colour, else the platform's. - The site layout editor drawers are unchanged and keep the built-in grey. Known limit, left to the PO: a colour the site stores on its own page area (the site layout editor's page properties, `middleCenterContainer`) is inherited by every page of the site at view time but is not read by the page editor, which renders the page alone, outside the site layout that carries that colour, and reads the theme at the document root; the page and application placeholders then show the platform's colour, and covering it needs a fetch of the site layout at open. Depends on the `iconPlaceholder` prop of the shared text input in Meeds-io/social, same task id. Verified with eslint on the changed files and a headless Chrome check of `getThemeColor`: an `initial` custom property computes to the empty string, a set one and `transparent` come back as written. No committed JS test (frontend-vue.md). Knowledge: owed by the eXIP's knowledge update (EXO-90341), gated at the `feature/mips` integration PR. This change is classified N3 at a7b90cde: editor-side placeholder only, no REST/DAO/schema/ACL surface touched (`classification.md` §3); the delivery is N1 by max-severity at the `feature/mips` integration PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5.1 (cherry picked from commit 116aaccae4f8247d6199d8b500688a3f2d6799c1) --- .../components/drawer/EditApplicationDrawer.vue | 6 ++++++ .../components/drawer/EditPageDrawer.vue | 6 ++++++ .../webapp/vue-app/common/js/ApplicationUtils.js | 12 ++++++++++++ 3 files changed, 24 insertions(+) diff --git a/layout-webapp/src/main/webapp/vue-app/common-layout/components/drawer/EditApplicationDrawer.vue b/layout-webapp/src/main/webapp/vue-app/common-layout/components/drawer/EditApplicationDrawer.vue index f9630e6d0..c18cca322 100644 --- a/layout-webapp/src/main/webapp/vue-app/common-layout/components/drawer/EditApplicationDrawer.vue +++ b/layout-webapp/src/main/webapp/vue-app/common-layout/components/drawer/EditApplicationDrawer.vue @@ -77,6 +77,7 @@ v-if="initialized" ref="textInput" v-model="container" + :icon-placeholder="inheritedIconColor" class="mt-4" custom-icon @refresh="refresh++" /> @@ -178,6 +179,7 @@