diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index 4e263a9..7f7096b 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -1,14 +1,13 @@ steps: - name: build-x86_64 - image: rust:bookworm + image: rust:1.88-bookworm commands: - cargo build --release when: - event: push - - event: pull_request - name: build-arm64 - image: rust:bookworm + image: rust:1.88-bookworm commands: - dpkg --add-architecture arm64 - apt-get update -y && apt-get install -y crossbuild-essential-arm64 libssl-dev:arm64 @@ -20,7 +19,6 @@ steps: PKG_CONFIG_PATH: /usr/aarch64-linux-gnu/lib/pkgconfig when: - event: push - - event: pull_request - name: container-build-and-publish image: docker diff --git a/.woodpecker/publish-docs.yml b/.woodpecker/publish-docs.yml index e6ce482..7744dc7 100644 --- a/.woodpecker/publish-docs.yml +++ b/.woodpecker/publish-docs.yml @@ -4,7 +4,7 @@ when: steps: - name: build-docs - image: rust:bookworm + image: rust:1.88-bookworm commands: - cargo doc --release --no-deps diff --git a/migrations/2025-07-22-195121_add_ban/down.sql b/migrations/2025-07-22-195121_add_ban/down.sql new file mode 100644 index 0000000..62fe554 --- /dev/null +++ b/migrations/2025-07-22-195121_add_ban/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE guild_bans; diff --git a/migrations/2025-07-22-195121_add_ban/up.sql b/migrations/2025-07-22-195121_add_ban/up.sql new file mode 100644 index 0000000..a590142 --- /dev/null +++ b/migrations/2025-07-22-195121_add_ban/up.sql @@ -0,0 +1,8 @@ +-- Your SQL goes here +CREATE TABLE guild_bans ( + guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE, + user_uuid uuid NOT NULL REFERENCES users(uuid), + reason VARCHAR(200) DEFAULT NULL, + banned_since TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_uuid, guild_uuid) +); diff --git a/src/api/v1/channels/mod.rs b/src/api/v1/channels/mod.rs index 41d029a..fa90ccd 100644 --- a/src/api/v1/channels/mod.rs +++ b/src/api/v1/channels/mod.rs @@ -1,11 +1,13 @@ use std::sync::Arc; use axum::{ - middleware::from_fn_with_state, routing::{any, delete, get, patch}, Router + Router, + middleware::from_fn_with_state, + routing::{any, delete, get, patch}, }; //use socketioxide::SocketIo; -use crate::{api::v1::auth::CurrentUser, AppState}; +use crate::{AppState, api::v1::auth::CurrentUser}; mod uuid; diff --git a/src/api/v1/guilds/uuid/bans.rs b/src/api/v1/guilds/uuid/bans.rs new file mode 100644 index 0000000..29d5a05 --- /dev/null +++ b/src/api/v1/guilds/uuid/bans.rs @@ -0,0 +1,57 @@ +use std::sync::Arc; + +use axum::{ + Extension, Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use uuid::Uuid; + +use crate::{ + AppState, + api::v1::auth::CurrentUser, + error::Error, + objects::{GuildBan, Member, Permissions}, + utils::global_checks, +}; + +pub async fn get( + State(app_state): State>, + Path(guild_uuid): Path, + Extension(CurrentUser(uuid)): Extension>, +) -> Result { + global_checks(&app_state, uuid).await?; + + let mut conn = app_state.pool.get().await?; + + let caller = Member::check_membership(&mut conn, uuid, guild_uuid).await?; + caller + .check_permission(&app_state, Permissions::BanMember) + .await?; + + let all_guild_bans = GuildBan::fetch_all(&mut conn, guild_uuid).await?; + + Ok((StatusCode::OK, Json(all_guild_bans))) +} + +pub async fn unban( + State(app_state): State>, + Path((guild_uuid, user_uuid)): Path<(Uuid, Uuid)>, + Extension(CurrentUser(uuid)): Extension>, +) -> Result { + global_checks(&app_state, uuid).await?; + + let mut conn = app_state.pool.get().await?; + + let caller = Member::check_membership(&mut conn, uuid, guild_uuid).await?; + caller + .check_permission(&app_state, Permissions::BanMember) + .await?; + + let ban = GuildBan::fetch_one(&mut conn, guild_uuid, user_uuid).await?; + + ban.unban(&mut conn).await?; + + Ok(StatusCode::OK) +} diff --git a/src/api/v1/guilds/uuid/channels.rs b/src/api/v1/guilds/uuid/channels.rs index 836982d..82368b9 100644 --- a/src/api/v1/guilds/uuid/channels.rs +++ b/src/api/v1/guilds/uuid/channels.rs @@ -35,8 +35,9 @@ pub async fn get( if let Ok(cache_hit) = app_state .get_cache_key(format!("{guild_uuid}_channels")) .await + && let Ok(channels) = serde_json::from_str::>(&cache_hit) { - return Ok((StatusCode::OK, Json(cache_hit)).into_response()); + return Ok((StatusCode::OK, Json(channels)).into_response()); } let channels = Channel::fetch_all(&app_state.pool, guild_uuid).await?; diff --git a/src/api/v1/guilds/uuid/mod.rs b/src/api/v1/guilds/uuid/mod.rs index 52f0b64..65a7c76 100644 --- a/src/api/v1/guilds/uuid/mod.rs +++ b/src/api/v1/guilds/uuid/mod.rs @@ -7,11 +7,12 @@ use axum::{ extract::{Multipart, Path, State}, http::StatusCode, response::IntoResponse, - routing::{get, patch, post}, + routing::{delete, get, patch, post}, }; use bytes::Bytes; use uuid::Uuid; +mod bans; mod channels; mod invites; mod members; @@ -42,6 +43,9 @@ pub fn router() -> Router> { .route("/invites", post(invites::create)) // Members .route("/members", get(members::get)) + // Bans + .route("/bans", get(bans::get)) + .route("/bans/{uuid}", delete(bans::unban)) } /// `GET /api/v1/guilds/{uuid}` DESCRIPTION diff --git a/src/api/v1/guilds/uuid/roles/mod.rs b/src/api/v1/guilds/uuid/roles/mod.rs index 820ef0d..0e496a0 100644 --- a/src/api/v1/guilds/uuid/roles/mod.rs +++ b/src/api/v1/guilds/uuid/roles/mod.rs @@ -35,8 +35,10 @@ pub async fn get( Member::check_membership(&mut conn, uuid, guild_uuid).await?; - if let Ok(cache_hit) = app_state.get_cache_key(format!("{guild_uuid}_roles")).await { - return Ok((StatusCode::OK, Json(cache_hit)).into_response()); + if let Ok(cache_hit) = app_state.get_cache_key(format!("{guild_uuid}_roles")).await + && let Ok(roles) = serde_json::from_str::>(&cache_hit) + { + return Ok((StatusCode::OK, Json(roles)).into_response()); } let roles = Role::fetch_all(&mut conn, guild_uuid).await?; diff --git a/src/api/v1/guilds/uuid/roles/uuid.rs b/src/api/v1/guilds/uuid/roles/uuid.rs index 06193a1..732d553 100644 --- a/src/api/v1/guilds/uuid/roles/uuid.rs +++ b/src/api/v1/guilds/uuid/roles/uuid.rs @@ -27,8 +27,10 @@ pub async fn get( Member::check_membership(&mut conn, uuid, guild_uuid).await?; - if let Ok(cache_hit) = app_state.get_cache_key(format!("{role_uuid}")).await { - return Ok((StatusCode::OK, Json(cache_hit)).into_response()); + if let Ok(cache_hit) = app_state.get_cache_key(format!("{role_uuid}")).await + && let Ok(role) = serde_json::from_str::(&cache_hit) + { + return Ok((StatusCode::OK, Json(role)).into_response()); } let role = Role::fetch_one(&mut conn, role_uuid).await?; diff --git a/src/api/v1/members/mod.rs b/src/api/v1/members/mod.rs new file mode 100644 index 0000000..59ceac2 --- /dev/null +++ b/src/api/v1/members/mod.rs @@ -0,0 +1,17 @@ +use std::sync::Arc; + +use axum::{ + Router, + routing::{delete, get, post}, +}; + +use crate::AppState; + +mod uuid; + +pub fn router() -> Router> { + Router::new() + .route("/{uuid}", get(uuid::get)) + .route("/{uuid}", delete(uuid::delete)) + .route("/{uuid}/ban", post(uuid::ban::post)) +} diff --git a/src/api/v1/members/uuid/ban.rs b/src/api/v1/members/uuid/ban.rs new file mode 100644 index 0000000..dfe53f6 --- /dev/null +++ b/src/api/v1/members/uuid/ban.rs @@ -0,0 +1,47 @@ +use std::sync::Arc; + +use axum::{ + Extension, + extract::{Json, Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde::Deserialize; + +use crate::{ + AppState, + api::v1::auth::CurrentUser, + error::Error, + objects::{Member, Permissions}, + utils::global_checks, +}; + +use uuid::Uuid; + +#[derive(Deserialize)] +pub struct RequstBody { + reason: String, +} + +pub async fn post( + State(app_state): State>, + Path(member_uuid): Path, + Extension(CurrentUser(uuid)): Extension>, + Json(payload): Json, +) -> Result { + global_checks(&app_state, uuid).await?; + + let mut conn = app_state.pool.get().await?; + + let member = Member::fetch_one_with_member(&app_state, None, member_uuid).await?; + + let caller = Member::check_membership(&mut conn, uuid, member.guild_uuid).await?; + + caller + .check_permission(&app_state, Permissions::BanMember) + .await?; + + member.ban(&mut conn, &payload.reason).await?; + + Ok(StatusCode::OK) +} diff --git a/src/api/v1/members/uuid/mod.rs b/src/api/v1/members/uuid/mod.rs new file mode 100644 index 0000000..42a4418 --- /dev/null +++ b/src/api/v1/members/uuid/mod.rs @@ -0,0 +1,62 @@ +//! `/api/v1/members/{uuid}` Member specific endpoints + +pub mod ban; + +use std::sync::Arc; + +use crate::{ + AppState, + api::v1::auth::CurrentUser, + error::Error, + objects::{Me, Member, Permissions}, + utils::global_checks, +}; +use axum::{ + Extension, Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; + +use uuid::Uuid; + +pub async fn get( + State(app_state): State>, + Path(member_uuid): Path, + Extension(CurrentUser(uuid)): Extension>, +) -> Result { + global_checks(&app_state, uuid).await?; + + let mut conn = app_state.pool.get().await?; + + let me = Me::get(&mut conn, uuid).await?; + + let member = Member::fetch_one_with_member(&app_state, Some(&me), member_uuid).await?; + Member::check_membership(&mut conn, uuid, member.guild_uuid).await?; + + Ok((StatusCode::OK, Json(member))) +} + +pub async fn delete( + State(app_state): State>, + Path(member_uuid): Path, + Extension(CurrentUser(uuid)): Extension>, +) -> Result { + global_checks(&app_state, uuid).await?; + + let mut conn = app_state.pool.get().await?; + + let me = Me::get(&mut conn, uuid).await?; + + let member = Member::fetch_one_with_member(&app_state, Some(&me), member_uuid).await?; + + let deleter = Member::check_membership(&mut conn, uuid, member.guild_uuid).await?; + + deleter + .check_permission(&app_state, Permissions::KickMember) + .await?; + + member.delete(&mut conn).await?; + + Ok(StatusCode::OK) +} diff --git a/src/api/v1/mod.rs b/src/api/v1/mod.rs index 860944c..70271ef 100644 --- a/src/api/v1/mod.rs +++ b/src/api/v1/mod.rs @@ -11,6 +11,7 @@ mod channels; mod guilds; mod invites; mod me; +mod members; mod stats; mod users; @@ -19,6 +20,7 @@ pub fn router(app_state: Arc) -> Router> { .nest("/users", users::router()) .nest("/guilds", guilds::router()) .nest("/invites", invites::router()) + .nest("/members", members::router()) .nest("/me", me::router()) .layer(from_fn_with_state( app_state.clone(), diff --git a/src/objects/bans.rs b/src/objects/bans.rs new file mode 100644 index 0000000..602afa6 --- /dev/null +++ b/src/objects/bans.rs @@ -0,0 +1,57 @@ +use diesel::{ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use diesel_async::RunQueryDsl; + +use crate::{Conn, error::Error, objects::load_or_empty, schema::guild_bans}; + +#[derive(Selectable, Queryable, Serialize, Deserialize)] +#[diesel(table_name = guild_bans)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct GuildBan { + pub guild_uuid: Uuid, + pub user_uuid: Uuid, + pub reason: Option, + pub banned_since: chrono::DateTime, +} + +impl GuildBan { + pub async fn fetch_one( + conn: &mut Conn, + guild_uuid: Uuid, + user_uuid: Uuid, + ) -> Result { + use guild_bans::dsl; + let guild_ban = dsl::guild_bans + .filter(dsl::guild_uuid.eq(guild_uuid)) + .filter(dsl::user_uuid.eq(user_uuid)) + .select(GuildBan::as_select()) + .get_result(conn) + .await?; + + Ok(guild_ban) + } + + pub async fn fetch_all(conn: &mut Conn, guild_uuid: Uuid) -> Result, Error> { + use guild_bans::dsl; + let all_guild_bans = load_or_empty( + dsl::guild_bans + .filter(dsl::guild_uuid.eq(guild_uuid)) + .load(conn) + .await, + )?; + + Ok(all_guild_bans) + } + + pub async fn unban(self, conn: &mut Conn) -> Result<(), Error> { + use guild_bans::dsl; + diesel::delete(guild_bans::table) + .filter(dsl::guild_uuid.eq(self.guild_uuid)) + .filter(dsl::user_uuid.eq(self.user_uuid)) + .execute(conn) + .await?; + Ok(()) + } +} diff --git a/src/objects/member.rs b/src/objects/member.rs index 50b76b0..dc35e08 100644 --- a/src/objects/member.rs +++ b/src/objects/member.rs @@ -1,5 +1,6 @@ use diesel::{ - ExpressionMethods, Insertable, QueryDsl, Queryable, Selectable, SelectableHelper, insert_into, + ExpressionMethods, Insertable, QueryDsl, Queryable, Selectable, SelectableHelper, delete, + insert_into, }; use diesel_async::RunQueryDsl; use serde::{Deserialize, Serialize}; @@ -8,8 +9,8 @@ use uuid::Uuid; use crate::{ AppState, Conn, error::Error, - objects::{Me, Permissions, Role}, - schema::guild_members, + objects::{GuildBan, Me, Permissions, Role}, + schema::{guild_bans, guild_members}, }; use super::{User, load_or_empty}; @@ -119,6 +120,23 @@ impl Member { member.build(app_state, Some(me)).await } + pub async fn fetch_one_with_member( + app_state: &AppState, + me: Option<&Me>, + uuid: Uuid, + ) -> Result { + let mut conn = app_state.pool.get().await?; + + use guild_members::dsl; + let member: MemberBuilder = dsl::guild_members + .filter(dsl::uuid.eq(uuid)) + .select(MemberBuilder::as_select()) + .get_result(&mut conn) + .await?; + + member.build(app_state, me).await + } + pub async fn fetch_all( app_state: &AppState, me: &Me, @@ -151,6 +169,13 @@ impl Member { ) -> Result { let mut conn = app_state.pool.get().await?; + let banned = GuildBan::fetch_one(&mut conn, guild_uuid, user_uuid).await; + match banned { + Ok(_) => Err(Error::Forbidden("User banned".to_string())), + Err(Error::SqlError(diesel::result::Error::NotFound)) => Ok(()), + Err(e) => Err(e), + }?; + let member_uuid = Uuid::now_v7(); let member = MemberBuilder { @@ -168,4 +193,36 @@ impl Member { member.build(app_state, None).await } + + pub async fn delete(self, conn: &mut Conn) -> Result<(), Error> { + if self.is_owner { + return Err(Error::Forbidden("Can not kick owner".to_string())) + } + delete(guild_members::table) + .filter(guild_members::uuid.eq(self.uuid)) + .execute(conn) + .await?; + + Ok(()) + } + + pub async fn ban(self, conn: &mut Conn, reason: &String) -> Result<(), Error> { + if self.is_owner { + return Err(Error::Forbidden("Can not ban owner".to_string())); + } + + use guild_bans::dsl; + insert_into(guild_bans::table) + .values(( + dsl::guild_uuid.eq(self.guild_uuid), + dsl::user_uuid.eq(self.user_uuid), + dsl::reason.eq(reason), + )) + .execute(conn) + .await?; + + self.delete(conn).await?; + + Ok(()) + } } diff --git a/src/objects/mod.rs b/src/objects/mod.rs index 4af16d8..3bcce9c 100644 --- a/src/objects/mod.rs +++ b/src/objects/mod.rs @@ -7,6 +7,7 @@ use log::debug; use serde::Deserialize; use uuid::Uuid; +mod bans; mod channel; mod email_token; mod friends; @@ -19,6 +20,7 @@ mod password_reset_token; mod role; mod user; +pub use bans::GuildBan; pub use channel::Channel; pub use email_token::EmailToken; pub use friends::Friend; diff --git a/src/objects/role.rs b/src/objects/role.rs index ea70686..4a4009b 100644 --- a/src/objects/role.rs +++ b/src/objects/role.rs @@ -176,6 +176,10 @@ pub enum Permissions { ManageGuild = 32, /// Lets users change member settings (nickname, etc) ManageMember = 64, + /// Lets users ban members + BanMember = 128, + /// Lets users kick members + KickMember = 256, } impl Permissions { @@ -188,6 +192,8 @@ impl Permissions { Self::ManageInvite, Self::ManageGuild, Self::ManageMember, + Self::BanMember, + Self::KickMember, ]; all_perms diff --git a/src/schema.rs b/src/schema.rs index 4095dcd..422c3a3 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -47,6 +47,16 @@ diesel::table! { } } +diesel::table! { + guild_bans (user_uuid, guild_uuid) { + guild_uuid -> Uuid, + user_uuid -> Uuid, + #[max_length = 200] + reason -> Nullable, + banned_since -> Timestamptz, + } +} + diesel::table! { guild_members (uuid) { uuid -> Uuid, @@ -154,6 +164,8 @@ diesel::joinable!(access_tokens -> refresh_tokens (refresh_token)); diesel::joinable!(access_tokens -> users (uuid)); diesel::joinable!(channel_permissions -> channels (channel_uuid)); diesel::joinable!(channels -> guilds (guild_uuid)); +diesel::joinable!(guild_bans -> guilds (guild_uuid)); +diesel::joinable!(guild_bans -> users (user_uuid)); diesel::joinable!(guild_members -> guilds (guild_uuid)); diesel::joinable!(guild_members -> users (user_uuid)); diesel::joinable!(instance_permissions -> users (uuid)); @@ -171,6 +183,7 @@ diesel::allow_tables_to_appear_in_same_query!( channels, friend_requests, friends, + guild_bans, guild_members, guilds, instance_permissions,