返回 presentation-ai
image-proxy.ts
根目录 / src / lib / image-proxy.ts
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
207 lines TYPESCRIPT