@@ -2,13 +2,14 @@ use std::collections::HashMap;
22use std:: net:: { IpAddr , Ipv4Addr , Ipv6Addr } ;
33use std:: time:: Duration ;
44
5- use reqwest:: header:: { HeaderMap , HeaderName , HeaderValue , CONTENT_TYPE , LOCATION } ;
5+ use reqwest:: header:: { HeaderMap , HeaderName , HeaderValue , CONTENT_LENGTH , CONTENT_TYPE , LOCATION } ;
66use reqwest:: redirect:: Policy ;
77use reqwest:: Url ;
88use serde:: { Deserialize , Serialize } ;
99
1010const WEB_RESOURCE_MAX_REDIRECTS : usize = 5 ;
1111const WEB_RESOURCE_REQUEST_TIMEOUT_SECS : u64 = 30 ;
12+ const WEB_IMAGE_MAX_BYTES : u64 = 25 * 1024 * 1024 ;
1213
1314#[ derive( Debug , Deserialize ) ]
1415#[ serde( rename_all = "camelCase" ) ]
@@ -28,13 +29,34 @@ pub(crate) struct WebResourceResponse {
2829 status : u16 ,
2930}
3031
32+ #[ derive( Debug , Deserialize ) ]
33+ #[ serde( rename_all = "camelCase" ) ]
34+ pub ( crate ) struct WebImageDownloadRequest {
35+ url : String ,
36+ }
37+
38+ #[ derive( Debug , PartialEq , Serialize ) ]
39+ #[ serde( rename_all = "camelCase" ) ]
40+ pub ( crate ) struct WebImageDownloadResponse {
41+ bytes : Vec < u8 > ,
42+ file_name : String ,
43+ mime_type : String ,
44+ }
45+
3146#[ tauri:: command]
3247pub ( crate ) async fn request_web_resource (
3348 request : WebResourceRequest ,
3449) -> Result < WebResourceResponse , String > {
3550 execute_web_resource_request ( request) . await
3651}
3752
53+ #[ tauri:: command]
54+ pub ( crate ) async fn download_web_image (
55+ request : WebImageDownloadRequest ,
56+ ) -> Result < WebImageDownloadResponse , String > {
57+ execute_web_image_download ( request) . await
58+ }
59+
3860async fn execute_web_resource_request (
3961 request : WebResourceRequest ,
4062) -> Result < WebResourceResponse , String > {
@@ -87,7 +109,73 @@ async fn execute_web_resource_request(
87109 Err ( "Web resource request followed too many redirects." . to_string ( ) )
88110}
89111
90- fn validated_web_resource_url ( value : & str , allow_localhost : bool ) -> Result < Url , String > {
112+ async fn execute_web_image_download (
113+ request : WebImageDownloadRequest ,
114+ ) -> Result < WebImageDownloadResponse , String > {
115+ let mut url = validated_web_resource_url ( & request. url , false ) ?;
116+ let client = reqwest:: Client :: builder ( )
117+ . redirect ( Policy :: none ( ) )
118+ . timeout ( Duration :: from_secs ( WEB_RESOURCE_REQUEST_TIMEOUT_SECS ) )
119+ . build ( )
120+ . map_err ( |error| error. to_string ( ) ) ?;
121+
122+ for _ in 0 ..=WEB_RESOURCE_MAX_REDIRECTS {
123+ let response = client
124+ . get ( url. clone ( ) )
125+ . send ( )
126+ . await
127+ . map_err ( |error| error. to_string ( ) ) ?;
128+ let status = response. status ( ) ;
129+
130+ if status. is_redirection ( ) {
131+ let location = response
132+ . headers ( )
133+ . get ( LOCATION )
134+ . ok_or_else ( || "Web image redirect did not include a location." . to_string ( ) ) ?;
135+ let location = location. to_str ( ) . map_err ( |error| error. to_string ( ) ) ?;
136+ let next_url = url. join ( location) . map_err ( |error| error. to_string ( ) ) ?;
137+ url = validated_web_resource_url ( next_url. as_str ( ) , false ) ?;
138+ continue ;
139+ }
140+
141+ if !status. is_success ( ) {
142+ return Err ( format ! (
143+ "Web image download failed: HTTP {}" ,
144+ status. as_u16( )
145+ ) ) ;
146+ }
147+
148+ if let Some ( content_length) = response. headers ( ) . get ( CONTENT_LENGTH ) {
149+ let content_length = content_length
150+ . to_str ( )
151+ . ok ( )
152+ . and_then ( |value| value. parse :: < u64 > ( ) . ok ( ) ) ;
153+ if content_length. is_some_and ( |length| length > WEB_IMAGE_MAX_BYTES ) {
154+ return Err ( "Web image is too large to paste into the document." . to_string ( ) ) ;
155+ }
156+ }
157+
158+ let mime_type = web_image_mime_type ( response. headers ( ) . get ( CONTENT_TYPE ) , & url) ?;
159+ let file_name = web_image_file_name ( & url, & mime_type) ;
160+ let bytes = response. bytes ( ) . await . map_err ( |error| error. to_string ( ) ) ?;
161+ if bytes. len ( ) as u64 > WEB_IMAGE_MAX_BYTES {
162+ return Err ( "Web image is too large to paste into the document." . to_string ( ) ) ;
163+ }
164+
165+ return Ok ( WebImageDownloadResponse {
166+ bytes : bytes. to_vec ( ) ,
167+ file_name,
168+ mime_type,
169+ } ) ;
170+ }
171+
172+ Err ( "Web image download followed too many redirects." . to_string ( ) )
173+ }
174+
175+ pub ( crate ) fn validated_web_resource_url (
176+ value : & str ,
177+ allow_localhost : bool ,
178+ ) -> Result < Url , String > {
91179 let url = Url :: parse ( value) . map_err ( |error| error. to_string ( ) ) ?;
92180 if !matches ! ( url. scheme( ) , "http" | "https" ) {
93181 return Err ( "Only HTTP and HTTPS web resource URLs are supported." . to_string ( ) ) ;
@@ -102,6 +190,96 @@ fn validated_web_resource_url(value: &str, allow_localhost: bool) -> Result<Url,
102190 Ok ( url)
103191}
104192
193+ fn web_image_mime_type ( content_type : Option < & HeaderValue > , url : & Url ) -> Result < String , String > {
194+ let normalized_content_type = content_type
195+ . and_then ( |value| value. to_str ( ) . ok ( ) )
196+ . and_then ( |value| value. split ( ';' ) . next ( ) )
197+ . map ( str:: trim)
198+ . map ( str:: to_ascii_lowercase)
199+ . filter ( |value| !value. is_empty ( ) ) ;
200+
201+ if let Some ( mime_type) = normalized_content_type {
202+ if mime_type. starts_with ( "image/" ) {
203+ return Ok ( mime_type) ;
204+ }
205+
206+ if mime_type != "application/octet-stream" {
207+ return Err ( "Downloaded web resource is not an image." . to_string ( ) ) ;
208+ }
209+ }
210+
211+ image_mime_type_from_url ( url)
212+ . map ( str:: to_string)
213+ . ok_or_else ( || "Downloaded web resource is not a supported image." . to_string ( ) )
214+ }
215+
216+ fn web_image_file_name ( url : & Url , mime_type : & str ) -> String {
217+ let file_name = url
218+ . path_segments ( )
219+ . and_then ( |segments| segments. filter ( |segment| !segment. is_empty ( ) ) . last ( ) )
220+ . filter ( |segment| !segment. trim ( ) . is_empty ( ) )
221+ . unwrap_or ( "web-image" ) ;
222+
223+ if image_extension_from_file_name ( file_name) . is_some ( ) {
224+ return file_name. to_string ( ) ;
225+ }
226+
227+ format ! (
228+ "{}.{}" ,
229+ file_name,
230+ image_extension_from_mime_type( mime_type)
231+ )
232+ }
233+
234+ fn image_mime_type_from_url ( url : & Url ) -> Option < & ' static str > {
235+ let file_name = url
236+ . path_segments ( )
237+ . and_then ( |segments| segments. filter ( |segment| !segment. is_empty ( ) ) . last ( ) ) ?;
238+ let extension = image_extension_from_file_name ( file_name) ?;
239+ image_mime_type_from_extension ( extension)
240+ }
241+
242+ fn image_extension_from_file_name ( file_name : & str ) -> Option < & str > {
243+ let extension = file_name. rsplit_once ( '.' ) ?. 1 ;
244+ if is_supported_image_extension ( extension) {
245+ Some ( extension)
246+ } else {
247+ None
248+ }
249+ }
250+
251+ fn is_supported_image_extension ( extension : & str ) -> bool {
252+ matches ! (
253+ extension. to_ascii_lowercase( ) . as_str( ) ,
254+ "avif" | "bmp" | "gif" | "jpeg" | "jpg" | "png" | "svg" | "webp"
255+ )
256+ }
257+
258+ fn image_mime_type_from_extension ( extension : & str ) -> Option < & ' static str > {
259+ match extension. to_ascii_lowercase ( ) . as_str ( ) {
260+ "avif" => Some ( "image/avif" ) ,
261+ "bmp" => Some ( "image/bmp" ) ,
262+ "gif" => Some ( "image/gif" ) ,
263+ "jpeg" | "jpg" => Some ( "image/jpeg" ) ,
264+ "png" => Some ( "image/png" ) ,
265+ "svg" => Some ( "image/svg+xml" ) ,
266+ "webp" => Some ( "image/webp" ) ,
267+ _ => None ,
268+ }
269+ }
270+
271+ fn image_extension_from_mime_type ( mime_type : & str ) -> & ' static str {
272+ match mime_type {
273+ "image/avif" => "avif" ,
274+ "image/bmp" => "bmp" ,
275+ "image/gif" => "gif" ,
276+ "image/jpeg" | "image/jpg" => "jpg" ,
277+ "image/svg+xml" => "svg" ,
278+ "image/webp" => "webp" ,
279+ _ => "png" ,
280+ }
281+ }
282+
105283fn is_local_or_private_host ( url : & Url ) -> bool {
106284 let Some ( host) = url. host_str ( ) else {
107285 return true ;
@@ -203,6 +381,42 @@ mod tests {
203381 assert_eq ! ( url. as_str( ) , "http://localhost:8888/search?q=markra" ) ;
204382 }
205383
384+ #[ test]
385+ fn accepts_image_content_types_for_web_image_downloads ( ) {
386+ let url = Url :: parse ( "https://example.com/assets/kitten" ) . expect ( "URL should parse" ) ;
387+ let content_type = HeaderValue :: from_static ( "image/png; charset=binary" ) ;
388+
389+ assert_eq ! (
390+ web_image_mime_type( Some ( & content_type) , & url) . expect( "image content type should pass" ) ,
391+ "image/png"
392+ ) ;
393+ assert_eq ! ( web_image_file_name( & url, "image/png" ) , "kitten.png" ) ;
394+ }
395+
396+ #[ test]
397+ fn infers_web_image_mime_type_from_url_for_octet_streams ( ) {
398+ let url = Url :: parse ( "https://cdn.example.com/assets/logo.svg?version=1" )
399+ . expect ( "URL should parse" ) ;
400+ let content_type = HeaderValue :: from_static ( "application/octet-stream" ) ;
401+
402+ assert_eq ! (
403+ web_image_mime_type( Some ( & content_type) , & url)
404+ . expect( "octet stream image URLs should infer a MIME type" ) ,
405+ "image/svg+xml"
406+ ) ;
407+ assert_eq ! ( web_image_file_name( & url, "image/svg+xml" ) , "logo.svg" ) ;
408+ }
409+
410+ #[ test]
411+ fn rejects_non_image_content_types_for_web_image_downloads ( ) {
412+ let url = Url :: parse ( "https://example.com/page.html" ) . expect ( "URL should parse" ) ;
413+ let content_type = HeaderValue :: from_static ( "text/html" ) ;
414+ let error = web_image_mime_type ( Some ( & content_type) , & url)
415+ . expect_err ( "non-image content type should be rejected" ) ;
416+
417+ assert ! ( error. contains( "not an image" ) ) ;
418+ }
419+
206420 #[ test]
207421 fn parses_custom_headers ( ) {
208422 let mut headers = HashMap :: new ( ) ;
0 commit comments