| 1 | const IMAGE_PROXY_ROUTE = "/api/image-proxy"; |
| 2 | |
| 3 | type PresentationImageProxyInput = { |
| 4 | embedType?: string; |
| 5 | imageSource?: "generate" | "search" | "gif" | "upload"; |
| 6 | stockImageProvider?: string; |
| 7 | }; |
| 8 | |
| 9 | type RewriteOptions = { |
| 10 | absolute?: boolean; |
| 11 | }; |
| 12 | |
| 13 | type ExportImageSourceInput = PresentationImageProxyInput; |
| 14 | |
| 15 | export type ExportImageSource = |
| 16 | | { |
| 17 | type: "data"; |
| 18 | value: string; |
| 19 | } |
| 20 | | { |
| 21 | type: "path"; |
| 22 | value: string; |
| 23 | }; |
| 24 | |
| 25 | function getCurrentOrigin(): string | null { |
| 26 | return typeof window === "undefined" ? null : window.location.origin; |
| 27 | } |
| 28 | |
| 29 | function isRemoteHttpUrl(value: string): boolean { |
| 30 | try { |
| 31 | const url = new URL(value); |
| 32 | return url.protocol === "http:" || url.protocol === "https:"; |
| 33 | } catch { |
| 34 | return false; |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | function isProxiedImageUrl(value: string): boolean { |
| 39 | if (value.startsWith(`${IMAGE_PROXY_ROUTE}?`)) { |
| 40 | return true; |
| 41 | } |
| 42 | |
| 43 | try { |
| 44 | return ( |
| 45 | new URL(value, getCurrentOrigin() ?? "http://localhost").pathname === |
| 46 | IMAGE_PROXY_ROUTE |
| 47 | ); |
| 48 | } catch { |
| 49 | return false; |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | function createImageProxyUrl( |
| 54 | value: string | undefined, |
| 55 | options: RewriteOptions = {}, |
| 56 | ): string | undefined { |
| 57 | if (!value || !isRemoteHttpUrl(value) || isProxiedImageUrl(value)) { |
| 58 | return value; |
| 59 | } |
| 60 | |
| 61 | const params = new URLSearchParams({ url: value }); |
| 62 | const relativeUrl = `${IMAGE_PROXY_ROUTE}?${params.toString()}`; |
| 63 | |
| 64 | if (!options.absolute) { |
| 65 | return relativeUrl; |
| 66 | } |
| 67 | |
| 68 | const origin = getCurrentOrigin(); |
| 69 | return origin ? `${origin}${relativeUrl}` : relativeUrl; |
| 70 | } |
| 71 | |
| 72 | function shouldProxyPresentationImage( |
| 73 | url: string | undefined, |
| 74 | input: PresentationImageProxyInput = {}, |
| 75 | ): boolean { |
| 76 | if (!url || !isRemoteHttpUrl(url) || isProxiedImageUrl(url)) { |
| 77 | return false; |
| 78 | } |
| 79 | |
| 80 | if (input.embedType || input.imageSource === "upload") { |
| 81 | return false; |
| 82 | } |
| 83 | |
| 84 | if (input.imageSource === "search" || input.stockImageProvider === "google") { |
| 85 | return true; |
| 86 | } |
| 87 | |
| 88 | return !input.imageSource; |
| 89 | } |
| 90 | |
| 91 | export function proxyPresentationImageUrl( |
| 92 | url: string | undefined, |
| 93 | input: PresentationImageProxyInput = {}, |
| 94 | options: RewriteOptions = {}, |
| 95 | ): string | undefined { |
| 96 | return shouldProxyPresentationImage(url, input) |
| 97 | ? createImageProxyUrl(url, options) |
| 98 | : url; |
| 99 | } |
| 100 | |
| 101 | function blobToDataUrl(blob: Blob): Promise<string> { |
| 102 | return new Promise((resolve, reject) => { |
| 103 | const reader = new FileReader(); |
| 104 | reader.addEventListener("load", () => { |
| 105 | if (typeof reader.result === "string") { |
| 106 | resolve(reader.result); |
| 107 | return; |
| 108 | } |
| 109 | |
| 110 | reject(new Error("Unable to convert image to data URL.")); |
| 111 | }); |
| 112 | reader.addEventListener("error", () => { |
| 113 | reject(reader.error ?? new Error("Unable to read image data.")); |
| 114 | }); |
| 115 | reader.readAsDataURL(blob); |
| 116 | }); |
| 117 | } |
| 118 | |
| 119 | export async function resolveExportImageSource( |
| 120 | url: string, |
| 121 | input: ExportImageSourceInput = {}, |
| 122 | ): Promise<ExportImageSource> { |
| 123 | if (url.startsWith("data:")) { |
| 124 | return { type: "data", value: url }; |
| 125 | } |
| 126 | |
| 127 | const proxiedUrl = proxyPresentationImageUrl(url, input, { absolute: true }); |
| 128 | if (!proxiedUrl || proxiedUrl === url) { |
| 129 | return { type: "path", value: url }; |
| 130 | } |
| 131 | |
| 132 | try { |
| 133 | const response = await fetch(proxiedUrl, { cache: "force-cache" }); |
| 134 | if (!response.ok) { |
| 135 | throw new Error( |
| 136 | `Image proxy request failed with status ${response.status}.`, |
| 137 | ); |
| 138 | } |
| 139 | |
| 140 | return { |
| 141 | type: "data", |
| 142 | value: await blobToDataUrl(await response.blob()), |
| 143 | }; |
| 144 | } catch (error) { |
| 145 | console.warn("Failed to prepare proxied image for export:", error); |
| 146 | return { type: "path", value: proxiedUrl }; |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | function rewriteCssUrls(value: string, options: RewriteOptions): string { |
| 151 | return value.replace( |
| 152 | /url\(\s*(["']?)(https?:\/\/[^"')\s]+)\1\s*\)/gi, |
| 153 | (match: string, quote: string, url: string) => { |
| 154 | const proxiedUrl = createImageProxyUrl(url, options); |
| 155 | if (!proxiedUrl || proxiedUrl === url) { |
| 156 | return match; |
| 157 | } |
| 158 | |
| 159 | const nextQuote = quote || '"'; |
| 160 | return `url(${nextQuote}${proxiedUrl}${nextQuote})`; |
| 161 | }, |
| 162 | ); |
| 163 | } |
| 164 | |
| 165 | function rewriteSrcSet(value: string, options: RewriteOptions): string { |
| 166 | return value |
| 167 | .split(",") |
| 168 | .map((candidate) => { |
| 169 | const trimmed = candidate.trim(); |
| 170 | if (!trimmed) { |
| 171 | return trimmed; |
| 172 | } |
| 173 | |
| 174 | const [url, ...descriptors] = trimmed.split(/\s+/); |
| 175 | if (!url) { |
| 176 | return trimmed; |
| 177 | } |
| 178 | |
| 179 | const proxiedUrl = createImageProxyUrl(url, options) ?? url; |
| 180 | return [proxiedUrl, ...descriptors].join(" "); |
| 181 | }) |
| 182 | .join(", "); |
| 183 | } |
| 184 | |
| 185 | export function rewriteHtmlArtifactImageUrls( |
| 186 | html: string, |
| 187 | options: RewriteOptions = {}, |
| 188 | ): string { |
| 189 | return rewriteCssUrls(html, options) |
| 190 | .replace( |
| 191 | /(<(?:img|source)\b[^>]*?\s(?:src)=)(["'])(https?:\/\/[^"']+)\2/gi, |
| 192 | (match: string, prefix: string, quote: string, url: string) => { |
| 193 | const proxiedUrl = createImageProxyUrl(url, options); |
| 194 | return proxiedUrl ? `${prefix}${quote}${proxiedUrl}${quote}` : match; |
| 195 | }, |
| 196 | ) |
| 197 | .replace( |
| 198 | /(<(?:img|source)\b[^>]*?\s(?:srcset)=)(["'])([^"']+)\2/gi, |
| 199 | (match: string, prefix: string, quote: string, srcset: string) => { |
| 200 | const rewrittenSrcset = rewriteSrcSet(srcset, options); |
| 201 | return rewrittenSrcset === srcset |
| 202 | ? match |
| 203 | : `${prefix}${quote}${rewrittenSrcset}${quote}`; |
| 204 | }, |
| 205 | ); |
| 206 | } |
| 207 |