From 3cc860f2f9ce6e5f80d2cdd4a610a3864bee66b0 Mon Sep 17 00:00:00 2001 From: Patrick Cleavelin Date: Fri, 27 Oct 2023 19:58:26 -0500 Subject: [PATCH] feat: ability to update permissions for users --- Cargo.lock | 21 +++++++ Cargo.toml | 3 +- flake.nix | 2 +- src/auth.rs | 49 +++++++++++++-- src/db/mod.rs | 47 +++++++++++++++ src/main.rs | 4 ++ src/page.rs | 163 +++++++++++++++++++++++++++++++++++++++----------- src/routes.rs | 61 +++++++++++++++++++ 8 files changed, 307 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a1f2cc..2e4e57d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,6 +508,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-iterator" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7add3873b5dd076766ee79c8e406ad1a472c385476b9e38849f8eec24f1be689" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eecf8589574ce9b895052fa12d69af7a233f99e6107f5cb8dd1044f2a17bfdcb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.27", +] + [[package]] name = "enum_primitive" version = "0.1.1" @@ -1097,6 +1117,7 @@ dependencies = [ "axum-extra", "chrono", "dotenv", + "enum-iterator", "futures", "iter_tools", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 88e29c7..1e8856e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "memejoin-rs" -version = "0.1.1-alpha" +version = "0.2.1-alpha" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -11,6 +11,7 @@ axum = { version = "0.6.9", features = ["headers", "multipart"] } axum-extra = { version = "0.7.5", features = ["cookie-private", "cookie"] } chrono = { version = "0.4.23", features = ["serde"] } dotenv = "0.15.0" +enum-iterator = "1.4.1" futures = "0.3.26" iter_tools = "0.1.4" reqwest = "0.11.14" diff --git a/flake.nix b/flake.nix index ee292d8..bc64af4 100644 --- a/flake.nix +++ b/flake.nix @@ -8,7 +8,7 @@ outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system: let - tag = "v0.2.0-alpha"; + tag = "v0.2.1-alpha"; overlays = [ (import rust-overlay) ]; pkgs = import nixpkgs { inherit system overlays; diff --git a/src/auth.rs b/src/auth.rs index 683d0ad..f5ec4cb 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,5 +1,10 @@ +use std::str::FromStr; + +use enum_iterator::Sequence; use serde::{Deserialize, Serialize}; +use crate::routes::Error; + #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct Discord { pub(crate) access_token: String, @@ -31,16 +36,22 @@ impl Default for Permissions { impl Permissions { pub(crate) fn can(&self, perm: Permission) -> bool { - self.0 & (perm as u8) > 0 + (self.0 & (perm as u8) > 0) || (self.0 & (Permission::Moderator as u8) > 0) + } + + pub(crate) fn add(&mut self, perm: Permission) { + self.0 |= perm as u8; } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Sequence)] #[repr(u8)] -pub enum Permission { - None, - UploadSounds, - DeleteSounds, +pub(crate) enum Permission { + None = 0, + UploadSounds = 1, + DeleteSounds = 2, + Soundboard = 4, + Moderator = 128, } impl Permission { @@ -48,3 +59,29 @@ impl Permission { 0xFF } } + +impl ToString for Permission { + fn to_string(&self) -> String { + match self { + Permission::None => todo!(), + Permission::UploadSounds => "Upload Sounds".to_string(), + Permission::DeleteSounds => "Delete Sounds".to_string(), + Permission::Soundboard => "Soundboard".to_string(), + Permission::Moderator => "Moderator".to_string(), + } + } +} + +impl FromStr for Permission { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "Upload Sounds" => Ok(Self::UploadSounds), + "Delete Sounds" => Ok(Self::DeleteSounds), + "Soundboard" => Ok(Self::Soundboard), + "Moderator" => Ok(Self::Moderator), + _ => Err(Self::Err::InvalidRequest), + } + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 3d96aff..2c71056 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -18,6 +18,25 @@ impl Database { }) } + pub(crate) fn get_guild_users(&self, guild_id: u64) -> Result> { + let mut query = self.conn.prepare( + " + SELECT + username + FROM UserGuild + WHERE guild_id = :guild_id + ", + )?; + + // NOTE(pcleavelin): for some reason this needs to be a let-binding or else + // the compiler complains about it being dropped too early (maybe I should update the compiler version) + let users = query + .query_map(&[(":guild_id", &guild_id.to_string())], |row| row.get(0))? + .collect::>>()?; + + Ok(users) + } + pub(crate) fn get_guilds(&self) -> Result> { let mut query = self.conn.prepare( " @@ -192,6 +211,34 @@ impl Database { intros } + pub(crate) fn get_all_user_permissions( + &self, + guild_id: u64, + ) -> Result> { + let mut query = self.conn.prepare( + " + SELECT + username, + permissions + FROM UserPermission + WHERE + guild_id = :guild_id + ", + )?; + + let permissions = query + .query_map( + &[ + // :vomit: + (":guild_id", &guild_id.to_string()), + ], + |row| Ok((row.get(0)?, auth::Permissions(row.get(1)?))), + )? + .collect::>>()?; + + Ok(permissions) + } + pub(crate) fn get_user_permissions( &self, username: &str, diff --git a/src/main.rs b/src/main.rs index 05602e1..e2fc768 100644 --- a/src/main.rs +++ b/src/main.rs @@ -136,6 +136,10 @@ fn spawn_api(db: Arc>) { .route("/index.html", get(page::home)) .route("/login", get(page::login)) .route("/guild/:guild_id", get(page::guild_dashboard)) + .route( + "/guild/:guild_id/permissions/update", + post(routes::update_guild_permissions), + ) .route("/v2/auth", get(routes::v2_auth)) .route( "/v2/intros/add/:guild_id/:channel", diff --git a/src/page.rs b/src/page.rs index e2785f2..894370e 100644 --- a/src/page.rs +++ b/src/page.rs @@ -104,35 +104,45 @@ pub(crate) async fn guild_dashboard( user: User, Path(guild_id): Path, ) -> Result, Redirect> { - let db = state.db.lock().await; + let (guild_intros, guild_channels, all_user_intros, user_permissions) = { + let db = state.db.lock().await; - let guild_intros = db.get_guild_intros(guild_id).map_err(|err| { - error!(?err, %guild_id, "couldn't get guild intros"); - // TODO: change to actual error - Redirect::to(&format!("{}/login", state.origin)) - })?; - let guild_channels = db.get_guild_channels(guild_id).map_err(|err| { - error!(?err, %guild_id, "couldn't get guild channels"); - // TODO: change to actual error - Redirect::to(&format!("{}/login", state.origin)) - })?; - let all_user_intros = db.get_all_user_intros(guild_id).map_err(|err| { - error!(?err, %guild_id, "couldn't get user intros"); - // TODO: change to actual error - Redirect::to(&format!("{}/login", state.origin)) - })?; - let user_permissions = db - .get_user_permissions(&user.name, guild_id) - .unwrap_or_default(); + let guild_intros = db.get_guild_intros(guild_id).map_err(|err| { + error!(?err, %guild_id, "couldn't get guild intros"); + // TODO: change to actual error + Redirect::to(&format!("{}/login", state.origin)) + })?; + let guild_channels = db.get_guild_channels(guild_id).map_err(|err| { + error!(?err, %guild_id, "couldn't get guild channels"); + // TODO: change to actual error + Redirect::to(&format!("{}/login", state.origin)) + })?; + let all_user_intros = db.get_all_user_intros(guild_id).map_err(|err| { + error!(?err, %guild_id, "couldn't get user intros"); + // TODO: change to actual error + Redirect::to(&format!("{}/login", state.origin)) + })?; + let user_permissions = db + .get_user_permissions(&user.name, guild_id) + .unwrap_or_default(); + + ( + guild_intros, + guild_channels, + all_user_intros, + user_permissions, + ) + }; + + let can_upload = user_permissions.can(auth::Permission::UploadSounds); + let is_moderator = user_permissions.can(auth::Permission::Moderator); + let mod_dashboard = moderator_dashboard(&state).await; let user_intros = all_user_intros .iter() - .filter(|intro| &intro.username == &user.name) + .filter(|intro| intro.username == user.name) .group_by(|intro| &intro.channel_name); - let can_upload = user_permissions.can(auth::Permission::UploadSounds); - let is_moderator = user_permissions.can(auth::Permission::DeleteSounds); - Ok(Html( HtmxBuilder::new(Tag::Html) .push_builder(page_header("MemeJoin - Dashboard")) @@ -149,7 +159,7 @@ pub(crate) async fn guild_dashboard( b.attribute("class", "container") .builder(Tag::Article, |b| { b.builder_text(Tag::Header, "Wow, you're a moderator") - .push_builder(moderator_dashboard(&state)) + .push_builder(mod_dashboard) .builder_text(Tag::Footer, "End of super cool mod section") }) }) @@ -281,17 +291,100 @@ fn ytdl_form(origin: &str, guild_id: u64) -> HtmxBuilder { }) } -fn moderator_dashboard(state: &ApiState) -> HtmxBuilder { - HtmxBuilder::new(Tag::Empty).link("Go back to old UI", &format!("{}/old", state.origin)) +async fn permissions_editor(state: &ApiState) -> HtmxBuilder { + let db = state.db.lock().await; + let user_permissions = db + .get_all_user_permissions(588149178912473103) + .unwrap_or_default(); + + HtmxBuilder::new(Tag::Empty).form(|b| { + b.hx_post(&format!( + "{}/guild/{}/permissions/update", + state.origin, 588149178912473103_i64 + )) + .attribute("hx-encoding", "multipart/form-data") + .builder(Tag::Table, |b| { + let mut b = b.attribute("role", "grid").builder(Tag::TableHead, |b| { + let mut b = b.builder_text(Tag::TableHeader, "User"); + + for perm in enum_iterator::all::() { + if perm == auth::Permission::Moderator || perm == auth::Permission::None { + continue; + } + + b = b.builder_text(Tag::TableHeader, &perm.to_string()); + } + + b + }); + + for permission in user_permissions { + b = b.builder(Tag::TableRow, |b| { + let mut b = b.builder_text(Tag::TableData, permission.0.as_str()); + + for perm in enum_iterator::all::() { + if perm == auth::Permission::Moderator || perm == auth::Permission::None { + continue; + } + + b = b.builder(Tag::TableData, |b| { + b.builder(Tag::Input, |b| { + let mut b = b.attribute("type", "checkbox").attribute( + "name", + &format!("{}#{}", permission.0, perm.to_string()), + ); + + if permission.1.can(auth::Permission::Moderator) { + b = b.flag("disabled"); + } + + if permission.1.can(perm) { + return b.flag("checked"); + } + + b + }) + }); + } + + b + }); + } + + b + }) + .button(|b| b.attribute("type", "submit").text("Update Permissions")) + }) } -pub(crate) async fn login(State(state): State) -> Html { - let authorize_uri = format!("https://discord.com/api/oauth2/authorize?client_id={}&redirect_uri={}/v2/auth&response_type=code&scope=guilds.members.read%20guilds%20identify", state.secrets.client_id, state.origin); - - Html( - HtmxBuilder::new(Tag::Html) - .push_builder(page_header("MemeJoin - Login")) - .link("Login", &authorize_uri) - .build(), - ) +async fn moderator_dashboard(state: &ApiState) -> HtmxBuilder { + let permissions_editor = permissions_editor(state).await; + HtmxBuilder::new(Tag::Empty).push_builder(permissions_editor) +} + +pub(crate) async fn login( + State(state): State, + user: Option, +) -> Result, Redirect> { + if user.is_some() { + Err(Redirect::to(&format!("{}/", state.origin))) + } else { + let authorize_uri = format!("https://discord.com/api/oauth2/authorize?client_id={}&redirect_uri={}/v2/auth&response_type=code&scope=guilds.members.read%20guilds%20identify", state.secrets.client_id, state.origin); + + Ok(Html( + page_header("MemeJoin - Login") + .builder(Tag::Main, |b| { + b.attribute("class", "container") + .link("Login with Discord", &authorize_uri) + }) + .build(), + )) + } + + //Html( + // HtmxBuilder::new(Tag::Html) + // .push_builder(page_header("MemeJoin - Login")) + // .link("Login", &authorize_uri) + // .build(), + //) } diff --git a/src/routes.rs b/src/routes.rs index 338822b..6311c3f 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -10,6 +10,7 @@ use axum_extra::extract::{cookie::Cookie, CookieJar}; use chrono::{Duration, Utc}; use reqwest::{StatusCode, Url}; use serde::{Deserialize, Deserializer}; +use std::str::FromStr; use tracing::{error, info}; use uuid::Uuid; @@ -442,3 +443,63 @@ pub(crate) async fn v2_add_guild_intro( Ok(headers) } + +pub(crate) async fn update_guild_permissions( + State(state): State, + Path(guild_id): Path, + user: db::User, + mut form_data: Multipart, +) -> Result { + let db = state.db.lock().await; + + let this_user_permissions = db + .get_user_permissions(&user.name, guild_id) + .unwrap_or_default(); + + if !this_user_permissions.can(auth::Permission::Moderator) { + return Err(Error::InvalidPermission); + } + + let mut users_to_update: HashMap = db + .get_guild_users(guild_id)? + .into_iter() + .map(|user| (user, Default::default())) + .collect(); + + while let Ok(Some(field)) = form_data.next_field().await { + let Some(field_name) = field.name() else { + continue; + }; + + if let Some((username, permission)) = field_name.split_once('#') { + let permission = auth::Permission::from_str(permission)?; + + let username = username.to_string(); + if field.text().await.map_err(|_| Error::InvalidRequest)? == "on" { + users_to_update + .entry(username) + .and_modify(|value| { + value.add(permission); + }) + .or_insert_with(|| { + let mut perm = auth::Permissions::default(); + perm.add(permission); + perm + }); + } + } + } + + for (user, permissions) in users_to_update { + let user_permissions = db.get_user_permissions(&user, guild_id).unwrap_or_default(); + + if !user_permissions.can(auth::Permission::Moderator) { + db.insert_user_permission(&user, guild_id, permissions)?; + } + } + + let mut headers = HeaderMap::new(); + headers.insert("HX-Refresh", HeaderValue::from_static("true")); + + Ok(headers) +}