Compare commits
	
		
			2 Commits 
		
	
	
		
			be505ce399
			...
			d935e8ea6d
		
	
	| Author | SHA1 | Date | 
|---|---|---|
|  | d935e8ea6d | |
|  | 40c651d99d | 
|  | @ -2,3 +2,4 @@ | |||
| **/result | ||||
| result/ | ||||
| result | ||||
| .env | ||||
|  |  | |||
|  | @ -0,0 +1,25 @@ | |||
| pipeline: | ||||
|   build: | ||||
|     image: alpine:edge | ||||
|       - apk add --no-cache git nix --repository=https://dl-cdn.alpinelinux.org/alpine/edge/testing | ||||
|       - nix build --extra-experimental-features nix-command --extra-experimental-features flakes .#default | ||||
| 
 | ||||
|   build-docker-image: | ||||
|     when: | ||||
|       event: tag | ||||
|     image: alpine:edge | ||||
|     commands: | ||||
|       - 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 | ||||
| 
 | ||||
|   publish-image: | ||||
|     when: | ||||
|       event: tag | ||||
|     image: git.spacegirl.nl/patrick/plugin-artifact | ||||
|     settings: | ||||
|       tag: $CI_COMMIT_TAG | ||||
|       user: patrick | ||||
|       password: | ||||
|         from_secret: forgejo_token | ||||
|       repo: $CI_REPO | ||||
|       image_tar: result | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -6,8 +6,11 @@ edition = "2021" | |||
| # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||||
| 
 | ||||
| [dependencies] | ||||
| axum = "0.6.9" | ||||
| async-trait = "0.1.72" | ||||
| axum = { version = "0.6.9", features = ["headers", "multipart"] } | ||||
| axum-extra = { version = "0.7.5", features = ["cookie-private", "cookie"] } | ||||
| chrono = "0.4.23" | ||||
| dotenv = "0.15.0" | ||||
| futures = "0.3.26" | ||||
| reqwest = "0.11.14" | ||||
| serde = "1.0.152" | ||||
|  |  | |||
|  | @ -0,0 +1,395 @@ | |||
| use std::collections::HashMap; | ||||
| 
 | ||||
| pub trait Build { | ||||
|     fn build(self) -> String; | ||||
| } | ||||
| 
 | ||||
| #[derive(PartialEq)] | ||||
| pub enum Tag { | ||||
|     Empty, | ||||
| 
 | ||||
|     Html, | ||||
|     Head, | ||||
|     Link, | ||||
|     Script, | ||||
|     Title, | ||||
|     Body, | ||||
|     Main, | ||||
|     Break, | ||||
| 
 | ||||
|     Details, | ||||
|     Summary, | ||||
| 
 | ||||
|     Dialog, | ||||
|     Article, | ||||
|     Header, | ||||
| 
 | ||||
|     Div, | ||||
| 
 | ||||
|     Table, | ||||
|     TableHead, | ||||
|     TableHeader, | ||||
|     TableBody, | ||||
|     TableRow, | ||||
|     TableData, | ||||
| 
 | ||||
|     Progress, | ||||
| 
 | ||||
|     Form, | ||||
|     Label, | ||||
|     FieldSet, | ||||
|     Input, | ||||
|     Select, | ||||
|     Option, | ||||
| 
 | ||||
|     Nav, | ||||
| 
 | ||||
|     OrderedList, | ||||
|     UnorderedList, | ||||
|     ListItem, | ||||
| 
 | ||||
|     Anchor, | ||||
|     Button, | ||||
| 
 | ||||
|     Header1, | ||||
|     Header2, | ||||
|     Header3, | ||||
|     Header4, | ||||
|     Header5, | ||||
|     Header6, | ||||
|     Strong, | ||||
|     Paragraph, | ||||
|     JustText, | ||||
| } | ||||
| 
 | ||||
