fix: isolate multi-embed resize handshake with per-snippet token
ForRelease mints a per-call token used as the iframe id and threaded into the src as EmbedId; the host script matches on it so multiple embeds resize independently. ForTrack unchanged.
This commit is contained in:
@@ -23,6 +23,17 @@ namespace DeepDrftPublic.Client.Helpers;
|
||||
/// outer resize is lost. The track snippet needs no script (no panel, no toggle).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Multi-embed isolation: each <see cref="ForRelease"/> call mints a fresh random token (8 hex
|
||||
/// chars). The token is used as the iframe id (<c>deepdrft-embed-{token}</c>) and threaded into
|
||||
/// the iframe src as <c>&EmbedId={token}</c> so the iframe can learn its own id. The host-side
|
||||
/// resize script matches incoming messages on <c>embedId</c> 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 <c>embedId</c> in their postMessage payload are
|
||||
/// silently ignored by the script (backward-compatible degradation).
|
||||
/// </para>
|
||||
///
|
||||
/// Pure string composition so the snippet shape is unit-testable without rendering the component.
|
||||
/// </summary>
|
||||
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 = "")
|
||||
=> $"""<iframe id="deepdrft-embed" src="{src}" width="656" height="{height}" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>{trailingScript}""";
|
||||
private static string Frame(string src, int height, string iframeId = "deepdrft-embed", string trailingScript = "")
|
||||
=> $"""<iframe id="{iframeId}" src="{src}" width="656" height="{height}" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>{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 =
|
||||
"""<script>window.addEventListener("message",function(e){var d=e.data;if(!d||d.type!=="deepdrft-embed-resize")return;var f=document.getElementById("deepdrft-embed");var h=Number(d.height);if(f&&h>=150)f.style.height=h+"px";});</script>""";
|
||||
// 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) =>
|
||||
"<script>(function(){var id=\"" + iframeId + "\",tok=\"" + token + "\";" +
|
||||
"window.addEventListener(\"message\",function(e){var d=e.data;" +
|
||||
"if(!d||d.type!==\"deepdrft-embed-resize\"||d.embedId!==tok)return;" +
|
||||
"var f=document.getElementById(id);var h=Number(d.height);" +
|
||||
"if(f&&h>=150)f.style.height=h+\"px\";});})();</script>";
|
||||
}
|
||||
|
||||
@@ -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=<token> 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<string, unknown> = { type: MESSAGE_TYPE, height };
|
||||
if (embedId !== null) payload.embedId = embedId;
|
||||
window.parent.postMessage(payload, "*");
|
||||
}
|
||||
|
||||
@@ -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("<script>"));
|
||||
}
|
||||
|
||||
// --- Multi-embed isolation (Phase 17 major remediation) ---
|
||||
|
||||
// Two ForRelease calls must produce snippets with different iframe ids so both can coexist on one
|
||||
// host page without the host-side resize script resolving only the first via getElementById.
|
||||
[Test]
|
||||
public void ForRelease_TwoCalls_ProduceDifferentIframeIds()
|
||||
{
|
||||
var a = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz");
|
||||
var b = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz"); // same release, different call
|
||||
|
||||
var idA = IframeId(a);
|
||||
var idB = IframeId(b);
|
||||
|
||||
Assert.That(idA, Is.Not.EqualTo(idB),
|
||||
"each ForRelease call must mint a distinct iframe id to prevent multi-embed cross-talk");
|
||||
}
|
||||
|
||||
// The iframe id and the token embedded in the host-side resize script must be consistent within
|
||||
// a single snippet — the script assigns the id string to a JS variable and calls getElementById
|
||||
// with it, so the id literal must appear in the script's var initializer.
|
||||
[Test]
|
||||
public void ForRelease_IframeIdAndScriptToken_AreConsistentWithinOneSnippet()
|
||||
{
|
||||
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-abc");
|
||||
|
||||
var id = IframeId(snippet);
|
||||
Assert.That(id, Does.StartWith("deepdrft-embed-"), "id must carry the expected prefix");
|
||||
|
||||
// The iframe element must declare the minted id.
|
||||
Assert.That(snippet, Does.Contain($@"id=""{id}"""),
|
||||
"iframe element must carry the minted id");
|
||||
|
||||
// The script stores the id in a JS var and calls getElementById(id) — confirm the id literal
|
||||
// appears in the script's var initializer so the right iframe is targeted.
|
||||
Assert.That(snippet, Does.Contain($@"var id=""{id}"""),
|
||||
"resize script must initialise its id variable with the same minted id");
|
||||
}
|
||||
|
||||
// The iframe src must carry EmbedId so the iframe content (embed-frame.ts) can read its own
|
||||
// token and include it in postMessage payloads for the host-side script to match on.
|
||||
[Test]
|
||||
public void ForRelease_SrcCarriesEmbedIdParam()
|
||||
{
|
||||
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-def");
|
||||
|
||||
Assert.That(snippet, Does.Contain("EmbedId="),
|
||||
"iframe src must include EmbedId query param so embed-frame.ts can read its own token");
|
||||
}
|
||||
|
||||
private static int HeightOf(string snippet)
|
||||
{
|
||||
var match = Regex.Match(snippet, @"height=""(\d+)""");
|
||||
Assert.That(match.Success, Is.True, "snippet must declare an iframe height");
|
||||
return int.Parse(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
private static string IframeId(string snippet)
|
||||
{
|
||||
var match = Regex.Match(snippet, @"id=""([^""]+)""");
|
||||
Assert.That(match.Success, Is.True, "snippet must declare an iframe id");
|
||||
return match.Groups[1].Value;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user