From 4019d1a6e821c0da26251398447aa0125a16f51e Mon Sep 17 00:00:00 2001 From: gortiz-dotcms Date: Fri, 12 Jun 2026 17:17:24 -0300 Subject: [PATCH 1/2] backport #36097 to 26.04.22-03 --- .../dotcms/rest/api/v1/page/PageResource.java | 15 +- .../rest/api/v1/page/PageResourceHelper.java | 12 +- .../exception/StalePageSaveException.java | 13 + .../factories/MultiTreeAPIImpl.java | 60 ++++ .../main/webapp/WEB-INF/openapi/openapi.yaml | 3 + .../factories/MultiTreeAPITest.java | 261 ++++++++++++++++++ 6 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 dotCMS/src/main/java/com/dotmarketing/exception/StalePageSaveException.java diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResource.java index 2c2a2784aa8e..ecb93e843071 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResource.java @@ -84,6 +84,7 @@ import com.dotmarketing.exception.DoesNotExistException; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.exception.StalePageSaveException; import com.dotmarketing.portlets.containers.model.Container; import com.dotmarketing.portlets.contentlet.business.ContentletAPI; import com.dotmarketing.portlets.contentlet.model.Contentlet; @@ -829,6 +830,7 @@ public Response saveLayout( ) ), @ApiResponse(responseCode = "400", description = "Bad request or data exception"), + @ApiResponse(responseCode = "409", description = "Conflict — net content loss exceeds the configured threshold; refresh and retry"), }) public final Response addContent(@Context final HttpServletRequest request, @Context final HttpServletResponse response, @@ -865,8 +867,17 @@ public final Response addContent(@Context final HttpServletRequest request, this.validateContainerEntries(pageContainerForm.getContainerEntries()); // Save content and Get the saved contentlets - final List savedContent = pageResourceHelper.saveContent( - pageId, this.reduce(pageContainerForm.getContainerEntries()), language, variantName, user); + final List savedContent; + try { + savedContent = pageResourceHelper.saveContent( + pageId, this.reduce(pageContainerForm.getContainerEntries()), language, variantName, user); + } catch (StalePageSaveException e) { + Logger.warn(this, String.format("Page content save rejected for pageId '%s' by user '%s': %s", + pageId, user.getUserId(), e.getMessage())); + return ExceptionMapperUtil.createResponse( + "Save rejected: net content loss exceeds the configured threshold. Please refresh and try again.", + Response.Status.CONFLICT); + } return Response.ok(new ResponseEntityContentView(savedContent)).build(); } catch(HTMLPageAssetNotFoundException e) { diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResourceHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResourceHelper.java index d821f5679c8b..2e0c28afba2c 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResourceHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResourceHelper.java @@ -209,8 +209,18 @@ public List saveContent(final String pageId, .collect(java.util.stream.Collectors.joining(", ")))); for (final String personalization : multiTreesMap.keySet()) { + final List multiTrees = multiTreesMap.get(personalization); + if (multiTrees.isEmpty()) { + Logger.warn(this, String.format( + "Empty contentlet payload for page '%s', personalization='%s', variant='%s', " + + "language=%d submitted by user '%s'. Existing content in this slot will be wiped " + + "unless MULTITREE_NET_LOSS_THRESHOLD is configured.", + pageId, personalization, variantName, + language != null ? language.getId() : -1L, + user != null ? user.getUserId() : "unknown")); + } multiTreeAPI.overridesMultitreesByPersonalization(pageId, personalization, - multiTreesMap.get(personalization), Optional.of(language.getId()), + multiTrees, Optional.of(language.getId()), variantName); } diff --git a/dotCMS/src/main/java/com/dotmarketing/exception/StalePageSaveException.java b/dotCMS/src/main/java/com/dotmarketing/exception/StalePageSaveException.java new file mode 100644 index 000000000000..3bc6a40c1015 --- /dev/null +++ b/dotCMS/src/main/java/com/dotmarketing/exception/StalePageSaveException.java @@ -0,0 +1,13 @@ +package com.dotmarketing.exception; + +/** + * Thrown when an empty-payload save would wipe existing page content, indicating the caller's + * session is stale (another user has added content since the session was opened). + * Handled as HTTP 409 Conflict in PageResource — callers should prompt the user to refresh. + */ +public class StalePageSaveException extends DotDataException { + + public StalePageSaveException(final String message) { + super(message); + } +} diff --git a/dotCMS/src/main/java/com/dotmarketing/factories/MultiTreeAPIImpl.java b/dotCMS/src/main/java/com/dotmarketing/factories/MultiTreeAPIImpl.java index 0ecd8be960f9..65bc9118f6e2 100644 --- a/dotCMS/src/main/java/com/dotmarketing/factories/MultiTreeAPIImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/factories/MultiTreeAPIImpl.java @@ -23,6 +23,7 @@ import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.exception.StalePageSaveException; import com.dotmarketing.portlets.containers.business.ContainerAPI; import com.dotmarketing.portlets.containers.model.Container; import com.dotmarketing.portlets.containers.model.FileAssetContainer; @@ -707,6 +708,65 @@ public void overridesMultitreesByPersonalization(final String pageId, languageIdOpt.map(String::valueOf).orElse("none"), multiTrees.size())); + // Net-loss threshold guard. Disabled by default (-1). Set to 1 to reject any save that + // drops 2+ contentlets — safe for the UVE because each user action (add/remove/move) + // produces a net change of at most ±1. Also guards the complete-wipe case (empty payload) + // since a loss of N > threshold always triggers when N equals all existing rows. + // The DB SELECT is skipped entirely when the payload is non-empty AND threshold is -1. + final int threshold = Config.getIntProperty("MULTITREE_NET_LOSS_THRESHOLD", -1); + if (multiTrees.isEmpty() || threshold >= 0) { + // Mirror the downstream DELETE branching so the guard counts the same rows that will + // be removed. When DEFAULT_CONTENT_TO_DEFAULT_LANGUAGE=true and the requested language + // differs from the default, the DELETE targets both languages — use the two-language + // overload so default-language-only contentlets are not invisible to the guard. + final boolean defaultContentToDefaultLanguageGuard = Config.getBooleanProperty( + "DEFAULT_CONTENT_TO_DEFAULT_LANGUAGE", false); + final Set existing; + if (languageIdOpt.isPresent() && defaultContentToDefaultLanguageGuard) { + final long defaultLanguageId = APILocator.getLanguageAPI().getDefaultLanguage().getId(); + if (defaultLanguageId == languageIdOpt.get()) { + existing = this.getOriginalContentlets(pageId, ContainerUUID.UUID_DEFAULT_VALUE, + personalization, variantId, languageIdOpt.get()); + } else { + existing = this.getOriginalContentlets(pageId, personalization, variantId, + languageIdOpt.get(), defaultLanguageId); + } + } else if (languageIdOpt.isPresent()) { + existing = this.getOriginalContentlets(pageId, ContainerUUID.UUID_DEFAULT_VALUE, + personalization, variantId, languageIdOpt.get()); + } else { + existing = this.getOriginalContentlets(pageId, ContainerUUID.UUID_DEFAULT_VALUE, + personalization, variantId); + } + if (!existing.isEmpty()) { + final int netLoss = existing.size() - multiTrees.size(); + final Set incomingIds = multiTrees.stream() + .map(MultiTree::getContentlet) + .collect(Collectors.toSet()); + final Set wipedIds = existing.stream() + .filter(id -> !incomingIds.contains(id)) + .collect(Collectors.toSet()); + if (multiTrees.isEmpty()) { + Logger.warn(this, String.format( + "Empty save payload would wipe %d existing contentlet(s) from page '%s' " + + "(personalization='%s', variantId='%s', language=%d). " + + "Contentlets at risk: %s", + existing.size(), pageId, personalization, variantId, + languageIdOpt.orElse(-1L), existing)); + } + if (threshold >= 0 && netLoss > threshold) { + Logger.warn(this, String.format( + "Save rejected: net loss of %d contentlet(s) from page '%s' exceeds threshold %d " + + "(personalization='%s', variantId='%s', language=%d). " + + "Incoming IDs: %s — Wiped IDs: %s", + netLoss, pageId, threshold, personalization, variantId, + languageIdOpt.orElse(-1L), incomingIds, wipedIds)); + throw new StalePageSaveException( + "Save rejected: net content loss exceeds the configured threshold. Please refresh and try again."); + } + } + } + Logger.debug(MultiTreeAPIImpl.class, ()->String.format("Saving page's content: %s", multiTrees)); Set originalContentletIds; final DotConnect db = new DotConnect(); diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index e80406cfb37c..9ed225c2b89e 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -12358,6 +12358,9 @@ paths: description: Contentlets saved successfully "400": description: Bad request or data exception + "409": + description: Conflict — net content loss exceeds the configured threshold; + refresh and retry summary: Add or update content in page containers tags: - Page diff --git a/dotcms-integration/src/test/java/com/dotmarketing/factories/MultiTreeAPITest.java b/dotcms-integration/src/test/java/com/dotmarketing/factories/MultiTreeAPITest.java index 61569673b96e..63b32229aa22 100644 --- a/dotcms-integration/src/test/java/com/dotmarketing/factories/MultiTreeAPITest.java +++ b/dotcms-integration/src/test/java/com/dotmarketing/factories/MultiTreeAPITest.java @@ -16,6 +16,7 @@ import com.dotmarketing.common.db.DotConnect; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.exception.StalePageSaveException; import com.dotmarketing.portlets.containers.model.Container; import com.dotmarketing.portlets.containers.model.FileAssetContainer; import com.dotmarketing.portlets.contentlet.model.Contentlet; @@ -4671,6 +4672,266 @@ public void testCreate_withNullStyleProperties() throws Exception { assertNull("Style properties should be null", retrieved.getStyleProperties()); } + // ── Net-loss threshold guard tests (empty + non-empty) ───────────────── + + /** + * Method to Test: {@link MultiTreeAPI#overridesMultitreesByPersonalization(String, String, List, Optional, String)} + * When: {@code MULTITREE_NET_LOSS_THRESHOLD=0}, the page already has contentlets, and + * the caller submits an empty list (complete stale-session wipe scenario). + * Should: throw {@link StalePageSaveException} — net loss equals all existing rows, exceeding threshold 0. + */ + @Test(expected = StalePageSaveException.class) + public void test_overridesMultitrees_threshold0_emptyPayload_throwsStalePageSaveException() throws Exception { + final Language defaultLanguage = APILocator.getLanguageAPI().getDefaultLanguage(); + final ContentType contentType = new ContentTypeDataGen().nextPersisted(); + final Contentlet contentlet = new ContentletDataGen(contentType.id()) + .languageId(defaultLanguage.getId()).nextPersisted(); + + final Template template = new TemplateDataGen().body("body").nextPersisted(); + final Folder folder = new FolderDataGen().nextPersisted(); + final HTMLPageAsset page = new HTMLPageDataGen(folder, template).nextPersisted(); + final Structure structure = new StructureDataGen().nextPersisted(); + final Container container = new ContainerDataGen().maxContentlets(1).withStructure(structure, "").nextPersisted(); + + new MultiTreeDataGen() + .setPage(page).setContainer(container).setContentlet(contentlet) + .setInstanceID(UUIDGenerator.shorty()).setPersonalization(DOT_PERSONALIZATION_DEFAULT) + .setTreeOrder(1).nextPersisted(); + + Config.setProperty("MULTITREE_NET_LOSS_THRESHOLD", 0); + try { + APILocator.getMultiTreeAPI().overridesMultitreesByPersonalization( + page.getIdentifier(), + DOT_PERSONALIZATION_DEFAULT, + Collections.emptyList(), + Optional.of(defaultLanguage.getId()), + VariantAPI.DEFAULT_VARIANT.name() + ); + } finally { + Config.setProperty("MULTITREE_NET_LOSS_THRESHOLD", -1); + } + } + + /** + * Method to Test: {@link MultiTreeAPI#overridesMultitreesByPersonalization(String, String, List, Optional, String)} + * When: {@code MULTITREE_NET_LOSS_THRESHOLD=-1} (default, disabled), the page has existing + * contentlets, and the caller submits an empty list. + * Should: not throw — wipe proceeds normally, leaving 0 rows for the page. + */ + @Test + public void test_overridesMultitrees_thresholdDisabled_emptyPayload_wipesExistingRows() throws Exception { + final Language defaultLanguage = APILocator.getLanguageAPI().getDefaultLanguage(); + final ContentType contentType = new ContentTypeDataGen().nextPersisted(); + final Contentlet contentlet = new ContentletDataGen(contentType.id()) + .languageId(defaultLanguage.getId()).nextPersisted(); + + final Template template = new TemplateDataGen().body("body").nextPersisted(); + final Folder folder = new FolderDataGen().nextPersisted(); + final HTMLPageAsset page = new HTMLPageDataGen(folder, template).nextPersisted(); + final Structure structure = new StructureDataGen().nextPersisted(); + final Container container = new ContainerDataGen().maxContentlets(1).withStructure(structure, "").nextPersisted(); + + new MultiTreeDataGen() + .setPage(page).setContainer(container).setContentlet(contentlet) + .setInstanceID(UUIDGenerator.shorty()).setPersonalization(DOT_PERSONALIZATION_DEFAULT) + .setTreeOrder(1).nextPersisted(); + + // Default: threshold is -1 (disabled) — no property set needed, but explicit for clarity + Config.setProperty("MULTITREE_NET_LOSS_THRESHOLD", -1); + APILocator.getMultiTreeAPI().overridesMultitreesByPersonalization( + page.getIdentifier(), + DOT_PERSONALIZATION_DEFAULT, + Collections.emptyList(), + Optional.of(defaultLanguage.getId()), + VariantAPI.DEFAULT_VARIANT.name() + ); + + final List result = APILocator.getMultiTreeAPI().getMultiTreesByPage(page.getIdentifier()); + assertTrue("Threshold disabled — empty save should wipe all rows", result.isEmpty()); + } + + /** + * Method to Test: {@link MultiTreeAPI#overridesMultitreesByPersonalization(String, String, List, Optional, String)} + * When: {@code MULTITREE_NET_LOSS_THRESHOLD=0} but the page genuinely has no existing contentlets + * (first save on a blank page). + * Should: not throw — the guard only fires when there are existing rows to protect. + */ + @Test + public void test_overridesMultitrees_threshold0_genuinelyEmptyPage_noException() throws Exception { + final Language defaultLanguage = APILocator.getLanguageAPI().getDefaultLanguage(); + + final Template template = new TemplateDataGen().body("body").nextPersisted(); + final Folder folder = new FolderDataGen().nextPersisted(); + final HTMLPageAsset page = new HTMLPageDataGen(folder, template).nextPersisted(); + + Config.setProperty("MULTITREE_NET_LOSS_THRESHOLD", 0); + try { + APILocator.getMultiTreeAPI().overridesMultitreesByPersonalization( + page.getIdentifier(), + DOT_PERSONALIZATION_DEFAULT, + Collections.emptyList(), + Optional.of(defaultLanguage.getId()), + VariantAPI.DEFAULT_VARIANT.name() + ); + } finally { + Config.setProperty("MULTITREE_NET_LOSS_THRESHOLD", -1); + } + + final List result = APILocator.getMultiTreeAPI().getMultiTreesByPage(page.getIdentifier()); + assertTrue("Genuinely empty page — should remain empty after save", result.isEmpty()); + } + + // ── Net-loss threshold guard tests ───────────────────────────────────── + + /** + * Method to Test: {@link MultiTreeAPI#overridesMultitreesByPersonalization(String, String, List, Optional, String)} + * When: {@code MULTITREE_NET_LOSS_THRESHOLD=5} and the save would drop 10 contentlets (20 → 10). + * Should: throw {@link StalePageSaveException} — the net loss exceeds the configured threshold. + */ + @Test(expected = StalePageSaveException.class) + public void test_overridesMultitrees_netLossThreshold_excessiveDrop_throwsStalePageSaveException() throws Exception { + final Language defaultLanguage = APILocator.getLanguageAPI().getDefaultLanguage(); + final ContentType contentType = new ContentTypeDataGen().nextPersisted(); + final Template template = new TemplateDataGen().body("body").nextPersisted(); + final Folder folder = new FolderDataGen().nextPersisted(); + final HTMLPageAsset page = new HTMLPageDataGen(folder, template).nextPersisted(); + final Structure structure = new StructureDataGen().nextPersisted(); + final Container container = new ContainerDataGen().maxContentlets(20).withStructure(structure, "").nextPersisted(); + final String instanceId = UUIDGenerator.shorty(); + + // Persist 20 contentlets on the page (simulates what the DB looks like after other users' work) + final List incoming = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + final Contentlet c = new ContentletDataGen(contentType.id()).languageId(defaultLanguage.getId()).nextPersisted(); + new MultiTreeDataGen() + .setPage(page).setContainer(container).setContentlet(c) + .setInstanceID(instanceId).setPersonalization(DOT_PERSONALIZATION_DEFAULT) + .setTreeOrder(i).nextPersisted(); + // Stale session only saw the first 10 — re-submit those 10 + if (i < 10) { + incoming.add(new MultiTree() + .setHtmlPage(page.getIdentifier()).setContainer(container.getIdentifier()) + .setContentlet(c.getIdentifier()).setInstanceId(instanceId).setTreeOrder(i)); + } + } + + Config.setProperty("MULTITREE_NET_LOSS_THRESHOLD", 5); + try { + // Submitting 10 of 20 contentlets → net loss of 10, which exceeds threshold of 5 + APILocator.getMultiTreeAPI().overridesMultitreesByPersonalization( + page.getIdentifier(), + DOT_PERSONALIZATION_DEFAULT, + incoming, + Optional.of(defaultLanguage.getId()), + VariantAPI.DEFAULT_VARIANT.name() + ); + } finally { + Config.setProperty("MULTITREE_NET_LOSS_THRESHOLD", -1); + } + } + + /** + * Method to Test: {@link MultiTreeAPI#overridesMultitreesByPersonalization(String, String, List, Optional, String)} + * When: {@code MULTITREE_NET_LOSS_THRESHOLD=5} and the user intentionally removes 2 contentlets + * (8 existing → 6 incoming, net loss of 2). + * Should: not throw — the net loss is within the configured threshold. + */ + @Test + public void test_overridesMultitrees_netLossThreshold_smallDrop_allowsSave() throws Exception { + final Language defaultLanguage = APILocator.getLanguageAPI().getDefaultLanguage(); + final ContentType contentType = new ContentTypeDataGen().nextPersisted(); + final Template template = new TemplateDataGen().body("body").nextPersisted(); + final Folder folder = new FolderDataGen().nextPersisted(); + final HTMLPageAsset page = new HTMLPageDataGen(folder, template).nextPersisted(); + final Structure structure = new StructureDataGen().nextPersisted(); + final Container container = new ContainerDataGen().maxContentlets(8).withStructure(structure, "").nextPersisted(); + final String instanceId = UUIDGenerator.shorty(); + + // Persist 8 contentlets; user intentionally keeps 6 (removes 2) + final List incoming = new ArrayList<>(); + for (int i = 0; i < 8; i++) { + final Contentlet c = new ContentletDataGen(contentType.id()).languageId(defaultLanguage.getId()).nextPersisted(); + new MultiTreeDataGen() + .setPage(page).setContainer(container).setContentlet(c) + .setInstanceID(instanceId).setPersonalization(DOT_PERSONALIZATION_DEFAULT) + .setTreeOrder(i).nextPersisted(); + if (i < 6) { + incoming.add(new MultiTree() + .setHtmlPage(page.getIdentifier()).setContainer(container.getIdentifier()) + .setContentlet(c.getIdentifier()).setInstanceId(instanceId).setTreeOrder(i)); + } + } + + Config.setProperty("MULTITREE_NET_LOSS_THRESHOLD", 5); + try { + // Net loss of 2 — within threshold of 5, should save cleanly + APILocator.getMultiTreeAPI().overridesMultitreesByPersonalization( + page.getIdentifier(), + DOT_PERSONALIZATION_DEFAULT, + incoming, + Optional.of(defaultLanguage.getId()), + VariantAPI.DEFAULT_VARIANT.name() + ); + } finally { + Config.setProperty("MULTITREE_NET_LOSS_THRESHOLD", -1); + } + + final List result = APILocator.getMultiTreeAPI().getMultiTreesByPage(page.getIdentifier()); + assertEquals("Intentional removal of 2 should leave 6 contentlets", 6, result.size()); + } + + /** + * Method to Test: {@link MultiTreeAPI#overridesMultitreesByPersonalization(String, String, List, Optional, String)} + * When: The default threshold (1) is in effect and the user removes exactly 1 contentlet + * (the most a single UVE action can ever remove). + * Should: not throw — a net loss of 1 is within the default threshold. + */ + @Test + public void test_overridesMultitrees_defaultThreshold_singleRemoval_allowsSave() throws Exception { + final Language defaultLanguage = APILocator.getLanguageAPI().getDefaultLanguage(); + final ContentType contentType = new ContentTypeDataGen().nextPersisted(); + final Template template = new TemplateDataGen().body("body").nextPersisted(); + final Folder folder = new FolderDataGen().nextPersisted(); + final HTMLPageAsset page = new HTMLPageDataGen(folder, template).nextPersisted(); + final Structure structure = new StructureDataGen().nextPersisted(); + final Container container = new ContainerDataGen().maxContentlets(3).withStructure(structure, "").nextPersisted(); + final String instanceId = UUIDGenerator.shorty(); + + final List incoming = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + final Contentlet c = new ContentletDataGen(contentType.id()).languageId(defaultLanguage.getId()).nextPersisted(); + new MultiTreeDataGen() + .setPage(page).setContainer(container).setContentlet(c) + .setInstanceID(instanceId).setPersonalization(DOT_PERSONALIZATION_DEFAULT) + .setTreeOrder(i).nextPersisted(); + // User intentionally removes the last contentlet; keeps the first two + if (i < 2) { + incoming.add(new MultiTree() + .setHtmlPage(page.getIdentifier()).setContainer(container.getIdentifier()) + .setContentlet(c.getIdentifier()).setInstanceId(instanceId).setTreeOrder(i)); + } + } + + // Threshold of 1 — a loss of exactly 1 should be allowed + Config.setProperty("MULTITREE_NET_LOSS_THRESHOLD", 1); + try { + APILocator.getMultiTreeAPI().overridesMultitreesByPersonalization( + page.getIdentifier(), + DOT_PERSONALIZATION_DEFAULT, + incoming, + Optional.of(defaultLanguage.getId()), + VariantAPI.DEFAULT_VARIANT.name() + ); + } finally { + Config.setProperty("MULTITREE_NET_LOSS_THRESHOLD", -1); + } + + final List result = APILocator.getMultiTreeAPI().getMultiTreesByPage(page.getIdentifier()); + assertEquals("Single intentional removal should leave 2 contentlets", 2, result.size()); + } + + // ── Style-properties tests ────────────────────────────────────────────── + /** * Method to test: {@link MultiTreeAPIImpl#saveMultiTree(MultiTree)} * When: You create a new MultiTree with empty style properties From 07e738d038f31ac09fe27f662582dc2e590af06c Mon Sep 17 00:00:00 2001 From: gortiz-dotcms Date: Fri, 12 Jun 2026 17:28:26 -0300 Subject: [PATCH 2/2] add missing code --- .../com/dotcms/rest/api/v1/page/PageResourceHelper.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResourceHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResourceHelper.java index 2e0c28afba2c..07be7f3f4b41 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResourceHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResourceHelper.java @@ -160,6 +160,15 @@ public List saveContent(final String pageId, final Map> multiTreesMap = new HashMap<>(); final List responseViews = new ArrayList<>(); + final int totalContentlets = containerEntries.stream() + .mapToInt(e -> UtilMethods.isSet(e.getContentIds()) ? e.getContentIds().size() : 0).sum(); + Logger.debug(this, () -> String.format( + "Page content save: pageId='%s' user='%s' containerEntries=%d totalContentlets=%d " + + "variant='%s' language=%d", + pageId, user != null ? user.getUserId() : "unknown", + containerEntries.size(), totalContentlets, + variantName, language != null ? language.getId() : -1L)); + for (final ContainerEntry containerEntry : containerEntries) { int i = 0; final List contentIds = containerEntry.getContentIds();