From 8f9ba701bed2b819167bf8062d437779a61b2e79 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 21:38:12 +0800 Subject: [PATCH] fix: QR code logo causes preview to vanish and become unrecoverable The QR code generator's logo feature was broken in production (Docker) due to three interacting issues: 1. The CSP connect-src directive did not include data:, so the qr-code-styling library's internal XHR to convert logo data URLs to blobs was silently blocked. The library has no onerror handler, so the render promise hung forever after the container was already cleared. 2. crossOrigin: "anonymous" was unnecessarily set on imageOptions for data URLs, which can cause canvas taint issues. 3. The logo options used a conditional spread that omitted the image key when no logo was set. The library's update() deep-merges options, so removing the logo preserved the stale data URL and the QR stayed broken even after logo removal. Closes #121 --- apps/api/src/lib/csp.ts | 2 +- .../components/tools/qr-generate-preview.tsx | 17 +++++--------- .../components/tools/qr-generate-settings.tsx | 17 +++++--------- tests/unit/api/csp.test.ts | 8 +++++++ tests/unit/web/zustand-stores.test.ts | 22 +++++++++++++++++++ 5 files changed, 43 insertions(+), 23 deletions(-) diff --git a/apps/api/src/lib/csp.ts b/apps/api/src/lib/csp.ts index b5bfa269..be49b7eb 100644 --- a/apps/api/src/lib/csp.ts +++ b/apps/api/src/lib/csp.ts @@ -3,7 +3,7 @@ const SENTRY_ORIGINS = ["https://*.ingest.us.sentry.io"]; const SCALAR_FONT_ORIGIN = "https://fonts.scalar.com"; export function buildCsp(isDocs: boolean): string { - const connectSrc = ["'self'", ...POSTHOG_ORIGINS, ...SENTRY_ORIGINS].join(" "); + const connectSrc = ["'self'", "data:", ...POSTHOG_ORIGINS, ...SENTRY_ORIGINS].join(" "); const fontSrc = isDocs ? `'self' data: ${SCALAR_FONT_ORIGIN}` : "'self' data:"; const scriptSrc = isDocs ? "'self' 'unsafe-inline' https://us-assets.i.posthog.com" diff --git a/apps/web/src/components/tools/qr-generate-preview.tsx b/apps/web/src/components/tools/qr-generate-preview.tsx index 487c7d07..48319bca 100644 --- a/apps/web/src/components/tools/qr-generate-preview.tsx +++ b/apps/web/src/components/tools/qr-generate-preview.tsx @@ -56,17 +56,12 @@ export function QrGeneratePreview() { color: store.useCustomCornerColors ? store.cornerDotColor : undefined, }, backgroundOptions: store.bgTransparent ? { color: "transparent" } : { color: store.bgColor }, - ...(store.logoDataUrl - ? { - image: store.logoDataUrl, - imageOptions: { - hideBackgroundDots: store.hideBackgroundDots, - imageSize: store.logoSize, - margin: store.logoMargin, - crossOrigin: "anonymous" as const, - }, - } - : {}), + image: store.logoDataUrl || "", + imageOptions: { + hideBackgroundDots: store.hideBackgroundDots, + imageSize: store.logoSize, + margin: store.logoMargin, + }, }; }, [ data, diff --git a/apps/web/src/components/tools/qr-generate-settings.tsx b/apps/web/src/components/tools/qr-generate-settings.tsx index ccffc71d..563c65fd 100644 --- a/apps/web/src/components/tools/qr-generate-settings.tsx +++ b/apps/web/src/components/tools/qr-generate-settings.tsx @@ -384,17 +384,12 @@ export function QrGenerateSettings() { color: store.useCustomCornerColors ? store.cornerDotColor : undefined, }, backgroundOptions: store.bgTransparent ? { color: "transparent" } : { color: store.bgColor }, - ...(store.logoDataUrl - ? { - image: store.logoDataUrl, - imageOptions: { - hideBackgroundDots: store.hideBackgroundDots, - imageSize: store.logoSize, - margin: store.logoMargin, - crossOrigin: "anonymous" as const, - }, - } - : {}), + image: store.logoDataUrl || "", + imageOptions: { + hideBackgroundDots: store.hideBackgroundDots, + imageSize: store.logoSize, + margin: store.logoMargin, + }, } as never); qr.download({ diff --git a/tests/unit/api/csp.test.ts b/tests/unit/api/csp.test.ts index 9c1a3b90..b94a249c 100644 --- a/tests/unit/api/csp.test.ts +++ b/tests/unit/api/csp.test.ts @@ -64,4 +64,12 @@ describe("buildCsp", () => { const sources = parseDirective(buildCsp(false), "img-src"); expect(sources).toContain("https://tile.openstreetmap.org"); }); + + it.each([ + true, + false, + ])("connect-src allows data: URIs for client-side blob operations (isDocs=%s)", (isDocs) => { + const sources = parseDirective(buildCsp(isDocs), "connect-src"); + expect(sources).toContain("data:"); + }); }); diff --git a/tests/unit/web/zustand-stores.test.ts b/tests/unit/web/zustand-stores.test.ts index 317b0661..2549c585 100644 --- a/tests/unit/web/zustand-stores.test.ts +++ b/tests/unit/web/zustand-stores.test.ts @@ -548,6 +548,28 @@ describe("useQrStore", () => { expect(useQrStore.getState().logoDataUrl).toBeNull(); }); + it("setLogoFile with a File reads it as a data URL", async () => { + const file = new File(["fake-png-data"], "logo.png", { type: "image/png" }); + useQrStore.getState().setLogoFile(file); + await vi.waitFor(() => { + expect(useQrStore.getState().logoDataUrl).not.toBeNull(); + }); + const s = useQrStore.getState(); + expect(s.logoFile).toBe(file); + expect(s.logoDataUrl).toMatch(/^data:/); + }); + + it("setLogoFile(null) after a file clears both fields", async () => { + const file = new File(["fake-png-data"], "logo.png", { type: "image/png" }); + useQrStore.getState().setLogoFile(file); + await vi.waitFor(() => { + expect(useQrStore.getState().logoDataUrl).not.toBeNull(); + }); + useQrStore.getState().setLogoFile(null); + expect(useQrStore.getState().logoFile).toBeNull(); + expect(useQrStore.getState().logoDataUrl).toBeNull(); + }); + it("reset restores all defaults", () => { useQrStore.getState().setContentType("wifi"); useQrStore.getState().setTextData("changed");