diff --git a/.gitignore b/.gitignore index aaf15c0..8085ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ /target -**/result -result/ -result +/config +/sounds +/.idea +.DS_Store + .env 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 1e8856e..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 @@ -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..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": { @@ -16,12 +19,15 @@ } }, "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, "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": { @@ -32,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": { @@ -48,11 +54,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 +81,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 +93,36 @@ "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" + } + }, + "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 bc64af4..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 { @@ -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 { @@ -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 { 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 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 f5ec4cb..b10ae50 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)] @@ -26,14 +27,64 @@ pub(crate) struct User { pub(crate) name: String, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -pub(crate) struct Permissions(pub(crate) u8); -impl Default for Permissions { - fn default() -> Permissions { - Permissions(0) +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub(crate) struct AppPermissions(pub(crate) u8); + +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; } } +#[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 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(), + } + ) + } +} + +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(Default, Debug, Clone, Copy, Serialize, Deserialize)] +pub(crate) struct Permissions(pub(crate) u8); + impl Permissions { pub(crate) fn can(&self, perm: Permission) -> bool { (self.0 & (perm as u8) > 0) || (self.0 & (Permission::Moderator as u8) > 0) @@ -51,6 +102,7 @@ pub(crate) enum Permission { UploadSounds = 1, DeleteSounds = 2, Soundboard = 4, + AddChannel = 8, Moderator = 128, } @@ -60,15 +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::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(), + }, + ) } } @@ -80,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 2c71056..f18a856 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( " @@ -37,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( " @@ -48,6 +70,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 { @@ -62,6 +85,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( " @@ -119,6 +154,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 { @@ -148,6 +184,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( &[ @@ -187,6 +224,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( &[ @@ -258,6 +296,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( " @@ -272,13 +324,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::>>(); @@ -295,13 +348,47 @@ 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(); 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_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, @@ -315,7 +402,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 +429,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 +442,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 +461,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 +488,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 +498,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 +529,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/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 e2fc768..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; @@ -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,6 +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(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), @@ -320,6 +326,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..3854a92 100644 --- a/src/page.rs +++ b/src/page.rs @@ -1,5 +1,5 @@ use crate::{ - auth::{self}, + auth, db::{self, User}, htmx::{Build, HtmxBuilder, Tag}, settings::ApiState, @@ -20,7 +20,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 +31,80 @@ 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)) })?; + 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(); + 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 +113,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 }) } @@ -82,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| { @@ -104,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 @@ -127,6 +205,7 @@ pub(crate) async fn guild_dashboard( .unwrap_or_default(); ( + guild_name, guild_intros, guild_channels, all_user_intros, @@ -135,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, 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() @@ -150,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 { @@ -192,24 +272,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(), )) @@ -234,18 +319,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 { @@ -255,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")) }) } @@ -274,90 +363,176 @@ 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, + 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; + + 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 { + 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::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")) + }) + } 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(); - 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.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.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")) - }) -} - -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( @@ -367,13 +542,24 @@ 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( - 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..7773ac5 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -87,12 +87,27 @@ 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, } +#[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>, @@ -138,7 +153,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 @@ -160,19 +174,18 @@ 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_user_count().map_err(Error::Database)? == 0; + 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() + }; - 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,6 +196,37 @@ pub(crate) async fn v2_auth( ) .map_err(Error::Database)?; + db.insert_user_app_permission( + &user.username, + auth::AppPermissions(auth::AppPermission::all()), + ) + .map_err(Error::Database)?; + } + + 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, + &token, + now + Duration::weeks(4), + &auth.access_token, + now + Duration::seconds(auth.expires_in as i64), + ) + .map_err(Error::Database)?; + } + db.insert_user_guild(&user.username, guild.id) .map_err(Error::Database)?; @@ -199,7 +243,6 @@ pub(crate) async fn v2_auth( .map_err(Error::Database)?; } } - if !in_a_guild { return Err(Error::NoGuildFound); } @@ -208,7 +251,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); @@ -444,6 +487,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, 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()) {