Fetch pasted organization images as files before cropping

Pasted image URLs were handed to the cropper as a raw remote src, so
encodeImage's canvas fetch hit the origin directly and failed CORS
(e.g. nips.nostr.com SVGs, or any host when the image proxy is off).
Now the paste handler fetches the bytes through the image proxy into an
object URL and feeds them through the same crop -> Blossom-upload flow
as a local file, so the cropper only ever sees a same-origin blob:.
This commit is contained in:
lemon
2026-06-12 22:55:04 -07:00
parent e07f04fad2
commit f534500075
3 changed files with 91 additions and 8 deletions
@@ -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<HTMLInputElement>(null);
const pendingFieldRef = useRef<CropField | null>(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(
+66
View File
@@ -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 `<canvas>` without tainting it.
*
* This is the canvas-safe counterpart to {@link proxyImageUrl}: where that
* helper produces an `<img src>` (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<File> {
// 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 `<img src>`).
* 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()}`;
}
+2 -1
View File
@@ -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",