From 68d0e01f2f1f68a4123bfa17d2b23cecd2bd1535 Mon Sep 17 00:00:00 2001 From: "daniel.solis" Date: Thu, 16 Apr 2026 14:09:54 -0600 Subject: [PATCH 01/13] fix(experiments): support /cmsHomePage vanity for experiment URL matching (#34747) VanityUrlAPIImpl.resolveVanityUrl has a legacy fallback that forwards a "/" request to the /cmsHomePage vanity. ExperimentUrlPatternCalculator did not account for this, so the generated regex only matched /cmsHomePage (not /) and variants were never served when the experiment page was reached via that fallback. Adds an integration test covering the /cmsHomePage scenario and removes an unused /cmsHomePage constant from VisitorFilter. Refs: #34747 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ExperimentUrlPatternCalculator.java | 22 ++++++-- .../visitor/filter/servlet/VisitorFilter.java | 2 - ...ntUrlPatternCalculatorIntegrationTest.java | 55 +++++++++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java index 8c4c7e12da4e..a3312277597e 100644 --- a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java +++ b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java @@ -7,6 +7,7 @@ import com.dotcms.analytics.metrics.*; import com.dotcms.experiments.model.Experiment; +import com.dotcms.vanityurl.business.VanityUrlAPIImpl; import com.dotcms.vanityurl.model.CachedVanityUrl; import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; @@ -21,6 +22,7 @@ import java.util.List; import java.util.Optional; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Util class to calculate the regex pattern for a given {@link HTMLPageAsset} @@ -80,11 +82,23 @@ public String calculatePageUrlRegexPattern(final Experiment experiment) { private static String getVanityUrlsRegex(final Host host, final Language language, final HTMLPageAsset htmlPageAsset) throws DotDataException { - final String vanityUrlRegex = APILocator.getVanityUrlAPI() - .findByForward(host, language, htmlPageAsset.getURI(), 200) - .stream() - .map(vanitysUrls -> String.format(DEFAULT_URL_REGEX_TEMPLATE, vanitysUrls.pattern)) + final List vanityUrls = APILocator.getVanityUrlAPI() + .findByForward(host, language, htmlPageAsset.getURI(), 200); + + final Stream vanityPatterns = vanityUrls.stream() + .map(vanity -> String.format(DEFAULT_URL_REGEX_TEMPLATE, vanity.pattern)); + + // A /cmsHomePage vanity is reached when a visitor requests "/" (see + // VanityUrlAPIImpl.resolveVanityUrl legacy fallback), so the browser URL + // at the experiment page stays "/" — add it as an extra alternative. + final Stream cmsHomePageFallback = vanityUrls.stream() + .anyMatch(vanity -> VanityUrlAPIImpl.LEGACY_CMS_HOME_PAGE.equals(vanity.url)) + ? Stream.of(String.format(DEFAULT_URL_REGEX_TEMPLATE, "\\/?")) + : Stream.empty(); + + final String vanityUrlRegex = Stream.concat(vanityPatterns, cmsHomePageFallback) .collect(Collectors.joining(StringPool.PIPE)); + return vanityUrlRegex.isEmpty() ? StringPool.BLANK : String.format("^%s$", vanityUrlRegex); } diff --git a/dotCMS/src/main/java/com/dotcms/visitor/filter/servlet/VisitorFilter.java b/dotCMS/src/main/java/com/dotcms/visitor/filter/servlet/VisitorFilter.java index 8b89354958f2..fa6555867804 100644 --- a/dotCMS/src/main/java/com/dotcms/visitor/filter/servlet/VisitorFilter.java +++ b/dotCMS/src/main/java/com/dotcms/visitor/filter/servlet/VisitorFilter.java @@ -19,7 +19,6 @@ import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.filters.CMSUrlUtil; -import com.dotmarketing.logConsole.model.LogMapper; import com.dotmarketing.portlets.languagesmanager.model.Language; import com.dotmarketing.util.Logger; import java.io.IOException; @@ -45,7 +44,6 @@ public class VisitorFilter implements Filter { private final LanguageWebAPI languageWebAPI; private final UserWebAPI userWebAPI; - private final static String CMS_HOME_PAGE = "/cmsHomePage"; public final static String VANITY_URL_ATTRIBUTE="VANITY_URL_ATTRIBUTE"; public final static String DOTPAGE_PROCESSING_TIME="DOTPAGE_PROCESSING_TIME"; diff --git a/dotcms-integration/src/test/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculatorIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculatorIntegrationTest.java index 0118b48145e9..a6837c1a38e8 100644 --- a/dotcms-integration/src/test/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculatorIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculatorIntegrationTest.java @@ -303,6 +303,61 @@ public void experimentWithVanityUrl() throws DotDataException { } + /** + * Method to test: {@link ExperimentUrlPatternCalculator#calculatePageUrlRegexPattern(Experiment)} + * When: A Published Vanity Url with URI "/cmsHomePage" and action 200 forwards to + * the Experiment's Page. Per the legacy fallback in + * {@link com.dotcms.vanityurl.business.VanityUrlAPIImpl#resolveVanityUrl}, a visitor + * requesting "/" is transparently forwarded to the target page, so the browser URL + * stays "/". + * Should: The regex returned by the method should match the Experiment Page URL, + * the "/cmsHomePage" URL, and the root URL "/". + * + * See issue https://github.com/dotCMS/core/issues/34747 + * + * @throws DotDataException + */ + @Test + public void experimentWithCmsHomePageVanity() throws DotDataException { + + final Host host = new SiteDataGen().nextPersisted(); + final Template template = new TemplateDataGen().host(host).nextPersisted(); + + final HTMLPageAsset experimentPage = new HTMLPageDataGen(host, template).nextPersisted(); + + final Condition condition = Condition.builder() + .parameter("url") + .value("testing") + .operator(AbstractCondition.Operator.CONTAINS) + .build(); + + final Metric metric = Metric.builder() + .name("Testing Metric") + .type(MetricType.REACH_PAGE) + .addConditions(condition).build(); + + final Goals goal = Goals.builder().primary(GoalFactory.create(metric)).build(); + final Experiment experiment = new ExperimentDataGen() + .page(experimentPage) + .addGoal(goal) + .nextPersisted(); + + final Contentlet vanityUrl = new VanityUrlDataGen() + .uri("/cmsHomePage") + .forwardTo(experimentPage.getURI()) + .action(200) + .host(host) + .languageId(experimentPage.getLanguageId()) + .nextPersistedAndPublish(); + + final String regex = ExperimentUrlPatternCalculator.INSTANCE.calculatePageUrlRegexPattern(experiment); + + assertTrue(("http://localhost:8080/" + experimentPage.getPageUrl()).matches(regex)); + assertTrue(("http://localhost:8080/cmsHomePage").matches(regex)); + assertTrue(("http://localhost:8080/").matches(regex)); + assertTrue(("http://localhost:8080").matches(regex)); + } + /** * Method to test: {@link ExperimentUrlPatternCalculator#calculatePageUrlRegexPattern(Experiment)} * When: Exists a Published Vanity Url with the forwardTo equals to the URI og the Experiment's Page but with not 200 action From e7f08ced4a8bad0a8af539f29cf9c882698a42a8 Mon Sep 17 00:00:00 2001 From: "daniel.solis" Date: Thu, 16 Apr 2026 14:24:36 -0600 Subject: [PATCH 02/13] refactor(vanity-url): move LEGACY_CMS_HOME_PAGE to VanityUrlAPI interface Addresses PR review feedback: the experiments package was reaching into VanityUrlAPIImpl for the LEGACY_CMS_HOME_PAGE constant, leaking an impl detail across module boundaries. Moves the constant to the VanityUrlAPI interface (matching the existing VANITY_URL_RESPONSE_HEADER pattern) and updates callers. Refs: #34747 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../business/ExperimentUrlPatternCalculator.java | 4 ++-- .../java/com/dotcms/vanityurl/business/VanityUrlAPI.java | 7 +++++++ .../com/dotcms/vanityurl/business/VanityUrlAPIImpl.java | 1 - .../test/java/com/dotmarketing/filters/FiltersTest.java | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java index a3312277597e..12c42fa79a7d 100644 --- a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java +++ b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java @@ -7,7 +7,7 @@ import com.dotcms.analytics.metrics.*; import com.dotcms.experiments.model.Experiment; -import com.dotcms.vanityurl.business.VanityUrlAPIImpl; +import com.dotcms.vanityurl.business.VanityUrlAPI; import com.dotcms.vanityurl.model.CachedVanityUrl; import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; @@ -92,7 +92,7 @@ private static String getVanityUrlsRegex(final Host host, final Language languag // VanityUrlAPIImpl.resolveVanityUrl legacy fallback), so the browser URL // at the experiment page stays "/" — add it as an extra alternative. final Stream cmsHomePageFallback = vanityUrls.stream() - .anyMatch(vanity -> VanityUrlAPIImpl.LEGACY_CMS_HOME_PAGE.equals(vanity.url)) + .anyMatch(vanity -> VanityUrlAPI.LEGACY_CMS_HOME_PAGE.equals(vanity.url)) ? Stream.of(String.format(DEFAULT_URL_REGEX_TEMPLATE, "\\/?")) : Stream.empty(); diff --git a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java index 47ce20e02ad2..7fdcdebd8fa5 100644 --- a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java +++ b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java @@ -28,6 +28,13 @@ public interface VanityUrlAPI { String VANITY_URL_RESPONSE_HEADER = "X-DOT-VanityUrl"; + /** + * Legacy Vanity URL URI used as the fallback home page. When the incoming + * request path is "/" and no other Vanity URL matches, implementations + * resolve this URI instead to support the historical cmsHomePage behavior. + */ + String LEGACY_CMS_HOME_PAGE = "/cmsHomePage"; + /** * Verifies that the Vanity URL as Contentlet has all the required fields. the list of mandatory fields can be * verified in the Content Type's definition. diff --git a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java index cd5fb0661a9c..26a295442636 100644 --- a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java @@ -69,7 +69,6 @@ public class VanityUrlAPIImpl implements VanityUrlAPI { + " (select velocity_var_name from structure where structuretype=7)"; - public static final String LEGACY_CMS_HOME_PAGE = "/cmsHomePage"; private final ContentletAPI contentletAPI; private final VanityUrlCache cache; private final LanguageAPI languageAPI; diff --git a/dotcms-integration/src/test/java/com/dotmarketing/filters/FiltersTest.java b/dotcms-integration/src/test/java/com/dotmarketing/filters/FiltersTest.java index 3e91960e3113..a8873568e93a 100644 --- a/dotcms-integration/src/test/java/com/dotmarketing/filters/FiltersTest.java +++ b/dotcms-integration/src/test/java/com/dotmarketing/filters/FiltersTest.java @@ -1,7 +1,7 @@ package com.dotmarketing.filters; import static com.dotcms.datagen.TestDataUtils.getNewsLikeContentType; -import static com.dotcms.vanityurl.business.VanityUrlAPIImpl.LEGACY_CMS_HOME_PAGE; +import static com.dotcms.vanityurl.business.VanityUrlAPI.LEGACY_CMS_HOME_PAGE; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.startsWith; From 40507af95f124eb63d633aa862bdbeaac0cbb49f Mon Sep 17 00:00:00 2001 From: "daniel.solis" Date: Thu, 16 Apr 2026 15:42:57 -0600 Subject: [PATCH 03/13] refactor(experiments): use Pattern.pattern() explicitly for regex source Addresses PR review feedback: passing a java.util.regex.Pattern to String.format("%s", ...) relies on implicit Pattern.toString(). Switch to vanity.pattern.pattern() so the intent (extract the regex source string) is explicit. Semantically a no-op. Refs: #34747 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../experiments/business/ExperimentUrlPatternCalculator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java index 12c42fa79a7d..bcefb704114f 100644 --- a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java +++ b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java @@ -86,7 +86,7 @@ private static String getVanityUrlsRegex(final Host host, final Language languag .findByForward(host, language, htmlPageAsset.getURI(), 200); final Stream vanityPatterns = vanityUrls.stream() - .map(vanity -> String.format(DEFAULT_URL_REGEX_TEMPLATE, vanity.pattern)); + .map(vanity -> String.format(DEFAULT_URL_REGEX_TEMPLATE, vanity.pattern.pattern())); // A /cmsHomePage vanity is reached when a visitor requests "/" (see // VanityUrlAPIImpl.resolveVanityUrl legacy fallback), so the browser URL From b8e0a1c60f289349155177f65113f0a8da2dd4e0 Mon Sep 17 00:00:00 2001 From: "daniel.solis" Date: Thu, 16 Apr 2026 15:54:49 -0600 Subject: [PATCH 04/13] refactor(experiments): inline stream variables in getVanityUrlsRegex Addresses PR review feedback: streams are single-use and lazy; storing them in named locals invites misuse. Inline both streams directly into Stream.concat(...) since each is consumed exactly once. Semantically a no-op. Refs: #34747 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ExperimentUrlPatternCalculator.java | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java index bcefb704114f..616da08da5ea 100644 --- a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java +++ b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java @@ -85,19 +85,17 @@ private static String getVanityUrlsRegex(final Host host, final Language languag final List vanityUrls = APILocator.getVanityUrlAPI() .findByForward(host, language, htmlPageAsset.getURI(), 200); - final Stream vanityPatterns = vanityUrls.stream() - .map(vanity -> String.format(DEFAULT_URL_REGEX_TEMPLATE, vanity.pattern.pattern())); - - // A /cmsHomePage vanity is reached when a visitor requests "/" (see - // VanityUrlAPIImpl.resolveVanityUrl legacy fallback), so the browser URL - // at the experiment page stays "/" — add it as an extra alternative. - final Stream cmsHomePageFallback = vanityUrls.stream() - .anyMatch(vanity -> VanityUrlAPI.LEGACY_CMS_HOME_PAGE.equals(vanity.url)) - ? Stream.of(String.format(DEFAULT_URL_REGEX_TEMPLATE, "\\/?")) - : Stream.empty(); - - final String vanityUrlRegex = Stream.concat(vanityPatterns, cmsHomePageFallback) - .collect(Collectors.joining(StringPool.PIPE)); + // When a /cmsHomePage vanity forwards to the experiment page, visitors + // reach it at "/" (see VanityUrlAPIImpl.resolveVanityUrl legacy fallback) + // — add "/" as an extra alternative so the regex still matches. + final String vanityUrlRegex = Stream.concat( + vanityUrls.stream() + .map(vanity -> String.format(DEFAULT_URL_REGEX_TEMPLATE, vanity.pattern.pattern())), + vanityUrls.stream() + .anyMatch(vanity -> VanityUrlAPI.LEGACY_CMS_HOME_PAGE.equals(vanity.url)) + ? Stream.of(String.format(DEFAULT_URL_REGEX_TEMPLATE, "\\/?")) + : Stream.empty() + ).collect(Collectors.joining(StringPool.PIPE)); return vanityUrlRegex.isEmpty() ? StringPool.BLANK : String.format("^%s$", vanityUrlRegex); } From 67dbb037325d5f2e15c91df09e4fa24e98927bba Mon Sep 17 00:00:00 2001 From: "daniel.solis" Date: Fri, 17 Apr 2026 08:53:27 -0600 Subject: [PATCH 05/13] fix(vanity-url): include SYSTEM_HOST matches in findByForward findByForward previously only searched the given host, unlike resolveVanityUrl which falls back to SYSTEM_HOST. As a result, a /cmsHomePage vanity published on SYSTEM_HOST (the recommended setup for cross-site home-page aliasing) would not be picked up by the Experiment URL regex calculator, and experiments on the target page would still be skipped for visitors arriving at "/". Updates findByForward to also collect vanities from SYSTEM_HOST, mirroring resolveVanityUrl's host resolution. Adds an integration test covering the SYSTEM_HOST + /cmsHomePage scenario. Refs: #34747 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../vanityurl/business/VanityUrlAPI.java | 13 +++-- .../vanityurl/business/VanityUrlAPIImpl.java | 12 +++- ...ntUrlPatternCalculatorIntegrationTest.java | 55 +++++++++++++++++++ 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java index 7fdcdebd8fa5..3f59f06bde42 100644 --- a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java +++ b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java @@ -116,13 +116,18 @@ boolean handleVanityURLRedirects(VanityUrlRequestWrapper request, HttpServletRes /** - * Look all the {@link VanityUrl} that are equals to forward + * Look up all published {@link VanityUrl}s whose {@code forwardTo} matches the + * given {@code forward} and whose action equals {@code action}. + * + *

Mirrors the host resolution semantics of {@link #resolveVanityUrl}: + * matches are collected both from the specified host and from {@code SYSTEM_HOST}, + * since vanities published on {@code SYSTEM_HOST} apply across all sites. * * @param host {@link VanityUrl}'s Host * @param language {@link VanityUrl}'s Language - * @param forward forward to look for - * @param language action to look for - * @return + * @param forward forward target to look for + * @param action HTTP action code to look for (e.g. 200, 301, 302) + * @return the matching {@link CachedVanityUrl}s from the given host and from SYSTEM_HOST */ List findByForward(Host host, Language language, String forward, int action); diff --git a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java index 26a295442636..c5be3878ae8e 100644 --- a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java @@ -46,6 +46,7 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Implementation class for the {@link VanityUrlAPI}. @@ -455,8 +456,15 @@ private String encodeRedirectURL(final String uri) { @CloseDBIfOpened public List findByForward(final Host host, final Language language, final String forward, int action) { - return load(host, language) - .stream() + // Mirror resolveVanityUrl's SYSTEM_HOST fallback: vanities published on + // SYSTEM_HOST apply across all sites, so include them alongside the + // host-specific matches. + final Host systemHost = APILocator.systemHost(); + final Stream systemHostVanities = systemHost.equals(host) + ? Stream.empty() + : load(systemHost, language).stream(); + + return Stream.concat(load(host, language).stream(), systemHostVanities) .filter(cachedVanityUrl -> cachedVanityUrl.response == action) .filter(cachedVanityUrl -> cachedVanityUrl.forwardTo.equals(forward)) .collect(Collectors.toList()); diff --git a/dotcms-integration/src/test/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculatorIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculatorIntegrationTest.java index a6837c1a38e8..c2f027bf8294 100644 --- a/dotcms-integration/src/test/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculatorIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculatorIntegrationTest.java @@ -8,6 +8,7 @@ import com.dotcms.util.IntegrationTestInitService; import com.dotcms.vanityurl.model.VanityUrl; import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.htmlpageasset.model.HTMLPageAsset; @@ -358,6 +359,60 @@ public void experimentWithCmsHomePageVanity() throws DotDataException { assertTrue(("http://localhost:8080").matches(regex)); } + /** + * Method to test: {@link ExperimentUrlPatternCalculator#calculatePageUrlRegexPattern(Experiment)} + * When: A Published Vanity Url with URI "/cmsHomePage" and action 200 is published + * on SYSTEM_HOST (rather than on the experiment page's host) and forwards to the + * Experiment's Page. Vanities on SYSTEM_HOST apply site-wide, so the "/" fallback + * still applies. + * Should: The regex returned by the method should match the Experiment Page URL + * and the root URL "/". + * + * See issue https://github.com/dotCMS/core/issues/34747 + * + * @throws DotDataException + */ + @Test + public void experimentWithSystemHostCmsHomePageVanity() throws DotDataException { + + final Host host = new SiteDataGen().nextPersisted(); + final Template template = new TemplateDataGen().host(host).nextPersisted(); + + final HTMLPageAsset experimentPage = new HTMLPageDataGen(host, template).nextPersisted(); + + final Condition condition = Condition.builder() + .parameter("url") + .value("testing") + .operator(AbstractCondition.Operator.CONTAINS) + .build(); + + final Metric metric = Metric.builder() + .name("Testing Metric") + .type(MetricType.REACH_PAGE) + .addConditions(condition).build(); + + final Goals goal = Goals.builder().primary(GoalFactory.create(metric)).build(); + final Experiment experiment = new ExperimentDataGen() + .page(experimentPage) + .addGoal(goal) + .nextPersisted(); + + final Contentlet vanityUrl = new VanityUrlDataGen() + .uri("/cmsHomePage") + .forwardTo(experimentPage.getURI()) + .action(200) + .host(APILocator.systemHost()) + .languageId(experimentPage.getLanguageId()) + .nextPersistedAndPublish(); + + final String regex = ExperimentUrlPatternCalculator.INSTANCE.calculatePageUrlRegexPattern(experiment); + + assertTrue(("http://localhost:8080/" + experimentPage.getPageUrl()).matches(regex)); + assertTrue(("http://localhost:8080/cmsHomePage").matches(regex)); + assertTrue(("http://localhost:8080/").matches(regex)); + assertTrue(("http://localhost:8080").matches(regex)); + } + /** * Method to test: {@link ExperimentUrlPatternCalculator#calculatePageUrlRegexPattern(Experiment)} * When: Exists a Published Vanity Url with the forwardTo equals to the URI og the Experiment's Page but with not 200 action From e4ba14c8b39dc1f69b7a260314cd6858d3a02a91 Mon Sep 17 00:00:00 2001 From: "daniel.solis" Date: Fri, 17 Apr 2026 08:59:23 -0600 Subject: [PATCH 06/13] fix(experiments): match /cmsHomePage vanity case-insensitively CachedVanityUrl compiles each vanity's URI with Pattern.CASE_INSENSITIVE, so VanityUrlAPIImpl.resolveVanityUrl triggers the /cmsHomePage legacy fallback even when an admin created the vanity with a different casing (/cmshomepage, /CmsHomePage, etc.). The previous equals() check missed those variants and the "/" regex fallback would not be added. Refs: #34747 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../experiments/business/ExperimentUrlPatternCalculator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java index 616da08da5ea..6f7054132bfb 100644 --- a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java +++ b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java @@ -92,7 +92,7 @@ private static String getVanityUrlsRegex(final Host host, final Language languag vanityUrls.stream() .map(vanity -> String.format(DEFAULT_URL_REGEX_TEMPLATE, vanity.pattern.pattern())), vanityUrls.stream() - .anyMatch(vanity -> VanityUrlAPI.LEGACY_CMS_HOME_PAGE.equals(vanity.url)) + .anyMatch(vanity -> VanityUrlAPI.LEGACY_CMS_HOME_PAGE.equalsIgnoreCase(vanity.url)) ? Stream.of(String.format(DEFAULT_URL_REGEX_TEMPLATE, "\\/?")) : Stream.empty() ).collect(Collectors.joining(StringPool.PIPE)); From fe7cf2bbf44df923739fe95e29996fd43db62e6e Mon Sep 17 00:00:00 2001 From: "daniel.solis" Date: Fri, 17 Apr 2026 14:34:34 -0600 Subject: [PATCH 07/13] fix(experiments): lowercase vanity URL regex for case-insensitive matching The server-assembled vanity-URL regex preserved the admin-entered casing of each vanity URI, but the SDK tracker (parser.ts verifyRegex) lowercases the incoming URL path before matching. A mixed-case vanity URI such as /cmsHomePage therefore never matched the lowercased client path, even though server-side vanity resolution itself is case-insensitive (CachedVanityUrl compiles patterns with Pattern.CASE_INSENSITIVE). Mirrors the toLowerCase() already applied to the experiment page regex in calculatePageUrlRegexPattern. Tests updated to use lowercase URLs that reflect real SDK matching. Refs: #34747 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../business/ExperimentUrlPatternCalculator.java | 7 ++++++- .../ExperimentUrlPatternCalculatorIntegrationTest.java | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java index 6f7054132bfb..14300e011b3d 100644 --- a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java +++ b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java @@ -97,7 +97,12 @@ private static String getVanityUrlsRegex(final Host host, final Language languag : Stream.empty() ).collect(Collectors.joining(StringPool.PIPE)); - return vanityUrlRegex.isEmpty() ? StringPool.BLANK : String.format("^%s$", vanityUrlRegex); + // Lowercase the assembled vanity regex for parity with the experiment + // page regex (see calculatePageUrlRegexPattern) and to align with the + // SDK's verifyRegex, which lowercases the incoming URL path before + // matching. Without this, a mixed-case vanity URI stored by the admin + // would never match the lowercased client-side path. + return vanityUrlRegex.isEmpty() ? StringPool.BLANK : String.format("^%s$", vanityUrlRegex).toLowerCase(); } private HTMLPageAsset getHtmlPageAsset(final Experiment experiment) { diff --git a/dotcms-integration/src/test/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculatorIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculatorIntegrationTest.java index c2f027bf8294..f3201f583a00 100644 --- a/dotcms-integration/src/test/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculatorIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculatorIntegrationTest.java @@ -353,8 +353,11 @@ public void experimentWithCmsHomePageVanity() throws DotDataException { final String regex = ExperimentUrlPatternCalculator.INSTANCE.calculatePageUrlRegexPattern(experiment); + // The SDK lowercases incoming URL paths before matching (see + // verifyRegex in parser.ts), and the server lowercases the assembled + // regex, so test with lowercased URLs that mirror runtime behavior. assertTrue(("http://localhost:8080/" + experimentPage.getPageUrl()).matches(regex)); - assertTrue(("http://localhost:8080/cmsHomePage").matches(regex)); + assertTrue(("http://localhost:8080/cmshomepage").matches(regex)); assertTrue(("http://localhost:8080/").matches(regex)); assertTrue(("http://localhost:8080").matches(regex)); } @@ -408,7 +411,7 @@ public void experimentWithSystemHostCmsHomePageVanity() throws DotDataException final String regex = ExperimentUrlPatternCalculator.INSTANCE.calculatePageUrlRegexPattern(experiment); assertTrue(("http://localhost:8080/" + experimentPage.getPageUrl()).matches(regex)); - assertTrue(("http://localhost:8080/cmsHomePage").matches(regex)); + assertTrue(("http://localhost:8080/cmshomepage").matches(regex)); assertTrue(("http://localhost:8080/").matches(regex)); assertTrue(("http://localhost:8080").matches(regex)); } From 565ef28363d7b1af19c46848dad8ad1cb2149474 Mon Sep 17 00:00:00 2001 From: "daniel.solis" Date: Fri, 17 Apr 2026 15:50:13 -0600 Subject: [PATCH 08/13] =?UTF-8?q?docs(experiments,vanity-url):=20address?= =?UTF-8?q?=20review=20=E2=80=94=20ReDoS=20note,=20boolean=20extract,=20AP?= =?UTF-8?q?I=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExperimentUrlPatternCalculator.calculatePageUrlRegexPattern: Javadoc note that the returned regex is consumed client-side by the Analytics SDK and is NOT protected by MatcherTimeoutFactory; follow-up tracked in #35379. - ExperimentUrlPatternCalculator.getVanityUrlsRegex: extract the /cmsHomePage-detection anyMatch into a named boolean so vanityUrls is no longer traversed twice inside Stream.concat. Adds a comment clarifying that the exact-match check is intentional — regex-based cmsHomePage URIs are unsupported by design, mirroring resolveVanityUrl's literal fallback lookup. - VanityUrlAPI.findByForward: Javadoc note that this is intended for system-user / internal routing contexts and performs no permission check; callers without READ permission on the host, or paths that expose results to end users, must not use it. Refs: #34747 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ExperimentUrlPatternCalculator.java | 22 +++++++++++++++---- .../vanityurl/business/VanityUrlAPI.java | 8 +++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java index 14300e011b3d..35b8ff3f0a3e 100644 --- a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java +++ b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java @@ -53,6 +53,14 @@ public enum ExperimentUrlPatternCalculator { * If the page use inside the Experiment isuse as Detail Page on any Content Type then is even * more complicated. * + *

Security note: The returned pattern is serialized to the + * Experiments Analytics SDK and evaluated client-side via + * {@code new RegExp(...).test(...)}. Because Vanity URL URIs can contain + * admin-authored regex, the assembled pattern is NOT protected by + * {@link com.dotcms.regex.MatcherTimeoutFactory} (which only guards the + * server-side Vanity URL resolver). Client-side ReDoS protection is tracked + * as a follow-up in #35379. + * * @param experiment * @return */ @@ -85,16 +93,22 @@ private static String getVanityUrlsRegex(final Host host, final Language languag final List vanityUrls = APILocator.getVanityUrlAPI() .findByForward(host, language, htmlPageAsset.getURI(), 200); + // Exact match is intentional — regex-based cmsHomePage URIs (e.g. "/cmsHome.*") + // are unsupported here. VanityUrlAPIImpl.resolveVanityUrl's legacy fallback + // looks up the literal LEGACY_CMS_HOME_PAGE string, so only vanities whose + // URI equals it (case-insensitive) actually participate in the "/" fallback. + final boolean hasCmsHomePageVanity = vanityUrls.stream() + .anyMatch(vanity -> VanityUrlAPI.LEGACY_CMS_HOME_PAGE.equalsIgnoreCase(vanity.url)); + // When a /cmsHomePage vanity forwards to the experiment page, visitors // reach it at "/" (see VanityUrlAPIImpl.resolveVanityUrl legacy fallback) // — add "/" as an extra alternative so the regex still matches. final String vanityUrlRegex = Stream.concat( vanityUrls.stream() .map(vanity -> String.format(DEFAULT_URL_REGEX_TEMPLATE, vanity.pattern.pattern())), - vanityUrls.stream() - .anyMatch(vanity -> VanityUrlAPI.LEGACY_CMS_HOME_PAGE.equalsIgnoreCase(vanity.url)) - ? Stream.of(String.format(DEFAULT_URL_REGEX_TEMPLATE, "\\/?")) - : Stream.empty() + hasCmsHomePageVanity + ? Stream.of(String.format(DEFAULT_URL_REGEX_TEMPLATE, "\\/?")) + : Stream.empty() ).collect(Collectors.joining(StringPool.PIPE)); // Lowercase the assembled vanity regex for parity with the experiment diff --git a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java index 3f59f06bde42..973b2aa4e3e5 100644 --- a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java +++ b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java @@ -123,6 +123,14 @@ boolean handleVanityURLRedirects(VanityUrlRequestWrapper request, HttpServletRes * matches are collected both from the specified host and from {@code SYSTEM_HOST}, * since vanities published on {@code SYSTEM_HOST} apply across all sites. * + *

Authorization: This method is intended for system-user / internal + * routing contexts (e.g. the Experiments URL pattern engine) where the caller + * represents the platform itself rather than an end user. It performs no + * permission check and returns vanities from {@code SYSTEM_HOST} in addition + * to the given host. Do not use it where the caller lacks {@code READ} + * permission on the host, or where results are exposed directly to an + * end user. + * * @param host {@link VanityUrl}'s Host * @param language {@link VanityUrl}'s Language * @param forward forward target to look for From e9ec4c87f1507f367873c04748e6e6a9a86dda6f Mon Sep 17 00:00:00 2001 From: "daniel.solis" Date: Fri, 17 Apr 2026 16:07:32 -0600 Subject: [PATCH 09/13] docs(experiments): document case-folding scope of assembled vanity regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final toLowerCase() in getVanityUrlsRegex affects every vanity URL alternative in the assembled pattern, not only the /cmsHomePage one. A reviewer flagged that this is a scope expansion: admin-authored vanity URIs that rely on uppercase characters or uppercase-only character classes (e.g. "[A-Z]+") lose those semantics in this path. Document the behavior and its justification in both the public Javadoc on calculatePageUrlRegexPattern and in the inline comment at the return statement. Rationale: CachedVanityUrl already compiles each vanity's URI with Pattern.CASE_INSENSITIVE, so case-sensitive regex constructs never actually influence vanity matching anywhere — no consumer loses behavior by the lowercase fold here. Refs: #34747 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ExperimentUrlPatternCalculator.java | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java index 35b8ff3f0a3e..43006f4a67ef 100644 --- a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java +++ b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java @@ -61,6 +61,17 @@ public enum ExperimentUrlPatternCalculator { * server-side Vanity URL resolver). Client-side ReDoS protection is tracked * as a follow-up in #35379. * + *

Case folding: The returned pattern is emitted entirely in + * lowercase — both the experiment-page alternative and every Vanity URL + * alternative — to match the SDK tracker, which lowercases the incoming + * URL path before calling {@code test}. As a side effect, any admin-authored + * vanity URI that relies on uppercase characters or uppercase-only + * character classes (e.g. {@code [A-Z]+}) is folded to lowercase in this + * path; such patterns are unsupported here. This is consistent with the + * server-side resolver, which already compiles vanity patterns with + * {@link java.util.regex.Pattern#CASE_INSENSITIVE} so case-sensitive regex + * constructs do not influence vanity matching in any consumer. + * * @param experiment * @return */ @@ -111,11 +122,16 @@ private static String getVanityUrlsRegex(final Host host, final Language languag : Stream.empty() ).collect(Collectors.joining(StringPool.PIPE)); - // Lowercase the assembled vanity regex for parity with the experiment - // page regex (see calculatePageUrlRegexPattern) and to align with the - // SDK's verifyRegex, which lowercases the incoming URL path before - // matching. Without this, a mixed-case vanity URI stored by the admin - // would never match the lowercased client-side path. + // Lowercase the ENTIRE assembled vanity regex — this affects every + // vanity pattern joined above, not just the /cmsHomePage fallback. The + // SDK (parser.ts#verifyRegex) lowercases the incoming URL path before + // calling RegExp.test, so a mixed-case vanity URI stored by the admin + // would otherwise never match. Consequence: any admin-authored regex + // construct that depends on uppercase characters (e.g. "[A-Z]+") is + // folded to lowercase here and is unsupported in this path. This is + // consistent with CachedVanityUrl, which compiles each vanity's URI + // pattern with Pattern.CASE_INSENSITIVE — server-side vanity matching + // is already case-insensitive, so no consumer loses functionality. return vanityUrlRegex.isEmpty() ? StringPool.BLANK : String.format("^%s$", vanityUrlRegex).toLowerCase(); } From b83cdbb210da179c6ee4e7328c7753f2e8732dbf Mon Sep 17 00:00:00 2001 From: "daniel.solis" Date: Sun, 19 Apr 2026 15:56:37 -0600 Subject: [PATCH 10/13] refactor(vanity-url): make SYSTEM_HOST inclusion in findByForward explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous iteration silently widened findByForward to also return SYSTEM_HOST vanities, documented only in Javadoc. Caller intent is now visible at every call site via a required includeSystemHost boolean parameter: - VanityUrlAPI.findByForward: new 5-arg signature; Javadoc updated. - VanityUrlAPIImpl.findByForward: flag gates SYSTEM_HOST stream inclusion. - ExperimentUrlPatternCalculator.getVanityUrlsRegex: passes true with a comment explaining why (a /cmsHomePage vanity forwarding to the experiment page may live on SYSTEM_HOST). - VanityUrlAPITest: three direct findByForward tests pass false — they assert host-specific behavior (pre-PR semantics preserved). Regression suite: 6 experiment + 3 findByForward tests all pass. Refs: #34747 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ExperimentUrlPatternCalculator.java | 5 ++++- .../vanityurl/business/VanityUrlAPI.java | 19 +++++++++++-------- .../vanityurl/business/VanityUrlAPIImpl.java | 14 +++++++------- .../vanityurl/business/VanityUrlAPITest.java | 6 +++--- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java index 43006f4a67ef..c2c80722e7b8 100644 --- a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java +++ b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java @@ -101,8 +101,11 @@ public String calculatePageUrlRegexPattern(final Experiment experiment) { private static String getVanityUrlsRegex(final Host host, final Language language, final HTMLPageAsset htmlPageAsset) throws DotDataException { + // includeSystemHost=true: a /cmsHomePage vanity forwarding to the + // experiment page may be published on SYSTEM_HOST (site-wide), so we + // need those matches as well. Mirrors resolveVanityUrl's host fallback. final List vanityUrls = APILocator.getVanityUrlAPI() - .findByForward(host, language, htmlPageAsset.getURI(), 200); + .findByForward(host, language, htmlPageAsset.getURI(), 200, true); // Exact match is intentional — regex-based cmsHomePage URIs (e.g. "/cmsHome.*") // are unsupported here. VanityUrlAPIImpl.resolveVanityUrl's legacy fallback diff --git a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java index 973b2aa4e3e5..afb77f1bba95 100644 --- a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java +++ b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java @@ -119,25 +119,28 @@ boolean handleVanityURLRedirects(VanityUrlRequestWrapper request, HttpServletRes * Look up all published {@link VanityUrl}s whose {@code forwardTo} matches the * given {@code forward} and whose action equals {@code action}. * - *

Mirrors the host resolution semantics of {@link #resolveVanityUrl}: - * matches are collected both from the specified host and from {@code SYSTEM_HOST}, - * since vanities published on {@code SYSTEM_HOST} apply across all sites. + *

When {@code includeSystemHost} is {@code true} the result also contains + * vanities published on {@code SYSTEM_HOST}, mirroring the host-resolution + * fallback in {@link #resolveVanityUrl}. Callers that only care about the + * given host should pass {@code false}. The flag is explicit (rather than a + * default) so the widened result scope is visible at every call site. * *

Authorization: This method is intended for system-user / internal * routing contexts (e.g. the Experiments URL pattern engine) where the caller * represents the platform itself rather than an end user. It performs no - * permission check and returns vanities from {@code SYSTEM_HOST} in addition - * to the given host. Do not use it where the caller lacks {@code READ} + * permission check. Do not use it where the caller lacks {@code READ} * permission on the host, or where results are exposed directly to an - * end user. + * end user — especially when {@code includeSystemHost} is {@code true}. * * @param host {@link VanityUrl}'s Host * @param language {@link VanityUrl}'s Language * @param forward forward target to look for * @param action HTTP action code to look for (e.g. 200, 301, 302) - * @return the matching {@link CachedVanityUrl}s from the given host and from SYSTEM_HOST + * @param includeSystemHost if {@code true}, also return vanities published on {@code SYSTEM_HOST} + * @return the matching {@link CachedVanityUrl}s from the given host, and optionally from {@code SYSTEM_HOST} */ - List findByForward(Host host, Language language, String forward, int action); + List findByForward(Host host, Language language, String forward, int action, + boolean includeSystemHost); /** * diff --git a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java index c5be3878ae8e..450d6299b55e 100644 --- a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java @@ -455,14 +455,14 @@ private String encodeRedirectURL(final String uri) { @Override @CloseDBIfOpened public List findByForward(final Host host, final Language language, final String forward, - int action) { - // Mirror resolveVanityUrl's SYSTEM_HOST fallback: vanities published on - // SYSTEM_HOST apply across all sites, so include them alongside the - // host-specific matches. + final int action, final boolean includeSystemHost) { + // When includeSystemHost is true, also pull vanities published on + // SYSTEM_HOST — they apply site-wide, mirroring resolveVanityUrl's + // SYSTEM_HOST fallback. final Host systemHost = APILocator.systemHost(); - final Stream systemHostVanities = systemHost.equals(host) - ? Stream.empty() - : load(systemHost, language).stream(); + final Stream systemHostVanities = includeSystemHost && !systemHost.equals(host) + ? load(systemHost, language).stream() + : Stream.empty(); return Stream.concat(load(host, language).stream(), systemHostVanities) .filter(cachedVanityUrl -> cachedVanityUrl.response == action) diff --git a/dotcms-integration/src/test/java/com/dotcms/vanityurl/business/VanityUrlAPITest.java b/dotcms-integration/src/test/java/com/dotcms/vanityurl/business/VanityUrlAPITest.java index 28138299dd12..e1be4625a044 100644 --- a/dotcms-integration/src/test/java/com/dotcms/vanityurl/business/VanityUrlAPITest.java +++ b/dotcms-integration/src/test/java/com/dotcms/vanityurl/business/VanityUrlAPITest.java @@ -1150,7 +1150,7 @@ public void findByForward(){ .nextPersistedAndPublish(); final List byForward = APILocator.getVanityUrlAPI().findByForward(host, language, - "/Testing", 200); + "/Testing", 200, false); assertEquals(2, byForward.size()); @@ -1213,7 +1213,7 @@ public void findByForwardNotReturnUnPublish() { .nextPersistedAndPublish(); final List byForward = APILocator.getVanityUrlAPI().findByForward(host, language, - "/Testing", 200); + "/Testing", 200, false); assertEquals(1, byForward.size()); @@ -1282,7 +1282,7 @@ public void findByForwardReturnedEmptyList(){ .nextPersistedAndPublish(); final List byForward = APILocator.getVanityUrlAPI().findByForward(host, language, - "/Testing", 200); + "/Testing", 200, false); assertTrue(byForward.isEmpty()); } From b8b8758811355dcd90c7e539b066dc9eb28d4d43 Mon Sep 17 00:00:00 2001 From: "daniel.solis" Date: Sun, 19 Apr 2026 17:17:12 -0600 Subject: [PATCH 11/13] refactor(vanity-url): restore source compatibility for findByForward / LEGACY_CMS_HOME_PAGE The previous iteration changed the public `findByForward` signature to require an explicit `includeSystemHost` flag, and moved `LEGACY_CMS_HOME_PAGE` from `VanityUrlAPIImpl` to the `VanityUrlAPI` interface. Both were source-breaking changes for external consumers. - VanityUrlAPI: add a default 4-arg `findByForward` overload that delegates to the 5-arg form with `includeSystemHost = false`, restoring pre-PR behavior for callers that relied on host-specific results. - VanityUrlAPIImpl: re-add `LEGACY_CMS_HOME_PAGE` as a public-static-final shim pointing at the canonical interface constant; update the internal `resolveVanityUrl` reference to qualify as `VanityUrlAPI.LEGACY_CMS_HOME_PAGE` so the impl's own code doesn't depend on the compat field. Refs: #34747 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../vanityurl/business/VanityUrlAPI.java | 19 +++++++++++++++++++ .../vanityurl/business/VanityUrlAPIImpl.java | 9 ++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java index afb77f1bba95..637602e43da2 100644 --- a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java +++ b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java @@ -142,6 +142,25 @@ boolean handleVanityURLRedirects(VanityUrlRequestWrapper request, HttpServletRes List findByForward(Host host, Language language, String forward, int action, boolean includeSystemHost); + /** + * Backward-compatible overload of + * {@link #findByForward(Host, Language, String, int, boolean)} that only + * searches the specified host ({@code includeSystemHost = false}). Preserves + * the behavior that existed before the {@code SYSTEM_HOST} fallback was + * added. New callers should prefer the 5-arg form and choose the flag + * explicitly. + * + * @param host {@link VanityUrl}'s Host + * @param language {@link VanityUrl}'s Language + * @param forward forward target to look for + * @param action HTTP action code to look for (e.g. 200, 301, 302) + * @return the matching {@link CachedVanityUrl}s from the given host only + */ + default List findByForward(final Host host, final Language language, + final String forward, final int action) { + return findByForward(host, language, forward, action, false); + } + /** * * @param vanityUrl diff --git a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java index 450d6299b55e..c7946a65995c 100644 --- a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java @@ -70,6 +70,13 @@ public class VanityUrlAPIImpl implements VanityUrlAPI { + " (select velocity_var_name from structure where structuretype=7)"; + /** + * Retained for source compatibility with callers that referenced + * {@code VanityUrlAPIImpl.LEGACY_CMS_HOME_PAGE} before the constant was + * promoted to the {@link VanityUrlAPI} interface. Points at the canonical + * interface constant; new code should use {@link VanityUrlAPI#LEGACY_CMS_HOME_PAGE}. + */ + public static final String LEGACY_CMS_HOME_PAGE = VanityUrlAPI.LEGACY_CMS_HOME_PAGE; private final ContentletAPI contentletAPI; private final VanityUrlCache cache; private final LanguageAPI languageAPI; @@ -257,7 +264,7 @@ public Optional resolveVanityUrl(final String url, final Host s // if this is the /cmsHomePage vanity if (matched.isEmpty() && StringPool.FORWARD_SLASH.equals(url)) { - matched = resolveVanityUrl(LEGACY_CMS_HOME_PAGE, site, language); + matched = resolveVanityUrl(VanityUrlAPI.LEGACY_CMS_HOME_PAGE, site, language); } From 1426af8d6f6f04ba991c96c4ccb01fc76a215035 Mon Sep 17 00:00:00 2001 From: "daniel.solis" Date: Sun, 19 Apr 2026 18:28:09 -0600 Subject: [PATCH 12/13] fix(experiments): skip vanities with blank pattern source in assembled regex CachedVanityUrl.normalize() returns "" when VanityUrlUtil.isValidRegex rejects the URI as an invalid regex, in which case the compiled Pattern's source is also "". Previously such vanities were still joined into the assembled vanity URL regex; String.format on the URL template then produced a path alternative that matched any URL, effectively creating a catch-all for the experiment and causing page-view events to be recorded for every URL the visitor navigated to. Filter out those entries before the map step so only vanities with a non-empty pattern source contribute alternatives. Refs: #34747 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../experiments/business/ExperimentUrlPatternCalculator.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java index c2c80722e7b8..49ac2ba075c6 100644 --- a/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java +++ b/dotCMS/src/main/java/com/dotcms/experiments/business/ExperimentUrlPatternCalculator.java @@ -119,6 +119,11 @@ private static String getVanityUrlsRegex(final Host host, final Language languag // — add "/" as an extra alternative so the regex still matches. final String vanityUrlRegex = Stream.concat( vanityUrls.stream() + // Skip vanities whose URI failed CachedVanityUrl.normalize + // (VanityUrlUtil.isValidRegex returned false) — their + // compiled Pattern's source is "", which would otherwise + // expand the URL template into a catch-all. + .filter(vanity -> !vanity.pattern.pattern().isEmpty()) .map(vanity -> String.format(DEFAULT_URL_REGEX_TEMPLATE, vanity.pattern.pattern())), hasCmsHomePageVanity ? Stream.of(String.format(DEFAULT_URL_REGEX_TEMPLATE, "\\/?")) From 0b9d0eedbc80b86eea85f9a4e08fe32200718d2e Mon Sep 17 00:00:00 2001 From: "daniel.solis" Date: Sun, 19 Apr 2026 18:47:12 -0600 Subject: [PATCH 13/13] refactor(vanity-url): give 5-arg findByForward a safe default body Previous iteration made the new 5-arg findByForward(..., boolean includeSystemHost) abstract on the VanityUrlAPI interface, so any OSGi alternative provider that only implemented the pre-PR 4-arg abstract method would throw AbstractMethodError when the 5-arg is invoked on its instance. Invert the abstract/default split on the interface so the 5-arg is the default method and the 4-arg stays abstract with pre-PR semantics: - 4-arg findByForward is now the canonical abstract method (pre-PR shape). All existing implementors already override it, so nothing breaks. - 5-arg findByForward gets a default body that delegates to the 4-arg (host-only results). Legacy providers that don't override the 5-arg get a safe, backward-compatible implementation automatically. - VanityUrlAPIImpl overrides both: the 4-arg delegates to the 5-arg with false; the 5-arg carries @CloseDBIfOpened and the SYSTEM_HOST-aware logic. ByteBuddy bytecode advice fires on the self-invocation, so the 4-arg delegation preserves connection lifecycle without duplicating the annotation. Regression suite: 6 experiment + 3 findByForward tests all pass. Refs: #34747 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../vanityurl/business/VanityUrlAPI.java | 61 ++++++++++--------- .../vanityurl/business/VanityUrlAPIImpl.java | 10 +++ 2 files changed, 43 insertions(+), 28 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java index 637602e43da2..fb225dec2a8e 100644 --- a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java +++ b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPI.java @@ -116,17 +116,38 @@ boolean handleVanityURLRedirects(VanityUrlRequestWrapper request, HttpServletRes /** - * Look up all published {@link VanityUrl}s whose {@code forwardTo} matches the - * given {@code forward} and whose action equals {@code action}. + * Look up all published {@link VanityUrl}s on the given host whose + * {@code forwardTo} equals {@code forward} and whose action equals + * {@code action}. * - *

When {@code includeSystemHost} is {@code true} the result also contains - * vanities published on {@code SYSTEM_HOST}, mirroring the host-resolution - * fallback in {@link #resolveVanityUrl}. Callers that only care about the - * given host should pass {@code false}. The flag is explicit (rather than a - * default) so the widened result scope is visible at every call site. + *

This is the pre-PR canonical form — it only searches the specified + * host. Callers that also want vanities from {@code SYSTEM_HOST} should use + * {@link #findByForward(Host, Language, String, int, boolean)} with + * {@code includeSystemHost = true}. * - *

Authorization: This method is intended for system-user / internal - * routing contexts (e.g. the Experiments URL pattern engine) where the caller + * @param host {@link VanityUrl}'s Host + * @param language {@link VanityUrl}'s Language + * @param forward forward target to look for + * @param action HTTP action code to look for (e.g. 200, 301, 302) + * @return the matching {@link CachedVanityUrl}s from the given host only + */ + List findByForward(Host host, Language language, String forward, int action); + + /** + * Extended overload of {@link #findByForward(Host, Language, String, int)} + * that can also include vanities published on {@code SYSTEM_HOST}, mirroring + * the host-resolution fallback in {@link #resolveVanityUrl}. The flag is + * explicit so the widened result scope is visible at every call site. + * + *

The default implementation delegates to the 4-arg overload (host-only + * results), so existing {@link VanityUrlAPI} implementors — including OSGi + * alternative providers — that did not override this method continue to + * work without throwing {@code AbstractMethodError}. Concrete + * implementations such as {@link VanityUrlAPIImpl} override this default + * with {@code SYSTEM_HOST}-aware logic when the flag is {@code true}. + * + *

Authorization: Intended for system-user / internal routing + * contexts (e.g. the Experiments URL pattern engine) where the caller * represents the platform itself rather than an end user. It performs no * permission check. Do not use it where the caller lacks {@code READ} * permission on the host, or where results are exposed directly to an @@ -139,26 +160,10 @@ boolean handleVanityURLRedirects(VanityUrlRequestWrapper request, HttpServletRes * @param includeSystemHost if {@code true}, also return vanities published on {@code SYSTEM_HOST} * @return the matching {@link CachedVanityUrl}s from the given host, and optionally from {@code SYSTEM_HOST} */ - List findByForward(Host host, Language language, String forward, int action, - boolean includeSystemHost); - - /** - * Backward-compatible overload of - * {@link #findByForward(Host, Language, String, int, boolean)} that only - * searches the specified host ({@code includeSystemHost = false}). Preserves - * the behavior that existed before the {@code SYSTEM_HOST} fallback was - * added. New callers should prefer the 5-arg form and choose the flag - * explicitly. - * - * @param host {@link VanityUrl}'s Host - * @param language {@link VanityUrl}'s Language - * @param forward forward target to look for - * @param action HTTP action code to look for (e.g. 200, 301, 302) - * @return the matching {@link CachedVanityUrl}s from the given host only - */ default List findByForward(final Host host, final Language language, - final String forward, final int action) { - return findByForward(host, language, forward, action, false); + final String forward, final int action, + final boolean includeSystemHost) { + return findByForward(host, language, forward, action); } /** diff --git a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java index c7946a65995c..a1bbc32afe65 100644 --- a/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/vanityurl/business/VanityUrlAPIImpl.java @@ -459,6 +459,16 @@ private String encodeRedirectURL(final String uri) { } } + @Override + public List findByForward(final Host host, final Language language, final String forward, + final int action) { + // Delegate to the 5-arg overload with host-only semantics (no SYSTEM_HOST). + // The 5-arg method carries @CloseDBIfOpened; ByteBuddy advice fires on the + // self-invocation, so this delegation keeps connection lifecycle correct + // without duplicating the annotation. + return findByForward(host, language, forward, action, false); + } + @Override @CloseDBIfOpened public List findByForward(final Host host, final Language language, final String forward,