From d0c1fbcf9a4eaf354721ba889af71841891a552d Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 1 Apr 2025 15:03:56 -0400 Subject: [PATCH] add: request-to-join communities add: private joined communities setting add: "void" community add: ability to delete communities --- Cargo.lock | 12 ++ crates/app/src/langs/en-US.toml | 6 +- crates/app/src/public/css/style.css | 1 + .../app/src/public/html/communities/base.html | 74 ++++++- .../src/public/html/communities/settings.html | 164 ++++++++++++--- crates/app/src/public/html/profile/base.html | 7 +- .../app/src/public/html/profile/settings.html | 13 +- .../routes/api/v1/communities/communities.rs | 59 +++++- crates/app/src/routes/api/v1/mod.rs | 14 +- crates/app/src/routes/pages/communities.rs | 115 +++++++--- crates/core/Cargo.toml | 1 + crates/core/src/config.rs | 3 +- crates/core/src/database/communities.rs | 199 ++++++++++++++++-- .../drivers/sql/create_communities.sql | 1 + crates/core/src/database/memberships.rs | 62 +++++- crates/core/src/database/notifications.rs | 2 +- crates/core/src/database/reactions.rs | 2 +- crates/core/src/model/auth.rs | 3 + crates/core/src/model/communities.rs | 52 ++++- .../core/src/model/communities_permissions.rs | 1 + 20 files changed, 669 insertions(+), 122 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2fb0319..694e857 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,6 +145,17 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.88" @@ -3066,6 +3077,7 @@ dependencies = [ name = "tetratto-core" version = "0.1.0" dependencies = [ + "async-recursion", "bb8-postgres", "bitflags 2.9.0", "pathbufd", diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 3c9556c..92f456e 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -16,7 +16,6 @@ version = "1.0.0" "dialog:action.no" = "No" "dialog:action.save_and_close" = "Save and close" - "auth:action.login" = "Login" "auth:action.register" = "Register" "auth:action.logout" = "Logout" @@ -37,17 +36,22 @@ version = "1.0.0" "communities:label.create_new" = "Create new community" "communities:label.name" = "Name" "communities:action.join" = "Join" +"communities:action.cancel_request" = "Cancel request" "communities:action.leave" = "Leave" "communities:action.configure" = "Configure" "communities:label.create_post" = "Create post" "communities:label.content" = "Content" "communities:label.posts" = "Posts" +"communities:label.not_allowed_to_read" = "You're not allowed to view this community's posts" +"communities:label.might_need_to_join" = "You might need to join this community in order to interact with it!" "communities:label.create_reply" = "Create reply" "communities:label.replies" = "Replies" "communities:action.continue_thread" = "Continue thread" "communities:tab.members" = "Members" "communities:label.select_member" = "Select member" "communities:label.user_id" = "User ID" +"communities:label.danger_zone" = "Danger zone" +"communities:label.delete_community" = "Delete community" "notifs:action.mark_as_read" = "Mark as read" "notifs:action.mark_as_unread" = "Mark as unread" diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 27f936f..3c8f2de 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -880,6 +880,7 @@ dialog::backdrop { .toast { box-shadow: 0 0 8px var(--color-shadow); width: max-content; + max-width: calc(100dvw - 1rem); border-radius: var(--radius); padding: 0.75rem 1rem; animation: popin ease-in-out 1 0.15s running; diff --git a/crates/app/src/public/html/communities/base.html b/crates/app/src/public/html/communities/base.html index 87d89f2..944c35f 100644 --- a/crates/app/src/public/html/communities/base.html +++ b/crates/app/src/public/html/communities/base.html @@ -20,7 +20,7 @@ {% if community.context.display_name %} {{ community.context.display_name }} {% else %} - {{ community.username }} + {{ community.title }} {% endif %} @@ -30,7 +30,8 @@ {% if user %}
- {% if not is_owner %} {% if not is_joined %} + {% if not is_owner %} {% if not is_joined %} {% if not + is_pending %} + + + {% endif %} {% else %}
-
{% block content %}{% endblock %}
+
+ {% if can_read %} {% block content %}{% endblock %} {% else %} +
+
+ {{ icon "frown" }} + {{ text "communities:label.not_allowed_to_read" + }} +
+ +
+ + {{ text "communities:label.might_need_to_join" }} + +
+
+ {% endif %} +
diff --git a/crates/app/src/public/html/communities/settings.html b/crates/app/src/public/html/communities/settings.html index fb15aeb..fa2b6d6 100644 --- a/crates/app/src/public/html/communities/settings.html +++ b/crates/app/src/public/html/communities/settings.html @@ -32,16 +32,39 @@ Everybody + + + + +
+
+ Join access +
+ +
+
@@ -77,6 +100,20 @@
+
+
+ {{ icon "skull" }} + {{ text "communities:label.danger_zone" }} +
+ +
+ +
+
+
` : ``} + ${res.payload.role !== 65 ? `` : ``} +
`; ui.refresh_container(element, ["actions"]); @@ -278,34 +373,11 @@ ], null, { - role: async (new_role) => { - if ( - !(await trigger("atto::confirm", [ - "Are you sure you would like to do this?", - ])) - ) { - return; - } - - fetch( - `/api/v1/communities/{{ community.id }}/memberships/${e.target.uid.value}/role`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - role: Number.parseInt(new_role), - }), - }, - ) - .then((res) => res.json()) - .then((res) => { - trigger("atto::toast", [ - res.ok ? "success" : "error", - res.message, - ]); - }); + role: (new_role) => { + return update_user_role( + e.target.uid.value, + user_role, + ); }, }, ); @@ -400,8 +472,30 @@ }); }; + globalThis.delete_community = async () => { + if ( + !(await trigger("atto::confirm", [ + "Are you sure you would like to do this? This action is permanent.", + ])) + ) { + return; + } + + fetch(`/api/v1/communities/{{ community.id }}`, { + method: "DELETE", + }) + .then((res) => res.json()) + .then((res) => { + trigger("atto::toast", [ + res.ok ? "success" : "error", + res.message, + ]); + }); + }; + ui.refresh_container(document.getElementById("manage_fields"), [ "read_access", + "join_access", "write_access", "change_avatar", "change_banner", diff --git a/crates/app/src/public/html/profile/base.html b/crates/app/src/public/html/profile/base.html index 8d65b06..cf66e8e 100644 --- a/crates/app/src/public/html/profile/base.html +++ b/crates/app/src/public/html/profile/base.html @@ -75,7 +75,7 @@ - {% if not is_self %} + {% if not is_self and user %}
{{ text "auth:label.relationship" }} @@ -157,8 +157,8 @@
- {% endif %} - + {% endif %} {% if not profile.settings.private_communities or + is_self %}
{{ icon "users-round" }} @@ -174,6 +174,7 @@ {% endfor %}
+ {% endif %}
{% block content %}{% endblock %}
diff --git a/crates/app/src/public/html/profile/settings.html b/crates/app/src/public/html/profile/settings.html index c75b0cb..b7a0766 100644 --- a/crates/app/src/public/html/profile/settings.html +++ b/crates/app/src/public/html/profile/settings.html @@ -378,10 +378,21 @@ profile_settings, [ [ - ["private_profile", "Private profile"], + [ + "private_profile", + "Only allow users I'm following to view my profile", + ], "{{ user.settings.private_profile }}", "checkbox", ], + [ + [ + "private_communities", + "Keep my joined communities private", + ], + "{{ user.settings.private_communities }}", + "checkbox", + ], ], settings, ); diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs index 9c4946e..a987278 100644 --- a/crates/app/src/routes/api/v1/communities/communities.rs +++ b/crates/app/src/routes/api/v1/communities/communities.rs @@ -14,8 +14,9 @@ use tetratto_core::model::{ use crate::{ State, get_user_from_token, routes::api::v1::{ - CreateCommunity, UpdateCommunityContext, UpdateCommunityReadAccess, UpdateCommunityTitle, - UpdateCommunityWriteAccess, UpdateMembershipRole, + CreateCommunity, UpdateCommunityContext, UpdateCommunityJoinAccess, + UpdateCommunityReadAccess, UpdateCommunityTitle, UpdateCommunityWriteAccess, + UpdateMembershipRole, }, }; @@ -175,6 +176,31 @@ pub async fn update_write_access_request( } } +pub async fn update_join_access_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .update_community_join_access(id, user, req.access) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Community updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + pub async fn get_membership( jar: CookieJar, Extension(data): Extension, @@ -225,9 +251,9 @@ pub async fn create_membership( )) .await { - Ok(_) => Json(ApiReturn { + Ok(m) => Json(ApiReturn { ok: true, - message: "Community joined".to_string(), + message: m, payload: (), }), Err(e) => Json(e.into()), @@ -329,6 +355,31 @@ pub async fn update_membership_role( return Json(e.into()); }; + if let Err(e) = data.incr_community_member_count(community.id).await { + return Json(e.into()); + } + } else if req.role.check(CommunityPermission::REQUESTED) { + // user was demoted to a request again + if let Err(e) = data.decr_community_member_count(community.id).await { + return Json(e.into()); + } + } else if membership.role.check(CommunityPermission::REQUESTED) { + // user was accepted to community + if let Err(e) = data + .create_notification(Notification::new( + "You have been accepted into a community you requested to join!" + .to_string(), + format!( + "You have been accepted into [{}](/community/{}).", + community.title, community.title + ), + membership.owner, + )) + .await + { + return Json(e.into()); + }; + if let Err(e) = data.incr_community_member_count(community.id).await { return Json(e.into()); } diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index c5da621..3f926fb 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -10,7 +10,10 @@ use axum::{ }; use serde::Deserialize; use tetratto_core::model::{ - communities::{CommunityContext, CommunityReadAccess, CommunityWriteAccess, PostContext}, + communities::{ + CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess, + PostContext, + }, communities_permissions::CommunityPermission, reactions::AssetType, }; @@ -53,6 +56,10 @@ pub fn routes() -> Router { "/communities/{id}/access/write", post(communities::communities::update_write_access_request), ) + .route( + "/communities/{id}/access/join", + post(communities::communities::update_join_access_request), + ) .route( "/communities/{id}/upload/avatar", post(communities::images::upload_avatar_request), @@ -194,6 +201,11 @@ pub struct UpdateCommunityWriteAccess { pub access: CommunityWriteAccess, } +#[derive(Deserialize)] +pub struct UpdateCommunityJoinAccess { + pub access: CommunityJoinAccess, +} + #[derive(Deserialize)] pub struct CreatePost { pub content: String, diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 82e972d..11c025a 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -11,26 +11,12 @@ use tetratto_core::model::{ Error, auth::User, communities::{Community, CommunityReadAccess}, + communities_permissions::CommunityPermission, }; macro_rules! check_permissions { - ($community:ident, $jar:ident, $data:ident, $user:ident) => { - match $community.read_access { - CommunityReadAccess::Private => { - if let Some(ref ua) = $user { - if ua.id != $community.owner { - return Err(Html( - render_error(Error::NotAllowed, &$jar, &$data, &$user).await, - )); - } - } else { - return Err(Html( - render_error(Error::NotAllowed, &$jar, &$data, &$user).await, - )); - } - } - _ => (), - }; + ($community:ident, $jar:ident, $data:ident, $user:ident) => {{ + let mut is_member: bool = false; if let Some(ref ua) = $user { if let Ok(membership) = $data @@ -42,30 +28,54 @@ macro_rules! check_permissions { return Err(Html( render_error(Error::NotAllowed, &$jar, &$data, &$user).await, )); + } else if membership.role.check_member() { + is_member = true; } } } - }; + + match $community.read_access { + CommunityReadAccess::Joined => { + if !is_member { + false + } else { + true + } + } + _ => true, + } + }}; } macro_rules! community_context_bools { ($data:ident, $user:ident, $community:ident) => {{ + let membership = if let Some(ref ua) = $user { + match $data + .0 + .get_membership_by_owner_community(ua.id, $community.id) + .await + { + Ok(m) => Some(m), + Err(_) => None, + } + } else { + None + }; + let is_owner = if let Some(ref ua) = $user { ua.id == $community.owner } else { false }; - let is_joined = if let Some(ref ua) = $user { - if let Ok(membership) = $data - .0 - .get_membership_by_owner_community(ua.id, $community.id) - .await - { - membership.role.check_member() - } else { - false - } + let is_joined = if let Some(ref membership) = membership { + membership.role.check_member() + } else { + false + }; + + let is_pending = if let Some(ref membership) = membership { + membership.role.check(CommunityPermission::REQUESTED) } else { false }; @@ -76,7 +86,7 @@ macro_rules! community_context_bools { false }; - (is_owner, is_joined, can_post) + (is_owner, is_joined, is_pending, can_post) }}; } @@ -120,12 +130,16 @@ pub fn community_context( community: &Community, is_owner: bool, is_joined: bool, + is_pending: bool, can_post: bool, + can_read: bool, ) { context.insert("community", &community); context.insert("is_owner", &is_owner); context.insert("is_joined", &is_joined); + context.insert("is_pending", &is_pending); context.insert("can_post", &can_post); + context.insert("can_read", &can_read); } /// `/community/{title}` @@ -143,8 +157,21 @@ pub async fn feed_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), }; + if community.id == 0 { + // don't show page for void community + return Err(Html( + render_error( + Error::GeneralNotFound("community".to_string()), + &jar, + &data, + &user, + ) + .await, + )); + } + // check permissions - check_permissions!(community, jar, data, user); + let can_read = check_permissions!(community, jar, data, user); // ... let feed = match data @@ -163,10 +190,19 @@ pub async fn feed_request( let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0, lang, &user).await; - let (is_owner, is_joined, can_post) = community_context_bools!(data, user, community); + let (is_owner, is_joined, is_pending, can_post) = + community_context_bools!(data, user, community); context.insert("feed", &feed); - community_context(&mut context, &community, is_owner, is_joined, can_post); + community_context( + &mut context, + &community, + is_owner, + is_joined, + is_pending, + can_post, + can_read, + ); // return Ok(Html( @@ -242,7 +278,7 @@ pub async fn post_request( }; // check permissions - check_permissions!(community, jar, data, user); + let can_read = check_permissions!(community, jar, data, user); // ... let feed = match data.0.get_post_comments(post.id, 12, props.page).await { @@ -257,7 +293,8 @@ pub async fn post_request( let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0, lang, &user).await; - let (is_owner, is_joined, can_post) = community_context_bools!(data, user, community); + let (is_owner, is_joined, is_pending, can_post) = + community_context_bools!(data, user, community); context.insert("post", &post); context.insert("replies", &feed); @@ -269,7 +306,15 @@ pub async fn post_request( .await .unwrap_or(User::deleted()), ); - community_context(&mut context, &community, is_owner, is_joined, can_post); + community_context( + &mut context, + &community, + is_owner, + is_joined, + is_pending, + can_post, + can_read, + ); // return Ok(Html( diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 109ab22..99f4219 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -24,3 +24,4 @@ rusqlite = { version = "0.34.0", optional = true } tokio-postgres = { version = "0.7.13", optional = true } bb8-postgres = { version = "0.9.0", optional = true } bitflags = "2.9.0" +async-recursion = "1.1.1" diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index f49d6f2..36d897e 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -145,7 +145,7 @@ pub struct Config { /// version built with the server binary. #[serde(default = "default_no_track")] pub no_track: Vec, - /// A list of usernames which cannot be used. + /// A list of usernames which cannot be used. This also includes community names. #[serde(default = "default_banned_usernames")] pub banned_usernames: Vec, } @@ -195,6 +195,7 @@ fn default_banned_usernames() -> Vec { "notifs".to_string(), "notification".to_string(), "post".to_string(), + "void".to_string(), ] } diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index a5846bc..fa43c48 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -1,6 +1,6 @@ use super::*; use crate::cache::Cache; -use crate::model::communities::{CommunityContext, CommunityMembership}; +use crate::model::communities::{CommunityContext, CommunityJoinAccess, CommunityMembership}; use crate::model::communities_permissions::CommunityPermission; use crate::model::{ Error, Result, @@ -10,6 +10,8 @@ use crate::model::{ permissions::FinePermission, }; use crate::{auto_method, execute, get, query_row}; +use pathbufd::PathBufD; +use std::fs::{exists, remove_file}; #[cfg(feature = "sqlite")] use rusqlite::Row; @@ -31,16 +33,91 @@ impl DataManager { owner: get!(x->4(isize)) as usize, read_access: serde_json::from_str(&get!(x->5(String))).unwrap(), write_access: serde_json::from_str(&get!(x->6(String))).unwrap(), + join_access: serde_json::from_str(&get!(x->7(String))).unwrap(), // likes - likes: get!(x->7(isize)) as isize, - dislikes: get!(x->8(isize)) as isize, + likes: get!(x->8(isize)) as isize, + dislikes: get!(x->9(isize)) as isize, // counts - member_count: get!(x->9(isize)) as usize, + member_count: get!(x->10(isize)) as usize, } } - auto_method!(get_community_by_id()@get_community_from_row -> "SELECT * FROM communities WHERE id = $1" --name="community" --returns=Community --cache-key-tmpl="atto.community:{}"); - auto_method!(get_community_by_title(&str)@get_community_from_row -> "SELECT * FROM communities WHERE title = $1" --name="community" --returns=Community --cache-key-tmpl="atto.community:{}"); + pub async fn get_community_by_id(&self, id: usize) -> Result { + if id == 0 { + return Ok(Community::void()); + } + + if let Some(cached) = self.2.get(format!("atto.community:{}", id)).await { + return Ok(serde_json::from_str(&cached).unwrap()); + } + + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT * FROM communities WHERE id = $1", + &[&(id as isize)], + |x| { Ok(Self::get_community_from_row(x)) } + ); + + if res.is_err() { + return Ok(Community::void()); + // return Err(Error::GeneralNotFound("community".to_string())); + } + + let x = res.unwrap(); + self.2 + .set( + format!("atto.community:{}", id), + serde_json::to_string(&x).unwrap(), + ) + .await; + + Ok(x) + } + + pub async fn get_community_by_title(&self, id: &str) -> Result { + if id == "void" { + return Ok(Community::void()); + } + + if let Some(cached) = self.2.get(format!("atto.community:{}", id)).await { + return Ok(serde_json::from_str(&cached).unwrap()); + } + + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT * FROM communities WHERE title = $1", + &[id], + |x| { Ok(Self::get_community_from_row(x)) } + ); + + if res.is_err() { + return Ok(Community::void()); + // return Err(Error::GeneralNotFound("community".to_string())); + } + + let x = res.unwrap(); + self.2 + .set( + format!("atto.community:{}", id), + serde_json::to_string(&x).unwrap(), + ) + .await; + + Ok(x) + } + + 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:{}"); + 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:{}"); /// Create a new community in the database. /// @@ -60,9 +137,30 @@ impl DataManager { )); } + if self.0.banned_usernames.contains(&data.title) { + return Err(Error::MiscError("This title cannot be used".to_string())); + } + + // check number of communities + let memberships = self.get_memberships_by_owner(data.owner).await?; + let mut admin_count = 0; // you can not make anymore communities if you are already admin of at least 5 + + for membership in memberships { + if membership.role.check(CommunityPermission::ADMINISTRATOR) { + admin_count += 1; + } + } + + if admin_count >= 5 { + return Err(Error::MiscError( + "You are already owner/co-owner of too many communities to create another" + .to_string(), + )); + } + // make sure community doesn't already exist with title if self - .get_community_by_title(&data.title.to_lowercase()) + .get_community_by_title_no_void(&data.title.to_lowercase()) .await .is_ok() { @@ -77,7 +175,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO communities VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + "INSERT INTO communities VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", &[ &data.id.to_string().as_str(), &data.created.to_string().as_str(), @@ -86,6 +184,7 @@ impl DataManager { &data.owner.to_string().as_str(), &serde_json::to_string(&data.read_access).unwrap().as_str(), &serde_json::to_string(&data.write_access).unwrap().as_str(), + &serde_json::to_string(&data.join_access).unwrap().as_str(), &0.to_string().as_str(), &0.to_string().as_str(), &0.to_string().as_str() @@ -118,17 +217,79 @@ impl DataManager { .await; } - auto_method!(delete_community()@get_community_by_id:MANAGE_COMMUNITIES -> "DELETE communities pages WHERE id = $1" --cache-key-tmpl=cache_clear_community); - auto_method!(update_community_title(String)@get_community_by_id:MANAGE_COMMUNITIES -> "UPDATE communities SET title = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_community); - auto_method!(update_community_context(CommunityContext)@get_community_by_id:MANAGE_COMMUNITIES -> "UPDATE communities SET context = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); - auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id:MANAGE_COMMUNITIES -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); - auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id:MANAGE_COMMUNITIES -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + pub async fn delete_community(&self, id: usize, user: User) -> Result<()> { + let y = self.get_community_by_id(id).await?; - auto_method!(incr_community_likes()@get_community_by_id -> "UPDATE communities SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr); - auto_method!(incr_community_dislikes()@get_community_by_id -> "UPDATE communities SET dislikes = dislikes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr); - auto_method!(decr_community_likes()@get_community_by_id -> "UPDATE communities SET likes = likes - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr); - auto_method!(decr_community_dislikes()@get_community_by_id -> "UPDATE communities SET dislikes = dislikes - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr); + if user.id != y.owner { + if !user.permissions.check(FinePermission::MANAGE_COMMUNITIES) { + return Err(Error::NotAllowed); + } + } - auto_method!(incr_community_member_count()@get_community_by_id -> "UPDATE communities SET member_count = member_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr); - auto_method!(decr_community_member_count()@get_community_by_id -> "UPDATE communities SET member_count = member_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr); + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "DELETE FROM communities WHERE id = $1", + &[&id.to_string()] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.cache_clear_community(&y).await; + + // remove memberships + let res = execute!( + &conn, + "DELETE FROM memberships WHERE community = $1", + &[&id.to_string()] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // remove images + let avatar = PathBufD::current().extend(&[ + self.0.dirs.media.as_str(), + "community_avatars", + &format!("{}.avif", &y.id), + ]); + + let banner = PathBufD::current().extend(&[ + self.0.dirs.media.as_str(), + "community_banners", + &format!("{}.avif", &y.id), + ]); + + if exists(&avatar).unwrap() { + remove_file(avatar).unwrap(); + } + + if exists(&banner).unwrap() { + remove_file(banner).unwrap(); + } + + // ... + Ok(()) + } + + auto_method!(update_community_title(String)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET title = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_context(CommunityContext)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET context = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_join_access(CommunityJoinAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET join_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + + 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); + 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); + 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); + 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); + + 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); + 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); } diff --git a/crates/core/src/database/drivers/sql/create_communities.sql b/crates/core/src/database/drivers/sql/create_communities.sql index b98635b..9e004a7 100644 --- a/crates/core/src/database/drivers/sql/create_communities.sql +++ b/crates/core/src/database/drivers/sql/create_communities.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS communities ( owner INTEGER NOT NULL, read_access TEXT NOT NULL, write_access TEXT NOT NULL, + join_access TEXT NOT NULL, -- likes likes INTEGER NOT NULL, dislikes INTEGER NOT NULL, diff --git a/crates/core/src/database/memberships.rs b/crates/core/src/database/memberships.rs index b1dae2f..1b73783 100644 --- a/crates/core/src/database/memberships.rs +++ b/crates/core/src/database/memberships.rs @@ -1,9 +1,13 @@ use super::*; use crate::cache::Cache; +use crate::model::auth::Notification; use crate::model::communities::Community; use crate::model::{ - Error, Result, auth::User, communities::CommunityMembership, - communities_permissions::CommunityPermission, permissions::FinePermission, + Error, Result, + auth::User, + communities::{CommunityJoinAccess, CommunityMembership}, + communities_permissions::CommunityPermission, + permissions::FinePermission, }; use crate::{auto_method, execute, get, query_row, query_rows}; @@ -73,7 +77,8 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM memberships WHERE owner = $1 AND role IS NOT 33", + // 33 = banned, 65 = pending membership + "SELECT * FROM memberships WHERE owner = $1 AND role IS NOT 33 AND role IS NOT 65 ORDER BY created DESC", &[&(owner as isize)], |x| { Self::get_membership_from_row(x) } ); @@ -89,7 +94,8 @@ impl DataManager { /// /// # Arguments /// * `data` - a mock [`CommunityMembership`] object to insert - pub async fn create_membership(&self, data: CommunityMembership) -> Result<()> { + #[async_recursion::async_recursion] + pub async fn create_membership(&self, data: CommunityMembership) -> Result { // make sure membership doesn't already exist if self .get_membership_by_owner_community(data.owner, data.community) @@ -99,6 +105,34 @@ impl DataManager { return Err(Error::MiscError("Already joined community".to_string())); } + // check permission + let community = self.get_community_by_id(data.community).await?; + + match community.join_access { + CommunityJoinAccess::Nobody => return Err(Error::NotAllowed), + CommunityJoinAccess::Request => { + if !data.role.check(CommunityPermission::REQUESTED) { + let mut data = data.clone(); + data.role = CommunityPermission::DEFAULT | CommunityPermission::REQUESTED; + + // send notification to the owner + self.create_notification(Notification::new( + "You've received a community join request!".to_string(), + format!( + "[Somebody](/api/v1/auth/profile/find/{}) is asking to join your [community](/community/{}).\n\n[Click here to review their request](/community/{}/manage?uid={}#/members).", + data.owner, data.community, data.community, data.owner + ), + community.owner, + )) + .await?; + + // ... + return self.create_membership(data).await; + } + } + _ => (), + } + // ... let conn = match self.connect().await { Ok(c) => c, @@ -121,11 +155,18 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } - self.incr_community_member_count(data.community) - .await - .unwrap(); + if !data.role.check(CommunityPermission::REQUESTED) { + // users who are just a requesting to join do not count towards the member count + self.incr_community_member_count(data.community) + .await + .unwrap(); + } - Ok(()) + Ok(if data.role.check(CommunityPermission::REQUESTED) { + "Join request sent".to_string() + } else { + "Community joined".to_string() + }) } /// Delete a membership given its `id` @@ -134,7 +175,10 @@ impl DataManager { if user.id != y.owner { // pull other user's membership status - if let Ok(z) = self.get_membership_by_id(user.id).await { + if let Ok(z) = self + .get_membership_by_owner_community(user.id, y.community) + .await + { // somebody with MANAGE_ROLES _and_ a higher role number can remove us if (!z.role.check(CommunityPermission::MANAGE_ROLES) | (z.role < y.role)) && !z.role.check(CommunityPermission::ADMINISTRATOR) diff --git a/crates/core/src/database/notifications.rs b/crates/core/src/database/notifications.rs index b63e979..a870081 100644 --- a/crates/core/src/database/notifications.rs +++ b/crates/core/src/database/notifications.rs @@ -36,7 +36,7 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM notifications WHERE owner = $1", + "SELECT * FROM notifications WHERE owner = $1 ORDER BY created DESC", &[&(owner as isize)], |x| { Self::get_notification_from_row(x) } ); diff --git a/crates/core/src/database/reactions.rs b/crates/core/src/database/reactions.rs index eab318f..13c3f19 100644 --- a/crates/core/src/database/reactions.rs +++ b/crates/core/src/database/reactions.rs @@ -96,7 +96,7 @@ impl DataManager { } { return Err(e); } else if data.is_like { - let community = self.get_community_by_id(data.asset).await.unwrap(); + let community = self.get_community_by_id_no_void(data.asset).await.unwrap(); if community.owner != user.id { if let Err(e) = self diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index f7ed81e..3950df7 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -33,6 +33,8 @@ pub struct UserSettings { pub biography: String, #[serde(default)] pub private_profile: bool, + #[serde(default)] + pub private_communities: bool, } impl Default for UserSettings { @@ -41,6 +43,7 @@ impl Default for UserSettings { display_name: String::new(), biography: String::new(), private_profile: false, + private_communities: false, } } } diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 5147a05..635675b 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -11,13 +11,15 @@ pub struct Community { pub context: CommunityContext, /// The ID of the owner of the community. pub owner: usize, - /// Who can read the community page. + /// Who can read the community. pub read_access: CommunityReadAccess, - /// Who can write to the community page (create posts belonging to it). + /// Who can write to the community (create posts belonging to it). /// - /// The owner of the community page (and moderators) are the ***only*** people + /// The owner of the community (and moderators) are the ***only*** people /// capable of removing posts. pub write_access: CommunityWriteAccess, + /// Who can join the community. + pub join_access: CommunityJoinAccess, // likes pub likes: isize, pub dislikes: isize, @@ -42,6 +44,25 @@ impl Community { owner, read_access: CommunityReadAccess::default(), write_access: CommunityWriteAccess::default(), + join_access: CommunityJoinAccess::default(), + likes: 0, + dislikes: 0, + member_count: 0, + } + } + + /// Create the "void" community. This is where all posts with a deleted community + /// resolve to. + pub fn void() -> Self { + Self { + id: 0, + created: 0, + title: "void".to_string(), + context: CommunityContext::default(), + owner: 0, + read_access: CommunityReadAccess::Joined, + write_access: CommunityWriteAccess::Owner, + join_access: CommunityJoinAccess::Nobody, likes: 0, dislikes: 0, member_count: 0, @@ -69,10 +90,8 @@ impl Default for CommunityContext { pub enum CommunityReadAccess { /// Everybody can view the community. Everybody, - /// Only people with the link to the community. - Unlisted, - /// Only the owner of the community. - Private, + /// Only people in the community can view the community. + Joined, } impl Default for CommunityReadAccess { @@ -100,7 +119,24 @@ impl Default for CommunityWriteAccess { } } -#[derive(Serialize, Deserialize)] +/// Who can join a [`Community`]. +#[derive(Serialize, Deserialize, PartialEq, Eq)] +pub enum CommunityJoinAccess { + /// Joins are closed. Nobody can join the community. + Nobody, + /// All authenticated users can join the community. + Everybody, + /// People must send a request to join. + Request, +} + +impl Default for CommunityJoinAccess { + fn default() -> Self { + Self::Everybody + } +} + +#[derive(Clone, Serialize, Deserialize)] pub struct CommunityMembership { pub id: usize, pub created: usize, diff --git a/crates/core/src/model/communities_permissions.rs b/crates/core/src/model/communities_permissions.rs index f5fa70a..0fdc4e3 100644 --- a/crates/core/src/model/communities_permissions.rs +++ b/crates/core/src/model/communities_permissions.rs @@ -14,6 +14,7 @@ bitflags! { const MANAGE_POSTS = 1 << 3; const MANAGE_ROLES = 1 << 4; const BANNED = 1 << 5; + const REQUESTED = 1 << 6; const _ = !0; }