]> Untitled Git - lemmy.git/blob - crates/api/src/local_user.rs
831e38a17de8af0b56056f5ddaeb63c919262153
[lemmy.git] / crates / api / src / local_user.rs
1 use crate::{captcha_as_wav_base64, Perform};
2 use actix_web::web::Data;
3 use anyhow::Context;
4 use bcrypt::verify;
5 use captcha::{gen, Difficulty};
6 use chrono::Duration;
7 use lemmy_api_common::{
8   blocking,
9   collect_moderated_communities,
10   community::{GetFollowedCommunities, GetFollowedCommunitiesResponse},
11   get_local_user_view_from_jwt,
12   is_admin,
13   password_length_check,
14   person::*,
15 };
16 use lemmy_db_queries::{
17   diesel_option_overwrite,
18   diesel_option_overwrite_to_url,
19   source::{
20     comment::Comment_,
21     community::Community_,
22     local_user::LocalUser_,
23     password_reset_request::PasswordResetRequest_,
24     person::Person_,
25     person_mention::PersonMention_,
26     post::Post_,
27     private_message::PrivateMessage_,
28   },
29   Crud,
30   SortType,
31 };
32 use lemmy_db_schema::{
33   naive_now,
34   source::{
35     comment::Comment,
36     community::*,
37     local_user::{LocalUser, LocalUserForm},
38     moderator::*,
39     password_reset_request::*,
40     person::*,
41     person_mention::*,
42     post::Post,
43     private_message::PrivateMessage,
44     site::*,
45   },
46 };
47 use lemmy_db_views::{
48   comment_report_view::CommentReportView,
49   comment_view::CommentQueryBuilder,
50   local_user_view::LocalUserView,
51   post_report_view::PostReportView,
52 };
53 use lemmy_db_views_actor::{
54   community_follower_view::CommunityFollowerView,
55   person_mention_view::{PersonMentionQueryBuilder, PersonMentionView},
56   person_view::PersonViewSafe,
57 };
58 use lemmy_utils::{
59   claims::Claims,
60   email::send_email,
61   location_info,
62   settings::structs::Settings,
63   utils::{generate_random_string, is_valid_preferred_username, naive_from_unix},
64   ApiError,
65   ConnectionId,
66   LemmyError,
67 };
68 use lemmy_websocket::{
69   messages::{CaptchaItem, SendAllMessage, SendUserRoomMessage},
70   LemmyContext,
71   UserOperation,
72 };
73 use std::str::FromStr;
74
75 #[async_trait::async_trait(?Send)]
76 impl Perform for Login {
77   type Response = LoginResponse;
78
79   async fn perform(
80     &self,
81     context: &Data<LemmyContext>,
82     _websocket_id: Option<ConnectionId>,
83   ) -> Result<LoginResponse, LemmyError> {
84     let data: &Login = &self;
85
86     // Fetch that username / email
87     let username_or_email = data.username_or_email.clone();
88     let local_user_view = match blocking(context.pool(), move |conn| {
89       LocalUserView::find_by_email_or_name(conn, &username_or_email)
90     })
91     .await?
92     {
93       Ok(uv) => uv,
94       Err(_e) => return Err(ApiError::err("couldnt_find_that_username_or_email").into()),
95     };
96
97     // Verify the password
98     let valid: bool = verify(
99       &data.password,
100       &local_user_view.local_user.password_encrypted,
101     )
102     .unwrap_or(false);
103     if !valid {
104       return Err(ApiError::err("password_incorrect").into());
105     }
106
107     // Return the jwt
108     Ok(LoginResponse {
109       jwt: Claims::jwt(local_user_view.local_user.id.0)?,
110     })
111   }
112 }
113
114 #[async_trait::async_trait(?Send)]
115 impl Perform for GetCaptcha {
116   type Response = GetCaptchaResponse;
117
118   async fn perform(
119     &self,
120     context: &Data<LemmyContext>,
121     _websocket_id: Option<ConnectionId>,
122   ) -> Result<Self::Response, LemmyError> {
123     let captcha_settings = Settings::get().captcha();
124
125     if !captcha_settings.enabled {
126       return Ok(GetCaptchaResponse { ok: None });
127     }
128
129     let captcha = match captcha_settings.difficulty.as_str() {
130       "easy" => gen(Difficulty::Easy),
131       "medium" => gen(Difficulty::Medium),
132       "hard" => gen(Difficulty::Hard),
133       _ => gen(Difficulty::Medium),
134     };
135
136     let answer = captcha.chars_as_string();
137
138     let png = captcha.as_base64().expect("failed to generate captcha");
139
140     let uuid = uuid::Uuid::new_v4().to_string();
141
142     let wav = captcha_as_wav_base64(&captcha);
143
144     let captcha_item = CaptchaItem {
145       answer,
146       uuid: uuid.to_owned(),
147       expires: naive_now() + Duration::minutes(10), // expires in 10 minutes
148     };
149
150     // Stores the captcha item on the queue
151     context.chat_server().do_send(captcha_item);
152
153     Ok(GetCaptchaResponse {
154       ok: Some(CaptchaResponse { png, wav, uuid }),
155     })
156   }
157 }
158
159 #[async_trait::async_trait(?Send)]
160 impl Perform for SaveUserSettings {
161   type Response = LoginResponse;
162
163   async fn perform(
164     &self,
165     context: &Data<LemmyContext>,
166     _websocket_id: Option<ConnectionId>,
167   ) -> Result<LoginResponse, LemmyError> {
168     let data: &SaveUserSettings = &self;
169     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
170
171     let avatar = diesel_option_overwrite_to_url(&data.avatar)?;
172     let banner = diesel_option_overwrite_to_url(&data.banner)?;
173     let email = diesel_option_overwrite(&data.email);
174     let bio = diesel_option_overwrite(&data.bio);
175     let preferred_username = diesel_option_overwrite(&data.preferred_username);
176     let matrix_user_id = diesel_option_overwrite(&data.matrix_user_id);
177
178     if let Some(Some(bio)) = &bio {
179       if bio.chars().count() > 300 {
180         return Err(ApiError::err("bio_length_overflow").into());
181       }
182     }
183
184     if let Some(Some(preferred_username)) = &preferred_username {
185       if !is_valid_preferred_username(preferred_username.trim()) {
186         return Err(ApiError::err("invalid_username").into());
187       }
188     }
189
190     let local_user_id = local_user_view.local_user.id;
191     let person_id = local_user_view.person.id;
192     let password_encrypted = match &data.new_password {
193       Some(new_password) => {
194         match &data.new_password_verify {
195           Some(new_password_verify) => {
196             password_length_check(&new_password)?;
197
198             // Make sure passwords match
199             if new_password != new_password_verify {
200               return Err(ApiError::err("passwords_dont_match").into());
201             }
202
203             // Check the old password
204             match &data.old_password {
205               Some(old_password) => {
206                 let valid: bool =
207                   verify(old_password, &local_user_view.local_user.password_encrypted)
208                     .unwrap_or(false);
209                 if !valid {
210                   return Err(ApiError::err("password_incorrect").into());
211                 }
212                 let new_password = new_password.to_owned();
213                 let user = blocking(context.pool(), move |conn| {
214                   LocalUser::update_password(conn, local_user_id, &new_password)
215                 })
216                 .await??;
217                 user.password_encrypted
218               }
219               None => return Err(ApiError::err("password_incorrect").into()),
220             }
221           }
222           None => return Err(ApiError::err("passwords_dont_match").into()),
223         }
224       }
225       None => local_user_view.local_user.password_encrypted,
226     };
227
228     let default_listing_type = data.default_listing_type;
229     let default_sort_type = data.default_sort_type;
230
231     let person_form = PersonForm {
232       name: local_user_view.person.name,
233       avatar,
234       banner,
235       inbox_url: None,
236       preferred_username,
237       published: None,
238       updated: Some(naive_now()),
239       banned: None,
240       deleted: None,
241       actor_id: None,
242       bio,
243       local: None,
244       admin: None,
245       private_key: None,
246       public_key: None,
247       last_refreshed_at: None,
248       shared_inbox_url: None,
249       matrix_user_id,
250     };
251
252     let person_res = blocking(context.pool(), move |conn| {
253       Person::update(conn, person_id, &person_form)
254     })
255     .await?;
256     let _updated_person: Person = match person_res {
257       Ok(p) => p,
258       Err(_) => {
259         return Err(ApiError::err("user_already_exists").into());
260       }
261     };
262
263     let local_user_form = LocalUserForm {
264       person_id,
265       email,
266       password_encrypted,
267       show_nsfw: data.show_nsfw,
268       theme: data.theme.to_owned(),
269       default_sort_type,
270       default_listing_type,
271       lang: data.lang.to_owned(),
272       show_avatars: data.show_avatars,
273       send_notifications_to_email: data.send_notifications_to_email,
274     };
275
276     let local_user_res = blocking(context.pool(), move |conn| {
277       LocalUser::update(conn, local_user_id, &local_user_form)
278     })
279     .await?;
280     let updated_local_user = match local_user_res {
281       Ok(u) => u,
282       Err(e) => {
283         let err_type = if e.to_string()
284           == "duplicate key value violates unique constraint \"local_user_email_key\""
285         {
286           "email_already_exists"
287         } else {
288           "user_already_exists"
289         };
290
291         return Err(ApiError::err(err_type).into());
292       }
293     };
294
295     // Return the jwt
296     Ok(LoginResponse {
297       jwt: Claims::jwt(updated_local_user.id.0)?,
298     })
299   }
300 }
301
302 #[async_trait::async_trait(?Send)]
303 impl Perform for AddAdmin {
304   type Response = AddAdminResponse;
305
306   async fn perform(
307     &self,
308     context: &Data<LemmyContext>,
309     websocket_id: Option<ConnectionId>,
310   ) -> Result<AddAdminResponse, LemmyError> {
311     let data: &AddAdmin = &self;
312     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
313
314     // Make sure user is an admin
315     is_admin(&local_user_view)?;
316
317     let added = data.added;
318     let added_person_id = data.person_id;
319     let added_admin = match blocking(context.pool(), move |conn| {
320       Person::add_admin(conn, added_person_id, added)
321     })
322     .await?
323     {
324       Ok(a) => a,
325       Err(_) => {
326         return Err(ApiError::err("couldnt_update_user").into());
327       }
328     };
329
330     // Mod tables
331     let form = ModAddForm {
332       mod_person_id: local_user_view.person.id,
333       other_person_id: added_admin.id,
334       removed: Some(!data.added),
335     };
336
337     blocking(context.pool(), move |conn| ModAdd::create(conn, &form)).await??;
338
339     let site_creator_id = blocking(context.pool(), move |conn| {
340       Site::read(conn, 1).map(|s| s.creator_id)
341     })
342     .await??;
343
344     let mut admins = blocking(context.pool(), move |conn| PersonViewSafe::admins(conn)).await??;
345     let creator_index = admins
346       .iter()
347       .position(|r| r.person.id == site_creator_id)
348       .context(location_info!())?;
349     let creator_person = admins.remove(creator_index);
350     admins.insert(0, creator_person);
351
352     let res = AddAdminResponse { admins };
353
354     context.chat_server().do_send(SendAllMessage {
355       op: UserOperation::AddAdmin,
356       response: res.clone(),
357       websocket_id,
358     });
359
360     Ok(res)
361   }
362 }
363
364 #[async_trait::async_trait(?Send)]
365 impl Perform for BanPerson {
366   type Response = BanPersonResponse;
367
368   async fn perform(
369     &self,
370     context: &Data<LemmyContext>,
371     websocket_id: Option<ConnectionId>,
372   ) -> Result<BanPersonResponse, LemmyError> {
373     let data: &BanPerson = &self;
374     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
375
376     // Make sure user is an admin
377     is_admin(&local_user_view)?;
378
379     let ban = data.ban;
380     let banned_person_id = data.person_id;
381     let ban_person = move |conn: &'_ _| Person::ban_person(conn, banned_person_id, ban);
382     if blocking(context.pool(), ban_person).await?.is_err() {
383       return Err(ApiError::err("couldnt_update_user").into());
384     }
385
386     // Remove their data if that's desired
387     if data.remove_data {
388       // Posts
389       blocking(context.pool(), move |conn: &'_ _| {
390         Post::update_removed_for_creator(conn, banned_person_id, None, true)
391       })
392       .await??;
393
394       // Communities
395       blocking(context.pool(), move |conn: &'_ _| {
396         Community::update_removed_for_creator(conn, banned_person_id, true)
397       })
398       .await??;
399
400       // Comments
401       blocking(context.pool(), move |conn: &'_ _| {
402         Comment::update_removed_for_creator(conn, banned_person_id, true)
403       })
404       .await??;
405     }
406
407     // Mod tables
408     let expires = data.expires.map(naive_from_unix);
409
410     let form = ModBanForm {
411       mod_person_id: local_user_view.person.id,
412       other_person_id: data.person_id,
413       reason: data.reason.to_owned(),
414       banned: Some(data.ban),
415       expires,
416     };
417
418     blocking(context.pool(), move |conn| ModBan::create(conn, &form)).await??;
419
420     let person_id = data.person_id;
421     let person_view = blocking(context.pool(), move |conn| {
422       PersonViewSafe::read(conn, person_id)
423     })
424     .await??;
425
426     let res = BanPersonResponse {
427       person_view,
428       banned: data.ban,
429     };
430
431     context.chat_server().do_send(SendAllMessage {
432       op: UserOperation::BanPerson,
433       response: res.clone(),
434       websocket_id,
435     });
436
437     Ok(res)
438   }
439 }
440
441 #[async_trait::async_trait(?Send)]
442 impl Perform for GetReplies {
443   type Response = GetRepliesResponse;
444
445   async fn perform(
446     &self,
447     context: &Data<LemmyContext>,
448     _websocket_id: Option<ConnectionId>,
449   ) -> Result<GetRepliesResponse, LemmyError> {
450     let data: &GetReplies = &self;
451     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
452
453     let sort = SortType::from_str(&data.sort)?;
454
455     let page = data.page;
456     let limit = data.limit;
457     let unread_only = data.unread_only;
458     let person_id = local_user_view.person.id;
459     let replies = blocking(context.pool(), move |conn| {
460       CommentQueryBuilder::create(conn)
461         .sort(&sort)
462         .unread_only(unread_only)
463         .recipient_id(person_id)
464         .my_person_id(person_id)
465         .page(page)
466         .limit(limit)
467         .list()
468     })
469     .await??;
470
471     Ok(GetRepliesResponse { replies })
472   }
473 }
474
475 #[async_trait::async_trait(?Send)]
476 impl Perform for GetPersonMentions {
477   type Response = GetPersonMentionsResponse;
478
479   async fn perform(
480     &self,
481     context: &Data<LemmyContext>,
482     _websocket_id: Option<ConnectionId>,
483   ) -> Result<GetPersonMentionsResponse, LemmyError> {
484     let data: &GetPersonMentions = &self;
485     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
486
487     let sort = SortType::from_str(&data.sort)?;
488
489     let page = data.page;
490     let limit = data.limit;
491     let unread_only = data.unread_only;
492     let person_id = local_user_view.person.id;
493     let mentions = blocking(context.pool(), move |conn| {
494       PersonMentionQueryBuilder::create(conn)
495         .recipient_id(person_id)
496         .my_person_id(person_id)
497         .sort(&sort)
498         .unread_only(unread_only)
499         .page(page)
500         .limit(limit)
501         .list()
502     })
503     .await??;
504
505     Ok(GetPersonMentionsResponse { mentions })
506   }
507 }
508
509 #[async_trait::async_trait(?Send)]
510 impl Perform for MarkPersonMentionAsRead {
511   type Response = PersonMentionResponse;
512
513   async fn perform(
514     &self,
515     context: &Data<LemmyContext>,
516     _websocket_id: Option<ConnectionId>,
517   ) -> Result<PersonMentionResponse, LemmyError> {
518     let data: &MarkPersonMentionAsRead = &self;
519     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
520
521     let person_mention_id = data.person_mention_id;
522     let read_person_mention = blocking(context.pool(), move |conn| {
523       PersonMention::read(conn, person_mention_id)
524     })
525     .await??;
526
527     if local_user_view.person.id != read_person_mention.recipient_id {
528       return Err(ApiError::err("couldnt_update_comment").into());
529     }
530
531     let person_mention_id = read_person_mention.id;
532     let read = data.read;
533     let update_mention =
534       move |conn: &'_ _| PersonMention::update_read(conn, person_mention_id, read);
535     if blocking(context.pool(), update_mention).await?.is_err() {
536       return Err(ApiError::err("couldnt_update_comment").into());
537     };
538
539     let person_mention_id = read_person_mention.id;
540     let person_id = local_user_view.person.id;
541     let person_mention_view = blocking(context.pool(), move |conn| {
542       PersonMentionView::read(conn, person_mention_id, Some(person_id))
543     })
544     .await??;
545
546     Ok(PersonMentionResponse {
547       person_mention_view,
548     })
549   }
550 }
551
552 #[async_trait::async_trait(?Send)]
553 impl Perform for MarkAllAsRead {
554   type Response = GetRepliesResponse;
555
556   async fn perform(
557     &self,
558     context: &Data<LemmyContext>,
559     _websocket_id: Option<ConnectionId>,
560   ) -> Result<GetRepliesResponse, LemmyError> {
561     let data: &MarkAllAsRead = &self;
562     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
563
564     let person_id = local_user_view.person.id;
565     let replies = blocking(context.pool(), move |conn| {
566       CommentQueryBuilder::create(conn)
567         .my_person_id(person_id)
568         .recipient_id(person_id)
569         .unread_only(true)
570         .page(1)
571         .limit(999)
572         .list()
573     })
574     .await??;
575
576     // TODO: this should probably be a bulk operation
577     // Not easy to do as a bulk operation,
578     // because recipient_id isn't in the comment table
579     for comment_view in &replies {
580       let reply_id = comment_view.comment.id;
581       let mark_as_read = move |conn: &'_ _| Comment::update_read(conn, reply_id, true);
582       if blocking(context.pool(), mark_as_read).await?.is_err() {
583         return Err(ApiError::err("couldnt_update_comment").into());
584       }
585     }
586
587     // Mark all user mentions as read
588     let update_person_mentions =
589       move |conn: &'_ _| PersonMention::mark_all_as_read(conn, person_id);
590     if blocking(context.pool(), update_person_mentions)
591       .await?
592       .is_err()
593     {
594       return Err(ApiError::err("couldnt_update_comment").into());
595     }
596
597     // Mark all private_messages as read
598     let update_pm = move |conn: &'_ _| PrivateMessage::mark_all_as_read(conn, person_id);
599     if blocking(context.pool(), update_pm).await?.is_err() {
600       return Err(ApiError::err("couldnt_update_private_message").into());
601     }
602
603     Ok(GetRepliesResponse { replies: vec![] })
604   }
605 }
606
607 #[async_trait::async_trait(?Send)]
608 impl Perform for PasswordReset {
609   type Response = PasswordResetResponse;
610
611   async fn perform(
612     &self,
613     context: &Data<LemmyContext>,
614     _websocket_id: Option<ConnectionId>,
615   ) -> Result<PasswordResetResponse, LemmyError> {
616     let data: &PasswordReset = &self;
617
618     // Fetch that email
619     let email = data.email.clone();
620     let local_user_view = match blocking(context.pool(), move |conn| {
621       LocalUserView::find_by_email(conn, &email)
622     })
623     .await?
624     {
625       Ok(lu) => lu,
626       Err(_e) => return Err(ApiError::err("couldnt_find_that_username_or_email").into()),
627     };
628
629     // Generate a random token
630     let token = generate_random_string();
631
632     // Insert the row
633     let token2 = token.clone();
634     let local_user_id = local_user_view.local_user.id;
635     blocking(context.pool(), move |conn| {
636       PasswordResetRequest::create_token(conn, local_user_id, &token2)
637     })
638     .await??;
639
640     // Email the pure token to the user.
641     // TODO no i18n support here.
642     let email = &local_user_view.local_user.email.expect("email");
643     let subject = &format!("Password reset for {}", local_user_view.person.name);
644     let hostname = &Settings::get().get_protocol_and_hostname();
645     let html = &format!("<h1>Password Reset Request for {}</h1><br><a href={}/password_change/{}>Click here to reset your password</a>", local_user_view.person.name, hostname, &token);
646     match send_email(subject, email, &local_user_view.person.name, html) {
647       Ok(_o) => _o,
648       Err(_e) => return Err(ApiError::err(&_e).into()),
649     };
650
651     Ok(PasswordResetResponse {})
652   }
653 }
654
655 #[async_trait::async_trait(?Send)]
656 impl Perform for PasswordChange {
657   type Response = LoginResponse;
658
659   async fn perform(
660     &self,
661     context: &Data<LemmyContext>,
662     _websocket_id: Option<ConnectionId>,
663   ) -> Result<LoginResponse, LemmyError> {
664     let data: &PasswordChange = &self;
665
666     // Fetch the user_id from the token
667     let token = data.token.clone();
668     let local_user_id = blocking(context.pool(), move |conn| {
669       PasswordResetRequest::read_from_token(conn, &token).map(|p| p.local_user_id)
670     })
671     .await??;
672
673     password_length_check(&data.password)?;
674
675     // Make sure passwords match
676     if data.password != data.password_verify {
677       return Err(ApiError::err("passwords_dont_match").into());
678     }
679
680     // Update the user with the new password
681     let password = data.password.clone();
682     let updated_local_user = match blocking(context.pool(), move |conn| {
683       LocalUser::update_password(conn, local_user_id, &password)
684     })
685     .await?
686     {
687       Ok(u) => u,
688       Err(_e) => return Err(ApiError::err("couldnt_update_user").into()),
689     };
690
691     // Return the jwt
692     Ok(LoginResponse {
693       jwt: Claims::jwt(updated_local_user.id.0)?,
694     })
695   }
696 }
697
698 #[async_trait::async_trait(?Send)]
699 impl Perform for GetReportCount {
700   type Response = GetReportCountResponse;
701
702   async fn perform(
703     &self,
704     context: &Data<LemmyContext>,
705     websocket_id: Option<ConnectionId>,
706   ) -> Result<GetReportCountResponse, LemmyError> {
707     let data: &GetReportCount = &self;
708     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
709
710     let person_id = local_user_view.person.id;
711     let community_id = data.community;
712     let community_ids =
713       collect_moderated_communities(person_id, community_id, context.pool()).await?;
714
715     let res = {
716       if community_ids.is_empty() {
717         GetReportCountResponse {
718           community: None,
719           comment_reports: 0,
720           post_reports: 0,
721         }
722       } else {
723         let ids = community_ids.clone();
724         let comment_reports = blocking(context.pool(), move |conn| {
725           CommentReportView::get_report_count(conn, &ids)
726         })
727         .await??;
728
729         let ids = community_ids.clone();
730         let post_reports = blocking(context.pool(), move |conn| {
731           PostReportView::get_report_count(conn, &ids)
732         })
733         .await??;
734
735         GetReportCountResponse {
736           community: data.community,
737           comment_reports,
738           post_reports,
739         }
740       }
741     };
742
743     context.chat_server().do_send(SendUserRoomMessage {
744       op: UserOperation::GetReportCount,
745       response: res.clone(),
746       local_recipient_id: local_user_view.local_user.id,
747       websocket_id,
748     });
749
750     Ok(res)
751   }
752 }
753
754 #[async_trait::async_trait(?Send)]
755 impl Perform for GetFollowedCommunities {
756   type Response = GetFollowedCommunitiesResponse;
757
758   async fn perform(
759     &self,
760     context: &Data<LemmyContext>,
761     _websocket_id: Option<ConnectionId>,
762   ) -> Result<GetFollowedCommunitiesResponse, LemmyError> {
763     let data: &GetFollowedCommunities = &self;
764     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
765
766     let person_id = local_user_view.person.id;
767     let communities = match blocking(context.pool(), move |conn| {
768       CommunityFollowerView::for_person(conn, person_id)
769     })
770     .await?
771     {
772       Ok(communities) => communities,
773       _ => return Err(ApiError::err("system_err_login").into()),
774     };
775
776     // Return the jwt
777     Ok(GetFollowedCommunitiesResponse { communities })
778   }
779 }