Compare commits

...

2 Commits

Author SHA1 Message Date
Patrick Cleavelin d935e8ea6d ci maybe?
ci/woodpecker/push/woodpecker Pipeline failed Details
2023-07-28 15:08:58 -05:00
Patrick Cleavelin 40c651d99d everything done except mod dashboard 2023-07-28 14:15:11 -05:00
9 changed files with 1584 additions and 540 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
**/result
result/
result
.env

25
.woodpecker.yml Normal file
View File

@ -0,0 +1,25 @@
pipeline:
build:
image: alpine:edge
- apk add --no-cache git nix --repository=https://dl-cdn.alpinelinux.org/alpine/edge/testing
- nix build --extra-experimental-features nix-command --extra-experimental-features flakes .#default
build-docker-image:
when:
event: tag
image: alpine:edge
commands:
- echo "system-features = nixos-test benchmark big-parallel uid-range kvm" > /etc/nix/nix.conf
- nix build --extra-experimental-features nix-command --extra-experimental-features flakes .#docker
publish-image:
when:
event: tag
image: git.spacegirl.nl/patrick/plugin-artifact
settings:
tag: $CI_COMMIT_TAG
user: patrick
password:
from_secret: forgejo_token
repo: $CI_REPO
image_tar: result

1121
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,11 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = "0.6.9"
async-trait = "0.1.72"
axum = { version = "0.6.9", features = ["headers", "multipart"] }
axum-extra = { version = "0.7.5", features = ["cookie-private", "cookie"] }
chrono = "0.4.23"
dotenv = "0.15.0"
futures = "0.3.26"
reqwest = "0.11.14"
serde = "1.0.152"

395
src/htmx.rs Normal file
View File

