add file upload

hexagon
Patrick Cleavelin 2025-10-08 18:26:43 -05:00
parent 79a2f2839f
commit 752ce3f16c
3 changed files with 119 additions and 12 deletions

View File

@ -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<IntroId, guild::AddIntroToGuildError> {
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()

View File

@ -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(

View File

@ -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<T, P>: Sized {
fn from_api(value: T, params: P) -> Result<Self, ApiError>;
async fn from_api(value: T, params: P) -> Result<Self, ApiError>;
}
trait IntoDomain<T, P> {
fn into_domain(self, params: P) -> Result<T, ApiError>;
async fn into_domain(self, params: P) -> Result<T, ApiError>;
}
impl<I, O: FromApi<I, P>, P> IntoDomain<O, P> for I {
fn into_domain(self, params: P) -> Result<O, ApiError> {
O::from_api(self, params)
async fn into_domain(self, params: P) -> Result<O, ApiError> {
O::from_api(self, params).await
}
}
impl FromApi<HashMap<String, String>, GuildId> for AddIntroToGuildRequest {
fn from_api(value: HashMap<String, String>, params: GuildId) -> Result<Self, ApiError> {
async fn from_api(value: HashMap<String, String>, params: GuildId) -> Result<Self, ApiError> {
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<HashMap<String, String>, GuildId> for AddIntroToGuildRequest {
}
}
impl FromApi<Multipart, GuildId> for AddIntroToGuildRequest {
async fn from_api(mut form_data: Multipart, params: GuildId) -> Result<Self, ApiError> {
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<S: IntroToolService>(
State(state): State<ApiState<S>>,
Path(guild_id): Path<u64>,
Query(params): Query<HashMap<String, String>>,
user: User,
) -> Result<HeaderMap, ApiError> {
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<S: IntroToolService>(
State(state): State<ApiState<S>>,
Path(guild_id): Path<u64>,
user: User,
form_data: Multipart,
) -> Result<HeaderMap, ApiError> {
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