diff --git a/src/lib/domain/intro_tool/service.rs b/src/lib/domain/intro_tool/service.rs index 9fe6874..2e799f0 100644 --- a/src/lib/domain/intro_tool/service.rs +++ b/src/lib/domain/intro_tool/service.rs @@ -1,9 +1,12 @@ use anyhow::{anyhow, Context}; use uuid::Uuid; -use crate::lib::domain::intro_tool::{ - models::guild::{self, GetUserError, GuildId, IntroId, User}, - ports::{IntroToolRepository, IntroToolService}, +use crate::{ + lib::domain::intro_tool::{ + models::guild::{self, GetUserError, GuildId, IntroId, User}, + ports::{IntroToolRepository, IntroToolService}, + }, + media, }; use super::models; @@ -98,14 +101,28 @@ where req: guild::AddIntroToGuildRequest, ) -> Result { let file_name = match &req.data { - guild::IntroRequestData::Data(items) => todo!(), + guild::IntroRequestData::Data(bytes) => { + // TODO: put this behind an interface + let uuid = Uuid::new_v4().to_string(); + let temp_path = format!("./sounds/temp/{uuid}"); + let dest_path = format!("./sounds/{uuid}.mp3"); + + // Write original file so its ready for codec conversion + std::fs::write(&temp_path, bytes).context("failed to write temp file")?; + media::normalize(&temp_path, &dest_path) + .await + .context("failed to normalize file")?; + std::fs::remove_file(&temp_path).context("failed to remove temp file")?; + + dest_path + } guild::IntroRequestData::Url(url) => { let uuid = Uuid::new_v4().to_string(); let file_name = format!("sounds/{uuid}"); // TODO: put this behind an interface let child = tokio::process::Command::new("yt-dlp") - .arg(&url) + .arg(url) .args(["-o", &file_name]) .args(["-x", "--audio-format", "mp3"]) .spawn() diff --git a/src/lib/inbound/http.rs b/src/lib/inbound/http.rs index ecbf80c..4ee47ca 100644 --- a/src/lib/inbound/http.rs +++ b/src/lib/inbound/http.rs @@ -117,6 +117,10 @@ where .route("/login", get(page::login)) .route("/guild/:guild_id", get(page::guild_dashboard)) .route("/v2/intros/:guild/add", get(handlers::add_guild_intro)) + .route( + "/v2/intros/:guild/upload", + post(handlers::upload_guild_intro), + ) // .route("/guild/:guild_id/setup", get(routes::guild_setup)) // .route( diff --git a/src/lib/inbound/http/handlers.rs b/src/lib/inbound/http/handlers.rs index 195f298..424e501 100644 --- a/src/lib/inbound/http/handlers.rs +++ b/src/lib/inbound/http/handlers.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use axum::{ - extract::{Path, Query, State}, + extract::{Multipart, Path, Query, State}, http::{HeaderMap, HeaderValue}, }; @@ -17,27 +17,33 @@ use crate::lib::{ }; trait FromApi: Sized { - fn from_api(value: T, params: P) -> Result; + async fn from_api(value: T, params: P) -> Result; } trait IntoDomain { - fn into_domain(self, params: P) -> Result; + async fn into_domain(self, params: P) -> Result; } impl, P> IntoDomain for I { - fn into_domain(self, params: P) -> Result { - O::from_api(self, params) + async fn into_domain(self, params: P) -> Result { + O::from_api(self, params).await } } impl FromApi, GuildId> for AddIntroToGuildRequest { - fn from_api(value: HashMap, params: GuildId) -> Result { + async fn from_api(value: HashMap, params: GuildId) -> Result { let Some(url) = value.get("url") else { return Err(ApiError::bad_request("url is required")); }; + if url.is_empty() { + return Err(ApiError::bad_request("url cannot be empty")); + } let Some(name) = value.get("name") else { return Err(ApiError::bad_request("name is required")); }; + if name.is_empty() { + return Err(ApiError::bad_request("name cannot be empty")); + } Ok(Self { guild_id: params, @@ -48,13 +54,93 @@ impl FromApi, GuildId> for AddIntroToGuildRequest { } } +impl FromApi for AddIntroToGuildRequest { + async fn from_api(mut form_data: Multipart, params: GuildId) -> Result { + let mut name = None; + let mut file = None; + + while let Ok(Some(field)) = form_data.next_field().await { + let Some(field_name) = field.name() else { + continue; + }; + + if field_name.eq_ignore_ascii_case("name") { + name = Some(field.text().await.map_err(|err| { + ApiError::bad_request(format!("expected text for name: {err:?}")) + })?); + continue; + } + + if field_name.eq_ignore_ascii_case("file") { + file = Some(field.bytes().await.map_err(|err| { + ApiError::bad_request(format!("expected bytes for file: {err:?}")) + })?); + continue; + } + } + + let Some(name) = name else { + return Err(ApiError::bad_request("name is required")); + }; + if name.is_empty() { + return Err(ApiError::bad_request("name cannot be empty")); + } + + let Some(file) = file else { + return Err(ApiError::bad_request("file is required")); + }; + if file.is_empty() { + return Err(ApiError::bad_request("file cannot be empty")); + } + + Ok(Self { + guild_id: params, + name: name.to_string(), + volume: 0, + data: IntroRequestData::Data(file.to_vec()), + }) + } +} + pub(super) async fn add_guild_intro( State(state): State>, Path(guild_id): Path, Query(params): Query>, user: User, ) -> Result { - let req = params.into_domain(guild_id.into())?; + let req = params.into_domain(guild_id.into()).await?; + + let guild = state.intro_tool_service.get_guild(guild_id).await?; + let user_guilds = state + .intro_tool_service + .get_user_guilds(user.name()) + .await?; + + // does user have access to this guild + if !user_guilds + .iter() + .any(|guild_ref| guild_ref.id() == guild.id()) + { + return Err(ApiError::forbidden( + "You do not have access to this guild".to_string(), + )); + } + + state.intro_tool_service.add_intro_to_guild(req).await?; + + let mut headers = HeaderMap::new(); + headers.insert("HX-Refresh", HeaderValue::from_static("true")); + + Ok(headers) +} + +pub(super) async fn upload_guild_intro( + State(state): State>, + Path(guild_id): Path, + user: User, + form_data: Multipart, +) -> Result { + let req = form_data.into_domain(guild_id.into()).await?; let guild = state.intro_tool_service.get_guild(guild_id).await?; let user_guilds = state