Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<ContentView> savedContent = pageResourceHelper.saveContent(
pageId, this.reduce(pageContainerForm.getContainerEntries()), language, variantName, user);
final List<ContentView> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ public List<ContentView> saveContent(final String pageId,
final Map<String, List<MultiTree>> multiTreesMap = new HashMap<>();
final List<ContentView> 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<String> contentIds = containerEntry.getContentIds();
Expand Down Expand Up @@ -209,8 +218,18 @@ public List<ContentView> saveContent(final String pageId,
.collect(java.util.stream.Collectors.joining(", "))));

for (final String personalization : multiTreesMap.keySet()) {
final List<MultiTree> 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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> 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<String> incomingIds = multiTrees.stream()
.map(MultiTree::getContentlet)
.collect(Collectors.toSet());
final Set<String> 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<String> originalContentletIds;
final DotConnect db = new DotConnect();
Expand Down
3 changes: 3 additions & 0 deletions dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading