Compare commits

...

6 Commits

Author SHA1 Message Date
Patrick Cleavelin 9bedffa616 allow intro uploads 2023-03-08 22:11:20 -06:00
Patrick Cleavelin 665e83a6fe guild level permissions, now not just saved for a session 2023-03-07 19:45:09 -06:00
Patrick Cleavelin a484de34a6 make all urls variable 2023-03-07 17:56:07 -06:00
Patrick Cleavelin f5e976103c latenite: QOL login stuff/permissions 2023-03-05 22:22:45 -06:00
Patrick Cleavelin 1ed1e55db4 permissions (still need to get discord roles)
intros can now be added to a guild (given proper permissions)
2023-03-03 01:13:21 -06:00
Patrick Cleavelin d87708772e pin ytdlp version 2023-03-03 01:02:45 -06:00
7 changed files with 375 additions and 78 deletions

View File

@ -12,11 +12,18 @@ 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,6 +12,16 @@
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" ];
};
@ -24,20 +34,22 @@
pkg-config
gcc
openssl
pkg-config
python3
ffmpeg
cmake
libopus
youtube-dl
yt-dlp
];
};
packages = with pkgs; flake-utils.lib.flattenTree rec {
default = rustPlatform.buildRustPackage rec {
name = "memejoin-rs";
version = "0.1.2-alpha";
version = "0.1.4-alpha";
src = self;
nativeBuildInputs = [ local-rust cmake gcc libopus ];
buildInputs = [ openssl.dev ];
nativeBuildInputs = [ local-rust pkg-config openssl openssl.dev cmake gcc libopus ];
cargoLock = {
lockFile = ./Cargo.lock;
@ -46,10 +58,10 @@
docker = dockerTools.buildImage {
name = "memejoin-rs";
tag = "0.1.2-alpha";
tag = "0.1.4-alpha";
copyToRoot = buildEnv {
name = "image-root";
paths = [ default ffmpeg libopus youtube-dl ];
paths = [ default cacert openssl openssl.dev ffmpeg libopus youtube-dl yt-dlp ];
};
runAsRoot = ''
#!${runtimeShell}

49
src/auth.rs Normal file
View File

@ -0,0 +1,49 @@
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,6 +2,8 @@
#![feature(proc_macro_hygiene)]
#![feature(async_closure)]
mod auth;
mod media;
mod routes;
pub mod settings;
@ -9,7 +11,7 @@ use axum::http::{HeaderValue, Method};
use axum::routing::{get, post};
use axum::Router;
use futures::StreamExt;
use settings::{ApiState, DiscordSecret};
use settings::ApiState;
use songbird::tracks::TrackQueue;
use std::collections::HashMap;
use std::env;
@ -116,36 +118,44 @@ impl EventHandler for Handler {
}
fn spawn_api(settings: Arc<Mutex<Settings>>) {
let secrets = DiscordSecret {
let secrets = auth::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 };
let state = ApiState {
settings,
secrets,
origin: origin.clone(),
};
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/:user/:intro",
"/intros/:guild/:channel/:intro",
post(routes::add_intro_to_user),
)
.route(
"/intros/:guild/:channel/:user/:intro/remove",
"/intros/:guild/:channel/:intro/remove",
post(routes::remove_intro_to_user),
)
.route("/auth", get(routes::auth))
.layer(
CorsLayer::new()
.allow_origin(Any)
// TODO: move this to env variable
.allow_origin([origin.parse().unwrap()])
.allow_headers(Any)
.allow_methods([Method::GET, Method::POST]),
)
.with_state(Arc::new(state));
let addr = SocketAddr::from(([0, 0, 0, 0], 7756));
let addr = SocketAddr::from(([0, 0, 0, 0], 8100));
info!("socket listening on {addr}");
axum::Server::bind(&addr)
.serve(api.into_make_service())
@ -246,7 +256,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) => {

20
src/media.rs Normal file
View File

@ -0,0 +1,20 @@
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,6 +1,7 @@
use std::{collections::HashMap, sync::Arc};
use axum::{
body::Bytes,
extract::{Path, Query, State},
http::HeaderMap,
response::IntoResponse,
@ -13,11 +14,15 @@ use serde_json::{json, Value};
use tracing::{error, info};
use uuid::Uuid;
use crate::settings::{ApiState, Auth, AuthUser, Intro, IntroIndex};
use crate::{auth, settings::FileIntro};
use crate::{
media,
settings::{ApiState, GuildUser, Intro, IntroIndex, UserSettings},
};
#[derive(Serialize)]
pub(crate) enum IntroResponse<'a> {
Intros(&'a Vec<Intro>),
Intros(&'a HashMap<String, Intro>),
NoGuildFound,
}
@ -35,8 +40,11 @@ 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)]
@ -52,19 +60,51 @@ pub(crate) async fn health() -> &'static str {
#[derive(Debug, thiserror::Error)]
pub(crate) enum Error {
#[error("{0}")]
AuthError(String),
Auth(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 {
let body = match self {
Self::AuthError(msg) => msg,
Self::GetUser(error) => error.to_string(),
};
error!("{self}");
(StatusCode::INTERNAL_SERVER_ERROR, body).into_response()
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()
}
}
}
}
@ -73,34 +113,43 @@ 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::AuthError("no code".to_string()));
return Err(Error::Auth("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", "http://localhost:5173/auth");
data.insert("redirect_uri", &redirect_uri);
let client = reqwest::Client::new();
let auth: Auth = client
let auth: auth::Discord = client
.post("https://discord.com/api/oauth2/token")
.form(&data)
.send()
.await
.map_err(|err| Error::AuthError(err.to_string()))?
.map_err(|err| Error::Auth(err.to_string()))?
.json()
.await
.map_err(|err| Error::AuthError(err.to_string()))?;
.map_err(|err| Error::Auth(err.to_string()))?;
let token = Uuid::new_v4().to_string();
// Get authorized username
@ -112,14 +161,51 @@ 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(),
AuthUser {
auth::User {
auth,
name: user.username.clone(),
},
);
// TODO: add permissions based on roles
Ok(Json(json!({"token": token, "username": user.username})))
}
@ -127,7 +213,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, usize)>,
Path((guild, channel, intro_index)): Path<(u64, String, String)>,
) {
let mut settings = state.settings.lock().await;
let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { return; };
@ -140,20 +226,23 @@ 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; };
user.intros.push(IntroIndex {
index: intro_index,
volume: 20,
});
if !user.intros.iter().any(|intro| intro.index == intro_index) {
user.intros.push(IntroIndex {
index: intro_index,
volume: 20,
});
if let Err(err) = settings.save() {
error!("Failed to save config: {err:?}");
// TODO: don't save on every change
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, usize)>,
Path((guild, channel, intro_index)): Path<(u64, String, String)>,
) {
let mut settings = state.settings.lock().await;
let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else { return; };
@ -174,6 +263,7 @@ 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:?}");
}
@ -189,34 +279,50 @@ pub(crate) async fn intros(
Json(json!(IntroResponse::Intros(&guild.intros)))
}
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)); };
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); };
let user = match settings.auth_users.get(token) {
Some(user) => user.name.clone(),
None => 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 mut me = Me {
username: user.clone(),
username: username.clone(),
guilds: Vec::new(),
};
for g in &settings.guilds {
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());
let mut guild = MeGuild {
name: g.0.to_string(),
id: g.0.to_string(),
name: g.1.name.clone(),
channels: Vec::new(),
permissions: guild_user.permissions,
};
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; };
for channel in g.1.channels.iter_mut() {
let user_settings = channel
.1
.users
.entry(username.clone())
.or_insert(UserSettings { intros: Vec::new() });
guild.channels.push(MeChannel {
name: channel.0.to_owned(),
intros: &user.1.intros,
intros: &user_settings.intros,
});
}
@ -224,8 +330,107 @@ pub(crate) async fn me(State(state): State<Arc<ApiState>>, headers: HeaderMap) -
}
if me.guilds.is_empty() {
Json(json!(MeResponse::NoUserFound))
Ok(Json(json!(MeResponse::NoUserFound)))
} else {
Json(json!(MeResponse::Me(me)))
Ok(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,33 +1,17 @@
use std::{collections::HashMap, sync::Arc};
use crate::auth;
use serde::{Deserialize, Serialize};
use serenity::prelude::TypeMapKey;
use tracing::trace;
use uuid::Uuid;
#[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,
}
type UserToken = String;
pub(crate) struct ApiState {
pub settings: Arc<tokio::sync::Mutex<Settings>>,
pub secrets: DiscordSecret,
}
#[derive(Clone)]
pub(crate) struct DiscordSecret {
pub client_id: String,
pub client_secret: String,
pub secrets: auth::DiscordSecret,
pub origin: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -40,7 +24,7 @@ pub(crate) struct Settings {
pub(crate) guilds: HashMap<u64, GuildSettings>,
#[serde(default)]
pub(crate) auth_users: HashMap<String, AuthUser>,
pub(crate) auth_users: HashMap<UserToken, auth::User>,
}
impl TypeMapKey for Settings {
type Value = Arc<Settings>;
@ -70,10 +54,20 @@ impl Settings {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GuildSettings {
#[serde(alias = "userEnteredSoundDelay")]
pub(crate) name: String,
pub(crate) sound_delay: u64,
#[serde(default)]
pub(crate) channels: HashMap<String, ChannelSettings>,
pub(crate) intros: Vec<Intro>,
#[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,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -106,7 +100,7 @@ pub(crate) struct ChannelSettings {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct IntroIndex {
pub(crate) index: usize,
pub(crate) index: String,
pub(crate) volume: i32,
}