Compare commits

..

4 Commits

5 changed files with 399 additions and 122 deletions

25
Cargo.lock generated
View File

@ -492,6 +492,12 @@ version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
[[package]]
name = "either"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
[[package]]
name = "encoding_rs"
version = "0.8.32"
@ -967,6 +973,24 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6"
[[package]]
name = "iter_tools"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "531cafdc99b3b3252bb32f5620e61d56b19415efc19900b12d1b2e7483854897"
dependencies = [
"itertools",
]
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.9"
@ -1073,6 +1097,7 @@ dependencies = [
"chrono",
"dotenv",
"futures",
"iter_tools",
"reqwest",
"rusqlite",
"serde",

View File

@ -12,6 +12,7 @@ axum-extra = { version = "0.7.5", features = ["cookie-private", "cookie"] }
chrono = "0.4.23"
dotenv = "0.15.0"
futures = "0.3.26"
iter_tools = "0.1.4"
reqwest = "0.11.14"
rusqlite = { version = "0.29.0", features = ["bundled"] }
serde = "1.0.152"

207
src/db.rs
View File

@ -1,6 +1,10 @@
use std::path::Path;
use iter_tools::Itertools;
use rusqlite::{Connection, Result};
use tracing::{error, warn};
use crate::auth;
pub struct Database {
conn: Connection,
@ -17,7 +21,7 @@ impl Database {
let mut query = self.conn.prepare(
"
SELECT
id, name, sound_delay
id, name, soundDelay
FROM Guild
LEFT JOIN UserGuild ON UserGuild.guild_id = Guild.id
WHERE UserGuild.username = :username
@ -39,6 +43,196 @@ impl Database {
guilds
}
pub fn get_guild_intros(&self, guild_id: u64) -> Result<Vec<Intro>> {
let mut query = self.conn.prepare(
"
SELECT
Intro.id,
Intro.name
FROM Intro
WHERE
Intro.guild_id = :guild_id
",
)?;
// NOTE(pcleavelin): for some reason this needs to be a let-binding or else
// the compiler complains about it being dropped too early (maybe I should update the compiler version)
let intros = query
.query_map(
&[
// :vomit:
(":guild_id", &guild_id.to_string()),
],
|row| {
Ok(Intro {
id: row.get(0)?,
name: row.get(1)?,
})
},
)?
.into_iter()
.collect::<Result<Vec<Intro>>>();
intros
}
pub fn get_all_user_intros(&self, guild_id: u64) -> Result<Vec<UserIntro>> {
let mut query = self.conn.prepare(
"
SELECT
Intro.id,
Intro.name,
UI.channel_name,
UI.username
FROM Intro
LEFT JOIN UserIntro UI ON UI.intro_id = Intro.id
WHERE
UI.guild_id = :guild_id
ORDER BY UI.username DESC, UI.channel_name DESC, UI.intro_id;
",
)?;
// NOTE(pcleavelin): for some reason this needs to be a let-binding or else
// the compiler complains about it being dropped too early (maybe I should update the compiler version)
let intros = query
.query_map(
&[
// :vomit:
(":guild_id", &guild_id.to_string()),
],
|row| {
Ok(UserIntro {
intro: Intro {
id: row.get(0)?,
name: row.get(1)?,
},
channel_name: row.get(2)?,
username: row.get(3)?,
})
},
)?
.into_iter()
.collect::<Result<Vec<UserIntro>>>();
intros
}
pub(crate) fn get_user_permissions(
&self,
username: &str,
guild_id: u64,
) -> Result<auth::Permissions> {
self.conn.query_row(
"
SELECT
permissions
FROM UserPermission
WHERE
username = ?1
",
[username],
|row| Ok(auth::Permissions(row.get(0)?)),
)
}
pub(crate) fn get_guild_channels(&self, guild_id: u64) -> Result<Vec<String>> {
let mut query = self.conn.prepare(
"
SELECT
Channel.name
FROM Channel
WHERE
Channel.guild_id = :guild_id
ORDER BY Channel.name DESC
",
)?;
// NOTE(pcleavelin): for some reason this needs to be a let-binding or else
// the compiler complains about it being dropped too early (maybe I should update the compiler version)
let intros = query
.query_map(
&[
// :vomit:
(":guild_id", &guild_id.to_string()),
],
|row| Ok(row.get(0)?),
)?
.into_iter()
.collect::<Result<Vec<String>>>();
intros
}
pub(crate) fn get_user_channel_intros(
&self,
username: &str,
guild_id: u64,
channel_name: &str,
) -> Result<Vec<Intro>> {
let all_user_intros = self.get_all_user_intros(guild_id)?.into_iter();
let intros = all_user_intros
.filter(|intro| &intro.username == &username && &intro.channel_name == channel_name)
.map(|intro| intro.intro)
.collect();
Ok(intros)
}
pub fn insert_user_intro(
&self,
username: &str,
guild_id: u64,
channel_name: &str,
intro_id: i32,
) -> Result<()> {
let affected = self.conn.execute(
"INSERT INTO UserIntro (username, guild_id, channel_name, intro_id) VALUES (?1, ?2, ?3, ?4)",
&[
username,
&guild_id.to_string(),
channel_name,
&intro_id.to_string(),
],
)?;
if affected < 1 {
warn!("no rows affected when attempting to insert user intro");
}
Ok(())
}
pub fn remove_user_intro(
&self,
username: &str,
guild_id: u64,
channel_name: &str,
intro_id: i32,
) -> Result<()> {
let affected = self.conn.execute(
"DELETE FROM
UserIntro
WHERE
username = ?1
AND guild_id = ?2
AND channel_name = ?3
AND intro_id = ?4",
&[
username,
&guild_id.to_string(),
channel_name,
&intro_id.to_string(),
],
)?;
if affected < 1 {
warn!("no rows affected when attempting to delete user intro");
}
Ok(())
}
}
pub struct Guild {
@ -46,3 +240,14 @@ pub struct Guild {
pub name: String,
pub sound_delay: u32,
}
pub struct Intro {
pub id: i32,
pub name: String,
}
pub struct UserIntro {
pub intro: Intro,
pub channel_name: String,
pub username: String,
}

View File

@ -8,6 +8,7 @@ use axum::{
extract::{Path, State},
response::{Html, Redirect},
};
use iter_tools::Itertools;
use tracing::error;
fn page_header(title: &str) -> HtmxBuilder {
@ -69,13 +70,14 @@ fn guild_list<'a>(origin: &str, guilds: impl Iterator<Item = &'a db::Guild>) ->
}
fn intro_list<'a>(
intros: impl Iterator<Item = (&'a String, &'a Intro)>,
intros: impl Iterator<Item = &'a db::Intro>,
label: &str,
post: &str,
) -> HtmxBuilder {
HtmxBuilder::new(Tag::Empty).form(|b| {
b.attribute("class", "container")
.hx_post(post)
.hx_target("closest #channel-intro-selector")
.attribute("hx-encoding", "multipart/form-data")
.builder(Tag::FieldSet, |b| {
let mut b = b
@ -84,9 +86,10 @@ fn intro_list<'a>(
for intro in intros {
b = b.builder(Tag::Label, |b| {
b.builder(Tag::Input, |b| {
b.attribute("type", "checkbox").attribute("name", &intro.0)
b.attribute("type", "checkbox")
.attribute("name", &intro.id.to_string())
})
.builder_text(Tag::Paragraph, intro.1.friendly_name())
.builder_text(Tag::Paragraph, &intro.name)
});
}
@ -101,19 +104,34 @@ pub(crate) async fn guild_dashboard(
user: User,
Path(guild_id): Path<u64>,
) -> Result<Html<String>, Redirect> {
let settings = state.settings.lock().await;
let db = state.db.lock().await;
let Some(guild) = settings.guilds.get(&guild_id) else {
error!(%guild_id, "no such guild");
return Err(Redirect::to(&format!("{}/", state.origin)));
};
let Some(guild_user) = guild.users.get(&user.name) else {
error!(%guild_id, %user.name, "no user in guild");
return Err(Redirect::to(&format!("{}/", state.origin)));
};
let guild_intros = db.get_guild_intros(guild_id).map_err(|err| {
error!(?err, %guild_id, "couldn't get guild intros");
// TODO: change to actual error
Redirect::to("/login")
})?;
let guild_channels = db.get_guild_channels(guild_id).map_err(|err| {
error!(?err, %guild_id, "couldn't get guild channels");
// TODO: change to actual error
Redirect::to("/login")
})?;
let all_user_intros = db.get_all_user_intros(guild_id).map_err(|err| {
error!(?err, %guild_id, "couldn't get user intros");
// TODO: change to actual error
Redirect::to("/login")
})?;
let user_permissions = db
.get_user_permissions(&user.name, guild_id)
.unwrap_or_default();
let can_upload = guild_user.permissions.can(auth::Permission::UploadSounds);
let is_moderator = guild_user.permissions.can(auth::Permission::DeleteSounds);
let user_intros = all_user_intros
.iter()
.filter(|intro| &intro.username == &user.name)
.group_by(|intro| &intro.channel_name);
let can_upload = user_permissions.can(auth::Permission::UploadSounds);
let is_moderator = user_permissions.can(auth::Permission::DeleteSounds);
Ok(Html(
HtmxBuilder::new(Tag::Html)
@ -162,53 +180,32 @@ pub(crate) async fn guild_dashboard(
.builder(Tag::Article, |b| {
let mut b = b.builder_text(Tag::Header, "Guild Intros");
for (channel_name, channel_settings) in &guild.channels {
if let Some(channel_user) = channel_settings.users.get(&user.name) {
let current_intros =
channel_user.intros.iter().filter_map(|intro_index| {
Some((
&intro_index.index,
guild.intros.get(&intro_index.index)?,
))
});
let available_intros =
guild.intros.iter().filter_map(|intro| {
if !channel_user
.intros
.iter()
.any(|intro_index| intro.0 == &intro_index.index)
{
Some((intro.0, intro.1))
} else {
None
}
});
b = b.builder(Tag::Article, |b| {
b.builder_text(Tag::Header, channel_name).builder(
Tag::Div,
|b| {
b.builder_text(Tag::Strong, "Your Current Intros")
.push_builder(intro_list(
current_intros,
"Remove Intro",
&format!(
"{}/v2/intros/remove/{}/{}",
state.origin, guild_id, channel_name
),
))
.builder_text(Tag::Strong, "Select Intros")
.push_builder(intro_list(
available_intros,
"Add Intro",
&format!(
"{}/v2/intros/add/{}/{}",
state.origin, guild_id, channel_name
),
))
},
)
});
}
let mut user_intros = user_intros.into_iter().peekable();
for guild_channel_name in guild_channels {
// Get user intros for this channel
let intros = user_intros
.peeking_take_while(|(channel_name, _)| {
channel_name == &&guild_channel_name
})
.map(|(_, intros)| intros.map(|intro| &intro.intro))
.flatten();
b = b.builder(Tag::Article, |b| {
b.builder_text(Tag::Header, &guild_channel_name).builder(
Tag::Div,
|b| {
b.attribute("id", "channel-intro-selector")
.push_builder(channel_intro_selector(
&state.origin,
guild_id,
&guild_channel_name,
intros,
guild_intros.iter(),
))
},
)
});
}
b
@ -219,6 +216,28 @@ pub(crate) async fn guild_dashboard(
))
}
pub fn channel_intro_selector<'a>(
origin: &str,
guild_id: u64,
channel_name: &String,
intros: impl Iterator<Item = &'a db::Intro>,
guild_intros: impl Iterator<Item = &'a db::Intro>,
) -> HtmxBuilder {
HtmxBuilder::new(Tag::Empty)
.builder_text(Tag::Strong, "Your Current Intros")
.push_builder(intro_list(
intros,
"Remove Intro",
&format!("{}/v2/intros/remove/{}/{}", origin, guild_id, &channel_name),
))
.builder_text(Tag::Strong, "Select Intros")
.push_builder(intro_list(
guild_intros,
"Add Intro",
&format!("{}/v2/intros/add/{}/{}", origin, guild_id, channel_name),
))
}
fn upload_form(origin: &str, guild_id: u64) -> HtmxBuilder {
HtmxBuilder::new(Tag::Empty).form(|b| {
b.attribute("class", "container")

View File

@ -4,11 +4,12 @@ use axum::{
body::Bytes,
extract::{Multipart, Path, Query, State},
http::{HeaderMap, HeaderValue},
response::{IntoResponse, Redirect},
response::{Html, IntoResponse, Redirect},
Form, Json,
};
use axum_extra::extract::{cookie::Cookie, CookieJar};
use iter_tools::Itertools;
use reqwest::{Proxy, StatusCode, Url};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@ -17,6 +18,8 @@ use uuid::Uuid;
use crate::{
auth::{self, User},
htmx::Build,
page,
settings::FileIntro,
};
use crate::{
@ -89,6 +92,9 @@ pub(crate) enum Error {
YtdlTerminated,
#[error("ffmpeg terminated unsuccessfully")]
FfmpegTerminated,
#[error("database error: {0}")]
Database(#[from] rusqlite::Error),
}
impl IntoResponse for Error {
@ -111,6 +117,10 @@ impl IntoResponse for Error {
Self::YtdlTerminated | Self::FfmpegTerminated => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response()
}
Self::Database(error) => {
(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response()
}
}
}
}
@ -328,45 +338,52 @@ pub(crate) async fn v2_add_intro_to_user(
Path((guild_id, channel)): Path<(u64, String)>,
user: User,
mut form_data: Multipart,
) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert("HX-Refresh", HeaderValue::from_static("true"));
let mut settings = state.settings.lock().await;
let Some(guild) = settings.guilds.get_mut(&guild_id) else {
return headers;
};
let Some(channel) = guild.channels.get_mut(&channel) else {
return headers;
};
let Some(channel_user) = channel.users.get_mut(&user.name) else {
return headers;
};
) -> Result<Html<String>, Redirect> {
let db = state.db.lock().await;
while let Ok(Some(field)) = form_data.next_field().await {
let Some(field_name) = field.name() else {
let Some(intro_id) = field.name() else {
continue;
};
if !channel_user
.intros
.iter()
.any(|intro| intro.index == field_name)
{
channel_user.intros.push(IntroIndex {
index: field_name.to_string(),
volume: 20,
});
}
let intro_id = intro_id.parse::<i32>().map_err(|err| {
error!(?err, "invalid intro id");
// TODO: change to actual error
Redirect::to("/login")
})?;
db.insert_user_intro(&user.name, guild_id, &channel, intro_id)
.map_err(|err| {
error!(?err, "failed to add user intro");
// TODO: change to actual error
Redirect::to("/login")
})?;
}
// TODO: don't save on every change
if let Err(err) = settings.save() {
error!("Failed to save config: {err:?}");
}
let guild_intros = db.get_guild_intros(guild_id).map_err(|err| {
error!(?err, %guild_id, "couldn't get guild intros");
// TODO: change to actual error
Redirect::to("/login")
})?;
headers
let intros = db
.get_user_channel_intros(&user.name, guild_id, &channel)
.map_err(|err| {
error!(?err, user = %user.name, %guild_id, "couldn't get user intros");
// TODO: change to actual error
Redirect::to("/login")
})?;
Ok(Html(
page::channel_intro_selector(
&state.origin,
guild_id,
&channel,
intros.iter(),
guild_intros.iter(),
)
.build(),
))
}
pub(crate) async fn v2_remove_intro_from_user(
@ -374,42 +391,52 @@ pub(crate) async fn v2_remove_intro_from_user(
Path((guild_id, channel)): Path<(u64, String)>,
user: User,
mut form_data: Multipart,
) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert("HX-Refresh", HeaderValue::from_static("true"));
let mut settings = state.settings.lock().await;
let Some(guild) = settings.guilds.get_mut(&guild_id) else {
return headers;
};
let Some(channel) = guild.channels.get_mut(&channel) else {
return headers;
};
let Some(channel_user) = channel.users.get_mut(&user.name) else {
return headers;
};
) -> Result<Html<String>, Redirect> {
let db = state.db.lock().await;
while let Ok(Some(field)) = form_data.next_field().await {
let Some(field_name) = field.name() else {
let Some(intro_id) = field.name() else {
continue;
};
if let Some(index) = channel_user
.intros
.iter()
.position(|intro| intro.index == field_name)
{
channel_user.intros.remove(index);
}
let intro_id = intro_id.parse::<i32>().map_err(|err| {
error!(?err, "invalid intro id");
// TODO: change to actual error
Redirect::to("/login")
})?;
db.remove_user_intro(&user.name, guild_id, &channel, intro_id)
.map_err(|err| {
error!(?err, "failed to remove user intro");
// TODO: change to actual error
Redirect::to("/login")
})?;
}
// TODO: don't save on every change
if let Err(err) = settings.save() {
error!("Failed to save config: {err:?}");
}
let guild_intros = db.get_guild_intros(guild_id).map_err(|err| {
error!(?err, %guild_id, "couldn't get guild intros");
// TODO: change to actual error
Redirect::to("/login")
})?;
headers
let intros = db
.get_user_channel_intros(&user.name, guild_id, &channel)
.map_err(|err| {
error!(?err, user = %user.name, %guild_id, "couldn't get user intros");
// TODO: change to actual error
Redirect::to("/login")
})?;
Ok(Html(
page::channel_intro_selector(
&state.origin,
guild_id,
&channel,
intros.iter(),
guild_intros.iter(),
)
.build(),
))
}
pub(crate) async fn add_intro_to_user(