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