]> Untitled Git - lemmy.git/blob - crates/api/src/local_user.rs
Merge pull request #1535 from LemmyNet/rust_captcha_features
[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       show_scores: data.show_scores,
269       theme: data.theme.to_owned(),
270       default_sort_type,
271       default_listing_type,
272       lang: data.lang.to_owned(),
273       show_avatars: data.show_avatars,
274       send_notifications_to_email: data.send_notifications_to_email,
275     };
276
277     let local_user_res = blocking(context.pool(), move |conn| {
278       LocalUser::update(conn, local_user_id, &local_user_form)
279     })
280     .await?;
281     let updated_local_user = match local_user_res {
282       Ok(u) => u,
283       Err(e) => {
284         let err_type = if e.to_string()
285           == "duplicate key value violates unique constraint \"local_user_email_key\""
286         {
287           "email_already_exists"
288         } else {
289           "user_already_exists"
290         };
291
292         return Err(ApiError::err(err_type).into());
293       }
294     };
295
296     // Return the jwt
297     Ok(LoginResponse {
298       jwt: Claims::jwt(updated_local_user.id.0)?,
299     })
300   }
301 }
302
303 #[async_trait::async_trait(?Send)]
304 impl Perform for AddAdmin {
305   type Response = AddAdminResponse;
306
307   async fn perform(
308     &self,
309     context: &Data<LemmyContext>,
310     websocket_id: Option<ConnectionId>,
311   ) -> Result<AddAdminResponse, LemmyError> {
312     let data: &AddAdmin = &self;
313     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
314
315     // Make sure user is an admin
316     is_admin(&local_user_view)?;
317
318     let added = data.added;
319     let added_person_id = data.person_id;
320     let added_admin = match blocking(context.pool(), move |conn| {
321       Person::add_admin(conn, added_person_id, added)
322     })
323     .await?
324     {
325       Ok(a) => a,
326       Err(_) => {
327         return Err(ApiError::err("couldnt_update_user").into());
328       }
329     };
330
331     // Mod tables
332     let form = ModAddForm {
333       mod_person_id: local_user_view.person.id,
334       other_person_id: added_admin.id,
335       removed: Some(!data.added),
336     };
337
338     blocking(context.pool(), move |conn| ModAdd::create(conn, &form)).await??;
339
340     let site_creator_id = blocking(context.pool(), move |conn| {
341       Site::read(conn, 1).map(|s| s.creator_id)
342     })
343     .await??;
344
345     let mut admins = blocking(context.pool(), move |conn| PersonViewSafe::admins(conn)).await??;
346     let creator_index = admins
347       .iter()
348       .position(|r| r.person.id == site_creator_id)
349       .context(location_info!())?;
350     let creator_person = admins.remove(creator_index);
351     admins.insert(0, creator_person);
352
353     let res = AddAdminResponse { admins };
354
355     context.chat_server().do_send(SendAllMessage {
356       op: UserOperation::AddAdmin,
357       response: res.clone(),
358       websocket_id,
359     });
360
361     Ok(res)
362   }
363 }
364
365 #[async_trait::async_trait(?Send)]
366 impl Perform for BanPerson {
367   type Response = BanPersonResponse;
368
369   async fn perform(
370     &self,
371     context: &Data<LemmyContext>,
372     websocket_id: Option<ConnectionId>,
373   ) -> Result<BanPersonResponse, LemmyError> {
374     let data: &BanPerson = &self;
375     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
376
377     // Make sure user is an admin
378     is_admin(&local_user_view)?;
379
380     let ban = data.ban;
381     let banned_person_id = data.person_id;
382     let ban_person = move |conn: &'_ _| Person::ban_person(conn, banned_person_id, ban);
383     if blocking(context.pool(), ban_person).await?.is_err() {
384       return Err(ApiError::err("couldnt_update_user").into());
385     }
386
387     // Remove their data if that's desired
388     if data.remove_data {
389       // Posts
390       blocking(context.pool(), move |conn: &'_ _| {
391         Post::update_removed_for_creator(conn, banned_person_id, None, true)
392       })
393       .await??;
394
395       // Communities
396       blocking(context.pool(), move |conn: &'_ _| {
397         Community::update_removed_for_creator(conn, banned_person_id, true)
398       })
399       .await??;
400
401       // Comments
402       blocking(context.pool(), move |conn: &'_ _| {
403         Comment::update_removed_for_creator(conn, banned_person_id, true)
404       })
405       .await??;
406     }
407
408     // Mod tables
409     let expires = data.expires.map(naive_from_unix);
410
411     let form = ModBanForm {
412       mod_person_id: local_user_view.person.id,
413       other_person_id: data.person_id,
414       reason: data.reason.to_owned(),
415       banned: Some(data.ban),
416       expires,
417     };
418
419     blocking(context.pool(), move |conn| ModBan::create(conn, &form)).await??;
420
421     let person_id = data.person_id;
422     let person_view = blocking(context.pool(), move |conn| {
423       PersonViewSafe::read(conn, person_id)
424     })
425     .await??;
426
427     let res = BanPersonResponse {
428       person_view,
429       banned: data.ban,
430     };
431
432     context.chat_server().do_send(SendAllMessage {
433       op: UserOperation::BanPerson,
434       response: res.clone(),
435       websocket_id,
436     });
437
438     Ok(res)
439   }
440 }
441
442 #[async_trait::async_trait(?Send)]
443 impl Perform for GetReplies {
444   type Response = GetRepliesResponse;
445
446   async fn perform(
447     &self,
448     context: &Data<LemmyContext>,
449     _websocket_id: Option<ConnectionId>,
450   ) -> Result<GetRepliesResponse, LemmyError> {
451     let data: &GetReplies = &self;
452     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
453
454     let sort = SortType::from_str(&data.sort)?;
455
456     let page = data.page;
457     let limit = data.limit;
458     let unread_only = data.unread_only;
459     let person_id = local_user_view.person.id;
460     let replies = blocking(context.pool(), move |conn| {
461       CommentQueryBuilder::create(conn)
462         .sort(&sort)
463         .unread_only(unread_only)
464         .recipient_id(person_id)
465         .my_person_id(person_id)
466         .page(page)
467         .limit(limit)
468         .list()
469     })
470     .await??;
471
472     Ok(GetRepliesResponse { replies })
473   }
474 }
475
476 #[async_trait::async_trait(?Send)]
477 impl Perform for GetPersonMentions {
478   type Response = GetPersonMentionsResponse;
479
480   async fn perform(
481     &self,
482     context: &Data<LemmyContext>,
483     _websocket_id: Option<ConnectionId>,
484   ) -> Result<GetPersonMentionsResponse, LemmyError> {
485     let data: &GetPersonMentions = &self;
486     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
487
488     let sort = SortType::from_str(&data.sort)?;
489
490     let page = data.page;
491     let limit = data.limit;
492     let unread_only = data.unread_only;
493     let person_id = local_user_view.person.id;
494     let mentions = blocking(context.pool(), move |conn| {
495       PersonMentionQueryBuilder::create(conn)
496         .recipient_id(person_id)
497         .my_person_id(person_id)
498         .sort(&sort)
499         .unread_only(unread_only)
500         .page(page)
501         .limit(limit)
502         .list()
503     })
504     .await??;
505
506     Ok(GetPersonMentionsResponse { mentions })
507   }
508 }
509
510 #[async_trait::async_trait(?Send)]
511 impl Perform for MarkPersonMentionAsRead {
512   type Response = PersonMentionResponse;
513
514   async fn perform(
515     &self,
516     context: &Data<LemmyContext>,
517     _websocket_id: Option<ConnectionId>,
518   ) -> Result<PersonMentionResponse, LemmyError> {
519     let data: &MarkPersonMentionAsRead = &self;
520     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
521
522     let person_mention_id = data.person_mention_id;
523     let read_person_mention = blocking(context.pool(), move |conn| {
524       PersonMention::read(conn, person_mention_id)
525     })
526     .await??;
527
528     if local_user_view.person.id != read_person_mention.recipient_id {
529       return Err(ApiError::err("couldnt_update_comment").into());
530     }
531
532     let person_mention_id = read_person_mention.id;
533     let read = data.read;
534     let update_mention =
535       move |conn: &'_ _| PersonMention::update_read(conn, person_mention_id, read);
536     if blocking(context.pool(), update_mention).await?.is_err() {
537       return Err(ApiError::err("couldnt_update_comment").into());
538     };
539
540     let person_mention_id = read_person_mention.id;
541     let person_id = local_user_view.person.id;
542     let person_mention_view = blocking(context.pool(), move |conn| {
543       PersonMentionView::read(conn, person_mention_id, Some(person_id))
544     })
545     .await??;
546
547     Ok(PersonMentionResponse {
548       person_mention_view,
549     })
550   }
551 }
552
553 #[async_trait::async_trait(?Send)]
554 impl Perform for MarkAllAsRead {
555   type Response = GetRepliesResponse;
556
557   async fn perform(
558     &self,
559     context: &Data<LemmyContext>,
560     _websocket_id: Option<ConnectionId>,
561   ) -> Result<GetRepliesResponse, LemmyError> {
562     let data: &MarkAllAsRead = &self;
563     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
564
565     let person_id = local_user_view.person.id;
566     let replies = blocking(context.pool(), move |conn| {
567       CommentQueryBuilder::create(conn)
568         .my_person_id(person_id)
569         .recipient_id(person_id)
570         .unread_only(true)
571         .page(1)
572         .limit(999)
573         .list()
574     })
575     .await??;
576
577     // TODO: this should probably be a bulk operation
578     // Not easy to do as a bulk operation,
579     // because recipient_id isn't in the comment table
580     for comment_view in &replies {
581       let reply_id = comment_view.comment.id;
582       let mark_as_read = move |conn: &'_ _| Comment::update_read(conn, reply_id, true);
583       if blocking(context.pool(), mark_as_read).await?.is_err() {
584         return Err(ApiError::err("couldnt_update_comment").into());
585       }
586     }
587
588     // Mark all user mentions as read
589     let update_person_mentions =
590       move |conn: &'_ _| PersonMention::mark_all_as_read(conn, person_id);
591     if blocking(context.pool(), update_person_mentions)
592       .await?
593       .is_err()
594     {
595       return Err(ApiError::err("couldnt_update_comment").into());
596     }
597
598     // Mark all private_messages as read
599     let update_pm = move |conn: &'_ _| PrivateMessage::mark_all_as_read(conn, person_id);
600     if blocking(context.pool(), update_pm).await?.is_err() {
601       return Err(ApiError::err("couldnt_update_private_message").into());
602     }
603
604     Ok(GetRepliesResponse { replies: vec![] })
605   }
606 }
607
608 #[async_trait::async_trait(?Send)]
609 impl Perform for PasswordReset {
610   type Response = PasswordResetResponse;
611
612   async fn perform(
613     &self,
614     context: &Data<LemmyContext>,
615     _websocket_id: Option<ConnectionId>,
616   ) -> Result<PasswordResetResponse, LemmyError> {
617     let data: &PasswordReset = &self;
618
619     // Fetch that email
620     let email = data.email.clone();
621     let local_user_view = match blocking(context.pool(), move |conn| {
622       LocalUserView::find_by_email(conn, &email)
623     })
624     .await?
625     {
626       Ok(lu) => lu,
627       Err(_e) => return Err(ApiError::err("couldnt_find_that_username_or_email").into()),
628     };
629
630     // Generate a random token
631     let token = generate_random_string();
632
633     // Insert the row
634     let token2 = token.clone();
635     let local_user_id = local_user_view.local_user.id;
636     blocking(context.pool(), move |conn| {
637       PasswordResetRequest::create_token(conn, local_user_id, &token2)
638     })
639     .await??;
640
641     // Email the pure token to the user.
642     // TODO no i18n support here.
643     let email = &local_user_view.local_user.email.expect("email");
644     let subject = &format!("Password reset for {}", local_user_view.person.name);
645     let hostname = &Settings::get().get_protocol_and_hostname();
646     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);
647     match send_email(subject, email, &local_user_view.person.name, html) {
648       Ok(_o) => _o,
649       Err(_e) => return Err(ApiError::err(&_e).into()),
650     };
651
652     Ok(PasswordResetResponse {})
653   }
654 }
655
656 #[async_trait::async_trait(?Send)]
657 impl Perform for PasswordChange {
658   type Response = LoginResponse;
659
660   async fn perform(
661     &self,
662     context: &Data<LemmyContext>,
663     _websocket_id: Option<ConnectionId>,
664   ) -> Result<LoginResponse, LemmyError> {
665     let data: &PasswordChange = &self;
666
667     // Fetch the user_id from the token
668     let token = data.token.clone();
669     let local_user_id = blocking(context.pool(), move |conn| {
670       PasswordResetRequest::read_from_token(conn, &token).map(|p| p.local_user_id)
671     })
672     .await??;
673
674     password_length_check(&data.password)?;
675
676     // Make sure passwords match
677     if data.password != data.password_verify {
678       return Err(ApiError::err("passwords_dont_match").into());
679     }
680
681     // Update the user with the new password
682     let password = data.password.clone();
683     let updated_local_user = match blocking(context.pool(), move |conn| {
684       LocalUser::update_password(conn, local_user_id, &password)
685     })
686     .await?
687     {
688       Ok(u) => u,
689       Err(_e) => return Err(ApiError::err("couldnt_update_user").into()),
690     };
691
692     // Return the jwt
693     Ok(LoginResponse {
694       jwt: Claims::jwt(updated_local_user.id.0)?,
695     })
696   }
697 }
698
699 #[async_trait::async_trait(?Send)]
700 impl Perform for GetReportCount {
701   type Response = GetReportCountResponse;
702
703   async fn perform(
704     &self,
705     context: &Data<LemmyContext>,
706     websocket_id: Option<ConnectionId>,
707   ) -> Result<GetReportCountResponse, LemmyError> {
708     let data: &GetReportCount = &self;
709     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
710
711     let person_id = local_user_view.person.id;
712     let community_id = data.community;
713     let community_ids =
714       collect_moderated_communities(person_id, community_id, context.pool()).await?;
715
716     let res = {
717       if community_ids.is_empty() {
718         GetReportCountResponse {
719           community: None,
720           comment_reports: 0,
721           post_reports: 0,
722         }
723       } else {
724         let ids = community_ids.clone();
725         let comment_reports = blocking(context.pool(), move |conn| {
726           CommentReportView::get_report_count(conn, &ids)
727         })
728         .await??;
729
730         let ids = community_ids.clone();
731         let post_reports = blocking(context.pool(), move |conn| {
732           PostReportView::get_report_count(conn, &ids)
733         })
734         .await??;
735
736         GetReportCountResponse {
737           community: data.community,
738           comment_reports,
739           post_reports,
740         }
741       }
742     };
743
744     context.chat_server().do_send(SendUserRoomMessage {
745       op: UserOperation::GetReportCount,
746       response: res.clone(),
747       local_recipient_id: local_user_view.local_user.id,
748       websocket_id,
749     });
750
751     Ok(res)
752   }
753 }
754
755 #[async_trait::async_trait(?Send)]
756 impl Perform for GetFollowedCommunities {
757   type Response = GetFollowedCommunitiesResponse;
758
759   async fn perform(
760     &self,
761     context: &Data<LemmyContext>,
762     _websocket_id: Option<ConnectionId>,
763   ) -> Result<GetFollowedCommunitiesResponse, LemmyError> {
764     let data: &GetFollowedCommunities = &self;
765     let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
766
767     let person_id = local_user_view.person.id;
768     let communities = match blocking(context.pool(), move |conn| {
769       CommunityFollowerView::for_person(conn, person_id)
770     })
771     .await?
772     {
773       Ok(communities) => communities,
774       _ => return Err(ApiError::err("system_err_login").into()),
775     };
776
777     // Return the jwt
778     Ok(GetFollowedCommunitiesResponse { communities })
779   }
780 }