Compare commits

...

2 Commits

Author SHA1 Message Date
Patrick Cleavelin 752ce3f16c add file upload 2025-10-08 18:29:32 -05:00
Patrick Cleavelin 79a2f2839f youtube downloader :) 2025-10-08 18:13:32 -05:00
10 changed files with 467 additions and 60 deletions

View File

@ -15,12 +15,12 @@
}; };
yt-dlp = pkgs.yt-dlp.overrideAttrs (oldAttr: rec { yt-dlp = pkgs.yt-dlp.overrideAttrs (oldAttr: rec {
inherit (oldAttr) name; inherit (oldAttr) name;
version = "2024.05.27"; version = "2025.09.26";
src = pkgs.fetchFromGitHub { src = pkgs.fetchFromGitHub {
owner = "yt-dlp"; owner = "yt-dlp";
repo = "yt-dlp"; repo = "yt-dlp";
rev = "${version}"; rev = "${version}";
sha256 = "55zDAMwCJPn5zKrAFw4ogTxxmvjrv4PvhYO7PsHbRo4="; sha256 = "/uzs87Vw+aDNfIJVLOx3C8RyZvWLqjggmnjrOvUX1Eg=";
}; };
}); });
local-rust = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain).override { local-rust = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain).override {
@ -30,6 +30,7 @@
{ {
devShell = pkgs.mkShell { devShell = pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
git
local-rust local-rust
rust-analyzer rust-analyzer
pkg-config pkg-config

View File

@ -1,7 +1,7 @@
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use crate::lib::domain::intro_tool::{ use crate::lib::domain::intro_tool::{
models, models::{self, guild::IntroId},
ports::{IntroToolRepository, IntroToolService}, ports::{IntroToolRepository, IntroToolService},
}; };
@ -112,7 +112,7 @@ where
async fn add_intro_to_guild( async fn add_intro_to_guild(
&self, &self,
req: models::guild::AddIntroToGuildRequest, req: models::guild::AddIntroToGuildRequest,
) -> Result<(), models::guild::AddIntroToGuildError> { ) -> Result<IntroId, models::guild::AddIntroToGuildError> {
self.wrapped_service.add_intro_to_guild(req).await self.wrapped_service.add_intro_to_guild(req).await
} }

View File

@ -258,10 +258,16 @@ pub struct CreateChannelRequest {
} }
pub struct AddIntroToGuildRequest { pub struct AddIntroToGuildRequest {
guild_id: GuildId, pub guild_id: GuildId,
name: String, pub name: String,
volume: i32, pub volume: i32,
filename: String,
pub data: IntroRequestData,
}
pub enum IntroRequestData {
Data(Vec<u8>),
Url(String),
} }
pub struct AddIntroToUserRequest { pub struct AddIntroToUserRequest {

View File

@ -1,6 +1,6 @@
use std::{collections::HashMap, future::Future}; use std::{collections::HashMap, future::Future};
use crate::lib::domain::intro_tool::models::guild::ChannelName; use crate::lib::domain::intro_tool::models::guild::{ChannelName, IntroId};
use super::models::guild::{ use super::models::guild::{
AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserError, AddIntroToUserRequest, AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserError, AddIntroToUserRequest,
@ -44,10 +44,10 @@ pub trait IntroToolService: Send + Sync + Clone + 'static {
req: CreateChannelRequest, req: CreateChannelRequest,
) -> Result<Channel, CreateChannelError>; ) -> Result<Channel, CreateChannelError>;
async fn add_intro_to_guild( fn add_intro_to_guild(
&self, &self,
req: AddIntroToGuildRequest, req: AddIntroToGuildRequest,
) -> Result<(), AddIntroToGuildError>; ) -> impl Future<Output = Result<IntroId, AddIntroToGuildError>> + Send;
async fn add_intro_to_user( async fn add_intro_to_user(
&self, &self,
@ -104,10 +104,12 @@ pub trait IntroToolRepository: Send + Sync + Clone + 'static {
req: CreateChannelRequest, req: CreateChannelRequest,
) -> Result<Channel, CreateChannelError>; ) -> Result<Channel, CreateChannelError>;
async fn add_intro_to_guild( fn add_intro_to_guild(
&self, &self,
req: AddIntroToGuildRequest, name: &str,
) -> Result<(), AddIntroToGuildError>; guild_id: GuildId,
filename: String,
) -> impl Future<Output = Result<IntroId, AddIntroToGuildError>> + Send;
async fn add_intro_to_user( async fn add_intro_to_user(
&self, &self,

View File

@ -1,6 +1,12 @@
use crate::lib::domain::intro_tool::{ use anyhow::{anyhow, Context};
models::guild::{GetUserError, GuildId, User}, use uuid::Uuid;
ports::{IntroToolRepository, IntroToolService},
use crate::{
lib::domain::intro_tool::{
models::guild::{self, GetUserError, GuildId, IntroId, User},
ports::{IntroToolRepository, IntroToolService},
},
media,
}; };
use super::models; use super::models;
@ -37,7 +43,7 @@ where
async fn get_guild( async fn get_guild(
&self, &self,
guild_id: impl Into<GuildId>, guild_id: impl Into<GuildId>,
) -> Result<models::guild::Guild, models::guild::GetGuildError> { ) -> Result<guild::Guild, guild::GetGuildError> {
self.repo.get_guild(guild_id.into()).await self.repo.get_guild(guild_id.into()).await
} }
@ -47,21 +53,21 @@ where
async fn get_guild_intros( async fn get_guild_intros(
&self, &self,
guild_id: GuildId, guild_id: GuildId,
) -> Result<Vec<models::guild::Intro>, models::guild::GetIntroError> { ) -> Result<Vec<guild::Intro>, guild::GetIntroError> {
self.repo.get_guild_intros(guild_id).await self.repo.get_guild_intros(guild_id).await
} }
async fn get_user( async fn get_user(
&self, &self,
username: impl AsRef<str> + Send, username: impl AsRef<str> + Send,
) -> Result<models::guild::User, models::guild::GetUserError> { ) -> Result<guild::User, guild::GetUserError> {
self.repo.get_user(username).await self.repo.get_user(username).await
} }
async fn get_user_guilds( async fn get_user_guilds(
&self, &self,
username: impl AsRef<str> + Send, username: impl AsRef<str> + Send,
) -> Result<Vec<models::guild::GuildRef>, models::guild::GetGuildError> { ) -> Result<Vec<guild::GuildRef>, guild::GetGuildError> {
self.repo.get_user_guilds(username).await self.repo.get_user_guilds(username).await
} }
@ -71,36 +77,79 @@ where
async fn create_guild( async fn create_guild(
&self, &self,
req: models::guild::CreateGuildRequest, req: guild::CreateGuildRequest,
) -> Result<models::guild::Guild, models::guild::CreateGuildError> { ) -> Result<guild::Guild, guild::CreateGuildError> {
self.repo.create_guild(req).await self.repo.create_guild(req).await
} }
async fn create_user( async fn create_user(
&self, &self,
req: models::guild::CreateUserRequest, req: guild::CreateUserRequest,
) -> Result<models::guild::User, models::guild::CreateUserError> { ) -> Result<guild::User, guild::CreateUserError> {
self.repo.create_user(req).await self.repo.create_user(req).await
} }
async fn create_channel( async fn create_channel(
&self, &self,
req: models::guild::CreateChannelRequest, req: guild::CreateChannelRequest,
) -> Result<models::guild::Channel, models::guild::CreateChannelError> { ) -> Result<guild::Channel, guild::CreateChannelError> {
self.repo.create_channel(req).await self.repo.create_channel(req).await
} }
async fn add_intro_to_guild( async fn add_intro_to_guild(
&self, &self,
req: models::guild::AddIntroToGuildRequest, req: guild::AddIntroToGuildRequest,
) -> Result<(), models::guild::AddIntroToGuildError> { ) -> Result<IntroId, guild::AddIntroToGuildError> {
self.repo.add_intro_to_guild(req).await let file_name = match &req.data {
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)
.args(["-o", &file_name])
.args(["-x", "--audio-format", "mp3"])
.spawn()
.context("failed to spawn yt-dlp process")?
.wait()
.await
.context("yt-dlp process failed")?;
if !child.success() {
return Err(guild::AddIntroToGuildError::Unknown(anyhow!(
"yt-dlp terminated unsuccessfully"
)));
}
file_name
}
};
self.repo
.add_intro_to_guild(&req.name, req.guild_id, file_name)
.await
} }
async fn add_intro_to_user( async fn add_intro_to_user(
&self, &self,
req: models::guild::AddIntroToUserRequest, req: guild::AddIntroToUserRequest,
) -> Result<(), models::guild::AddIntroToUserError> { ) -> Result<(), guild::AddIntroToUserError> {
self.repo.add_intro_to_user(req).await self.repo.add_intro_to_user(req).await
} }
} }

View File

@ -1,3 +1,4 @@
mod handlers;
mod page; mod page;
use std::{net::SocketAddr, sync::Arc}; use std::{net::SocketAddr, sync::Arc};
@ -115,10 +116,12 @@ where
.route("/", get(page::home)) .route("/", get(page::home))
.route("/login", get(page::login)) .route("/login", get(page::login))
.route("/guild/:guild_id", get(page::guild_dashboard)) .route("/guild/:guild_id", get(page::guild_dashboard))
// .route("/", get(page::home)) .route("/v2/intros/:guild/add", get(handlers::add_guild_intro))
// .route("/index.html", get(page::home)) .route(
// .route("/login", get(page::login)) "/v2/intros/:guild/upload",
// .route("/guild/:guild_id", get(page::guild_dashboard)) post(handlers::upload_guild_intro),
)
// .route("/guild/:guild_id/setup", get(routes::guild_setup)) // .route("/guild/:guild_id/setup", get(routes::guild_setup))
// .route( // .route(
// "/guild/:guild_id/add_channel", // "/guild/:guild_id/add_channel",

View File

@ -0,0 +1,167 @@
use std::collections::HashMap;
use axum::{
extract::{Multipart, Path, Query, State},
http::{HeaderMap, HeaderValue},
};
use crate::lib::{
domain::intro_tool::{
models::guild::{AddIntroToGuildRequest, GuildId, IntroRequestData, User},
ports::IntroToolService,
},
inbound::{
http::ApiState,
response::{ApiError, ErrorAsRedirect},
},
};
trait FromApi<T, P>: Sized {
async fn from_api(value: T, params: P) -> Result<Self, ApiError>;
}
trait IntoDomain<T, P> {
async fn into_domain(self, params: P) -> Result<T, ApiError>;
}
impl<I, O: FromApi<I, P>, P> IntoDomain<O, P> for I {
async fn into_domain(self, params: P) -> Result<O, ApiError> {
O::from_api(self, params).await
}
}
impl FromApi<HashMap<String, String>, GuildId> for AddIntroToGuildRequest {
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,
name: name.to_string(),
volume: 0,
data: IntroRequestData::Url(url.to_string()),
})
}
}
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()).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
.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)
}

