From 18b67e471a5bfd0e7f897721c808b875328ee35a Mon Sep 17 00:00:00 2001 From: Patrick Cleavelin Date: Mon, 1 Apr 2024 20:46:56 -0500 Subject: [PATCH 1/9] use just file, make flake work on mac --- .gitignore | 5 ++--- flake.nix | 2 +- justfile | 10 ++++++++++ 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 justfile diff --git a/.gitignore b/.gitignore index aaf15c0..ab84db3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ /target -**/result -result/ -result +/config + .env diff --git a/flake.nix b/flake.nix index bc64af4..899f911 100644 --- a/flake.nix +++ b/flake.nix @@ -42,7 +42,7 @@ cmake libopus yt-dlp - ]; + ] ++ (if pkgs.system == "aarch64-darwin" || pkgs.system == "x86_64-darwin" then [ darwin.apple_sdk.frameworks.Security darwin.apple_sdk.frameworks.SystemConfiguration ] else []); }; packages = with pkgs; flake-utils.lib.flattenTree rec { diff --git a/justfile b/justfile new file mode 100644 index 0000000..3170016 --- /dev/null +++ b/justfile @@ -0,0 +1,10 @@ +alias b := build +alias r := run + +set dotenv-load + +build: + cargo build + +run: + cargo run -- 2.40.1 From fbc6b6f45769a8a376e17ac70b0ba3a7f90b8127 Mon Sep 17 00:00:00 2001 From: Patrick Cleavelin Date: Sun, 9 Jun 2024 17:29:40 -0500 Subject: [PATCH 2/9] allow starting from empty database, allow adding guilds, upgrade pico-css to v2 --- .gitignore | 2 + src/auth.rs | 54 +++++++++++++++ src/db/mod.rs | 80 +++++++++++++++++++--- src/db/schema.sql | 23 +++++-- src/main.rs | 9 ++- src/page.rs | 164 +++++++++++++++++++++++++++++++++++++++++----- src/routes.rs | 73 +++++++++++++-------- 7 files changed, 345 insertions(+), 60 deletions(-) diff --git a/.gitignore b/.gitignore index ab84db3..fd9eebb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /target /config +/.idea +.DS_Store .env diff --git a/src/auth.rs b/src/auth.rs index f5ec4cb..9e2dc17 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -26,6 +26,60 @@ pub(crate) struct User { pub(crate) name: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct AppPermissions(pub(crate) u8); +impl Default for AppPermissions { + fn default() -> AppPermissions { + AppPermissions(0) + } +} + +impl AppPermissions { + pub(crate) fn can(&self, perm: AppPermission) -> bool { + (self.0 & (perm as u8) > 0) || (self.0 & (AppPermission::Admin as u8) > 0) + } + + pub(crate) fn add(&mut self, perm: Permission) { + self.0 |= perm as u8; + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Sequence)] +#[repr(u8)] +pub(crate) enum AppPermission { + None = 0, + AddGuild = 1, + Admin = 128, +} + +impl AppPermission { + pub(crate) fn all() -> u8 { + 0xFF + } +} + +impl ToString for AppPermission { + fn to_string(&self) -> String { + match self { + AppPermission::None => todo!(), + AppPermission::AddGuild => "Add Guild".to_string(), + AppPermission::Admin => "Admin".to_string(), + } + } +} + +impl FromStr for AppPermission { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "Add Guild" => Ok(Self::AddGuild), + "Admin" => Ok(Self::Admin), + _ => Err(Self::Err::InvalidRequest), + } + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub(crate) struct Permissions(pub(crate) u8); impl Default for Permissions { diff --git a/src/db/mod.rs b/src/db/mod.rs index 2c71056..31c940f 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -18,6 +18,12 @@ impl Database { }) } + pub(crate) fn init(&self) -> Result<()> { + self.conn.execute_batch(include_str!("schema.sql"))?; + + Ok(()) + } + pub(crate) fn get_guild_users(&self, guild_id: u64) -> Result> { let mut query = self.conn.prepare( " @@ -258,6 +264,20 @@ impl Database { ) } + pub(crate) fn get_user_app_permissions(&self, username: &str) -> Result { + self.conn.query_row( + " + SELECT + permissions + FROM UserAppPermission + WHERE + username = ?1 + ", + [username], + |row| Ok(auth::AppPermissions(row.get(0)?)), + ) + } + pub(crate) fn get_guild_channels(&self, guild_id: u64) -> Result> { let mut query = self.conn.prepare( " @@ -302,6 +322,25 @@ impl Database { Ok(intros) } + pub fn insert_guild(&self, guild_id: &u64, name: &str, sound_delay: u32) -> Result<()> { + let affected = self.conn.execute( + "INSERT INTO + Guild (id, name, sound_delay) + VALUES (?1, ?2, ?3)", + [ + guild_id.to_string(), + name.to_string(), + sound_delay.to_string(), + ], + )?; + + if affected < 1 { + warn!("no rows affected when attempting to insert guild"); + } + + Ok(()) + } + pub fn insert_user( &self, username: &str, @@ -315,7 +354,7 @@ impl Database { User (username, api_key, api_key_expires_at, discord_token, discord_token_expires_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(username) DO UPDATE SET api_key = ?2, api_key_expires_at = ?3, discord_token = ?4, discord_token_expires_at = ?5", - &[ + [ username, api_key, &api_key_expires_at.to_string(), @@ -342,7 +381,7 @@ impl Database { "INSERT INTO Intro (name, volume, guild_id, filename) VALUES (?1, ?2, ?3, ?4)", - &[name, &volume.to_string(), &guild_id.to_string(), filename], + [name, &volume.to_string(), &guild_id.to_string(), filename], )?; if affected < 1 { @@ -355,7 +394,7 @@ impl Database { pub fn insert_user_guild(&self, username: &str, guild_id: u64) -> Result<()> { let affected = self.conn.execute( "INSERT OR IGNORE INTO UserGuild (username, guild_id) VALUES (?1, ?2)", - &[username, &guild_id.to_string()], + [username, &guild_id.to_string()], )?; if affected < 1 { @@ -374,7 +413,7 @@ impl Database { ) -> Result<()> { let affected = self.conn.execute( "INSERT INTO UserIntro (username, guild_id, channel_name, intro_id) VALUES (?1, ?2, ?3, ?4)", - &[ + [ username, &guild_id.to_string(), channel_name, @@ -401,7 +440,7 @@ impl Database { UserPermission (username, guild_id, permissions) VALUES (?1, ?2, ?3) ON CONFLICT(username, guild_id) DO UPDATE SET permissions = ?3", - &[username, &guild_id.to_string(), &permissions.0.to_string()], + [username, &guild_id.to_string(), &permissions.0.to_string()], )?; if affected < 1 { @@ -411,6 +450,27 @@ impl Database { Ok(()) } + pub(crate) fn insert_user_app_permission( + &self, + username: &str, + permissions: auth::AppPermissions, + ) -> Result<()> { + let affected = self.conn.execute( + " + INSERT INTO + UserAppPermission (username, permissions) + VALUES (?1, ?2) + ON CONFLICT(username) DO UPDATE SET permissions = ?2", + [username, &permissions.0.to_string()], + )?; + + if affected < 1 { + warn!("no rows affected when attempting to insert user app permissions"); + } + + Ok(()) + } + pub fn delete_user_intro( &self, username: &str, @@ -421,12 +481,12 @@ impl Database { let affected = self.conn.execute( "DELETE FROM UserIntro - WHERE - username = ?1 - AND guild_id = ?2 - AND channel_name = ?3 + WHERE + username = ?1 + AND guild_id = ?2 + AND channel_name = ?3 AND intro_id = ?4", - &[ + [ username, &guild_id.to_string(), channel_name, diff --git a/src/db/schema.sql b/src/db/schema.sql index d38ef1e..137aee1 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -1,6 +1,6 @@ BEGIN; -create table User +create table if not exists User ( username TEXT not null constraint User_pk @@ -11,7 +11,7 @@ create table User discord_token_expires_at DATETIME not null ); -create table Intro +create table if not exists Intro ( id integer not null constraint Intro_pk @@ -24,7 +24,7 @@ create table Intro filename TEXT not null ); -create table Guild +create table if not exists Guild ( id integer not null primary key, @@ -32,7 +32,7 @@ create table Guild sound_delay integer not null ); -create table Channel +create table if not exists Channel ( name TEXT primary key, @@ -41,7 +41,7 @@ create table Channel references Guild (id) ); -create table UserGuild +create table if not exists UserGuild ( username TEXT not null constraint UserGuild_User_username_fk @@ -52,7 +52,7 @@ create table UserGuild primary key ("username", "guild_id") ); -create table UserIntro +create table if not exists UserIntro ( username text not null constraint UserIntro_User_username_fk @@ -69,7 +69,7 @@ create table UserIntro primary key ("username", "intro_id", "guild_id", "channel_name") ); -create table UserPermission +create table if not exists UserPermission ( username TEXT not null constraint UserPermission_User_username_fk @@ -81,4 +81,13 @@ create table UserPermission primary key ("username", "guild_id") ); +create table if not exists UserAppPermission +( + username TEXT not null + constraint UserPermission_User_username_fk + references User, + permissions integer not null, + primary key ("username") +); + COMMIT; diff --git a/src/main.rs b/src/main.rs index e2fc768..3595234 100644 --- a/src/main.rs +++ b/src/main.rs @@ -136,6 +136,7 @@ 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/setup", get(page::guild_setup)) .route( "/guild/:guild_id/permissions/update", post(routes::update_guild_permissions), @@ -309,7 +310,7 @@ async fn main() -> std::io::Result<()> { tracing_subscriber::fmt::init(); - let settings = serde_json::from_str::( + let mut settings = serde_json::from_str::( &std::fs::read_to_string("config/settings.json").expect("no config/settings.json"), ) .expect("error parsing settings file"); @@ -320,6 +321,12 @@ async fn main() -> std::io::Result<()> { db::Database::new("./config/db.sqlite").expect("couldn't open sqlite db"), )); + { + // attempt to initialize the database with the schema + let db = db.lock().await; + db.init().expect("couldn't init db"); + } + if run_api { spawn_api(db.clone()); } diff --git a/src/page.rs b/src/page.rs index 452642b..d3292b5 100644 --- a/src/page.rs +++ b/src/page.rs @@ -5,10 +5,11 @@ use crate::{ settings::ApiState, }; use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, response::{Html, Redirect}, }; use iter_tools::Itertools; +use serde::Deserialize; use tracing::error; fn page_header(title: &str) -> HtmxBuilder { @@ -20,7 +21,7 @@ fn page_header(title: &str) -> HtmxBuilder { ) // Not currently using // .script("https://unpkg.com/hyperscript.org@0.9.9", None) - .style_link("https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css") + .style_link("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css") }) } @@ -31,18 +32,86 @@ pub(crate) async fn home( if let Some(user) = user { let db = state.db.lock().await; + let needs_setup = db + .get_guilds() + .map_err(|err| { + error!(?err, "failed to get user guilds"); + // TODO: change this to returning a error to the client + Redirect::to(&format!("{}/error", state.origin)) + })? + .is_empty(); let user_guilds = db.get_user_guilds(&user.name).map_err(|err| { error!(?err, "failed to get user guilds"); // TODO: change this to returning a error to the client Redirect::to(&format!("{}/login", state.origin)) })?; + tracing::info!("user name: {}", user.name); + let user_app_permissions = db.get_user_app_permissions(&user.name).map_err(|err| { + error!(?err, "failed to get user app permissions"); + // TODO: change this to returning a error to the client + Redirect::to(&format!("{}/error", state.origin)) + })?; + + let can_add_guild = user_app_permissions.can(auth::AppPermission::AddGuild); + + let client = reqwest::Client::new(); + let discord_guilds: Vec = if can_add_guild { + client + .get("https://discord.com/api/v10/users/@me/guilds") + .bearer_auth(&user.discord_token) + .send() + .await + .map_err(|err| { + error!(?err, "failed to get guilds"); + // TODO: change this to returning a error to the client + Redirect::to(&format!("{}/error", state.origin)) + })? + .json() + .await + .map_err(|err| { + error!(?err, "failed to parse json"); + // TODO: change this to returning a error to the client + Redirect::to(&format!("{}/error", state.origin)) + })? + } else { + vec![] + } + .into_iter() + // lol, why does this need to have an explicit type annotation + .filter(|discord_guild: &crate::routes::DiscordUserGuild| { + !user_guilds + .iter() + .any(|user_guild| discord_guild.id == user_guild.id) + }) + .collect(); + + let guild_list = if needs_setup { + HtmxBuilder::new(Tag::Empty).builder(Tag::Div, |b| { + b.attribute("class", "container") + .builder_text(Tag::Header2, "Select a Guild to setup") + .push_builder(setup_guild_list(&state.origin, &discord_guilds)) + }) + } else { + HtmxBuilder::new(Tag::Empty).builder(Tag::Div, |b| { + b.attribute("class", "container") + .builder_text(Tag::Header2, "Choose a Guild") + .push_builder(guild_list(&state.origin, user_guilds.iter())) + }) + }; Ok(Html( page_header("MemeJoin - Home") .builder(Tag::Div, |b| { - b.attribute("class", "container") - .builder_text(Tag::Header2, "Choose a Guild") - .push_builder(guild_list(&state.origin, user_guilds.iter())) + let mut b = b.push_builder(guild_list); + + if !needs_setup && can_add_guild && !discord_guilds.is_empty() { + b = b + .attribute("class", "container") + .builder_text(Tag::Header2, "Add a Guild") + .push_builder(setup_guild_list(&state.origin, &discord_guilds)); + } + + b }) .build(), )) @@ -51,20 +120,30 @@ pub(crate) async fn home( } } +fn setup_guild_list(origin: &str, user_guilds: &[crate::routes::DiscordUserGuild]) -> HtmxBuilder { + HtmxBuilder::new(Tag::Empty).ul(|b| { + let mut b = b; + for guild in user_guilds { + b = b.li(|b| { + b.link( + &guild.name, + // TODO: url encode the name + &format!("{}/guild/{}/setup?name={}", origin, guild.id, guild.name), + ) + }); + } + + b + }) +} + fn guild_list<'a>(origin: &str, guilds: impl Iterator) -> HtmxBuilder { HtmxBuilder::new(Tag::Empty).ul(|b| { let mut b = b; - let mut in_any_guilds = false; for guild in guilds { - in_any_guilds = true; - b = b.li(|b| b.link(&guild.name, &format!("{}/guild/{}", origin, guild.id))); } - if !in_any_guilds { - b = b.builder_text(Tag::Header4, "Looks like you aren't in any guilds"); - } - b }) } @@ -226,6 +305,50 @@ pub(crate) async fn guild_dashboard( )) } +#[derive(Debug, Deserialize)] +pub(crate) struct GuildSetupParams { + name: String, +} + +pub(crate) async fn guild_setup( + State(state): State, + user: User, + Path(guild_id): Path, + Query(GuildSetupParams { name }): Query, +) -> Result { + let db = state.db.lock().await; + + let user_permissions = db.get_user_app_permissions(&user.name).unwrap_or_default(); + if !user_permissions.can(auth::AppPermission::AddGuild) { + return Err(Redirect::to(&state.origin)); + } + + db.insert_guild(&guild_id, &name, 0).map_err(|err| { + error!("failed to insert guild into db: {err}"); + Redirect::to(&state.origin) + })?; + + db.insert_user_guild(&user.name, guild_id).map_err(|err| { + error!("failed to insert user guild into db: {err}"); + Redirect::to(&state.origin) + })?; + + db.insert_user_permission( + &user.name, + guild_id, + auth::Permissions(auth::Permission::all()), + ) + .map_err(|err| { + error!("failed to insert user permissions into db: {err}"); + Redirect::to(&state.origin) + })?; + + Ok(Redirect::to(&format!( + "{}/guild/{}", + state.origin, guild_id + ))) +} + pub fn channel_intro_selector<'a>( origin: &str, guild_id: u64, @@ -370,10 +493,21 @@ pub(crate) async fn login( 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") + HtmxBuilder::new(Tag::Html) + .push_builder(page_header("MemeJoin - Dashboard")) + .builder(Tag::Nav, |b| { + b.builder(Tag::HeaderGroup, |b| { + b.attribute("class", "container") + .builder(Tag::Header1, |b| b.text("MemeJoin - A bot for user intros")) + .builder_text(Tag::Header6, "salad") + }) + }) .builder(Tag::Main, |b| { - b.attribute("class", "container") - .link("Login with Discord", &authorize_uri) + b.attribute("class", "container").builder(Tag::Anchor, |b| { + b.attribute("role", "button") + .text("Login with Discord") + .attribute("href", &authorize_uri) + }) }) .build(), )) diff --git a/src/routes.rs b/src/routes.rs index 6311c3f..d6f76ee 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -87,9 +87,10 @@ struct DiscordUser { } #[derive(Deserialize)] -struct DiscordUserGuild { +pub(crate) struct DiscordUserGuild { #[serde(deserialize_with = "serde_string_as_u64")] pub id: u64, + pub name: String, pub owner: bool, } @@ -160,19 +161,9 @@ pub(crate) async fn v2_auth( .map_err(|err| Error::Auth(err.to_string()))?; let db = state.db.lock().await; + let needs_setup = db.get_guilds().map_err(Error::Database)?.is_empty(); - let guilds = db.get_guilds().map_err(Error::Database)?; - let mut in_a_guild = false; - for guild in guilds { - let Some(discord_guild) = discord_guilds - .iter() - .find(|discord_guild| discord_guild.id == guild.id) - else { - continue; - }; - - in_a_guild = true; - + if needs_setup { let now = Utc::now().naive_utc(); db.insert_user( &user.username, @@ -183,25 +174,53 @@ pub(crate) async fn v2_auth( ) .map_err(Error::Database)?; - db.insert_user_guild(&user.username, guild.id) - .map_err(Error::Database)?; + db.insert_user_app_permission( + &user.username, + auth::AppPermissions(auth::AppPermission::all()), + ) + .map_err(Error::Database)?; + } else { + let guilds = db.get_guilds().map_err(Error::Database)?; + let mut in_a_guild = false; + for guild in guilds { + let Some(discord_guild) = discord_guilds + .iter() + .find(|discord_guild| discord_guild.id == guild.id) + else { + continue; + }; - if db.get_user_permissions(&user.username, guild.id).is_err() { - db.insert_user_permission( + in_a_guild = true; + + let now = Utc::now().naive_utc(); + db.insert_user( &user.username, - guild.id, - if discord_guild.owner { - auth::Permissions(auth::Permission::all()) - } else { - Default::default() - }, + &token, + now + Duration::weeks(4), + &auth.access_token, + now + Duration::seconds(auth.expires_in as i64), ) .map_err(Error::Database)?; - } - } - if !in_a_guild { - return Err(Error::NoGuildFound); + db.insert_user_guild(&user.username, guild.id) + .map_err(Error::Database)?; + + if db.get_user_permissions(&user.username, guild.id).is_err() { + db.insert_user_permission( + &user.username, + guild.id, + if discord_guild.owner { + auth::Permissions(auth::Permission::all()) + } else { + Default::default() + }, + ) + .map_err(Error::Database)?; + } + } + if !in_a_guild { + return Err(Error::NoGuildFound); + } } // TODO: add permissions based on roles -- 2.40.1 From 6d60ad57ef150c91597f6b0adda9eb4c7ae4042b Mon Sep 17 00:00:00 2001 From: Patrick Cleavelin Date: Sun, 9 Jun 2024 19:50:03 -0500 Subject: [PATCH 3/9] reuse api key on login (this enabled sessions on multiple devices with the risk of worse security) --- src/routes.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/routes.rs b/src/routes.rs index d6f76ee..04c46bb 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -139,7 +139,6 @@ pub(crate) async fn v2_auth( error!(?err, "auth error"); Error::Auth(err.to_string()) })?; - let token = Uuid::new_v4().to_string(); // Get authorized username let user: DiscordUser = client @@ -162,6 +161,15 @@ pub(crate) async fn v2_auth( let db = state.db.lock().await; let needs_setup = db.get_guilds().map_err(Error::Database)?.is_empty(); + let token = if let Some(user) = db + .get_user(&user.username) + .map_err(Error::Database)? + .filter(|user| user.api_key_expires_at >= Utc::now().naive_utc()) + { + user.api_key + } else { + Uuid::new_v4().to_string() + }; if needs_setup { let now = Utc::now().naive_utc(); -- 2.40.1 From 66ea9ac2fa2ddb15f4753944da63b0aee78a017a Mon Sep 17 00:00:00 2001 From: Patrick Cleavelin Date: Sun, 9 Jun 2024 20:01:11 -0500 Subject: [PATCH 4/9] allow adding more VCs to add intros to --- .gitignore | 1 + src/auth.rs | 4 ++ src/db/mod.rs | 15 ++++++ src/main.rs | 7 ++- src/page.rs | 130 ++++++++++++++++++++++++++++++-------------------- src/routes.rs | 77 +++++++++++++++++++++++++++++- 6 files changed, 181 insertions(+), 53 deletions(-) diff --git a/.gitignore b/.gitignore index fd9eebb..8085ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /target /config +/sounds /.idea .DS_Store diff --git a/src/auth.rs b/src/auth.rs index 9e2dc17..4a84c45 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -18,6 +18,7 @@ pub(crate) struct Discord { pub(crate) struct DiscordSecret { pub(crate) client_id: String, pub(crate) client_secret: String, + pub(crate) bot_token: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -105,6 +106,7 @@ pub(crate) enum Permission { UploadSounds = 1, DeleteSounds = 2, Soundboard = 4, + AddChannel = 8, Moderator = 128, } @@ -121,6 +123,7 @@ impl ToString for Permission { Permission::UploadSounds => "Upload Sounds".to_string(), Permission::DeleteSounds => "Delete Sounds".to_string(), Permission::Soundboard => "Soundboard".to_string(), + Permission::AddChannel => "Add Channel".to_string(), Permission::Moderator => "Moderator".to_string(), } } @@ -134,6 +137,7 @@ impl FromStr for Permission { "Upload Sounds" => Ok(Self::UploadSounds), "Delete Sounds" => Ok(Self::DeleteSounds), "Soundboard" => Ok(Self::Soundboard), + "Add Channel" => Ok(Self::AddChannel), "Moderator" => Ok(Self::Moderator), _ => Err(Self::Err::InvalidRequest), } diff --git a/src/db/mod.rs b/src/db/mod.rs index 31c940f..b4b9ec3 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -341,6 +341,21 @@ impl Database { Ok(()) } + pub fn insert_guild_channel(&self, guild_id: &u64, name: &str) -> Result<()> { + let affected = self.conn.execute( + "INSERT INTO + Channel (name, guild_id) + VALUES (?1, ?2)", + [name.to_string(), guild_id.to_string()], + )?; + + if affected < 1 { + warn!("no rows affected when attempting to insert channel"); + } + + Ok(()) + } + pub fn insert_user( &self, username: &str, diff --git a/src/main.rs b/src/main.rs index 3595234..baee7a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -121,6 +121,7 @@ fn spawn_api(db: Arc>) { client_id: env::var("DISCORD_CLIENT_ID").expect("expected DISCORD_CLIENT_ID env var"), client_secret: env::var("DISCORD_CLIENT_SECRET") .expect("expected DISCORD_CLIENT_SECRET env var"), + bot_token: env::var("DISCORD_TOKEN").expect("expected DISCORD_TOKEN env var"), }; let origin = env::var("APP_ORIGIN").expect("expected APP_ORIGIN"); @@ -136,7 +137,11 @@ 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/setup", get(page::guild_setup)) + .route("/guild/:guild_id/setup", get(routes::guild_setup)) + .route( + "/guild/:guild_id/add_channel", + post(routes::guild_add_channel), + ) .route( "/guild/:guild_id/permissions/update", post(routes::update_guild_permissions), diff --git a/src/page.rs b/src/page.rs index d3292b5..c5efe60 100644 --- a/src/page.rs +++ b/src/page.rs @@ -215,7 +215,7 @@ pub(crate) async fn guild_dashboard( let can_upload = user_permissions.can(auth::Permission::UploadSounds); let is_moderator = user_permissions.can(auth::Permission::Moderator); - let mod_dashboard = moderator_dashboard(&state, guild_id).await; + let mod_dashboard = moderator_dashboard(&state, &state.secrets.bot_token, guild_id).await; let user_intros = all_user_intros .iter() @@ -305,50 +305,6 @@ pub(crate) async fn guild_dashboard( )) } -#[derive(Debug, Deserialize)] -pub(crate) struct GuildSetupParams { - name: String, -} - -pub(crate) async fn guild_setup( - State(state): State, - user: User, - Path(guild_id): Path, - Query(GuildSetupParams { name }): Query, -) -> Result { - let db = state.db.lock().await; - - let user_permissions = db.get_user_app_permissions(&user.name).unwrap_or_default(); - if !user_permissions.can(auth::AppPermission::AddGuild) { - return Err(Redirect::to(&state.origin)); - } - - db.insert_guild(&guild_id, &name, 0).map_err(|err| { - error!("failed to insert guild into db: {err}"); - Redirect::to(&state.origin) - })?; - - db.insert_user_guild(&user.name, guild_id).map_err(|err| { - error!("failed to insert user guild into db: {err}"); - Redirect::to(&state.origin) - })?; - - db.insert_user_permission( - &user.name, - guild_id, - auth::Permissions(auth::Permission::all()), - ) - .map_err(|err| { - error!("failed to insert user permissions into db: {err}"); - Redirect::to(&state.origin) - })?; - - Ok(Redirect::to(&format!( - "{}/guild/{}", - state.origin, guild_id - ))) -} - pub fn channel_intro_selector<'a>( origin: &str, guild_id: u64, @@ -414,6 +370,83 @@ fn ytdl_form(origin: &str, guild_id: u64) -> HtmxBuilder { }) } +async fn moderator_dashboard(state: &ApiState, bot_token: &str, guild_id: u64) -> HtmxBuilder { + let permissions_editor = permissions_editor(state, guild_id).await; + let channel_editor = channel_editor(state, bot_token, guild_id).await; + HtmxBuilder::new(Tag::Empty) + .push_builder(permissions_editor) + .push_builder(channel_editor) +} + +async fn channel_editor(state: &ApiState, bot_token: &str, guild_id: u64) -> HtmxBuilder { + let db = state.db.lock().await; + let added_guild_channels = db.get_guild_channels(guild_id).unwrap_or_default(); + + let mut got_channels = true; + let client = reqwest::Client::new(); + let channels: Vec = { + match client + .get(format!( + "https://discord.com/api/v10/guilds/{}/channels", + guild_id + )) + .header("Authorization", format!("Bot {}", bot_token)) + .send() + .await + { + Ok(resp) => match resp.json::>().await { + Ok(channels) => channels + .into_iter() + .filter(|channel| channel.ty == crate::routes::ChannelType::GuildVoice as u32) + .filter_map(|channel| channel.name) + .filter(|name| !added_guild_channels.contains(name)) + .collect(), + Err(err) => { + error!(?err, "failed to parse json"); + got_channels = false; + + vec![] + } + }, + Err(err) => { + error!(?err, "failed to get channels"); + got_channels = false; + + vec![] + } + } + }; + + if got_channels && !channels.is_empty() { + HtmxBuilder::new(Tag::Empty).form(|b| { + b.attribute("class", "container") + .hx_post(&format!("{}/guild/{}/add_channel", state.origin, guild_id)) + .attribute("hx-encoding", "multipart/form-data") + .builder(Tag::FieldSet, |b| { + let mut b = b + .attribute("class", "container") + .attribute("style", "max-height: 50%; overflow-y: scroll"); + for channel_name in channels { + b = b.builder(Tag::Label, |b| { + b.builder(Tag::Input, |b| { + b.attribute("type", "checkbox") + .attribute("name", &channel_name.to_string()) + }) + .builder_text(Tag::Paragraph, &channel_name) + }); + } + + b + }) + .button(|b| b.attribute("type", "submit").text("Add Channel")) + }) + } else if channels.is_empty() { + HtmxBuilder::new(Tag::Empty) + } else { + HtmxBuilder::new(Tag::Empty).text("Failed to get channels") + } +} + async fn permissions_editor(state: &ApiState, guild_id: u64) -> HtmxBuilder { let db = state.db.lock().await; let user_permissions = db.get_all_user_permissions(guild_id).unwrap_or_default(); @@ -478,11 +511,6 @@ async fn permissions_editor(state: &ApiState, guild_id: u64) -> HtmxBuilder { }) } -async fn moderator_dashboard(state: &ApiState, guild_id: u64) -> HtmxBuilder { - let permissions_editor = permissions_editor(state, guild_id).await; - HtmxBuilder::new(Tag::Empty).push_builder(permissions_editor) -} - pub(crate) async fn login( State(state): State, user: Option, @@ -490,7 +518,7 @@ pub(crate) async fn login( 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); + let authorize_uri = format!("https://discord.com/api/oauth2/authorize?client_id={}&redirect_uri={}/v2/auth&response_type=code&scope=guilds.members.read+guilds+identify", state.secrets.client_id, state.origin); Ok(Html( HtmxBuilder::new(Tag::Html) diff --git a/src/routes.rs b/src/routes.rs index 04c46bb..6b9190a 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -94,6 +94,20 @@ pub(crate) struct DiscordUserGuild { pub owner: bool, } +#[derive(Deserialize)] +pub(crate) struct DiscordChannel { + pub name: Option, + #[serde(rename = "type")] + pub ty: u32, +} + +#[derive(Deserialize, PartialEq, Eq)] +#[repr(u32)] +pub(crate) enum ChannelType { + GuildText = 0, + GuildVoice = 2, +} + fn serde_string_as_u64<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -235,7 +249,7 @@ pub(crate) async fn v2_auth( let uri = Url::parse(&state.origin).expect("should be a valid url"); - let mut cookie = Cookie::new("access_token", token.clone()); + let mut cookie = Cookie::new("access_token", token); cookie.set_path(uri.path().to_string()); cookie.set_secure(true); @@ -471,6 +485,67 @@ pub(crate) async fn v2_add_guild_intro( Ok(headers) } +#[derive(Debug, Deserialize)] +pub(crate) struct GuildSetupParams { + name: String, +} + +pub(crate) async fn guild_setup( + State(state): State, + user: db::User, + Path(guild_id): Path, + Query(GuildSetupParams { name }): Query, +) -> Result { + let db = state.db.lock().await; + + let user_permissions = db.get_user_app_permissions(&user.name).unwrap_or_default(); + if !user_permissions.can(auth::AppPermission::AddGuild) { + return Err(Error::InvalidPermission); + } + + db.insert_guild(&guild_id, &name, 0)?; + db.insert_user_guild(&user.name, guild_id)?; + db.insert_user_permission( + &user.name, + guild_id, + auth::Permissions(auth::Permission::all()), + )?; + + Ok(Redirect::to(&format!( + "{}/guild/{}", + state.origin, guild_id + ))) +} + +pub(crate) async fn guild_add_channel( + State(state): State, + user: db::User, + Path(guild_id): Path, + mut form_data: Multipart, +) -> Result { + let db = state.db.lock().await; + + let user_permissions = db + .get_user_permissions(&user.name, guild_id) + .unwrap_or_default(); + if !user_permissions.can(auth::Permission::AddChannel) { + return Err(Error::InvalidPermission); + } + + while let Ok(Some(field)) = form_data.next_field().await { + let Some(channel_name) = field.name() else { + continue; + }; + + db.insert_guild_channel(&guild_id, channel_name)?; + } + + let mut headers = HeaderMap::new(); + headers.insert("HX-Refresh", HeaderValue::from_static("true")); + + Ok(headers) +} + pub(crate) async fn update_guild_permissions( State(state): State, Path(guild_id): Path, -- 2.40.1 From c07ac7ceac86280a7c60f711e4eb47c7020f780a Mon Sep 17 00:00:00 2001 From: Patrick Cleavelin Date: Sun, 9 Jun 2024 22:26:41 -0500 Subject: [PATCH 5/9] properly set user guilds/permissions with first time setup --- src/db/mod.rs | 13 +++++++++++ src/page.rs | 7 +----- src/routes.rs | 62 ++++++++++++++++++++++++++------------------------- 3 files changed, 46 insertions(+), 36 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index b4b9ec3..6affdb8 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -54,6 +54,7 @@ impl Database { // 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) + #[allow(clippy::useless_conversion)] let guilds = query .query_map([], |row| { Ok(Guild { @@ -68,6 +69,18 @@ impl Database { guilds } + pub(crate) fn get_user_count(&self) -> Result { + self.conn.query_row( + " + SELECT + COUNT(username) + FROM User + ", + [], + |row| row.get(0), + ) + } + pub(crate) fn get_user_from_api_key(&self, api_key: &str) -> Result { self.conn.query_row( " diff --git a/src/page.rs b/src/page.rs index c5efe60..403763b 100644 --- a/src/page.rs +++ b/src/page.rs @@ -46,12 +46,7 @@ pub(crate) async fn home( Redirect::to(&format!("{}/login", state.origin)) })?; tracing::info!("user name: {}", user.name); - let user_app_permissions = db.get_user_app_permissions(&user.name).map_err(|err| { - error!(?err, "failed to get user app permissions"); - // TODO: change this to returning a error to the client - Redirect::to(&format!("{}/error", state.origin)) - })?; - + let user_app_permissions = db.get_user_app_permissions(&user.name).unwrap_or_default(); let can_add_guild = user_app_permissions.can(auth::AppPermission::AddGuild); let client = reqwest::Client::new(); diff --git a/src/routes.rs b/src/routes.rs index 6b9190a..7773ac5 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -174,7 +174,7 @@ pub(crate) async fn v2_auth( .map_err(|err| Error::Auth(err.to_string()))?; let db = state.db.lock().await; - let needs_setup = db.get_guilds().map_err(Error::Database)?.is_empty(); + let needs_setup = db.get_user_count().map_err(Error::Database)? == 0; let token = if let Some(user) = db .get_user(&user.username) .map_err(Error::Database)? @@ -201,19 +201,21 @@ pub(crate) async fn v2_auth( auth::AppPermissions(auth::AppPermission::all()), ) .map_err(Error::Database)?; - } else { - let guilds = db.get_guilds().map_err(Error::Database)?; - let mut in_a_guild = false; - for guild in guilds { - let Some(discord_guild) = discord_guilds - .iter() - .find(|discord_guild| discord_guild.id == guild.id) - else { - continue; - }; + } - in_a_guild = true; + let guilds = db.get_guilds().map_err(Error::Database)?; + let mut in_a_guild = false; + for guild in guilds { + let Some(discord_guild) = discord_guilds + .iter() + .find(|discord_guild| discord_guild.id == guild.id) + else { + continue; + }; + in_a_guild = true; + + if !needs_setup { let now = Utc::now().naive_utc(); db.insert_user( &user.username, @@ -223,27 +225,27 @@ pub(crate) async fn v2_auth( now + Duration::seconds(auth.expires_in as i64), ) .map_err(Error::Database)?; - - db.insert_user_guild(&user.username, guild.id) - .map_err(Error::Database)?; - - if db.get_user_permissions(&user.username, guild.id).is_err() { - db.insert_user_permission( - &user.username, - guild.id, - if discord_guild.owner { - auth::Permissions(auth::Permission::all()) - } else { - Default::default() - }, - ) - .map_err(Error::Database)?; - } } - if !in_a_guild { - return Err(Error::NoGuildFound); + + db.insert_user_guild(&user.username, guild.id) + .map_err(Error::Database)?; + + if db.get_user_permissions(&user.username, guild.id).is_err() { + db.insert_user_permission( + &user.username, + guild.id, + if discord_guild.owner { + auth::Permissions(auth::Permission::all()) + } else { + Default::default() + }, + ) + .map_err(Error::Database)?; } } + if !in_a_guild { + return Err(Error::NoGuildFound); + } // TODO: add permissions based on roles -- 2.40.1 From b02d3f3da7049dc6ddd407a570de3d10d50dbb73 Mon Sep 17 00:00:00 2001 From: Patrick Cleavelin Date: Sun, 9 Jun 2024 22:41:33 -0500 Subject: [PATCH 6/9] fix warnings + upgrade toolchain --- Cargo.toml | 3 +++ flake.lock | 36 ++++++++++++++++++++++-------- rust-toolchain | 2 +- src/auth.rs | 58 ++++++++++++++++++++++++------------------------- src/db/mod.rs | 8 +++++-- src/htmx.rs | 14 +++++++----- src/main.rs | 8 +++---- src/page.rs | 12 +++++----- src/settings.rs | 2 +- 9 files changed, 84 insertions(+), 59 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1e8856e..4ca4cb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,3 +38,6 @@ rusqlite = { version = "0.29.0", features = ["chrono"] } [target.'cfg(windows)'.dependencies] rusqlite = { version = "0.29.0", features = ["bundled", "chrono"] } + +[lints.clippy] +map_flatten = "allow" diff --git a/flake.lock b/flake.lock index d3176d0..697ca3d 100644 --- a/flake.lock +++ b/flake.lock @@ -16,12 +16,15 @@ } }, "flake-utils_2": { + "inputs": { + "systems": "systems" + }, "locked": { - "lastModified": 1659877975, - "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=", + "lastModified": 1705309234, + "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", "owner": "numtide", "repo": "flake-utils", - "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0", + "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", "type": "github" }, "original": { @@ -48,11 +51,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1665296151, - "narHash": "sha256-uOB0oxqxN9K7XGF1hcnY+PQnlQJ+3bP2vCn/+Ru/bbc=", + "lastModified": 1706487304, + "narHash": "sha256-LE8lVX28MV2jWJsidW13D2qrHU/RUUONendL2Q/WlJg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "14ccaaedd95a488dd7ae142757884d8e125b3363", + "rev": "90f456026d284c22b3e3497be980b2e47d0b28ac", "type": "github" }, "original": { @@ -75,11 +78,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1676169013, - "narHash": "sha256-mhUWa6TUg6Qjba1OdxPuW1ctCuU4O4lSObVc6UUUE0E=", + "lastModified": 1717985971, + "narHash": "sha256-24h/qKp0aeI+Ew13WdRF521kY24PYa5HOvw0mlrABjk=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "ef4cd733dc6b595cab5092f5004a489c5fd80b07", + "rev": "abfe5b3126b1b7e9e4daafc1c6478d17f0b584e7", "type": "github" }, "original": { @@ -87,6 +90,21 @@ "repo": "rust-overlay", "type": "github" } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", diff --git a/rust-toolchain b/rust-toolchain index bf867e0..2bf5ad0 100644 --- a/rust-toolchain +++ b/rust-toolchain @@ -1 +1 @@ -nightly +stable diff --git a/src/auth.rs b/src/auth.rs index 4a84c45..b10ae50 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -27,19 +27,16 @@ pub(crate) struct User { pub(crate) name: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Default, Debug, Clone, Serialize, Deserialize)] pub(crate) struct AppPermissions(pub(crate) u8); -impl Default for AppPermissions { - fn default() -> AppPermissions { - AppPermissions(0) - } -} impl AppPermissions { pub(crate) fn can(&self, perm: AppPermission) -> bool { (self.0 & (perm as u8) > 0) || (self.0 & (AppPermission::Admin as u8) > 0) } + // FIXME: eventually use this + #[allow(dead_code)] pub(crate) fn add(&mut self, perm: Permission) { self.0 |= perm as u8; } @@ -59,13 +56,17 @@ impl AppPermission { } } -impl ToString for AppPermission { - fn to_string(&self) -> String { - match self { - AppPermission::None => todo!(), - AppPermission::AddGuild => "Add Guild".to_string(), - AppPermission::Admin => "Admin".to_string(), - } +impl std::fmt::Display for AppPermission { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + AppPermission::None => todo!(), + AppPermission::AddGuild => "Add Guild".to_string(), + AppPermission::Admin => "Admin".to_string(), + } + ) } } @@ -81,13 +82,8 @@ impl FromStr for AppPermission { } } -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize)] pub(crate) struct Permissions(pub(crate) u8); -impl Default for Permissions { - fn default() -> Permissions { - Permissions(0) - } -} impl Permissions { pub(crate) fn can(&self, perm: Permission) -> bool { @@ -116,16 +112,20 @@ impl Permission { } } -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::AddChannel => "Add Channel".to_string(), - Permission::Moderator => "Moderator".to_string(), - } +impl std::fmt::Display for Permission { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Permission::None => todo!(), + Permission::UploadSounds => "Upload Sounds".to_string(), + Permission::DeleteSounds => "Delete Sounds".to_string(), + Permission::Soundboard => "Soundboard".to_string(), + Permission::AddChannel => "Add Channel".to_string(), + Permission::Moderator => "Moderator".to_string(), + }, + ) } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 6affdb8..bbb5fa5 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -138,6 +138,7 @@ impl Database { // 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) + #[allow(clippy::useless_conversion)] let guilds = query .query_map(&[(":username", username)], |row| { Ok(Guild { @@ -167,6 +168,7 @@ impl Database { // 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) + #[allow(clippy::useless_conversion)] let intros = query .query_map( &[ @@ -206,6 +208,7 @@ impl Database { // 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) + #[allow(clippy::useless_conversion)] let intros = query .query_map( &[ @@ -305,13 +308,14 @@ impl Database { // 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) + #[allow(clippy::useless_conversion)] let intros = query .query_map( &[ // :vomit: (":guild_id", &guild_id.to_string()), ], - |row| Ok(row.get(0)?), + |row| row.get(0), )? .into_iter() .collect::>>(); @@ -328,7 +332,7 @@ impl Database { let all_user_intros = self.get_all_user_intros(guild_id)?.into_iter(); let intros = all_user_intros - .filter(|intro| &intro.username == &username && &intro.channel_name == channel_name) + .filter(|intro| intro.username == username && intro.channel_name == channel_name) .map(|intro| intro.intro) .collect(); diff --git a/src/htmx.rs b/src/htmx.rs index 751f4e0..39a67c1 100644 --- a/src/htmx.rs +++ b/src/htmx.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use std::collections::HashMap; pub trait Build { @@ -191,7 +193,7 @@ impl Build for HtmxBuilder { } } if self.tag != Tag::JustText && self.tag != Tag::Empty { - string.push_str(">"); + string.push('>'); } } @@ -229,23 +231,23 @@ impl HtmxBuilder { self } - pub fn hx_get(mut self, uri: &str) -> Self { + pub fn hx_get(self, uri: &str) -> Self { self.attribute("hx-get", uri) } - pub fn hx_post(mut self, uri: &str) -> Self { + pub fn hx_post(self, uri: &str) -> Self { self.attribute("hx-post", uri) } - pub fn hx_swap(mut self, swap_method: SwapMethod) -> Self { + pub fn hx_swap(self, swap_method: SwapMethod) -> Self { self.attribute("hx-swap", swap_method.as_str()) } - pub fn hx_trigger(mut self, trigger: &str) -> Self { + pub fn hx_trigger(self, trigger: &str) -> Self { self.attribute("hx-trigger", trigger) } - pub fn hx_target(mut self, target: &str) -> Self { + pub fn hx_target(self, target: &str) -> Self { self.attribute("hx-target", target) } diff --git a/src/main.rs b/src/main.rs index baee7a7..4ac9783 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ -#![feature(stmt_expr_attributes)] -#![feature(proc_macro_hygiene)] -#![feature(async_closure)] +// #![feature(stmt_expr_attributes)] +// #![feature(proc_macro_hygiene)] +// #![feature(async_closure)] mod auth; mod db; @@ -315,7 +315,7 @@ async fn main() -> std::io::Result<()> { tracing_subscriber::fmt::init(); - let mut settings = serde_json::from_str::( + let settings = serde_json::from_str::( &std::fs::read_to_string("config/settings.json").expect("no config/settings.json"), ) .expect("error parsing settings file"); diff --git a/src/page.rs b/src/page.rs index 403763b..87c1cde 100644 --- a/src/page.rs +++ b/src/page.rs @@ -1,15 +1,14 @@ use crate::{ - auth::{self}, + auth, db::{self, User}, htmx::{Build, HtmxBuilder, Tag}, settings::ApiState, }; use axum::{ - extract::{Path, Query, State}, + extract::{Path, State}, response::{Html, Redirect}, }; use iter_tools::Itertools; -use serde::Deserialize; use tracing::error; fn page_header(title: &str) -> HtmxBuilder { @@ -478,10 +477,9 @@ async fn permissions_editor(state: &ApiState, guild_id: u64) -> HtmxBuilder { 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()), - ); + let mut b = b + .attribute("type", "checkbox") + .attribute("name", &format!("{}#{}", permission.0, perm)); if permission.1.can(auth::Permission::Moderator) { b = b.flag("disabled"); diff --git a/src/settings.rs b/src/settings.rs index bbb040a..c5b23c0 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -27,7 +27,7 @@ impl FromRequestParts for db::User { Parts { headers, .. }: &mut Parts, state: &ApiState, ) -> Result { - let jar = CookieJar::from_headers(&headers); + let jar = CookieJar::from_headers(headers); if let Some(token) = jar.get("access_token") { match state.db.lock().await.get_user_from_api_key(token.value()) { -- 2.40.1 From d8d9fb578c990a754f02f5ba3265c92962c265f2 Mon Sep 17 00:00:00 2001 From: Patrick Cleavelin Date: Sun, 9 Jun 2024 23:05:15 -0500 Subject: [PATCH 7/9] rearrange intro selector to be side-by-side --- src/page.rs | 48 +++++++++++++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/src/page.rs b/src/page.rs index 87c1cde..ab8081b 100644 --- a/src/page.rs +++ b/src/page.rs @@ -44,7 +44,6 @@ pub(crate) async fn home( // TODO: change this to returning a error to the client Redirect::to(&format!("{}/login", state.origin)) })?; - tracing::info!("user name: {}", user.name); let user_app_permissions = db.get_user_app_permissions(&user.name).unwrap_or_default(); let can_add_guild = user_app_permissions.can(auth::AppPermission::AddGuild); @@ -155,7 +154,7 @@ fn intro_list<'a>( .builder(Tag::FieldSet, |b| { let mut b = b .attribute("class", "container") - .attribute("style", "max-height: 50%; overflow-y: scroll"); + .attribute("style", "height: 256px; overflow: auto"); for intro in intros { b = b.builder(Tag::Label, |b| { b.builder(Tag::Input, |b| { @@ -265,24 +264,29 @@ pub(crate) async fn guild_dashboard( let mut user_intros = user_intros.into_iter().peekable(); - for guild_channel_name in guild_channels { + for guild_channel_name in &guild_channels { // Get user intros for this channel let intros = user_intros .peeking_take_while(|(channel_name, _)| { - channel_name == &&guild_channel_name + channel_name == &guild_channel_name }) .map(|(_, intros)| intros.map(|intro| &intro.intro)) .flatten(); - b = b.builder(Tag::Article, |b| { - b.builder_text(Tag::Header, &guild_channel_name).builder( + b = b.builder(Tag::Details, |b| { + let mut b = b; + if guild_channels.len() < 2 { + b = b.attribute("open", ""); + } + b.builder_text(Tag::Summary, guild_channel_name).builder( Tag::Div, |b| { b.attribute("id", "channel-intro-selector") + .attribute("style", "display: flex; align-items: flex-end; max-height: 50%; overflow: hidden;") .push_builder(channel_intro_selector( &state.origin, guild_id, - &guild_channel_name, + guild_channel_name, intros, guild_intros.iter(), )) @@ -307,18 +311,24 @@ pub fn channel_intro_selector<'a>( guild_intros: impl Iterator, ) -> HtmxBuilder { HtmxBuilder::new(Tag::Empty) - .builder_text(Tag::Strong, "Your Current Intros") - .push_builder(intro_list( - intros, - "Remove Intro", - &format!("{}/v2/intros/remove/{}/{}", origin, guild_id, &channel_name), - )) - .builder_text(Tag::Strong, "Select Intros") - .push_builder(intro_list( - guild_intros, - "Add Intro", - &format!("{}/v2/intros/add/{}/{}", origin, guild_id, channel_name), - )) + .builder(Tag::Div, |b| { + b.attribute("style", "display: flex; flex-direction: column; justify-content: space-between; align-items: center; width: 100%; height: 100%; padding: 16px;") + .builder_text(Tag::Strong, "Your Current Intros") + .push_builder(intro_list( + intros, + "Remove Intro", + &format!("{}/v2/intros/remove/{}/{}", origin, guild_id, &channel_name), + )) + }) + .builder(Tag::Div, |b| { + b.attribute("style", "display: flex; flex-direction: column; justify-content: space-between; align-items: center; width: 100%; height: 100%; padding: 16px;") + .builder_text(Tag::Strong, "Select Intros") + .push_builder(intro_list( + guild_intros, + "Add Intro", + &format!("{}/v2/intros/add/{}/{}", origin, guild_id, channel_name), + )) + }) } fn upload_form(origin: &str, guild_id: u64) -> HtmxBuilder { -- 2.40.1 From 13ac538ca286e262df5941dd476389271c562d92 Mon Sep 17 00:00:00 2001 From: Patrick Cleavelin Date: Mon, 10 Jun 2024 17:25:09 -0500 Subject: [PATCH 8/9] collapse individual server settings --- src/db/mod.rs | 16 ++++ src/page.rs | 205 ++++++++++++++++++++++++++++---------------------- 2 files changed, 129 insertions(+), 92 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index bbb5fa5..f18a856 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -43,6 +43,22 @@ impl Database { Ok(users) } + pub fn get_guild(&self, guild_id: u64) -> Result { + let mut query = self.conn.prepare( + " + SELECT + Guild.name + FROM Guild + WHERE Guild.id = :guild_id + ", + )?; + + let guild_name = + query.query_row(&[(":guild_id", &guild_id.to_string())], |row| row.get(0))?; + + Ok(guild_name) + } + pub(crate) fn get_guilds(&self) -> Result> { let mut query = self.conn.prepare( " diff --git a/src/page.rs b/src/page.rs index ab8081b..3854a92 100644 --- a/src/page.rs +++ b/src/page.rs @@ -176,9 +176,15 @@ pub(crate) async fn guild_dashboard( user: User, Path(guild_id): Path, ) -> Result, Redirect> { - let (guild_intros, guild_channels, all_user_intros, user_permissions) = { + let (guild_name, guild_intros, guild_channels, all_user_intros, user_permissions) = { let db = state.db.lock().await; + let guild_name = db.get_guild(guild_id).map_err(|err| { + error!(?err, %guild_id, "couldn't get guild"); + // TODO: change to actual error + Redirect::to(&format!("{}/login", state.origin)) + })?; + 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 @@ -199,6 +205,7 @@ pub(crate) async fn guild_dashboard( .unwrap_or_default(); ( + guild_name, guild_intros, guild_channels, all_user_intros, @@ -207,8 +214,10 @@ pub(crate) async fn guild_dashboard( }; let can_upload = user_permissions.can(auth::Permission::UploadSounds); + let can_add_channel = user_permissions.can(auth::Permission::AddChannel); let is_moderator = user_permissions.can(auth::Permission::Moderator); - let mod_dashboard = moderator_dashboard(&state, &state.secrets.bot_token, guild_id).await; + let mod_dashboard = + moderator_dashboard(&state, &state.secrets.bot_token, guild_id, user_permissions).await; let user_intros = all_user_intros .iter() @@ -222,17 +231,16 @@ pub(crate) async fn guild_dashboard( b.builder(Tag::HeaderGroup, |b| { b.attribute("class", "container") .builder(Tag::Header1, |b| b.text("MemeJoin - A bot for user intros")) - .builder_text(Tag::Header6, &user.name) + .builder_text(Tag::Header6, &format!("{} - {}", user.name, guild_name)) }) }) .builder(Tag::Empty, |b| { - let mut b = if is_moderator { + let mut b = if is_moderator || can_add_channel { b.builder(Tag::Div, |b| { b.attribute("class", "container") .builder(Tag::Article, |b| { - b.builder_text(Tag::Header, "Wow, you're a moderator") + b.builder_text(Tag::Header, "Server Settings") .push_builder(mod_dashboard) - .builder_text(Tag::Footer, "End of super cool mod section") }) }) } else { @@ -338,16 +346,14 @@ fn upload_form(origin: &str, guild_id: u64) -> HtmxBuilder { .attribute("hx-encoding", "multipart/form-data") .builder(Tag::FieldSet, |b| { b.attribute("class", "container") + .attribute("role", "group") + .input(|b| b.attribute("type", "file").attribute("name", "file")) .input(|b| { b.attribute("name", "name") .attribute("placeholder", "enter intro title") }) - .label(|b| { - b.text("Choose File") - .input(|b| b.attribute("type", "file").attribute("name", "file")) - }) + .button(|b| b.attribute("type", "submit").text("Upload")) }) - .button(|b| b.attribute("type", "submit").text("Upload")) }) } @@ -357,29 +363,39 @@ fn ytdl_form(origin: &str, guild_id: u64) -> HtmxBuilder { .hx_get(&format!("{}/v2/intros/{}/add", origin, guild_id)) .builder(Tag::FieldSet, |b| { b.attribute("class", "container") - .label(|b| { - b.text("Video Url").input(|b| { - b.attribute("placeholder", "enter video url") - .attribute("name", "url") - }) + .attribute("role", "group") + .input(|b| { + b.attribute("placeholder", "enter video url") + .attribute("name", "url") }) - .label(|b| { - b.text("Intro Title").input(|b| { - b.attribute("placeholder", "enter intro title") - .attribute("name", "name") - }) + .input(|b| { + b.attribute("placeholder", "enter intro title") + .attribute("name", "name") }) + .button(|b| b.attribute("type", "submit").text("Upload")) }) - .button(|b| b.attribute("type", "submit").text("Upload")) }) } -async fn moderator_dashboard(state: &ApiState, bot_token: &str, guild_id: u64) -> HtmxBuilder { +async fn moderator_dashboard( + state: &ApiState, + bot_token: &str, + guild_id: u64, + user_permissions: auth::Permissions, +) -> HtmxBuilder { let permissions_editor = permissions_editor(state, guild_id).await; let channel_editor = channel_editor(state, bot_token, guild_id).await; - HtmxBuilder::new(Tag::Empty) - .push_builder(permissions_editor) - .push_builder(channel_editor) + + let mut b = HtmxBuilder::new(Tag::Empty); + + if user_permissions.can(auth::Permission::Moderator) { + b = b.push_builder(permissions_editor); + } + if user_permissions.can(auth::Permission::AddChannel) { + b = b.push_builder(channel_editor); + } + + b } async fn channel_editor(state: &ApiState, bot_token: &str, guild_id: u64) -> HtmxBuilder { @@ -422,28 +438,30 @@ async fn channel_editor(state: &ApiState, bot_token: &str, guild_id: u64) -> Htm }; if got_channels && !channels.is_empty() { - HtmxBuilder::new(Tag::Empty).form(|b| { - b.attribute("class", "container") - .hx_post(&format!("{}/guild/{}/add_channel", state.origin, guild_id)) - .attribute("hx-encoding", "multipart/form-data") - .builder(Tag::FieldSet, |b| { - let mut b = b - .attribute("class", "container") - .attribute("style", "max-height: 50%; overflow-y: scroll"); - for channel_name in channels { - b = b.builder(Tag::Label, |b| { - b.builder(Tag::Input, |b| { - b.attribute("type", "checkbox") - .attribute("name", &channel_name.to_string()) - }) - .builder_text(Tag::Paragraph, &channel_name) - }); - } + HtmxBuilder::new(Tag::Details) + .builder_text(Tag::Summary, "Add Channels") + .form(|b| { + b.attribute("class", "container") + .hx_post(&format!("{}/guild/{}/add_channel", state.origin, guild_id)) + .attribute("hx-encoding", "multipart/form-data") + .builder(Tag::FieldSet, |b| { + let mut b = b + .attribute("class", "container") + .attribute("style", "max-height: 50%; overflow-y: scroll"); + for channel_name in channels { + b = b.builder(Tag::Label, |b| { + b.builder(Tag::Input, |b| { + b.attribute("type", "checkbox") + .attribute("name", &channel_name.to_string()) + }) + .builder_text(Tag::Paragraph, &channel_name) + }); + } - b - }) - .button(|b| b.attribute("type", "submit").text("Add Channel")) - }) + b + }) + .button(|b| b.attribute("type", "submit").text("Add Channel")) + }) } else if channels.is_empty() { HtmxBuilder::new(Tag::Empty) } else { @@ -455,63 +473,66 @@ async fn permissions_editor(state: &ApiState, guild_id: u64) -> HtmxBuilder { let db = state.db.lock().await; let user_permissions = db.get_all_user_permissions(guild_id).unwrap_or_default(); - HtmxBuilder::new(Tag::Empty).form(|b| { - b.hx_post(&format!( - "{}/guild/{}/permissions/update", - state.origin, guild_id - )) - .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()); + HtmxBuilder::new(Tag::Details) + .builder_text(Tag::Summary, "Permissions") + .form(|b| { + b.hx_post(&format!( + "{}/guild/{}/permissions/update", + state.origin, guild_id + )) + .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(Tag::TableData, |b| { - b.builder(Tag::Input, |b| { - let mut b = b - .attribute("type", "checkbox") - .attribute("name", &format!("{}#{}", permission.0, perm)); - - if permission.1.can(auth::Permission::Moderator) { - b = b.flag("disabled"); - } - - if permission.1.can(perm) { - return b.flag("checked"); - } - - b - }) - }); + b = b.builder_text(Tag::TableHeader, &perm.to_string()); } b }); - } - 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)); + + 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")) }) - .button(|b| b.attribute("type", "submit").text("Update Permissions")) - }) } pub(crate) async fn login( -- 2.40.1 From 633b2922997bf5a90fc208ab128202ee4ce84a0d Mon Sep 17 00:00:00 2001 From: Patrick Cleavelin Date: Mon, 10 Jun 2024 18:07:32 -0500 Subject: [PATCH 9/9] bump version --- .woodpecker.yml | 3 ++- Cargo.lock | 2 +- Cargo.toml | 2 +- flake.lock | 32 +++++++++++++++++++++++++------- flake.nix | 11 ++++++++--- 5 files changed, 37 insertions(+), 13 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 7114908..9dc5132 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -4,9 +4,10 @@ steps: event: [push, tag] image: alpine:edge commands: + - apk update && apk upgrade - apk add --no-cache git nix --repository=https://dl-cdn.alpinelinux.org/alpine/edge/testing - echo "system-features = nixos-test benchmark big-parallel uid-range kvm" > /etc/nix/nix.conf - - nix build --extra-experimental-features nix-command --extra-experimental-features flakes .#docker + - nix build --extra-experimental-features nix-command --extra-experimental-features flakes --max-jobs 16 .#docker - cp $(nix build --extra-experimental-features nix-command --extra-experimental-features flakes --print-out-paths .#docker) ./memejoin-rs.tar.gz volumes: - ${AGENT_NIX_STORE_PATH}:/nix diff --git a/Cargo.lock b/Cargo.lock index 3493bc7..12006c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1110,7 +1110,7 @@ checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" [[package]] name = "memejoin-rs" -version = "0.2.1-alpha" +version = "0.2.2-alpha" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 4ca4cb6..a34d895 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "memejoin-rs" -version = "0.2.1-alpha" +version = "0.2.2-alpha" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/flake.lock b/flake.lock index 697ca3d..a420bc1 100644 --- a/flake.lock +++ b/flake.lock @@ -1,12 +1,15 @@ { "nodes": { "flake-utils": { + "inputs": { + "systems": "systems" + }, "locked": { - "lastModified": 1667395993, - "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", "owner": "numtide", "repo": "flake-utils", - "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", "type": "github" }, "original": { @@ -17,7 +20,7 @@ }, "flake-utils_2": { "inputs": { - "systems": "systems" + "systems": "systems_2" }, "locked": { "lastModified": 1705309234, @@ -35,11 +38,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1675942811, - "narHash": "sha256-/v4Z9mJmADTpXrdIlAjFa1e+gkpIIROR670UVDQFwIw=", + "lastModified": 1717786204, + "narHash": "sha256-4q0s6m0GUcN7q+Y2DqD27iLvbcd1G50T2lv08kKxkSI=", "owner": "nixos", "repo": "nixpkgs", - "rev": "724bfc0892363087709bd3a5a1666296759154b1", + "rev": "051f920625ab5aabe37c920346e3e69d7d34400e", "type": "github" }, "original": { @@ -105,6 +108,21 @@ "repo": "default", "type": "github" } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 899f911..a67982e 100644 --- a/flake.nix +++ b/flake.nix @@ -8,19 +8,19 @@ outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system: let - tag = "v0.2.1-alpha"; + tag = "v0.2.2-alpha"; overlays = [ (import rust-overlay) ]; pkgs = import nixpkgs { inherit system overlays; }; yt-dlp = pkgs.yt-dlp.overrideAttrs (oldAttr: rec { inherit (oldAttr) name; - version = "2023.02.17"; + version = "2024.05.27"; src = pkgs.fetchFromGitHub { owner = "yt-dlp"; repo = "yt-dlp"; rev = "${version}"; - sha256 = "naC74T6aqCLX45wJLmygsMmTMqdqLbfXLjJKIKMRpiI="; + sha256 = "55zDAMwCJPn5zKrAFw4ogTxxmvjrv4PvhYO7PsHbRo4="; }; }); local-rust = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain).override { @@ -56,6 +56,11 @@ cargoLock = { lockFile = ./Cargo.lock; }; + + # lol, why does `buildRustPackage` not work without this? + postPatch = '' + ln -sf ${./Cargo.lock} Cargo.lock + ''; }; docker = dockerTools.buildImage { -- 2.40.1