@ -0,0 +1,395 @@
use std::collections::HashMap;
pub trait Build {
fn build(self) -> String;
}
#[derive(PartialEq)]
pub enum Tag {
Empty,
Html,
Head,
Link,
Script,
Title,
Body,
Main,
Break,
Details,
Summary,
Dialog,
Article,
Header,
Div,
Table,
TableHead,
TableHeader,
TableBody,
TableRow,
TableData,
Progress,
Form,
Label,
FieldSet,
Input,
Select,
Option,
Nav,
OrderedList,
UnorderedList,
ListItem,
Anchor,
Button,
Header1,
Header2,
Header3,
Header4,
Header5,
Header6,
Strong,
Paragraph,
JustText,
}
impl Tag {
fn as_str(&self) -> &'static str {
match self {
Self::Empty => "",
Self::JustText => "",
Self::Html => "html",
Self::Head => "head",
Self::Link => "link",
Self::Script => "script",
Self::Title => "title",
Self::Body => "body",
Self::Main => "main",
Self::Break => "break",
Self::Progress => "progress",
Self::Details => "details",
Self::Summary => "summary",
Self::Dialog => "dialog",
Self::Article => "article",
Self::Header => "header",
Self::Div => "div",
Self::Table => "table",
Self::TableHead => "thead",
Self::TableHeader => "th",
Self::TableBody => "tbody",
Self::TableRow => "tr",
Self::TableData => "td",
Self::Form => "form",
Self::Label => "label",
Self::FieldSet => "fieldset",
Self::Input => "input",
Self::Select => "select",
Self::Option => "option",
Self::Nav => "nav",
Self::OrderedList => "ol",
Self::UnorderedList => "ul",
Self::ListItem => "li",
Self::Anchor => "a",
Self::Button => "button",
Self::Header1 => "h1",
Self::Header2 => "h2",
Self::Header3 => "h3",
Self::Header4 => "h4",
Self::Header5 => "h5",
Self::Header6 => "h6",
Self::Strong => "strong",
Self::Paragraph => "paragraph",
}
}
fn start(&self) -> String {
if *self != Self::JustText && *self != Self::Empty {
format!("<{}>", self.as_str())
} else {
String::new()
}
}
fn end(&self) -> String {
if *self != Self::JustText
&& *self != Self::Empty
&& *self != Self::Link
&& *self != Self::Input
{
format!("</{}>", self.as_str())
} else {
String::new()
}
}
}
pub enum SwapMethod {
InnerHtml,
OuterHtml,
BeforeEnd,
Refresh,
}
impl SwapMethod {
fn as_str(&self) -> &'static str {
match self {
SwapMethod::InnerHtml => "innerHTML",
SwapMethod::OuterHtml => "outerHTML",
SwapMethod::BeforeEnd => "beforeend",
SwapMethod::Refresh => "refresh",
}
}
}
pub struct HtmxBuilder {
tag: Tag,
attributes: HashMap<String, String>,
children: Vec<HtmxBuilder>,
text: Option<String>,
}
impl Build for HtmxBuilder {
#[must_use]
fn build(self) -> String {
let mut string = String::new();
// TODO: do this better
{
if self.tag != Tag::JustText && self.tag != Tag::Empty {
string.push_str(&format!("<{}", self.tag.as_str()));
}
for (attr, value) in self.attributes {
if value.is_empty() {
string.push_str(&format!(" {attr} "));
} else {
string.push_str(&format!(" {attr}='{value}' "));
}
}
if self.tag != Tag::JustText && self.tag != Tag::Empty {
string.push_str(">");
}
}
if let Some(text) = self.text {
string.push_str(&text);
}
for child in self.children {
string.push_str(&child.build());
}
string.push_str(&self.tag.end());
string
}
}
impl HtmxBuilder {
pub fn new(tag: Tag) -> Self {
Self {
tag,
attributes: HashMap::new(),
children: Vec::new(),
text: None,
}
}
pub fn push_builder(mut self, builder: HtmxBuilder) -> Self {
self.children.push(builder);
self
}
pub fn attribute(mut self, attr: &str, val: &str) -> Self {
self.attributes.insert(attr.to_string(), val.to_string());
self
}
pub fn hx_get(mut self, uri: &str) -> Self {
self.attribute("hx-get", uri)
}
pub fn hx_post(mut self, uri: &str) -> Self {
self.attribute("hx-post", uri)
}
pub fn hx_swap(mut self, swap_method: SwapMethod) -> Self {
self.attribute("hx-swap", swap_method.as_str())
}
pub fn hx_trigger(mut self, trigger: &str) -> Self {
self.attribute("hx-trigger", trigger)
}
pub fn hx_target(mut self, target: &str) -> Self {
self.attribute("hx-target", target)
}
pub fn html<F>(mut self, builder_fn: F) -> Self
where
F: FnOnce(HtmxBuilder) -> HtmxBuilder,
{
self.children.push(builder_fn(HtmxBuilder::new(Tag::Html)));
self
}
pub fn head<F>(mut self, builder_fn: F) -> Self
where
F: FnOnce(HtmxBuilder) -> HtmxBuilder,
{
self.children.push(builder_fn(HtmxBuilder::new(Tag::Head)));
self
}
pub fn title(mut self, text: &str) -> HtmxBuilder {
self.children.push(HtmxBuilder::new(Tag::Title).text(text));
self
}
pub fn body<F>(mut self, builder_fn: F) -> Self
where
F: FnOnce(HtmxBuilder) -> HtmxBuilder,
{
self.children.push(builder_fn(HtmxBuilder::new(Tag::Body)));
self
}
pub fn script(mut self, src: &str, integrity: Option<&str>) -> Self {
let mut b = HtmxBuilder::new(Tag::Script).attribute("src", src);
if let Some(integrity) = integrity {
b = b
.attribute("integrity", integrity)
.attribute("crossorigin", "anonymous");
}
self.children.push(b);
self
}
pub fn style_link(mut self, link: &str) -> Self {
self.children.push(
HtmxBuilder::new(Tag::Link)
.attribute("rel", "stylesheet")
.attribute("href", link),
);
self
}
pub fn flag(mut self, flag: &str) -> Self {
self.attributes.insert(flag.to_string(), "".to_string());
self
}
pub fn builder<F>(mut self, tag: Tag, builder_fn: F) -> Self
where
F: FnOnce(HtmxBuilder) -> HtmxBuilder,
{
self.children.push(builder_fn(HtmxBuilder::new(tag)));
self
}
pub fn builder_text(mut self, tag: Tag, text: &str) -> Self {
self.children.push(HtmxBuilder::new(tag).text(text));
self
}
pub fn nav<F>(mut self, builder_fn: F) -> Self
where
F: FnOnce(HtmxBuilder) -> HtmxBuilder,
{
self.children.push(builder_fn(HtmxBuilder::new(Tag::Nav)));
self
}
pub fn form<F>(mut self, builder_fn: F) -> HtmxBuilder
where
F: FnOnce(HtmxBuilder) -> HtmxBuilder,
{
self.children.push(builder_fn(HtmxBuilder::new(Tag::Form)));
self
}
pub fn label<F>(mut self, builder_fn: F) -> HtmxBuilder
where
F: FnOnce(HtmxBuilder) -> HtmxBuilder,
{
self.children.push(builder_fn(HtmxBuilder::new(Tag::Label)));
self
}
pub fn input<F>(mut self, builder_fn: F) -> HtmxBuilder
where
F: FnOnce(HtmxBuilder) -> HtmxBuilder,
{
self.children.push(builder_fn(HtmxBuilder::new(Tag::Input)));
self
}
pub fn button<F>(mut self, builder_fn: F) -> HtmxBuilder
where
F: FnOnce(HtmxBuilder) -> HtmxBuilder,
{
self.children
.push(builder_fn(HtmxBuilder::new(Tag::Button)));
self
}
pub fn ul<F>(mut self, builder_fn: F) -> HtmxBuilder
where
F: FnOnce(HtmxBuilder) -> HtmxBuilder,
{
self.children
.push(builder_fn(HtmxBuilder::new(Tag::UnorderedList)));
self
}
pub fn li<F>(mut self, builder_fn: F) -> HtmxBuilder
where
F: FnOnce(HtmxBuilder) -> HtmxBuilder,
{
self.children
.push(builder_fn(HtmxBuilder::new(Tag::ListItem)));
self
}
pub fn link(mut self, text: &str, href: &str) -> HtmxBuilder {
// TODO: add href attribute
self.children.push(
HtmxBuilder::new(Tag::Anchor)
.text(text)
.attribute("href", href),
);
self
}
pub fn text(mut self, text: &str) -> HtmxBuilder {
self.text = Some(text.to_string());
self
}
pub fn strong(mut self, text: &str) -> HtmxBuilder {
self.children.push(HtmxBuilder::new(Tag::Strong).text(text));
self
}
}

