From aa4f76188bd4a4de221d594c5483474ce205ec14 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 4 Aug 2026 21:52:45 -0700 Subject: [PATCH] fix(desktop): preserve sends with invalid link previews Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../messages/ui/useComposerLinkPreviews.tsx | 1 + .../shared/lib/linkPreviewSnapshot.test.mjs | 25 ++++++++++++++++ desktop/src/shared/lib/linkPreviewSnapshot.ts | 18 ++++++++++-- desktop/src/testing/e2eBridge.ts | 29 +++++++++++++++++++ desktop/tests/e2e/messaging.spec.ts | 29 +++++++++++++++---- 5 files changed, 94 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx index 5788d57550..c1665d4f35 100644 --- a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx @@ -192,6 +192,7 @@ export function useComposerLinkPreviews(content: string) { faviconUrl: favicon.url, faviconSha256: favicon.sha256, }); + if (!tag) return; setReadyTags((current) => ({ ...current, [preview.href]: tag })); }) .catch(() => {}) diff --git a/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs b/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs index 2a84e2b766..8b4bbf2756 100644 --- a/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs +++ b/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs @@ -70,6 +70,7 @@ test("snapshot tags sanitize control characters and enforce UTF-8 byte limits", faviconSha256: "", }); + assert.ok(tag); for (const value of tag.slice(4, 7)) { assert.ok( Array.from(value).every((char) => { @@ -83,6 +84,30 @@ test("snapshot tags sanitize control characters and enforce UTF-8 byte limits", assert.ok(Buffer.byteLength(tag[6], "utf8") <= 1000); }); +test("snapshot tags omit canonical URLs rejected by native validation", () => { + for (const canonicalUrl of [ + `${URL}#`, + `${URL}#details`, + `http://linear.app/acme/issue/ABC-123/example`, + `https://user@linear.app/acme/issue/ABC-123/example`, + ]) { + assert.equal( + buildLinkPreviewSnapshotTag({ + canonicalUrl, + title: "Example", + siteName: "Linear", + description: "Description", + imageUrl: "", + imageSha256: "", + faviconUrl: "", + faviconSha256: "", + }), + null, + canonicalUrl, + ); + } +}); + test("messages without authored snapshots never create recipient previews", () => { assert.deepEqual(parseLinkPreviewSnapshots([], CONTENT, ORIGIN), []); assert.deepEqual(parseLinkPreviewSnapshots(undefined, CONTENT, ORIGIN), []); diff --git a/desktop/src/shared/lib/linkPreviewSnapshot.ts b/desktop/src/shared/lib/linkPreviewSnapshot.ts index 6ed73df179..d3dc00eac2 100644 --- a/desktop/src/shared/lib/linkPreviewSnapshot.ts +++ b/desktop/src/shared/lib/linkPreviewSnapshot.ts @@ -9,6 +9,18 @@ const MAX_SNAPSHOTS = 8; const SHA256_RE = /^[0-9a-f]{64}$/; const IMAGE_EXT_RE = /^(?:jpg|png|gif|webp)$/; +export function isValidLinkPreviewSnapshotCanonicalUrl(value: string): boolean { + if (value.includes("#")) return false; + try { + const url = new URL(value); + return ( + url.protocol === "https:" && !url.username && !url.password && !url.hash + ); + } catch { + return false; + } +} + export type LinkPreviewSnapshot = { canonicalUrl: string; title: string; @@ -40,7 +52,7 @@ function sanitizeSnapshotText(value: string, maxBytes: number): string { function validText(value: string, max: number): boolean { return ( - value.length <= max && + new TextEncoder().encode(value).length <= max && !Array.from(value).some((char) => { const code = char.charCodeAt(0); return code <= 0x1f || code === 0x7f; @@ -144,7 +156,9 @@ export function parseLinkPreviewSnapshots( export function buildLinkPreviewSnapshotTag( snapshot: LinkPreviewSnapshot, -): string[] { +): string[] | null { + if (!isValidLinkPreviewSnapshotCanonicalUrl(snapshot.canonicalUrl)) + return null; return [ "link-preview", "snapshot", diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 68a8723203..1607649384 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -62,6 +62,10 @@ import type { RuntimeFileConfigSubset, } from "@/shared/api/tauri"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + isValidLinkPreviewSnapshotCanonicalUrl, + parseLinkPreviewSnapshots, +} from "@/shared/lib/linkPreviewSnapshot"; type TestIdentity = { privateKey: string; @@ -8976,6 +8980,31 @@ async function handleSendChannelMessage( // real command and echoed on the stored event. Preserve them in the mock so // E2E recipient rendering exercises the same authored-snapshot path. const linkPreviewTags = args.linkPreviewTags ?? []; + if (linkPreviewTags.length > 0) { + const suppression = linkPreviewTags.some( + (tag) => + tag.length === 2 && tag[0] === "link-preview" && tag[1] === "none", + ); + const snapshots = linkPreviewTags.filter( + (tag) => tag[0] === "link-preview" && tag[1] === "snapshot", + ); + const validSnapshots = parseLinkPreviewSnapshots( + snapshots, + args.content, + new URL(getRelayHttpUrl(config)).origin, + ); + if ( + (suppression && linkPreviewTags.length !== 1) || + (!suppression && + (snapshots.length !== linkPreviewTags.length || + validSnapshots.length !== snapshots.length || + snapshots.some( + (tag) => !isValidLinkPreviewSnapshotCanonicalUrl(tag[3] ?? ""), + ))) + ) { + throw new Error("invalid link-preview snapshot tag"); + } + } // Both kinds end up on the stored event's tag set, just like the real relay. const extraTags = [ ...mediaTags, diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 585bd740b1..31cdbdbed9 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -532,14 +532,31 @@ for (const [pasteShape, wrapUrl] of [ }); } -test("completed link preview sends once and leaves the composer cleared", async ({ +test("completed link previews send when one URL has an unsnapshotable fragment", async ({ page, }) => { - const previewUrl = "https://github.com/block/buzz/pull/3246?send=complete"; + const previewUrls = [ + "https://twitter.com/tellaho", + "https://github.com/block/buzz/pull/3246", + "https://x.com/tellaho/status/1884289176381841506#", + ]; + const pastedText = previewUrls.join("\n"); await page.goto("/"); await page.getByTestId("channel-general").click(); - await page.getByTestId("message-input").fill(previewUrl); - await waitForReadyComposerSnapshots(page); + const input = page.getByTestId("message-input"); + await input.focus(); + await input.evaluate((element, text) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + element.dispatchEvent( + new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); + }, pastedText); + await waitForReadyComposerSnapshots(page, 2); const send = page.getByTestId("send-message"); await expect(send).toBeEnabled(); @@ -558,8 +575,8 @@ test("completed link preview sends once and leaves the composer cleared", async expect( ( calls[0]?.payload as { linkPreviewTags?: string[][] | null } | undefined - )?.linkPreviewTags?.[0]?.slice(0, 4), - ).toEqual(["link-preview", "snapshot", "1", previewUrl]); + )?.linkPreviewTags?.map((tag) => tag[3]), + ).toEqual(previewUrls.slice(0, 2)); }); test("send does not wait for a pending link preview snapshot", async ({