Skip to main content

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