View File

@ -3,7 +3,9 @@
#![feature(async_closure)]
mod auth;
mod htmx;
mod media;
mod page;
mod routes;
pub mod settings;
@ -133,6 +135,18 @@ fn spawn_api(settings: Arc<Mutex<Settings>>) {
tokio::spawn(async move {
let api = Router::new()
.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/add/:guild_id/:channel",
post(routes::v2_add_intro_to_user),
)
.route(
"/v2/intros/remove/:guild_id/:channel",
post(routes::v2_remove_intro_from_user),
)
.route("/health", get(routes::health))
.route("/me", get(routes::me))
.route("/intros/:guild", get(routes::intros))
@ -148,6 +162,7 @@ fn spawn_api(settings: Arc<Mutex<Settings>>) {
post(routes::remove_intro_to_user),
)
.route("/auth", get(routes::auth))
.route("/v2/auth", get(routes::v2_auth))
.layer(
CorsLayer::new()
// TODO: move this to env variable
@ -155,7 +170,7 @@ fn spawn_api(settings: Arc<Mutex<Settings>>) {
.allow_headers(Any)
.allow_methods([Method::GET, Method::POST, Method::DELETE]),
)
.with_state(Arc::new(state));
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], 8100));
info!("socket listening on {addr}");
axum::Server::bind(&addr)
@ -233,21 +248,29 @@ async fn spawn_bot(settings: Arc<Mutex<Settings>>) {
info!("Got PlaySound message");
let settings = settings.lock().await;
let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx.cache) else {
let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx.cache)
else {
error!("Failed to get cached channel from member!");
continue;
};
let Some(guild_settings) = settings.guilds.get(channel.guild_id.as_u64()) else {
let Some(guild_settings) = settings.guilds.get(channel.guild_id.as_u64())
else {
error!("couldn't get guild from id: {}", channel.guild_id.as_u64());
continue;
};
let Some(channel_settings) = guild_settings.channels.get(channel.name()) else {
error!("couldn't get channel_settings from name: {}", channel.name());
error!(
"couldn't get channel_settings from name: {}",
channel.name()
);
continue;
};
let Some(user) = channel_settings.users.get(&member.user.name) else {
error!("couldn't get user settings from name: {}", &member.user.name);
error!(
"couldn't get user settings from name: {}",
&member.user.name
);
continue;
};
@ -310,6 +333,8 @@ async fn spawn_bot(settings: Arc<Mutex<Settings>>) {
#[tokio::main]
#[instrument]
async fn main() -> std::io::Result<()> {
dotenv::dotenv().ok();
tracing_subscriber::fmt::init();
let settings = serde_json::from_str::<Settings>(

161
src/page.rs Normal file
View File

@ -0,0 +1,161 @@
use crate::{
auth::{self, User},
htmx::{Build, HtmxBuilder, SwapMethod, Tag},
settings::{ApiState, Intro, IntroFriendlyName},
};
use axum::{
extract::{Path, State},
response::{Html, Redirect},
};
use tracing::error;
pub(crate) async fn home(user: Option<User>) -> Redirect {
if user.is_some() {
Redirect::to("/guild/588149178912473103")
} else {
Redirect::to("/login")
}
}
fn intro_list<'a>(
intros: impl Iterator<Item = (&'a String, &'a Intro)>,
label: &str,
post: &str,
) -> HtmxBuilder {
HtmxBuilder::new(Tag::Empty).form(|b| {
b.attribute("class", "container")
.hx_post(post)
.attribute("hx-encoding", "multipart/form-data")
.builder(Tag::FieldSet, |b| {
let mut b = b
.attribute("class", "container")
.attribute("style", "max-height: 20%; overflow-y: scroll");
for intro in intros {
b = b.builder(Tag::Label, |b| {
b.builder(Tag::Input, |b| {
b.attribute("type", "checkbox").attribute("name", &intro.0)
})
.builder_text(Tag::Paragraph, intro.1.friendly_name())
});
}
b
})
.button(|b| b.attribute("type", "submit").text(label))
})
}
pub(crate) async fn guild_dashboard(
State(state): State<ApiState>,
user: User,
Path(guild_id): Path<u64>,
) -> Result<Html<String>, Redirect> {
let settings = state.settings.lock().await;
let Some(guild) = settings.guilds.get(&guild_id) else {
error!(%guild_id, "no such guild");
return Err(Redirect::to("/"));
};
let Some(guild_user) = guild.users.get(&user.name) else {
error!(%guild_id, %user.name, "no user in guild");
return Err(Redirect::to("/"));
};
let is_moderator = guild_user.permissions.can(auth::Permission::DeleteSounds);
Ok(Html(
HtmxBuilder::new(Tag::Html)
.head(|b| {
b.title("MemeJoin - Dashboard")
.script(
"https://unpkg.com/htmx.org@1.9.3",
Some("sha384-lVb3Rd/Ca0AxaoZg5sACe8FJKF0tnUgR2Kd7ehUOG5GCcROv5uBIZsOqovBAcWua"),
)
.script("https://unpkg.com/hyperscript.org@0.9.9", None)
.style_link("https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css")
})
.builder(Tag::Nav, |b| {
b.builder(Tag::Header1, |b| b.text("MemeJoin - A bot for user intros"))
.builder_text(Tag::Paragraph, &user.name)
})
.builder(Tag::Main, |b| {
if is_moderator {
b.builder(Tag::Article, |b| {
b.builder_text(Tag::Header, "Wow, you're a moderator")
})
} else {
b
}
.builder(Tag::Article, |b| {
let mut b = b.builder_text(Tag::Header, "Guild Settings");
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_text(Tag::Strong, 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/{}/{}",
guild_id, channel_name
),
))
.builder_text(Tag::Strong, "Select Intros")
.push_builder(intro_list(
available_intros,
"Add Intro",
&format!(
"/v2/intros/add/{}/{}",
guild_id, channel_name
),
))
});
}
}
b
})
})
.build(),
))
}
pub(crate) async fn login(State(state): State<ApiState>) -> Html<String> {
let authorize_uri = format!("https://discord.com/api/oauth2/authorize?client_id={}&redirect_uri={}/v2/auth&response_type=code&scope=guilds.members.read%20guilds%20identify", state.secrets.client_id, state.origin);
Html(
HtmxBuilder::new(Tag::Html)
.head(|b| {
b.title("MemeJoin - Login")
.script(
"https://unpkg.com/htmx.org@1.9.3",
Some("sha384-lVb3Rd/Ca0AxaoZg5sACe8FJKF0tnUgR2Kd7ehUOG5GCcROv5uBIZsOqovBAcWua"),
)
.script("https://unpkg.com/hyperscript.org@0.9.9", None)
.style_link("https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css")
})
.link("Login", &authorize_uri)
.build(),
)
}

