18 Commits

Author SHA1 Message Date
d082f63635 Bump version 2023-09-23 17:37:28 +01:00
9a51c548d6 Update dependencies 2023-09-23 16:18:33 +01:00
4bc7ae8e23 Change migration 2023-09-23 15:16:45 +01:00
6f1ef206df Correctly highlight options on mobile 2023-09-17 18:33:01 +01:00
ec63c942d6 Move button row down 2023-09-17 18:11:22 +01:00
06165c1b36 Restyle to work on most screen sizes 2023-09-17 18:03:57 +01:00
5ee9094bac Handle deleted channels in sender 2023-09-17 14:09:50 +01:00
82dab53744 Merge pull request 'jude/orphan-reminders' (#1) from jude/orphan-reminders into next
Reviewed-on: #1
2023-09-16 17:09:33 +00:00
5f703e8538 Add status update time to sender 2023-09-16 17:59:03 +01:00
2993505a47 Add times to the log 2023-09-09 15:34:43 +01:00
b225ad7e45 Render log rows 2023-09-03 16:00:49 +01:00
ee89cb40c5 Move errors route into get_reminders route. Add database migration. 2023-09-03 15:01:42 +01:00
b6b5e6d2b2 Add error pane 2023-08-27 17:41:23 +01:00
adf29dca5d Start to think about how to display errors 2023-08-19 22:37:48 +01:00
ea3fe3f543 Ensure postman doesn't try to send reminders with no channel 2023-08-19 21:28:05 +01:00
109cf16dbb Store guild when creating reminders 2023-08-19 21:24:02 +01:00
6726ca0c2d Correct migration script.
Stub code for routes. Update existing routes to set the reminder's guild
ID.
2023-08-19 21:24:02 +01:00
38133be15d In progress 2023-08-19 21:24:02 +01:00
40 changed files with 1562 additions and 1466 deletions

106
Cargo.lock generated
View File

@ -59,7 +59,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"getrandom", "getrandom 0.2.10",
"once_cell", "once_cell",
"version_check", "version_check",
] ]
@ -345,7 +345,7 @@ dependencies = [
"base64 0.21.4", "base64 0.21.4",
"hkdf", "hkdf",
"percent-encoding", "percent-encoding",
"rand", "rand 0.8.5",
"sha2", "sha2",
"subtle", "subtle",
"time", "time",
@ -437,7 +437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core", "rand_core 0.6.4",
"typenum", "typenum",
] ]
@ -910,6 +910,17 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "getrandom"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
dependencies = [
"cfg-if",
"libc",
"wasi 0.9.0+wasi-snapshot-preview1",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.10" version = "0.2.10"
@ -919,7 +930,7 @@ dependencies = [
"cfg-if", "cfg-if",
"js-sys", "js-sys",
"libc", "libc",
"wasi", "wasi 0.11.0+wasi-snapshot-preview1",
"wasm-bindgen", "wasm-bindgen",
] ]
@ -1496,7 +1507,7 @@ checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
"wasi", "wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.48.0", "windows-sys 0.48.0",
] ]
@ -1608,7 +1619,7 @@ dependencies = [
"num-integer", "num-integer",
"num-iter", "num-iter",
"num-traits", "num-traits",
"rand", "rand 0.8.5",
"smallvec", "smallvec",
"zeroize", "zeroize",
] ]
@ -1662,9 +1673,9 @@ checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f"
dependencies = [ dependencies = [
"base64 0.13.1", "base64 0.13.1",
"chrono", "chrono",
"getrandom", "getrandom 0.2.10",
"http", "http",
"rand", "rand 0.8.5",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@ -1901,7 +1912,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0"
dependencies = [ dependencies = [
"phf_shared", "phf_shared",
"rand", "rand 0.8.5",
] ]
[[package]] [[package]]
@ -2068,6 +2079,19 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "rand"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
dependencies = [
"getrandom 0.1.16",
"libc",
"rand_chacha 0.2.2",
"rand_core 0.5.1",
"rand_hc",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.8.5" version = "0.8.5"
@ -2075,8 +2099,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
dependencies = [
"ppv-lite86",
"rand_core 0.5.1",
] ]
[[package]] [[package]]
@ -2086,7 +2120,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand_core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
dependencies = [
"getrandom 0.1.16",
] ]
[[package]] [[package]]
@ -2095,7 +2138,16 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.2.10",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
dependencies = [
"rand_core 0.5.1",
] ]
[[package]] [[package]]
@ -2173,7 +2225,7 @@ checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da"
[[package]] [[package]]
name = "reminder-rs" name = "reminder-rs"
version = "1.6.47" version = "1.6.40"
dependencies = [ dependencies = [
"base64 0.21.4", "base64 0.21.4",
"chrono", "chrono",
@ -2187,7 +2239,7 @@ dependencies = [
"num-integer", "num-integer",
"poise", "poise",
"postman", "postman",
"rand", "rand 0.8.5",
"regex", "regex",
"reminder_web", "reminder_web",
"reqwest", "reqwest",
@ -2201,7 +2253,7 @@ dependencies = [
[[package]] [[package]]
name = "reminder_web" name = "reminder_web"
version = "0.1.2" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.13.1", "base64 0.13.1",
"chrono", "chrono",
@ -2210,7 +2262,7 @@ dependencies = [
"lazy_static", "lazy_static",
"log", "log",
"oauth2", "oauth2",
"rand", "rand 0.7.3",
"reqwest", "reqwest",
"rocket", "rocket",
"rocket_dyn_templates", "rocket_dyn_templates",
@ -2321,7 +2373,7 @@ dependencies = [
"num_cpus", "num_cpus",
"parking_lot", "parking_lot",
"pin-project-lite", "pin-project-lite",
"rand", "rand 0.8.5",
"ref-cast", "ref-cast",
"rocket_codegen", "rocket_codegen",
"rocket_http", "rocket_http",
@ -2410,7 +2462,7 @@ dependencies = [
"num-traits", "num-traits",
"pkcs1", "pkcs1",
"pkcs8", "pkcs8",
"rand_core", "rand_core 0.6.4",
"signature", "signature",
"spki", "spki",
"subtle", "subtle",
@ -2727,7 +2779,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e1788eed21689f9cf370582dfc467ef36ed9c707f073528ddafa8d83e3b8500" checksum = "5e1788eed21689f9cf370582dfc467ef36ed9c707f073528ddafa8d83e3b8500"
dependencies = [ dependencies = [
"digest", "digest",
"rand_core", "rand_core 0.6.4",
] ]
[[package]] [[package]]
@ -2944,7 +2996,7 @@ dependencies = [
"memchr", "memchr",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"rand", "rand 0.8.5",
"rsa", "rsa",
"serde", "serde",
"sha1", "sha1",
@ -2986,7 +3038,7 @@ dependencies = [
"memchr", "memchr",
"num-bigint", "num-bigint",
"once_cell", "once_cell",
"rand", "rand 0.8.5",
"serde", "serde",
"serde_json", "serde_json",
"sha1", "sha1",
@ -3112,7 +3164,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pest", "pest",
"pest_derive", "pest_derive",
"rand", "rand 0.8.5",
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
@ -3409,7 +3461,7 @@ dependencies = [
"http", "http",
"httparse", "httparse",
"log", "log",
"rand", "rand 0.8.5",
"rustls 0.20.9", "rustls 0.20.9",
"sha-1", "sha-1",
"thiserror", "thiserror",
@ -3624,6 +3676,12 @@ dependencies = [
"try-lock", "try-lock",
] ]
[[package]]
name = "wasi"
version = "0.9.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.0+wasi-snapshot-preview1" version = "0.11.0+wasi-snapshot-preview1"

View File

@ -1,6 +1,6 @@
[package] [package]
name = "reminder-rs" name = "reminder-rs"
version = "1.6.48" version = "1.6.40"
authors = ["Jude Southworth <judesouthworth@pm.me>"] authors = ["Jude Southworth <judesouthworth@pm.me>"]
edition = "2021" edition = "2021"
license = "AGPL-3.0 only" license = "AGPL-3.0 only"
@ -11,7 +11,7 @@ poise = "0.5"
dotenv = "0.15" dotenv = "0.15"
tokio = { version = "1", features = ["process", "full"] } tokio = { version = "1", features = ["process", "full"] }
reqwest = "0.11" reqwest = "0.11"
lazy-regex = "3.0.2" lazy-regex = "3.0"
regex = "1.9" regex = "1.9"
log = "0.4" log = "0.4"
env_logger = "0.10" env_logger = "0.10"

View File

@ -0,0 +1,19 @@
-- Drop existing constraint
ALTER TABLE `reminders` DROP CONSTRAINT `reminders_ibfk_1`;
ALTER TABLE `reminders` MODIFY COLUMN `channel_id` INT UNSIGNED;
ALTER TABLE `reminders` ADD COLUMN `guild_id` INT UNSIGNED;
ALTER TABLE `reminders`
ADD CONSTRAINT `guild_id_fk`
FOREIGN KEY (`guild_id`)
REFERENCES `guilds`(`id`)
ON DELETE CASCADE;
ALTER TABLE `reminders`
ADD CONSTRAINT `channel_id_fk`
FOREIGN KEY (`channel_id`)
REFERENCES `channels`(`id`)
ON DELETE SET NULL;
UPDATE `reminders` SET `guild_id` = (SELECT guilds.`id` FROM `channels` INNER JOIN `guilds` ON channels.guild_id = guilds.id WHERE reminders.channel_id = channels.id);

View File

@ -0,0 +1,4 @@
ALTER TABLE reminders ADD COLUMN `status_change_time` DATETIME;
-- This is a best-guess as to the status change time.
UPDATE reminders SET `status_change_time` = `utc_time` WHERE `status` != 'pending';

View File

@ -1,3 +0,0 @@
ALTER TABLE `reminder_template` ADD COLUMN `interval_seconds` INT UNSIGNED;
ALTER TABLE `reminder_template` ADD COLUMN `interval_days` INT UNSIGNED;
ALTER TABLE `reminder_template` ADD COLUMN `interval_months` INT UNSIGNED;

View File

@ -237,11 +237,11 @@ impl Into<CreateEmbed> for Embed {
pub struct Reminder { pub struct Reminder {
id: u32, id: u32,
channel_id: u64, channel_id: Option<u64>,
webhook_id: Option<u64>, webhook_id: Option<u64>,
webhook_token: Option<String>, webhook_token: Option<String>,
channel_paused: bool, channel_paused: Option<bool>,
channel_paused_until: Option<NaiveDateTime>, channel_paused_until: Option<NaiveDateTime>,
enabled: bool, enabled: bool,
@ -297,7 +297,7 @@ SELECT
reminders.`username` AS username reminders.`username` AS username
FROM FROM
reminders reminders
INNER JOIN LEFT JOIN
channels channels
ON ON
reminders.channel_id = channels.id reminders.channel_id = channels.id
@ -343,7 +343,10 @@ WHERE
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) { async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
let _ = sqlx::query!( let _ = sqlx::query!(
"UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?", "
UPDATE channels SET webhook_id = NULL, webhook_token = NULL
WHERE channel = ?
",
self.channel_id self.channel_id
) )
.execute(pool) .execute(pool)
@ -415,7 +418,9 @@ WHERE
self.set_sent(pool).await; self.set_sent(pool).await;
} else { } else {
sqlx::query!( sqlx::query!(
"UPDATE reminders SET `utc_time` = ? WHERE `id` = ?", "
UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
",
updated_reminder_time.with_timezone(&Utc), updated_reminder_time.with_timezone(&Utc),
self.id self.id
) )
@ -448,7 +453,10 @@ WHERE
if *LOG_TO_DATABASE { if *LOG_TO_DATABASE {
sqlx::query!( sqlx::query!(
"INSERT INTO stat (type, reminder_id, message) VALUES ('reminder_failed', ?, ?)", "
INSERT INTO stat (type, reminder_id, message)
VALUES ('reminder_failed', ?, ?)
",
self.id, self.id,
message, message,
) )
@ -461,7 +469,10 @@ WHERE
async fn log_success(&self, pool: impl Executor<'_, Database = Database> + Copy) { async fn log_success(&self, pool: impl Executor<'_, Database = Database> + Copy) {
if *LOG_TO_DATABASE { if *LOG_TO_DATABASE {
sqlx::query!( sqlx::query!(
"INSERT INTO stat (type, reminder_id) VALUES ('reminder_sent', ?)", "
INSERT INTO stat (type, reminder_id)
VALUES ('reminder_sent', ?)
",
self.id, self.id,
) )
.execute(pool) .execute(pool)
@ -471,7 +482,14 @@ WHERE
} }
async fn set_sent(&self, pool: impl Executor<'_, Database = Database> + Copy) { async fn set_sent(&self, pool: impl Executor<'_, Database = Database> + Copy) {
sqlx::query!("UPDATE reminders SET `status` = 'sent' WHERE `id` = ?", self.id) sqlx::query!(
"
UPDATE reminders
SET `status` = 'sent', `status_change_time` = NOW()
WHERE `id` = ?
",
self.id
)
.execute(pool) .execute(pool)
.await .await
.expect(&format!("Could not delete Reminder {}", self.id)); .expect(&format!("Could not delete Reminder {}", self.id));
@ -483,7 +501,11 @@ WHERE
message: &'static str, message: &'static str,
) { ) {
sqlx::query!( sqlx::query!(
"UPDATE reminders SET `status` = 'failed', `status_message` = ? WHERE `id` = ?", "
UPDATE reminders
SET `status` = 'failed', `status_message` = ?, `status_change_time` = NOW()
WHERE `id` = ?
",
message, message,
self.id self.id
) )
@ -493,7 +515,9 @@ WHERE
} }
async fn pin_message<M: Into<u64>>(&self, message_id: M, http: impl AsRef<Http>) { async fn pin_message<M: Into<u64>>(&self, message_id: M, http: impl AsRef<Http>) {
let _ = http.as_ref().pin_message(self.channel_id, message_id.into(), None).await; if let Some(channel_id) = self.channel_id {
let _ = http.as_ref().pin_message(channel_id, message_id.into(), None).await;
}
} }
pub async fn send( pub async fn send(
@ -503,10 +527,11 @@ WHERE
) { ) {
async fn send_to_channel( async fn send_to_channel(
cache_http: impl CacheHttp, cache_http: impl CacheHttp,
channel_id: u64,
reminder: &Reminder, reminder: &Reminder,
embed: Option<CreateEmbed>, embed: Option<CreateEmbed>,
) -> Result<()> { ) -> Result<()> {
let channel = ChannelId(reminder.channel_id).to_channel(&cache_http).await; let channel = ChannelId(channel_id).to_channel(&cache_http).await;
match channel { match channel {
Ok(Channel::Guild(channel)) => { Ok(Channel::Guild(channel)) => {
@ -538,6 +563,7 @@ WHERE
Err(e) => Err(e), Err(e) => Err(e),
} }
} }
Ok(Channel::Private(channel)) => { Ok(Channel::Private(channel)) => {
match channel match channel
.send_message(&cache_http.http(), |m| { .send_message(&cache_http.http(), |m| {
@ -567,7 +593,9 @@ WHERE
Err(e) => Err(e), Err(e) => Err(e),
} }
} }
Err(e) => Err(e), Err(e) => Err(e),
_ => Err(Error::Other("Channel not of valid type")), _ => Err(Error::Other("Channel not of valid type")),
} }
} }
@ -622,14 +650,20 @@ WHERE
} }
} }
match self.channel_id {
Some(channel_id) => {
if self.enabled if self.enabled
&& !(self.channel_paused && !(self.channel_paused.unwrap_or(false)
&& self && self
.channel_paused_until .channel_paused_until
.map_or(true, |inner| inner >= Utc::now().naive_local())) .map_or(true, |inner| inner >= Utc::now().naive_local()))
{ {
let _ = sqlx::query!( let _ = sqlx::query!(
"UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?", "
UPDATE `channels`
SET paused = 0, paused_until = NULL
WHERE `channel` = ?
",
self.channel_id self.channel_id
) )
.execute(pool) .execute(pool)
@ -640,8 +674,10 @@ WHERE
let result = if let (Some(webhook_id), Some(webhook_token)) = let result = if let (Some(webhook_id), Some(webhook_token)) =
(self.webhook_id, &self.webhook_token) (self.webhook_id, &self.webhook_token)
{ {
let webhook_res = let webhook_res = cache_http
cache_http.http().get_webhook_with_token(webhook_id, webhook_token).await; .http()
.get_webhook_with_token(webhook_id, webhook_token)
.await;
if let Ok(webhook) = webhook_res { if let Ok(webhook) = webhook_res {
send_to_webhook(cache_http, &self, webhook, embed).await send_to_webhook(cache_http, &self, webhook, embed).await
@ -649,10 +685,10 @@ WHERE
warn!("Webhook vanished for reminder {}: {:?}", self.id, webhook_res); warn!("Webhook vanished for reminder {}: {:?}", self.id, webhook_res);
self.reset_webhook(pool).await; self.reset_webhook(pool).await;
send_to_channel(cache_http, &self, embed).await send_to_channel(cache_http, channel_id, &self, embed).await
} }
} else { } else {
send_to_channel(cache_http, &self, embed).await send_to_channel(cache_http, channel_id, &self, embed).await
}; };
if let Err(e) = result { if let Err(e) = result {
@ -679,7 +715,10 @@ WHERE
None::<&'static str>, None::<&'static str>,
) )
.await; .await;
self.set_failed(pool, "Could not be sent as guild does not exist") self.set_failed(
pool,
"Could not be sent as guild does not exist",
)
.await; .await;
} }
50001 => { 50001 => {
@ -689,7 +728,11 @@ WHERE
None::<&'static str>, None::<&'static str>,
) )
.await; .await;
self.set_failed(pool, "Could not be sent as missing access").await; self.set_failed(
pool,
"Could not be sent as missing access",
)
.await;
} }
50007 => { 50007 => {
self.log_error( self.log_error(
@ -698,7 +741,10 @@ WHERE
None::<&'static str>, None::<&'static str>,
) )
.await; .await;
self.set_failed(pool, "Could not be sent as user has DMs disabled") self.set_failed(
pool,
"Could not be sent as user has DMs disabled",
)
.await; .await;
} }
50013 => { 50013 => {
@ -742,4 +788,13 @@ WHERE
self.refresh(pool).await; self.refresh(pool).await;
} }
} }
None => {
info!("Reminder {} is orphaned", self.id);
self.log_error(pool, "Orphaned", Option::<u8>::None).await;
self.set_failed(pool, "Could not be sent as channel was deleted").await;
}
}
}
} }

View File

@ -114,8 +114,6 @@ pub async fn offset(
#[description = "Number of minutes to offset by"] minutes: Option<isize>, #[description = "Number of minutes to offset by"] minutes: Option<isize>,
#[description = "Number of seconds to offset by"] seconds: Option<isize>, #[description = "Number of seconds to offset by"] seconds: Option<isize>,
) -> Result<(), Error> { ) -> Result<(), Error> {
ctx.defer().await?;
let combined_time = hours.map_or(0, |h| h * HOUR as isize) let combined_time = hours.map_or(0, |h| h * HOUR as isize)
+ minutes.map_or(0, |m| m * MINUTE as isize) + minutes.map_or(0, |m| m * MINUTE as isize)
+ seconds.map_or(0, |s| s); + seconds.map_or(0, |s| s);
@ -621,7 +619,7 @@ pub async fn multiline(
)] )]
pub async fn remind( pub async fn remind(
ctx: ApplicationContext<'_>, ctx: ApplicationContext<'_>,
#[description = "The time (and optionally date) to set the reminder for"] #[description = "A description of the time to set the reminder for"]
#[autocomplete = "time_hint_autocomplete"] #[autocomplete = "time_hint_autocomplete"]
time: String, time: String,
#[description = "The message content to send"] content: String, #[description = "The message content to send"] content: String,
@ -775,7 +773,7 @@ async fn create_reminder(
b.emoji(ReactionType::Unicode("📝".to_string())) b.emoji(ReactionType::Unicode("📝".to_string()))
.label("Edit") .label("Edit")
.style(ButtonStyle::Link) .style(ButtonStyle::Link)
.url("https://beta.reminder-bot.com/dashboard") .url("https://reminder-bot.com/dashboard")
}) })
}) })
}) })

