fix(multitree): reject save when empty payload would wipe all page content#36098
fix(multitree): reject save when empty payload would wipe all page content#36098dsilvam wants to merge 17 commits into
Conversation
…ntent Add a net-loss guard in overridesMultitreesByPersonalization(): if the incoming multiTrees list is empty and the page already has existing rows, throw DotDataException to prevent a stale edit session from silently deleting all contentlets added by concurrent users. Closes #36097 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…RD_ENABLED config flag Guard is off by default; set MULTITREE_EMPTY_SAVE_GUARD_ENABLED=true to enable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…content The WARN log fires unconditionally whenever an empty payload is about to overwrite existing multi_tree rows, regardless of MULTITREE_EMPTY_SAVE_GUARD_ENABLED. The guard (rejection) still requires the flag. This allows the scenario to be identified in logs even before the guard is turned on. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…cenario - PageResourceHelper.saveContent(): DEBUG on every save (user, pageId, entry/contentlet counts, variant, language); WARN per personalization when an empty payload is about to wipe existing content, including the submitting user's email address. - MultiTreeAPIImpl: add language ID to the existing WARN so all relevant dimensions are logged in one line. Together these let us grep logs for the scenario and immediately identify the user whose stale session triggered the wipe, without enabling the guard. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Claude finished @dsilvam's task in 3m 14s —— View job 🔍 dotCMS Backend Review
Review posted at #36098 (comment) Summary: Java Standards and REST API sub-agents returned NO_FINDINGS. Security and Database reviews surfaced 4 medium-severity findings — all in |
🔍 dotCMS Backend Review[🔴 Critical]
@ApiResponses(value = {
@ApiResponse(responseCode = "200", ...),
@ApiResponse(responseCode = "400", ...),
// ← 409 CONFLICT returned in code but never declared
})💡 Add [🔴 Critical]
Logger.debug(PageResourceHelper.class, () -> String.format(
"Page content save: pageId='%s' user='%s' ...💡 Change to [🔴 Critical]
Logger.warn(PageResourceHelper.class, String.format(
"Empty contentlet payload for page '%s', ...💡 Change to [🔴 Critical]
Logger.warn(MultiTreeAPIImpl.class, String.format(
"Empty save payload would wipe %d existing contentlet(s) ...💡 Change to [🟠 High]
} catch (DotDataException e) {
return ExceptionMapperUtil.createResponse(
new DotDataException("Page content may have been modified by another user ..."),
Response.Status.CONFLICT);
}💡 Introduce a dedicated [🟠 High]
throw new DotDataException(String.format(
"Save rejected: empty payload would delete %d existing contentlet(s) from page '%s'. ...",
existing.size(), pageId));💡 Keep the count and pageId in the server-side [🟡 Medium]
if (multiTrees.isEmpty()) {
final Set<String> existing = ... // DB SELECT always runs
if (!existing.isEmpty()) {
if (Config.getBooleanProperty("MULTITREE_EMPTY_SAVE_GUARD_ENABLED", false)) { // flag checked after💡 Check the flag first: Next steps
|
…aException in addContent - MultiTreeAPIImpl: guard's existence check now mirrors the DELETE branching — uses the language-aware getOriginalContentlets overload when languageIdOpt is present, preventing false-positive rejections and inaccurate warn log counts on language-scoped saves. - PageResource.addContent: explicitly catch DotDataException and return 409 CONFLICT via WARN (not ERROR), so the guard's intentional rejection doesn't pollute the error log as an unexpected server failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…II in logs - PageResource.addContent: remove DotDataException from throws declaration (now fully caught) - PageResource.addContent: narrow DotDataException catch to saveContent only, so unrelated DB errors from getPage/checkPermission do not become 409 CONFLICT; return generic client-safe message (detail already in server-side WARN) - PageResourceHelper: replace getEmailAddress() with getUserId() in both log statements to avoid writing PII to production logs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add 409 CONFLICT to @ApiResponses on addContent — returned by the DotDataException catch when saveContent rejects a stale-session save - Add outer catch (DotDataException) → 400 BAD_REQUEST to handle DotDataException from getPage/checkPermission (compile fix after removing the throws declaration in previous commit) - Regenerate openapi.yaml to match Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
🔍 dotCMS Backend Review[🔴 Critical]
// Guard — always single-language scope:
final Set<String> existing = languageIdOpt.isPresent()
? this.getOriginalContentlets(pageId, ContainerUUID.UUID_DEFAULT_VALUE, personalization, variantId, languageIdOpt.get())
: this.getOriginalContentlets(pageId, ContainerUUID.UUID_DEFAULT_VALUE, personalization, variantId);
// But downstream delete uses two-language scope when DEFAULT_CONTENT_TO_DEFAULT_LANGUAGE=true💡 Mirror the downstream branching logic: read [🔴 Critical]
return ExceptionMapperUtil.createResponse(
new DotDataException("Save rejected: net content loss exceeds the configured threshold..."),
Response.Status.CONFLICT);💡 Replace with: [🟡 Medium]
Logger.warn(this, String.format(
"... Contentlets at risk: %s",
existing.size(), pageId, ..., existing)); // full ID set dumped at WARN💡 Either downgrade this block to DEBUG (where full IDs are appropriate), or at WARN level replace [🟡 Medium]
Logger.warn(this, String.format(
"... Incoming IDs: %s — Wiped IDs: %s",
..., incomingIds, wipedIds)); // full UUID sets at WARN💡 At WARN level log only counts ( [🟡 Medium]
// Calls before the inner try declare throws DotDataException (which StalePageSaveException extends)
this.validateContainerEntries(pageContainerForm.getContainerEntries());
final List<ContentView> savedContent;
try {
savedContent = pageResourceHelper.saveContent(...);
} catch (StalePageSaveException e) { ... } // only covers saveContent()💡 Add an inline comment: Next steps
|
Introduce StalePageSaveException extends DotDataException thrown only by the empty-save guard. PageResource now catches StalePageSaveException for 409 — other DotDataException subtypes (DB timeout, constraint violations) fall through to the existing 400 handler instead of returning a misleading "modified by another user" response. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three cases: - guard enabled + existing rows → StalePageSaveException thrown - guard disabled + existing rows → wipe proceeds (default behaviour) - guard enabled + genuinely empty page → no exception Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The outer catch(DotDataException) → 400 was making a blanket assumption that any data-layer error (DB timeout, constraint violation, etc.) is a bad request. Removed the explicit catch and restored throws DotDataException on the method signature so the JAX-RS DotDataExceptionMapper @Provider handles it consistently with the rest of the API. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces MULTITREE_NET_LOSS_THRESHOLD (int, default -1 = disabled).
When set to N, any save that would drop more than N contentlets from a
page throws StalePageSaveException — catching the scenario where a stale
session has N > 0 contentlets but still silently wipes additions made
by concurrent users.
The DB SELECT is skipped entirely when the property is absent, so there
is no performance cost on normal saves. The empty-payload guard
(MULTITREE_EMPTY_SAVE_GUARD_ENABLED) is unchanged and complementary.
Tests: excessive drop (20→10, threshold=5) → throws;
intentional removal (8→6, threshold=5) → allowed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove MULTITREE_EMPTY_SAVE_GUARD_ENABLED. A single configurable threshold now covers both the complete-wipe (empty payload) and partial stale-session scenarios. The DB SELECT is still skipped when the payload is non-empty and the threshold is -1 (default/disabled). Unconditional WARN for empty payloads is preserved for diagnostics. Also fix Logger class literals (PageResourceHelper.class → this) and update guard tests to use the unified property. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace "modified by another user" with a factual description of what triggered the guard. The cause is a net-loss delta exceeding the configured threshold — concurrent editing is one possible reason but not the only one. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace "modified concurrently" with the accurate trigger condition in both the @apiresponse annotation and the regenerated openapi.yaml. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the net-loss guard fires: - MultiTreeAPIImpl now logs the specific contentlet IDs at risk (empty case) or the incoming vs wiped ID sets (threshold case) - PageResource now includes the submitting user's ID in the WARN so all diagnostic context is in one log line Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…NT_TO_DEFAULT_LANGUAGE - Guard now uses the two-language getOriginalContentlets overload when DEFAULT_CONTENT_TO_DEFAULT_LANGUAGE=true and requested language differs from default, matching the deleteEntriesByRequestedAndDefaultLanguage scope so default-language-only contentlets are not invisible to the guard. - Replace ExceptionMapperUtil.createResponse(Throwable, Status) with createResponse(String, Status) in the 409 path to prevent stack trace leakage in dev mode. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
🔍 dotCMS Backend Review[🟡 Medium]
Logger.warn(this, String.format(
"... Contentlets at risk: %s",
existing.size(), pageId, ..., existing)); // full UUID set at WARN💡 Log only the count at WARN level and move the full ID set to DEBUG: [🟡 Medium]
Logger.warn(this, String.format(
"... Incoming IDs: %s — Wiped IDs: %s",
..., incomingIds, wipedIds)); // full UUID sets at WARN💡 Log only counts at WARN; emit the full sets at DEBUG: [🟡 Medium]
// threshold == -1 (disabled), multiTrees is empty → SELECT runs for logging only
if (multiTrees.isEmpty() || threshold >= 0) {
existing = this.getOriginalContentlets(...); // paid unconditionally
if (threshold >= 0 && netLoss > threshold) // never true here💡 Only issue the SELECT when at least one consumer will use it. Check [🟡 Medium]
// line 737 — guard block
final long defaultLanguageId = APILocator.getLanguageAPI().getDefaultLanguage().getId();
// line 799 — delete block (same transaction, no shared variable)
final long defaultLanguageId = APILocator.getLanguageAPI().getDefaultLanguage().getId();💡 Hoist Next steps
|
Summary
Adds a net-loss guard in `MultiTreeAPIImpl.overridesMultitreesByPersonalization()` to prevent a stale Edit Mode session from silently deleting all contentlets on a page.
Root Cause (see #36097)
For TRADITIONAL pages, the editor's `$pageData` signal is frozen at session-open time and never refreshed. A user who opened the page while empty will post an empty `containers` payload after other users have built up content. `overridesMultitreesByPersonalization()` does a page-wide DELETE + reinsert, so an empty post wipes all concurrent work with no warning.
Test Plan
Links
🤖 Generated with Claude Code