Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ private int getCurrentDepthValue(final HttpServletRequest request) {
@SuppressWarnings("unchecked")
public StoryBlockReferenceResult refreshStoryBlockValueReferences(final Object storyBlockValue, final String parentContentletIdentifier) {
boolean refreshed;
if (null != storyBlockValue && JsonUtil.isValidJSON(storyBlockValue.toString())) {
if (null != storyBlockValue && isJsonObject(storyBlockValue.toString())) {
try {
final LinkedHashMap<String, Object> blockEditorMap = this.toMap(storyBlockValue);
final Object contentsMap = blockEditorMap.get(CONTENT_KEY);
Expand Down Expand Up @@ -227,15 +227,23 @@ private boolean isRefreshed(final String parentContentletIdentifier,
boolean refreshed = false;
for (final Map<String, Object> contentMap : contentsMap) {
if (UtilMethods.isSet(contentMap)) {
final String type = contentMap.get(TYPE_KEY).toString();
if (allowedTypes.contains(type)) { // if somebody adds a story block to itself, we don't want to refresh it
// Isolate per-block failures so that one bad nested reference
// does not prevent the rest of the Story Block from refreshing.
try {
final String type = contentMap.get(TYPE_KEY).toString();
if (allowedTypes.contains(type)) { // if somebody adds a story block to itself, we don't want to refresh it

refreshed |= this.refreshStoryBlockMap(contentMap, parentContentletIdentifier);
} else {
final Object nestedContent = contentMap.get(CONTENT_KEY);
if (nestedContent instanceof List) {
refreshed |= this.isRefreshed(parentContentletIdentifier, (List<Map<String, Object>>) nestedContent);
refreshed |= this.refreshStoryBlockMap(contentMap, parentContentletIdentifier);
} else {
final Object nestedContent = contentMap.get(CONTENT_KEY);
if (nestedContent instanceof List) {
refreshed |= this.isRefreshed(parentContentletIdentifier, (List<Map<String, Object>>) nestedContent);
}
}
} catch (final Exception e) {
Logger.warnAndDebug(StoryBlockAPIImpl.class, String.format(
"Skipping Story Block child while refreshing parent '%s': %s",
parentContentletIdentifier, ExceptionUtil.getErrorMessage(e)), e);
}
}
}
Expand Down Expand Up @@ -338,7 +346,7 @@ public List<StoryBlockDependency> getDependencies(final Object storyBlockValue)

try {

if (null != storyBlockValue && JsonUtil.isValidJSON(storyBlockValue.toString())) {
if (null != storyBlockValue && isJsonObject(storyBlockValue.toString())) {
final Map<String, Object> blockEditorMap = this.toMap(storyBlockValue);
Object contentsMap = blockEditorMap.getOrDefault(CONTENT_KEY, List.of());
if(!(contentsMap instanceof List)) {
Expand Down Expand Up @@ -570,6 +578,22 @@ private static void addDependencies(final ImmutableList.Builder<StoryBlockDepend
}
}

/**
* Returns {@code true} when the supplied String is valid JSON whose root
* token is an object. Story Block documents are always JSON objects, so
* scalar JSON tokens (numbers, strings, booleans) and arrays must be
* rejected here — otherwise {@link #toMap(Object)} fails to deserialize
* them into a {@link LinkedHashMap} and the entire transformer pipeline
* aborts (see issue surfaced via /api/content/_search).
*/
private static boolean isJsonObject(final String value) {
if (value == null) {
return false;
}
final String trimmed = value.trim();
return trimmed.startsWith("{") && JsonUtil.isValidJSON(trimmed);
}

@Override
@SuppressWarnings("unchecked")
public LinkedHashMap<String, Object> toMap(final Object blockEditorValue) throws JsonProcessingException {
Comment thread
wezell marked this conversation as resolved.
Expand Down Expand Up @@ -746,25 +770,36 @@ private Map<String, Object> refreshContentlet(final Contentlet contentlet)
for (final Field field : fields) {
final Object value = contentlet.get(field.variable());
if (null != value) {
if (field instanceof StoryBlockField) {
// At this depth, if the Contentlet inside the Block Editor also has a Block
// Editor field, we'll return the raw JSON data of any potential Contentlets it
// is referencing. This will prevent infinite recursion problems.
// Prefer the _raw companion field when it contains valid JSON; otherwise fall
// back to the story block value itself. If neither is valid JSON (e.g. a test
// or misconfigured field whose default value is plain text), skip the field
// entirely so the rest of the data map is still populated correctly.
final Object rawValue = contentlet.get(field.variable() + "_raw");
final String rawStr = rawValue != null ? rawValue.toString() : null;
if (rawStr != null && JsonUtil.isValidJSON(rawStr)) {
dataMap.put(field.variable(), this.toMap(rawValue));
} else if (JsonUtil.isValidJSON(value.toString())) {
dataMap.put(field.variable(), this.toMap(value));
// Isolate per-field failures so that one malformed field on a
// nested contentlet does not abort hydration of the rest.
try {
if (field instanceof StoryBlockField) {
// At this depth, if the Contentlet inside the Block Editor also has a Block
// Editor field, we'll return the raw JSON data of any potential Contentlets it
// is referencing. This will prevent infinite recursion problems.
// Prefer the _raw companion field when it contains valid JSON; otherwise fall
// back to the story block value itself. If neither is valid JSON (e.g. a test
// or misconfigured field whose default value is plain text), skip the field
// entirely so the rest of the data map is still populated correctly.
final Object rawValue = contentlet.get(field.variable() + "_raw");
final String rawStr = rawValue != null ? rawValue.toString() : null;
if (rawStr != null && isJsonObject(rawStr)) {
dataMap.put(field.variable(), this.toMap(rawValue));
} else if (isJsonObject(value.toString())) {
dataMap.put(field.variable(), this.toMap(value));
}
} else {
dataMap.putIfAbsent(field.variable(),
this.refreshNestedStoryBlockValues(value, contentlet.getIdentifier(),
MAX_NESTED_STORY_BLOCK_REFRESH_DEPTH));
}
} else {
dataMap.putIfAbsent(field.variable(),
this.refreshNestedStoryBlockValues(value, contentlet.getIdentifier(),
MAX_NESTED_STORY_BLOCK_REFRESH_DEPTH));
} catch (final Exception e) {
Logger.warnAndDebug(StoryBlockAPIImpl.class, String.format(
"Skipping field '%s' while hydrating contentlet '%s': %s",
field.variable(), contentlet.getIdentifier(),
ExceptionUtil.getErrorMessage(e)), e);
// Fall back to the raw value so the field still appears in the response.
dataMap.putIfAbsent(field.variable(), value);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.dotcms.contenttype.model.type.FileAssetContentType;
import com.dotcms.contenttype.transform.field.LegacyFieldTransformer;
import com.dotcms.util.ConversionUtils;
import com.dotcms.exception.ExceptionUtil;
import com.dotcms.util.transform.DBTransformer;
import com.dotmarketing.beans.Host;
import com.dotmarketing.beans.Identifier;
Expand Down Expand Up @@ -178,10 +179,22 @@ private static String replaceBadContentTypes(String jsonStringIn, String content
* @param contentlet The {@link Contentlet} whose Story Block fields will be inspected.
*/
private static void refreshStoryBlockReferences(final Contentlet contentlet) {
final StoryBlockReferenceResult result = APILocator.getStoryBlockAPI().refreshReferences(contentlet);
if (result.isRefreshed()) {
Logger.debug(ContentletTransformer.class,
()-> "Refreshed story block dependencies for the contentlet: " + contentlet.getIdentifier());
try {
final StoryBlockReferenceResult result = APILocator.getStoryBlockAPI().refreshReferences(contentlet);
if (result.isRefreshed()) {
Logger.debug(ContentletTransformer.class,
() -> "Refreshed story block dependencies for the contentlet: " + contentlet.getIdentifier());
}
} catch (final Exception e) {
// Story Block hydration is a best-effort enrichment. A single bad
// contentlet (e.g. malformed JSON, scalar-only field values, broken
// reference) must not abort the entire transform and take down the
// surrounding /api/content/_search response.
Logger.warnAndDebug(ContentletTransformer.class, String.format(
"Failed to refresh Story Block references for contentlet '%s' (inode '%s'); "
+ "returning the contentlet with un-refreshed Story Block data: %s",
contentlet.getIdentifier(), contentlet.getInode(),
ExceptionUtil.getErrorMessage(e)), e);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,131 @@ public void test_refreshStoryBlockValueReferences_with_json_value() {

}

/**
* Method to test: {@link StoryBlockAPI#refreshStoryBlockValueReferences(Object, String)}
* Given Scenario: A non-object JSON scalar (number, string, boolean, array) is passed in —
* these are valid JSON tokens but are not Story Block documents. They can reach this method
* via {@code refreshNestedStoryBlockValues} when iterating over scalar field values on
* related contentlets.
* ExpectedResult: No exception must be thrown and the original value must be returned
* unchanged. Regression test for "/api/content/_search failing with
* MismatchedInputException: Cannot deserialize value of type LinkedHashMap from Integer".
*/
@Test
public void test_refreshStoryBlockValueReferences_with_non_object_json_scalars() {
final StoryBlockAPI storyBlockAPI = APILocator.getStoryBlockAPI();

// Bare integer — valid JSON, but not an object. Was the trigger of the original bug.
StoryBlockReferenceResult result = storyBlockAPI.refreshStoryBlockValueReferences("42", "parent-id");
assertNotNull(result);
assertFalse(result.isRefreshed());
assertEquals("42", result.getValue());

// Bare quoted string — also valid JSON.
result = storyBlockAPI.refreshStoryBlockValueReferences("\"hello\"", "parent-id");
assertNotNull(result);
assertFalse(result.isRefreshed());

// Bare boolean.
result = storyBlockAPI.refreshStoryBlockValueReferences("true", "parent-id");
assertNotNull(result);
assertFalse(result.isRefreshed());

// JSON array — valid JSON, but not a Story Block document object.
result = storyBlockAPI.refreshStoryBlockValueReferences("[1,2,3]", "parent-id");
assertNotNull(result);
assertFalse(result.isRefreshed());
Comment thread
fmontes marked this conversation as resolved.

// Untransformed HTML body content — not JSON at all, must be returned untouched.
final String html = "<p>Hello <strong>world</strong></p>";
result = storyBlockAPI.refreshStoryBlockValueReferences(html, "parent-id");
assertNotNull(result);
assertFalse(result.isRefreshed());
assertEquals(html, result.getValue());
}

/**
* Method to test: {@link StoryBlockAPI#refreshStoryBlockValueReferences(Object, String)}
* Given Scenario: A Story Block document that contains two children, where the first
* child is malformed (missing the {@code type} key, which would have caused a
* NullPointerException inside {@code isRefreshed}). The second child is well-formed.
* ExpectedResult: No exception is propagated. The bad child is skipped and the call
* still returns a non-null result so the surrounding contentlet (and the rest of the
* search response) is not aborted by a single bad nested reference.
*/
/**
* Method to test: {@link StoryBlockAPI#refreshReferences(Contentlet)}
* Given Scenario: A contentlet has a Story Block field whose value contains a malformed
* nested child (missing the {@code type} key).
* ExpectedResult: refreshReferences must complete normally (no thrown exception). This is
* the resilience boundary that prevents one bad contentlet from aborting an entire
* /api/content/_search response when ContentletTransformer iterates over the result set.
*/
@Test
public void test_refreshReferences_does_not_throw_on_malformed_nested_block()
throws DotDataException, DotSecurityException {
ContentType storyBlockType = null;
try {
// Reuse an existing helper pattern: any content type with a Story Block field.
final long timestamp = System.currentTimeMillis();
storyBlockType = new ContentTypeDataGen()
.name("storyBlockResilience" + timestamp)
.velocityVarName("storyBlockResilience" + timestamp)
.nextPersisted();
final Field storyBlockField = new FieldDataGen()
.type(StoryBlockField.class)
.contentTypeId(storyBlockType.id())
.nextPersisted();

final String malformedStoryBlock =
"{"
+ "\"type\":\"doc\","
+ "\"attrs\":{},"
+ "\"content\":["
+ " {\"attrs\":{\"data\":{\"identifier\":\"missing-type-key\"}}}"
+ "]"
+ "}";

final Contentlet contentlet = new ContentletDataGen(storyBlockType.id())
.languageId(APILocator.getLanguageAPI().getDefaultLanguage().getId())
.setProperty(storyBlockField.variable(), malformedStoryBlock)
.nextPersisted();

try {
APILocator.getStoryBlockAPI().refreshReferences(contentlet);
} catch (final Throwable t) {
Assert.fail("refreshReferences must not propagate exceptions for a single "
+ "malformed Story Block: " + t.getMessage());
}
} finally {
if (storyBlockType != null) {
ContentTypeDataGen.remove(storyBlockType);
}
}
}

@Test
public void test_refreshStoryBlockValueReferences_isolates_bad_child_block() {
final String storyBlockWithBadChild =
"{"
+ "\"type\":\"doc\","
+ "\"attrs\":{},"
+ "\"content\":["
+ " {\"attrs\":{\"data\":{\"identifier\":\"missing-type-key\"}}},"
+ " {\"type\":\"paragraph\",\"content\":[]}"
+ "]"
+ "}";

StoryBlockReferenceResult result = null;
try {
result = APILocator.getStoryBlockAPI()
.refreshStoryBlockValueReferences(storyBlockWithBadChild, "parent-resilience");
} catch (final Throwable t) {
Assert.fail("A malformed nested block must not abort the parent refresh: " + t.getMessage());
}
assertNotNull(result);
}

/**
* Method to test: {@link StoryBlockAPI#refreshReferences(Contentlet)}
* Given Scenario: This will create 2 block contents, adds a rich content to each block content and retrieve the json.
Expand Down
Loading