View File

@ -166,15 +166,21 @@ impl ComponentDataModel {
.await; .await;
} }
ComponentDataModel::DelSelector(selector) => { ComponentDataModel::DelSelector(selector) => {
let selected_id = component.data.values.join(","); for id in &component.data.values {
match id.parse::<u32>() {
Ok(id) => {
if let Some(reminder) = Reminder::from_id(&data.database, id).await {
reminder.delete(&data.database).await.unwrap();
} else {
warn!("Attempt to delete non-existent reminder");
}
}
sqlx::query!( Err(e) => {
"UPDATE reminders SET `status` = 'deleted' WHERE FIND_IN_SET(id, ?)", warn!("Error casting ID to integer: {:?}.", e);
selected_id }
) }
.execute(&data.database) }
.await
.unwrap();
let reminders = Reminder::from_guild( let reminders = Reminder::from_guild(
&ctx, &ctx,

View File

@ -150,7 +150,7 @@ impl<'a> Parser<'a> {
"hours" | "hour" | "hr" | "hrs" | "h" => (0, 0, n.mul(3600)?, 0), "hours" | "hour" | "hr" | "hrs" | "h" => (0, 0, n.mul(3600)?, 0),
"days" | "day" | "d" => (0, n, 0, 0), "days" | "day" | "d" => (0, n, 0, 0),
"weeks" | "week" | "w" => (0, n.mul(7)?, 0, 0), "weeks" | "week" | "w" => (0, n.mul(7)?, 0, 0),
"months" | "month" => (n, 0, 0, 0), "months" | "month" | "M" => (n, 0, 0, 0),
"years" | "year" | "y" => (n.mul(12)?, 0, 0, 0), "years" | "year" | "y" => (n.mul(12)?, 0, 0, 0),
_ => { _ => {
return Err(Error::UnknownUnit { return Err(Error::UnknownUnit {
@ -255,7 +255,7 @@ impl<'a> Parser<'a> {
/// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000))); /// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000)));
/// ``` /// ```
pub fn parse_duration(s: &str) -> Result<Interval, Error> { pub fn parse_duration(s: &str) -> Result<Interval, Error> {
Parser { iter: s.to_lowercase().chars(), src: &s.to_lowercase(), current: (0, 0, 0, 0) }.parse() Parser { iter: s.chars(), src: s, current: (0, 0, 0, 0) }.parse()
} }
#[cfg(test)] #[cfg(test)]
@ -324,13 +324,4 @@ mod tests {
assert_eq!(interval.day, 0); assert_eq!(interval.day, 0);
assert_eq!(interval.month, 120); assert_eq!(interval.month, 120);
} }
#[test]
fn parse_case() {
let interval = parse_duration("200 Seconds").unwrap();
assert_eq!(interval.sec, 200);
assert_eq!(interval.day, 0);
assert_eq!(interval.month, 0);
}
} }

View File

@ -10,6 +10,7 @@ pub struct ChannelData {
pub webhook_id: Option<u64>, pub webhook_id: Option<u64>,
pub webhook_token: Option<String>, pub webhook_token: Option<String>,
pub paused: bool, pub paused: bool,
pub db_guild_id: Option<u32>,
pub paused_until: Option<NaiveDateTime>, pub paused_until: Option<NaiveDateTime>,
} }
@ -22,7 +23,11 @@ impl ChannelData {
if let Ok(c) = sqlx::query_as_unchecked!( if let Ok(c) = sqlx::query_as_unchecked!(
Self, Self,
"SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until FROM channels WHERE channel = ?", "
SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until,
guild_id AS db_guild_id
FROM channels WHERE channel = ?
",
channel_id channel_id
) )
.fetch_one(pool) .fetch_one(pool)
@ -30,12 +35,18 @@ impl ChannelData {
{ {
Ok(c) Ok(c)
} else { } else {
let props = channel.to_owned().guild().map(|g| (g.guild_id.as_u64().to_owned(), g.name)); let props =
channel.to_owned().guild().map(|g| (g.guild_id.as_u64().to_owned(), g.name));
let (guild_id, channel_name) = if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) }; let (guild_id, channel_name) =
if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) };
sqlx::query!( sqlx::query!(
"INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?))", "
INSERT IGNORE INTO channels
(channel, name, guild_id)
VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?))
",
channel_id, channel_id,
channel_name, channel_name,
guild_id guild_id
@ -46,7 +57,10 @@ impl ChannelData {
Ok(sqlx::query_as_unchecked!( Ok(sqlx::query_as_unchecked!(
Self, Self,
" "
SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until FROM channels WHERE channel = ? SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused,
paused_until, guild_id AS db_guild_id
FROM channels
WHERE channel = ?
", ",
channel_id channel_id
) )
@ -58,8 +72,10 @@ SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_u
pub async fn commit_changes(&self, pool: &MySqlPool) { pub async fn commit_changes(&self, pool: &MySqlPool) {
sqlx::query!( sqlx::query!(
" "
UPDATE channels SET name = ?, nudge = ?, blacklisted = ?, webhook_id = ?, webhook_token = ?, paused = ?, paused_until \ UPDATE channels
= ? WHERE id = ? SET name = ?, nudge = ?, blacklisted = ?, webhook_id = ?, webhook_token = ?,
paused = ?, paused_until = ?
WHERE id = ?
", ",
self.name, self.name,
self.nudge, self.nudge,

View File

@ -51,6 +51,7 @@ pub struct ReminderBuilder {
pool: MySqlPool, pool: MySqlPool,
uid: String, uid: String,
channel: u32, channel: u32,
guild: Option<u32>,
thread_id: Option<u64>, thread_id: Option<u64>,
utc_time: NaiveDateTime, utc_time: NaiveDateTime,
timezone: String, timezone: String,
@ -86,6 +87,7 @@ impl ReminderBuilder {
INSERT INTO reminders ( INSERT INTO reminders (
`uid`, `uid`,
`channel_id`, `channel_id`,
`guild_id`,
`utc_time`, `utc_time`,
`timezone`, `timezone`,
`interval_seconds`, `interval_seconds`,
@ -110,11 +112,13 @@ INSERT INTO reminders (
?, ?,
?, ?,
?, ?,
?,
? ?
) )
", ",
self.uid, self.uid,
self.channel, self.channel,
self.guild,
utc_time, utc_time,
self.timezone, self.timezone,
self.interval_seconds, self.interval_seconds,
@ -247,10 +251,10 @@ impl<'a> MultiReminderBuilder<'a> {
{ {
Err(ReminderError::UserBlockedDm) Err(ReminderError::UserBlockedDm)
} else { } else {
Ok(user_data.dm_channel) Ok((user_data.dm_channel, None))
} }
} else { } else {
Ok(user_data.dm_channel) Ok((user_data.dm_channel, None))
} }
} else { } else {
Err(ReminderError::InvalidTag) Err(ReminderError::InvalidTag)
@ -297,13 +301,13 @@ impl<'a> MultiReminderBuilder<'a> {
.commit_changes(&self.ctx.data().database) .commit_changes(&self.ctx.data().database)
.await; .await;
Ok(channel_data.id) Ok((channel_data.id, channel_data.db_guild_id))
} }
Err(e) => Err(ReminderError::DiscordError(e.to_string())), Err(e) => Err(ReminderError::DiscordError(e.to_string())),
} }
} else { } else {
Ok(channel_data.id) Ok((channel_data.id, channel_data.db_guild_id))
} }
} }
} else { } else {
@ -317,7 +321,8 @@ impl<'a> MultiReminderBuilder<'a> {
let builder = ReminderBuilder { let builder = ReminderBuilder {
pool: self.ctx.data().database.clone(), pool: self.ctx.data().database.clone(),
uid: generate_uid(), uid: generate_uid(),
channel: c, channel: c.0,
guild: c.1,
thread_id, thread_id,
utc_time: self.utc_time, utc_time: self.utc_time,
timezone: self.timezone.to_string(), timezone: self.timezone.to_string(),

View File

@ -304,7 +304,10 @@ WHERE
&self, &self,
db: impl Executor<'_, Database = Database>, db: impl Executor<'_, Database = Database>,
) -> Result<(), sqlx::Error> { ) -> Result<(), sqlx::Error> {
sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", self.uid) sqlx::query!(
"UPDATE reminders SET `status` = 'deleted', `status_change_time` = NOW() WHERE uid = ?",
self.uid
)
.execute(db) .execute(db)
.await .await
.map(|_| ()) .map(|_| ())

View File

@ -1,6 +1,6 @@
[package] [package]
name = "reminder_web" name = "reminder_web"
version = "0.1.2" version = "0.1.0"
authors = ["jellywx <judesouthworth@pm.me>"] authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018" edition = "2018"
@ -16,6 +16,6 @@ sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql",
chrono = "0.4" chrono = "0.4"
chrono-tz = "0.8" chrono-tz = "0.8"
lazy_static = "1.4.0" lazy_static = "1.4.0"
rand = "0.8" rand = "0.7"
base64 = "0.13" base64 = "0.13"
csv = "1.2" csv = "1.1"

View File

@ -1,40 +0,0 @@
use std::collections::HashMap;
use rocket::serde::json::json;
use rocket_dyn_templates::Template;
use crate::JsonValue;
#[catch(403)]
pub(crate) async fn forbidden() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/403", &map)
}
#[catch(500)]
pub(crate) async fn internal_server_error() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/500", &map)
}
#[catch(401)]
pub(crate) async fn not_authorized() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/401", &map)
}
#[catch(404)]
pub(crate) async fn not_found() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/404", &map)
}
#[catch(413)]
pub(crate) async fn payload_too_large() -> JsonValue {
json!({"error": "Data too large.", "errors": ["Data too large."]})
}
#[catch(422)]
pub(crate) async fn unprocessable_entity() -> JsonValue {
json!({"error": "Invalid request.", "errors": ["Invalid request."]})
}

View File

@ -1 +0,0 @@
pub(crate) mod transaction;

View File

@ -1,44 +0,0 @@
use rocket::{
http::Status,
request::{FromRequest, Outcome},
Request, State,
};
use sqlx::Pool;
use crate::Database;
pub struct Transaction<'a>(sqlx::Transaction<'a, Database>);
impl Transaction<'_> {
pub fn executor(&mut self) -> impl sqlx::Executor<'_, Database = Database> {
&mut *(self.0)
}
pub async fn commit(self) -> Result<(), sqlx::Error> {
self.0.commit().await
}
}
#[derive(Debug)]
pub enum TransactionError {
Error(sqlx::Error),
Missing,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Transaction<'r> {
type Error = TransactionError;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
match request.guard::<&State<Pool<Database>>>().await {
Outcome::Success(pool) => match pool.begin().await {
Ok(transaction) => Outcome::Success(Transaction(transaction)),
Err(e) => {
Outcome::Failure((Status::InternalServerError, TransactionError::Error(e)))
}
},
Outcome::Failure(e) => Outcome::Failure((e.0, TransactionError::Missing)),
Outcome::Forward(f) => Outcome::Forward(f),
}
}
}

View File

@ -4,16 +4,13 @@ extern crate rocket;
mod consts; mod consts;
#[macro_use] #[macro_use]
mod macros; mod macros;
mod catchers;
mod guards;
mod routes; mod routes;
use std::{env, path::Path}; use std::{collections::HashMap, env, path::Path};
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
use rocket::{ use rocket::{
fs::FileServer, fs::FileServer,
http::CookieJar,
serde::json::{json, Value as JsonValue}, serde::json::{json, Value as JsonValue},
tokio::sync::broadcast::Sender, tokio::sync::broadcast::Sender,
}; };
@ -35,6 +32,40 @@ enum Error {
Serenity(serenity::Error), Serenity(serenity::Error),
} }
#[catch(401)]
async fn not_authorized() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/401", &map)
}
#[catch(403)]
async fn forbidden() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/403", &map)
}
#[catch(404)]
async fn not_found() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/404", &map)
}
#[catch(413)]
async fn payload_too_large() -> JsonValue {
json!({"error": "Data too large.", "errors": ["Data too large."]})
}
#[catch(422)]
async fn unprocessable_entity() -> JsonValue {
json!({"error": "Invalid request.", "errors": ["Invalid request."]})
}
#[catch(500)]
async fn internal_server_error() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/500", &map)
}
pub async fn initialize( pub async fn initialize(
kill_channel: Sender<()>, kill_channel: Sender<()>,
serenity_context: Context, serenity_context: Context,
@ -69,12 +100,12 @@ pub async fn initialize(
.register( .register(
"/", "/",
catchers![ catchers![
catchers::not_authorized, not_authorized,
catchers::forbidden, forbidden,
catchers::not_found, not_found,
catchers::internal_server_error, internal_server_error,
catchers::unprocessable_entity, unprocessable_entity,
catchers::payload_too_large, payload_too_large,
], ],
) )
.manage(oauth2_client) .manage(oauth2_client)
@ -119,21 +150,22 @@ pub async fn initialize(
.mount( .mount(
"/dashboard", "/dashboard",
routes![ routes![
routes::dashboard::dashboard, routes::dashboard::dashboard_1,
routes::dashboard::dashboard_2,
routes::dashboard::dashboard_home, routes::dashboard::dashboard_home,
routes::dashboard::api::user::get_user_info, routes::dashboard::user::get_user_info,
routes::dashboard::api::user::update_user_info, routes::dashboard::user::update_user_info,
routes::dashboard::api::user::get_user_guilds, routes::dashboard::user::get_user_guilds,
routes::dashboard::api::guild::get_guild_info, routes::dashboard::guild::get_guild_patreon,
routes::dashboard::api::guild::get_guild_channels, routes::dashboard::guild::get_guild_channels,
routes::dashboard::api::guild::get_guild_roles, routes::dashboard::guild::get_guild_roles,
routes::dashboard::api::guild::get_reminder_templates, routes::dashboard::guild::get_reminder_templates,
routes::dashboard::api::guild::create_reminder_template, routes::dashboard::guild::create_reminder_template,
routes::dashboard::api::guild::delete_reminder_template, routes::dashboard::guild::delete_reminder_template,
routes::dashboard::api::guild::create_guild_reminder, routes::dashboard::guild::create_guild_reminder,
routes::dashboard::api::guild::get_reminders, routes::dashboard::guild::get_reminders,
routes::dashboard::api::guild::edit_reminder, routes::dashboard::guild::edit_reminder,
routes::dashboard::api::guild::delete_reminder, routes::dashboard::guild::delete_reminder,
routes::dashboard::export::export_reminders, routes::dashboard::export::export_reminders,
routes::dashboard::export::export_reminder_templates, routes::dashboard::export::export_reminder_templates,
routes::dashboard::export::export_todos, routes::dashboard::export::export_todos,
@ -191,65 +223,3 @@ pub async fn check_guild_subscription(
false false
} }
} }
pub async fn check_authorization(
cookies: &CookieJar<'_>,
ctx: &Context,
guild: u64,
) -> Result<(), JsonValue> {
let user_id = cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten();
if std::env::var("OFFLINE").map_or(true, |v| v != "1") {
match user_id {
Some(user_id) => {
let admin_id = std::env::var("ADMIN_ID")
.map_or(false, |u| u.parse::<u64>().map_or(false, |u| u == user_id));
if admin_id {
return Ok(());
}
match GuildId(guild).to_guild_cached(ctx) {
Some(guild) => {
let member_res = guild.member(ctx, UserId(user_id)).await;
match member_res {
Err(_) => {
return Err(json!({"error": "User not in guild"}));
}
Ok(member) => {
let permissions_res = member.permissions(ctx);
match permissions_res {
Err(_) => {
return Err(json!({"error": "Couldn't fetch permissions"}));
}
Ok(permissions) => {
if !(permissions.manage_messages()
|| permissions.manage_guild()
|| permissions.administrator())
{
return Err(json!({"error": "Incorrect permissions"}));
}
}
}
}
}
}
None => {
return Err(json!({"error": "Bot not in guild"}));
}
}
}
None => {
return Err(json!({"error": "User not authorized"}));
}
}
}
Ok(())
}

View File

@ -54,6 +54,56 @@ macro_rules! check_url_opt {
}; };
} }
macro_rules! check_authorization {
($cookies:expr, $ctx:expr, $guild:expr) => {
use serenity::model::id::UserId;
let user_id = $cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten();
if std::env::var("OFFLINE").map_or(true, |v| v != "1") {
match user_id {
Some(user_id) => {
match GuildId($guild).to_guild_cached($ctx) {
Some(guild) => {
let member_res = guild.member($ctx, UserId(user_id)).await;
match member_res {
Err(_) => {
return Err(json!({"error": "User not in guild"}));
}
Ok(member) => {
let permissions_res = member.permissions($ctx);
match permissions_res {
Err(_) => {
return Err(json!({"error": "Couldn't fetch permissions"}));
}
Ok(permissions) => {
if !(permissions.manage_messages() || permissions.manage_guild() || permissions.administrator()) {
return Err(json!({"error": "Incorrect permissions"}));
}
}
}
}
}
}
None => {
return Err(json!({"error": "Bot not in guild"}));
}
}
}
None => {
return Err(json!({"error": "User not authorized"}));
}
}
}
}
}
macro_rules! update_field { macro_rules! update_field {
($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => { ($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => {
if let Some(value) = &$reminder.$field { if let Some(value) = &$reminder.$field {

View File

@ -1,61 +0,0 @@
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

@ -1,42 +0,0 @@
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

@ -1,373 +0,0 @@
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

@ -1,35 +0,0 @@
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

@ -1,181 +0,0 @@
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

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

View File

@ -1,81 +0,0 @@
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

@ -1,97 +0,0 @@
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

@ -1,20 +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, routes::JsonResult};

View File

@ -1,29 +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, 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

@ -6,20 +6,15 @@ use rocket::{
}; };
use serenity::{ use serenity::{
client::Context, client::Context,
model::id::{ChannelId, GuildId, UserId}, model::id::{ChannelId, GuildId},
}; };
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use crate::{ use crate::routes::{
check_authorization,
guards::transaction::Transaction,
routes::{
dashboard::{ dashboard::{
create_reminder, generate_uid, ImportBody, Reminder, ReminderCsv, ReminderTemplateCsv, create_reminder, ImportBody, ReminderCreate, ReminderCsv, ReminderTemplateCsv, TodoCsv,
TodoCsv,
}, },
JsonResult, JsonResult,
},
}; };
#[get("/api/guild/<id>/export/reminders")] #[get("/api/guild/<id>/export/reminders")]
@ -29,7 +24,7 @@ pub async fn export_reminders(
ctx: &State<Context>, ctx: &State<Context>,
pool: &State<Pool<MySql>>, pool: &State<Pool<MySql>>,
) -> JsonResult { ) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?; check_authorization!(cookies, ctx.inner(), id);
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]); let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
@ -74,7 +69,7 @@ pub async fn export_reminders(
reminders.utc_time reminders.utc_time
FROM reminders FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE FIND_IN_SET(channels.channel, ?) AND status = 'pending'", WHERE FIND_IN_SET(channels.channel, ?)",
channels channels
) )
.fetch_all(pool.inner()) .fetch_all(pool.inner())
@ -122,14 +117,14 @@ pub async fn export_reminders(
} }
#[put("/api/guild/<id>/export/reminders", data = "<body>")] #[put("/api/guild/<id>/export/reminders", data = "<body>")]
pub(crate) async fn import_reminders( pub async fn import_reminders(
id: u64, id: u64,
cookies: &CookieJar<'_>, cookies: &CookieJar<'_>,
body: Json<ImportBody>, body: Json<ImportBody>,
ctx: &State<Context>, ctx: &State<Context>,
mut transaction: Transaction<'_>, pool: &State<Pool<MySql>>,
) -> JsonResult { ) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?; check_authorization!(cookies, ctx.inner(), id);
let user_id = let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap(); cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
@ -137,7 +132,6 @@ pub(crate) async fn import_reminders(
match base64::decode(&body.body) { match base64::decode(&body.body) {
Ok(body) => { Ok(body) => {
let mut reader = csv::Reader::from_reader(body.as_slice()); let mut reader = csv::Reader::from_reader(body.as_slice());
let mut count = 0;
for result in reader.deserialize::<ReminderCsv>() { for result in reader.deserialize::<ReminderCsv>() {
match result { match result {
@ -146,7 +140,7 @@ pub(crate) async fn import_reminders(
match channel_id.parse::<u64>() { match channel_id.parse::<u64>() {
Ok(channel_id) => { Ok(channel_id) => {
let reminder = Reminder { let reminder = ReminderCreate {
attachment: record.attachment, attachment: record.attachment,
attachment_name: record.attachment_name, attachment_name: record.attachment_name,
avatar: record.avatar, avatar: record.avatar,
@ -173,21 +167,18 @@ pub(crate) async fn import_reminders(
name: record.name, name: record.name,
restartable: record.restartable, restartable: record.restartable,
tts: record.tts, tts: record.tts,
uid: generate_uid(),
username: record.username, username: record.username,
utc_time: record.utc_time, utc_time: record.utc_time,
}; };
create_reminder( create_reminder(
ctx.inner(), ctx.inner(),
&mut transaction, pool.inner(),
GuildId(id), GuildId(id),
UserId(user_id), UserId(user_id),
reminder, reminder,
) )
.await?; .await?;
count += 1;
} }
Err(_) => { Err(_) => {
@ -207,16 +198,7 @@ pub(crate) async fn import_reminders(
} }
} }
match transaction.commit().await { Ok(json!({}))
Ok(_) => Ok(json!({
"message": format!("Imported {} reminders", count)
})),
Err(e) => {
warn!("Failed to commit transaction: {:?}", e);
json_err!("Couldn't commit transaction")
}
}
} }
Err(_) => { Err(_) => {
@ -232,7 +214,7 @@ pub async fn export_todos(
ctx: &State<Context>, ctx: &State<Context>,
pool: &State<Pool<MySql>>, pool: &State<Pool<MySql>>,
) -> JsonResult { ) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?; check_authorization!(cookies, ctx.inner(), id);
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]); let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
@ -287,7 +269,7 @@ pub async fn import_todos(
ctx: &State<Context>, ctx: &State<Context>,
pool: &State<Pool<MySql>>, pool: &State<Pool<MySql>>,
) -> JsonResult { ) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?; check_authorization!(cookies, ctx.inner(), id);
let channels_res = GuildId(id).channels(&ctx.inner()).await; let channels_res = GuildId(id).channels(&ctx.inner()).await;
@ -382,7 +364,7 @@ pub async fn export_reminder_templates(
ctx: &State<Context>, ctx: &State<Context>,
pool: &State<Pool<MySql>>, pool: &State<Pool<MySql>>,
) -> JsonResult { ) -> JsonResult {
check_authorization(cookies, ctx.inner(), id).await?; check_authorization!(cookies, ctx.inner(), id);
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]); let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
@ -404,9 +386,6 @@ pub async fn export_reminder_templates(
embed_thumbnail_url, embed_thumbnail_url,
embed_title, embed_title,
embed_fields, embed_fields,
interval_seconds,
interval_days,
interval_months,
tts, tts,
username username
FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",

View File

@ -1 +1,621 @@
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},
},
};
use sqlx::{MySql, Pool};
use crate::{
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,
},
routes::{
dashboard::{
create_database_channel, create_reminder, template_name_default, DeleteReminder,
DeleteReminderTemplate, PatchReminder, Reminder, ReminderCreate, ReminderTemplate,
},
JsonResult,
},
};
#[derive(Serialize)]
struct ChannelInfo {
id: String,
name: String,
webhook_avatar: Option<String>,
webhook_name: Option<String>,
}
#[get("/api/guild/<id>/patreon")]
pub async fn get_guild_patreon(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
) -> JsonResult {
offline!(Ok(json!({ "patreon": true })));
check_authorization!(cookies, ctx.inner(), id);
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 }))
}
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);
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);
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);
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);
// 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,
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.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);
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<ReminderCreate>,
cookies: &CookieJar<'_>,
serenity_context: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
check_authorization!(cookies, serenity_context.inner(), id);
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
create_reminder(
serenity_context.inner(),
pool.inner(),
GuildId(id),
UserId(user_id),
reminder.into_inner(),
)
.await
}
#[get("/api/guild/<id>/reminders?<status>")]
pub async fn get_reminders(
id: u64,
cookies: &CookieJar<'_>,
serenity_context: &State<Context>,
pool: &State<Pool<MySql>>,
status: Option<String>,
) -> JsonResult {
check_authorization!(cookies, serenity_context.inner(), id);
let status = status.unwrap_or("pending".to_string());
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,
reminders.status,
reminders.status_change_time,
reminders.status_message
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE FIND_IN_SET(`status`, ?) AND reminders.guild_id = (SELECT id FROM guilds WHERE guild = ?)",
status,
id
)
.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")
})
}
#[patch("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn edit_reminder(
id: u64,
reminder: Json<PatchReminder>,
serenity_context: &State<Context>,
pool: &State<Pool<MySql>>,
cookies: &CookieJar<'_>,
) -> JsonResult {
check_authorization!(cookies, serenity_context.inner(), id);
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!(pool.inner(), error, reminder.[
content,
embed_author,
embed_description,
embed_footer,
embed_title,
embed_fields,
username
]);
} else {
error.push("Message exceeds limits.".to_string());
}
update_field!(pool.inner(), 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(&serenity_context.inner(), id).await
|| check_subscription(&serenity_context.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(pool.inner())
.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(pool.inner())
.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(pool.inner())
.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!(pool.inner(), error, reminder.[
interval_days,
interval_months,
interval_seconds
]);
}
}
}
if reminder.channel > 0 {
let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.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(
serenity_context.inner(),
ChannelId(reminder.channel),
pool.inner(),
)
.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(pool.inner())
.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"}));
}
}
}
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,
reminders.status,
reminders.status_change_time,
reminders.status_message
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/<_>/reminders", data = "<reminder>")]
pub async fn delete_reminder(
reminder: Json<DeleteReminder>,
pool: &State<Pool<MySql>>,
) -> JsonResult {
match sqlx::query!(
"UPDATE reminders SET `status` = 'deleted', `status_change_time` = NOW() 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

@ -10,7 +10,7 @@ use serenity::{
http::Http, http::Http,
model::id::{ChannelId, GuildId, UserId}, model::id::{ChannelId, GuildId, UserId},
}; };
use sqlx::types::Json; use sqlx::{types::Json, Executor};
use crate::{ use crate::{
check_guild_subscription, check_subscription, check_guild_subscription, check_subscription,
@ -20,14 +20,13 @@ use crate::{
MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH,
MAX_NAME_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL, MAX_NAME_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL,
}, },
guards::transaction::Transaction,
routes::JsonResult, routes::JsonResult,
Error, Database, Error,
}; };
pub mod api;
pub mod export; pub mod export;
pub mod guild; pub mod guild;
pub mod user;
type Unset<T> = Option<T>; type Unset<T> = Option<T>;
@ -81,9 +80,6 @@ pub struct ReminderTemplate {
embed_thumbnail_url: Option<String>, embed_thumbnail_url: Option<String>,
embed_title: String, embed_title: String,
embed_fields: Option<Json<Vec<EmbedField>>>, embed_fields: Option<Json<Vec<EmbedField>>>,
interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>,
tts: bool, tts: bool,
username: Option<String>, username: Option<String>,
} }
@ -106,9 +102,6 @@ pub struct ReminderTemplateCsv {
embed_thumbnail_url: Option<String>, embed_thumbnail_url: Option<String>,
embed_title: String, embed_title: String,
embed_fields: Option<String>, embed_fields: Option<String>,
interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>,
tts: bool, tts: bool,
username: Option<String>, username: Option<String>,
} }
@ -125,8 +118,8 @@ pub struct EmbedField {
inline: bool, inline: bool,
} }
#[derive(Serialize, Deserialize)] #[derive(Deserialize)]
pub struct Reminder { pub struct ReminderCreate {
#[serde(with = "base64s")] #[serde(with = "base64s")]
attachment: Option<Vec<u8>>, attachment: Option<Vec<u8>>,
attachment_name: Option<String>, attachment_name: Option<String>,
@ -153,10 +146,45 @@ pub struct Reminder {
name: String, name: String,
restartable: bool, restartable: bool,
tts: bool, tts: bool,
username: Option<String>,
utc_time: NaiveDateTime,
}
#[derive(Serialize, Deserialize)]
pub struct Reminder {
#[serde(with = "base64s")]
attachment: Option<Vec<u8>>,
attachment_name: Option<String>,
avatar: Option<String>,
#[serde(with = "string_opt")]
channel: Option<u64>,
content: String,
embed_author: String,
embed_author_url: Option<String>,
embed_color: u32,
embed_description: String,
embed_footer: String,
embed_footer_url: Option<String>,
embed_image_url: Option<String>,
embed_thumbnail_url: Option<String>,
embed_title: String,
embed_fields: Option<Json<Vec<EmbedField>>>,
enabled: bool,
expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>,
#[serde(default = "name_default")]
name: String,
restartable: bool,
tts: bool,
#[serde(default)] #[serde(default)]
uid: String, uid: String,
username: Option<String>, username: Option<String>,
utc_time: NaiveDateTime, utc_time: NaiveDateTime,
status: String,
status_message: Option<String>,
status_change_time: Option<NaiveDateTime>,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -295,15 +323,7 @@ pub fn generate_uid() -> String {
mod string { mod string {
use std::{fmt::Display, str::FromStr}; use std::{fmt::Display, str::FromStr};
use serde::{de, Deserialize, Deserializer, Serializer}; use serde::{de, Deserialize, Deserializer};
pub fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
where
T: Display,
S: Serializer,
{
serializer.collect_str(value)
}
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error> pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
where where
@ -315,6 +335,34 @@ mod string {
} }
} }
mod string_opt {
use std::{fmt::Display, str::FromStr};
use serde::{de, Deserialize, Deserializer, Serializer};
pub fn serialize<T, S>(value: &Option<T>, serializer: S) -> Result<S::Ok, S::Error>
where
T: Display,
S: Serializer,
{
match value {
Some(value) => serializer.collect_str(value),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
where
T: FromStr,
T::Err: Display,
D: Deserializer<'de>,
{
Option::deserialize(deserializer)?
.map(|d: String| d.parse().map_err(de::Error::custom))
.transpose()
}
}
mod base64s { mod base64s {
use serde::{de, Deserialize, Deserializer, Serializer}; use serde::{de, Deserialize, Deserializer, Serializer};
@ -354,21 +402,21 @@ pub struct TodoCsv {
channel_id: Option<String>, channel_id: Option<String>,
} }
pub(crate) async fn create_reminder( pub async fn create_reminder(
ctx: &Context, ctx: &Context,
transaction: &mut Transaction<'_>, pool: impl sqlx::Executor<'_, Database = Database> + Copy,
guild_id: GuildId, guild_id: GuildId,
user_id: UserId, user_id: UserId,
reminder: Reminder, reminder: ReminderCreate,
) -> JsonResult { ) -> JsonResult {
// check guild in db // check guild in db
match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.0) match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.0)
.fetch_one(transaction.executor()) .fetch_one(pool)
.await .await
{ {
Err(sqlx::Error::RowNotFound) => { Err(sqlx::Error::RowNotFound) => {
if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.0) if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.0)
.execute(transaction.executor()) .execute(pool)
.await .await
.is_err() .is_err()
{ {
@ -387,14 +435,14 @@ pub(crate) async fn create_reminder(
if !channel_matches_guild || !channel_exists { if !channel_matches_guild || !channel_exists {
warn!( warn!(
"Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})", "Error in `create_reminder`: channel {:?} not found for guild {} (channel exists: {})",
reminder.channel, guild_id, channel_exists reminder.channel, guild_id, channel_exists
); );
return Err(json!({"error": "Channel not found"})); return Err(json!({"error": "Channel not found"}));
} }
let channel = create_database_channel(&ctx, ChannelId(reminder.channel), transaction).await; let channel = create_database_channel(&ctx, ChannelId(reminder.channel), pool).await;
if let Err(e) = channel { if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e); warn!("`create_database_channel` returned an error code: {:?}", e);
@ -468,6 +516,8 @@ pub(crate) async fn create_reminder(
} }
} }
// base64 decode error dropped here
let attachment_data = reminder.attachment.as_ref().map(|s| base64::decode(s).ok()).flatten();
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() }; let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
let username = if reminder.username.as_ref().map(|s| s.is_empty()).unwrap_or(true) { let username = if reminder.username.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
None None
@ -479,11 +529,13 @@ pub(crate) async fn create_reminder(
// write to db // write to db
match sqlx::query!( match sqlx::query!(
"INSERT INTO reminders ( "
INSERT INTO reminders (
uid, uid,
attachment, attachment,
attachment_name, attachment_name,
channel_id, channel_id,
guild_id,
avatar, avatar,
content, content,
embed_author, embed_author,
@ -506,11 +558,14 @@ pub(crate) async fn create_reminder(
tts, tts,
username, username,
`utc_time` `utc_time`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) VALUES (?, ?, ?, ?,
(SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?)",
new_uid, new_uid,
reminder.attachment, attachment_data,
reminder.attachment_name, reminder.attachment_name,
channel, channel,
guild_id.0,
reminder.avatar, reminder.avatar,
reminder.content, reminder.content,
reminder.embed_author, reminder.embed_author,
@ -534,7 +589,7 @@ pub(crate) async fn create_reminder(
username, username,
reminder.utc_time, reminder.utc_time,
) )
.execute(transaction.executor()) .execute(pool)
.await .await
{ {
Ok(_) => sqlx::query_as_unchecked!( Ok(_) => sqlx::query_as_unchecked!(
@ -565,13 +620,16 @@ pub(crate) async fn create_reminder(
reminders.tts, reminders.tts,
reminders.uid, reminders.uid,
reminders.username, reminders.username,
reminders.utc_time reminders.utc_time,
reminders.status,
reminders.status_change_time,
reminders.status_message
FROM reminders FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE uid = ?", WHERE uid = ?",
new_uid new_uid
) )
.fetch_one(transaction.executor()) .fetch_one(pool)
.await .await
.map(|r| Ok(json!(r))) .map(|r| Ok(json!(r)))
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
@ -591,11 +649,11 @@ pub(crate) async fn create_reminder(
async fn create_database_channel( async fn create_database_channel(
ctx: impl AsRef<Http>, ctx: impl AsRef<Http>,
channel: ChannelId, channel: ChannelId,
transaction: &mut Transaction<'_>, pool: impl Executor<'_, Database = Database> + Copy,
) -> Result<u32, crate::Error> { ) -> Result<u32, crate::Error> {
let row = let row =
sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0) sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0)
.fetch_one(transaction.executor()) .fetch_one(pool)
.await; .await;
match row { match row {
@ -612,7 +670,7 @@ async fn create_database_channel(
webhook.token, webhook.token,
channel.0 channel.0
) )
.execute(transaction.executor()) .execute(pool)
.await .await
.map_err(|e| Error::SQLx(e))?; .map_err(|e| Error::SQLx(e))?;
} }
@ -638,7 +696,7 @@ async fn create_database_channel(
webhook.token, webhook.token,
channel.0 channel.0
) )
.execute(transaction.executor()) .execute(pool)
.await .await
.map_err(|e| Error::SQLx(e))?; .map_err(|e| Error::SQLx(e))?;
@ -649,7 +707,7 @@ async fn create_database_channel(
}?; }?;
let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.0) let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.0)
.fetch_one(transaction.executor()) .fetch_one(pool)
.await .await
.map_err(|e| Error::SQLx(e))?; .map_err(|e| Error::SQLx(e))?;
@ -659,19 +717,27 @@ async fn create_database_channel(
#[get("/")] #[get("/")]
pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Redirect> { pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
if cookies.get_private("userid").is_some() { if cookies.get_private("userid").is_some() {
let mut map = HashMap::new(); let map: HashMap<&str, String> = HashMap::new();
map.insert("version", env!("CARGO_PKG_VERSION"));
Ok(Template::render("dashboard", &map)) Ok(Template::render("dashboard", &map))
} else { } else {
Err(Redirect::to("/login/discord")) Err(Redirect::to("/login/discord"))
} }
} }
#[get("/<_..>")] #[get("/<_>")]
pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<Template, Redirect> { pub async fn dashboard_1(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
if cookies.get_private("userid").is_some() { if cookies.get_private("userid").is_some() {
let mut map = HashMap::new(); let map: HashMap<&str, String> = HashMap::new();
map.insert("version", env!("CARGO_PKG_VERSION")); Ok(Template::render("dashboard", &map))
} else {
Err(Redirect::to("/login/discord"))
}
}
#[get("/<_>/<_>")]
pub async fn dashboard_2(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
if cookies.get_private("userid").is_some() {
let map: HashMap<&str, String> = HashMap::new();
Ok(Template::render("dashboard", &map)) Ok(Template::render("dashboard", &map))
} else { } else {
Err(Redirect::to("/login/discord")) Err(Redirect::to("/login/discord"))

View File

@ -0,0 +1,172 @@
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

@ -15,10 +15,6 @@ div.reminderContent.is-collapsed .column.settings {
display: none; display: none;
} }
div.reminderContent.is-collapsed .reminder-settings {
margin-bottom: 0;
}
div.reminderContent.is-collapsed .button-row { div.reminderContent.is-collapsed .button-row {
display: none; display: none;
} }
@ -308,6 +304,11 @@ div.dashboard-sidebar {
padding-right: 0; padding-right: 0;
} }
div.dashboard-sidebar:not(.mobile-sidebar) {
display: flex;
flex-direction: column;
}
ul.guildList { ul.guildList {
flex-grow: 1; flex-grow: 1;
flex-shrink: 1; flex-shrink: 1;
@ -317,9 +318,6 @@ ul.guildList {
div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer { div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer {
flex-shrink: 0; flex-shrink: 0;
flex-grow: 0; flex-grow: 0;
position: fixed;
bottom: 0;
width: 226px;
} }
div.dashboard-sidebar svg { div.dashboard-sidebar svg {

View File

@ -18,6 +18,7 @@ const $downloader = document.querySelector("a#downloader");
const $uploader = document.querySelector("input#uploader"); const $uploader = document.querySelector("input#uploader");
let channels = []; let channels = [];
let reminderErrors = [];
let guildNames = {}; let guildNames = {};
let roles = []; let roles = [];
let templates = {}; let templates = {};
@ -33,16 +34,11 @@ let globalPatreon = false;
let guildPatreon = false; let guildPatreon = false;
function guildId() { function guildId() {
return window.location.pathname.match(/dashboard\/(\d+)/)[1]; return document.querySelector("li > a.is-active").parentElement.dataset["guild"];
} }
function pane() { function guildName() {
const match = window.location.pathname.match(/dashboard\/\d+\/(.+)/); return guildNames[guildId()];
if (match === null) {
return null;
} else {
return match[1];
}
} }
function colorToInt(r, g, b) { function colorToInt(r, g, b) {
@ -61,7 +57,7 @@ function switch_pane(selector) {
el.classList.add("is-hidden"); el.classList.add("is-hidden");
}); });
document.getElementById(selector).classList.remove("is-hidden"); document.querySelector(`*[data-name=${selector}]`).classList.remove("is-hidden");
} }
function update_select(sel) { function update_select(sel) {
@ -105,7 +101,7 @@ function reset_guild_pane() {
} }
async function fetch_patreon(guild_id) { async function fetch_patreon(guild_id) {
fetch(`/dashboard/api/guild/${guild_id}`) fetch(`/dashboard/api/guild/${guild_id}/patreon`)
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
if (data.error) { if (data.error) {
@ -232,10 +228,11 @@ async function fetch_reminders(guild_id) {
} }
async function serialize_reminder(node, mode) { async function serialize_reminder(node, mode) {
let utc_time, expiration_time; let interval, utc_time, expiration_time;
let interval = get_interval(node);
if (mode !== "template") { if (mode !== "template") {
interval = get_interval(node);
utc_time = luxon.DateTime.fromISO( utc_time = luxon.DateTime.fromISO(
node.querySelector('input[name="time"]').value node.querySelector('input[name="time"]').value
).setZone("UTC"); ).setZone("UTC");
@ -364,9 +361,9 @@ async function serialize_reminder(node, mode) {
embed_title: embed_title, embed_title: embed_title,
embed_fields: fields, embed_fields: fields,
expires: expiration_time, expires: expiration_time,
interval_seconds: interval.seconds, interval_seconds: mode !== "template" ? interval.seconds : null,
interval_days: interval.days, interval_days: mode !== "template" ? interval.days : null,
interval_months: interval.months, interval_months: mode !== "template" ? interval.months : null,
name: node.querySelector('input[name="name"]').value, name: node.querySelector('input[name="name"]').value,
tts: node.querySelector('input[name="tts"]').checked, tts: node.querySelector('input[name="tts"]').checked,
username: node.querySelector('input[name="username"]').value, username: node.querySelector('input[name="username"]').value,
@ -428,9 +425,9 @@ function deserialize_reminder(reminder, frame, mode) {
.insertBefore(embed_field, lastChild); .insertBefore(embed_field, lastChild);
} }
if (mode !== "template") {
if (reminder["interval_seconds"]) update_interval(frame); if (reminder["interval_seconds"]) update_interval(frame);
if (mode !== "template") {
let $enableBtn = frame.querySelector(".disable-enable"); let $enableBtn = frame.querySelector(".disable-enable");
$enableBtn.dataset["action"] = reminder["enabled"] ? "disable" : "enable"; $enableBtn.dataset["action"] = reminder["enabled"] ? "disable" : "enable";
@ -457,23 +454,27 @@ document.addEventListener("guildSwitched", async (e) => {
.querySelectorAll(".patreon-only") .querySelectorAll(".patreon-only")
.forEach((el) => el.classList.add("is-locked")); .forEach((el) => el.classList.add("is-locked"));
let $anchor = document.querySelector( let $li = document.querySelectorAll(`li[data-guild="${e.detail.guild_id}"]`);
`.switch-pane[data-guild="${e.detail.guild_id}"]`
);
let hasError = false; if ($li.length === 0) {
switch_pane("user-error");
if (pane() === null) { return;
window.history.replaceState({}, "", `/dashboard/${guildId()}/reminders`);
}
switch_pane(pane());
if ($anchor !== null) {
$anchor.classList.add("is-active");
} }
switch_pane(e.detail.pane);
reset_guild_pane(); reset_guild_pane();
document
.querySelectorAll(`li[data-guild="${e.detail.guild_id}"] > a`)
.forEach((el) => {
el.classList.add("is-active");
});
document
.querySelectorAll(
`li[data-guild="${e.detail.guild_id}"] *[data-pane="${e.detail.pane}"]`
)
.forEach((el) => {
el.classList.add("is-active");
});
if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) { if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) {
document document
@ -481,15 +482,26 @@ document.addEventListener("guildSwitched", async (e) => {
.forEach((el) => el.classList.remove("is-locked")); .forEach((el) => el.classList.remove("is-locked"));
} }
hasError = await fetch_channels(e.detail.guild_id); const event = new CustomEvent("paneLoad", {
detail: {
guild_id: e.detail.guild_id,
pane: e.detail.pane,
},
});
document.dispatchEvent(event);
});
document.addEventListener("paneLoad", async (ev) => {
const hasError = await fetch_channels(ev.detail.guild_id);
if (!hasError) { if (!hasError) {
fetch_roles(e.detail.guild_id); fetch_roles(ev.detail.guild_id);
fetch_templates(e.detail.guild_id); fetch_templates(ev.detail.guild_id);
fetch_reminders(e.detail.guild_id); fetch_reminders(ev.detail.guild_id);
document.querySelectorAll("p.pageTitle").forEach((el) => { document.querySelectorAll("p.pageTitle").forEach((el) => {
el.textContent = `${e.detail.guild_name} Reminders`; el.textContent = `${guildName()} Reminders`;
}); });
document.querySelectorAll("select.channel-selector").forEach((el) => { document.querySelectorAll("select.channel-selector").forEach((el) => {
el.addEventListener("change", (e) => { el.addEventListener("change", (e) => {
update_select(e.target); update_select(e.target);
@ -614,16 +626,6 @@ function show_error(error) {
}, 5000); }, 5000);
} }
function show_success(error) {
document.getElementById("success").querySelector("span.success-message").textContent =
error;
document.getElementById("success").classList.add("is-active");
window.setTimeout(() => {
document.getElementById("success").classList.remove("is-active");
}, 5000);
}
$colorPickerInput.value = colorPicker.color.hexString; $colorPickerInput.value = colorPicker.color.hexString;
$colorPickerInput.addEventListener("input", () => { $colorPickerInput.addEventListener("input", () => {
@ -704,40 +706,56 @@ document.addEventListener("DOMContentLoaded", async () => {
"%guildname%", "%guildname%",
guild.name guild.name
); );
$anchor.dataset["guild"] = guild.id;
$anchor.dataset["name"] = guild.name; $anchor.dataset["name"] = guild.name;
$anchor.href = `/dashboard/${guild.id}/reminders`; $anchor.href = `/dashboard/${guild.id}/reminders`;
$anchor.addEventListener("click", async (e) => { const $li = $anchor.parentElement;
$li.dataset["guild"] = guild.id;
$li.querySelectorAll("a").forEach((el) => {
el.addEventListener("click", (e) => {
const pane = el.dataset["pane"];
const slug = el.dataset["slug"];
if (pane !== undefined && slug !== undefined) {
e.preventDefault(); e.preventDefault();
switch_pane(pane);
window.history.pushState( window.history.pushState(
{}, {},
"", "",
`/dashboard/${guild.id}/reminders` `/dashboard/${guild.id}/${slug}`
); );
const event = new CustomEvent("guildSwitched", { const event = new CustomEvent("guildSwitched", {
detail: { detail: {
guild_name: guild.name,
guild_id: guild.id, guild_id: guild.id,
pane,
}, },
}); });
document.dispatchEvent(event); document.dispatchEvent(event);
}
});
}); });
element.append($clone); element.append($clone);
}); });
} }
const matches = window.location.href.match(/dashboard\/(\d+)/); const matches = window.location.href.match(
/dashboard\/(\d+)(\/)?([a-zA-Z\-]+)?/
);
if (matches) { if (matches) {
let id = matches[1]; let id = matches[1];
let kind = matches[3];
let name = guildNames[id]; let name = guildNames[id];
const event = new CustomEvent("guildSwitched", { const event = new CustomEvent("guildSwitched", {
detail: { detail: {
guild_name: name, guild_name: name,
guild_id: id, guild_id: id,
pane: kind,
}, },
}); });
@ -778,25 +796,11 @@ $uploader.addEventListener("change", (ev) => {
fileReader.onload = (e) => resolve(fileReader.result); fileReader.onload = (e) => resolve(fileReader.result);
fileReader.readAsDataURL($uploader.files[0]); fileReader.readAsDataURL($uploader.files[0]);
}).then((dataUrl) => { }).then((dataUrl) => {
$importBtn.setAttribute("disabled", true);
fetch(`/dashboard/api/guild/${guildId()}/export/${urlTail}`, { fetch(`/dashboard/api/guild/${guildId()}/export/${urlTail}`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ body: dataUrl.split(",")[1] }), body: JSON.stringify({ body: dataUrl.split(",")[1] }),
}) }).then(() => {
.then((response) => response.json())
.then((data) => {
$importBtn.removeAttribute("disabled");
if (data.error) {
show_error(data.error);
} else {
show_success(data.message);
}
})
.then(() => {
delete $uploader.files[0]; delete $uploader.files[0];
fetch_reminders(guild);
}); });
}); });
}); });

View File

@ -0,0 +1,45 @@
function loadErrors() {
return fetch(
`/dashboard/api/guild/${guildId()}/reminders?status=deleted,sent,failed`
).then((response) => response.json());
}
document.addEventListener("paneLoad", (ev) => {
if (ev.detail.pane !== "errors") {
return;
}
document.querySelectorAll(".reminderError").forEach((el) => el.remove());
const template = document.getElementById("reminderError");
const container = document.getElementById("reminderLog");
loadErrors()
.then((res) => {
res = res
.filter((r) => r.status_change_time !== null)
.sort((a, b) => a.status_change_time < b.status_change_time);
for (const reminder of res) {
const newRow = template.content.cloneNode(true);
newRow.querySelector(".reminderError").dataset["case"] = reminder.status;
const statusTime = new luxon.DateTime.fromISO(
reminder.status_change_time,
{ zone: "UTC" }
);
newRow.querySelector(".reminderName").textContent = reminder.name;
newRow.querySelector(".reminderMessage").textContent =
reminder.status_message;
newRow.querySelector(".reminderTime").textContent = statusTime
.toLocal()
.toLocaleString(luxon.DateTime.DATETIME_MED);
container.appendChild(newRow);
}
})
.finally(() => {
$loader.classList.add("is-hidden");
});
});

View File

@ -27,7 +27,7 @@
<link rel="stylesheet" href="/static/css/bulma.min.css"> <link rel="stylesheet" href="/static/css/bulma.min.css">
<link rel="stylesheet" href="/static/css/fa.css"> <link rel="stylesheet" href="/static/css/fa.css">
<link rel="stylesheet" href="/static/css/font.css"> <link rel="stylesheet" href="/static/css/font.css">
<link rel="stylesheet" href="/static/css/style.css?v{{ version }}"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/dtsel.css"> <link rel="stylesheet" href="/static/css/dtsel.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tributejs/5.1.3/tribute.css" integrity="sha512-GnwBnXd+ZGO9CdP343MUr0jCcJXCr++JVtQRnllexRW2IDq4Zvrh/McTQjooAKnSUbXZ7wamp7AQSweTnfMVoA==" crossorigin="anonymous" referrerpolicy="no-referrer" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tributejs/5.1.3/tribute.css" integrity="sha512-GnwBnXd+ZGO9CdP343MUr0jCcJXCr++JVtQRnllexRW2IDq4Zvrh/McTQjooAKnSUbXZ7wamp7AQSweTnfMVoA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
@ -76,10 +76,6 @@
<span class="icon"><i class="far fa-exclamation-circle"></i></span> <span class="error-message"></span> <span class="icon"><i class="far fa-exclamation-circle"></i></span> <span class="error-message"></span>
</div> </div>
<div class="notification is-success flash-message" id="success">
<span class="icon"><i class="far fa-check"></i></span> <span class="success-message"></span>
</div>
<div class="modal" id="addImageModal"> <div class="modal" id="addImageModal">
<div class="modal-background"></div> <div class="modal-background"></div>
<div class="modal-card"> <div class="modal-card">
@ -189,6 +185,14 @@
</label> </label>
</div> </div>
</div> </div>
<div class="control">
<div class="field">
<label>
<input type="radio" class="default-width" name="exportSelect" value="todos">
Todo Lists
</label>
</div>
</div>
<br> <br>
<div class="has-text-centered"> <div class="has-text-centered">
<div style="color: red"> <div style="color: red">
@ -253,9 +257,11 @@
</p> </p>
<ul class="menu-list"> <ul class="menu-list">
<li> <li>
{#
<a class="show-modal" data-modal="dataManagerModal"> <a class="show-modal" data-modal="dataManagerModal">
<span class="icon"><i class="fas fa-exchange"></i></span> Import/Export <span class="icon"><i class="fas fa-exchange"></i></span> Import/Export
</a> </a>
#}
<a class="show-modal" data-modal="chooseTimezoneModal"> <a class="show-modal" data-modal="chooseTimezoneModal">
<span class="icon"><i class="fas fa-map-marked"></i></span> Timezone <span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
</a> </a>
@ -297,9 +303,11 @@
</p> </p>
<ul class="menu-list"> <ul class="menu-list">
<li> <li>
{#
<a class="show-modal" data-modal="dataManagerModal"> <a class="show-modal" data-modal="dataManagerModal">
<span class="icon"><i class="fas fa-exchange"></i></span> Import/Export <span class="icon"><i class="fas fa-exchange"></i></span> Import/Export
</a> </a>
#}
<a class="show-modal" data-modal="chooseTimezoneModal"> <a class="show-modal" data-modal="chooseTimezoneModal">
<span class="icon"><i class="fas fa-map-marked"></i></span> Timezone <span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
</a> </a>
@ -325,16 +333,16 @@
<p class="subtitle is-hidden-desktop">Press the <span class="icon"><i class="fal fa-bars"></i></span> to get started</p> <p class="subtitle is-hidden-desktop">Press the <span class="icon"><i class="fal fa-bars"></i></span> to get started</p>
</div> </div>
</section> </section>
<section id="reminders" class="is-hidden"> <section data-name="reminders" class="is-hidden">
{% include "reminder_dashboard/reminder_dashboard" %} {% include "reminder_dashboard/reminder_dashboard" %}
</section> </section>
<section id="reminder-errors" class="is-hidden"> <section data-name="errors" class="is-hidden">
{% include "reminder_dashboard/reminder_errors" %} {% include "reminder_dashboard/reminder_errors" %}
</section> </section>
<section id="guild-error" class="is-hidden"> <section data-name="guild-error" class="is-hidden">
{% include "reminder_dashboard/guild_error" %} {% include "reminder_dashboard/guild_error" %}
</section> </section>
<section id="user-error" class="is-hidden"> <section data-name="user-error" class="is-hidden">
{% include "reminder_dashboard/user_error" %} {% include "reminder_dashboard/user_error" %}
</section> </section>
</div> </div>
@ -368,22 +376,36 @@
<template id="guildListEntry"> <template id="guildListEntry">
<li> <li>
<a class="switch-pane" data-pane="guild"> <a class="switch-pane" data-pane="reminders" data-slug="reminders">
<span class="icon"><i class="fas fa-map-pin"></i></span> <span class="guild-name">%guildname%</span> <span class="icon"><i class="fas fa-map-pin"></i></span> <span class="guild-name">%guildname%</span>
</a> </a>
<ul class="guild-submenu">
<li>
<a class="switch-pane" data-pane="reminders" data-slug="reminders">
<span class="icon"><i class="fas fa-calendar-alt"></i></span> Reminders
</a>
<a class="switch-pane" data-pane="errors" data-slug="errors">
<span class="icon"><i class="fas fa-file-alt"></i></span> Logs
</a>
</li>
</ul>
</li> </li>
</template> </template>
<template id="guildReminder"> <template id="guildReminder">
{% include "reminder_dashboard/guild_reminder" %} {% include "reminder_dashboard/templates/guild_reminder" %}
</template>
<template id="reminderError">
{% include "reminder_dashboard/templates/reminder_error" %}
</template> </template>
<script src="/static/js/iro.js"></script> <script src="/static/js/iro.js"></script>
<script src="/static/js/dtsel.js"></script> <script src="/static/js/dtsel.js"></script>
<script src="/static/js/interval.js?v{{ version }}"></script> <script src="/static/js/interval.js"></script>
<script src="/static/js/timezone.js?v{{ version }}" defer></script> <script src="/static/js/timezone.js" defer></script>
<script src="/static/js/main.js?v{{ version }}" defer></script> <script src="/static/js/main.js" defer></script>
</body> </body>
</html> </html>

View File

@ -2,7 +2,7 @@
<strong>Create Reminder</strong> <strong>Create Reminder</strong>
<div id="reminderCreator"> <div id="reminderCreator">
{% set creating = true %} {% set creating = true %}
{% include "reminder_dashboard/guild_reminder" %} {% include "reminder_dashboard/templates/guild_reminder" %}
{% set creating = false %} {% set creating = false %}
</div> </div>
<br> <br>
@ -46,6 +46,10 @@
<div id="guildReminders"> <div id="guildReminders">
</div> </div>
<div id="guildErrors">
</div>
</div> </div>
<script src="/static/js/sort.js"></script> <script src="/static/js/sort.js"></script>

View File

@ -1,5 +1,5 @@
<div> <div id="reminderLog">
</div> </div>
<!--<script src="/static/js/reminder_errors.js"></script>--> <script src="/static/js/reminder_errors.js"></script>

View File

@ -0,0 +1,20 @@
<div class="reminderError" data-case="success">
<div class="errorHead">
<div class="errorIcon">
<span class="icon">
<i class="fas fa-trash"></i>
<i class="fas fa-check"></i>
<i class="fas fa-exclamation-triangle"></i>
</span>
</div>
<div class="reminderName">
Reminder
</div>
<div class="reminderMessage">
</div>
<div class="reminderTime">
</div>
</div>
</div>