From c4d12562a104f8334729e61f403a9f242acc66a8 Mon Sep 17 00:00:00 2001 From: Patrick Cleavelin Date: Wed, 8 Oct 2025 12:38:11 -0500 Subject: [PATCH] baseline stuff just for displaying intros --- src/lib/domain/intro_tool/debug_service.rs | 125 ++++++++ src/lib/domain/intro_tool/mod.rs | 1 + src/lib/domain/intro_tool/models/guild.rs | 262 ++++++++++++++++- src/lib/domain/intro_tool/ports.rs | 109 +++++-- src/lib/domain/intro_tool/service.rs | 49 +++- src/lib/inbound.rs | 2 + src/lib/inbound/http.rs | 146 ++++++++++ src/lib/inbound/http/page.rs | 292 +++++++++++++++++++ src/lib/inbound/mod.rs | 0 src/lib/inbound/response.rs | 65 +++++ src/lib/outbound/sqlite.rs | 322 ++++++++++++++++++++- src/main.rs | 29 +- 12 files changed, 1364 insertions(+), 38 deletions(-) create mode 100644 src/lib/domain/intro_tool/debug_service.rs create mode 100644 src/lib/inbound.rs create mode 100644 src/lib/inbound/http.rs create mode 100644 src/lib/inbound/http/page.rs delete mode 100644 src/lib/inbound/mod.rs create mode 100644 src/lib/inbound/response.rs diff --git a/src/lib/domain/intro_tool/debug_service.rs b/src/lib/domain/intro_tool/debug_service.rs new file mode 100644 index 0000000..42b15ca --- /dev/null +++ b/src/lib/domain/intro_tool/debug_service.rs @@ -0,0 +1,125 @@ +use chrono::{Duration, Utc}; + +use crate::lib::domain::intro_tool::{ + models, + ports::{IntroToolRepository, IntroToolService}, +}; + +#[derive(Clone)] +pub struct DebugService +where + S: IntroToolService, +{ + impersonated_username: String, + wrapped_service: S, +} + +impl DebugService +where + S: IntroToolService, +{ + pub fn new(wrapped_service: S, impersonated_username: String) -> Self { + Self { + wrapped_service, + impersonated_username, + } + } +} + +impl IntroToolService for DebugService +where + S: IntroToolService, +{ + async fn needs_setup(&self) -> bool { + self.wrapped_service.needs_setup().await + } + + async fn get_guild( + &self, + guild_id: impl Into + Send, + ) -> Result { + self.wrapped_service.get_guild(guild_id).await + } + + async fn get_guild_users( + &self, + guild_id: models::guild::GuildId, + ) -> Result, models::guild::GetUserError> { + self.wrapped_service.get_guild_users(guild_id).await + } + + async fn get_guild_intros( + &self, + guild_id: models::guild::GuildId, + ) -> Result, models::guild::GetIntroError> { + self.wrapped_service.get_guild_intros(guild_id).await + } + + async fn get_user( + &self, + username: impl AsRef + Send, + ) -> Result { + self.wrapped_service.get_user(username).await + } + + async fn get_user_guilds( + &self, + username: impl AsRef + Send, + ) -> Result, models::guild::GetGuildError> { + self.wrapped_service.get_user_guilds(username).await + } + + async fn get_user_from_api_key( + &self, + _api_key: &str, + ) -> Result { + let user = self + .wrapped_service + .get_user(&self.impersonated_username) + .await?; + + Ok(models::guild::User::new( + self.impersonated_username.clone(), + "testApiKey".into(), + Utc::now().naive_utc() + Duration::days(1), + "testDiscordToken".into(), + Utc::now().naive_utc() + Duration::days(1), + ) + .with_channel_intros(user.intros().clone())) + } + + async fn create_guild( + &self, + req: models::guild::CreateGuildRequest, + ) -> Result { + self.wrapped_service.create_guild(req).await + } + + async fn create_user( + &self, + req: models::guild::CreateUserRequest, + ) -> Result { + self.wrapped_service.create_user(req).await + } + + async fn create_channel( + &self, + req: models::guild::CreateChannelRequest, + ) -> Result { + self.wrapped_service.create_channel(req).await + } + + async fn add_intro_to_guild( + &self, + req: models::guild::AddIntroToGuildRequest, + ) -> Result<(), models::guild::AddIntroToGuildError> { + self.wrapped_service.add_intro_to_guild(req).await + } + + async fn add_intro_to_user( + &self, + req: models::guild::AddIntroToUserRequest, + ) -> Result<(), models::guild::AddIntroToUserError> { + self.wrapped_service.add_intro_to_user(req).await + } +} diff --git a/src/lib/domain/intro_tool/mod.rs b/src/lib/domain/intro_tool/mod.rs index 901e625..b7b7d1f 100644 --- a/src/lib/domain/intro_tool/mod.rs +++ b/src/lib/domain/intro_tool/mod.rs @@ -1,3 +1,4 @@ +pub mod debug_service; pub mod models; pub mod ports; pub mod service; diff --git a/src/lib/domain/intro_tool/models/guild.rs b/src/lib/domain/intro_tool/models/guild.rs index 19ef931..79360a1 100644 --- a/src/lib/domain/intro_tool/models/guild.rs +++ b/src/lib/domain/intro_tool/models/guild.rs @@ -1,33 +1,226 @@ use std::collections::HashMap; +use chrono::NaiveDateTime; use thiserror::Error; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct GuildId(u64); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ExternalGuildId(u64); + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct UserName(String); + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ChannelName(String); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct IntroId(i32); -pub struct Guild { - id: GuildId, +impl From for GuildId { + fn from(id: u64) -> Self { + Self(id) + } +} - name: String, - sound_delay: u32, - external_id: ExternalGuildId, +impl From for ExternalGuildId { + fn from(id: u64) -> Self { + Self(id) + } +} + +impl From for IntroId { + fn from(id: i32) -> Self { + Self(id) + } +} + +impl From for UserName { + fn from(name: String) -> Self { + Self(name) + } +} + +impl From for ChannelName { + fn from(name: String) -> Self { + Self(name) + } +} + +impl AsRef for UserName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl AsRef for ChannelName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for GuildId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::fmt::Display for IntroId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug)] +pub struct Guild { + guild: GuildRef, channels: Vec, users: Vec, } -pub struct User { - user: UserName, - channel_intros: HashMap>, +#[derive(Debug)] +pub struct GuildRef { + id: GuildId, + name: String, + sound_delay: u32, + external_id: ExternalGuildId, } +impl GuildRef { + pub fn id(&self) -> GuildId { + self.id + } + + pub fn name(&self) -> &str { + &self.name + } +} + +impl GuildRef { + pub fn new(id: GuildId, name: String, sound_delay: u32, external_id: ExternalGuildId) -> Self { + Self { + id, + name, + sound_delay, + external_id, + } + } +} + +impl Guild { + pub fn new(id: GuildId, name: String, sound_delay: u32, external_id: ExternalGuildId) -> Self { + Self { + guild: GuildRef { + id, + name, + sound_delay, + external_id, + }, + channels: vec![], + users: vec![], + } + } + + pub fn id(&self) -> GuildId { + self.guild.id() + } + + pub fn name(&self) -> &str { + self.guild.name() + } + + pub fn users(&self) -> &[User] { + &self.users + } + + pub fn channels(&self) -> &[Channel] { + &self.channels + } + + pub fn with_users(self, users: Vec) -> Self { + Self { users, ..self } + } + + pub fn with_channels(self, channels: Vec) -> Self { + Self { channels, ..self } + } +} + +#[derive(Debug)] +pub struct User { + name: UserName, + + api_key: String, + api_key_expires_at: NaiveDateTime, + discord_token: String, + discord_token_expires_at: NaiveDateTime, + + channel_intros: HashMap<(GuildId, ChannelName), Vec>, +} + +impl User { + pub fn new( + name: impl Into, + api_key: String, + api_key_expires_at: NaiveDateTime, + discord_token: String, + discord_token_expires_at: NaiveDateTime, + ) -> Self { + Self { + name: name.into(), + api_key, + api_key_expires_at, + discord_token, + discord_token_expires_at, + channel_intros: HashMap::new(), + } + } + + pub fn name(&self) -> &str { + &self.name.0 + } + + pub fn intros(&self) -> &HashMap<(GuildId, ChannelName), Vec> { + &self.channel_intros + } + + pub fn api_key_expires_at(&self) -> NaiveDateTime { + self.api_key_expires_at + } + + pub fn discord_token_expires_at(&self) -> NaiveDateTime { + self.discord_token_expires_at + } + + pub fn with_channel_intros( + self, + channel_intros: HashMap<(GuildId, ChannelName), Vec>, + ) -> Self { + Self { + channel_intros, + ..self + } + } +} + +#[derive(Debug)] pub struct Channel { name: ChannelName, } +impl Channel { + pub fn new(name: ChannelName) -> Self { + Self { name } + } + + pub fn name(&self) -> &ChannelName { + &self.name + } +} + +#[derive(Debug, Clone)] pub struct Intro { id: IntroId, @@ -35,6 +228,20 @@ pub struct Intro { filename: String, } +impl Intro { + pub fn new(id: IntroId, name: String, filename: String) -> Self { + Self { id, name, filename } + } + + pub fn id(&self) -> IntroId { + self.id + } + + pub fn name(&self) -> &str { + &self.name + } +} + pub struct CreateGuildRequest { name: String, sound_delay: u32, @@ -99,6 +306,45 @@ pub enum GetGuildError { #[error("Guild not found")] NotFound, + #[error("Could not fetch guild users")] + CouldNotFetchUsers(#[from] GetUserError), + + #[error("Could not fetch guild channels")] + CouldNotFetchChannels(#[from] GetChannelError), + + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Debug, Error)] +pub enum GetUserError { + #[error("User not found")] + NotFound, + + #[error("Could not fetch user guilds")] + CouldNotFetchGuilds(#[from] Box), + + #[error("Could not fetch user channel intros")] + CouldNotFetchChannelIntros(#[from] GetIntroError), + + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Debug, Error)] +pub enum GetChannelError { + #[error("Channel not found")] + NotFound, + + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} + +#[derive(Debug, Error)] +pub enum GetIntroError { + #[error("Intro not found")] + NotFound, + #[error(transparent)] Unknown(#[from] anyhow::Error), } diff --git a/src/lib/domain/intro_tool/ports.rs b/src/lib/domain/intro_tool/ports.rs index 082895d..aa6ce18 100644 --- a/src/lib/domain/intro_tool/ports.rs +++ b/src/lib/domain/intro_tool/ports.rs @@ -1,30 +1,101 @@ +use std::{collections::HashMap, future::Future}; + +use crate::lib::domain::intro_tool::models::guild::ChannelName; + use super::models::guild::{ AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserError, AddIntroToUserRequest, Channel, CreateChannelError, CreateChannelRequest, CreateGuildError, CreateGuildRequest, - CreateUserError, CreateUserRequest, GetGuildError, Guild, GuildId, User, + CreateUserError, CreateUserRequest, GetChannelError, GetGuildError, GetIntroError, + GetUserError, Guild, GuildId, GuildRef, Intro, User, }; -pub trait IntroToolService { - async fn create_guild(&self, req: CreateGuildRequest) -> Result; - async fn create_user(&self, req: CreateUserRequest) -> Result; - async fn create_channel( - &self, - req: CreateChannelRequest, - ) -> Result; +pub trait IntroToolService: Send + Sync + Clone + 'static { + fn needs_setup(&self) -> impl Future + Send; - async fn add_intro_to_guild( + fn get_guild( &self, - req: AddIntroToGuildRequest, - ) -> Result<(), AddIntroToGuildError>; - - async fn add_intro_to_user( + guild_id: impl Into + Send, + ) -> impl Future> + Send; + fn get_guild_users( &self, - req: AddIntroToUserRequest, - ) -> Result<(), AddIntroToUserError>; -} - -pub trait IntroToolRepository { - async fn get_guild(&self, guild_id: GuildId) -> Result; + guild_id: GuildId, + ) -> impl Future, GetUserError>> + Send; + fn get_guild_intros( + &self, + guild_id: GuildId, + ) -> impl Future, GetIntroError>> + Send; + fn get_user( + &self, + username: impl AsRef + Send, + ) -> impl Future> + Send; + fn get_user_guilds( + &self, + username: impl AsRef + Send, + ) -> impl Future, GetGuildError>> + Send; + fn get_user_from_api_key( + &self, + api_key: &str, + ) -> impl Future> + Send; + + async fn create_guild(&self, req: CreateGuildRequest) -> Result; + async fn create_user(&self, req: CreateUserRequest) -> Result; + async fn create_channel( + &self, + req: CreateChannelRequest, + ) -> Result; + + async fn add_intro_to_guild( + &self, + req: AddIntroToGuildRequest, + ) -> Result<(), AddIntroToGuildError>; + + async fn add_intro_to_user( + &self, + req: AddIntroToUserRequest, + ) -> Result<(), AddIntroToUserError>; +} + +pub trait IntroToolRepository: Send + Sync + Clone + 'static { + fn get_guild( + &self, + guild_id: GuildId, + ) -> impl Future> + Send; + fn get_guild_count(&self) -> impl Future> + Send; + + fn get_guild_users( + &self, + guild_id: GuildId, + ) -> impl Future, GetUserError>> + Send; + + fn get_guild_channels( + &self, + guild_id: GuildId, + ) -> impl Future, GetChannelError>> + Send; + fn get_guild_intros( + &self, + guild_id: GuildId, + ) -> impl Future, GetIntroError>> + Send; + + fn get_user( + &self, + username: impl AsRef + Send, + ) -> impl Future> + Send; + + fn get_user_channel_intros( + &self, + username: impl AsRef + Send, + guild_id: GuildId, + ) -> impl Future>, GetIntroError>> + Send; + + fn get_user_guilds( + &self, + username: impl AsRef + Send, + ) -> impl Future, GetGuildError>> + Send; + + fn get_user_from_api_key( + &self, + api_key: &str, + ) -> impl Future> + Send; async fn create_guild(&self, req: CreateGuildRequest) -> Result; async fn create_user(&self, req: CreateUserRequest) -> Result; diff --git a/src/lib/domain/intro_tool/service.rs b/src/lib/domain/intro_tool/service.rs index 2fb45c7..ffcfedc 100644 --- a/src/lib/domain/intro_tool/service.rs +++ b/src/lib/domain/intro_tool/service.rs @@ -1,7 +1,11 @@ -use crate::lib::domain::intro_tool::ports::{IntroToolRepository, IntroToolService}; +use crate::lib::domain::intro_tool::{ + models::guild::{GetUserError, GuildId, User}, + ports::{IntroToolRepository, IntroToolService}, +}; use super::models; +#[derive(Clone)] pub struct Service where R: IntroToolRepository, @@ -22,6 +26,49 @@ impl IntroToolService for Service where R: IntroToolRepository, { + async fn needs_setup(&self) -> bool { + let Ok(guild_count) = self.repo.get_guild_count().await else { + return false; + }; + + guild_count == 0 + } + + async fn get_guild( + &self, + guild_id: impl Into, + ) -> Result { + self.repo.get_guild(guild_id.into()).await + } + + async fn get_guild_users(&self, guild_id: GuildId) -> Result, GetUserError> { + self.repo.get_guild_users(guild_id).await + } + async fn get_guild_intros( + &self, + guild_id: GuildId, + ) -> Result, models::guild::GetIntroError> { + self.repo.get_guild_intros(guild_id).await + } + + async fn get_user( + &self, + username: impl AsRef + Send, + ) -> Result { + self.repo.get_user(username).await + } + + async fn get_user_guilds( + &self, + username: impl AsRef + Send, + ) -> Result, models::guild::GetGuildError> { + self.repo.get_user_guilds(username).await + } + + async fn get_user_from_api_key(&self, api_key: &str) -> Result { + self.repo.get_user_from_api_key(api_key).await + } + async fn create_guild( &self, req: models::guild::CreateGuildRequest, diff --git a/src/lib/inbound.rs b/src/lib/inbound.rs new file mode 100644 index 0000000..459570a --- /dev/null +++ b/src/lib/inbound.rs @@ -0,0 +1,2 @@ +pub mod http; +pub mod response; diff --git a/src/lib/inbound/http.rs b/src/lib/inbound/http.rs new file mode 100644 index 0000000..5333818 --- /dev/null +++ b/src/lib/inbound/http.rs @@ -0,0 +1,146 @@ +mod page; + +use std::{net::SocketAddr, sync::Arc}; + +use axum::{ + extract::FromRequestParts, + http::request::Parts, + response::Redirect, + routing::{get, post}, +}; +use axum_extra::extract::CookieJar; +use chrono::Utc; +use reqwest::Method; +use tower_http::cors::CorsLayer; +use tracing::info; + +use crate::{ + auth, + lib::domain::intro_tool::{models::guild::User, ports::IntroToolService}, +}; + +#[derive(Clone)] +pub(crate) struct ApiState +where + S: IntroToolService, +{ + intro_tool_service: Arc, + + pub secrets: auth::DiscordSecret, + pub origin: String, +} + +#[axum::async_trait] +impl FromRequestParts> for User { + type Rejection = Redirect; + + async fn from_request_parts( + Parts { headers, .. }: &mut Parts, + state: &ApiState, + ) -> Result { + let jar = CookieJar::from_headers(headers); + + if let Some(token) = jar.get("access_token") { + match state + .intro_tool_service + .get_user_from_api_key(token.value()) + .await + { + Ok(user) => { + let now = Utc::now().naive_utc(); + if user.api_key_expires_at() < now || user.discord_token_expires_at() < now { + Err(Redirect::to(&format!("{}/login", state.origin))) + } else { + Ok(user) + } + } + Err(err) => { + tracing::error!(?err, "failed to authenticate user"); + + Err(Redirect::to(&format!("{}/login", state.origin))) + } + } + } else { + Err(Redirect::to(&format!("{}/login", state.origin))) + } + } +} + +pub struct HttpServer { + make_service: axum::routing::IntoMakeService, +} + +impl HttpServer { + pub fn new( + intro_tool_service: impl IntroToolService, + secrets: auth::DiscordSecret, + origin: String, + ) -> anyhow::Result { + let state = ApiState { + intro_tool_service: Arc::new(intro_tool_service), + secrets, + origin: origin.clone(), + }; + + let router = routes() + .layer( + CorsLayer::new() + .allow_origin([origin.parse().unwrap()]) + .allow_headers(tower_http::cors::Any) + .allow_methods([Method::GET, Method::POST, Method::DELETE]), + ) + .with_state(state); + + Ok(Self { + make_service: router.into_make_service(), + }) + } + + pub async fn run(self) { + let addr = SocketAddr::from(([0, 0, 0, 0], 8100)); + info!("socket listening on {addr}"); + + axum::Server::bind(&addr) + .serve(self.make_service) + .await + .expect("couldn't start http server"); + } +} + +fn routes() -> axum::Router> +where + S: IntroToolService, +{ + axum::Router::>::new() + .route("/", get(page::home)) + .route("/login", get(page::login)) + .route("/guild/:guild_id", get(page::guild_dashboard)) + // .route("/", get(page::home)) + // .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), + // ) + // .route("/v2/auth", get(routes::v2_auth)) + // .route( + // "/v2/intros/add/:guild_id/:channel", + // post(routes::v2_add_intro_to_user), + // ) + // .route( + // "/v2/intros/remove/:guild_id/:channel", + // post(routes::v2_remove_intro_from_user), + // ) + // .route("/v2/intros/:guild/add", get(routes::v2_add_guild_intro)) + // .route( + // "/v2/intros/:guild/upload", + // post(routes::v2_upload_guild_intro), + // ) + // .route("/health", get(routes::health)) +} diff --git a/src/lib/inbound/http/page.rs b/src/lib/inbound/http/page.rs new file mode 100644 index 0000000..a93e3a0 --- /dev/null +++ b/src/lib/inbound/http/page.rs @@ -0,0 +1,292 @@ +use axum::{ + extract::{Path, State}, + response::{Html, Redirect}, +}; + +use crate::{ + htmx::{Build, HtmxBuilder, Tag}, + lib::{ + domain::intro_tool::{ + models::guild::{ChannelName, GuildRef, Intro, User}, + ports::IntroToolService, + }, + inbound::{http::ApiState, response::ErrorAsRedirect}, + }, +}; + +pub async fn home( + State(state): State>, + user: Option, +) -> Result { + if let Some(user) = user { + let needs_setup = state.intro_tool_service.needs_setup().await; + let user_guilds = state + .intro_tool_service + .get_user_guilds(user.name()) + .await + .as_redirect(&state.origin, "/login")?; + + // TODO: get user app permissions + // TODO: check if user can add guilds + // TODO: fetch guilds from discord + + let can_add_guild = false; + let discord_guilds: Vec = vec![]; + + let guild_list = if needs_setup { + // TODO: + // 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)) + // }) + todo!() + } 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.push_builder(guild_list) + + // TODO: + // 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(), + )) + } else { + Err(Redirect::to(&format!("{}/login", state.origin))) + } +} + +pub async fn login( + State(state): State>, + user: Option, +) -> Result, Redirect> { + if user.is_some() { + Err(Redirect::to(&format!("{}/", state.origin))) + } else { + let authorize_uri = format!("https://discord.com/api/oauth2/authorize?client_id={}&redirect_uri={}/v2/auth&response_type=code&scope=guilds.members.read+guilds+identify", state.secrets.client_id, state.origin); + + Ok(Html( + 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").builder(Tag::Anchor, |b| { + b.attribute("role", "button") + .text("Login with Discord") + .attribute("href", &authorize_uri) + }) + }) + .build(), + )) + } +} + +pub async fn guild_dashboard( + State(state): State>, + user: User, + Path(guild_id): Path, +) -> Result, Redirect> { + let guild = state + .intro_tool_service + .get_guild(guild_id) + .await + .as_redirect(&state.origin, "/login")?; + let user_guilds = state + .intro_tool_service + .get_user_guilds(user.name()) + .await + .as_redirect(&state.origin, "/login")?; + let guild_intros = state + .intro_tool_service + .get_guild_intros(guild_id.into()) + .await + .as_redirect(&state.origin, "/login")?; + + // does user have access to this guild + if !user_guilds + .iter() + .any(|guild_ref| guild_ref.id() == guild.id()) + { + return Err(Redirect::to(&format!("{}/error", state.origin))); + } + + Ok(Html( + 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, &format!("{} - {}", user.name(), guild.name())) + }) + }) + .builder(Tag::Empty, |b| { + // TODO: + // 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, "Server Settings") + // .push_builder(mod_dashboard) + // }) + // }) + // } else { + // b + // }; + // b = if can_upload { + // b.builder(Tag::Div, |b| { + // b.attribute("class", "container") + // .builder(Tag::Article, |b| { + // b.builder_text(Tag::Header, "Upload New Intro") + // .push_builder(upload_form(&state.origin, guild_id)) + // }) + // }) + // .builder(Tag::Div, |b| { + // b.attribute("class", "container") + // .builder(Tag::Article, |b| { + // b.builder_text(Tag::Header, "Upload New Intro from Url") + // .push_builder(ytdl_form(&state.origin, guild_id)) + // }) + // }) + // } else { + // b + // }; + + b.builder(Tag::Div, |b| { + b.attribute("class", "container") + .builder(Tag::Article, |b| { + let mut b = b.builder_text(Tag::Header, "Guild Intros"); + + for guild_channel in guild.channels() { + let intros = user.intros().get(&(guild.id(), guild_channel.name().clone())).map(|intros| intros.iter()).unwrap_or_default(); + + 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().as_ref()).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(), + intros, + guild_intros.iter(), + )) + }, + ) + }); + } + + b + }) + }) + }) + .build(), + )) +} + +fn page_header(title: &str) -> HtmxBuilder { + HtmxBuilder::new(Tag::Html).head(|b| { + b.title(title) + .script( + "https://unpkg.com/htmx.org@1.9.3", + Some("sha384-lVb3Rd/Ca0AxaoZg5sACe8FJKF0tnUgR2Kd7ehUOG5GCcROv5uBIZsOqovBAcWua"), + ) + // Not currently using + // .script("https://unpkg.com/hyperscript.org@0.9.9", None) + .style_link("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css") + }) +} + +fn guild_list<'a>(origin: &str, guilds: impl Iterator) -> HtmxBuilder { + HtmxBuilder::new(Tag::Empty).ul(|b| { + let mut b = b; + for guild in guilds { + b = b.li(|b| b.link(guild.name(), &format!("{}/guild/{}", origin, guild.id()))); + } + + b + }) +} + +pub fn channel_intro_selector<'a>( + origin: &str, + guild_id: u64, + channel_name: &ChannelName, + intros: impl Iterator, + guild_intros: impl Iterator, +) -> HtmxBuilder { + HtmxBuilder::new(Tag::Empty) + .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.as_ref()), + )) + }) + .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.as_ref()), + )) + }) +} + +fn intro_list<'a>(intros: impl Iterator, label: &str, post: &str) -> HtmxBuilder { + HtmxBuilder::new(Tag::Empty).form(|b| { + b.attribute("class", "container") + .hx_post(post) + .hx_target("closest #channel-intro-selector") + .attribute("hx-encoding", "multipart/form-data") + .builder(Tag::FieldSet, |b| { + let mut b = b + .attribute("class", "container") + .attribute("style", "height: 256px; overflow: auto"); + for intro in intros { + b = b.builder(Tag::Label, |b| { + b.builder(Tag::Input, |b| { + b.attribute("type", "checkbox") + .attribute("name", &intro.id().to_string()) + }) + .builder_text(Tag::Paragraph, intro.name()) + }); + } + + b + }) + .button(|b| b.attribute("type", "submit").text(label)) + }) +} diff --git a/src/lib/inbound/mod.rs b/src/lib/inbound/mod.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/inbound/response.rs b/src/lib/inbound/response.rs new file mode 100644 index 0000000..82b4ac4 --- /dev/null +++ b/src/lib/inbound/response.rs @@ -0,0 +1,65 @@ +use std::fmt::Debug; + +use axum::response::Redirect; + +use crate::lib::domain::intro_tool::models::guild::{ + GetChannelError, GetGuildError, GetIntroError, +}; + +pub(super) trait ErrorAsRedirect: Sized { + fn as_redirect(self, origin: impl AsRef, path: impl AsRef) -> Result; +} + +impl ErrorAsRedirect for Result { + fn as_redirect(self, origin: impl AsRef, path: impl AsRef) -> Result { + match self { + Ok(value) => Ok(value), + Err(GetGuildError::NotFound) + | Err(GetGuildError::CouldNotFetchUsers(_)) + | Err(GetGuildError::CouldNotFetchChannels(_)) + | Err(GetGuildError::Unknown(_)) => { + tracing::error!(err = ?self, "failed to get guild"); + + Err(Redirect::to(&format!( + "{}/{}", + origin.as_ref(), + path.as_ref() + ))) + } + } + } +} + +impl ErrorAsRedirect for Result { + fn as_redirect(self, origin: impl AsRef, path: impl AsRef) -> Result { + match self { + Ok(value) => Ok(value), + Err(GetChannelError::NotFound) | Err(GetChannelError::Unknown(_)) => { + tracing::error!(err = ?self, "failed to get channel"); + + Err(Redirect::to(&format!( + "{}/{}", + origin.as_ref(), + path.as_ref() + ))) + } + } + } +} + +impl ErrorAsRedirect for Result { + fn as_redirect(self, origin: impl AsRef, path: impl AsRef) -> Result { + match self { + Ok(value) => Ok(value), + Err(GetIntroError::NotFound) | Err(GetIntroError::Unknown(_)) => { + tracing::error!(err = ?self, "failed to get intro"); + + Err(Redirect::to(&format!( + "{}/{}", + origin.as_ref(), + path.as_ref() + ))) + } + } + } +} diff --git a/src/lib/outbound/sqlite.rs b/src/lib/outbound/sqlite.rs index e0822fe..2f9d666 100644 --- a/src/lib/outbound/sqlite.rs +++ b/src/lib/outbound/sqlite.rs @@ -1,23 +1,333 @@ +use iter_tools::Itertools; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::Mutex; + +use anyhow::Context; +use rusqlite::Connection; + use crate::lib::domain::intro_tool::{ models::guild::{ self, AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserRequest, Channel, - CreateChannelError, CreateChannelRequest, CreateGuildError, CreateGuildRequest, - CreateUserError, CreateUserRequest, GetGuildError, Guild, GuildId, User, + ChannelName, CreateChannelError, CreateChannelRequest, CreateGuildError, + CreateGuildRequest, CreateUserError, CreateUserRequest, GetChannelError, GetGuildError, + GetIntroError, GetUserError, Guild, GuildId, GuildRef, Intro, User, UserName, }, ports::IntroToolRepository, }; -pub struct Sqlite {} +#[derive(Clone)] +pub struct Sqlite { + conn: Arc>, +} impl Sqlite { - pub fn new(path: &str) -> Result { - todo!() + pub fn new(path: &str) -> rusqlite::Result { + Ok(Self { + conn: Arc::new(Mutex::new(Connection::open(path)?)), + }) } } impl IntroToolRepository for Sqlite { async fn get_guild(&self, guild_id: GuildId) -> Result { - todo!() + let guild = { + let conn = self.conn.lock().await; + + let mut query = conn + .prepare( + " + select + Guild.id, + Guild.name, + Guild.sound_delay + from Guild + where Guild.id = :guild_id + ", + ) + .context("failed to prepare query")?; + + query + .query_row(&[(":guild_id", &guild_id.to_string())], |row| { + Ok(Guild::new( + row.get::<_, u64>(0)?.into(), + row.get(1)?, + row.get(2)?, + row.get::<_, u64>(0)?.into(), + )) + }) + .context("failed to query row")? + }; + + Ok(guild + .with_users(self.get_guild_users(guild_id).await?) + .with_channels(self.get_guild_channels(guild_id).await?)) + } + + async fn get_guild_count(&self) -> Result { + let conn = self.conn.lock().await; + + let mut query = conn + .prepare( + " + select + count(*) + from Guild + ", + ) + .context("failed to prepare query")?; + + Ok(query + .query_row([], |row| row.get::<_, usize>(0)) + .context("failed to query row")?) + } + + async fn get_guild_users(&self, guild_id: GuildId) -> Result, GetUserError> { + let conn = self.conn.lock().await; + + let mut query = conn + .prepare( + " + SELECT + User.username AS name, + User.api_key, + User.api_key_expires_at, + User.discord_token, + User.discord_token_expires_at + FROM UserGuild + LEFT JOIN User ON User.username = UserGuild.username + WHERE UserGuild.guild_id = :guild_id + ", + ) + .context("failed to prepare query")?; + + let users = query + .query_map(&[(":guild_id", &guild_id.to_string())], |row| { + Ok(User::new( + UserName::from(row.get::<_, String>(0)?), + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }) + .context("failed to map prepared query")? + .collect::>() + .context("failed to fetch guild user rows")?; + + Ok(users) + } + + async fn get_user_guilds( + &self, + username: impl AsRef, + ) -> Result, GetGuildError> { + let conn = self.conn.lock().await; + + let mut query = conn + .prepare( + " + SELECT + Guild.id, + Guild.name, + Guild.sound_delay + FROM Guild + LEFT JOIN UserGuild ON Guild.id = UserGuild.guild_id + LEFT JOIN User ON User.username = UserGuild.username + WHERE User.username = :username + ", + ) + .context("failed to prepare query")?; + + let guilds = query + .query_map(&[(":username", username.as_ref())], |row| { + Ok(GuildRef::new( + row.get::<_, u64>(0)?.into(), + row.get(1)?, + row.get(2)?, + row.get::<_, u64>(0)?.into(), + )) + }) + .context("failed to map prepared query")? + .collect::>() + .context("failed to fetch guild user rows")?; + + Ok(guilds) + } + + async fn get_guild_channels(&self, guild_id: GuildId) -> Result, GetChannelError> { + let conn = self.conn.lock().await; + + let mut query = conn + .prepare( + " + SELECT + Channel.name + FROM Channel + WHERE + Channel.guild_id = :guild_id + ORDER BY Channel.name DESC + ", + ) + .context("failed to prepare query")?; + + let channels = query + .query_map(&[(":guild_id", &guild_id.to_string())], |row| { + Ok(Channel::new(row.get::<_, String>(0)?.into())) + }) + .context("failed to map prepared query")? + .collect::>() + .context("failed to fetch guild channel rows")?; + + Ok(channels) + } + + async fn get_guild_intros(&self, guild_id: GuildId) -> Result, GetIntroError> { + let conn = self.conn.lock().await; + + let mut query = conn + .prepare( + " + SELECT + Intro.id, + Intro.name, + Intro.filename + FROM Intro + WHERE + Intro.guild_id = :guild_id + ", + ) + .context("failed to prepare query")?; + + let intros = query + .query_map(&[(":guild_id", &guild_id.to_string())], |row| { + Ok(Intro::new( + row.get::<_, i32>(0)?.into(), + row.get(1)?, + row.get(2)?, + )) + }) + .context("failed to map prepared query")? + .collect::>() + .context("failed to fetch guild intro rows")?; + + Ok(intros) + } + + async fn get_user(&self, username: impl AsRef) -> Result { + let user = { + let conn = self.conn.lock().await; + + let mut query = conn + .prepare( + " + SELECT + username AS name, api_key, api_key_expires_at, discord_token, discord_token_expires_at + FROM User + WHERE username = :username + ", + ) + .context("failed to prepare query")?; + + query + .query_row(&[(":username", username.as_ref())], |row| { + Ok(User::new( + UserName::from(row.get::<_, String>(0)?), + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }) + .context("failed to query row")? + }; + + let guilds = self + .get_user_guilds(username.as_ref()) + .await + .map_err(Box::new)?; + + let mut intros = HashMap::new(); + for guild in guilds { + intros.extend( + self.get_user_channel_intros(username.as_ref(), guild.id()) + .await?, + ); + } + + Ok(user.with_channel_intros(intros)) + } + + async fn get_user_channel_intros( + &self, + username: impl AsRef, + guild_id: GuildId, + ) -> Result>, GetIntroError> { + let conn = self.conn.lock().await; + + struct ChannelIntro { + channel_name: ChannelName, + intro: Intro, + } + + let mut query = conn + .prepare( + " + SELECT + Intro.id, + Intro.name, + Intro.filename, + UI.channel_name + FROM Intro + LEFT JOIN UserIntro UI ON UI.intro_id = Intro.id + WHERE + UI.username = ?1 + AND UI.guild_id = ?2 + ", + ) + .context("failed to prepare query")?; + + let intros = query + .query_map([username.as_ref(), &guild_id.to_string()], |row| { + Ok(ChannelIntro { + channel_name: ChannelName::from(row.get::<_, String>(3)?), + intro: Intro::new(row.get::<_, i32>(0)?.into(), row.get(1)?, row.get(2)?), + }) + }) + .context("failed to map prepared query")? + .collect::, _>>() + .context("failed to fetch user channel intro rows")?; + + let intros = intros + .into_iter() + .map(|intro| ((guild_id, intro.channel_name), intro.intro)) + .into_group_map(); + + Ok(intros) + } + + async fn get_user_from_api_key(&self, api_key: &str) -> Result { + let username = { + let conn = self.conn.lock().await; + + let mut query = conn + .prepare( + " + SELECT + username AS name + FROM User + WHERE api_key = :api_key + ", + ) + .context("failed to prepare query")?; + + query + .query_row(&[(":api_key", api_key)], |row| { + Ok(UserName::from(row.get::<_, String>(0)?)) + }) + .context("failed to query row")? + }; + + self.get_user(username).await } async fn create_guild(&self, req: CreateGuildRequest) -> Result { diff --git a/src/main.rs b/src/main.rs index 8609872..895a210 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,7 +31,7 @@ use songbird::SerenityInit; use tracing::*; use crate::lib::domain::intro_tool; -use crate::lib::outbound; +use crate::lib::{inbound, outbound}; use crate::settings::Settings; enum HandlerMessage { @@ -322,11 +322,32 @@ async fn main() -> std::io::Result<()> { &std::fs::read_to_string("config/settings.json").expect("no config/settings.json"), ) .expect("error parsing settings file"); + let secrets = auth::DiscordSecret { + 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"); - let db = outbound::sqlite::Sqlite::new(".config/db.sqlite").expect("couldn't open sqlite db"); - let service = intro_tool::service::Service::new(db); + let db = outbound::sqlite::Sqlite::new("./config/db.sqlite").expect("couldn't open sqlite db"); - // TODO: http server + if let Ok(impersonated_username) = env::var("IMPERSONATED_USERNAME") { + let service = intro_tool::service::Service::new(db); + let service = intro_tool::debug_service::DebugService::new(service, impersonated_username); + + let http_server = inbound::http::HttpServer::new(service, secrets, origin) + .expect("couldn't start http server"); + + http_server.run().await; + } else { + let service = intro_tool::service::Service::new(db); + + let http_server = inbound::http::HttpServer::new(service, secrets, origin) + .expect("couldn't start http server"); + + http_server.run().await; + } Ok(())