View File

@ -2,19 +2,23 @@ use std::{collections::HashMap, sync::Arc};
use axum::{
body::Bytes,
extract::{Path, Query, State},
http::HeaderMap,
response::IntoResponse,
Json,
extract::{Multipart, Path, Query, State},
http::{HeaderMap, HeaderValue},
response::{IntoResponse, Redirect},
Form, Json,
};
use reqwest::StatusCode;
use axum_extra::extract::{cookie::Cookie, CookieJar};
use reqwest::{Proxy, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tracing::{error, info};
use uuid::Uuid;
use crate::{auth, settings::FileIntro};
use crate::{
auth::{self, User},
settings::FileIntro,
};
use crate::{
media,
settings::{ApiState, GuildUser, Intro, IntroIndex, UserSettings},
@ -123,8 +127,109 @@ struct DiscordUserGuild {
pub owner: bool,
}
pub(crate) async fn v2_auth(
State(state): State<ApiState>,
Query(params): Query<HashMap<String, String>>,
jar: CookieJar,
) -> Result<(CookieJar, Redirect), Error> {
let Some(code) = params.get("code") else {
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!("{}/v2/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);
let client = reqwest::Client::new();
let auth: auth::Discord = client
.post("https://discord.com/api/oauth2/token")
.form(&data)
.send()
.await
.map_err(|err| Error::Auth(err.to_string()))?
.json()
.await
.map_err(|err| {
error!(?err, "auth error");
Error::Auth(err.to_string())
})?;
let token = Uuid::new_v4().to_string();
// Get authorized username
let user: DiscordUser = client
.get("https://discord.com/api/v10/users/@me")
.bearer_auth(&auth.access_token)
.send()
.await?
.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 {
auth,
name: user.username.clone(),
},
);
// TODO: add permissions based on roles
let mut cookie = Cookie::new("access_token", token.clone());
cookie.set_path("/");
cookie.set_secure(true);
Ok((jar.add(cookie), Redirect::to("/")))
}
pub(crate) async fn auth(
State(state): State<Arc<ApiState>>,
State(state): State<ApiState>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Value>, Error> {
let Some(code) = params.get("code") else {
@ -179,7 +284,10 @@ pub(crate) async fn auth(
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; };
.find(|discord_guild| discord_guild.id == g.0.to_string())
else {
continue;
};
in_a_guild = true;
@ -213,21 +321,118 @@ pub(crate) async fn auth(
Ok(Json(json!({"token": token, "username": user.username})))
}
pub(crate) async fn v2_add_intro_to_user(
State(state): State<ApiState>,
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;
};
while let Ok(Some(field)) = form_data.next_field().await {
let Some(field_name) = 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,
});
}
}
// TODO: don't save on every change
if let Err(err) = settings.save() {
error!("Failed to save config: {err:?}");
}
headers
}
pub(crate) async fn v2_remove_intro_from_user(
State(state): State<ApiState>,
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;
};
while let Ok(Some(field)) = form_data.next_field().await {
let Some(field_name) = field.name() else {
continue;
};
if let Some(index) = channel_user
.intros
.iter()
.position(|intro| intro.index == field_name)
{
channel_user.intros.remove(index);
}
}
// TODO: don't save on every change
if let Err(err) = settings.save() {
error!("Failed to save config: {err:?}");
}
headers
}
pub(crate) async fn add_intro_to_user(
State(state): State<Arc<ApiState>>,
State(state): State<ApiState>,
headers: HeaderMap,
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; };
let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else {
return;
};
let user = match settings.auth_users.get(token) {
Some(user) => user.name.clone(),
None => return,
};
let Some(guild) = settings.guilds.get_mut(&guild) else { return; };
let Some(channel) = guild.channels.get_mut(&channel) else { return; };
let Some(user) = channel.users.get_mut(&user) else { return; };
let Some(guild) = settings.guilds.get_mut(&guild) else {
return;
};
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 {
@ -243,20 +448,28 @@ pub(crate) async fn add_intro_to_user(
}
pub(crate) async fn remove_intro_to_user(
State(state): State<Arc<ApiState>>,
State(state): State<ApiState>,
headers: HeaderMap,
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; };
let Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else {
return;
};
let user = match settings.auth_users.get(token) {
Some(user) => user.name.clone(),
None => return,
};
let Some(guild) = settings.guilds.get_mut(&guild) else { return; };
let Some(channel) = guild.channels.get_mut(&channel) else { return; };
let Some(user) = channel.users.get_mut(&user) else { return; };
let Some(guild) = settings.guilds.get_mut(&guild) else {
return;
};
let Some(channel) = guild.channels.get_mut(&channel) else {
return;
};
let Some(user) = channel.users.get_mut(&user) else {
return;
};
if let Some(index) = user
.intros
@ -272,22 +485,23 @@ pub(crate) async fn remove_intro_to_user(
}
}
pub(crate) async fn intros(
State(state): State<Arc<ApiState>>,
Path(guild): Path<u64>,
) -> Json<Value> {
pub(crate) async fn intros(State(state): State<ApiState>, Path(guild): Path<u64>) -> Json<Value> {
let settings = state.settings.lock().await;
let Some(guild) = settings.guilds.get(&guild) else { return Json(json!(IntroResponse::NoGuildFound)); };
let Some(guild) = settings.guilds.get(&guild) else {
return Json(json!(IntroResponse::NoGuildFound));
};
Json(json!(IntroResponse::Intros(&guild.intros)))
}
pub(crate) async fn me(
State(state): State<Arc<ApiState>>,
State(state): State<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 Some(token) = headers.get("token").and_then(|v| v.to_str().ok()) else {
return Err(Error::NoUserFound);
};
let (username, access_token) = match settings.auth_users.get(token) {
Some(user) => (user.name.clone(), user.auth.access_token.clone()),
@ -340,7 +554,7 @@ pub(crate) async fn me(
}
pub(crate) async fn upload_guild_intro(
State(state): State<Arc<ApiState>>,
State(state): State<ApiState>,
Path(guild): Path<u64>,
Query(mut params): Query<HashMap<String, String>>,
headers: HeaderMap,
@ -348,23 +562,33 @@ pub(crate) async fn upload_guild_intro(
) -> 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(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 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) };
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 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");
@ -386,31 +610,43 @@ pub(crate) async fn upload_guild_intro(
}
pub(crate) async fn add_guild_intro(
State(state): State<Arc<ApiState>>,
State(state): State<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(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 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) };
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 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")
@ -439,29 +675,37 @@ pub(crate) async fn add_guild_intro(
}
pub(crate) async fn delete_guild_intro(
State(state): State<Arc<ApiState>>,
State(state): State<ApiState>,
Path(guild): Path<u64>,
headers: HeaderMap,
Json(body): Json<DeleteIntroRequest>,
) -> 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(token) = headers.get("token").and_then(|v| v.to_str().ok()) else {
return Err(Error::NoUserFound);
};
{
let Some(guild) = settings.guilds.get(&guild) else { return Err(Error::NoGuildFound); };
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) };
let Some(guild_user) = guild.users.get(&auth_user.name) else {
return Err(Error::NoUserFound);
};
if !guild_user.permissions.can(auth::Permission::DeleteSounds) {
return Err(Error::InvalidPermission);
}
}
let Some(guild) = settings.guilds.get_mut(&guild) else { return Err(Error::NoGuildFound); };
let Some(guild) = settings.guilds.get_mut(&guild) else {
return Err(Error::NoGuildFound);
};
// Remove intro from any users
for channel in guild.channels.iter_mut() {

View File

@ -1,6 +1,8 @@
use std::{collections::HashMap, sync::Arc};
use crate::auth;
use axum::{async_trait, extract::FromRequestParts, http::request::Parts, response::Redirect};
use axum_extra::extract::CookieJar;
use serde::{Deserialize, Serialize};
use serenity::prelude::TypeMapKey;
use tracing::trace;
@ -8,12 +10,36 @@ use uuid::Uuid;
type UserToken = String;
// TODO: make this is wrapped type so cloning isn't happening
#[derive(Clone)]
pub(crate) struct ApiState {
pub settings: Arc<tokio::sync::Mutex<Settings>>,
pub secrets: auth::DiscordSecret,
pub origin: String,
}
#[async_trait]
impl FromRequestParts<ApiState> for crate::auth::User {
type Rejection = Redirect;
async fn from_request_parts(
Parts { headers, .. }: &mut Parts,
state: &ApiState,
) -> Result<Self, Self::Rejection> {
let jar = CookieJar::from_headers(&headers);
if let Some(token) = jar.get("access_token") {
match state.settings.lock().await.auth_users.get(token.value()) {
// :vomit:
Some(user) => Ok(user.clone()),
None => Err(Redirect::to("/login")),
}
} else {
Err(Redirect::to("/login"))
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Settings {
@ -70,12 +96,25 @@ pub(crate) struct GuildUser {
pub(crate) permissions: auth::Permissions,
}
pub(crate) trait IntroFriendlyName {
fn friendly_name(&self) -> &str;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) enum Intro {
File(FileIntro),
Online(OnlineIntro),
}
impl IntroFriendlyName for Intro {
fn friendly_name(&self) -> &str {
match self {
Self::File(intro) => intro.friendly_name(),
Self::Online(intro) => intro.friendly_name(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct FileIntro {
@ -83,6 +122,12 @@ pub(crate) struct FileIntro {
pub(crate) friendly_name: String,
}
impl IntroFriendlyName for FileIntro {
fn friendly_name(&self) -> &str {
&self.friendly_name
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OnlineIntro {
@ -90,6 +135,12 @@ pub(crate) struct OnlineIntro {
pub(crate) friendly_name: String,
}
impl IntroFriendlyName for OnlineIntro {
fn friendly_name(&self) -> &str {
&self.friendly_name
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChannelSettings {