tetratto_core/database/
communities.rs

1use super::common::NAME_REGEX;
2use oiseau::cache::Cache;
3use crate::{
4    auto_method, DataManager,
5    model::{
6        Error, Result,
7        auth::User,
8        communities::{
9            CommunityReadAccess, CommunityWriteAccess, ForumTopic, Community, CommunityContext,
10            CommunityJoinAccess, CommunityMembership,
11        },
12        permissions::{FinePermission, SecondaryPermission},
13        communities_permissions::CommunityPermission,
14    },
15};
16use pathbufd::PathBufD;
17use std::{
18    fs::{exists, remove_file},
19    collections::HashMap,
20};
21
22use oiseau::{PostgresRow, execute, get, query_row, query_rows, params};
23
24impl DataManager {
25    /// Get a [`Community`] from an SQL row.
26    pub(crate) fn get_community_from_row(x: &PostgresRow) -> Community {
27        Community {
28            id: get!(x->0(i64)) as usize,
29            created: get!(x->1(i64)) as usize,
30            title: get!(x->2(String)),
31            context: serde_json::from_str(&get!(x->3(String))).unwrap(),
32            owner: get!(x->4(i64)) as usize,
33            read_access: serde_json::from_str(&get!(x->5(String))).unwrap(),
34            write_access: serde_json::from_str(&get!(x->6(String))).unwrap(),
35            join_access: serde_json::from_str(&get!(x->7(String))).unwrap(),
36            likes: get!(x->8(i32)) as isize,
37            dislikes: get!(x->9(i32)) as isize,
38            member_count: get!(x->10(i32)) as usize,
39            is_forge: get!(x->11(i32)) as i8 == 1,
40            post_count: get!(x->12(i32)) as usize,
41            is_forum: get!(x->13(i32)) as i8 == 1,
42            topics: serde_json::from_str(&get!(x->14(String))).unwrap(),
43        }
44    }
45
46    pub async fn get_community_by_id(&self, id: usize) -> Result<Community> {
47        if id == 0 {
48            return Ok(Community::void());
49        }
50
51        if let Some(cached) = self.0.1.get(format!("atto.community:{}", id)).await {
52            match serde_json::from_str(&cached) {
53                Ok(c) => return Ok(c),
54                Err(_) => self.0.1.remove(format!("atto.community:{}", id)).await,
55            };
56        }
57
58        let conn = match self.0.connect().await {
59            Ok(c) => c,
60            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
61        };
62
63        let res = query_row!(
64            &conn,
65            "SELECT * FROM communities WHERE id = $1",
66            &[&(id as i64)],
67            |x| { Ok(Self::get_community_from_row(x)) }
68        );
69
70        if res.is_err() {
71            return Ok(Community::void());
72            // return Err(Error::GeneralNotFound("community".to_string()));
73        }
74
75        let x = res.unwrap();
76        self.0
77            .1
78            .set(
79                format!("atto.community:{}", id),
80                serde_json::to_string(&x).unwrap(),
81            )
82            .await;
83
84        Ok(x)
85    }
86
87    pub async fn get_community_by_title(&self, id: &str) -> Result<Community> {
88        if id == "void" {
89            return Ok(Community::void());
90        }
91
92        if let Some(cached) = self.0.1.get(format!("atto.community:{}", id)).await {
93            match serde_json::from_str(&cached) {
94                Ok(c) => return Ok(c),
95                Err(_) => self.0.1.remove(format!("atto.community:{}", id)).await,
96            };
97        }
98
99        let conn = match self.0.connect().await {
100            Ok(c) => c,
101            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
102        };
103
104        let res = query_row!(
105            &conn,
106            "SELECT * FROM communities WHERE title = $1",
107            params![&id],
108            |x| { Ok(Self::get_community_from_row(x)) }
109        );
110
111        if res.is_err() {
112            return Ok(Community::void());
113            // return Err(Error::GeneralNotFound("community".to_string()));
114        }
115
116        let x = res.unwrap();
117        self.0
118            .1
119            .set(
120                format!("atto.community:{}", id),
121                serde_json::to_string(&x).unwrap(),
122            )
123            .await;
124
125        Ok(x)
126    }
127
128    auto_method!(get_community_by_id_no_void()@get_community_from_row -> "SELECT * FROM communities WHERE id = $1" --name="community" --returns=Community --cache-key-tmpl="atto.community:{}");
129    auto_method!(get_community_by_title_no_void(&str)@get_community_from_row -> "SELECT * FROM communities WHERE title = $1" --name="community" --returns=Community --cache-key-tmpl="atto.community:{}");
130
131    /// Get the top 12 most popular (most likes) communities.
132    pub async fn get_popular_communities(&self) -> Result<Vec<Community>> {
133        let conn = match self.0.connect().await {
134            Ok(c) => c,
135            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
136        };
137
138        let res = query_rows!(
139            &conn,
140            "SELECT * FROM communities WHERE NOT context LIKE '%\"is_nsfw\":true%' ORDER BY member_count DESC LIMIT 12",
141            params![],
142            |x| { Self::get_community_from_row(x) }
143        );
144
145        if res.is_err() {
146            return Err(Error::GeneralNotFound("communities".to_string()));
147        }
148
149        Ok(res.unwrap())
150    }
151
152    /// Get all communities, filtering their title.
153    /// Communities are sorted by popularity first, creation date second.
154    pub async fn get_communities_searched(
155        &self,
156        query: &str,
157        batch: usize,
158        page: usize,
159    ) -> Result<Vec<Community>> {
160        let conn = match self.0.connect().await {
161            Ok(c) => c,
162            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
163        };
164
165        let res = query_rows!(
166            &conn,
167            "SELECT * FROM communities WHERE title LIKE $1 ORDER BY member_count DESC, created DESC LIMIT $2 OFFSET $3",
168            params![
169                &format!("%{query}%"),
170                &(batch as i64),
171                &((page * batch) as i64)
172            ],
173            |x| { Self::get_community_from_row(x) }
174        );
175
176        if res.is_err() {
177            return Err(Error::GeneralNotFound("communities".to_string()));
178        }
179
180        Ok(res.unwrap())
181    }
182
183    /// Get all communities by their owner.
184    pub async fn get_communities_by_owner(&self, id: usize) -> Result<Vec<Community>> {
185        let conn = match self.0.connect().await {
186            Ok(c) => c,
187            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
188        };
189
190        let res = query_rows!(
191            &conn,
192            "SELECT * FROM communities WHERE owner = $1",
193            params![&(id as i64)],
194            |x| { Self::get_community_from_row(x) }
195        );
196
197        if res.is_err() {
198            return Err(Error::GeneralNotFound("communities".to_string()));
199        }
200
201        Ok(res.unwrap())
202    }
203
204    /// Create a new community in the database.
205    ///
206    /// # Arguments
207    /// * `data` - a mock [`Community`] to insert
208    pub async fn create_community(&self, data: Community) -> Result<String> {
209        // check values
210        if data.title.trim().len() < 2 {
211            return Err(Error::DataTooShort("title".to_string()));
212        } else if data.title.len() > 32 {
213            return Err(Error::DataTooLong("title".to_string()));
214        }
215
216        if self.0.0.banned_usernames.contains(&data.title) {
217            return Err(Error::MiscError("This title cannot be used".to_string()));
218        }
219
220        let regex = regex::RegexBuilder::new(NAME_REGEX)
221            .multi_line(true)
222            .build()
223            .unwrap();
224
225        if regex.captures(&data.title).is_some() {
226            return Err(Error::MiscError(
227                "This title contains invalid characters".to_string(),
228            ));
229        }
230
231        // check number of communities
232        let owner = self.get_user_by_id(data.owner).await?;
233
234        if !owner
235            .permissions
236            .check(FinePermission::INFINITE_COMMUNITIES)
237        {
238            let memberships = self.get_memberships_by_owner(data.owner).await?;
239            let mut admin_count = 0; // you can not make anymore communities if you are already admin of at least 5
240
241            for membership in memberships {
242                if membership.role.check(CommunityPermission::ADMINISTRATOR) {
243                    admin_count += 1;
244                }
245            }
246
247            let maximum_count = if owner.permissions.check(FinePermission::SUPPORTER) {
248                10
249            } else {
250                5
251            };
252
253            if admin_count >= maximum_count {
254                return Err(Error::MiscError(
255                    "You are already owner/co-owner of too many communities to create another"
256                        .to_string(),
257                ));
258            }
259        }
260
261        // check is_forge
262        // only supporters can CREATE forge communities... anybody can contribute to them
263        if data.is_forge
264            && !owner
265                .secondary_permissions
266                .check(SecondaryPermission::DEVELOPER_PASS)
267        {
268            return Err(Error::RequiresSupporter);
269        }
270
271        // make sure community doesn't already exist with title
272        if self
273            .get_community_by_title_no_void(&data.title.to_lowercase())
274            .await
275            .is_ok()
276        {
277            return Err(Error::MiscError("Title already in use".to_string()));
278        }
279
280        // ...
281        let conn = match self.0.connect().await {
282            Ok(c) => c,
283            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
284        };
285
286        let res = execute!(
287            &conn,
288            "INSERT INTO communities VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)",
289            params![
290                &(data.id as i64),
291                &(data.created as i64),
292                &data.title.to_lowercase(),
293                &serde_json::to_string(&data.context).unwrap().as_str(),
294                &(data.owner as i64),
295                &serde_json::to_string(&data.read_access).unwrap().as_str(),
296                &serde_json::to_string(&data.write_access).unwrap().as_str(),
297                &serde_json::to_string(&data.join_access).unwrap().as_str(),
298                &0_i32,
299                &0_i32,
300                &1_i32,
301                &{ if data.is_forge { 1 } else { 0 } },
302                &0_i32,
303                &{ if data.is_forum { 1 } else { 0 } },
304                &serde_json::to_string(&data.topics).unwrap().as_str(),
305            ]
306        );
307
308        if let Err(e) = res {
309            return Err(Error::DatabaseError(e.to_string()));
310        }
311
312        // add community owner as admin
313        self.create_membership(
314            CommunityMembership::new(data.owner, data.id, CommunityPermission::ADMINISTRATOR),
315            &owner,
316        )
317        .await
318        .unwrap();
319
320        // return
321        Ok(data.title)
322    }
323
324    pub async fn cache_clear_community(&self, community: &Community) {
325        self.0
326            .1
327            .remove(format!("atto.community:{}", community.id))
328            .await;
329        self.0
330            .1
331            .remove(format!("atto.community:{}", community.title))
332            .await;
333    }
334
335    pub async fn delete_community(&self, id: usize, user: &User) -> Result<()> {
336        let y = self.get_community_by_id(id).await?;
337
338        if user.id != y.owner {
339            if !user.permissions.check(FinePermission::MANAGE_COMMUNITIES) {
340                return Err(Error::NotAllowed);
341            } else {
342                self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new(
343                    user.id,
344                    format!("invoked `delete_community` with x value `{id}`"),
345                ))
346                .await?
347            }
348        }
349
350        let conn = match self.0.connect().await {
351            Ok(c) => c,
352            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
353        };
354
355        let res = execute!(
356            &conn,
357            "DELETE FROM communities WHERE id = $1",
358            &[&(id as i64)]
359        );
360
361        if let Err(e) = res {
362            return Err(Error::DatabaseError(e.to_string()));
363        }
364
365        self.cache_clear_community(&y).await;
366
367        // remove memberships
368        let res = execute!(
369            &conn,
370            "DELETE FROM memberships WHERE community = $1",
371            &[&(id as i64)]
372        );
373
374        if let Err(e) = res {
375            return Err(Error::DatabaseError(e.to_string()));
376        }
377
378        // remove channels
379        for channel in self.get_channels_by_community(id).await? {
380            self.delete_channel(channel.id, &user).await?;
381        }
382
383        // remove images
384        let avatar = PathBufD::current().extend(&[
385            self.0.0.dirs.media.as_str(),
386            "community_avatars",
387            &format!("{}.avif", &y.id),
388        ]);
389
390        let banner = PathBufD::current().extend(&[
391            self.0.0.dirs.media.as_str(),
392            "community_banners",
393            &format!("{}.avif", &y.id),
394        ]);
395
396        if exists(&avatar).unwrap() {
397            remove_file(avatar).unwrap();
398        }
399
400        if exists(&banner).unwrap() {
401            remove_file(banner).unwrap();
402        }
403
404        // ...
405        Ok(())
406    }
407
408    pub async fn update_community_title(&self, id: usize, user: User, title: &str) -> Result<()> {
409        // check values
410        if title.len() < 2 {
411            return Err(Error::DataTooShort("title".to_string()));
412        } else if title.len() > 32 {
413            return Err(Error::DataTooLong("title".to_string()));
414        }
415
416        if self.0.0.banned_usernames.contains(&title.to_string()) {
417            return Err(Error::MiscError("This title cannot be used".to_string()));
418        }
419
420        let regex = regex::RegexBuilder::new(NAME_REGEX)
421            .multi_line(true)
422            .build()
423            .unwrap();
424
425        if regex.captures(&title).is_some() {
426            return Err(Error::MiscError(
427                "This title contains invalid characters".to_string(),
428            ));
429        }
430
431        // ...
432        let y = self.get_community_by_id(id).await?;
433
434        if user.id != y.owner {
435            if !user.permissions.check(FinePermission::MANAGE_COMMUNITIES) {
436                return Err(Error::NotAllowed);
437            } else {
438                self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new(
439                    user.id,
440                    format!("invoked `update_community_title` with x value `{id}`"),
441                ))
442                .await?
443            }
444        }
445
446        // check for existing community
447        let title = &title.to_lowercase();
448        if self.get_community_by_title_no_void(title).await.is_ok() {
449            return Err(Error::TitleInUse);
450        }
451
452        // ...
453        let conn = match self.0.connect().await {
454            Ok(c) => c,
455            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
456        };
457
458        let res = execute!(
459            &conn,
460            "UPDATE communities SET title = $1 WHERE id = $2",
461            params![&title, &(id as i64)]
462        );
463
464        if let Err(e) = res {
465            return Err(Error::DatabaseError(e.to_string()));
466        }
467
468        self.cache_clear_community(&y).await;
469
470        Ok(())
471    }
472
473    pub async fn update_community_owner(
474        &self,
475        id: usize,
476        user: User,
477        new_owner: usize,
478    ) -> Result<()> {
479        let y = self.get_community_by_id(id).await?;
480
481        if user.id != y.owner {
482            if !user.permissions.check(FinePermission::MANAGE_COMMUNITIES) {
483                return Err(Error::NotAllowed);
484            } else {
485                self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new(
486                    user.id,
487                    format!("invoked `update_community_owner` with x value `{id}`"),
488                ))
489                .await?
490            }
491        }
492
493        let new_owner_membership = self
494            .get_membership_by_owner_community(new_owner, y.id)
495            .await?;
496        let current_owner_membership = self
497            .get_membership_by_owner_community(y.owner, y.id)
498            .await?;
499
500        // ...
501        let conn = match self.0.connect().await {
502            Ok(c) => c,
503            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
504        };
505
506        let res = execute!(
507            &conn,
508            "UPDATE communities SET owner = $1 WHERE id = $2",
509            params![&(new_owner as i64), &(id as i64)]
510        );
511
512        if let Err(e) = res {
513            return Err(Error::DatabaseError(e.to_string()));
514        }
515
516        self.cache_clear_community(&y).await;
517
518        // update memberships
519        self.update_membership_role(
520            new_owner_membership.id,
521            CommunityPermission::DEFAULT | CommunityPermission::ADMINISTRATOR,
522        )
523        .await?;
524
525        self.update_membership_role(
526            current_owner_membership.id,
527            CommunityPermission::DEFAULT | CommunityPermission::MEMBER,
528        )
529        .await?;
530
531        // return
532        Ok(())
533    }
534
535    pub async fn delete_topic_posts(&self, id: usize, topic: usize) -> Result<()> {
536        let conn = match self.0.connect().await {
537            Ok(c) => c,
538            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
539        };
540
541        let res = execute!(
542            &conn,
543            "DELETE FROM posts WHERE community = $1 AND topic = $2",
544            params![&(id as i64), &(topic as i64)]
545        );
546
547        if let Err(e) = res {
548            return Err(Error::DatabaseError(e.to_string()));
549        }
550
551        Ok(())
552    }
553
554    auto_method!(update_community_context(CommunityContext)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET context = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
555    auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
556    auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
557    auto_method!(update_community_join_access(CommunityJoinAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET join_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
558    auto_method!(update_community_topics(HashMap<usize, ForumTopic>)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET topics = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
559    auto_method!(update_community_is_forum(i32)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET is_forum = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_community);
560
561    auto_method!(incr_community_likes()@get_community_by_id_no_void -> "UPDATE communities SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);
562    auto_method!(incr_community_dislikes()@get_community_by_id_no_void -> "UPDATE communities SET dislikes = dislikes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);
563    auto_method!(decr_community_likes()@get_community_by_id_no_void -> "UPDATE communities SET likes = likes - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr=likes);
564    auto_method!(decr_community_dislikes()@get_community_by_id_no_void -> "UPDATE communities SET dislikes = dislikes - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr=dislikes);
565
566    auto_method!(incr_community_member_count()@get_community_by_id_no_void -> "UPDATE communities SET member_count = member_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);
567    auto_method!(decr_community_member_count()@get_community_by_id_no_void -> "UPDATE communities SET member_count = member_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr=member_count);
568
569    auto_method!(incr_community_post_count()@get_community_by_id_no_void -> "UPDATE communities SET post_count = post_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);
570    auto_method!(decr_community_post_count()@get_community_by_id_no_void -> "UPDATE communities SET post_count = post_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr=post_count);
571}