diff --git a/src/components/onboarding/VerifierIdentityStep.tsx b/src/components/onboarding/VerifierIdentityStep.tsx index b5296d56..953ae5c4 100644 --- a/src/components/onboarding/VerifierIdentityStep.tsx +++ b/src/components/onboarding/VerifierIdentityStep.tsx @@ -6,9 +6,10 @@ import { ProfileCard } from '@/components/ProfileCard'; import { ImageCropDialog } from '@/components/ImageCropDialog'; import { Button } from '@/components/ui/button'; import { useUploadFile } from '@/hooks/useUploadFile'; -import { useImageProxy } from '@/hooks/useImageProxy'; +import { useAppContext } from '@/hooks/useAppContext'; import { useToast } from '@/hooks/useToast'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { fetchImageAsFile } from '@/lib/proxyImageUrl'; import { cn } from '@/lib/utils'; /** @@ -64,7 +65,7 @@ export function VerifierIdentityStep({ const { t } = useTranslation(); const { toast } = useToast(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); - const proxyImage = useImageProxy(); + const { config } = useAppContext(); const fileInputRef = useRef(null); const pendingFieldRef = useRef(null); @@ -80,8 +81,11 @@ export function VerifierIdentityStep({ fileInputRef.current?.click(); }, []); - // Read an image URL from the clipboard, validate it, and route it through - // the same crop/upload flow as local files. + // Read an image URL from the clipboard, validate it, then fetch its bytes + // (through the image proxy so the request is CORS-safe) into an object URL. + // From there it joins the exact same crop → Blossom-upload flow as a local + // file — the cropper only ever sees a same-origin `blob:` source, so the + // canvas never taints and arbitrary remote hosts / SVGs work. const handlePasteUrl = useCallback( async (field: CropField) => { let text = ''; @@ -104,13 +108,25 @@ export function VerifierIdentityStep({ return; } + let file: File; + try { + file = await fetchImageAsFile(url, config.imageProxy); + } catch (error) { + toast({ + title: t('onboarding.verifier.identity.pasteUrlFetchFailed'), + description: error instanceof Error ? error.message : undefined, + variant: 'destructive', + }); + return; + } + setCropState({ field, - imageSrc: proxyImage(url, field === 'banner' ? 1500 : 512), - objectUrl: false, + imageSrc: URL.createObjectURL(file), + objectUrl: true, }); }, - [proxyImage, t, toast], + [config.imageProxy, t, toast], ); const handleFileChosen = useCallback( diff --git a/src/lib/proxyImageUrl.ts b/src/lib/proxyImageUrl.ts index 4f338fdf..1b0d1034 100644 --- a/src/lib/proxyImageUrl.ts +++ b/src/lib/proxyImageUrl.ts @@ -61,3 +61,69 @@ export function proxyImageUrl( return `${base}/?${params.toString()}`; } +/** + * Fetch a remote image and return its bytes as a `File`, routed through the + * configured image proxy so the request is CORS-safe and can later be drawn + * onto a `` without tainting it. + * + * This is the canvas-safe counterpart to {@link proxyImageUrl}: where that + * helper produces an `` (and tolerates `data:`/`.svg` pass-through), + * this one needs the *bytes* and therefore forces everything — including SVGs + * — through the proxy, which rasterizes to PNG. Without that, a cross-origin + * `fetch()` of an arbitrary host (or an SVG the proxy would otherwise skip) + * fails CORS and the crop/encode pipeline silently dies. + * + * `data:` URIs are fetched directly — they carry their own bytes and never + * hit the network or a CORS check. + * + * @param src Original image URL. + * @param proxyBaseUrl Base URL of a wsrv.nl-compatible proxy. Falls back to + * `https://wsrv.nl` when empty, since a direct fetch of an + * arbitrary origin would almost always fail CORS. + * @param filename Base filename for the returned `File`. + */ +export async function fetchImageAsFile( + src: string, + proxyBaseUrl: string, + filename = 'pasted-image', +): Promise { + // data: URIs carry their own bytes — fetch directly, no proxy, no CORS. + const fetchUrl = src.startsWith('data:') + ? src + : proxyFetchUrl(src, proxyBaseUrl || 'https://wsrv.nl'); + + const res = await fetch(fetchUrl); + if (!res.ok) { + throw new Error(`Failed to fetch image (${res.status})`); + } + const blob = await res.blob(); + if (!blob.type.startsWith('image/')) { + throw new Error('Fetched resource is not an image'); + } + + const ext = blob.type === 'image/png' ? '.png' + : blob.type === 'image/webp' ? '.webp' + : '.jpg'; + + return new File([blob], `${filename}${ext}`, { type: blob.type }); +} + +/** + * Build a proxy URL for *fetching bytes* (as opposed to an ``). + * Unlike {@link proxyImageUrl} this forces SVGs through the proxy so they + * rasterize, and omits the width cap so the cropper gets full resolution. + */ +function proxyFetchUrl(src: string, proxyBaseUrl: string): string { + let parsedProxy: URL; + try { + parsedProxy = new URL(proxyBaseUrl); + } catch { + return src; + } + if (parsedProxy.protocol !== 'https:') return src; + + const base = (parsedProxy.origin + parsedProxy.pathname).replace(/\/+$/, ''); + const params = new URLSearchParams({ url: src, output: 'png' }); + return `${base}/?${params.toString()}`; +} + diff --git a/src/locales/en.json b/src/locales/en.json index 76f44e54..69fe1f77 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -159,7 +159,8 @@ "cropBanner": "Crop banner", "uploading": "Uploading image…", "clipboardFailed": "Couldn't read from clipboard.", - "pasteUrlInvalid": "Clipboard doesn't contain a valid https URL." + "pasteUrlInvalid": "Clipboard doesn't contain a valid https URL.", + "pasteUrlFetchFailed": "Couldn't load that image. Check the URL and try again." }, "bio": { "title": "Tell us about your organization",