diff --git a/DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs b/DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs index 0585d7d..34ab1f4 100644 --- a/DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs +++ b/DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs @@ -23,6 +23,17 @@ namespace DeepDrftPublic.Client.Helpers; /// outer resize is lost. The track snippet needs no script (no panel, no toggle). /// /// +/// +/// Multi-embed isolation: each call mints a fresh random token (8 hex +/// chars). The token is used as the iframe id (deepdrft-embed-{token}) and threaded into +/// the iframe src as &EmbedId={token} so the iframe can learn its own id. The host-side +/// resize script matches incoming messages on embedId and resizes only the iframe whose id +/// matches the token — two releases on one host page resize independently without cross-talk. Two +/// calls for the same release still get distinct tokens, ensuring uniqueness even when the same +/// release is pasted twice. Older snippets that lack embedId in their postMessage payload are +/// silently ignored by the script (backward-compatible degradation). +/// +/// /// Pure string composition so the snippet shape is unit-testable without rendering the component. /// public static class EmbedSnippetBuilder @@ -39,14 +50,27 @@ public static class EmbedSnippetBuilder => Frame($"{baseUri}FramePlayer?TrackEntryKey={trackEntryKey}", TrackHeight); public static string ForRelease(string baseUri, string releaseEntryKey) - => Frame($"{baseUri}FramePlayer?ReleaseEntryKey={releaseEntryKey}", ReleaseHeight, ResizeScript); + { + // Mint a fresh random token per call so two embeds on the same host page never share an id, + // even when they point at the same release. + var token = Guid.NewGuid().ToString("N")[..8]; + var iframeId = $"deepdrft-embed-{token}"; + var src = $"{baseUri}FramePlayer?ReleaseEntryKey={releaseEntryKey}&EmbedId={token}"; + return Frame(src, ReleaseHeight, iframeId, ResizeScript(iframeId, token)); + } - private static string Frame(string src, int height, string trailingScript = "") - => $"""{trailingScript}"""; + private static string Frame(string src, int height, string iframeId = "deepdrft-embed", string trailingScript = "") + => $"""{trailingScript}"""; - // Host-side listener: resize #deepdrft-embed when the embedded player posts its panel height. The - // shape/origin-agnostic guard (ignores non-matching messages) keeps a foreign frame from driving - // it; the height is clamped to a sane floor so a bad payload can't collapse the player away. - private const string ResizeScript = - """"""; + // Host-side listener: resize the matching iframe when the embedded player posts its panel height. + // The embedId field in the payload is matched against the snippet's own token so only this + // iframe is driven — foreign frames or other release embeds on the same page cannot interfere. + // The height is clamped to a sane floor so a bad payload can't collapse the player away. + // Messages without embedId (older snippets) are silently ignored. + private static string ResizeScript(string iframeId, string token) => + ""; } diff --git a/DeepDrftPublic/Interop/embed/embed-frame.ts b/DeepDrftPublic/Interop/embed/embed-frame.ts index 3f0f98f..03493fd 100644 --- a/DeepDrftPublic/Interop/embed/embed-frame.ts +++ b/DeepDrftPublic/Interop/embed/embed-frame.ts @@ -9,15 +9,37 @@ // Degrades safely: if the host page ignores or strips the snippet's listener (Option B's value), the // panel still renders and toggles inside the iframe — only the outer resize is lost. We post nothing // when not framed (window === parent), so the docked player is unaffected. +// +// Multi-embed isolation: EmbedSnippetBuilder.ForRelease mints a per-snippet random token and passes +// it as ?EmbedId= in the iframe src. We read it here from window.location.search and include +// it in the postMessage payload as `embedId`. The host-side resize script matches on this token so +// only the correct iframe is resized when multiple embeds share a host page. If EmbedId is absent +// (older already-pasted snippets), embedId is omitted from the payload — those snippets' scripts +// ignore messages without a matching embedId, so there is no cross-talk either way. const MESSAGE_TYPE = "deepdrft-embed-resize"; +/** Read the EmbedId query param from the iframe's own URL, if present. */ +function readEmbedId(): string | null { + try { + return new URLSearchParams(window.location.search).get("EmbedId"); + } catch { + return null; + } +} + +// Resolved once at module load — the URL does not change while the iframe is alive. +const embedId: string | null = readEmbedId(); + /** * Measure the live rendered height of the player element and post it to the host page so it can size * the iframe to match. No-op when not embedded in a frame, or when the element is unmeasurable. * * targetOrigin is "*" deliberately: the embedder's origin is unknown (any blog can embed us) and the * payload carries no secrets — just a height the host is free to ignore. + * + * The payload includes `embedId` when the iframe src carried an EmbedId query param. The host-side + * resize script matches on this field to isolate multiple embeds on the same page. */ export function postHeight(element: HTMLElement): void { if (window.parent === window) return; // Not framed — nothing to resize. @@ -27,5 +49,7 @@ export function postHeight(element: HTMLElement): void { const height = Math.ceil(element.getBoundingClientRect().height) + 2; if (!Number.isFinite(height) || height <= 0) return; - window.parent.postMessage({ type: MESSAGE_TYPE, height }, "*"); + const payload: Record = { type: MESSAGE_TYPE, height }; + if (embedId !== null) payload.embedId = embedId; + window.parent.postMessage(payload, "*"); } diff --git a/DeepDrftTests/EmbedSnippetBuilderTests.cs b/DeepDrftTests/EmbedSnippetBuilderTests.cs index c55e126..e455bd9 100644 --- a/DeepDrftTests/EmbedSnippetBuilderTests.cs +++ b/DeepDrftTests/EmbedSnippetBuilderTests.cs @@ -29,7 +29,8 @@ public class EmbedSnippetBuilderTests { var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz"); - Assert.That(snippet, Does.Contain(@"src=""https://deepdrft.example/FramePlayer?ReleaseEntryKey=rel-xyz""")); + // src contains ReleaseEntryKey; may also carry additional query params (e.g. EmbedId). + Assert.That(snippet, Does.Contain("ReleaseEntryKey=rel-xyz")); Assert.That(snippet, Does.Not.Contain("TrackEntryKey")); } @@ -94,10 +95,66 @@ public class EmbedSnippetBuilderTests Assert.That(track, Does.Not.Contain("