diff --git a/e2e/slide-media-sync.spec.js b/e2e/slide-media-sync.spec.js index bbcb9c51..65962b50 100644 --- a/e2e/slide-media-sync.spec.js +++ b/e2e/slide-media-sync.spec.js @@ -112,7 +112,7 @@ 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 = []; @@ -120,7 +120,33 @@ test.describe("Slide media sync", () => { // 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"); }); }); diff --git a/src/components/slide/slide-media-utils.js b/src/components/slide/slide-media-utils.js index 70d4df9e..67bfb0f4 100644 --- a/src/components/slide/slide-media-utils.js +++ b/src/components/slide/slide-media-utils.js @@ -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)];