diff --git a/flake.nix b/flake.nix index b09a39e..a00b741 100644 --- a/flake.nix +++ b/flake.nix @@ -15,12 +15,12 @@ }; yt-dlp = pkgs.yt-dlp.overrideAttrs (oldAttr: rec { inherit (oldAttr) name; - version = "2024.05.27"; + version = "2025.09.26"; src = pkgs.fetchFromGitHub { owner = "yt-dlp"; repo = "yt-dlp"; rev = "${version}"; - sha256 = "55zDAMwCJPn5zKrAFw4ogTxxmvjrv4PvhYO7PsHbRo4="; + sha256 = "/uzs87Vw+aDNfIJVLOx3C8RyZvWLqjggmnjrOvUX1Eg="; }; }); local-rust = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain).override { @@ -30,6 +30,7 @@ { devShell = pkgs.mkShell { buildInputs = with pkgs; [ + git local-rust rust-analyzer pkg-config diff --git a/src/lib/domain/intro_tool/debug_service.rs b/src/lib/domain/intro_tool/debug_service.rs index 42b15ca..0608d49 100644 --- a/src/lib/domain/intro_tool/debug_service.rs +++ b/src/lib/domain/intro_tool/debug_service.rs @@ -1,7 +1,7 @@ use chrono::{Duration, Utc}; use crate::lib::domain::intro_tool::{ - models, + models::{self, guild::IntroId}, ports::{IntroToolRepository, IntroToolService}, }; @@ -112,7 +112,7 @@ where async fn add_intro_to_guild( &self, req: models::guild::AddIntroToGuildRequest, - ) -> Result<(), models::guild::AddIntroToGuildError> { + ) -> Result { self.wrapped_service.add_intro_to_guild(req).await } diff --git a/src/lib/domain/intro_tool/models/guild.rs b/src/lib/domain/intro_tool/models/guild.rs index 79360a1..2abd84c 100644 --- a/src/lib/domain/intro_tool/models/guild.rs +++ b/src/lib/domain/intro_tool/models/guild.rs @@ -258,10 +258,16 @@ pub struct CreateChannelRequest { } pub struct AddIntroToGuildRequest { - guild_id: GuildId, - name: String, - volume: i32, - filename: String, + pub guild_id: GuildId, + pub name: String, + pub volume: i32, + + pub data: IntroRequestData, +} + +pub enum IntroRequestData { + Data(Vec), + Url(String), } pub struct AddIntroToUserRequest { diff --git a/src/lib/domain/intro_tool/ports.rs b/src/lib/domain/intro_tool/ports.rs index aa6ce18..ca269a2 100644 --- a/src/lib/domain/intro_tool/ports.rs +++ b/src/lib/domain/intro_tool/ports.rs @@ -1,6 +1,6 @@ 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::{ AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserError, AddIntroToUserRequest, @@ -44,10 +44,10 @@ pub trait IntroToolService: Send + Sync + Clone + 'static { req: CreateChannelRequest, ) -> Result; - async fn add_intro_to_guild( + fn add_intro_to_guild( &self, req: AddIntroToGuildRequest, - ) -> Result<(), AddIntroToGuildError>; + ) -> impl Future> + Send; async fn add_intro_to_user( &self, @@ -104,10 +104,12 @@ pub trait IntroToolRepository: Send + Sync + Clone + 'static { req: CreateChannelRequest, ) -> Result; - async fn add_intro_to_guild( + fn add_intro_to_guild( &self, - req: AddIntroToGuildRequest, - ) -> Result<(), AddIntroToGuildError>; + name: &str, + guild_id: GuildId, + filename: String, + ) -> impl Future> + Send; async fn add_intro_to_user( &self, diff --git a/src/lib/domain/intro_tool/service.rs b/src/lib/domain/intro_tool/service.rs index ffcfedc..9fe6874 100644 --- a/src/lib/domain/intro_tool/service.rs +++ b/src/lib/domain/intro_tool/service.rs @@ -1,5 +1,8 @@ +use anyhow::{anyhow, Context}; +use uuid::Uuid; + use crate::lib::domain::intro_tool::{ - models::guild::{GetUserError, GuildId, User}, + models::guild::{self, GetUserError, GuildId, IntroId, User}, ports::{IntroToolRepository, IntroToolService}, }; @@ -37,7 +40,7 @@ where async fn get_guild( &self, guild_id: impl Into, - ) -> Result { + ) -> Result { self.repo.get_guild(guild_id.into()).await } @@ -47,21 +50,21 @@ where async fn get_guild_intros( &self, guild_id: GuildId, - ) -> Result, models::guild::GetIntroError> { + ) -> Result, guild::GetIntroError> { self.repo.get_guild_intros(guild_id).await } async fn get_user( &self, username: impl AsRef + Send, - ) -> Result { + ) -> Result { self.repo.get_user(username).await } async fn get_user_guilds( &self, username: impl AsRef + Send, - ) -> Result, models::guild::GetGuildError> { + ) -> Result, guild::GetGuildError> { self.repo.get_user_guilds(username).await } @@ -71,36 +74,65 @@ where async fn create_guild( &self, - req: models::guild::CreateGuildRequest, - ) -> Result { + req: guild::CreateGuildRequest, + ) -> Result { self.repo.create_guild(req).await } async fn create_user( &self, - req: models::guild::CreateUserRequest, - ) -> Result { + req: guild::CreateUserRequest, + ) -> Result { self.repo.create_user(req).await } async fn create_channel( &self, - req: models::guild::CreateChannelRequest, - ) -> Result { + req: guild::CreateChannelRequest, + ) -> Result { self.repo.create_channel(req).await } async fn add_intro_to_guild( &self, - req: models::guild::AddIntroToGuildRequest, - ) -> Result<(), models::guild::AddIntroToGuildError> { - self.repo.add_intro_to_guild(req).await + req: guild::AddIntroToGuildRequest, + ) -> Result { + let file_name = match &req.data { + guild::IntroRequestData::Data(items) => todo!(), + 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( &self, - req: models::guild::AddIntroToUserRequest, - ) -> Result<(), models::guild::AddIntroToUserError> { + req: guild::AddIntroToUserRequest, + ) -> Result<(), guild::AddIntroToUserError> { self.repo.add_intro_to_user(req).await } } diff --git a/src/lib/inbound/http.rs b/src/lib/inbound/http.rs index 5333818..ecbf80c 100644 --- a/src/lib/inbound/http.rs +++ b/src/lib/inbound/http.rs @@ -1,3 +1,4 @@ +mod handlers; mod page; use std::{net::SocketAddr, sync::Arc}; @@ -115,10 +116,8 @@ where .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("/v2/intros/:guild/add", get(handlers::add_guild_intro)) + // .route("/guild/:guild_id/setup", get(routes::guild_setup)) // .route( // "/guild/:guild_id/add_channel", diff --git a/src/lib/inbound/http/handlers.rs b/src/lib/inbound/http/handlers.rs new file mode 100644 index 0000000..195f298 --- /dev/null +++ b/src/lib/inbound/http/handlers.rs @@ -0,0 +1,81 @@ +use std::collections::HashMap; + +use axum::{ + extract::{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: Sized { + fn from_api(value: T, params: P) -> Result; +} +trait IntoDomain { + fn into_domain(self, params: P) -> Result; +} + +impl, P> IntoDomain for I { + fn into_domain(self, params: P) -> Result { + O::from_api(self, params) + } +} + +impl FromApi, GuildId> for AddIntroToGuildRequest { + fn from_api(value: HashMap, params: GuildId) -> Result { + let Some(url) = value.get("url") else { + return Err(ApiError::bad_request("url is required")); + }; + + let Some(name) = value.get("name") else { + return Err(ApiError::bad_request("name is required")); + }; + + Ok(Self { + guild_id: params, + name: name.to_string(), + volume: 0, + data: IntroRequestData::Url(url.to_string()), + }) + } +} + +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 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) +} diff --git a/src/lib/inbound/http/page.rs b/src/lib/inbound/http/page.rs index a93e3a0..fba14b5 100644 --- a/src/lib/inbound/http/page.rs +++ b/src/lib/inbound/http/page.rs @@ -133,6 +133,8 @@ pub async fn guild_dashboard( return Err(Redirect::to(&format!("{}/error", state.origin))); } + let can_upload = true; + Ok(Html( HtmxBuilder::new(Tag::Html) .push_builder(page_header("MemeJoin - Dashboard")) @@ -156,24 +158,24 @@ pub async fn guild_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 - // }; + let 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") @@ -290,3 +292,41 @@ fn intro_list<'a>(intros: impl Iterator, label: &str, post: &s .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")) + }) + }) +} diff --git a/src/lib/inbound/response.rs b/src/lib/inbound/response.rs index 82b4ac4..0ddbc20 100644 --- a/src/lib/inbound/response.rs +++ b/src/lib/inbound/response.rs @@ -1,9 +1,14 @@ 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::{ - GetChannelError, GetGuildError, GetIntroError, + AddIntroToGuildError, GetChannelError, GetGuildError, GetIntroError, }; pub(super) trait ErrorAsRedirect: Sized { @@ -63,3 +68,99 @@ impl ErrorAsRedirect for Result { } } } + +pub(super) struct ApiResponse(StatusCode, Json); + +#[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 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 for ApiError { + fn from(value: AddIntroToGuildError) -> Self { + match value { + AddIntroToGuildError::Unknown(error) => { + tracing::error!(err = ?error, "unknown error"); + + Self::internal(error.to_string()) + } + } + } +} diff --git a/src/lib/outbound/sqlite.rs b/src/lib/outbound/sqlite.rs index 2f9d666..ac26a08 100644 --- a/src/lib/outbound/sqlite.rs +++ b/src/lib/outbound/sqlite.rs @@ -10,7 +10,7 @@ use crate::lib::domain::intro_tool::{ self, AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserRequest, Channel, ChannelName, CreateChannelError, CreateChannelRequest, CreateGuildError, CreateGuildRequest, CreateUserError, CreateUserRequest, GetChannelError, GetGuildError, - GetIntroError, GetUserError, Guild, GuildId, GuildRef, Intro, User, UserName, + GetIntroError, GetUserError, Guild, GuildId, GuildRef, Intro, IntroId, User, UserName, }, ports::IntroToolRepository, }; @@ -347,9 +347,47 @@ impl IntroToolRepository for Sqlite { async fn add_intro_to_guild( &self, - req: AddIntroToGuildRequest, - ) -> Result<(), AddIntroToGuildError> { - todo!() + name: &str, + guild_id: GuildId, + filename: String, + ) -> Result { + 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(