Restructure code

This commit is contained in:
jude 2023-10-08 18:24:04 +01:00
parent 55acc8fd16
commit ca13fd4fa7
16 changed files with 941 additions and 840 deletions

View File

@ -121,19 +121,19 @@ pub async fn initialize(
routes![
routes::dashboard::dashboard,
routes::dashboard::dashboard_home,
routes::dashboard::user::get_user_info,
routes::dashboard::user::update_user_info,
routes::dashboard::user::get_user_guilds,
routes::dashboard::guild::get_guild_info,
routes::dashboard::guild::get_guild_channels,
routes::dashboard::guild::get_guild_roles,
routes::dashboard::guild::get_reminder_templates,
routes::dashboard::guild::create_reminder_template,
routes::dashboard::guild::delete_reminder_template,
routes::dashboard::guild::create_guild_reminder,
routes::dashboard::guild::get_reminders,
routes::dashboard::guild::edit_reminder,
routes::dashboard::guild::delete_reminder,
routes::dashboard::api::user::get_user_info,
routes::dashboard::api::user::update_user_info,
routes::dashboard::api::user::get_user_guilds,
routes::dashboard::api::guild::get_guild_info,
routes::dashboard::api::guild::get_guild_channels,
routes::dashboard::api::guild::get_guild_roles,
routes::dashboard::api::guild::get_reminder_templates,
routes::dashboard::api::guild::create_reminder_template,
routes::dashboard::api::guild::delete_reminder_template,
routes::dashboard::api::guild::create_guild_reminder,
routes::dashboard::api::guild::get_reminders,
routes::dashboard::api::guild::edit_reminder,
routes::dashboard::api::guild::delete_reminder,
routes::dashboard::export::export_reminders,
routes::dashboard::export::export_reminder_templates,
routes::dashboard::export::export_todos,

View File

@ -0,0 +1,61 @@
use rocket::{http::CookieJar, serde::json::json, State};
use serde::Serialize;
use serenity::{
client::Context,
model::{
channel::GuildChannel,
id::{ChannelId, GuildId},
},
};
use crate::{check_authorization, routes::JsonResult};
#[derive(Serialize)]
struct ChannelInfo {
id: String,
name: String,
webhook_avatar: Option<String>,
webhook_name: Option<String>,
}
#[get("/api/guild/<id>/channels")]
pub async fn get_guild_channels(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
) -> JsonResult {
offline!(Ok(json!(vec![ChannelInfo {
name: "general".to_string(),
id: "1".to_string(),
webhook_avatar: None,
webhook_name: None,
}])));
check_authorization(cookies, ctx.inner(), id).await?;
match GuildId(id).to_guild_cached(ctx.inner()) {
Some(guild) => {
let mut channels = guild
.channels
.iter()
.filter_map(|(id, channel)| channel.to_owned().guild().map(|c| (id.to_owned(), c)))
.filter(|(_, channel)| channel.is_text_based())
.collect::<Vec<(ChannelId, GuildChannel)>>();
channels.sort_by(|(_, c1), (_, c2)| c1.position.cmp(&c2.position));
let channel_info = channels
.iter()
.map(|(channel_id, channel)| ChannelInfo {
name: channel.name.to_string(),
id: channel_id.to_string(),
webhook_avatar: None,
webhook_name: None,
})
.collect::<Vec<ChannelInfo>>();
Ok(json!(channel_info))
}
None => json_err!("Bot not in guild"),
}
}

View File

@ -0,0 +1,42 @@
mod channels;
mod reminders;
mod roles;
mod templates;
use std::env;
pub use channels::*;
pub use reminders::*;
use rocket::{http::CookieJar, serde::json::json, State};
pub use roles::*;
use serenity::{
client::Context,
model::id::{GuildId, RoleId},
};
pub use templates::*;
use crate::{check_authorization, routes::JsonResult};
#[get("/api/guild/<id>")]
pub async fn get_guild_info(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
offline!(Ok(json!({ "patreon": true, "name": "Guild" })));
check_authorization(cookies, ctx.inner(), id).await?;
match GuildId(id).to_guild_cached(ctx.inner()) {
Some(guild) => {
let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
.member(&ctx.inner(), guild.owner_id)
.await;
let patreon = member_res.map_or(false, |member| {
member
.roles
.contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
});
Ok(json!({ "patreon": patreon, "name": guild.name }))
}
None => json_err!("Bot not in guild"),
}
}

View File

@ -0,0 +1,373 @@
use rocket::{
http::CookieJar,
serde::json::{json, Json},
State,
};
use serenity::{
client::Context,
model::id::{ChannelId, GuildId, UserId},
};
use sqlx::{MySql, Pool};
use crate::{
check_authorization, check_guild_subscription, check_subscription,
consts::MIN_INTERVAL,
guards::transaction::Transaction,
routes::{
dashboard::{
create_database_channel, create_reminder, DeleteReminder, PatchReminder, Reminder,
},
JsonResult,
},
Database,
};
#[post("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn create_guild_reminder(
id: u64,
reminder: Json<Reminder>,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
mut transaction: Transaction<'_>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
match create_reminder(
ctx.inner(),
&mut transaction,
GuildId(id),
UserId(user_id),
reminder.into_inner(),
)
.await
{
Ok(r) => match transaction.commit().await {
Ok(_) => Ok(r),
Err(e) => {
warn!("Couldn't commit transaction: {:?}", e);
json_err!("Couldn't commit transaction.")
}
},
Err(e) => Err(e),
}
}
#[get("/api/guild/<id>/reminders")]
pub async fn get_reminders(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
let channels_res = GuildId(id).channels(&ctx.inner()).await;
match channels_res {
Ok(channels) => {
let channels = channels
.keys()
.into_iter()
.map(|k| k.as_u64().to_string())
.collect::<Vec<String>>()
.join(",");
sqlx::query_as_unchecked!(
Reminder,
"SELECT
reminders.attachment,
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
reminders.embed_title,
IFNULL(reminders.embed_fields, '[]') AS embed_fields,
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months,
reminders.name,
reminders.restartable,
reminders.tts,
reminders.uid,
reminders.username,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE `status` = 'pending' AND FIND_IN_SET(channels.channel, ?)",
channels
)
.fetch_all(pool.inner())
.await
.map(|r| Ok(json!(r)))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
json_err!("Could not load reminders")
})
}
Err(e) => {
warn!("Could not fetch channels from {}: {:?}", id, e);
Ok(json!([]))
}
}
}
#[patch("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn edit_reminder(
id: u64,
reminder: Json<PatchReminder>,
ctx: &State<Context>,
mut transaction: Transaction<'_>,
pool: &State<Pool<Database>>,
cookies: &CookieJar<'_>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
let mut error = vec![];
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
if reminder.message_ok() {
update_field!(transaction.executor(), error, reminder.[
content,
embed_author,
embed_description,
embed_footer,
embed_title,
embed_fields,
username
]);
} else {
error.push("Message exceeds limits.".to_string());
}
update_field!(transaction.executor(), error, reminder.[
attachment,
attachment_name,
avatar,
embed_author_url,
embed_color,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
enabled,
expires,
name,
restartable,
tts,
utc_time
]);
if reminder.interval_days.flatten().is_some()
|| reminder.interval_months.flatten().is_some()
|| reminder.interval_seconds.flatten().is_some()
{
if check_guild_subscription(&ctx.inner(), id).await
|| check_subscription(&ctx.inner(), user_id).await
{
let new_interval_length = match reminder.interval_days {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_days AS days FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(transaction.executor())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.days
.unwrap_or(0),
} * 86400 + match reminder.interval_months {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_months AS months FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(transaction.executor())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.months
.unwrap_or(0),
} * 2592000 + match reminder.interval_seconds {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_seconds AS seconds FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(transaction.executor())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.seconds
.unwrap_or(0),
};
if new_interval_length < *MIN_INTERVAL {
error.push(String::from("New interval is too short."));
} else {
update_field!(transaction.executor(), error, reminder.[
interval_days,
interval_months,
interval_seconds
]);
}
}
}
if reminder.channel > 0 {
let channel = ChannelId(reminder.channel).to_channel_cached(&ctx.inner());
match channel {
Some(channel) => {
let channel_matches_guild = channel.guild().map_or(false, |c| c.guild_id.0 == id);
if !channel_matches_guild {
warn!(
"Error in `edit_reminder`: channel {:?} not found for guild {}",
reminder.channel, id
);
return Err(json!({"error": "Channel not found"}));
}
let channel = create_database_channel(
ctx.inner(),
ChannelId(reminder.channel),
&mut transaction,
)
.await;
if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e);
return Err(
json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
);
}
let channel = channel.unwrap();
match sqlx::query!(
"UPDATE reminders SET channel_id = ? WHERE uid = ?",
channel,
reminder.uid
)
.execute(transaction.executor())
.await
{
Ok(_) => {}
Err(e) => {
warn!("Error setting channel: {:?}", e);
error.push("Couldn't set channel".to_string())
}
}
}
None => {
warn!(
"Error in `edit_reminder`: channel {:?} not found for guild {}",
reminder.channel, id
);
return Err(json!({"error": "Channel not found"}));
}
}
}
if let Err(e) = transaction.commit().await {
warn!("Couldn't commit transaction: {:?}", e);
return json_err!("Couldn't commit transaction");
}
match sqlx::query_as_unchecked!(
Reminder,
"SELECT reminders.attachment,
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
reminders.embed_title,
reminders.embed_fields,
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months,
reminders.name,
reminders.restartable,
reminders.tts,
reminders.uid,
reminders.username,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE uid = ?",
reminder.uid
)
.fetch_one(pool.inner())
.await
{
Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})),
Err(e) => {
warn!("Error exiting `edit_reminder': {:?}", e);
Err(json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]}))
}
}
}
#[delete("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn delete_reminder(
cookies: &CookieJar<'_>,
id: u64,
reminder: Json<DeleteReminder>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
match sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", reminder.uid)
.execute(pool.inner())
.await
{
Ok(_) => Ok(json!({})),
Err(e) => {
warn!("Error in `delete_reminder`: {:?}", e);
Err(json!({"error": "Could not delete reminder"}))
}
}
}

View File

@ -0,0 +1,35 @@
use rocket::{http::CookieJar, serde::json::json, State};
use serde::Serialize;
use serenity::client::Context;
use crate::{check_authorization, routes::JsonResult};
#[derive(Serialize)]
struct RoleInfo {
id: String,
name: String,
}
#[get("/api/guild/<id>/roles")]
pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
offline!(Ok(json!(vec![RoleInfo { name: "@everyone".to_string(), id: "1".to_string() }])));
check_authorization(cookies, ctx.inner(), id).await?;
let roles_res = ctx.cache.guild_roles(id);
match roles_res {
Some(roles) => {
let roles = roles
.iter()
.map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() })
.collect::<Vec<RoleInfo>>();
Ok(json!(roles))
}
None => {
warn!("Could not fetch roles from {}", id);
json_err!("Could not get roles")
}
}
}

View File

@ -0,0 +1,181 @@
use rocket::{
http::CookieJar,
serde::json::{json, Json},
State,
};
use serenity::client::Context;
use sqlx::{MySql, Pool};
use crate::{
check_authorization,
consts::{
MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH,
},
routes::{
dashboard::{template_name_default, DeleteReminderTemplate, ReminderTemplate},
JsonResult,
},
};
#[get("/api/guild/<id>/templates")]
pub async fn get_reminder_templates(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
match sqlx::query_as_unchecked!(
ReminderTemplate,
"SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
id
)
.fetch_all(pool.inner())
.await
{
Ok(templates) => Ok(json!(templates)),
Err(e) => {
warn!("Could not fetch templates from {}: {:?}", id, e);
json_err!("Could not get templates")
}
}
}
#[post("/api/guild/<id>/templates", data = "<reminder_template>")]
pub async fn create_reminder_template(
id: u64,
reminder_template: Json<ReminderTemplate>,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
// validate lengths
check_length!(MAX_CONTENT_LENGTH, reminder_template.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder_template.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder_template.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder_template.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder_template.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder_template.embed_fields);
if let Some(fields) = &reminder_template.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
}
}
check_length_opt!(MAX_USERNAME_LENGTH, reminder_template.username);
check_length_opt!(
MAX_URL_LENGTH,
reminder_template.embed_footer_url,
reminder_template.embed_thumbnail_url,
reminder_template.embed_author_url,
reminder_template.embed_image_url,
reminder_template.avatar
);
// validate urls
check_url_opt!(
reminder_template.embed_footer_url,
reminder_template.embed_thumbnail_url,
reminder_template.embed_author_url,
reminder_template.embed_image_url,
reminder_template.avatar
);
let name = if reminder_template.name.is_empty() {
template_name_default()
} else {
reminder_template.name.clone()
};
match sqlx::query!(
"INSERT INTO reminder_template
(guild_id,
name,
attachment,
attachment_name,
avatar,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
embed_fields,
interval_seconds,
interval_days,
interval_months,
tts,
username
) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?)",
id,
name,
reminder_template.attachment,
reminder_template.attachment_name,
reminder_template.avatar,
reminder_template.content,
reminder_template.embed_author,
reminder_template.embed_author_url,
reminder_template.embed_color,
reminder_template.embed_description,
reminder_template.embed_footer,
reminder_template.embed_footer_url,
reminder_template.embed_image_url,
reminder_template.embed_thumbnail_url,
reminder_template.embed_title,
reminder_template.embed_fields,
reminder_template.interval_seconds,
reminder_template.interval_days,
reminder_template.interval_months,
reminder_template.tts,
reminder_template.username,
)
.fetch_all(pool.inner())
.await
{
Ok(_) => Ok(json!({})),
Err(e) => {
warn!("Could not create template for {}: {:?}", id, e);
json_err!("Could not create template")
}
}
}
#[delete("/api/guild/<id>/templates", data = "<delete_reminder_template>")]
pub async fn delete_reminder_template(
id: u64,
delete_reminder_template: Json<DeleteReminderTemplate>,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
match sqlx::query!(
"DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?",
id, delete_reminder_template.id
)
.fetch_all(pool.inner())
.await
{
Ok(_) => {
Ok(json!({}))
}
Err(e) => {
warn!("Could not delete template from {}: {:?}", id, e);
json_err!("Could not delete template")
}
}
}

View File

@ -0,0 +1,2 @@
pub mod guild;
pub mod user;

View File

@ -0,0 +1,81 @@
use reqwest::Client;
use rocket::{
http::CookieJar,
serde::json::{json, Value as JsonValue},
State,
};
use serde::{Deserialize, Serialize};
use serenity::model::{id::GuildId, permissions::Permissions};
use crate::consts::DISCORD_API;
#[derive(Serialize)]
struct GuildInfo {
id: String,
name: String,
}
#[derive(Deserialize)]
struct PartialGuild {
pub id: GuildId,
pub name: String,
#[serde(default)]
pub owner: bool,
#[serde(rename = "permissions_new")]
pub permissions: Option<String>,
}
#[get("/api/user/guilds")]
pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue {
offline!(json!(vec![GuildInfo { id: "1".to_string(), name: "Guild".to_string() }]));
if let Some(access_token) = cookies.get_private("access_token") {
let request_res = reqwest_client
.get(format!("{}/users/@me/guilds", DISCORD_API))
.bearer_auth(access_token.value())
.send()
.await;
match request_res {
Ok(response) => {
let guilds_res = response.json::<Vec<PartialGuild>>().await;
match guilds_res {
Ok(guilds) => {
let reduced_guilds = guilds
.iter()
.filter(|g| {
g.owner
|| g.permissions.as_ref().map_or(false, |p| {
let permissions =
Permissions::from_bits_truncate(p.parse().unwrap());
permissions.manage_messages()
|| permissions.manage_guild()
|| permissions.administrator()
})
})
.map(|g| GuildInfo { id: g.id.to_string(), name: g.name.to_string() })
.collect::<Vec<GuildInfo>>();
json!(reduced_guilds)
}
Err(e) => {
warn!("Error constructing user from request: {:?}", e);
json!({"error": "Could not get user details"})
}
}
}
Err(e) => {
warn!("Error getting user guilds: {:?}", e);
json!({"error": "Could not reach Discord"})
}
}
} else {
json!({"error": "Not authorized"})
}
}

View File

@ -0,0 +1,97 @@
mod guilds;
use std::env;
use chrono_tz::Tz;
pub use guilds::*;
use rocket::{
http::CookieJar,
serde::json::{json, Json, Value as JsonValue},
State,
};
use serde::{Deserialize, Serialize};
use serenity::{
client::Context,
model::id::{GuildId, RoleId},
};
use sqlx::{MySql, Pool};
#[derive(Serialize)]
struct UserInfo {
name: String,
patreon: bool,
timezone: Option<String>,
}
#[derive(Deserialize)]
pub struct UpdateUser {
timezone: String,
}
#[get("/api/user")]
pub async fn get_user_info(
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
offline!(json!(UserInfo { name: "Discord".to_string(), patreon: true, timezone: None }));
if let Some(user_id) =
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
{
let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
.member(&ctx.inner(), user_id)
.await;
let timezone = sqlx::query!(
"SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?",
user_id
)
.fetch_one(pool.inner())
.await
.map_or(None, |q| Some(q.timezone));
let user_info = UserInfo {
name: cookies
.get_private("username")
.map_or("DiscordUser#0000".to_string(), |c| c.value().to_string()),
patreon: member_res.map_or(false, |member| {
member
.roles
.contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
}),
timezone,
};
json!(user_info)
} else {
json!({"error": "Not authorized"})
}
}
#[patch("/api/user", data = "<user>")]
pub async fn update_user_info(
cookies: &CookieJar<'_>,
user: Json<UpdateUser>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
if let Some(user_id) =
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
{
if user.timezone.parse::<Tz>().is_ok() {
let _ = sqlx::query!(
"UPDATE users SET timezone = ? WHERE user = ?",
user.timezone,
user_id,
)
.execute(pool.inner())
.await;
json!({})
} else {
json!({"error": "Timezone not recognized"})
}
} else {
json!({"error": "Not authorized"})
}
}

View File

@ -0,0 +1,20 @@
use std::env;
use chrono_tz::Tz;
use reqwest::Client;
use rocket::{
http::CookieJar,
serde::json::{json, Json, Value as JsonValue},
State,
};
use serde::{Deserialize, Serialize};
use serenity::{
client::Context,
model::{
id::{GuildId, RoleId},
permissions::Permissions,
},
};
use sqlx::{MySql, Pool};
use crate::{consts::DISCORD_API, routes::JsonResult};

View File

@ -0,0 +1,29 @@
use std::env;
use chrono_tz::Tz;
use reqwest::Client;
use rocket::{
http::CookieJar,
serde::json::{json, Json, Value as JsonValue},
State,
};
use serde::{Deserialize, Serialize};
use serenity::{
client::Context,
model::{
id::{GuildId, RoleId},
permissions::Permissions,
},
};
use sqlx::{MySql, Pool};
use crate::{consts::DISCORD_API, routes::JsonResult};
#[get("/api/user/reminders")]
pub async fn get_reminders(
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
Ok(json! {})
}

View File

@ -1,650 +1 @@
use std::env;
use rocket::{
http::CookieJar,
serde::json::{json, Json},
State,
};
use serde::Serialize;
use serenity::{
client::Context,
model::{
channel::GuildChannel,
id::{ChannelId, GuildId, RoleId, UserId},
},
};
use sqlx::{MySql, Pool};
use crate::{
check_authorization, check_guild_subscription, check_subscription,
consts::{
MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH,
MIN_INTERVAL,
},
guards::transaction::Transaction,
routes::{
dashboard::{
create_database_channel, create_reminder, template_name_default, DeleteReminder,
DeleteReminderTemplate, PatchReminder, Reminder, ReminderTemplate,
},
JsonResult,
},
Database,
};
#[derive(Serialize)]
struct ChannelInfo {
id: String,
name: String,
webhook_avatar: Option<String>,
webhook_name: Option<String>,
}
#[get("/api/guild/<id>")]
pub async fn get_guild_info(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
offline!(Ok(json!({ "patreon": true, "name": "Guild" })));
check_authorization(cookies, ctx.inner(), id).await?;
match GuildId(id).to_guild_cached(ctx.inner()) {
Some(guild) => {
let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
.member(&ctx.inner(), guild.owner_id)
.await;
let patreon = member_res.map_or(false, |member| {
member
.roles
.contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
});
Ok(json!({ "patreon": patreon, "name": guild.name }))
}
None => json_err!("Bot not in guild"),
}
}
#[get("/api/guild/<id>/channels")]
pub async fn get_guild_channels(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
) -> JsonResult {
offline!(Ok(json!(vec![ChannelInfo {
name: "general".to_string(),
id: "1".to_string(),
webhook_avatar: None,
webhook_name: None,
}])));
check_authorization(cookies, ctx.inner(), id).await?;
match GuildId(id).to_guild_cached(ctx.inner()) {
Some(guild) => {
let mut channels = guild
.channels
.iter()
.filter_map(|(id, channel)| channel.to_owned().guild().map(|c| (id.to_owned(), c)))
.filter(|(_, channel)| channel.is_text_based())
.collect::<Vec<(ChannelId, GuildChannel)>>();
channels.sort_by(|(_, c1), (_, c2)| c1.position.cmp(&c2.position));
let channel_info = channels
.iter()
.map(|(channel_id, channel)| ChannelInfo {
name: channel.name.to_string(),
id: channel_id.to_string(),
webhook_avatar: None,
webhook_name: None,
})
.collect::<Vec<ChannelInfo>>();
Ok(json!(channel_info))
}
None => json_err!("Bot not in guild"),
}
}
#[derive(Serialize)]
struct RoleInfo {
id: String,
name: String,
}
#[get("/api/guild/<id>/roles")]
pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
offline!(Ok(json!(vec![RoleInfo { name: "@everyone".to_string(), id: "1".to_string() }])));
check_authorization(cookies, ctx.inner(), id).await?;
let roles_res = ctx.cache.guild_roles(id);
match roles_res {
Some(roles) => {
let roles = roles
.iter()
.map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() })
.collect::<Vec<RoleInfo>>();
Ok(json!(roles))
}
None => {
warn!("Could not fetch roles from {}", id);
json_err!("Could not get roles")
}
}
}
#[get("/api/guild/<id>/templates")]
pub async fn get_reminder_templates(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
match sqlx::query_as_unchecked!(
ReminderTemplate,
"SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
id
)
.fetch_all(pool.inner())
.await
{
Ok(templates) => Ok(json!(templates)),
Err(e) => {
warn!("Could not fetch templates from {}: {:?}", id, e);
json_err!("Could not get templates")
}
}
}
#[post("/api/guild/<id>/templates", data = "<reminder_template>")]
pub async fn create_reminder_template(
id: u64,
reminder_template: Json<ReminderTemplate>,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
// validate lengths
check_length!(MAX_CONTENT_LENGTH, reminder_template.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder_template.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder_template.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder_template.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder_template.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder_template.embed_fields);
if let Some(fields) = &reminder_template.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
}
}
check_length_opt!(MAX_USERNAME_LENGTH, reminder_template.username);
check_length_opt!(
MAX_URL_LENGTH,
reminder_template.embed_footer_url,
reminder_template.embed_thumbnail_url,
reminder_template.embed_author_url,
reminder_template.embed_image_url,
reminder_template.avatar
);
// validate urls
check_url_opt!(
reminder_template.embed_footer_url,
reminder_template.embed_thumbnail_url,
reminder_template.embed_author_url,
reminder_template.embed_image_url,
reminder_template.avatar
);
let name = if reminder_template.name.is_empty() {
template_name_default()
} else {
reminder_template.name.clone()
};
match sqlx::query!(
"INSERT INTO reminder_template
(guild_id,
name,
attachment,
attachment_name,
avatar,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
embed_fields,
interval_seconds,
interval_days,
interval_months,
tts,
username
) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?)",
id,
name,
reminder_template.attachment,
reminder_template.attachment_name,
reminder_template.avatar,
reminder_template.content,
reminder_template.embed_author,
reminder_template.embed_author_url,
reminder_template.embed_color,
reminder_template.embed_description,
reminder_template.embed_footer,
reminder_template.embed_footer_url,
reminder_template.embed_image_url,
reminder_template.embed_thumbnail_url,
reminder_template.embed_title,
reminder_template.embed_fields,
reminder_template.interval_seconds,
reminder_template.interval_days,
reminder_template.interval_months,
reminder_template.tts,
reminder_template.username,
)
.fetch_all(pool.inner())
.await
{
Ok(_) => Ok(json!({})),
Err(e) => {
warn!("Could not create template for {}: {:?}", id, e);
json_err!("Could not create template")
}
}
}
#[delete("/api/guild/<id>/templates", data = "<delete_reminder_template>")]
pub async fn delete_reminder_template(
id: u64,
delete_reminder_template: Json<DeleteReminderTemplate>,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
match sqlx::query!(
"DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?",
id, delete_reminder_template.id
)
.fetch_all(pool.inner())
.await
{
Ok(_) => {
Ok(json!({}))
}
Err(e) => {
warn!("Could not delete template from {}: {:?}", id, e);
json_err!("Could not delete template")
}
}
}
#[post("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn create_guild_reminder(
id: u64,
reminder: Json<Reminder>,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
mut transaction: Transaction<'_>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
match create_reminder(
ctx.inner(),
&mut transaction,
GuildId(id),
UserId(user_id),
reminder.into_inner(),
)
.await
{
Ok(r) => match transaction.commit().await {
Ok(_) => Ok(r),
Err(e) => {
warn!("Could'nt commit transaction: {:?}", e);
json_err!("Couldn't commit transaction.")
}
},
Err(e) => Err(e),
}
}
#[get("/api/guild/<id>/reminders")]
pub async fn get_reminders(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
let channels_res = GuildId(id).channels(&ctx.inner()).await;
match channels_res {
Ok(channels) => {
let channels = channels
.keys()
.into_iter()
.map(|k| k.as_u64().to_string())
.collect::<Vec<String>>()
.join(",");
sqlx::query_as_unchecked!(
Reminder,
"SELECT
reminders.attachment,
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
reminders.embed_title,
IFNULL(reminders.embed_fields, '[]') AS embed_fields,
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months,
reminders.name,
reminders.restartable,
reminders.tts,
reminders.uid,
reminders.username,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE `status` = 'pending' AND FIND_IN_SET(channels.channel, ?)",
channels
)
.fetch_all(pool.inner())
.await
.map(|r| Ok(json!(r)))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
json_err!("Could not load reminders")
})
}
Err(e) => {
warn!("Could not fetch channels from {}: {:?}", id, e);
Ok(json!([]))
}
}
}
#[patch("/api/guild/<id>/reminders", data = "<reminder>")]
pub(crate) async fn edit_reminder(
id: u64,
reminder: Json<PatchReminder>,
ctx: &State<Context>,
mut transaction: Transaction<'_>,
pool: &State<Pool<Database>>,
cookies: &CookieJar<'_>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
let mut error = vec![];
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
if reminder.message_ok() {
update_field!(transaction.executor(), error, reminder.[
content,
embed_author,
embed_description,
embed_footer,
embed_title,
embed_fields,
username
]);
} else {
error.push("Message exceeds limits.".to_string());
}
update_field!(transaction.executor(), error, reminder.[
attachment,
attachment_name,
avatar,
embed_author_url,
embed_color,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
enabled,
expires,
name,
restartable,
tts,
utc_time
]);
if reminder.interval_days.flatten().is_some()
|| reminder.interval_months.flatten().is_some()
|| reminder.interval_seconds.flatten().is_some()
{
if check_guild_subscription(&ctx.inner(), id).await
|| check_subscription(&ctx.inner(), user_id).await
{
let new_interval_length = match reminder.interval_days {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_days AS days FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(transaction.executor())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.days
.unwrap_or(0),
} * 86400 + match reminder.interval_months {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_months AS months FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(transaction.executor())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.months
.unwrap_or(0),
} * 2592000 + match reminder.interval_seconds {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_seconds AS seconds FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(transaction.executor())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.seconds
.unwrap_or(0),
};
if new_interval_length < *MIN_INTERVAL {
error.push(String::from("New interval is too short."));
} else {
update_field!(transaction.executor(), error, reminder.[
interval_days,
interval_months,
interval_seconds
]);
}
}
}
if reminder.channel > 0 {
let channel = ChannelId(reminder.channel).to_channel_cached(&ctx.inner());
match channel {
Some(channel) => {
let channel_matches_guild = channel.guild().map_or(false, |c| c.guild_id.0 == id);
if !channel_matches_guild {
warn!(
"Error in `edit_reminder`: channel {:?} not found for guild {}",
reminder.channel, id
);
return Err(json!({"error": "Channel not found"}));
}
let channel = create_database_channel(
ctx.inner(),
ChannelId(reminder.channel),
&mut transaction,
)
.await;
if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e);
return Err(
json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
);
}
let channel = channel.unwrap();
match sqlx::query!(
"UPDATE reminders SET channel_id = ? WHERE uid = ?",
channel,
reminder.uid
)
.execute(transaction.executor())
.await
{
Ok(_) => {}
Err(e) => {
warn!("Error setting channel: {:?}", e);
error.push("Couldn't set channel".to_string())
}
}
}
None => {
warn!(
"Error in `edit_reminder`: channel {:?} not found for guild {}",
reminder.channel, id
);
return Err(json!({"error": "Channel not found"}));
}
}
}
if let Err(e) = transaction.commit().await {
warn!("Couldn't commit transaction: {:?}", e);
return json_err!("Couldn't commit transaction");
}
match sqlx::query_as_unchecked!(
Reminder,
"SELECT reminders.attachment,
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
reminders.embed_title,
reminders.embed_fields,
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months,
reminders.name,
reminders.restartable,
reminders.tts,
reminders.uid,
reminders.username,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE uid = ?",
reminder.uid
)
.fetch_one(pool.inner())
.await
{
Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})),
Err(e) => {
warn!("Error exiting `edit_reminder': {:?}", e);
Err(json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]}))
}
}
}
#[delete("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn delete_reminder(
cookies: &CookieJar<'_>,
id: u64,
reminder: Json<DeleteReminder>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?;
match sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", reminder.uid)
.execute(pool.inner())
.await
{
Ok(_) => Ok(json!({})),
Err(e) => {
warn!("Error in `delete_reminder`: {:?}", e);
Err(json!({"error": "Could not delete reminder"}))
}
}
}

View File

@ -25,9 +25,9 @@ use crate::{
Error,
};
pub mod api;
pub mod export;
pub mod guild;
pub mod user;
type Unset<T> = Option<T>;

View File

@ -1,172 +0,0 @@
use std::env;
use chrono_tz::Tz;
use reqwest::Client;
use rocket::{
http::CookieJar,
serde::json::{json, Json, Value as JsonValue},
State,
};
use serde::{Deserialize, Serialize};
use serenity::{
client::Context,
model::{
id::{GuildId, RoleId},
permissions::Permissions,
},
};
use sqlx::{MySql, Pool};
use crate::consts::DISCORD_API;
#[derive(Serialize)]
struct UserInfo {
name: String,
patreon: bool,
timezone: Option<String>,
}
#[derive(Deserialize)]
pub struct UpdateUser {
timezone: String,
}
#[derive(Serialize)]
struct GuildInfo {
id: String,
name: String,
}
#[derive(Deserialize)]
pub struct PartialGuild {
pub id: GuildId,
pub icon: Option<String>,
pub name: String,
#[serde(default)]
pub owner: bool,
#[serde(rename = "permissions_new")]
pub permissions: Option<String>,
}
#[get("/api/user")]
pub async fn get_user_info(
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
offline!(json!(UserInfo { name: "Discord".to_string(), patreon: true, timezone: None }));
if let Some(user_id) =
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
{
let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
.member(&ctx.inner(), user_id)
.await;
let timezone = sqlx::query!(
"SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?",
user_id
)
.fetch_one(pool.inner())
.await
.map_or(None, |q| Some(q.timezone));
let user_info = UserInfo {
name: cookies
.get_private("username")
.map_or("DiscordUser#0000".to_string(), |c| c.value().to_string()),
patreon: member_res.map_or(false, |member| {
member
.roles
.contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
}),
timezone,
};
json!(user_info)
} else {
json!({"error": "Not authorized"})
}
}
#[patch("/api/user", data = "<user>")]
pub async fn update_user_info(
cookies: &CookieJar<'_>,
user: Json<UpdateUser>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
if let Some(user_id) =
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
{
if user.timezone.parse::<Tz>().is_ok() {
let _ = sqlx::query!(
"UPDATE users SET timezone = ? WHERE user = ?",
user.timezone,
user_id,
)
.execute(pool.inner())
.await;
json!({})
} else {
json!({"error": "Timezone not recognized"})
}
} else {
json!({"error": "Not authorized"})
}
}
#[get("/api/user/guilds")]
pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue {
offline!(json!(vec![GuildInfo { id: "1".to_string(), name: "Guild".to_string() }]));
if let Some(access_token) = cookies.get_private("access_token") {
let request_res = reqwest_client
.get(format!("{}/users/@me/guilds", DISCORD_API))
.bearer_auth(access_token.value())
.send()
.await;
match request_res {
Ok(response) => {
let guilds_res = response.json::<Vec<PartialGuild>>().await;
match guilds_res {
Ok(guilds) => {
let reduced_guilds = guilds
.iter()
.filter(|g| {
g.owner
|| g.permissions.as_ref().map_or(false, |p| {
let permissions =
Permissions::from_bits_truncate(p.parse().unwrap());
permissions.manage_messages()
|| permissions.manage_guild()
|| permissions.administrator()
})
})
.map(|g| GuildInfo { id: g.id.to_string(), name: g.name.to_string() })
.collect::<Vec<GuildInfo>>();
json!(reduced_guilds)
}
Err(e) => {
warn!("Error constructing user from request: {:?}", e);
json!({"error": "Could not get user details"})
}
}
}
Err(e) => {
warn!("Error getting user guilds: {:?}", e);
json!({"error": "Could not reach Discord"})
}
}
} else {
json!({"error": "Not authorized"})
}
}

View File

@ -463,15 +463,16 @@ document.addEventListener("guildSwitched", async (e) => {
let hasError = false;
if ($anchor !== null) {
$anchor.classList.add("is-active");
}
if (pane() === null) {
window.history.replaceState({}, "", `/dashboard/${guildId()}/reminders`);
}
switch_pane(pane());
if ($anchor !== null) {
$anchor.classList.add("is-active");
}
reset_guild_pane();
if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) {

View File

@ -2,4 +2,4 @@
</div>
<script src="/static/js/reminder_errors.js"></script>
<!--<script src="/static/js/reminder_errors.js"></script>-->