]> Untitled Git - lemmy.git/blob - crates/api_common/src/request.rs
Various pedantic clippy fixes (#2568)
[lemmy.git] / crates / api_common / src / request.rs
1 use crate::post::SiteMetadata;
2 use encoding::{all::encodings, DecoderTrap};
3 use lemmy_db_schema::newtypes::DbUrl;
4 use lemmy_utils::{
5   error::LemmyError,
6   settings::structs::Settings,
7   version::VERSION,
8   REQWEST_TIMEOUT,
9 };
10 use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
11 use reqwest_middleware::ClientWithMiddleware;
12 use serde::Deserialize;
13 use tracing::info;
14 use url::Url;
15 use webpage::HTML;
16
17 /// Fetches the post link html tags (like title, description, image, etc)
18 #[tracing::instrument(skip_all)]
19 pub async fn fetch_site_metadata(
20   client: &ClientWithMiddleware,
21   url: &Url,
22 ) -> Result<SiteMetadata, LemmyError> {
23   info!("Fetching site metadata for url: {}", url);
24   let response = client.get(url.as_str()).send().await?;
25
26   // Can't use .text() here, because it only checks the content header, not the actual bytes
27   // https://github.com/LemmyNet/lemmy/issues/1964
28   let html_bytes = response.bytes().await.map_err(LemmyError::from)?.to_vec();
29
30   let tags = html_to_site_metadata(&html_bytes)?;
31
32   Ok(tags)
33 }
34
35 fn html_to_site_metadata(html_bytes: &[u8]) -> Result<SiteMetadata, LemmyError> {
36   let html = String::from_utf8_lossy(html_bytes);
37
38   // Make sure the first line is doctype html
39   let first_line = html
40     .trim_start()
41     .lines()
42     .into_iter()
43     .next()
44     .ok_or_else(|| LemmyError::from_message("No lines in html"))?
45     .to_lowercase();
46
47   if !first_line.starts_with("<!doctype html>") {
48     return Err(LemmyError::from_message(
49       "Site metadata page fetch is not DOCTYPE html",
50     ));
51   }
52
53   let mut page = HTML::from_string(html.to_string(), None)?;
54
55   // If the web page specifies that it isn't actually UTF-8, re-decode the received bytes with the
56   // proper encoding. If the specified encoding cannot be found, fall back to the original UTF-8
57   // version.
58   if let Some(charset) = page.meta.get("charset") {
59     if charset.to_lowercase() != "utf-8" {
60       if let Some(encoding_ref) = encodings().iter().find(|e| e.name() == charset) {
61         if let Ok(html_with_encoding) = encoding_ref.decode(html_bytes, DecoderTrap::Replace) {
62           page = HTML::from_string(html_with_encoding, None)?;
63         }
64       }
65     }
66   }
67
68   let page_title = page.title;
69   let page_description = page.description;
70
71   let og_description = page
72     .opengraph
73     .properties
74     .get("description")
75     .map(std::string::ToString::to_string);
76   let og_title = page
77     .opengraph
78     .properties
79     .get("title")
80     .map(std::string::ToString::to_string);
81   let og_image = page
82     .opengraph
83     .images
84     .get(0)
85     .and_then(|ogo| Url::parse(&ogo.url).ok());
86   let og_embed_url = page
87     .opengraph
88     .videos
89     .first()
90     .and_then(|v| Url::parse(&v.url).ok());
91
92   Ok(SiteMetadata {
93     title: og_title.or(page_title),
94     description: og_description.or(page_description),
95     image: og_image.map(Into::into),
96     embed_video_url: og_embed_url.map(Into::into),
97   })
98 }
99
100 #[derive(Deserialize, Debug, Clone)]
101 pub(crate) struct PictrsResponse {
102   files: Vec<PictrsFile>,
103   msg: String,
104 }
105
106 #[derive(Deserialize, Debug, Clone)]
107 pub(crate) struct PictrsFile {
108   file: String,
109   #[allow(dead_code)]
110   delete_token: String,
111 }
112
113 #[derive(Deserialize, Debug, Clone)]
114 pub(crate) struct PictrsPurgeResponse {
115   msg: String,
116 }
117
118 #[tracing::instrument(skip_all)]
119 pub(crate) async fn fetch_pictrs(
120   client: &ClientWithMiddleware,
121   settings: &Settings,
122   image_url: &Url,
123 ) -> Result<PictrsResponse, LemmyError> {
124   let pictrs_config = settings.pictrs_config()?;
125   is_image_content_type(client, image_url).await?;
126
127   let fetch_url = format!(
128     "{}image/download?url={}",
129     pictrs_config.url,
130     utf8_percent_encode(image_url.as_str(), NON_ALPHANUMERIC) // TODO this might not be needed
131   );
132
133   let response = client
134     .get(&fetch_url)
135     .timeout(REQWEST_TIMEOUT)
136     .send()
137     .await?;
138
139   let response: PictrsResponse = response.json().await.map_err(LemmyError::from)?;
140
141   if response.msg == "ok" {
142     Ok(response)
143   } else {
144     Err(LemmyError::from_message(&response.msg))
145   }
146 }
147
148 /// Purges an image from pictrs
149 /// Note: This should often be coerced from a Result to .ok() in order to fail softly, because:
150 /// - It might fail due to image being not local
151 /// - It might not be an image
152 /// - Pictrs might not be set up
153 pub async fn purge_image_from_pictrs(
154   client: &ClientWithMiddleware,
155   settings: &Settings,
156   image_url: &Url,
157 ) -> Result<(), LemmyError> {
158   let pictrs_config = settings.pictrs_config()?;
159   is_image_content_type(client, image_url).await?;
160
161   let alias = image_url
162     .path_segments()
163     .ok_or_else(|| LemmyError::from_message("Image URL missing path segments"))?
164     .next_back()
165     .ok_or_else(|| LemmyError::from_message("Image URL missing last path segment"))?;
166
167   let purge_url = format!("{}/internal/purge?alias={}", pictrs_config.url, alias);
168
169   let pictrs_api_key = pictrs_config
170     .api_key
171     .ok_or_else(|| LemmyError::from_message("pictrs_api_key_not_provided"))?;
172   let response = client
173     .post(&purge_url)
174     .timeout(REQWEST_TIMEOUT)
175     .header("x-api-token", pictrs_api_key)
176     .send()
177     .await?;
178
179   let response: PictrsPurgeResponse = response.json().await.map_err(LemmyError::from)?;
180
181   if response.msg == "ok" {
182     Ok(())
183   } else {
184     Err(LemmyError::from_message(&response.msg))
185   }
186 }
187
188 /// Both are options, since the URL might be either an html page, or an image
189 /// Returns the SiteMetadata, and a Pictrs URL, if there is a picture associated
190 #[tracing::instrument(skip_all)]
191 pub async fn fetch_site_data(
192   client: &ClientWithMiddleware,
193   settings: &Settings,
194   url: Option<&Url>,
195 ) -> (Option<SiteMetadata>, Option<DbUrl>) {
196   match &url {
197     Some(url) => {
198       // Fetch metadata
199       // Ignore errors, since it may be an image, or not have the data.
200       // Warning, this may ignore SSL errors
201       let metadata_option = fetch_site_metadata(client, url).await.ok();
202
203       // Fetch pictrs thumbnail
204       let pictrs_hash = match &metadata_option {
205         Some(metadata_res) => match &metadata_res.image {
206           // Metadata, with image
207           // Try to generate a small thumbnail if there's a full sized one from post-links
208           Some(metadata_image) => fetch_pictrs(client, settings, metadata_image)
209             .await
210             .map(|r| r.files[0].file.clone()),
211           // Metadata, but no image
212           None => fetch_pictrs(client, settings, url)
213             .await
214             .map(|r| r.files[0].file.clone()),
215         },
216         // No metadata, try to fetch the URL as an image
217         None => fetch_pictrs(client, settings, url)
218           .await
219           .map(|r| r.files[0].file.clone()),
220       };
221
222       // The full urls are necessary for federation
223       let pictrs_thumbnail = pictrs_hash
224         .map(|p| {
225           Url::parse(&format!(
226             "{}/pictrs/image/{}",
227             settings.get_protocol_and_hostname(),
228             p
229           ))
230           .ok()
231         })
232         .ok()
233         .flatten();
234
235       (metadata_option, pictrs_thumbnail.map(Into::into))
236     }
237     None => (None, None),
238   }
239 }
240
241 #[tracing::instrument(skip_all)]
242 async fn is_image_content_type(client: &ClientWithMiddleware, url: &Url) -> Result<(), LemmyError> {
243   let response = client.get(url.as_str()).send().await?;
244   if response
245     .headers()
246     .get("Content-Type")
247     .ok_or_else(|| LemmyError::from_message("No Content-Type header"))?
248     .to_str()?
249     .starts_with("image/")
250   {
251     Ok(())
252   } else {
253     Err(LemmyError::from_message("Not an image type."))
254   }
255 }
256
257 pub fn build_user_agent(settings: &Settings) -> String {
258   format!(
259     "Lemmy/{}; +{}",
260     VERSION,
261     settings.get_protocol_and_hostname()
262   )
263 }
264
265 #[cfg(test)]
266 mod tests {
267   use crate::request::{build_user_agent, fetch_site_metadata, SiteMetadata};
268   use lemmy_utils::settings::SETTINGS;
269   use url::Url;
270
271   // These helped with testing
272   #[actix_rt::test]
273   async fn test_site_metadata() {
274     let settings = &SETTINGS.clone();
275     let client = reqwest::Client::builder()
276       .user_agent(build_user_agent(settings))
277       .build()
278       .unwrap()
279       .into();
280     let sample_url = Url::parse("https://gitlab.com/IzzyOnDroid/repo/-/wikis/FAQ").unwrap();
281     let sample_res = fetch_site_metadata(&client, &sample_url).await.unwrap();
282     assert_eq!(
283       SiteMetadata {
284         title: Some("FAQ · Wiki · IzzyOnDroid / repo · GitLab".to_string()),
285         description: Some(
286           "The F-Droid compatible repo at https://apt.izzysoft.de/fdroid/".to_string()
287         ),
288         image: Some(
289           Url::parse("https://gitlab.com/uploads/-/system/project/avatar/4877469/iod_logo.png")
290             .unwrap()
291             .into()
292         ),
293         embed_video_url: None,
294       },
295       sample_res
296     );
297   }
298
299   // #[test]
300   // fn test_pictshare() {
301   //   let res = fetch_pictshare("https://upload.wikimedia.org/wikipedia/en/2/27/The_Mandalorian_logo.jpg");
302   //   assert!(res.is_ok());
303   //   let res_other = fetch_pictshare("https://upload.wikimedia.org/wikipedia/en/2/27/The_Mandalorian_logo.jpgaoeu");
304   //   assert!(res_other.is_err());
305   // }
306 }