]> Untitled Git - lemmy.git/blob - lemmy_api/src/lib.rs
Move websocket code into workspace (#107)
[lemmy.git] / lemmy_api / src / lib.rs
1 use crate::claims::Claims;
2 use actix_web::{web, web::Data};
3 use anyhow::anyhow;
4 use lemmy_db::{
5   community::Community,
6   community_view::CommunityUserBanView,
7   post::Post,
8   user::User_,
9   Crud,
10   DbPool,
11 };
12 use lemmy_structs::{blocking, comment::*, community::*, post::*, site::*, user::*};
13 use lemmy_utils::{
14   apub::get_apub_protocol_string,
15   request::{retry, RecvError},
16   settings::Settings,
17   APIError,
18   ConnectionId,
19   LemmyError,
20 };
21 use lemmy_websocket::{serialize_websocket_message, LemmyContext, UserOperation};
22 use log::error;
23 use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
24 use reqwest::Client;
25 use serde::Deserialize;
26 use std::process::Command;
27
28 pub mod claims;
29 pub mod comment;
30 pub mod community;
31 pub mod post;
32 pub mod site;
33 pub mod user;
34 pub mod version;
35
36 #[async_trait::async_trait(?Send)]
37 pub trait Perform {
38   type Response: serde::ser::Serialize + Send;
39
40   async fn perform(
41     &self,
42     context: &Data<LemmyContext>,
43     websocket_id: Option<ConnectionId>,
44   ) -> Result<Self::Response, LemmyError>;
45 }
46
47 pub(in crate) async fn is_mod_or_admin(
48   pool: &DbPool,
49   user_id: i32,
50   community_id: i32,
51 ) -> Result<(), LemmyError> {
52   let is_mod_or_admin = blocking(pool, move |conn| {
53     Community::is_mod_or_admin(conn, user_id, community_id)
54   })
55   .await?;
56   if !is_mod_or_admin {
57     return Err(APIError::err("not_a_mod_or_admin").into());
58   }
59   Ok(())
60 }
61 pub async fn is_admin(pool: &DbPool, user_id: i32) -> Result<(), LemmyError> {
62   let user = blocking(pool, move |conn| User_::read(conn, user_id)).await??;
63   if !user.admin {
64     return Err(APIError::err("not_an_admin").into());
65   }
66   Ok(())
67 }
68
69 pub(in crate) async fn get_post(post_id: i32, pool: &DbPool) -> Result<Post, LemmyError> {
70   match blocking(pool, move |conn| Post::read(conn, post_id)).await? {
71     Ok(post) => Ok(post),
72     Err(_e) => Err(APIError::err("couldnt_find_post").into()),
73   }
74 }
75
76 pub(in crate) async fn get_user_from_jwt(jwt: &str, pool: &DbPool) -> Result<User_, LemmyError> {
77   let claims = match Claims::decode(&jwt) {
78     Ok(claims) => claims.claims,
79     Err(_e) => return Err(APIError::err("not_logged_in").into()),
80   };
81   let user_id = claims.id;
82   let user = blocking(pool, move |conn| User_::read(conn, user_id)).await??;
83   // Check for a site ban
84   if user.banned {
85     return Err(APIError::err("site_ban").into());
86   }
87   Ok(user)
88 }
89
90 pub(in crate) async fn get_user_from_jwt_opt(
91   jwt: &Option<String>,
92   pool: &DbPool,
93 ) -> Result<Option<User_>, LemmyError> {
94   match jwt {
95     Some(jwt) => Ok(Some(get_user_from_jwt(jwt, pool).await?)),
96     None => Ok(None),
97   }
98 }
99
100 pub(in crate) async fn check_community_ban(
101   user_id: i32,
102   community_id: i32,
103   pool: &DbPool,
104 ) -> Result<(), LemmyError> {
105   let is_banned = move |conn: &'_ _| CommunityUserBanView::get(conn, user_id, community_id).is_ok();
106   if blocking(pool, is_banned).await? {
107     Err(APIError::err("community_ban").into())
108   } else {
109     Ok(())
110   }
111 }
112
113 pub async fn match_websocket_operation(
114   context: LemmyContext,
115   id: ConnectionId,
116   op: UserOperation,
117   data: &str,
118 ) -> Result<String, LemmyError> {
119   match op {
120     // User ops
121     UserOperation::Login => do_websocket_operation::<Login>(context, id, op, data).await,
122     UserOperation::Register => do_websocket_operation::<Register>(context, id, op, data).await,
123     UserOperation::GetCaptcha => do_websocket_operation::<GetCaptcha>(context, id, op, data).await,
124     UserOperation::GetUserDetails => {
125       do_websocket_operation::<GetUserDetails>(context, id, op, data).await
126     }
127     UserOperation::GetReplies => do_websocket_operation::<GetReplies>(context, id, op, data).await,
128     UserOperation::AddAdmin => do_websocket_operation::<AddAdmin>(context, id, op, data).await,
129     UserOperation::BanUser => do_websocket_operation::<BanUser>(context, id, op, data).await,
130     UserOperation::GetUserMentions => {
131       do_websocket_operation::<GetUserMentions>(context, id, op, data).await
132     }
133     UserOperation::MarkUserMentionAsRead => {
134       do_websocket_operation::<MarkUserMentionAsRead>(context, id, op, data).await
135     }
136     UserOperation::MarkAllAsRead => {
137       do_websocket_operation::<MarkAllAsRead>(context, id, op, data).await
138     }
139     UserOperation::DeleteAccount => {
140       do_websocket_operation::<DeleteAccount>(context, id, op, data).await
141     }
142     UserOperation::PasswordReset => {
143       do_websocket_operation::<PasswordReset>(context, id, op, data).await
144     }
145     UserOperation::PasswordChange => {
146       do_websocket_operation::<PasswordChange>(context, id, op, data).await
147     }
148     UserOperation::UserJoin => do_websocket_operation::<UserJoin>(context, id, op, data).await,
149     UserOperation::PostJoin => do_websocket_operation::<PostJoin>(context, id, op, data).await,
150     UserOperation::CommunityJoin => {
151       do_websocket_operation::<CommunityJoin>(context, id, op, data).await
152     }
153     UserOperation::SaveUserSettings => {
154       do_websocket_operation::<SaveUserSettings>(context, id, op, data).await
155     }
156
157     // Private Message ops
158     UserOperation::CreatePrivateMessage => {
159       do_websocket_operation::<CreatePrivateMessage>(context, id, op, data).await
160     }
161     UserOperation::EditPrivateMessage => {
162       do_websocket_operation::<EditPrivateMessage>(context, id, op, data).await
163     }
164     UserOperation::DeletePrivateMessage => {
165       do_websocket_operation::<DeletePrivateMessage>(context, id, op, data).await
166     }
167     UserOperation::MarkPrivateMessageAsRead => {
168       do_websocket_operation::<MarkPrivateMessageAsRead>(context, id, op, data).await
169     }
170     UserOperation::GetPrivateMessages => {
171       do_websocket_operation::<GetPrivateMessages>(context, id, op, data).await
172     }
173
174     // Site ops
175     UserOperation::GetModlog => do_websocket_operation::<GetModlog>(context, id, op, data).await,
176     UserOperation::CreateSite => do_websocket_operation::<CreateSite>(context, id, op, data).await,
177     UserOperation::EditSite => do_websocket_operation::<EditSite>(context, id, op, data).await,
178     UserOperation::GetSite => do_websocket_operation::<GetSite>(context, id, op, data).await,
179     UserOperation::GetSiteConfig => {
180       do_websocket_operation::<GetSiteConfig>(context, id, op, data).await
181     }
182     UserOperation::SaveSiteConfig => {
183       do_websocket_operation::<SaveSiteConfig>(context, id, op, data).await
184     }
185     UserOperation::Search => do_websocket_operation::<Search>(context, id, op, data).await,
186     UserOperation::TransferCommunity => {
187       do_websocket_operation::<TransferCommunity>(context, id, op, data).await
188     }
189     UserOperation::TransferSite => {
190       do_websocket_operation::<TransferSite>(context, id, op, data).await
191     }
192     UserOperation::ListCategories => {
193       do_websocket_operation::<ListCategories>(context, id, op, data).await
194     }
195
196     // Community ops
197     UserOperation::GetCommunity => {
198       do_websocket_operation::<GetCommunity>(context, id, op, data).await
199     }
200     UserOperation::ListCommunities => {
201       do_websocket_operation::<ListCommunities>(context, id, op, data).await
202     }
203     UserOperation::CreateCommunity => {
204       do_websocket_operation::<CreateCommunity>(context, id, op, data).await
205     }
206     UserOperation::EditCommunity => {
207       do_websocket_operation::<EditCommunity>(context, id, op, data).await
208     }
209     UserOperation::DeleteCommunity => {
210       do_websocket_operation::<DeleteCommunity>(context, id, op, data).await
211     }
212     UserOperation::RemoveCommunity => {
213       do_websocket_operation::<RemoveCommunity>(context, id, op, data).await
214     }
215     UserOperation::FollowCommunity => {
216       do_websocket_operation::<FollowCommunity>(context, id, op, data).await
217     }
218     UserOperation::GetFollowedCommunities => {
219       do_websocket_operation::<GetFollowedCommunities>(context, id, op, data).await
220     }
221     UserOperation::BanFromCommunity => {
222       do_websocket_operation::<BanFromCommunity>(context, id, op, data).await
223     }
224     UserOperation::AddModToCommunity => {
225       do_websocket_operation::<AddModToCommunity>(context, id, op, data).await
226     }
227
228     // Post ops
229     UserOperation::CreatePost => do_websocket_operation::<CreatePost>(context, id, op, data).await,
230     UserOperation::GetPost => do_websocket_operation::<GetPost>(context, id, op, data).await,
231     UserOperation::GetPosts => do_websocket_operation::<GetPosts>(context, id, op, data).await,
232     UserOperation::EditPost => do_websocket_operation::<EditPost>(context, id, op, data).await,
233     UserOperation::DeletePost => do_websocket_operation::<DeletePost>(context, id, op, data).await,
234     UserOperation::RemovePost => do_websocket_operation::<RemovePost>(context, id, op, data).await,
235     UserOperation::LockPost => do_websocket_operation::<LockPost>(context, id, op, data).await,
236     UserOperation::StickyPost => do_websocket_operation::<StickyPost>(context, id, op, data).await,
237     UserOperation::CreatePostLike => {
238       do_websocket_operation::<CreatePostLike>(context, id, op, data).await
239     }
240     UserOperation::SavePost => do_websocket_operation::<SavePost>(context, id, op, data).await,
241
242     // Comment ops
243     UserOperation::CreateComment => {
244       do_websocket_operation::<CreateComment>(context, id, op, data).await
245     }
246     UserOperation::EditComment => {
247       do_websocket_operation::<EditComment>(context, id, op, data).await
248     }
249     UserOperation::DeleteComment => {
250       do_websocket_operation::<DeleteComment>(context, id, op, data).await
251     }
252     UserOperation::RemoveComment => {
253       do_websocket_operation::<RemoveComment>(context, id, op, data).await
254     }
255     UserOperation::MarkCommentAsRead => {
256       do_websocket_operation::<MarkCommentAsRead>(context, id, op, data).await
257     }
258     UserOperation::SaveComment => {
259       do_websocket_operation::<SaveComment>(context, id, op, data).await
260     }
261     UserOperation::GetComments => {
262       do_websocket_operation::<GetComments>(context, id, op, data).await
263     }
264     UserOperation::CreateCommentLike => {
265       do_websocket_operation::<CreateCommentLike>(context, id, op, data).await
266     }
267   }
268 }
269
270 async fn do_websocket_operation<'a, 'b, Data>(
271   context: LemmyContext,
272   id: ConnectionId,
273   op: UserOperation,
274   data: &str,
275 ) -> Result<String, LemmyError>
276 where
277   for<'de> Data: Deserialize<'de> + 'a,
278   Data: Perform,
279 {
280   let parsed_data: Data = serde_json::from_str(&data)?;
281   let res = parsed_data
282     .perform(&web::Data::new(context), Some(id))
283     .await?;
284   serialize_websocket_message(&op, &res)
285 }
286
287 pub(crate) fn captcha_espeak_wav_base64(captcha: &str) -> Result<String, LemmyError> {
288   let mut built_text = String::new();
289
290   // Building proper speech text for espeak
291   for mut c in captcha.chars() {
292     let new_str = if c.is_alphabetic() {
293       if c.is_lowercase() {
294         c.make_ascii_uppercase();
295         format!("lower case {} ... ", c)
296       } else {
297         c.make_ascii_uppercase();
298         format!("capital {} ... ", c)
299       }
300     } else {
301       format!("{} ...", c)
302     };
303
304     built_text.push_str(&new_str);
305   }
306
307   espeak_wav_base64(&built_text)
308 }
309
310 pub(crate) fn espeak_wav_base64(text: &str) -> Result<String, LemmyError> {
311   // Make a temp file path
312   let uuid = uuid::Uuid::new_v4().to_string();
313   let file_path = format!("/tmp/lemmy_espeak_{}.wav", &uuid);
314
315   // Write the wav file
316   Command::new("espeak")
317     .arg("-w")
318     .arg(&file_path)
319     .arg(text)
320     .status()?;
321
322   // Read the wav file bytes
323   let bytes = std::fs::read(&file_path)?;
324
325   // Delete the file
326   std::fs::remove_file(file_path)?;
327
328   // Convert to base64
329   let base64 = base64::encode(bytes);
330
331   Ok(base64)
332 }
333
334 #[derive(Deserialize, Debug)]
335 pub(crate) struct IframelyResponse {
336   title: Option<String>,
337   description: Option<String>,
338   thumbnail_url: Option<String>,
339   html: Option<String>,
340 }
341
342 pub(crate) async fn fetch_iframely(
343   client: &Client,
344   url: &str,
345 ) -> Result<IframelyResponse, LemmyError> {
346   let fetch_url = format!("http://iframely/oembed?url={}", url);
347
348   let response = retry(|| client.get(&fetch_url).send()).await?;
349
350   let res: IframelyResponse = response
351     .json()
352     .await
353     .map_err(|e| RecvError(e.to_string()))?;
354   Ok(res)
355 }
356
357 #[derive(Deserialize, Debug, Clone)]
358 pub(crate) struct PictrsResponse {
359   files: Vec<PictrsFile>,
360   msg: String,
361 }
362
363 #[derive(Deserialize, Debug, Clone)]
364 pub(crate) struct PictrsFile {
365   file: String,
366   delete_token: String,
367 }
368
369 pub(crate) async fn fetch_pictrs(
370   client: &Client,
371   image_url: &str,
372 ) -> Result<PictrsResponse, LemmyError> {
373   is_image_content_type(client, image_url).await?;
374
375   let fetch_url = format!(
376     "http://pictrs:8080/image/download?url={}",
377     utf8_percent_encode(image_url, NON_ALPHANUMERIC) // TODO this might not be needed
378   );
379
380   let response = retry(|| client.get(&fetch_url).send()).await?;
381
382   let response: PictrsResponse = response
383     .json()
384     .await
385     .map_err(|e| RecvError(e.to_string()))?;
386
387   if response.msg == "ok" {
388     Ok(response)
389   } else {
390     Err(anyhow!("{}", &response.msg).into())
391   }
392 }
393
394 async fn fetch_iframely_and_pictrs_data(
395   client: &Client,
396   url: Option<String>,
397 ) -> (
398   Option<String>,
399   Option<String>,
400   Option<String>,
401   Option<String>,
402 ) {
403   match &url {
404     Some(url) => {
405       // Fetch iframely data
406       let (iframely_title, iframely_description, iframely_thumbnail_url, iframely_html) =
407         match fetch_iframely(client, url).await {
408           Ok(res) => (res.title, res.description, res.thumbnail_url, res.html),
409           Err(e) => {
410             error!("iframely err: {}", e);
411             (None, None, None, None)
412           }
413         };
414
415       // Fetch pictrs thumbnail
416       let pictrs_hash = match iframely_thumbnail_url {
417         Some(iframely_thumbnail_url) => match fetch_pictrs(client, &iframely_thumbnail_url).await {
418           Ok(res) => Some(res.files[0].file.to_owned()),
419           Err(e) => {
420             error!("pictrs err: {}", e);
421             None
422           }
423         },
424         // Try to generate a small thumbnail if iframely is not supported
425         None => match fetch_pictrs(client, &url).await {
426           Ok(res) => Some(res.files[0].file.to_owned()),
427           Err(e) => {
428             error!("pictrs err: {}", e);
429             None
430           }
431         },
432       };
433
434       // The full urls are necessary for federation
435       let pictrs_thumbnail = if let Some(pictrs_hash) = pictrs_hash {
436         Some(format!(
437           "{}://{}/pictrs/image/{}",
438           get_apub_protocol_string(),
439           Settings::get().hostname,
440           pictrs_hash
441         ))
442       } else {
443         None
444       };
445
446       (
447         iframely_title,
448         iframely_description,
449         iframely_html,
450         pictrs_thumbnail,
451       )
452     }
453     None => (None, None, None, None),
454   }
455 }
456
457 pub(crate) async fn is_image_content_type(client: &Client, test: &str) -> Result<(), LemmyError> {
458   let response = retry(|| client.get(test).send()).await?;
459
460   if response
461     .headers()
462     .get("Content-Type")
463     .ok_or_else(|| anyhow!("No Content-Type header"))?
464     .to_str()?
465     .starts_with("image/")
466   {
467     Ok(())
468   } else {
469     Err(anyhow!("Not an image type.").into())
470   }
471 }
472
473 #[cfg(test)]
474 mod tests {
475   use crate::{captcha_espeak_wav_base64, is_image_content_type};
476
477   #[test]
478   fn test_image() {
479     actix_rt::System::new("tset_image").block_on(async move {
480       let client = reqwest::Client::default();
481       assert!(is_image_content_type(&client, "https://1734811051.rsc.cdn77.org/data/images/full/365645/as-virus-kills-navajos-in-their-homes-tribal-women-provide-lifeline.jpg?w=600?w=650").await.is_ok());
482       assert!(is_image_content_type(&client,
483                                     "https://twitter.com/BenjaminNorton/status/1259922424272957440?s=20"
484       )
485         .await.is_err()
486       );
487     });
488   }
489
490   #[test]
491   fn test_espeak() {
492     assert!(captcha_espeak_wav_base64("WxRt2l").is_ok())
493   }
494
495   // These helped with testing
496   // #[test]
497   // fn test_iframely() {
498   //   let res = fetch_iframely(client, "https://www.redspark.nu/?p=15341").await;
499   //   assert!(res.is_ok());
500   // }
501
502   // #[test]
503   // fn test_pictshare() {
504   //   let res = fetch_pictshare("https://upload.wikimedia.org/wikipedia/en/2/27/The_Mandalorian_logo.jpg");
505   //   assert!(res.is_ok());
506   //   let res_other = fetch_pictshare("https://upload.wikimedia.org/wikipedia/en/2/27/The_Mandalorian_logo.jpgaoeu");
507   //   assert!(res_other.is_err());
508   // }
509 }