From 0c814e95d7fcda28601c308e5dac8104f9badafc Mon Sep 17 00:00:00 2001
From: trisua <tri@swmff.org>
Date: Thu, 24 Apr 2025 16:57:25 -0400
Subject: [PATCH] add: ability to transfer community ownership

---
 .../app/src/public/html/communities/list.html |  5 ++
 .../src/public/html/communities/settings.html | 28 +++++++++
 .../routes/api/v1/communities/communities.rs  | 37 ++++++++++-
 crates/app/src/routes/api/v1/mod.rs           |  9 +++
 crates/core/src/database/communities.rs       | 62 +++++++++++++++++++
 5 files changed, 139 insertions(+), 2 deletions(-)

diff --git a/crates/app/src/public/html/communities/list.html b/crates/app/src/public/html/communities/list.html
index e5cb525..ee15f87 100644
--- a/crates/app/src/public/html/communities/list.html
+++ b/crates/app/src/public/html/communities/list.html
@@ -68,6 +68,11 @@
     async function create_community_from_form(e) {
         e.preventDefault();
         await trigger("atto::debounce", ["communities::create"]);
+
+        if (e.target.title.value.includes(" ")) {
+            return alert("Cannot contain spaces!");
+        }
+
         fetch("/api/v1/communities", {
             method: "POST",
             headers: {
diff --git a/crates/app/src/public/html/communities/settings.html b/crates/app/src/public/html/communities/settings.html
index 7a4a537..4fc28bd 100644
--- a/crates/app/src/public/html/communities/settings.html
+++ b/crates/app/src/public/html/communities/settings.html
@@ -317,6 +317,33 @@
                 });
         };
 
+        globalThis.transfer_ownership = async (uid) => {
+            if (
+                !(await trigger("atto::confirm", [
+                    "Are you sure you would like to do this?\n\nThis action is PERMANENT!",
+                ]))
+            ) {
+                return;
+            }
+
+            fetch(`/api/v1/communities/{{ community.id }}/transfer_ownership`, {
+                method: "POST",
+                headers: {
+                    "Content-Type": "application/json",
+                },
+                body: JSON.stringify({
+                    user: uid,
+                }),
+            })
+                .then((res) => res.json())
+                .then((res) => {
+                    trigger("atto::toast", [
+                        res.ok ? "success" : "error",
+                        res.message,
+                    ]);
+                });
+        };
+
         globalThis.select_user_from_form = (e) => {
             e.preventDefault();
             fetch(
@@ -359,6 +386,7 @@
                         ${res.payload.role !== 33 ? `<button class="red quaternary" onclick="update_user_role('${e.target.uid.value}', 33)">Ban</button>` : `<button class="quaternary" onclick="update_user_role('${e.target.uid.value}', 5)">Unban</button>`}
                         ${res.payload.role !== 65 ? `<button class="red quaternary" onclick="update_user_role('${e.target.uid.value}', 65)">Send to review</button>` : `<button class="green quaternary" onclick="update_user_role('${e.target.uid.value}', 5)">Accept join request</button>`}
                         <button class="red quaternary" onclick="kick_user('${e.target.uid.value}')">Kick</button>
+                        <button class="red quaternary" onclick="transfer_ownership('${e.target.uid.value}')">Transfer ownership</button>
                     </div>
 
                     <div class="flex flex-col gap-2" ui_ident="permissions" id="permissions">
diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs
index ba4eb89..10bf691 100644
--- a/crates/app/src/routes/api/v1/communities/communities.rs
+++ b/crates/app/src/routes/api/v1/communities/communities.rs
@@ -12,12 +12,13 @@ use tetratto_core::model::{
 };
 
 use crate::{
-    State, get_user_from_token,
+    get_user_from_token,
     routes::api::v1::{
-        CreateCommunity, UpdateCommunityContext, UpdateCommunityJoinAccess,
+        CreateCommunity, UpdateCommunityContext, UpdateCommunityJoinAccess, UpdateCommunityOwner,
         UpdateCommunityReadAccess, UpdateCommunityTitle, UpdateCommunityWriteAccess,
         UpdateMembershipRole,
     },
+    State,
 };
 
 pub async fn redirect_from_id(
@@ -201,6 +202,38 @@ pub async fn update_join_access_request(
     }
 }
 
+pub async fn update_owner_request(
+    jar: CookieJar,
+    Extension(data): Extension<State>,
+    Path(id): Path<usize>,
+    Json(req): Json<UpdateCommunityOwner>,
+) -> 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_owner(
+            id,
+            user,
+            match req.user.parse::<usize>() {
+                Ok(x) => x,
+                Err(e) => return Json(Error::MiscError(e.to_string()).into()),
+            },
+        )
+        .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<State>,
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index 84e49e2..c0135f4 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -64,6 +64,10 @@ pub fn routes() -> Router {
             "/communities/{id}/access/join",
             post(communities::communities::update_join_access_request),
         )
+        .route(
+            "/communities/{id}/transfer_ownership",
+            post(communities::communities::update_owner_request),
+        )
         .route(
             "/communities/{id}/upload/avatar",
             post(communities::images::upload_avatar_request),
@@ -342,6 +346,11 @@ pub struct UpdateMembershipRole {
     pub role: CommunityPermission,
 }
 
+#[derive(Deserialize)]
+pub struct UpdateCommunityOwner {
+    pub user: String,
+}
+
 #[derive(Deserialize)]
 pub struct UpdateUserRole {
     pub role: FinePermission,
diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs
index 33accc3..2f0e6ff 100644
--- a/crates/core/src/database/communities.rs
+++ b/crates/core/src/database/communities.rs
@@ -397,6 +397,68 @@ impl DataManager {
         Ok(())
     }
 
+    pub async fn update_community_owner(
+        &self,
+        id: usize,
+        user: User,
+        new_owner: usize,
+    ) -> Result<()> {
+        let y = self.get_community_by_id(id).await?;
+
+        if user.id != y.owner {
+            if !user.permissions.check(FinePermission::MANAGE_COMMUNITIES) {
+                return Err(Error::NotAllowed);
+            } else {
+                self.create_audit_log_entry(crate::model::moderation::AuditLogEntry::new(
+                    user.id,
+                    format!("invoked `update_community_owner` with x value `{id}`"),
+                ))
+                .await?
+            }
+        }
+
+        let new_owner_membership = self
+            .get_membership_by_owner_community(new_owner, y.id)
+            .await?;
+        let current_owner_membership = self
+            .get_membership_by_owner_community(y.owner, y.id)
+            .await?;
+
+        // ...
+        let conn = match self.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = execute!(
+            &conn,
+            "UPDATE communities SET owner = $1 WHERE id = $2",
+            params![&(new_owner as i64), &(id as i64)]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        self.cache_clear_community(&y).await;
+
+        // update memberships
+        self.update_membership_role(
+            new_owner_membership.id,
+            CommunityPermission::DEFAULT | CommunityPermission::ADMINISTRATOR,
+        )
+        .await?;
+
+        self.update_membership_role(
+            current_owner_membership.id,
+            CommunityPermission::DEFAULT | CommunityPermission::MEMBER,
+        )
+        .await?;
+
+        // return
+        Ok(())
+    }
+
     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);