Skip to content
File

Blob: src/client/components/editor/controllers/image/validate-image-url.ts

typescript99 lines
1import { IMAGE_MIME_TYPES } from "../../lib/media-actions";
2 
3const IMAGE_EMBED_TIMEOUT_MS = 8000;
4const ALLOWED_EMBED_MIME_SET = new Set(IMAGE_MIME_TYPES);
5const ALLOWED_EMBED_MIME_LABEL = "JPEG, PNG, GIF, WebP, or HEIC";
6const REMOTE_IMAGE_VERIFY_ERROR =
7 "Could not verify that remote image URL from the browser. The host may block CORS checks; upload the image instead.";
8 
9async function fetchImageContentType(url: string, signal: AbortSignal): Promise<string | null> {
10 for (const method of ["HEAD", "GET"] as const) {
11 let response: Response;
12 try {
13 response = await fetch(url, {
14 method,
15 redirect: "follow",
16 signal,
17 });
18 } catch (error) {
19 if (method === "HEAD") continue;
20 if (error instanceof Error && error.name === "AbortError") throw error;
21 throw new Error(REMOTE_IMAGE_VERIFY_ERROR, { cause: error });
22 }
23 
24 if (!response.ok) {
25 if (method === "HEAD") continue;
26 throw new Error("Could not fetch image URL");
27 }
28 
29 const contentType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? null;
30 if (contentType) return contentType;
31 }
32 
33 return null;
34}
35 
36export function validateImageUrl(url: string): Promise<string> {
37 let parsed: URL;
38 try {
39 parsed = new URL(url);
40 } catch {
41 return Promise.reject(new Error("Enter a valid image URL"));
42 }
43 
44 if (parsed.protocol !== "https:") {
45 return Promise.reject(new Error("Image URLs must start with https://"));
46 }
47 
48 return new Promise((resolve, reject) => {
49 const img = new window.Image();
50 const controller = new AbortController();
51 const timeoutId = window.setTimeout(() => {
52 cleanup();
53 reject(new Error("Timed out loading image URL"));
54 }, IMAGE_EMBED_TIMEOUT_MS);
55 
56 const cleanup = () => {
57 window.clearTimeout(timeoutId);
58 controller.abort();
59 img.onload = null;
60 img.onerror = null;
61 };
62 
63 void (async () => {
64 try {
65 const contentType = await fetchImageContentType(parsed.toString(), controller.signal);
66 if (!contentType) {
67 cleanup();
68 reject(new Error("Could not verify image content type"));
69 return;
70 }
71 if (!ALLOWED_EMBED_MIME_SET.has(contentType)) {
72 cleanup();
73 reject(new Error(`Remote image must be ${ALLOWED_EMBED_MIME_LABEL}`));
74 return;
75 }
76 
77 img.src = parsed.toString();
78 } catch (error) {
79 cleanup();
80 reject(
81 error instanceof Error && error.name !== "AbortError"
82 ? error
83 : new Error("Could not verify remote image URL", { cause: error }),
84 );
85 }
86 })();
87 
88 img.onload = () => {
89 cleanup();
90 resolve(parsed.toString());
91 };
92 
93 img.onerror = () => {
94 cleanup();
95 reject(new Error("Could not load image from that URL"));
96 };
97 });
98}