View File

@ -133,6 +133,8 @@ pub async fn guild_dashboard<S: IntroToolService>(
return Err(Redirect::to(&format!("{}/error", state.origin))); return Err(Redirect::to(&format!("{}/error", state.origin)));
} }
let can_upload = true;
Ok(Html( Ok(Html(
HtmxBuilder::new(Tag::Html) HtmxBuilder::new(Tag::Html)
.push_builder(page_header("MemeJoin - Dashboard")) .push_builder(page_header("MemeJoin - Dashboard"))
@ -156,24 +158,24 @@ pub async fn guild_dashboard<S: IntroToolService>(
// } else { // } else {
// b // b
// }; // };
// b = if can_upload { let b = if can_upload {
// b.builder(Tag::Div, |b| { b.builder(Tag::Div, |b| {
// b.attribute("class", "container") b.attribute("class", "container")
// .builder(Tag::Article, |b| { .builder(Tag::Article, |b| {
// b.builder_text(Tag::Header, "Upload New Intro") b.builder_text(Tag::Header, "Upload New Intro")
// .push_builder(upload_form(&state.origin, guild_id)) .push_builder(upload_form(&state.origin, guild_id))
// }) })
// }) })
// .builder(Tag::Div, |b| { .builder(Tag::Div, |b| {
// b.attribute("class", "container") b.attribute("class", "container")
// .builder(Tag::Article, |b| { .builder(Tag::Article, |b| {
// b.builder_text(Tag::Header, "Upload New Intro from Url") b.builder_text(Tag::Header, "Upload New Intro from Url")
// .push_builder(ytdl_form(&state.origin, guild_id)) .push_builder(ytdl_form(&state.origin, guild_id))
// }) })
// }) })
// } else { } else {
// b b
// }; };
b.builder(Tag::Div, |b| { b.builder(Tag::Div, |b| {
b.attribute("class", "container") b.attribute("class", "container")
@ -290,3 +292,41 @@ fn intro_list<'a>(intros: impl Iterator<Item = &'a Intro>, label: &str, post: &s
.button(|b| b.attribute("type", "submit").text(label)) .button(|b| b.attribute("type", "submit").text(label))
}) })
} }
fn upload_form(origin: &str, guild_id: u64) -> HtmxBuilder {
HtmxBuilder::new(Tag::Empty).form(|b| {
b.attribute("class", "container")
.hx_post(&format!("{}/v2/intros/{}/upload", origin, guild_id))
.attribute("hx-encoding", "multipart/form-data")
.builder(Tag::FieldSet, |b| {
b.attribute("class", "container")
.attribute("role", "group")
.input(|b| b.attribute("type", "file").attribute("name", "file"))
.input(|b| {
b.attribute("name", "name")
.attribute("placeholder", "enter intro title")
})
.button(|b| b.attribute("type", "submit").text("Upload"))
})
})
}
fn ytdl_form(origin: &str, guild_id: u64) -> HtmxBuilder {
HtmxBuilder::new(Tag::Empty).form(|b| {
b.attribute("class", "container")
.hx_get(&format!("{}/v2/intros/{}/add", origin, guild_id))
.builder(Tag::FieldSet, |b| {
b.attribute("class", "container")
.attribute("role", "group")
.input(|b| {
b.attribute("placeholder", "enter video url")
.attribute("name", "url")
})
.input(|b| {
b.attribute("placeholder", "enter intro title")
.attribute("name", "name")
})
.button(|b| b.attribute("type", "submit").text("Upload"))
})
})
}

View File

@ -1,9 +1,14 @@
use std::fmt::Debug; use std::fmt::Debug;
use axum::response::Redirect; use axum::{
response::{IntoResponse, Redirect},
Json,
};
use reqwest::StatusCode;
use serde::Serialize;
use crate::lib::domain::intro_tool::models::guild::{ use crate::lib::domain::intro_tool::models::guild::{
GetChannelError, GetGuildError, GetIntroError, AddIntroToGuildError, GetChannelError, GetGuildError, GetIntroError,
}; };
pub(super) trait ErrorAsRedirect<T>: Sized { pub(super) trait ErrorAsRedirect<T>: Sized {
@ -63,3 +68,99 @@ impl<T: Debug> ErrorAsRedirect<T> for Result<T, GetIntroError> {
} }
} }
} }
pub(super) struct ApiResponse<T: Serialize>(StatusCode, Json<T>);
#[derive(Serialize, Debug)]
#[serde(tag = "status")]
pub(super) enum ApiError {
NotFound {
message: String,
},
BadRequest {
message: String,
},
Forbidden {
message: String,
},
InternalServerError {
#[serde(skip)]
message: String,
},
}
impl ApiError {
fn status_code(&self) -> StatusCode {
match self {
ApiError::NotFound { .. } => StatusCode::NOT_FOUND,
ApiError::BadRequest { .. } => StatusCode::BAD_REQUEST,
ApiError::Forbidden { .. } => StatusCode::FORBIDDEN,
ApiError::InternalServerError { .. } => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub(super) fn not_found(message: impl ToString) -> Self {
Self::NotFound {
message: message.to_string(),
}
}
pub(super) fn bad_request(message: impl ToString) -> Self {
Self::BadRequest {
message: message.to_string(),
}
}
pub(super) fn forbidden(message: impl ToString) -> Self {
Self::Forbidden {
message: message.to_string(),
}
}
pub(super) fn internal(message: impl ToString) -> Self {
Self::InternalServerError {
message: message.to_string(),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
(self.status_code(), Json(self)).into_response()
}
}
impl From<GetGuildError> for ApiError {
fn from(value: GetGuildError) -> Self {
match value {
GetGuildError::NotFound => Self::not_found("Guild not found"),
GetGuildError::CouldNotFetchUsers(get_user_error) => {
tracing::error!(err = ?get_user_error, "could not fetch users from guild");
Self::internal("Could not fetch users from guild".to_string())
}
GetGuildError::CouldNotFetchChannels(get_channel_error) => {
tracing::error!(err = ?get_channel_error, "could not fetch channels from guild");
Self::internal("Could not fetch channels from guild".to_string())
}
GetGuildError::Unknown(error) => {
tracing::error!(err = ?error, "unknown error");
Self::internal(error.to_string())
}
}
}
}
impl From<AddIntroToGuildError> for ApiError {
fn from(value: AddIntroToGuildError) -> Self {
match value {
AddIntroToGuildError::Unknown(error) => {
tracing::error!(err = ?error, "unknown error");
Self::internal(error.to_string())
}
}
}
}

View File

@ -10,7 +10,7 @@ use crate::lib::domain::intro_tool::{
self, AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserRequest, Channel, self, AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserRequest, Channel,
ChannelName, CreateChannelError, CreateChannelRequest, CreateGuildError, ChannelName, CreateChannelError, CreateChannelRequest, CreateGuildError,
CreateGuildRequest, CreateUserError, CreateUserRequest, GetChannelError, GetGuildError, CreateGuildRequest, CreateUserError, CreateUserRequest, GetChannelError, GetGuildError,
GetIntroError, GetUserError, Guild, GuildId, GuildRef, Intro, User, UserName, GetIntroError, GetUserError, Guild, GuildId, GuildRef, Intro, IntroId, User, UserName,
}, },
ports::IntroToolRepository, ports::IntroToolRepository,
}; };
@ -347,9 +347,47 @@ impl IntroToolRepository for Sqlite {
async fn add_intro_to_guild( async fn add_intro_to_guild(
&self, &self,
req: AddIntroToGuildRequest, name: &str,
) -> Result<(), AddIntroToGuildError> { guild_id: GuildId,
todo!() filename: String,
) -> Result<IntroId, AddIntroToGuildError> {
let conn = self.conn.lock().await;
let mut query = conn
.prepare(
"
INSERT INTO Intro
(
name,
volume,
guild_id,
filename
)
VALUES
(
:name,
:volume,
:guild_id,
:filename
)
RETURNING id
",
)
.context("failed to prepare query")?;
let intro_id = query
.query_row(
&[
(":name", name),
(":volume", &0.to_string()),
(":guild_id", &guild_id.to_string()),
(":filename", &filename),
],
|row| Ok(row.get::<_, i32>(0)?.into()),
)
.context("failed to query row")?;
Ok(intro_id)
} }
async fn add_intro_to_user( async fn add_intro_to_user(