]> Untitled Git - lemmy.git/blob - crates/api_common/src/request.rs
Adding admin purging of DB items and pictures. #904 #1331 (#1809)
[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(|t| t.to_string());
76   let og_title = page
77     .opengraph
78     .properties
79     .get("title")
80     .map(|t| t.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 response = client
170     .post(&purge_url)
171     .timeout(REQWEST_TIMEOUT)
172     .header("x-api-token", pictrs_config.api_key)
173     .send()
174     .await?;
175
176   let response: PictrsPurgeResponse = response.json().await.map_err(LemmyError::from)?;
177
178   if response.msg == "ok" {
179     Ok(())
180   } else {
181     Err(LemmyError::from_message(&response.msg))
182   }
183 }
184
185 /// Both are options, since the URL might be either an html page, or an image
186 /// Returns the SiteMetadata, and a Pictrs URL, if there is a picture associated
187 #[tracing::instrument(skip_all)]
188 pub async fn fetch_site_data(
189   client: &ClientWithMiddleware,
190   settings: &Settings,
191   url: Option<&Url>,
192 ) -> (Option<SiteMetadata>, Option<DbUrl>) {
193   match &url {
194     Some(url) => {
195       // Fetch metadata
196       // Ignore errors, since it may be an image, or not have the data.
197       // Warning, this may ignore SSL errors
198       let metadata_option = fetch_site_metadata(client, url).await.ok();
199
200       // Fetch pictrs thumbnail
201       let pictrs_hash = match &metadata_option {
202         Some(metadata_res) => match &metadata_res.image {
203           // Metadata, with image
204           // Try to generate a small thumbnail if there's a full sized one from post-links
205           Some(metadata_image) => fetch_pictrs(client, settings, metadata_image)
206             .await
207             .map(|r| r.files[0].file.to_owned()),
208           // Metadata, but no image
209           None => fetch_pictrs(client, settings, url)
210             .await
211             .map(|r| r.files[0].file.to_owned()),
212         },
213         // No metadata, try to fetch the URL as an image
214         None => fetch_pictrs(client, settings, url)
215           .await
216           .map(|r| r.files[0].file.to_owned()),
217       };
218
219       // The full urls are necessary for federation
220       let pictrs_thumbnail = pictrs_hash
221         .map(|p| {
222           Url::parse(&format!(
223             "{}/pictrs/image/{}",
224             settings.get_protocol_and_hostname(),
225             p
226           ))
227           .ok()
228         })
229         .ok()
230         .flatten();
231
232       (metadata_option, pictrs_thumbnail.map(Into::into))
233     }
234     None => (None, None),
235   }
236 }
237
238 #[tracing::instrument(skip_all)]
239 async fn is_image_content_type(client: &ClientWithMiddleware, url: &Url) -> Result<(), LemmyError> {
240   let response = client.get(url.as_str()).send().await?;
241   if response
242     .headers()
243     .get("Content-Type")
244     .ok_or_else(|| LemmyError::from_message("No Content-Type header"))?
245     .to_str()?
246     .starts_with("image/")
247   {
248     Ok(())
249   } else {
250     Err(LemmyError::from_message("Not an image type."))
251   }
252 }
253
254 pub fn build_user_agent(settings: &Settings) -> String {
255   format!(
256     "Lemmy/{}; +{}",
257     VERSION,
258     settings.get_protocol_and_hostname()
259   )
260 }
261
262 #[cfg(test)]
263 mod tests {
264   use crate::request::{build_user_agent, fetch_site_metadata, SiteMetadata};
265   use lemmy_utils::settings::structs::Settings;
266   use url::Url;
267
268   // These helped with testing
269   #[actix_rt::test]
270   async fn test_site_metadata() {
271     let settings = Settings::init().unwrap();
272     let client = reqwest::Client::builder()
273       .user_agent(build_user_agent(&settings))
274       .build()
275       .unwrap()
276       .into();
277     let sample_url = Url::parse("https://gitlab.com/IzzyOnDroid/repo/-/wikis/FAQ").unwrap();
278     let sample_res = fetch_site_metadata(&client, &sample_url).await.unwrap();
279     assert_eq!(
280       SiteMetadata {
281         title: Some("FAQ · Wiki · IzzyOnDroid / repo · GitLab".to_string()),
282         description: Some(
283           "The F-Droid compatible repo at https://apt.izzysoft.de/fdroid/".to_string()
284         ),
285         image: Some(
286           Url::parse("https://gitlab.com/uploads/-/system/project/avatar/4877469/iod_logo.png")
287             .unwrap()
288             .into()
289         ),
290         embed_video_url: None,
291       },
292       sample_res
293     );
294   }
295
296   // #[test]
297   // fn test_pictshare() {
298   //   let res = fetch_pictshare("https://upload.wikimedia.org/wikipedia/en/2/27/The_Mandalorian_logo.jpg");
299   //   assert!(res.is_ok());
300   //   let res_other = fetch_pictshare("https://upload.wikimedia.org/wikipedia/en/2/27/The_Mandalorian_logo.jpgaoeu");
301   //   assert!(res_other.is_err());
302   // }
303 }