diff --git a/Cargo.lock b/Cargo.lock index 318b043..ab7f6f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2131,6 +2131,7 @@ version = "0.1.0" dependencies = [ "chrono", "chrono-tz 0.5.3", + "lazy_static", "log", "oauth2", "reqwest", diff --git a/web/Cargo.toml b/web/Cargo.toml index 0d0742f..16bcb28 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -15,3 +15,4 @@ serde = { version = "1.0", features = ["derive"] } sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono"] } chrono = "0.4" chrono-tz = "0.5" +lazy_static = "1.4.0" diff --git a/web/src/consts.rs b/web/src/consts.rs index 8a529bb..1e18130 100644 --- a/web/src/consts.rs +++ b/web/src/consts.rs @@ -2,3 +2,50 @@ pub const DISCORD_OAUTH_TOKEN: &'static str = "https://discord.com/api/oauth2/to pub const DISCORD_OAUTH_AUTHORIZE: &'static str = "https://discord.com/api/oauth2/authorize"; pub const DISCORD_API: &'static str = "https://discord.com/api"; pub const DISCORD_CDN: &'static str = "https://cdn.discordapp.com/avatars"; + +pub const MAX_CONTENT_LENGTH: usize = 2000; +pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096; +pub const MAX_EMBED_TITLE_LENGTH: usize = 256; +pub const MAX_EMBED_AUTHOR_LENGTH: usize = 256; +pub const MAX_EMBED_FOOTER_LENGTH: usize = 2048; +pub const MAX_URL_LENGTH: usize = 512; +pub const MAX_USERNAME_LENGTH: usize = 100; +pub const MAX_EMBED_FIELD_TITLE_LENGTH: usize = 256; +pub const MAX_EMBED_FIELD_VALUE_LENGTH: usize = 1024; +pub const MAX_EMBED_FIELDS: usize = 25; + +pub const MINUTE: usize = 60; +pub const HOUR: usize = 60 * MINUTE; +pub const DAY: usize = 24 * HOUR; + +use std::{collections::HashSet, env, iter::FromIterator}; + +use lazy_static::lazy_static; +use serenity::model::prelude::AttachmentType; + +lazy_static! { + pub static ref DEFAULT_AVATAR: AttachmentType<'static> = ( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../assets/", + env!("WEBHOOK_AVATAR", "WEBHOOK_AVATAR not provided for compilation") + )) as &[u8], + env!("WEBHOOK_AVATAR"), + ) + .into(); + pub static ref SUBSCRIPTION_ROLES: HashSet = HashSet::from_iter( + env::var("SUBSCRIPTION_ROLES") + .map(|var| var + .split(',') + .filter_map(|item| { item.parse::().ok() }) + .collect::>()) + .unwrap_or_else(|_| Vec::new()) + ); + pub static ref CNC_GUILD: Option = + env::var("CNC_GUILD").map(|var| var.parse::().ok()).ok().flatten(); + pub static ref MIN_INTERVAL: u32 = env::var("MIN_INTERVAL") + .ok() + .map(|inner| inner.parse::().ok()) + .flatten() + .unwrap_or(600); +} diff --git a/web/src/lib.rs b/web/src/lib.rs index ed3ce7f..0442fa5 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -2,6 +2,8 @@ extern crate rocket; mod consts; +#[macro_use] +mod macros; mod routes; use std::{collections::HashMap, env}; @@ -9,13 +11,23 @@ use std::{collections::HashMap, env}; use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; use rocket::fs::FileServer; use rocket_dyn_templates::Template; -use serenity::client::Context; +use serenity::{ + client::Context, + http::CacheHttp, + model::id::{GuildId, UserId}, +}; use sqlx::{MySql, Pool}; -use crate::consts::{DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN}; +use crate::consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES}; type Database = MySql; +#[derive(Debug)] +enum Error { + SQLx(sqlx::Error), + serenity(serenity::Error), +} + #[catch(401)] async fn not_authorized() -> Template { let map: HashMap = HashMap::new(); @@ -98,3 +110,34 @@ pub async fn initialize( Ok(()) } + +pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into) -> bool { + if let Some(subscription_guild) = *CNC_GUILD { + let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await; + + if let Ok(member) = guild_member { + for role in member.roles { + if SUBSCRIPTION_ROLES.contains(role.as_u64()) { + return true; + } + } + } + + false + } else { + true + } +} + +pub async fn check_guild_subscription( + cache_http: impl CacheHttp, + guild_id: impl Into, +) -> bool { + if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) { + let owner = guild.owner_id; + + check_subscription(&cache_http, owner).await + } else { + false + } +} diff --git a/web/src/macros.rs b/web/src/macros.rs new file mode 100644 index 0000000..f5cd0ae --- /dev/null +++ b/web/src/macros.rs @@ -0,0 +1,47 @@ +macro_rules! check_length { + ($max:ident, $field:expr) => { + if $field.len() > $max { + return json!({ "error": format!("{} exceeded", stringify!($max)) }); + } + }; + ($max:ident, $field:expr, $($fields:expr),+) => { + check_length!($max, $field); + check_length!($max, $($fields),+); + }; +} + +macro_rules! check_length_opt { + ($max:ident, $field:expr) => { + if let Some(field) = &$field { + check_length!($max, field); + } + }; + ($max:ident, $field:expr, $($fields:expr),+) => { + check_length_opt!($max, $field); + check_length_opt!($max, $($fields),+); + }; +} + +macro_rules! check_url { + ($field:expr) => { + if $field.starts_with("http://") || $field.starts_with("https://") { + return json!({ "error": "URL invalid" }); + } + }; + ($field:expr, $($fields:expr),+) => { + check_url!($max, $field); + check_url!($max, $($fields),+); + }; +} + +macro_rules! check_url_opt { + ($field:expr) => { + if let Some(field) = &$field { + check_url!(field); + } + }; + ($field:expr, $($fields:expr),+) => { + check_url_opt!($field); + check_url_opt!($($fields),+); + }; +} diff --git a/web/src/routes/dashboard/guild.rs b/web/src/routes/dashboard/guild.rs index 55f40e9..c29e4e6 100644 --- a/web/src/routes/dashboard/guild.rs +++ b/web/src/routes/dashboard/guild.rs @@ -1,13 +1,25 @@ +use chrono::Utc; use rocket::{ + http::CookieJar, serde::json::{json, Json, Value as JsonValue}, State, }; use serde::Serialize; -use serenity::{client::Context, model::id::GuildId}; +use serenity::{ + client::Context, + model::id::{ChannelId, GuildId}, +}; use sqlx::{MySql, Pool}; -use super::Reminder; -use crate::consts::DISCORD_CDN; +use crate::{ + check_guild_subscription, check_subscription, + consts::{ + DAY, DISCORD_CDN, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, + MAX_EMBED_DESCRIPTION_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, + MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL, + }, + routes::dashboard::{create_database_channel, DeleteReminder, Reminder}, +}; #[derive(Serialize)] struct ChannelInfo { @@ -108,10 +120,178 @@ pub async fn get_guild_roles(id: u64, ctx: &State) -> JsonValue { pub async fn create_reminder( id: u64, reminder: Json, + cookies: &CookieJar<'_>, serenity_context: &State, pool: &State>, ) -> JsonValue { - json!({"error": "Not implemented"}) + // get userid from cookies + let user_id = cookies.get_private("userid").map(|c| c.value().parse::().ok()).flatten(); + + if user_id.is_none() { + return json!({"error": "User not authorized"}); + } + + let user_id = user_id.unwrap(); + + // validate channel + let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner()); + let channel_exists = channel.is_some(); + + let channel_matches_guild = + channel.map_or(false, |c| c.guild().map_or(false, |c| c.guild_id.0 == id)); + + if !channel_matches_guild || !channel_exists { + warn!( + "Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})", + reminder.channel, id, channel_exists + ); + + return json!({"error": "Channel not found"}); + } + + let channel = create_database_channel( + serenity_context.inner(), + ChannelId(reminder.channel), + pool.inner(), + ) + .await; + + if let Err(e) = channel { + warn!("`create_database_channel` returned an error code: {:?}", e); + + return json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}); + } + + let channel = channel.unwrap(); + + // validate lengths + check_length!(MAX_CONTENT_LENGTH, reminder.content); + check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description); + check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title); + check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author); + check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer); + check_length_opt!(MAX_USERNAME_LENGTH, reminder.username); + check_length_opt!( + MAX_URL_LENGTH, + reminder.embed_footer_url, + reminder.embed_thumbnail_url, + reminder.embed_author_url, + reminder.embed_image_url, + reminder.avatar + ); + + // validate urls + check_url_opt!( + reminder.embed_footer_url, + reminder.embed_thumbnail_url, + reminder.embed_author_url, + reminder.embed_image_url, + reminder.avatar + ); + + // validate time and interval + if reminder.utc_time < Utc::now().naive_utc() { + return json!({"error": "Time must be in the future"}); + } + if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32 + + reminder.interval_seconds.unwrap_or(0) + < *MIN_INTERVAL + { + return json!({"error": "Interval too short"}); + } + + // check patreon if necessary + if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() { + if !check_guild_subscription(serenity_context.inner(), GuildId(id)).await + && !check_subscription(serenity_context.inner(), user_id).await + { + return json!({"error": "Patreon is required to set intervals"}); + } + } + + // write to db + match sqlx::query!( + "INSERT INTO reminders ( + channel_id, + avatar, + content, + embed_author, + embed_author_url, + embed_color, + embed_description, + embed_footer, + embed_footer_url, + embed_image_url, + embed_thumbnail_url, + embed_title, + enabled, + expires, + interval_seconds, + interval_months, + name, + pin, + restartable, + tts, + username, + `utc_time` + ) VALUES ( + channel_id = ?, + avatar = ?, + content = ?, + embed_author = ?, + embed_author_url = ?, + embed_color = ?, + embed_description = ?, + embed_footer = ?, + embed_footer_url = ?, + embed_image_url = ?, + embed_thumbnail_url = ?, + embed_title = ?, + enabled = ?, + expires = ?, + interval_seconds = ?, + interval_months = ?, + name = ?, + pin = ?, + restartable = ?, + tts = ?, + username = ?, + `utc_time` = ? + )", + channel, + reminder.avatar, + reminder.content, + reminder.embed_author, + reminder.embed_author_url, + reminder.embed_color, + reminder.embed_description, + reminder.embed_footer, + reminder.embed_footer_url, + reminder.embed_image_url, + reminder.embed_thumbnail_url, + reminder.embed_title, + reminder.enabled, + reminder.expires, + reminder.interval_seconds, + reminder.interval_months, + reminder.name, + reminder.pin, + reminder.restartable, + reminder.tts, + reminder.username, + reminder.utc_time, + ) + .execute(pool.inner()) + .await + { + Ok(_) => json!({}), + + Err(e) => { + warn!("Error in `create_reminder`: Could not execute query: {:?}", e); + + json!({"error": "Unknown error"}) + } + } } #[get("/api/guild//reminders")] @@ -197,8 +377,21 @@ pub async fn edit_reminder( #[delete("/api/guild//reminders", data = "")] pub async fn delete_reminder( id: u64, - reminder: Json, + reminder: Json, pool: &State>, ) -> JsonValue { - json!({"error": "Not implemented"}) + match sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid) + .execute(pool.inner()) + .await + { + Ok(_) => { + json!({}) + } + + Err(e) => { + warn!("Error in `delete_reminder`: {:?}", e); + + json!({"error": "Could not delete reminder"}) + } + } } diff --git a/web/src/routes/dashboard/mod.rs b/web/src/routes/dashboard/mod.rs index 8276b51..df80d75 100644 --- a/web/src/routes/dashboard/mod.rs +++ b/web/src/routes/dashboard/mod.rs @@ -1,9 +1,17 @@ +use std::collections::HashMap; + use chrono::naive::NaiveDateTime; -use rocket::http::CookieJar; -use rocket::response::Redirect; +use rocket::{http::CookieJar, response::Redirect}; use rocket_dyn_templates::Template; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use serenity::{ + client::Context, + http::{CacheHttp, Http}, + model::id::ChannelId, +}; +use sqlx::{Executor, Pool}; + +use crate::{consts::DEFAULT_AVATAR, Database, Error}; pub mod guild; pub mod user; @@ -46,8 +54,7 @@ pub struct Reminder { // https://github.com/serde-rs/json/issues/329#issuecomment-305608405 mod string { - use std::fmt::Display; - use std::str::FromStr; + use std::{fmt::Display, str::FromStr}; use serde::{de, Deserialize, Deserializer, Serializer}; @@ -74,6 +81,78 @@ pub struct DeleteReminder { uid: String, } +async fn create_database_channel( + ctx: impl AsRef, + channel: ChannelId, + pool: impl Executor<'_, Database = Database> + Copy, +) -> Result { + let row = + sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0) + .fetch_one(pool) + .await; + + match row { + Ok(row) => { + if row.webhook_token.is_none() || row.webhook_id.is_none() { + let webhook = channel + .create_webhook_with_avatar(&ctx, "Reminder", DEFAULT_AVATAR.clone()) + .await + .map_err(|e| Error::serenity(e))?; + + sqlx::query!( + "UPDATE channels SET webhook_id = ?, webhook_token = ? WHERE channel = ?", + webhook.id.0, + webhook.token, + channel.0 + ) + .execute(pool) + .await + .map_err(|e| Error::SQLx(e))?; + } + + Ok(()) + } + + Err(sqlx::Error::RowNotFound) => { + // create webhook + let webhook = channel + .create_webhook_with_avatar(&ctx, "Reminder", DEFAULT_AVATAR.clone()) + .await + .map_err(|e| Error::serenity(e))?; + + // create database entry + sqlx::query!( + "INSERT INTO channels ( + webhook_id, + webhook_token, + channel + ) VALUES ( + webhook_id = ?, + webhook_token = ?, + channel = ? + )", + webhook.id.0, + webhook.token, + channel.0 + ) + .execute(pool) + .await + .map_err(|e| Error::SQLx(e))?; + + Ok(()) + } + + Err(e) => Err(Error::SQLx(e)), + }?; + + let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.0) + .fetch_one(pool) + .await + .map_err(|e| Error::SQLx(e))?; + + Ok(row.id) +} + #[get("/")] pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result { if cookies.get_private("userid").is_some() { diff --git a/web/src/routes/login.rs b/web/src/routes/login.rs index c7a3d95..0c83c04 100644 --- a/web/src/routes/login.rs +++ b/web/src/routes/login.rs @@ -1,18 +1,18 @@ -use crate::consts::DISCORD_API; use log::warn; -use oauth2::basic::BasicClient; -use oauth2::reqwest::async_http_client; use oauth2::{ - AuthorizationCode, CsrfToken, PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse, + basic::BasicClient, reqwest::async_http_client, AuthorizationCode, CsrfToken, + PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse, }; use reqwest::Client; -use rocket::http::private::cookie::Expiration; -use rocket::http::{Cookie, CookieJar, SameSite}; -use rocket::response::{Flash, Redirect}; -use rocket::uri; -use rocket::State; +use rocket::{ + http::{private::cookie::Expiration, Cookie, CookieJar, SameSite}, + response::{Flash, Redirect}, + uri, State, +}; use serenity::model::user::User; +use crate::consts::DISCORD_API; + #[get("/discord")] pub async fn discord_login( oauth2_client: &State, diff --git a/web/templates/cookies.html.tera b/web/templates/cookies.html.tera index 4d6912b..b6272b4 100644 --- a/web/templates/cookies.html.tera +++ b/web/templates/cookies.html.tera @@ -12,7 +12,7 @@
-

User Data

+

User data

This website uses some necessary cookies and session data to operate. None of this can be disabled, since it is all necessary for the site to function. However, it is worth mentioning that all of @@ -38,7 +38,7 @@

-

Session Storage

+

Session storage

Session data are data that is stored just for the active browser session. Session storage is read and written by our server and cannot be modified on your computer. @@ -51,7 +51,7 @@

-

How Can We Trust You?

+

How can we trust you?

Feel free to audit this website. Go to our GitHub to get started, or just press F12

diff --git a/web/templates/dashboard.html.tera b/web/templates/dashboard.html.tera index fb3dbb0..481037a 100644 --- a/web/templates/dashboard.html.tera +++ b/web/templates/dashboard.html.tera @@ -722,6 +722,27 @@ document.querySelector('div#reminderCreator').classList.toggle('is-hidden'); }); + let $showInterval = document.querySelectorAll('a.intervalLabel'); + + $showInterval.forEach((element) => { + element.addEventListener('click', () => { + element.querySelector('i').classList.toggle('fa-chevron-right'); + element.querySelector('i').classList.toggle('fa-chevron-down'); + element.nextElementSibling.classList.toggle('is-hidden'); + }); + }); + + const fileInput = document.querySelectorAll('input[type=file]'); + + fileInput.forEach((element) => { + element.addEventListener('change', () => { + if (element.files.length > 0) { + const fileName = element.parentElement.querySelector('.file-label'); + fileName.textContent = element.files[0].name; + } + }) + }); + document.querySelectorAll('.discord-field-title').forEach((element) => { const $template = document.querySelector('template#embedFieldTemplate'); const $complement = element.parentElement.querySelector('.discord-field-value'); diff --git a/web/templates/privacy.html.tera b/web/templates/privacy.html.tera index d02cf1b..d99d5d4 100644 --- a/web/templates/privacy.html.tera +++ b/web/templates/privacy.html.tera @@ -12,8 +12,63 @@
-

Privacy Policy

+

Who we are

+ Reminder Bot is operated solely by Jude Southworth. You can contact me by email at + jude@jellywx.com, or via private/public message on Discord at + https://discord.jellywx.com. +

+
+
+ +
+
+

What data we collect

+

+ Reminder Bot stores limited data necessary for the function of the bot. This data + is your unique user ID, timezone, and direct message channel. +
+
+ Timezones are provided by the user or the user's browser. +

+
+
+ +
+
+

Why we collect this data

+

+ Unique user IDs are stored to keep track of who sets reminders. User timezones are + stored to allow users to set reminders in their local timezone. Direct message channels are stored to + allow the setting of reminders for your direct message channel. +

+
+
+ +
+
+

Who your data is shared with

+

+ Your data may also be guarded by the privacy policies of MEGA, our backup provider, and + Hetzner, our hosting provider. +

+
+
+ +
+
+

Accessing or removing your data

+

+ Your timezone can be removed with the command /timezone UTC. Other data can be removed + on request. Please contact me. +
+
+ Reminders created in a guild/channel will be removed automatically when the bot is removed from the + guild, the guild is deleted, or channel is deleted. Data is otherwise not removed automatically. +
+
+ Reminders deleted with /del or via the dashboard are removed from the live database + instantly, but may persist in backups.

diff --git a/web/templates/reminder_dashboard/guild_reminder.html.tera b/web/templates/reminder_dashboard/guild_reminder.html.tera index 94f3ff0..70c7c00 100644 --- a/web/templates/reminder_dashboard/guild_reminder.html.tera +++ b/web/templates/reminder_dashboard/guild_reminder.html.tera @@ -126,45 +126,69 @@
+
- +
-
- + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+ +

- Set Embed Color -

-
- -
- -

- Attach File - - - -

-
-
- -

- Set Interval + Set Embed Color

+ {% if creating %}