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
32 changes: 29 additions & 3 deletions e2e/slide-media-sync.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,41 @@ test.describe("Slide media sync", () => {
images: ["/v2/media/1"],
title: "Some text",
separator: true,
contacts: [{ name: "John", image: ["/v2/media/2"] }],
contacts: [{ name: "John", image: ["/v2/media/2"], tags: ["news"] }],
};
const mediaFields = [];

const result = rebuildMediaFromContent(content, mediaFields);

// images field is picked up via top-level scan
expect(result).toContain("/v2/media/1");
// contacts is an array of objects, not strings — objects are skipped
expect(result).not.toContain("/v2/media/2");
expect(result).toContain("/v2/media/2");
expect(result).not.toContain("news");
});

test("It does not include non-media string arrays from content", () => {
// Only actual media IRIs should be returned.
const content = {
images: ["/v2/media/1"],
tags: ["news", "sports"],
};
const mediaFields = []; // rely on top-level scan

const result = rebuildMediaFromContent(content, mediaFields);

expect(result).toEqual(["/v2/media/1"]);
expect(result).not.toContain("news");
expect(result).not.toContain("sports");
});

test("It avoids infinite recursion when content contains circular references", () => {
const circular = { images: ["/v2/media/1"] };
circular.self = circular; // create an explicit cycle

// If we didn't track `seen`, this would crash.
expect(() => rebuildMediaFromContent(circular, [])).not.toThrow();

const result = rebuildMediaFromContent(circular, []);
expect(result).toContain("/v2/media/1");
});
});
48 changes: 39 additions & 9 deletions src/components/slide/slide-media-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,51 @@ export default function rebuildMediaFromContent(content, mediaFields) {
const media = [];
const fieldsToScan = new Set(mediaFields);

// Also scan top-level content keys to catch media fields not yet
// tracked via handleMedia (e.g. on first edit of another field).
const mediaIriRegex = /\/v2\/media\/.+/;

const isMediaIri = (value) =>
typeof value === "string" &&
!value.startsWith("TEMP--") &&
mediaIriRegex.test(value);

const collectMediaFromValue = (value, seen = new Set()) => {
// 1) Ignore empty values early (nothing to scan)
if (value === null || value === undefined) return;

// 2) If it's a string, it might be a media IRI; validate and collect it
if (typeof value === "string") {
if (isMediaIri(value)) media.push(value);
return;
}

// 3) If it's not an object (e.g. number/boolean/function), it cannot contain nested media
if (typeof value !== "object") return;

// 4) Defensive guard against circular references:
// - JSON content won't have cycles, but runtime objects might.
// - If we've seen this object/array already, stop to avoid infinite recursion.
if (seen.has(value)) return;
seen.add(value);

// 5) If it's an array, scan each element (elements can be strings, objects, or more arrays)
if (Array.isArray(value)) {
value.forEach((item) => collectMediaFromValue(item, seen));
return;
}

// 6) Otherwise it's a plain object: scan its property values recursively
Object.values(value).forEach((item) => collectMediaFromValue(item, seen));
};

// Also, scan top-level content keys to catch media fields not yet
// tracked via handleMedia (e.g. on the first edit of another field).
if (content && typeof content === "object") {
Object.keys(content).forEach((key) => fieldsToScan.add(key));
}

fieldsToScan.forEach((fieldName) => {
const fieldData = get(content, fieldName);
if (Array.isArray(fieldData)) {
fieldData.forEach((mediaId) => {
if (typeof mediaId === "string" && !mediaId.startsWith("TEMP--")) {
media.push(mediaId);
}
});
}
collectMediaFromValue(fieldData);
});

return [...new Set(media)];
Expand Down