From 22e81c9bc8e0ffca067d693b3140e350bb9b9781 Mon Sep 17 00:00:00 2001 From: Cestercian Date: Sat, 19 Sep 2026 21:13:17 +0000 Subject: [PATCH] fix(web): keep desktop annotation screenshots under CSP Windows and other desktop annotation crops were dropped because fetch(dataUrl) is a connect-src violation. Decode the PNG with dataUrlToFile instead and leave CSP unchanged. Fixes pingdotgg/t3code#12265. Co-authored-by: Cestercian --- .../components/preview/PreviewView.test.tsx | 12 +-- .../src/components/preview/PreviewView.tsx | 2 +- apps/web/src/lib/previewAnnotation.test.ts | 74 +++++++------------ apps/web/src/lib/previewAnnotation.ts | 45 ++++------- 4 files changed, 44 insertions(+), 89 deletions(-) diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index ea28a93235ee..e5b40c907b14 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -27,7 +27,6 @@ const mocks = vi.hoisted(() => ({ openPictureInPicture: vi.fn(async (_tabId: string): Promise => undefined), closePictureInPicture: vi.fn(async (_tabId: string): Promise => undefined), pickElement: vi.fn(), - capturePreviewAnnotationScreenshot: vi.fn(), addPreviewAnnotation: vi.fn(), addImage: vi.fn(), toggleAnnotation: null as (() => void) | null, @@ -90,10 +89,6 @@ vi.mock("~/composerDraftStore", () => ({ }), })); -vi.mock("~/lib/previewAnnotation", () => ({ - capturePreviewAnnotationScreenshot: mocks.capturePreviewAnnotationScreenshot, -})); - vi.mock("~/localApi", () => ({ ensureLocalApi: vi.fn(), })); @@ -342,8 +337,6 @@ describe("PreviewView navigation", () => { mocks.openPictureInPicture.mockClear(); mocks.closePictureInPicture.mockClear(); mocks.pickElement.mockReset(); - mocks.capturePreviewAnnotationScreenshot.mockReset(); - mocks.capturePreviewAnnotationScreenshot.mockResolvedValue({ status: "none" }); mocks.addPreviewAnnotation.mockClear(); vi.mocked(toastManager.add).mockClear(); mocks.addImage.mockClear(); @@ -585,7 +578,7 @@ describe("PreviewView navigation", () => { expect(toastManager.add).toHaveBeenCalledTimes(1); }); - it("still sends when the picked element's crop cannot be captured", async () => { + it("still sends annotation text when the picked element's crop is malformed", async () => { const annotation = { id: "annotation-2", pageUrl: "https://example.com/dashboard", @@ -596,7 +589,7 @@ describe("PreviewView navigation", () => { strokes: [], styleChanges: [], screenshot: { - dataUrl: "data:image/png;base64,c2NyZWVuc2hvdA==", + dataUrl: "data:image/png;base64,%%%", width: 10, height: 10, cropRect: { x: 0, y: 0, width: 10, height: 10 }, @@ -605,7 +598,6 @@ describe("PreviewView navigation", () => { }; const onSendAnnotation = vi.fn(); mocks.pickElement.mockResolvedValue({ annotation, submission: "send" }); - mocks.capturePreviewAnnotationScreenshot.mockResolvedValue({ status: "failed" }); renderToStaticMarkup( { afterEach(() => { - vi.useRealTimers(); vi.unstubAllGlobals(); }); - it("returns the crop when the fetch resolves", async () => { - vi.stubGlobal("fetch", async () => new Response(new Blob(["png"], { type: "image/png" }))); - const capture = await capturePreviewAnnotationScreenshot(annotation); + it("preserves the PNG bytes, MIME type, and filename without fetching", async () => { + const fetch = vi.fn().mockRejectedValue(new TypeError("Blocked by connect-src")); + vi.stubGlobal("fetch", fetch); + const capture = capturePreviewAnnotationScreenshot(annotation); expect(capture.status).toBe("captured"); + if (capture.status !== "captured") throw new Error("Expected a screenshot file"); + expect(capture.file.name).toBe("preview-annotation-annotation_1.png"); + expect(capture.file.type).toBe("image/png"); + expect(Buffer.from(await capture.file.arrayBuffer()).toString("hex")).toBe( + "89504e470d0a1a0a0000000d4948445200000001000000010804000000b51c0c020000000b4944415478da63fcff1f0003030200efa2a75b0000000049454e44ae426082", + ); + expect(fetch).not.toHaveBeenCalled(); }); - it("reports none when the annotation carries no crop", async () => { - const capture = await capturePreviewAnnotationScreenshot({ ...annotation, screenshot: null }); + it("reports none when the annotation carries no crop", () => { + const capture = capturePreviewAnnotationScreenshot({ ...annotation, screenshot: null }); expect(capture).toEqual({ status: "none" }); }); - it("fails instead of hanging when the crop never arrives", async () => { - vi.useFakeTimers(); - vi.stubGlobal("fetch", () => new Promise(() => {})); - const capturePromise = capturePreviewAnnotationScreenshot(annotation, 1_000); - await vi.advanceTimersByTimeAsync(1_000); - expect(await capturePromise).toEqual({ status: "failed" }); - }); - - it("fails when the crop fetch throws", async () => { - vi.stubGlobal("fetch", async () => { - throw new Error("data url unreadable"); - }); - expect(await capturePreviewAnnotationScreenshot(annotation)).toEqual({ status: "failed" }); - }); + it.each(["data:image/jpeg;base64,AA==", "data:image/png;base64,", "data:image/png;base64,%%%"])( + "reports a malformed screenshot as failed: %s", + (dataUrl) => { + const picked = { ...annotation, screenshot: { ...annotation.screenshot!, dataUrl } }; + expect(capturePreviewAnnotationScreenshot(picked)).toEqual({ status: "failed" }); + }, + ); }); diff --git a/apps/web/src/lib/previewAnnotation.ts b/apps/web/src/lib/previewAnnotation.ts index 1b4a449e5098..e3dda16f121f 100644 --- a/apps/web/src/lib/previewAnnotation.ts +++ b/apps/web/src/lib/previewAnnotation.ts @@ -1,49 +1,30 @@ import type { PreviewAnnotationPayload } from "@t3tools/contracts"; -async function previewAnnotationScreenshotFile( - annotation: PreviewAnnotationPayload, -): Promise { - if (!annotation.screenshot) return null; - const response = await fetch(annotation.screenshot.dataUrl); - const blob = await response.blob(); - return new File([blob], `preview-annotation-${annotation.id}.png`, { - type: blob.type || "image/png", - }); -} - -/** Upper bound on turning a picked element's crop into a composer attachment. */ -const PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS = 5_000; +import { dataUrlToFile } from "./imageCompression"; export type PreviewAnnotationCapture = /** The crop is ready to attach. */ | { readonly status: "captured"; readonly file: File } /** The pick carried no crop, which is normal for comment-only annotations. */ | { readonly status: "none" } - /** The crop stalled or threw. Send the annotation without it. */ + /** The crop could not be decoded. Send the annotation without it. */ | { readonly status: "failed" }; -/** - * Bounded wrapper around `previewAnnotationScreenshotFile`. The picker holds the - * composer while this runs, so it must always settle: a stalled crop resolves as - * `failed` instead of leaving the caller waiting. - */ -export async function capturePreviewAnnotationScreenshot( +const PNG_DATA_URL_PREFIX = "data:image/png;base64,"; + +/** Decode Electron's PNG crop locally; fetching a data URL violates desktop connect-src. */ +export function capturePreviewAnnotationScreenshot( annotation: PreviewAnnotationPayload, - timeoutMs: number = PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS, -): Promise { +): PreviewAnnotationCapture { if (!annotation.screenshot) return { status: "none" }; - let timer: ReturnType | undefined; try { - const file = await Promise.race([ - previewAnnotationScreenshotFile(annotation), - new Promise((resolve) => { - timer = setTimeout(() => resolve(null), timeoutMs); - }), - ]); - return file ? { status: "captured", file } : { status: "failed" }; + const { dataUrl } = annotation.screenshot; + if (!dataUrl.startsWith(PNG_DATA_URL_PREFIX)) { + return { status: "failed" }; + } + const file = dataUrlToFile(dataUrl, `preview-annotation-${annotation.id}.png`, "image/png"); + return file.size > 0 ? { status: "captured", file } : { status: "failed" }; } catch { return { status: "failed" }; - } finally { - clearTimeout(timer); } }