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 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 }
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 }
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 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 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 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 pub async fn create_community(&self, data: Community) -> Result<String> {
208 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 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; 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 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 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 self.create_membership(
302 CommunityMembership::new(data.owner, data.id, CommunityPermission::ADMINISTRATOR),
303 &owner,
304 )
305 .await
306 .unwrap();
307
308 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 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 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 Ok(())
389 }
390
391 pub async fn update_community_title(&self, id: usize, user: User, title: &str) -> Result<()> {
392 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 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 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 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 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 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 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}