]> Untitled Git - lemmy.git/blob - crates/api_common/src/request.rs
260abce1bb0649ee9061a3b30e0103e9afa5c49a
[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     .first()
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       let missing_pictrs_file =
204         |r: PictrsResponse| r.files.first().expect("missing pictrs file").file.clone();
205
206       // Fetch pictrs thumbnail
207       let pictrs_hash = match &metadata_option {
208         Some(metadata_res) => match &metadata_res.image {
209           // Metadata, with image
210           // Try to generate a small thumbnail if there's a full sized one from post-links
211           Some(metadata_image) => fetch_pictrs(client, settings, metadata_image)
212             .await
213             .map(missing_pictrs_file),
214           // Metadata, but no image
215           None => fetch_pictrs(client, settings, url)
216             .await
217             .map(missing_pictrs_file),
218         },
219         // No metadata, try to fetch the URL as an image
220         None => fetch_pictrs(client, settings, url)
221           .await
222           .map(missing_pictrs_file),
223       };
224
225       // The full urls are necessary for federation
226       let pictrs_thumbnail = pictrs_hash
227         .map(|p| {
228           Url::parse(&format!(
229             "{}/pictrs/image/{}",
230             settings.get_protocol_and_hostname(),
231             p
232           ))
233           .ok()
234         })
235         .ok()
236         .flatten();
237
238       (metadata_option, pictrs_thumbnail.map(Into::into))
239     }
240     None => (None, None),
241   }
242 }
243
244 #[tracing::instrument(skip_all)]
245 async fn is_image_content_type(client: &ClientWithMiddleware, url: &Url) -> Result<(), LemmyError> {
246   let response = client.get(url.as_str()).send().await?;
247   if response
248     .headers()
249     .get("Content-Type")
250     .ok_or_else(|| LemmyError::from_message("No Content-Type header"))?
251     .to_str()?
252     .starts_with("image/")
253   {
254     Ok(())
255   } else {
256     Err(LemmyError::from_message("Not an image type."))
257   }
258 }
259
260 pub fn build_user_agent(settings: &Settings) -> String {
261   format!(
262     "Lemmy/{}; +{}",
263     VERSION,
264     settings.get_protocol_and_hostname()
265   )
266 }
267
268 #[cfg(test)]
269 mod tests {
270   use crate::request::{build_user_agent, fetch_site_metadata, SiteMetadata};
271   use lemmy_utils::settings::SETTINGS;
272   use url::Url;
273
274   // These helped with testing
275   #[actix_rt::test]
276   async fn test_site_metadata() {
277     let settings = &SETTINGS.clone();
278     let client = reqwest::Client::builder()
279       .user_agent(build_user_agent(settings))
280       .build()
281       .unwrap()
282       .into();
283     let sample_url = Url::parse("https://gitlab.com/IzzyOnDroid/repo/-/wikis/FAQ").unwrap();
284     let sample_res = fetch_site_metadata(&client, &sample_url).await.unwrap();
285     assert_eq!(
286       SiteMetadata {
287         title: Some("FAQ · Wiki · IzzyOnDroid / repo · GitLab".to_string()),
288         description: Some(
289           "The F-Droid compatible repo at https://apt.izzysoft.de/fdroid/".to_string()
290         ),
291         image: Some(
292           Url::parse("https://gitlab.com/uploads/-/system/project/avatar/4877469/iod_logo.png")
293             .unwrap()
294             .into()
295         ),
296         embed_video_url: None,
297       },
298       sample_res
299     );
300   }
301
302   // #[test]
303   // fn test_pictshare() {
304   //   let res = fetch_pictshare("https://upload.wikimedia.org/wikipedia/en/2/27/The_Mandalorian_logo.jpg");
305   //   assert!(res.is_ok());
306   //   let res_other = fetch_pictshare("https://upload.wikimedia.org/wikipedia/en/2/27/The_Mandalorian_logo.jpgaoeu");
307   //   assert!(res_other.is_err());
308   // }
309 }