| impl Tag { | ||||
|     fn as_str(&self) -> &'static str { | ||||
|         match self { | ||||
|             Self::Empty => "", | ||||
|             Self::JustText => "", | ||||
| 
 | ||||
|             Self::Html => "html", | ||||
|             Self::Head => "head", | ||||
|             Self::Link => "link", | ||||
|             Self::Script => "script", | ||||
|             Self::Title => "title", | ||||
|             Self::Body => "body", | ||||
|             Self::Main => "main", | ||||
|             Self::Break => "break", | ||||
| 
 | ||||
|             Self::Progress => "progress", | ||||
| 
 | ||||
|             Self::Details => "details", | ||||
|             Self::Summary => "summary", | ||||
| 
 | ||||
|             Self::Dialog => "dialog", | ||||
|             Self::Article => "article", | ||||
|             Self::Header => "header", | ||||
| 
 | ||||
|             Self::Div => "div", | ||||
| 
 | ||||
|             Self::Table => "table", | ||||
|             Self::TableHead => "thead", | ||||
|             Self::TableHeader => "th", | ||||
|             Self::TableBody => "tbody", | ||||
|             Self::TableRow => "tr", | ||||
|             Self::TableData => "td", | ||||
| 
 | ||||
|             Self::Form => "form", | ||||
|             Self::Label => "label", | ||||
|             Self::FieldSet => "fieldset", | ||||
|             Self::Input => "input", | ||||
|             Self::Select => "select", | ||||
|             Self::Option => "option", | ||||
| 
 | ||||
|             Self::Nav => "nav", | ||||
| 
 | ||||
|             Self::OrderedList => "ol", | ||||
|             Self::UnorderedList => "ul", | ||||
|             Self::ListItem => "li", | ||||
| 
 | ||||
|             Self::Anchor => "a", | ||||
|             Self::Button => "button", | ||||
| 
 | ||||
|             Self::Header1 => "h1", | ||||
|             Self::Header2 => "h2", | ||||
|             Self::Header3 => "h3", | ||||
|             Self::Header4 => "h4", | ||||
|             Self::Header5 => "h5", | ||||
|             Self::Header6 => "h6", | ||||
|             Self::Strong => "strong", | ||||
|             Self::Paragraph => "paragraph", | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fn start(&self) -> String { | ||||
|         if *self != Self::JustText && *self != Self::Empty { | ||||
|             format!("<{}>", self.as_str()) | ||||
|         } else { | ||||
|             String::new() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fn end(&self) -> String { | ||||
|         if *self != Self::JustText | ||||
|             && *self != Self::Empty | ||||
|             && *self != Self::Link | ||||
|             && *self != Self::Input | ||||
|         { | ||||
|             format!("</{}>", self.as_str()) | ||||
|         } else { | ||||
|             String::new() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| pub enum SwapMethod { | ||||
|     InnerHtml, | ||||
|     OuterHtml, | ||||
|     BeforeEnd, | ||||
|     Refresh, | ||||
| } | ||||
| 
 | ||||
| impl SwapMethod { | ||||
|     fn as_str(&self) -> &'static str { | ||||
|         match self { | ||||
|             SwapMethod::InnerHtml => "innerHTML", | ||||
|             SwapMethod::OuterHtml => "outerHTML", | ||||
|             SwapMethod::BeforeEnd => "beforeend", | ||||
|             SwapMethod::Refresh => "refresh", | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| pub struct HtmxBuilder { | ||||
|     tag: Tag, | ||||
|     attributes: HashMap<String, String>, | ||||
|     children: Vec<HtmxBuilder>, | ||||
|     text: Option<String>, | ||||
| } | ||||
| 
 | ||||
| impl Build for HtmxBuilder { | ||||
|     #[must_use] | ||||
|     fn build(self) -> String { | ||||
|         let mut string = String::new(); | ||||
| 
 | ||||
|         // TODO: do this better
 | ||||
|         { | ||||
|             if self.tag != Tag::JustText && self.tag != Tag::Empty { | ||||
|                 string.push_str(&format!("<{}", self.tag.as_str())); | ||||
|             } | ||||
| 
 | ||||
|             for (attr, value) in self.attributes { | ||||
|                 if value.is_empty() { | ||||
|                     string.push_str(&format!(" {attr} ")); | ||||
|                 } else { | ||||
|                     string.push_str(&format!(" {attr}='{value}' ")); | ||||
|                 } | ||||
|             } | ||||
|             if self.tag != Tag::JustText && self.tag != Tag::Empty { | ||||
|                 string.push_str(">"); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if let Some(text) = self.text { | ||||
|             string.push_str(&text); | ||||
|         } | ||||
| 
 | ||||
|         for child in self.children { | ||||
|             string.push_str(&child.build()); | ||||
|         } | ||||
| 
 | ||||
|         string.push_str(&self.tag.end()); | ||||
| 
 | ||||
|         string | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl HtmxBuilder { | ||||
|     pub fn new(tag: Tag) -> Self { | ||||
|         Self { | ||||
|             tag, | ||||
|             attributes: HashMap::new(), | ||||
|             children: Vec::new(), | ||||
|             text: None, | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     pub fn push_builder(mut self, builder: HtmxBuilder) -> Self { | ||||
|         self.children.push(builder); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn attribute(mut self, attr: &str, val: &str) -> Self { | ||||
|         self.attributes.insert(attr.to_string(), val.to_string()); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn hx_get(mut self, uri: &str) -> Self { | ||||
|         self.attribute("hx-get", uri) | ||||
|     } | ||||
| 
 | ||||
|     pub fn hx_post(mut self, uri: &str) -> Self { | ||||
|         self.attribute("hx-post", uri) | ||||
|     } | ||||
| 
 | ||||
|     pub fn hx_swap(mut self, swap_method: SwapMethod) -> Self { | ||||
|         self.attribute("hx-swap", swap_method.as_str()) | ||||
|     } | ||||
| 
 | ||||
|     pub fn hx_trigger(mut self, trigger: &str) -> Self { | ||||
|         self.attribute("hx-trigger", trigger) | ||||
|     } | ||||
| 
 | ||||
|     pub fn hx_target(mut self, target: &str) -> Self { | ||||
|         self.attribute("hx-target", target) | ||||
|     } | ||||
| 
 | ||||
|     pub fn html<F>(mut self, builder_fn: F) -> Self | ||||
|     where | ||||
|         F: FnOnce(HtmxBuilder) -> HtmxBuilder, | ||||
|     { | ||||
|         self.children.push(builder_fn(HtmxBuilder::new(Tag::Html))); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn head<F>(mut self, builder_fn: F) -> Self | ||||
|     where | ||||
|         F: FnOnce(HtmxBuilder) -> HtmxBuilder, | ||||
|     { | ||||
|         self.children.push(builder_fn(HtmxBuilder::new(Tag::Head))); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn title(mut self, text: &str) -> HtmxBuilder { | ||||
|         self.children.push(HtmxBuilder::new(Tag::Title).text(text)); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn body<F>(mut self, builder_fn: F) -> Self | ||||
|     where | ||||
|         F: FnOnce(HtmxBuilder) -> HtmxBuilder, | ||||
|     { | ||||
|         self.children.push(builder_fn(HtmxBuilder::new(Tag::Body))); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn script(mut self, src: &str, integrity: Option<&str>) -> Self { | ||||
|         let mut b = HtmxBuilder::new(Tag::Script).attribute("src", src); | ||||
| 
 | ||||
|         if let Some(integrity) = integrity { | ||||
|             b = b | ||||
|                 .attribute("integrity", integrity) | ||||
|                 .attribute("crossorigin", "anonymous"); | ||||
|         } | ||||
| 
 | ||||
|         self.children.push(b); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn style_link(mut self, link: &str) -> Self { | ||||
|         self.children.push( | ||||
|             HtmxBuilder::new(Tag::Link) | ||||
|                 .attribute("rel", "stylesheet") | ||||
|                 .attribute("href", link), | ||||
|         ); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn flag(mut self, flag: &str) -> Self { | ||||
|         self.attributes.insert(flag.to_string(), "".to_string()); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn builder<F>(mut self, tag: Tag, builder_fn: F) -> Self | ||||
|     where | ||||
|         F: FnOnce(HtmxBuilder) -> HtmxBuilder, | ||||
|     { | ||||
|         self.children.push(builder_fn(HtmxBuilder::new(tag))); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn builder_text(mut self, tag: Tag, text: &str) -> Self { | ||||
|         self.children.push(HtmxBuilder::new(tag).text(text)); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn nav<F>(mut self, builder_fn: F) -> Self | ||||
|     where | ||||
|         F: FnOnce(HtmxBuilder) -> HtmxBuilder, | ||||
|     { | ||||
|         self.children.push(builder_fn(HtmxBuilder::new(Tag::Nav))); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn form<F>(mut self, builder_fn: F) -> HtmxBuilder | ||||
|     where | ||||
|         F: FnOnce(HtmxBuilder) -> HtmxBuilder, | ||||
|     { | ||||
|         self.children.push(builder_fn(HtmxBuilder::new(Tag::Form))); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn label<F>(mut self, builder_fn: F) -> HtmxBuilder | ||||
|     where | ||||
|         F: FnOnce(HtmxBuilder) -> HtmxBuilder, | ||||
|     { | ||||
|         self.children.push(builder_fn(HtmxBuilder::new(Tag::Label))); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn input<F>(mut self, builder_fn: F) -> HtmxBuilder | ||||
|     where | ||||
|         F: FnOnce(HtmxBuilder) -> HtmxBuilder, | ||||
|     { | ||||
|         self.children.push(builder_fn(HtmxBuilder::new(Tag::Input))); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn button<F>(mut self, builder_fn: F) -> HtmxBuilder | ||||
|     where | ||||
|         F: FnOnce(HtmxBuilder) -> HtmxBuilder, | ||||
|     { | ||||
|         self.children | ||||
|             .push(builder_fn(HtmxBuilder::new(Tag::Button))); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn ul<F>(mut self, builder_fn: F) -> HtmxBuilder | ||||
|     where | ||||
|         F: FnOnce(HtmxBuilder) -> HtmxBuilder, | ||||
|     { | ||||
|         self.children | ||||
|             .push(builder_fn(HtmxBuilder::new(Tag::UnorderedList))); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn li<F>(mut self, builder_fn: F) -> HtmxBuilder | ||||
|     where | ||||
|         F: FnOnce(HtmxBuilder) -> HtmxBuilder, | ||||
|     { | ||||
|         self.children | ||||
|             .push(builder_fn(HtmxBuilder::new(Tag::ListItem))); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn link(mut self, text: &str, href: &str) -> HtmxBuilder { | ||||
|         // TODO: add href attribute
 | ||||
|         self.children.push( | ||||
|             HtmxBuilder::new(Tag::Anchor) | ||||
|                 .text(text) | ||||
|                 .attribute("href", href), | ||||
|         ); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn text(mut self, text: &str) -> HtmxBuilder { | ||||
|         self.text = Some(text.to_string()); | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn strong(mut self, text: &str) -> HtmxBuilder { | ||||
|         self.children.push(HtmxBuilder::new(Tag::Strong).text(text)); | ||||
|         self | ||||
|     } | ||||
| } | ||||
							
								
								
									
										35
									
								
								src/main.rs
								
								
								
								
							
							
						
						
									
										35
									
								
								src/main.rs
								
								
								
								
							|  | @ -3,7 +3,9 @@ | |||
| #![feature(async_closure)] | ||||
| 
 | ||||
| mod auth; | ||||
| mod htmx; | ||||
| mod media; | ||||
| mod page; | ||||
| mod routes; | ||||
| pub mod settings; | ||||
| 
 | ||||
|  | @ -133,6 +135,18 @@ fn spawn_api(settings: Arc<Mutex<Settings>>) { | |||
| 
 | ||||
|     tokio::spawn(async move { | ||||
|         let api = Router::new() | ||||
|             .route("/", get(page::home)) | ||||
|             .route("/index.html", get(page::home)) | ||||
|             .route("/login", get(page::login)) | ||||
|             .route("/guild/:guild_id", get(page::guild_dashboard)) | ||||
|             .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("/health", get(routes::health)) | ||||
|             .route("/me", get(routes::me)) | ||||
|             .route("/intros/:guild", get(routes::intros)) | ||||
|  | @ -148,6 +162,7 @@ fn spawn_api(settings: Arc<Mutex<Settings>>) { | |||
|                 post(routes::remove_intro_to_user), | ||||
|             ) | ||||
|             .route("/auth", get(routes::auth)) | ||||
|             .route("/v2/auth", get(routes::v2_auth)) | ||||
|             .layer( | ||||
|                 CorsLayer::new() | ||||
|                     // TODO: move this to env variable
 | ||||
|  | @ -155,7 +170,7 @@ fn spawn_api(settings: Arc<Mutex<Settings>>) { | |||
|                     .allow_headers(Any) | ||||
|                     .allow_methods([Method::GET, Method::POST, Method::DELETE]), | ||||
|             ) | ||||
|             .with_state(Arc::new(state)); | ||||
|             .with_state(state); | ||||
|         let addr = SocketAddr::from(([0, 0, 0, 0], 8100)); | ||||
|         info!("socket listening on {addr}"); | ||||
|         axum::Server::bind(&addr) | ||||
|  | @ -233,21 +248,29 @@ async fn spawn_bot(settings: Arc<Mutex<Settings>>) { | |||
|                     info!("Got PlaySound message"); | ||||
|                     let settings = settings.lock().await; | ||||
| 
 | ||||
|                     let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx.cache) else { | ||||
|                     let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx.cache) | ||||
|                     else { | ||||
|                         error!("Failed to get cached channel from member!"); | ||||
|                         continue; | ||||
|                     }; | ||||
| 
 | ||||
|                     let Some(guild_settings) = settings.guilds.get(channel.guild_id.as_u64()) else { | ||||
|                     let Some(guild_settings) = settings.guilds.get(channel.guild_id.as_u64()) | ||||
|                     else { | ||||
|                         error!("couldn't get guild from id: {}", channel.guild_id.as_u64()); | ||||
|                         continue; | ||||
|                     }; | ||||
|                     let Some(channel_settings) = guild_settings.channels.get(channel.name()) else { | ||||
|                         error!("couldn't get channel_settings from name: {}", channel.name()); | ||||
|                         error!( | ||||
|                             "couldn't get channel_settings from name: {}", | ||||
|                             channel.name() | ||||
|                         ); | ||||
|                         continue; | ||||
|                     }; | ||||
|                     let Some(user) = channel_settings.users.get(&member.user.name) else { | ||||
|                         error!("couldn't get user settings from name: {}", &member.user.name); | ||||
|                         error!( | ||||
|                             "couldn't get user settings from name: {}", | ||||
|                             &member.user.name | ||||
|                         ); | ||||
|                         continue; | ||||
|                     }; | ||||
| 
 | ||||
|  | @ -310,6 +333,8 @@ async fn spawn_bot(settings: Arc<Mutex<Settings>>) { | |||
| #[tokio::main] | ||||
| #[instrument] | ||||
| async fn main() -> std::io::Result<()> { | ||||
|     dotenv::dotenv().ok(); | ||||
| 
 | ||||
|     tracing_subscriber::fmt::init(); | ||||
| 
 | ||||
|     let settings = serde_json::from_str::<Settings>( | ||||
|  |  | |||
|  | @ -0,0 +1,161 @@ | |||
| use crate::{ | ||||
|     auth::{self, User}, | ||||
|     htmx::{Build, HtmxBuilder, SwapMethod, Tag}, | ||||
|     settings::{ApiState, Intro, IntroFriendlyName}, | ||||
| }; | ||||
| use axum::{ | ||||
|     extract::{Path, State}, | ||||
|     response::{Html, Redirect}, | ||||
| }; | ||||
| use tracing::error; | ||||
| 
 | ||||
| pub(crate) async fn home(user: Option<User>) -> Redirect { | ||||
|     if user.is_some() { | ||||
|         Redirect::to("/guild/588149178912473103") | ||||
|     } else { | ||||
|         Redirect::to("/login") | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| fn intro_list<'a>( | ||||
|     intros: impl Iterator<Item = (&'a String, &'a Intro)>, | ||||
|     label: &str, | ||||
|     post: &str, | ||||
| ) -> HtmxBuilder { | ||||
|     HtmxBuilder::new(Tag::Empty).form(|b| { | ||||
|         b.attribute("class", "container") | ||||
|             .hx_post(post) | ||||
|             .attribute("hx-encoding", "multipart/form-data") | ||||
|             .builder(Tag::FieldSet, |b| { | ||||
|                 let mut b = b | ||||
|                     .attribute("class", "container") | ||||
|                     .attribute("style", "max-height: 20%; overflow-y: scroll"); | ||||
|                 for intro in intros { | ||||
|                     b = b.builder(Tag::Label, |b| { | ||||
|                         b.builder(Tag::Input, |b| { | ||||
|                             b.attribute("type", "checkbox").attribute("name", &intro.0) | ||||
|                         }) | ||||
|                         .builder_text(Tag::Paragraph, intro.1.friendly_name()) | ||||
|                     }); | ||||
|                 } | ||||
| 
 | ||||
|                 b | ||||
|             }) | ||||
|             .button(|b| b.attribute("type", "submit").text(label)) | ||||
|     }) | ||||
| } | ||||
| 
 | ||||
| pub(crate) async fn guild_dashboard( | ||||
|     State(state): State<ApiState>, | ||||
|     user: User, | ||||
|     Path(guild_id): Path<u64>, | ||||
| ) -> Result<Html<String>, Redirect> { | ||||
|     let settings = state.settings.lock().await; | ||||
| 
 | ||||
|     let Some(guild) = settings.guilds.get(&guild_id) else { | ||||
|         error!(%guild_id, "no such guild"); | ||||
|         return Err(Redirect::to("/")); | ||||
|     }; | ||||
|     let Some(guild_user) = guild.users.get(&user.name) else { | ||||
|         error!(%guild_id, %user.name, "no user in guild"); | ||||
|         return Err(Redirect::to("/")); | ||||
|     }; | ||||
| 
 | ||||
|     let is_moderator = guild_user.permissions.can(auth::Permission::DeleteSounds); | ||||
| 
 | ||||
|     Ok(Html( | ||||
|         HtmxBuilder::new(Tag::Html) | ||||
|             .head(|b| { | ||||
|                 b.title("MemeJoin - Dashboard") | ||||
|                 .script( | ||||
|                     "https://unpkg.com/htmx.org@1.9.3", | ||||
|                     Some("sha384-lVb3Rd/Ca0AxaoZg5sACe8FJKF0tnUgR2Kd7ehUOG5GCcROv5uBIZsOqovBAcWua"), | ||||
|                 ) | ||||
|                 .script("https://unpkg.com/hyperscript.org@0.9.9", None) | ||||
|                 .style_link("https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css") | ||||
|             }) | ||||
|             .builder(Tag::Nav, |b| { | ||||
|                 b.builder(Tag::Header1, |b| b.text("MemeJoin - A bot for user intros")) | ||||
|                     .builder_text(Tag::Paragraph, &user.name) | ||||
|             }) | ||||
|             .builder(Tag::Main, |b| { | ||||
|                 if is_moderator { | ||||
|                     b.builder(Tag::Article, |b| { | ||||
|                         b.builder_text(Tag::Header, "Wow, you're a moderator") | ||||
|                     }) | ||||
|                 } else { | ||||
|                     b | ||||
|                 } | ||||
|                 .builder(Tag::Article, |b| { | ||||
|                     let mut b = b.builder_text(Tag::Header, "Guild Settings"); | ||||
| 
 | ||||
|                     for (channel_name, channel_settings) in &guild.channels { | ||||
|                         if let Some(channel_user) = channel_settings.users.get(&user.name) { | ||||
|                             let current_intros = | ||||
|                                 channel_user.intros.iter().filter_map(|intro_index| { | ||||
|                                     Some(( | ||||
|                                         &intro_index.index, | ||||
|                                         guild.intros.get(&intro_index.index)?, | ||||
|                                     )) | ||||
|                                 }); | ||||
|                             let available_intros = guild.intros.iter().filter_map(|intro| { | ||||
|                                 if !channel_user | ||||
|                                     .intros | ||||
|                                     .iter() | ||||
|                                     .any(|intro_index| intro.0 == &intro_index.index) | ||||
|                                 { | ||||
|                                     Some((intro.0, intro.1)) | ||||
|                                 } else { | ||||
|                                     None | ||||
|                                 } | ||||
|                             }); | ||||
|                             b = b | ||||
|                                 .builder_text(Tag::Strong, channel_name) | ||||
|                                 .builder(Tag::Div, |b| { | ||||
|                                     b.builder_text(Tag::Strong, "Your Current Intros") | ||||
|                                         .push_builder(intro_list( | ||||
|                                             current_intros, | ||||
|                                             "Remove Intro", | ||||
|                                             &format!( | ||||
|                                                 "/v2/intros/remove/{}/{}", | ||||
|                                                 guild_id, channel_name | ||||
|                                             ), | ||||
|                                         )) | ||||
|                                         .builder_text(Tag::Strong, "Select Intros") | ||||
|                                         .push_builder(intro_list( | ||||
|                                             available_intros, | ||||
|                                             "Add Intro", | ||||
|                                             &format!( | ||||
|                                                 "/v2/intros/add/{}/{}", | ||||
|                                                 guild_id, channel_name | ||||
|                                             ), | ||||
|                                         )) | ||||
|                                 }); | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     b | ||||
|                 }) | ||||
|             }) | ||||
|             .build(), | ||||
|     )) | ||||
| } | ||||
| 
 | ||||
| pub(crate) async fn login(State(state): State<ApiState>) -> Html<String> { | ||||
|     let authorize_uri = format!("https://discord.com/api/oauth2/authorize?client_id={}&redirect_uri={}/v2/auth&response_type=code&scope=guilds.members.read%20guilds%20identify", state.secrets.client_id, state.origin); | ||||
| 
 | ||||
|     Html( | ||||
|         HtmxBuilder::new(Tag::Html) | ||||
|             .head(|b| { | ||||
|                 b.title("MemeJoin - Login") | ||||
|                 .script( | ||||
|                     "https://unpkg.com/htmx.org@1.9.3", | ||||
|                     Some("sha384-lVb3Rd/Ca0AxaoZg5sACe8FJKF0tnUgR2Kd7ehUOG5GCcROv5uBIZsOqovBAcWua"), | ||||
|                 ) | ||||
|                 .script("https://unpkg.com/hyperscript.org@0.9.9", None) | ||||
|                 .style_link("https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css") | ||||
|             }) | ||||
|             .link("Login", &authorize_uri) | ||||
|             .build(), | ||||
|     ) | ||||
| } | ||||
							
								
								
									
										330
									
								
								src/routes.rs
								
								
								
								
							
							
						
						
									
										330
									
								
								src/routes.rs
								
								
								
								
							|  | @ -2,19 +2,23 @@ use std::{collections::HashMap, sync::Arc}; | |||
| 
 | ||||
| use axum::{ | ||||
|     body::Bytes, | ||||
|     extract::{Path, Query, State}, | ||||
|     http::HeaderMap, | ||||
|     response::IntoResponse, | ||||
|     Json, | ||||
|     extract::{Multipart, Path, Query, State}, | ||||
|     http::{HeaderMap, HeaderValue}, | ||||
|     response::{IntoResponse, Redirect}, | ||||
|     Form, Json, | ||||
| }; | ||||
| 
 | ||||
| use reqwest::StatusCode; | ||||
| use axum_extra::extract::{cookie::Cookie, CookieJar}; | ||||
| use reqwest::{Proxy, StatusCode}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serde_json::{json, Value}; | ||||
| use tracing::{error, info}; | ||||
| use uuid::Uuid; | ||||
| 
 | ||||
| use crate::{auth, settings::FileIntro}; | ||||
| use crate::{ | ||||
|     auth::{self, User}, | ||||
|     settings::FileIntro, | ||||
| }; | ||||
| use crate::{ | ||||
|     media, | ||||
|     settings::{ApiState, GuildUser, Intro, IntroIndex, UserSettings}, | ||||
|  | @ -123,8 +127,109 @@ struct DiscordUserGuild { | |||
|     pub owner: bool, | ||||
| } | ||||
| 
 | ||||
| pub(crate) async fn v2_auth( | ||||
|     State(state): State<ApiState>, | ||||
|     Query(params): Query<HashMap<String, String>>, | ||||
|     jar: CookieJar, | ||||
| ) -> Result<(CookieJar, Redirect), Error> { | ||||
|     let Some(code) = params.get("code") else { | ||||
|         return Err(Error::Auth("no code".to_string())); | ||||
|     }; | ||||
| 
 | ||||
|     info!("attempting to get access token with code {}", code); | ||||
| 
 | ||||
|     let mut data = HashMap::new(); | ||||
| 
 | ||||
|     let redirect_uri = format!("{}/v2/auth", state.origin); | ||||
|     data.insert("client_id", state.secrets.client_id.as_str()); | ||||
|     data.insert("client_secret", state.secrets.client_secret.as_str()); | ||||
|     data.insert("grant_type", "authorization_code"); | ||||
|     data.insert("code", code); | ||||
|     data.insert("redirect_uri", &redirect_uri); | ||||
| 
 | ||||
|     let client = reqwest::Client::new(); | ||||
| 
 | ||||
|     let auth: auth::Discord = client | ||||
|         .post("https://discord.com/api/oauth2/token") | ||||
|         .form(&data) | ||||
|         .send() | ||||
|         .await | ||||
|         .map_err(|err| Error::Auth(err.to_string()))? | ||||
|         .json() | ||||
|         .await | ||||
|         .map_err(|err| { | ||||
|             error!(?err, "auth error"); | ||||
|             Error::Auth(err.to_string()) | ||||
|         })?; | ||||
|     let token = Uuid::new_v4().to_string(); | ||||
| 
 | ||||
|     // Get authorized username
 | ||||
|     let user: DiscordUser = client | ||||
|         .get("https://discord.com/api/v10/users/@me") | ||||
|         .bearer_auth(&auth.access_token) | ||||
|         .send() | ||||
|         .await? | ||||
|         .json() | ||||
|         .await?; | ||||
| 
 | ||||
|     // TODO: get bot's guilds so we only save users who are able to use the bot
 | ||||
|     let discord_guilds: Vec<DiscordUserGuild> = client | ||||
|         .get("https://discord.com/api/v10/users/@me/guilds") | ||||
|         .bearer_auth(&auth.access_token) | ||||
|         .send() | ||||
|         .await? | ||||
|         .json() | ||||
|         .await | ||||
|         .map_err(|err| Error::Auth(err.to_string()))?; | ||||
| 
 | ||||
|     let mut settings = state.settings.lock().await; | ||||
|     let mut in_a_guild = false; | ||||
|     for g in settings.guilds.iter_mut() { | ||||
|         let Some(discord_guild) = discord_guilds | ||||
|             .iter() | ||||
|             .find(|discord_guild| discord_guild.id == g.0.to_string()) | ||||
|         else { | ||||
|             continue; | ||||
|         }; | ||||
| 
 | ||||
|         in_a_guild = true; | ||||
| 
 | ||||
|         if !g.1.users.contains_key(&user.username) { | ||||
|             g.1.users.insert( | ||||
|                 user.username.clone(), | ||||
|                 GuildUser { | ||||
|                     permissions: if discord_guild.owner { | ||||
|                         auth::Permissions(auth::Permission::all()) | ||||
|                     } else { | ||||
|                         Default::default() | ||||
|                     }, | ||||
|                 }, | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     if !in_a_guild { | ||||
|         return Err(Error::NoGuildFound); | ||||
|     } | ||||
| 
 | ||||
|     settings.auth_users.insert( | ||||
|         token.clone(), | ||||
|         auth::User { | ||||
|             auth, | ||||
|             name: user.username.clone(), | ||||
|         }, | ||||
|     ); | ||||
|     // TODO: add permissions based on roles
 | ||||
| 
 | ||||
|     let mut cookie = Cookie::new("access_token", token.clone()); | ||||
|     cookie.set_path("/"); | ||||
|     cookie.set_secure(true); | ||||
| 
 | ||||
|     Ok((jar.add(cookie), Redirect::to("/"))) | ||||
| } | ||||
| 
 | ||||
| pub(crate) async fn auth( | ||||
|     State(state): State<Arc<ApiState>>, | ||||
|     State(state): State<ApiState>, | ||||
|     Query(params): Query<HashMap<String, String>>, | ||||
| ) -> Result<Json<Value>, Error> { | ||||
|     let Some(code) = params.get("code") else { | ||||
|  | @ -179,7 +284,10 @@ pub(crate) async fn auth( | |||
|     for g in settings.guilds.iter_mut() { | ||||
|         let Some(discord_guild) = discord_guilds | ||||
|             .iter() | ||||
|             .find(|discord_guild| discord_guild.id == g.0.to_string()) else { continue; }; | ||||
|             .find(|discord_guild| discord_guild.id == g.0.to_string()) | ||||
|         else { | ||||
|             continue; | ||||
|         }; | ||||
| 
 | ||||
|         in_a_guild = true; | ||||
| 
 | ||||
|  | @ -213,21 +321,118 @@ pub(crate) async fn auth( | |||
|     Ok(Json(json!({"token": token, "username": user.username}))) | ||||
| } | ||||
| 
 | ||||
| pub(crate) async fn v2_add_intro_to_user( | ||||
|     State(state): State<ApiState>, | ||||
|     Path((guild_id, channel)): Path<(u64, String)>, | ||||
|     user: User, | ||||
|     mut form_data: Multipart, | ||||
| ) -> HeaderMap { | ||||
|     let mut headers = HeaderMap::new(); | ||||
|     headers.insert("HX-Refresh", HeaderValue::from_static("true")); | ||||
| 
 | ||||
|     let mut settings = state.settings.lock().await; | ||||
| 
 | ||||
|     let Some(guild) = settings.guilds.get_mut(&guild_id) else { | ||||
|         return headers; | ||||
|     }; | ||||
|     let Some(channel) = guild.channels.get_mut(&channel) else { | ||||
|         return headers; | ||||
|     }; | ||||
|     let Some(channel_user) = channel.users.get_mut(&user.name) else { | ||||
|         return headers; | ||||
|     }; | ||||
| 
 | ||||
|     while let Ok(Some(field)) = form_data.next_field().await { | ||||
|         let Some(field_name) = field.name() else { | ||||
|             continue; | ||||
|         }; | ||||
| 
 | ||||
|         if !channel_user | ||||
|             .intros | ||||
|             .iter() | ||||
|             .any(|intro| intro.index == field_name) | ||||
|         { | ||||
|             channel_user.intros.push(IntroIndex { | ||||
|                 index: field_name.to_string(), | ||||
|                 volume: 20, | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     // TODO: don't save on every change
 | ||||
|     if let Err(err) = settings.save() { | ||||
|         error!("Failed to save config: {err:?}"); | ||||
|     } | ||||
| 
 | ||||
|     headers | ||||
| } | ||||
| 
 | ||||
| pub(crate) async fn v2_remove_intro_from_user( | ||||
|     State(state): State<ApiState>, | ||||
|     Path((guild_id, channel)): Path<(u64, String)>, | ||||
|     user: User, | ||||
|     mut form_data: Multipart, | ||||
| ) -> HeaderMap { | ||||
|     let mut headers = HeaderMap::new(); | ||||
|     headers.insert("HX-Refresh", HeaderValue::from_static("true")); | ||||
| 
 | ||||
|     let mut settings = state.settings.lock().await; | ||||
| 
 | ||||
|     let Some(guild) = settings.guilds.get_mut(&guild_id) else { | ||||
|         return headers; | ||||
|     }; | ||||
|     let Some(channel) = guild.channels.get_mut(&channel) else { | ||||
|         return headers; | ||||
|     }; | ||||
|     let Some(channel_user) = channel.users.get_mut(&user.name) else { | ||||
|         return headers; | ||||
|     }; | ||||
| 
 | ||||
|     while let Ok(Some(field)) = form_data.next_field().await { | ||||
|         let Some(field_name) = field.name() else { | ||||
|             continue; | ||||
|         }; | ||||
| 
 | ||||
|         if let Some(index) = channel_user | ||||
|             .intros | ||||
|             .iter() | ||||
|             .position(|intro| intro.index == field_name) | ||||
|         { | ||||
|             channel_user.intros.remove(index); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     // TODO: don't save on every change
 | ||||
|     if let Err(err) = settings.save() { | ||||
|         error!("Failed to save config: {err:?}"); | ||||
|     } | ||||
| 
 | ||||
|     headers | ||||
| } | ||||
| 
 | ||||
| pub(crate) async fn add_intro_to_user( | ||||
|     State(state): State<Arc<ApiState>>, | ||||
|     State(state): State<ApiState>, | ||||
|     headers: HeaderMap, | ||||
|     Path((guild, channel, intro_index)): Path<(u64, String, String)>, | ||||
| ) { | ||||
|     let mut settings = state.settings.lock().await; | ||||
|     let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { return; }; | ||||
|     let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { | ||||
|         return; | ||||
|     }; | ||||
|     let user = match settings.auth_users.get(token) { | ||||
|         Some(user) => user.name.clone(), | ||||
|         None => return, | ||||
|     }; | ||||
| 
 | ||||
|     let Some(guild) = settings.guilds.get_mut(&guild) else { return; }; | ||||
|     let Some(channel) = guild.channels.get_mut(&channel) else { return; }; | ||||
|     let Some(user) = channel.users.get_mut(&user) else { return; }; | ||||
|     let Some(guild) = settings.guilds.get_mut(&guild) else { | ||||
|         return; | ||||
|     }; | ||||
|     let Some(channel) = guild.channels.get_mut(&channel) else { | ||||
|         return; | ||||
|     }; | ||||
|     let Some(user) = channel.users.get_mut(&user) else { | ||||
|         return; | ||||
|     }; | ||||
| 
 | ||||
|     if !user.intros.iter().any(|intro| intro.index == intro_index) { | ||||
|         user.intros.push(IntroIndex { | ||||
|  | @ -243,20 +448,28 @@ pub(crate) async fn add_intro_to_user( | |||
| } | ||||
| 
 | ||||
| pub(crate) async fn remove_intro_to_user( | ||||
|     State(state): State<Arc<ApiState>>, | ||||
|     State(state): State<ApiState>, | ||||
|     headers: HeaderMap, | ||||
|     Path((guild, channel, intro_index)): Path<(u64, String, String)>, | ||||
| ) { | ||||
|     let mut settings = state.settings.lock().await; | ||||
|     let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { return; }; | ||||
|     let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { | ||||
|         return; | ||||
|     }; | ||||
|     let user = match settings.auth_users.get(token) { | ||||
|         Some(user) => user.name.clone(), | ||||
|         None => return, | ||||
|     }; | ||||
| 
 | ||||
|     let Some(guild) = settings.guilds.get_mut(&guild) else { return; }; | ||||
|     let Some(channel) = guild.channels.get_mut(&channel) else { return; }; | ||||
|     let Some(user) = channel.users.get_mut(&user) else { return; }; | ||||
|     let Some(guild) = settings.guilds.get_mut(&guild) else { | ||||
|         return; | ||||
|     }; | ||||
|     let Some(channel) = guild.channels.get_mut(&channel) else { | ||||
|         return; | ||||
|     }; | ||||
|     let Some(user) = channel.users.get_mut(&user) else { | ||||
|         return; | ||||
|     }; | ||||
| 
 | ||||
|     if let Some(index) = user | ||||
|         .intros | ||||
|  | @ -272,22 +485,23 @@ pub(crate) async fn remove_intro_to_user( | |||
|     } | ||||
| } | ||||
| 
 | ||||
| pub(crate) async fn intros( | ||||
|     State(state): State<Arc<ApiState>>, | ||||
|     Path(guild): Path<u64>, | ||||
| ) -> Json<Value> { | ||||
| pub(crate) async fn intros(State(state): State<ApiState>, Path(guild): Path<u64>) -> Json<Value> { | ||||
|     let settings = state.settings.lock().await; | ||||
|     let Some(guild) = settings.guilds.get(&guild) else { return Json(json!(IntroResponse::NoGuildFound)); }; | ||||
|     let Some(guild) = settings.guilds.get(&guild) else { | ||||
|         return Json(json!(IntroResponse::NoGuildFound)); | ||||
|     }; | ||||
| 
 | ||||
|     Json(json!(IntroResponse::Intros(&guild.intros))) | ||||
| } | ||||
| 
 | ||||
| pub(crate) async fn me( | ||||
|     State(state): State<Arc<ApiState>>, | ||||
|     State(state): State<ApiState>, | ||||
|     headers: HeaderMap, | ||||
| ) -> Result<Json<Value>, Error> { | ||||
|     let mut settings = state.settings.lock().await; | ||||
|     let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { return Err(Error::NoUserFound); }; | ||||
|     let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { | ||||
|         return Err(Error::NoUserFound); | ||||
|     }; | ||||
| 
 | ||||
|     let (username, access_token) = match settings.auth_users.get(token) { | ||||
|         Some(user) => (user.name.clone(), user.auth.access_token.clone()), | ||||
|  | @ -340,7 +554,7 @@ pub(crate) async fn me( | |||
| } | ||||
| 
 | ||||
| pub(crate) async fn upload_guild_intro( | ||||
|     State(state): State<Arc<ApiState>>, | ||||
|     State(state): State<ApiState>, | ||||
|     Path(guild): Path<u64>, | ||||
|     Query(mut params): Query<HashMap<String, String>>, | ||||
|     headers: HeaderMap, | ||||
|  | @ -348,23 +562,33 @@ pub(crate) async fn upload_guild_intro( | |||
| ) -> Result<(), Error> { | ||||
|     let mut settings = state.settings.lock().await; | ||||
| 
 | ||||
|     let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { return Err(Error::NoUserFound); }; | ||||
|     let Some(friendly_name) = params.remove("name") else { return Err(Error::InvalidRequest); }; | ||||
|     let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { | ||||
|         return Err(Error::NoUserFound); | ||||
|     }; | ||||
|     let Some(friendly_name) = params.remove("name") else { | ||||
|         return Err(Error::InvalidRequest); | ||||
|     }; | ||||
| 
 | ||||
|     { | ||||
|         let Some(guild) = settings.guilds.get(&guild) else { return Err(Error::NoGuildFound); }; | ||||
|         let Some(guild) = settings.guilds.get(&guild) else { | ||||
|             return Err(Error::NoGuildFound); | ||||
|         }; | ||||
|         let auth_user = match settings.auth_users.get(token) { | ||||
|             Some(user) => user, | ||||
|             None => return Err(Error::NoUserFound), | ||||
|         }; | ||||
|         let Some(guild_user) = guild.users.get(&auth_user.name) else { return Err(Error::NoUserFound) }; | ||||
|         let Some(guild_user) = guild.users.get(&auth_user.name) else { | ||||
|             return Err(Error::NoUserFound); | ||||
|         }; | ||||
| 
 | ||||
|         if !guild_user.permissions.can(auth::Permission::UploadSounds) { | ||||
|             return Err(Error::InvalidPermission); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     let Some(guild) = settings.guilds.get_mut(&guild) else { return Err(Error::NoGuildFound); }; | ||||
|     let Some(guild) = settings.guilds.get_mut(&guild) else { | ||||
|         return Err(Error::NoGuildFound); | ||||
|     }; | ||||
|     let uuid = Uuid::new_v4().to_string(); | ||||
|     let temp_path = format!("./sounds/temp/{uuid}"); | ||||
|     let dest_path = format!("./sounds/{uuid}.mp3"); | ||||
|  | @ -386,31 +610,43 @@ pub(crate) async fn upload_guild_intro( | |||
| } | ||||
| 
 | ||||
| pub(crate) async fn add_guild_intro( | ||||
|     State(state): State<Arc<ApiState>>, | ||||
|     State(state): State<ApiState>, | ||||
|     Path(guild): Path<u64>, | ||||
|     Query(mut params): Query<HashMap<String, String>>, | ||||
|     headers: HeaderMap, | ||||
| ) -> Result<(), Error> { | ||||
|     let mut settings = state.settings.lock().await; | ||||
|     // TODO: make this an impl on HeaderMap
 | ||||
|     let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { return Err(Error::NoUserFound); }; | ||||
|     let Some(url) = params.remove("url") else { return Err(Error::InvalidRequest); }; | ||||
|     let Some(friendly_name) = params.remove("name") else { return Err(Error::InvalidRequest); }; | ||||
|     let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { | ||||
|         return Err(Error::NoUserFound); | ||||
|     }; | ||||
|     let Some(url) = params.remove("url") else { | ||||
|         return Err(Error::InvalidRequest); | ||||
|     }; | ||||
|     let Some(friendly_name) = params.remove("name") else { | ||||
|         return Err(Error::InvalidRequest); | ||||
|     }; | ||||
| 
 | ||||
|     { | ||||
|         let Some(guild) = settings.guilds.get(&guild) else { return Err(Error::NoGuildFound); }; | ||||
|         let Some(guild) = settings.guilds.get(&guild) else { | ||||
|             return Err(Error::NoGuildFound); | ||||
|         }; | ||||
|         let auth_user = match settings.auth_users.get(token) { | ||||
|             Some(user) => user, | ||||
|             None => return Err(Error::NoUserFound), | ||||
|         }; | ||||
|         let Some(guild_user) = guild.users.get(&auth_user.name) else { return Err(Error::NoUserFound) }; | ||||
|         let Some(guild_user) = guild.users.get(&auth_user.name) else { | ||||
|             return Err(Error::NoUserFound); | ||||
|         }; | ||||
| 
 | ||||
|         if !guild_user.permissions.can(auth::Permission::UploadSounds) { | ||||
|             return Err(Error::InvalidPermission); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     let Some(guild) = settings.guilds.get_mut(&guild) else { return Err(Error::NoGuildFound); }; | ||||
|     let Some(guild) = settings.guilds.get_mut(&guild) else { | ||||
|         return Err(Error::NoGuildFound); | ||||
|     }; | ||||
| 
 | ||||
|     let uuid = Uuid::new_v4().to_string(); | ||||
|     let child = tokio::process::Command::new("yt-dlp") | ||||
|  | @ -439,29 +675,37 @@ pub(crate) async fn add_guild_intro( | |||
| } | ||||
| 
 | ||||
| pub(crate) async fn delete_guild_intro( | ||||
|     State(state): State<Arc<ApiState>>, | ||||
|     State(state): State<ApiState>, | ||||
|     Path(guild): Path<u64>, | ||||
|     headers: HeaderMap, | ||||
|     Json(body): Json<DeleteIntroRequest>, | ||||
| ) -> Result<(), Error> { | ||||
|     let mut settings = state.settings.lock().await; | ||||
|     // TODO: make this an impl on HeaderMap
 | ||||
|     let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { return Err(Error::NoUserFound); }; | ||||
|     let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { | ||||
|         return Err(Error::NoUserFound); | ||||
|     }; | ||||
| 
 | ||||
|     { | ||||
|         let Some(guild) = settings.guilds.get(&guild) else { return Err(Error::NoGuildFound); }; | ||||
|         let Some(guild) = settings.guilds.get(&guild) else { | ||||
|             return Err(Error::NoGuildFound); | ||||
|         }; | ||||
|         let auth_user = match settings.auth_users.get(token) { | ||||
|             Some(user) => user, | ||||
|             None => return Err(Error::NoUserFound), | ||||
|         }; | ||||
|         let Some(guild_user) = guild.users.get(&auth_user.name) else { return Err(Error::NoUserFound) }; | ||||
|         let Some(guild_user) = guild.users.get(&auth_user.name) else { | ||||
|             return Err(Error::NoUserFound); | ||||
|         }; | ||||
| 
 | ||||
|         if !guild_user.permissions.can(auth::Permission::DeleteSounds) { | ||||
|             return Err(Error::InvalidPermission); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     let Some(guild) = settings.guilds.get_mut(&guild) else { return Err(Error::NoGuildFound); }; | ||||
|     let Some(guild) = settings.guilds.get_mut(&guild) else { | ||||
|         return Err(Error::NoGuildFound); | ||||
|     }; | ||||
| 
 | ||||
|     // Remove intro from any users
 | ||||
|     for channel in guild.channels.iter_mut() { | ||||
|  |  | |||
|  | @ -1,6 +1,8 @@ | |||
| use std::{collections::HashMap, sync::Arc}; | ||||
| 
 | ||||
| use crate::auth; | ||||
| use axum::{async_trait, extract::FromRequestParts, http::request::Parts, response::Redirect}; | ||||
| use axum_extra::extract::CookieJar; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serenity::prelude::TypeMapKey; | ||||
| use tracing::trace; | ||||
|  | @ -8,12 +10,36 @@ use uuid::Uuid; | |||
| 
 | ||||
| type UserToken = String; | ||||
| 
 | ||||
| // TODO: make this is wrapped type so cloning isn't happening
 | ||||
| #[derive(Clone)] | ||||
| pub(crate) struct ApiState { | ||||
|     pub settings: Arc<tokio::sync::Mutex<Settings>>, | ||||
|     pub secrets: auth::DiscordSecret, | ||||
|     pub origin: String, | ||||
| } | ||||
| 
 | ||||
| #[async_trait] | ||||
| impl FromRequestParts<ApiState> for crate::auth::User { | ||||
|     type Rejection = Redirect; | ||||
| 
 | ||||
|     async fn from_request_parts( | ||||
|         Parts { headers, .. }: &mut Parts, | ||||
|         state: &ApiState, | ||||
|     ) -> Result<Self, Self::Rejection> { | ||||
|         let jar = CookieJar::from_headers(&headers); | ||||
| 
 | ||||
|         if let Some(token) = jar.get("access_token") { | ||||
|             match state.settings.lock().await.auth_users.get(token.value()) { | ||||
|                 // :vomit:
 | ||||
|                 Some(user) => Ok(user.clone()), | ||||
|                 None => Err(Redirect::to("/login")), | ||||
|             } | ||||
|         } else { | ||||
|             Err(Redirect::to("/login")) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| #[serde(rename_all = "camelCase")] | ||||
| pub(crate) struct Settings { | ||||
|  | @ -70,12 +96,25 @@ pub(crate) struct GuildUser { | |||
|     pub(crate) permissions: auth::Permissions, | ||||
| } | ||||
| 
 | ||||
| pub(crate) trait IntroFriendlyName { | ||||
|     fn friendly_name(&self) -> &str; | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| pub(crate) enum Intro { | ||||
|     File(FileIntro), | ||||
|     Online(OnlineIntro), | ||||
| } | ||||
| 
 | ||||
| impl IntroFriendlyName for Intro { | ||||
|     fn friendly_name(&self) -> &str { | ||||
|         match self { | ||||
|             Self::File(intro) => intro.friendly_name(), | ||||
|             Self::Online(intro) => intro.friendly_name(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| #[serde(rename_all = "camelCase")] | ||||
| pub(crate) struct FileIntro { | ||||
|  | @ -83,6 +122,12 @@ pub(crate) struct FileIntro { | |||
|     pub(crate) friendly_name: String, | ||||
| } | ||||
| 
 | ||||
| impl IntroFriendlyName for FileIntro { | ||||
|     fn friendly_name(&self) -> &str { | ||||
|         &self.friendly_name | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| #[serde(rename_all = "camelCase")] | ||||
| pub(crate) struct OnlineIntro { | ||||
|  | @ -90,6 +135,12 @@ pub(crate) struct OnlineIntro { | |||
|     pub(crate) friendly_name: String, | ||||
| } | ||||
| 
 | ||||
| impl IntroFriendlyName for OnlineIntro { | ||||
|     fn friendly_name(&self) -> &str { | ||||
|         &self.friendly_name | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| #[serde(rename_all = "camelCase")] | ||||
| pub(crate) struct ChannelSettings { | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue