Compare commits

..

No commits in common. "9bedffa616a632e76d36350546ac374ad75048da" and "b984f048f6a8d71f58e8726a859f54e8c53b5875" have entirely different histories.

7 changed files with 78 additions and 375 deletions

View File

@ -12,18 +12,11 @@ futures = "0.3.26"
reqwest = "0.11.14"
serde = "1.0.152"
serde_json = "1.0.93"
serenity = { version = "0.11.5", default-features = false, features = ["client", "gateway", "rustls_backend", "model", "cache", "voice"] }
songbird = { version = "0.3.0", features = [ "builtin-queue" ] }
thiserror = "1.0.38"
tokio = { version = "1.25.0", features = ["rt-multi-thread", "macros", "signal"] }
tower-http = { version = "0.4.0", features = ["cors"] }
tracing = "0.1.37"
tracing-subscriber = "0.3.16"
uuid = { version = "1.3.0", features = ["v4"] }
[dependencies.serenity]
version = "0.11.5"
default-features = false
features = ["client", "gateway", "rustls_backend", "model", "cache", "voice"]
[dependencies.songbird]
version = "0.3.0"
features = [ "builtin-queue", "yt-dlp" ]

View File

@ -12,16 +12,6 @@
pkgs = import nixpkgs {
inherit system overlays;
};
yt-dlp = pkgs.yt-dlp.overrideAttrs (oldAttr: rec {
inherit (oldAttr) name;
version = "2023.02.17";
src = pkgs.fetchFromGitHub {
owner = "yt-dlp";
repo = "yt-dlp";
rev = "${version}";
sha256 = "naC74T6aqCLX45wJLmygsMmTMqdqLbfXLjJKIKMRpiI=";
};
});
local-rust = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain).override {
extensions = [ "rust-analysis" ];
};
@ -34,22 +24,20 @@
pkg-config
gcc
openssl
pkg-config
python3
ffmpeg
cmake
libopus
yt-dlp
youtube-dl
];
};
packages = with pkgs; flake-utils.lib.flattenTree rec {
default = rustPlatform.buildRustPackage rec {
name = "memejoin-rs";
version = "0.1.4-alpha";
version = "0.1.2-alpha";
src = self;
buildInputs = [ openssl.dev ];
nativeBuildInputs = [ local-rust pkg-config openssl openssl.dev cmake gcc libopus ];
nativeBuildInputs = [ local-rust cmake gcc libopus ];
cargoLock = {
lockFile = ./Cargo.lock;
@ -58,10 +46,10 @@
docker = dockerTools.buildImage {
name = "memejoin-rs";
tag = "0.1.4-alpha";
tag = "0.1.2-alpha";
copyToRoot = buildEnv {
name = "image-root";
paths = [ default cacert openssl openssl.dev ffmpeg libopus youtube-dl yt-dlp ];
paths = [ default ffmpeg libopus youtube-dl ];
};
runAsRoot = ''
#!${runtimeShell}

View File

@ -1,49 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Discord {
pub(crate) access_token: String,
pub(crate) token_type: String,
pub(crate) expires_in: usize,
pub(crate) refresh_token: String,
pub(crate) scope: String,
}
#[derive(Clone)]
pub(crate) struct DiscordSecret {
pub(crate) client_id: String,
pub(crate) client_secret: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct User {
pub(crate) auth: Discord,
pub(crate) name: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub(crate) struct Permissions(pub(crate) u8);
impl Default for Permissions {
fn default() -> Permissions {
Permissions(0)
}
}
impl Permissions {
pub(crate) fn can(&self, perm: Permission) -> bool {
self.0 & (perm as u8) > 0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[repr(u8)]
pub enum Permission {
None,
UploadSounds,
}
impl Permission {
pub(crate) fn all() -> u8 {
0xFF
}
}

View File

@ -2,8 +2,6 @@
#![feature(proc_macro_hygiene)]
#![feature(async_closure)]
mod auth;
mod media;
mod routes;
pub mod settings;
@ -11,7 +9,7 @@ use axum::http::{HeaderValue, Method};
use axum::routing::{get, post};
use axum::Router;
use futures::StreamExt;
use settings::ApiState;
use settings::{ApiState, DiscordSecret};
use songbird::tracks::TrackQueue;
use std::collections::HashMap;
use std::env;
@ -118,44 +116,36 @@ impl EventHandler for Handler {
}
fn spawn_api(settings: Arc<Mutex<Settings>>) {
let secrets = auth::DiscordSecret {
let secrets = DiscordSecret {
client_id: env::var("DISCORD_CLIENT_ID").expect("expected DISCORD_CLIENT_ID env var"),
client_secret: env::var("DISCORD_CLIENT_SECRET")
.expect("expected DISCORD_CLIENT_SECRET env var"),
};
let origin = env::var("APP_ORIGIN").expect("expected APP_ORIGIN");
let state = ApiState {
settings,
secrets,
origin: origin.clone(),
};
let state = ApiState { settings, secrets };
tokio::spawn(async move {
let api = Router::new()
.route("/health", get(routes::health))
.route("/me", get(routes::me))
.route("/intros/:guild/add", get(routes::add_guild_intro))
.route("/intros/:guild/upload", post(routes::upload_guild_intro))
.route("/intros/:guild", get(routes::intros))
.route(
"/intros/:guild/:channel/:intro",
"/intros/:guild/:channel/:user/:intro",
post(routes::add_intro_to_user),
)
.route(
"/intros/:guild/:channel/:intro/remove",
"/intros/:guild/:channel/:user/:intro/remove",
post(routes::remove_intro_to_user),
)
.route("/auth", get(routes::auth))
.layer(
CorsLayer::new()
// TODO: move this to env variable
.allow_origin([origin.parse().unwrap()])
.allow_origin(Any)
.allow_headers(Any)
.allow_methods([Method::GET, Method::POST]),
)
.with_state(Arc::new(state));
let addr = SocketAddr::from(([0, 0, 0, 0], 8100));
let addr = SocketAddr::from(([0, 0, 0, 0], 7756));
info!("socket listening on {addr}");
axum::Server::bind(&addr)
.serve(api.into_make_service())
@ -256,7 +246,7 @@ async fn spawn_bot(settings: Arc<Mutex<Settings>>) {
continue;
};
let source = match guild_settings.intros.get(&intro.index) {
let source = match guild_settings.intros.get(intro.index) {
Some(Intro::Online(intro)) => match songbird::ytdl(&intro.url).await {
Ok(source) => source,
Err(err) => {

View File

@ -1,20 +0,0 @@
use crate::routes::Error;
pub(crate) async fn normalize(src: &str, dest: &str) -> Result<(), Error> {
let child = tokio::process::Command::new("ffmpeg")
.args(["-i", src])
.arg("-vn")
.args(["-map", "0:a"])
.arg(dest)
.spawn()
.map_err(|err| Error::Ffmpeg(err.to_string()))?
.wait()
.await
.map_err(|err| Error::Ffmpeg(err.to_string()))?;
if !child.success() {
return Err(Error::FfmpegTerminated);
}
Ok(())
}

View File

@ -1,7 +1,6 @@
use std::{collections::HashMap, sync::Arc};
use axum::{
body::Bytes,
extract::{Path, Query, State},
http::HeaderMap,
response::IntoResponse,
@ -14,15 +13,11 @@ use serde_json::{json, Value};
use tracing::{error, info};
use uuid::Uuid;
use crate::{auth, settings::FileIntro};
use crate::{
media,
settings::{ApiState, GuildUser, Intro, IntroIndex, UserSettings},
};
use crate::settings::{ApiState, Auth, AuthUser, Intro, IntroIndex};
#[derive(Serialize)]
pub(crate) enum IntroResponse<'a> {
Intros(&'a HashMap<String, Intro>),
Intros(&'a Vec<Intro>),
NoGuildFound,
}
@ -40,11 +35,8 @@ pub(crate) struct Me<'a> {
#[derive(Serialize)]
pub(crate) struct MeGuild<'a> {
// NOTE(pcleavelin): for some reason this doesn't serialize properly if a u64
pub(crate) id: String,
pub(crate) name: String,
pub(crate) channels: Vec<MeChannel<'a>>,
pub(crate) permissions: auth::Permissions,
}
#[derive(Serialize)]
@ -60,51 +52,19 @@ pub(crate) async fn health() -> &'static str {
#[derive(Debug, thiserror::Error)]
pub(crate) enum Error {
#[error("{0}")]
Auth(String),
AuthError(String),
#[error("{0}")]
GetUser(#[from] reqwest::Error),
#[error("User doesn't exist")]
NoUserFound,
#[error("Guild doesn't exist")]
NoGuildFound,
#[error("invalid request")]
InvalidRequest,
#[error("Invalid permissions for request")]
InvalidPermission,
#[error("{0}")]
Ytdl(#[from] std::io::Error),
#[error("{0}")]
Ffmpeg(String),
#[error("ytdl terminated unsuccessfully")]
YtdlTerminated,
#[error("ffmpeg terminated unsuccessfully")]
FfmpegTerminated,
}
impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
error!("{self}");
let body = match self {
Self::AuthError(msg) => msg,
Self::GetUser(error) => error.to_string(),
};
match self {
Self::Auth(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response(),
Self::GetUser(error) => (StatusCode::UNAUTHORIZED, error.to_string()).into_response(),
Self::NoGuildFound => (StatusCode::NOT_FOUND, self.to_string()).into_response(),
Self::NoUserFound => (StatusCode::NOT_FOUND, self.to_string()).into_response(),
Self::InvalidRequest => (StatusCode::BAD_REQUEST, self.to_string()).into_response(),
Self::InvalidPermission => (StatusCode::UNAUTHORIZED, self.to_string()).into_response(),
Self::Ytdl(error) => {
(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response()
}
Self::Ffmpeg(error) => (StatusCode::INTERNAL_SERVER_ERROR, error).into_response(),
Self::YtdlTerminated | Self::FfmpegTerminated => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response()
}
}
(StatusCode::INTERNAL_SERVER_ERROR, body).into_response()
}
}
@ -113,43 +73,34 @@ struct DiscordUser {
pub username: String,
}
#[derive(Deserialize)]
struct DiscordUserGuild {
pub id: String,
pub name: String,
pub owner: bool,
}
pub(crate) async fn auth(
State(state): State<Arc<ApiState>>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Value>, Error> {
let Some(code) = params.get("code") else {
return Err(Error::Auth("no code".to_string()));
return Err(Error::AuthError("no code".to_string()));
};
info!("attempting to get access token with code {}", code);
let mut data = HashMap::new();
let redirect_uri = format!("{}/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);
data.insert("redirect_uri", "http://localhost:5173/auth");
let client = reqwest::Client::new();
let auth: auth::Discord = client
let auth: Auth = client
.post("https://discord.com/api/oauth2/token")
.form(&data)
.send()
.await
.map_err(|err| Error::Auth(err.to_string()))?
.map_err(|err| Error::AuthError(err.to_string()))?
.json()
.await
.map_err(|err| Error::Auth(err.to_string()))?;
.map_err(|err| Error::AuthError(err.to_string()))?;
let token = Uuid::new_v4().to_string();
// Get authorized username
@ -161,51 +112,14 @@ pub(crate) async fn auth(
.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 {
AuthUser {
auth,
name: user.username.clone(),
},
);
// TODO: add permissions based on roles
Ok(Json(json!({"token": token, "username": user.username})))
}
@ -213,7 +127,7 @@ pub(crate) async fn auth(
pub(crate) async fn add_intro_to_user(
State(state): State<Arc<ApiState>>,
headers: HeaderMap,
Path((guild, channel, intro_index)): Path<(u64, String, String)>,
Path((guild, channel, intro_index)): Path<(u64, String, usize)>,
) {
let mut settings = state.settings.lock().await;
let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { return; };
@ -226,23 +140,20 @@ pub(crate) async fn add_intro_to_user(
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 {
index: intro_index,
volume: 20,
});
user.intros.push(IntroIndex {
index: intro_index,
volume: 20,
});
// TODO: don't save on every change
if let Err(err) = settings.save() {
error!("Failed to save config: {err:?}");
}
if let Err(err) = settings.save() {
error!("Failed to save config: {err:?}");
}
}
pub(crate) async fn remove_intro_to_user(
State(state): State<Arc<ApiState>>,
headers: HeaderMap,
Path((guild, channel, intro_index)): Path<(u64, String, String)>,
Path((guild, channel, intro_index)): Path<(u64, String, usize)>,
) {
let mut settings = state.settings.lock().await;
let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { return; };
@ -263,7 +174,6 @@ pub(crate) async fn remove_intro_to_user(
user.intros.remove(index);
}
// TODO: don't save on every change
if let Err(err) = settings.save() {
error!("Failed to save config: {err:?}");
}
@ -279,50 +189,34 @@ pub(crate) async fn intros(
Json(json!(IntroResponse::Intros(&guild.intros)))
}
pub(crate) async fn me(
State(state): State<Arc<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); };
pub(crate) async fn me(State(state): State<Arc<ApiState>>, headers: HeaderMap) -> Json<Value> {
let settings = state.settings.lock().await;
let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { return Json(json!(MeResponse::NoUserFound)); };
let (username, access_token) = match settings.auth_users.get(token) {
Some(user) => (user.name.clone(), user.auth.access_token.clone()),
None => return Err(Error::NoUserFound),
let user = match settings.auth_users.get(token) {
Some(user) => user.name.clone(),
None => return Json(json!(MeResponse::NoUserFound)),
};
let mut me = Me {
username: username.clone(),
username: user.clone(),
guilds: Vec::new(),
};
for g in settings.guilds.iter_mut() {
// TODO: don't do this n^2 lookup
let guild_user =
g.1.users
// TODO: why must clone
.entry(username.clone())
// TODO: check if owner for permissions
.or_insert(Default::default());
for g in &settings.guilds {
let mut guild = MeGuild {
id: g.0.to_string(),
name: g.1.name.clone(),
name: g.0.to_string(),
channels: Vec::new(),
permissions: guild_user.permissions,
};
for channel in g.1.channels.iter_mut() {
let user_settings = channel
.1
.users
.entry(username.clone())
.or_insert(UserSettings { intros: Vec::new() });
for channel in &g.1.channels {
let user_settings = channel.1.users.iter().find(|u| *u.0 == user);
let Some(user) = user_settings else { continue; };
guild.channels.push(MeChannel {
name: channel.0.to_owned(),
intros: &user_settings.intros,
intros: &user.1.intros,
});
}
@ -330,107 +224,8 @@ pub(crate) async fn me(
}
if me.guilds.is_empty() {
Ok(Json(json!(MeResponse::NoUserFound)))
Json(json!(MeResponse::NoUserFound))
} else {
Ok(Json(json!(MeResponse::Me(me))))
Json(json!(MeResponse::Me(me)))
}
}
pub(crate) async fn upload_guild_intro(
State(state): State<Arc<ApiState>>,
Path(guild): Path<u64>,
Query(mut params): Query<HashMap<String, String>>,
headers: HeaderMap,
file: Bytes,
) -> 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(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) };
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 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, file)?;
media::normalize(&temp_path, &dest_path).await?;
std::fs::remove_file(&temp_path)?;
guild.intros.insert(
uuid.clone(),
Intro::File(FileIntro {
filename: format!("{uuid}.mp3"),
friendly_name,
}),
);
Ok(())
}
pub(crate) async fn add_guild_intro(
State(state): State<Arc<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(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) };
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 uuid = Uuid::new_v4().to_string();
let child = tokio::process::Command::new("yt-dlp")
.arg(&url)
.args(["-o", &format!("sounds/{uuid}")])
.args(["-x", "--audio-format", "mp3"])
.spawn()
.map_err(Error::Ytdl)?
.wait()
.await
.map_err(Error::Ytdl)?;
if !child.success() {
return Err(Error::YtdlTerminated);
}
guild.intros.insert(
uuid.clone(),
Intro::File(FileIntro {
filename: format!("{uuid}.mp3"),
friendly_name,
}),
);
Ok(())
}

View File

@ -1,17 +1,33 @@
use std::{collections::HashMap, sync::Arc};
use crate::auth;
use serde::{Deserialize, Serialize};
use serenity::prelude::TypeMapKey;
use tracing::trace;
use uuid::Uuid;
type UserToken = String;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Auth {
pub(crate) access_token: String,
pub(crate) token_type: String,
pub(crate) expires_in: usize,
pub(crate) refresh_token: String,
pub(crate) scope: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct AuthUser {
pub auth: Auth,
pub name: String,
}
pub(crate) struct ApiState {
pub settings: Arc<tokio::sync::Mutex<Settings>>,
pub secrets: auth::DiscordSecret,
pub origin: String,
pub secrets: DiscordSecret,
}
#[derive(Clone)]
pub(crate) struct DiscordSecret {
pub client_id: String,
pub client_secret: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -24,7 +40,7 @@ pub(crate) struct Settings {
pub(crate) guilds: HashMap<u64, GuildSettings>,
#[serde(default)]
pub(crate) auth_users: HashMap<UserToken, auth::User>,
pub(crate) auth_users: HashMap<String, AuthUser>,
}
impl TypeMapKey for Settings {
type Value = Arc<Settings>;
@ -54,20 +70,10 @@ impl Settings {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GuildSettings {
pub(crate) name: String,
#[serde(alias = "userEnteredSoundDelay")]
pub(crate) sound_delay: u64,
#[serde(default)]
pub(crate) channels: HashMap<String, ChannelSettings>,
#[serde(default)]
pub(crate) intros: HashMap<String, Intro>,
#[serde(default)]
pub(crate) users: HashMap<String, GuildUser>,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GuildUser {
pub(crate) permissions: auth::Permissions,
pub(crate) intros: Vec<Intro>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -100,7 +106,7 @@ pub(crate) struct ChannelSettings {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct IntroIndex {
pub(crate) index: String,
pub(crate) index: usize,
pub(crate) volume: i32,
}