Skip to content

Commit 527c553

Browse files
dsolistorresclaude
andauthored
fix(page): exclude archived content from page render and content save (#35993) (#36215)
## Problem When a contentlet placed in a container on a page is **archived**, it continues to render in the page's **Edit** and **Preview** modes (UVE). It is correctly hidden in **Live** mode. Archived content should not appear on a page in any mode. A secondary symptom: while an archived contentlet remains in a container, removing a different (non-archived) contentlet from the page hangs ("spinning forever"). The browser POST to `/api/v1/page/{id}/content` returns **400 "Can't find contentlet: \<archived-id\>"**. ## Root cause Archiving sets `deleted = true` on the version info but keeps the working inode. In Edit/Preview `PageMode.showLive = false`, so `PageRenderUtil.getSpecificContentlet()` resolves `find(workingInode)` and returns the archived working version — and `populateContainers()` only guarded against `null`. Live mode has no live version, so it was already excluded. Because the archived content kept rendering, it stayed in the editor's page model. On save (a **full replacement**), `PageResource.validateContainerEntries()` looked the id up via `findContentletByIdentifierAnyLanguageAnyVariant()`, which filters out deleted versions, found nothing, and threw `DotContentletStateException` — a `RuntimeException` not caught by the method's `catch (DotDataException)` — surfacing as HTTP 400 and hanging the remove. ## Changes - **`PageRenderUtil.populateContainers()`** — skip `contentlet.isArchived()` in all modes, consistent with Live-mode behavior. (Also corrected a stray `DotStateException` import.) - **`PageResource.validateContainerEntries()`** — skip archived content instead of throwing the 400, so a page that still references archived content remains saveable (the content gets removed on save). - **`PageResourceTest`** — new self-validating integration test `testArchivedContentNotRenderedInEditAndPreviewMode`: renders the page **before** archiving (asserts the content shows, count == 1), archives, then asserts count == 0 in PREVIEW and EDIT modes. ## Testing - Bug reproduced on unfixed core: the new test **failed** `expected:<0> but was:<1>`. - With the fix: the test **passes**. - Full `PageResourceTest` (31 tests) passes against the fixed core with the build cache disabled. ## ⚠️ Reviewer note — intentional behavior change in page-save semantics After this fix, archived content no longer renders on a page in any mode. Because the page-save endpoint (`POST /api/v1/page/{id}/content`) is a **full replacement** of each container's content, the next time a user saves the page the archived content — no longer in the editor's model — is **removed from the page's `multi_tree` association**. **Implication:** if that content is later **unarchived**, it will **not** automatically reappear on the page; it must be re-added manually. We consider this acceptable (archived content shouldn't be bound to live pages), but flagging it explicitly. An alternative — preserving the `multi_tree` association so unarchive restores placement — was considered and rejected as more complex and less intuitive. **Please confirm you're comfortable with the chosen semantics.** Refs: #35993 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 390a561 commit 527c553

3 files changed

Lines changed: 174 additions & 12 deletions

File tree

dotCMS/src/main/java/com/dotcms/rendering/velocity/services/PageRenderUtil.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,26 @@ private List<ContainerRaw> populateContainers() throws DotDataException, DotSecu
296296
continue;
297297
}
298298

299+
// Archived (deleted) content keeps its working version, so a showLive=false
300+
// lookup (EDIT/PREVIEW modes) still resolves it. Skip it in every mode so that
301+
// archived content never renders on the page, consistent with LIVE-mode behavior.
302+
// isArchived() declares DotSecurityException, but VersionableAPI.isDeleted() does
303+
// not throw it via this path (it is declared for forward-compatibility). Should a
304+
// DotSecurityException ever surface, it is a genuine access-control failure and
305+
// must NOT be swallowed as "probably archived" -- so it is intentionally left
306+
// uncaught and propagates to the caller.
307+
try {
308+
if (nonHydratedContentlet.isArchived()) {
309+
Logger.debug(this, () -> "Skipping archived contentlet: "
310+
+ nonHydratedContentlet.getIdentifier());
311+
continue;
312+
}
313+
} catch (final DotStateException | DotDataException e) {
314+
Logger.warn(this, "Could not determine archived state for contentlet '"
315+
+ nonHydratedContentlet.getIdentifier() + "'; skipping it", e);
316+
continue;
317+
}
318+
299319
final DotContentletTransformer transformer = new DotTransformerBuilder()
300320
.defaultOptions().content(nonHydratedContentlet).build();
301321
final Contentlet contentlet = transformer.hydrate().get(0);

dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResource.java

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,10 @@
8686
import com.dotmarketing.exception.DotSecurityException;
8787
import com.dotmarketing.exception.StalePageSaveException;
8888
import com.dotmarketing.portlets.containers.model.Container;
89+
import com.dotcms.contenttype.exception.NotFoundInDbException;
90+
import com.dotcms.exception.ExceptionUtil;
8991
import com.dotmarketing.portlets.contentlet.business.ContentletAPI;
92+
import com.dotmarketing.portlets.contentlet.business.DotContentletStateException;
9093
import com.dotmarketing.portlets.contentlet.model.Contentlet;
9194
import com.dotmarketing.portlets.contentlet.transform.DotTransformerBuilder;
9295
import com.dotmarketing.portlets.contentlet.util.ContentletUtil;
@@ -1033,24 +1036,69 @@ protected void validateContainerEntries(final List<ContainerEntry> containerEntr
10331036
for (final ContainerEntry containerEntry : containerEntries) {
10341037

10351038
final String containerId = containerEntry.getContainerId();
1039+
1040+
if (!UtilMethods.isSet(containerId)) {
1041+
throw new BadRequestException("A container identifier is required");
1042+
}
1043+
10361044
final Set<String> contentTypeSet = containerContentTypesMap.computeIfAbsent(containerId, key -> this.getContainerContentTypes(containerId));
10371045
final List<String> contentletIdList = containerEntry.getContentIds();
10381046
for (final String contentletId : contentletIdList) {
1047+
1048+
if (!UtilMethods.isSet(contentletId)) {
1049+
throw new BadRequestException("A contentlet identifier is required for the container");
1050+
}
1051+
10391052
final Contentlet contentlet;
10401053
try {
1054+
// This lookup excludes deleted (archived) versions. It wraps everything it
1055+
// catches in a DotContentletStateException, so we must inspect the cause:
1056+
// a NotFoundInDbException means the content is archived or does not exist --
1057+
// both are skipped silently (treated identically to avoid leaking whether an
1058+
// identifier exists, and so a page still referencing archived content stays
1059+
// saveable). Any other cause is a genuine failure and must be surfaced rather
1060+
// than silently dropping the contentlet. See issue #35993.
10411061
contentlet = APILocator.getContentletAPI().findContentletByIdentifierAnyLanguageAnyVariant(contentletId);
1042-
if (null == contentlet) {
1043-
1044-
throw new BadRequestException("The contentlet: " + contentletId + " does not exists!");
1062+
} catch (final DotContentletStateException e) {
1063+
if (ExceptionUtil.causedBy(e, NotFoundInDbException.class)) {
1064+
// Expected/benign case (archived or non-existent). Checks the full cause
1065+
// chain (not just the direct cause) so an added intermediate wrapper does
1066+
// not silently turn this into a client error. Logged without the exception
1067+
// so we don't emit a stack trace -- or the wrapped lookup message -- on
1068+
// every page save that references archived content.
1069+
Logger.warn(this, "Skipping contentlet '" + contentletId
1070+
+ "' on page content save: archived or not found");
1071+
continue;
10451072
}
1073+
// Genuine lookup/DB failure wrapped as DotContentletStateException: log full
1074+
// detail server-side and return a generic message so internal identifiers /
1075+
// SQL fragments are not leaked in the response.
1076+
Logger.error(this, "Error validating contentlet '" + contentletId
1077+
+ "' on page content save", e);
1078+
throw new BadRequestException("Error validating one or more contentlets for the page");
1079+
} catch (final DotDataException e) {
1080+
// findContentletByIdentifierAnyLanguageAnyVariant declares this checked
1081+
// exception (in practice it wraps failures in DotContentletStateException,
1082+
// handled above). Handle it the same way so this method throws only unchecked
1083+
// exceptions and never forwards a raw message to the client.
1084+
Logger.error(this, "Error validating contentlet '" + contentletId
1085+
+ "' on page content save", e);
1086+
throw new BadRequestException("Error validating one or more contentlets for the page");
1087+
}
10461088

1047-
if (contentlet.getBaseType().get().equals(BaseContentType.CONTENT) && !contentTypeSet.contains(contentlet.getContentType().variable())) {
1048-
1049-
throw new BadRequestException("The content type: " + contentlet.getContentType().variable() + " is not valid for the container");
1050-
}
1051-
} catch (DotDataException e) {
1089+
if (null == contentlet) {
1090+
Logger.warn(this, "Skipping contentlet '" + contentletId
1091+
+ "' on page content save: working version not found");
1092+
continue;
1093+
}
10521094

1053-
throw new BadRequestException(e, e.getMessage());
1095+
if (contentlet.getBaseType().get().equals(BaseContentType.CONTENT)
1096+
&& !contentTypeSet.contains(contentlet.getContentType().variable())) {
1097+
// The content-type variable is public schema metadata (already exposed via the
1098+
// Content Types API), so it is safe to include and helps the caller identify the
1099+
// offending content.
1100+
throw new BadRequestException("Content type '" + contentlet.getContentType().variable()
1101+
+ "' is not allowed in this container");
10541102
}
10551103
}
10561104
}
@@ -1063,9 +1111,22 @@ private Set<String> getContainerContentTypes (final String containerId) {
10631111
.orElseThrow(() -> new DoesNotExistException("Container with ID :" + containerId + " not found"));
10641112
final List<ContentType> contentTypes = APILocator.getContainerAPI().getContentTypesInContainer(container);
10651113
return null != contentTypes? contentTypes.stream().map(ContentType::variable).collect(Collectors.toSet()) : Collections.emptySet();
1066-
} catch (DotDataException | DotSecurityException e) {
1067-
1068-
throw new BadRequestException(e, e.getMessage());
1114+
} catch (final DotSecurityException e) {
1115+
// The system user lacking read permission on the container is a server-side
1116+
// misconfiguration, not a client bad request -- surface it as 403 (generic message;
1117+
// full detail logged server-side) rather than 400.
1118+
Logger.error(this, "No permission to read container '" + containerId + "'", e);
1119+
throw new ForbiddenException(e, "Not allowed to read the container");
1120+
} catch (final DotDataException e) {
1121+
// Log full detail server-side; return a generic message so internal identifiers /
1122+
// SQL fragments are not leaked in the response.
1123+
//
1124+
// DoesNotExistException is intentionally NOT caught here: a missing container must
1125+
// surface as a 404 carrying its message. The id in that message is the caller's own
1126+
// input (not sensitive), and the 404 + message is an established API contract verified
1127+
// by the Define_Contentlets_StyleProperties postman test.
1128+
Logger.error(this, "Error retrieving content types for container '" + containerId + "'", e);
1129+
throw new BadRequestException("Error retrieving content types for the container");
10691130
}
10701131
}
10711132

dotcms-integration/src/test/java/com/dotcms/rest/api/v1/page/PageResourceTest.java

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,87 @@ public void testRenderWithContent() throws DotDataException, DotSecurityExceptio
749749
assertEquals(pageView.getNumberContents(), 1);
750750
}
751751

752+
/**
753+
* Method to test: {@link PageResource#loadJson(HttpServletRequest, HttpServletResponse, String, String, String, String, String, String)}
754+
* Given Scenario: A page has a container with a single contentlet, and that contentlet is then
755+
* archived. Archiving keeps the working version (it only sets deleted=true on the
756+
* version info), so a showLive=false lookup still resolves it in EDIT/PREVIEW mode.
757+
* Expected Result: The archived contentlet must NOT be rendered on the page in EDIT or PREVIEW mode,
758+
* consistent with LIVE-mode behavior. See issue #35993.
759+
*/
760+
@Test
761+
public void testArchivedContentNotRenderedInEditAndPreviewMode()
762+
throws DotDataException, DotSecurityException {
763+
764+
final User systemUser = APILocator.getUserAPI().getSystemUser();
765+
final long languageId = 1L;
766+
767+
final ContentType containerContentType = new ContentTypeDataGen().nextPersisted();
768+
final Container localContainer = new ContainerDataGen().withContentType(containerContentType, "")
769+
.friendlyName("container-archived-friendly-name").title("container-archived-title")
770+
.nextPersisted();
771+
772+
final TemplateLayout templateLayout = TemplateLayoutDataGen.get()
773+
.withContainer(localContainer.getIdentifier())
774+
.next();
775+
776+
final Template newTemplate = new TemplateDataGen()
777+
.drawedBody(templateLayout)
778+
.withContainer(localContainer.getIdentifier())
779+
.nextPersisted();
780+
APILocator.getVersionableAPI().setWorking(newTemplate);
781+
APILocator.getVersionableAPI().setLive(newTemplate);
782+
783+
final Contentlet checkout = APILocator.getContentletAPI().checkout(pageAsset.getInode(), systemUser, false);
784+
checkout.setStringProperty(HTMLPageAssetAPI.TEMPLATE_FIELD, newTemplate.getIdentifier());
785+
APILocator.getContentletAPI().checkin(checkout, systemUser, false);
786+
787+
final ContentTypeAPI contentTypeAPI = APILocator.getContentTypeAPI(systemUser);
788+
final ContentType contentGenericType = contentTypeAPI.find("webPageContent");
789+
790+
final ContentletDataGen contentletDataGen = new ContentletDataGen(contentGenericType.id());
791+
final Contentlet contentlet = contentletDataGen.setProperty("title", "title")
792+
.setProperty("body", TestDataUtils.BLOCK_EDITOR_DUMMY_CONTENT).languageId(languageId).nextPersisted();
793+
794+
final MultiTreeAPI multiTreeAPI = APILocator.getMultiTreeAPI();
795+
final MultiTree multiTree = new MultiTree(pageAsset.getIdentifier(), localContainer.getIdentifier(),
796+
contentlet.getIdentifier(), "1", 1);
797+
multiTreeAPI.saveMultiTree(multiTree);
798+
799+
when(request.getAttribute(WebKeys.HTMLPAGE_LANGUAGE)).thenReturn(String.valueOf(languageId));
800+
801+
// Baseline: while the contentlet is live/working it must render in PREVIEW mode.
802+
// This proves the test setup actually places the content on the page.
803+
final int previewCountBeforeArchive = renderAndCountContents(PageMode.PREVIEW_MODE);
804+
assertEquals("Content should render in PREVIEW mode before archiving", 1,
805+
previewCountBeforeArchive);
806+
807+
// Archive the contentlet placed in the container
808+
APILocator.getContentletAPI().archive(contentlet, systemUser, false);
809+
assertTrue("Contentlet should be archived", contentlet.isArchived());
810+
811+
// PREVIEW_MODE: archived content must not render
812+
assertEquals("Archived content must not render in PREVIEW mode", 0,
813+
renderAndCountContents(PageMode.PREVIEW_MODE));
814+
815+
// EDIT_MODE: archived content must not render
816+
assertEquals("Archived content must not render in EDIT mode", 0,
817+
renderAndCountContents(PageMode.EDIT_MODE));
818+
}
819+
820+
/**
821+
* Renders {@code pagePath} in the given {@link PageMode} via {@link PageResource#loadJson} and
822+
* returns the number of contentlets placed in the page's containers.
823+
*/
824+
private int renderAndCountContents(final PageMode mode)
825+
throws DotDataException, DotSecurityException {
826+
final Response response = pageResource
827+
.loadJson(request, this.response, pagePath, mode.name(), null, "1", null, null);
828+
RestUtilTest.verifySuccessResponse(response);
829+
final PageView pageView = (PageView) ((ResponseEntityView) response.getEntity()).getEntity();
830+
return pageView.getNumberContents();
831+
}
832+
752833
@Test
753834
public void shouldReturnPageByURLPattern()
754835
throws DotDataException, DotSecurityException, InterruptedException, SystemException, PortalException {

0 commit comments

Comments
 (0)