Compare commits
	
		
			35 Commits
		
	
	
		
			82dab53744
			...
			jude/react
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 1a03c2471b | ||
| a476f43f28 | |||
| 17192b0f89 | |||
|  | 0419863afa | ||
|  | 827a982a40 | ||
|  | 6e435bfc2e | ||
| 8ba0f02b98 | |||
| d36438c6ce | |||
| e0c60e2ce3 | |||
|  | e7160215b0 | ||
|  | 6eaa6f0f28 | ||
|  | 9db0fa2513 | ||
|  | ca13fd4fa7 | ||
|  | 55acc8fd16 | ||
|  | 145711fa5d | ||
|  | 5524215786 | ||
|  | e8bd05893f | ||
|  | e3d3418f99 | ||
|  | 2681280a39 | ||
|  | 00579428a1 | ||
|  | b8ef999710 | ||
|  | e8f84e281a | ||
|  | 8ddff698e5 | ||
|  | 541633270c | ||
|  | 25286da5e0 | ||
|  | 4bad1324b9 | ||
|  | bd1462a00c | ||
|  | 56ffc43616 | ||
|  | 52cf642455 | ||
|  | 0bf578357a | ||
|  | 6e9eccb62e | ||
|  | 6ea28284ce | ||
|  | a6525f3052 | ||
|  | 348639270d | ||
|  | 37177c2431 | 
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -3,3 +3,5 @@ | |||||||
| /venv | /venv | ||||||
| .cargo | .cargo | ||||||
| /.idea | /.idea | ||||||
|  | web/static/index.html | ||||||
|  | web/static/assets | ||||||
|   | |||||||
							
								
								
									
										1910
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1910
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										11
									
								
								Cargo.toml
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								Cargo.toml
									
									
									
									
									
								
							| @@ -1,18 +1,18 @@ | |||||||
| [package] | [package] | ||||||
| name = "reminder-rs" | name = "reminder-rs" | ||||||
| version = "1.6.38" | version = "1.6.50" | ||||||
| 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" | ||||||
| description = "Reminder Bot for Discord, now in Rust" | description = "Reminder Bot for Discord, now in Rust" | ||||||
|  |  | ||||||
| [dependencies] | [dependencies] | ||||||
| poise = "0.5.5" | 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 = "2.3.0" | lazy-regex = "3.0.2" | ||||||
| regex = "1.6" | regex = "1.9" | ||||||
| log = "0.4" | log = "0.4" | ||||||
| env_logger = "0.10" | env_logger = "0.10" | ||||||
| chrono = "0.4" | chrono = "0.4" | ||||||
| @@ -25,7 +25,7 @@ serde_repr = "0.1" | |||||||
| rmp-serde = "1.1" | rmp-serde = "1.1" | ||||||
| rand = "0.8" | rand = "0.8" | ||||||
| levenshtein = "1.0" | levenshtein = "1.0" | ||||||
| sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]} | sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]} | ||||||
| base64 = "0.21.0" | base64 = "0.21.0" | ||||||
|  |  | ||||||
| [dependencies.postman] | [dependencies.postman] | ||||||
| @@ -43,6 +43,7 @@ assets = [ | |||||||
|     ["conf/default.env", "etc/reminder-rs/config.env", "600"], |     ["conf/default.env", "etc/reminder-rs/config.env", "600"], | ||||||
|     ["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"], |     ["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"], | ||||||
|     ["web/static/**/*", "lib/reminder-rs/static", "644"], |     ["web/static/**/*", "lib/reminder-rs/static", "644"], | ||||||
|  |     ["reminder-dashboard/dist/static/**/*", "lib/reminder-rs/static", "644"], | ||||||
|     ["web/templates/**/*", "lib/reminder-rs/templates", "644"], |     ["web/templates/**/*", "lib/reminder-rs/templates", "644"], | ||||||
|     ["healthcheck", "lib/reminder-rs/healthcheck", "755"], |     ["healthcheck", "lib/reminder-rs/healthcheck", "755"], | ||||||
|     ["cron.d/reminder_health", "etc/cron.d/reminder_health", "644"], |     ["cron.d/reminder_health", "etc/cron.d/reminder_health", "644"], | ||||||
|   | |||||||
| @@ -1,19 +0,0 @@ | |||||||
| -- 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); |  | ||||||
| @@ -1,4 +0,0 @@ | |||||||
| 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`; |  | ||||||
| @@ -0,0 +1,3 @@ | |||||||
|  | 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; | ||||||
| @@ -5,12 +5,12 @@ edition = "2021" | |||||||
|  |  | ||||||
| [dependencies] | [dependencies] | ||||||
| tokio = { version = "1", features = ["process", "full"] } | tokio = { version = "1", features = ["process", "full"] } | ||||||
| regex = "1.4" | regex = "1.9" | ||||||
| log = "0.4" | log = "0.4" | ||||||
| chrono = "0.4" | chrono = "0.4" | ||||||
| chrono-tz = { version = "0.5", features = ["serde"] } | chrono-tz = { version = "0.8", features = ["serde"] } | ||||||
| lazy_static = "1.4" | lazy_static = "1.4" | ||||||
| num-integer = "0.1" | num-integer = "0.1" | ||||||
| serde = "1.0" | serde = "1.0" | ||||||
| sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]} | sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]} | ||||||
| serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } | serenity = { version = "0.11", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } | ||||||
|   | |||||||
| @@ -310,7 +310,6 @@ WHERE | |||||||
|             reminders |             reminders | ||||||
|         WHERE |         WHERE | ||||||
|             reminders.`utc_time` <= NOW() AND |             reminders.`utc_time` <= NOW() AND | ||||||
|             reminders.`channel_id` IS NOT NULL AND |  | ||||||
|             `status` = 'pending' AND |             `status` = 'pending' AND | ||||||
|             ( |             ( | ||||||
|                 reminders.`interval_seconds` IS NOT NULL |                 reminders.`interval_seconds` IS NOT NULL | ||||||
| @@ -472,17 +471,10 @@ 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!( |         sqlx::query!("UPDATE reminders SET `status` = 'sent' WHERE `id` = ?", self.id) | ||||||
|             " |             .execute(pool) | ||||||
|             UPDATE reminders |             .await | ||||||
|             SET `status` = 'sent', `status_change_time` = NOW() |             .expect(&format!("Could not delete Reminder {}", self.id)); | ||||||
|             WHERE `id` = ? |  | ||||||
|             ", |  | ||||||
|             self.id |  | ||||||
|         ) |  | ||||||
|         .execute(pool) |  | ||||||
|         .await |  | ||||||
|         .expect(&format!("Could not delete Reminder {}", self.id)); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     async fn set_failed( |     async fn set_failed( | ||||||
|   | |||||||
| @@ -27,7 +27,7 @@ pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> { | |||||||
|         "SELECT name, command FROM command_aliases WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", |         "SELECT name, command FROM command_aliases WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", | ||||||
|         guild_id.0 |         guild_id.0 | ||||||
|     ) |     ) | ||||||
|     .fetch_all(&mut transaction) |     .fetch_all(&mut *transaction) | ||||||
|     .await?; |     .await?; | ||||||
|  |  | ||||||
|     let mut added_aliases = 0; |     let mut added_aliases = 0; | ||||||
| @@ -42,7 +42,7 @@ pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> { | |||||||
|                     cmd_macro.description, |                     cmd_macro.description, | ||||||
|                     cmd_macro.commands |                     cmd_macro.commands | ||||||
|                 ) |                 ) | ||||||
|                 .execute(&mut transaction) |                 .execute(&mut *transaction) | ||||||
|                 .await?; |                 .await?; | ||||||
|  |  | ||||||
|                 added_aliases += 1; |                 added_aliases += 1; | ||||||
|   | |||||||
| @@ -114,6 +114,8 @@ 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); | ||||||
| @@ -619,7 +621,7 @@ pub async fn multiline( | |||||||
| )] | )] | ||||||
| pub async fn remind( | pub async fn remind( | ||||||
|     ctx: ApplicationContext<'_>, |     ctx: ApplicationContext<'_>, | ||||||
|     #[description = "A description of the time to set the reminder for"] |     #[description = "The time (and optionally date) 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, | ||||||
| @@ -773,7 +775,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://reminder-bot.com/dashboard") |                                         .url("https://beta.reminder-bot.com/dashboard") | ||||||
|                                 }) |                                 }) | ||||||
|                             }) |                             }) | ||||||
|                         }) |                         }) | ||||||
|   | |||||||
| @@ -166,21 +166,15 @@ impl ComponentDataModel { | |||||||
|                     .await; |                     .await; | ||||||
|             } |             } | ||||||
|             ComponentDataModel::DelSelector(selector) => { |             ComponentDataModel::DelSelector(selector) => { | ||||||
|                 for id in &component.data.values { |                 let selected_id = component.data.values.join(","); | ||||||
|                     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"); |  | ||||||
|                             } |  | ||||||
|                         } |  | ||||||
|  |  | ||||||
|                         Err(e) => { |                 sqlx::query!( | ||||||
|                             warn!("Error casting ID to integer: {:?}.", e); |                     "UPDATE reminders SET `status` = 'deleted' WHERE FIND_IN_SET(id, ?)", | ||||||
|                         } |                     selected_id | ||||||
|                     } |                 ) | ||||||
|                 } |                 .execute(&data.database) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(); | ||||||
|  |  | ||||||
|                 let reminders = Reminder::from_guild( |                 let reminders = Reminder::from_guild( | ||||||
|                     &ctx, |                     &ctx, | ||||||
|   | |||||||
| @@ -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" | "M" => (n, 0, 0, 0), |             "months" | "month" => (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.chars(), src: s, current: (0, 0, 0, 0) }.parse() |     Parser { iter: s.to_lowercase().chars(), src: &s.to_lowercase(), current: (0, 0, 0, 0) }.parse() | ||||||
| } | } | ||||||
|  |  | ||||||
| #[cfg(test)] | #[cfg(test)] | ||||||
| @@ -324,4 +324,13 @@ 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); | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -10,7 +10,6 @@ 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>, | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -23,11 +22,7 @@ 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) | ||||||
| @@ -35,18 +30,12 @@ impl ChannelData { | |||||||
|         { |         { | ||||||
|             Ok(c) |             Ok(c) | ||||||
|         } else { |         } else { | ||||||
|             let props = |             let props = channel.to_owned().guild().map(|g| (g.guild_id.as_u64().to_owned(), g.name)); | ||||||
|                 channel.to_owned().guild().map(|g| (g.guild_id.as_u64().to_owned(), g.name)); |  | ||||||
|  |  | ||||||
|             let (guild_id, channel_name) = |             let (guild_id, channel_name) = if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) }; | ||||||
|                 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 | ||||||
| @@ -57,10 +46,7 @@ impl ChannelData { | |||||||
|             Ok(sqlx::query_as_unchecked!( |             Ok(sqlx::query_as_unchecked!( | ||||||
|                 Self, |                 Self, | ||||||
|                 " |                 " | ||||||
|                 SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, | SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until FROM channels WHERE channel = ? | ||||||
|                     paused_until, guild_id AS db_guild_id |  | ||||||
|                 FROM channels |  | ||||||
|                 WHERE channel = ? |  | ||||||
|                 ", |                 ", | ||||||
|                 channel_id |                 channel_id | ||||||
|             ) |             ) | ||||||
| @@ -72,10 +58,9 @@ impl ChannelData { | |||||||
|     pub async fn commit_changes(&self, pool: &MySqlPool) { |     pub async fn commit_changes(&self, pool: &MySqlPool) { | ||||||
|         sqlx::query!( |         sqlx::query!( | ||||||
|             " |             " | ||||||
|             UPDATE channels | UPDATE channels SET name = ?, nudge = ?, blacklisted = ?, webhook_id = ?, webhook_token = ?, paused = ?, paused_until \ | ||||||
|             SET name = ?, nudge = ?, blacklisted = ?, webhook_id = ?, webhook_token = ?, |              = ? WHERE id = ? | ||||||
|                 paused = ?, paused_until = ? |             ", | ||||||
|             WHERE id = ?", |  | ||||||
|             self.name, |             self.name, | ||||||
|             self.nudge, |             self.nudge, | ||||||
|             self.blacklisted, |             self.blacklisted, | ||||||
|   | |||||||
| @@ -51,7 +51,6 @@ 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, | ||||||
| @@ -87,7 +86,6 @@ 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`, | ||||||
| @@ -112,13 +110,11 @@ 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, | ||||||
| @@ -251,10 +247,10 @@ impl<'a> MultiReminderBuilder<'a> { | |||||||
|                                 { |                                 { | ||||||
|                                     Err(ReminderError::UserBlockedDm) |                                     Err(ReminderError::UserBlockedDm) | ||||||
|                                 } else { |                                 } else { | ||||||
|                                     Ok((user_data.dm_channel, None)) |                                     Ok(user_data.dm_channel) | ||||||
|                                 } |                                 } | ||||||
|                             } else { |                             } else { | ||||||
|                                 Ok((user_data.dm_channel, None)) |                                 Ok(user_data.dm_channel) | ||||||
|                             } |                             } | ||||||
|                         } else { |                         } else { | ||||||
|                             Err(ReminderError::InvalidTag) |                             Err(ReminderError::InvalidTag) | ||||||
| @@ -301,13 +297,13 @@ impl<'a> MultiReminderBuilder<'a> { | |||||||
|                                                 .commit_changes(&self.ctx.data().database) |                                                 .commit_changes(&self.ctx.data().database) | ||||||
|                                                 .await; |                                                 .await; | ||||||
|  |  | ||||||
|                                             Ok((channel_data.id, channel_data.db_guild_id)) |                                             Ok(channel_data.id) | ||||||
|                                         } |                                         } | ||||||
|  |  | ||||||
|                                         Err(e) => Err(ReminderError::DiscordError(e.to_string())), |                                         Err(e) => Err(ReminderError::DiscordError(e.to_string())), | ||||||
|                                     } |                                     } | ||||||
|                                 } else { |                                 } else { | ||||||
|                                     Ok((channel_data.id, channel_data.db_guild_id)) |                                     Ok(channel_data.id) | ||||||
|                                 } |                                 } | ||||||
|                             } |                             } | ||||||
|                         } else { |                         } else { | ||||||
| @@ -321,8 +317,7 @@ 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.0, |                             channel: c, | ||||||
|                             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(), | ||||||
|   | |||||||
| @@ -304,13 +304,10 @@ WHERE | |||||||
|         &self, |         &self, | ||||||
|         db: impl Executor<'_, Database = Database>, |         db: impl Executor<'_, Database = Database>, | ||||||
|     ) -> Result<(), sqlx::Error> { |     ) -> Result<(), sqlx::Error> { | ||||||
|         sqlx::query!( |         sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", self.uid) | ||||||
|             "UPDATE reminders SET `status` = 'deleted', `status_change_time` = NOW() WHERE uid = ?", |             .execute(db) | ||||||
|             self.uid |             .await | ||||||
|         ) |             .map(|_| ()) | ||||||
|         .execute(db) |  | ||||||
|         .await |  | ||||||
|         .map(|_| ()) |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub fn display_content(&self) -> &str { |     pub fn display_content(&self) -> &str { | ||||||
|   | |||||||
| @@ -1,21 +1,22 @@ | |||||||
| [package] | [package] | ||||||
| name = "reminder_web" | name = "reminder_web" | ||||||
| version = "0.1.0" | version = "0.1.4" | ||||||
| authors = ["jellywx <judesouthworth@pm.me>"] | authors = ["jellywx <judesouthworth@pm.me>"] | ||||||
| edition = "2018" | edition = "2018" | ||||||
|  |  | ||||||
| [dependencies] | [dependencies] | ||||||
| rocket = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tls", "secrets", "json"] } | rocket = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tls", "secrets", "json"] } | ||||||
| rocket_dyn_templates = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tera"] } | rocket_dyn_templates = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tera"] } | ||||||
| serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } | serenity = { version = "0.11", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } | ||||||
| oauth2 = "4" | oauth2 = "4" | ||||||
| log = "0.4" | log = "0.4" | ||||||
| reqwest = "0.11" | reqwest = "0.11" | ||||||
| serde = { version = "1.0", features = ["derive"] } | serde = { version = "1.0", features = ["derive"] } | ||||||
| sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] } | sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] } | ||||||
| chrono = "0.4" | chrono = "0.4" | ||||||
| chrono-tz = "0.5" | chrono-tz = "0.8" | ||||||
| lazy_static = "1.4.0" | lazy_static = "1.4.0" | ||||||
| rand = "0.7" | rand = "0.8" | ||||||
| base64 = "0.13" | base64 = "0.13" | ||||||
| csv = "1.1" | csv = "1.2" | ||||||
|  | prometheus = "0.13.3" | ||||||
|   | |||||||
							
								
								
									
										40
									
								
								web/src/catchers.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								web/src/catchers.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | |||||||
|  | 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."]}) | ||||||
|  | } | ||||||
							
								
								
									
										1
									
								
								web/src/guards/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								web/src/guards/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | pub(crate) mod transaction; | ||||||
							
								
								
									
										42
									
								
								web/src/guards/transaction.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								web/src/guards/transaction.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | |||||||
|  | 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::Error((Status::InternalServerError, TransactionError::Error(e))), | ||||||
|  |             }, | ||||||
|  |             Outcome::Error(e) => Outcome::Error((e.0, TransactionError::Missing)), | ||||||
|  |             Outcome::Forward(f) => Outcome::Forward(f), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										158
									
								
								web/src/lib.rs
									
									
									
									
									
								
							
							
						
						
									
										158
									
								
								web/src/lib.rs
									
									
									
									
									
								
							| @@ -4,13 +4,17 @@ extern crate rocket; | |||||||
| mod consts; | mod consts; | ||||||
| #[macro_use] | #[macro_use] | ||||||
| mod macros; | mod macros; | ||||||
|  | mod catchers; | ||||||
|  | mod guards; | ||||||
|  | mod metrics; | ||||||
| mod routes; | mod routes; | ||||||
|  |  | ||||||
| use std::{collections::HashMap, env, path::Path}; | use std::{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, | ||||||
| }; | }; | ||||||
| @@ -22,7 +26,10 @@ use serenity::{ | |||||||
| }; | }; | ||||||
| use sqlx::{MySql, Pool}; | use sqlx::{MySql, Pool}; | ||||||
|  |  | ||||||
| use crate::consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES}; | use crate::{ | ||||||
|  |     consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES}, | ||||||
|  |     metrics::{init_metrics, MetricProducer}, | ||||||
|  | }; | ||||||
|  |  | ||||||
| type Database = MySql; | type Database = MySql; | ||||||
|  |  | ||||||
| @@ -32,40 +39,6 @@ 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, | ||||||
| @@ -95,17 +68,20 @@ pub async fn initialize( | |||||||
|     let static_path = |     let static_path = | ||||||
|         if Path::new("web/static").exists() { "web/static" } else { "/lib/reminder-rs/static" }; |         if Path::new("web/static").exists() { "web/static" } else { "/lib/reminder-rs/static" }; | ||||||
|  |  | ||||||
|  |     init_metrics(); | ||||||
|  |  | ||||||
|     rocket::build() |     rocket::build() | ||||||
|  |         .attach(MetricProducer) | ||||||
|         .attach(Template::fairing()) |         .attach(Template::fairing()) | ||||||
|         .register( |         .register( | ||||||
|             "/", |             "/", | ||||||
|             catchers![ |             catchers![ | ||||||
|                 not_authorized, |                 catchers::not_authorized, | ||||||
|                 forbidden, |                 catchers::forbidden, | ||||||
|                 not_found, |                 catchers::not_found, | ||||||
|                 internal_server_error, |                 catchers::internal_server_error, | ||||||
|                 unprocessable_entity, |                 catchers::unprocessable_entity, | ||||||
|                 payload_too_large, |                 catchers::payload_too_large, | ||||||
|             ], |             ], | ||||||
|         ) |         ) | ||||||
|         .manage(oauth2_client) |         .manage(oauth2_client) | ||||||
| @@ -116,12 +92,13 @@ pub async fn initialize( | |||||||
|         .mount( |         .mount( | ||||||
|             "/", |             "/", | ||||||
|             routes![ |             routes![ | ||||||
|                 routes::index, |  | ||||||
|                 routes::cookies, |                 routes::cookies, | ||||||
|  |                 routes::index, | ||||||
|  |                 routes::metrics::metrics, | ||||||
|                 routes::privacy, |                 routes::privacy, | ||||||
|                 routes::terms, |  | ||||||
|                 routes::return_to_same_site, |  | ||||||
|                 routes::report::report_error, |                 routes::report::report_error, | ||||||
|  |                 routes::return_to_same_site, | ||||||
|  |                 routes::terms, | ||||||
|             ], |             ], | ||||||
|         ) |         ) | ||||||
|         .mount( |         .mount( | ||||||
| @@ -150,22 +127,21 @@ pub async fn initialize( | |||||||
|         .mount( |         .mount( | ||||||
|             "/dashboard", |             "/dashboard", | ||||||
|             routes![ |             routes![ | ||||||
|                 routes::dashboard::dashboard_1, |                 routes::dashboard::dashboard, | ||||||
|                 routes::dashboard::dashboard_2, |  | ||||||
|                 routes::dashboard::dashboard_home, |                 routes::dashboard::dashboard_home, | ||||||
|                 routes::dashboard::user::get_user_info, |                 routes::dashboard::api::user::get_user_info, | ||||||
|                 routes::dashboard::user::update_user_info, |                 routes::dashboard::api::user::update_user_info, | ||||||
|                 routes::dashboard::user::get_user_guilds, |                 routes::dashboard::api::user::get_user_guilds, | ||||||
|                 routes::dashboard::guild::get_guild_patreon, |                 routes::dashboard::api::guild::get_guild_info, | ||||||
|                 routes::dashboard::guild::get_guild_channels, |                 routes::dashboard::api::guild::get_guild_channels, | ||||||
|                 routes::dashboard::guild::get_guild_roles, |                 routes::dashboard::api::guild::get_guild_roles, | ||||||
|                 routes::dashboard::guild::get_reminder_templates, |                 routes::dashboard::api::guild::get_reminder_templates, | ||||||
|                 routes::dashboard::guild::create_reminder_template, |                 routes::dashboard::api::guild::create_reminder_template, | ||||||
|                 routes::dashboard::guild::delete_reminder_template, |                 routes::dashboard::api::guild::delete_reminder_template, | ||||||
|                 routes::dashboard::guild::create_guild_reminder, |                 routes::dashboard::api::guild::create_guild_reminder, | ||||||
|                 routes::dashboard::guild::get_reminders, |                 routes::dashboard::api::guild::get_reminders, | ||||||
|                 routes::dashboard::guild::edit_reminder, |                 routes::dashboard::api::guild::edit_reminder, | ||||||
|                 routes::dashboard::guild::delete_reminder, |                 routes::dashboard::api::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, | ||||||
| @@ -223,3 +199,65 @@ 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(()) | ||||||
|  | } | ||||||
|   | |||||||
| @@ -54,56 +54,6 @@ 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 { | ||||||
|   | |||||||
							
								
								
									
										43
									
								
								web/src/metrics.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								web/src/metrics.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | |||||||
|  | use lazy_static::lazy_static; | ||||||
|  | use prometheus::{IntCounterVec, Opts, Registry}; | ||||||
|  | use rocket::{ | ||||||
|  |     fairing::{Fairing, Info, Kind}, | ||||||
|  |     Data, Request, Response, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | lazy_static! { | ||||||
|  |     pub static ref REGISTRY: Registry = Registry::new(); | ||||||
|  |     static ref REQUEST_COUNTER: IntCounterVec = | ||||||
|  |         IntCounterVec::new(Opts::new("requests", "Requests"), &["method", "route"]).unwrap(); | ||||||
|  |     static ref RESPONSE_COUNTER: IntCounterVec = | ||||||
|  |         IntCounterVec::new(Opts::new("responses", "Responses"), &["status", "route"]).unwrap(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub fn init_metrics() { | ||||||
|  |     REGISTRY.register(Box::new(REQUEST_COUNTER.clone())).unwrap(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub struct MetricProducer; | ||||||
|  |  | ||||||
|  | #[rocket::async_trait] | ||||||
|  | impl Fairing for MetricProducer { | ||||||
|  |     fn info(&self) -> Info { | ||||||
|  |         Info { name: "Metrics fairing", kind: Kind::Request } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async fn on_request(&self, req: &mut Request<'_>, _data: &mut Data<'_>) { | ||||||
|  |         if let Some(route) = req.route() { | ||||||
|  |             REQUEST_COUNTER | ||||||
|  |                 .with_label_values(&[req.method().as_str(), &route.uri.to_string()]) | ||||||
|  |                 .inc(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async fn on_response<'r>(&self, req: &'r Request<'_>, resp: &mut Response<'r>) { | ||||||
|  |         if let Some(route) = req.route() { | ||||||
|  |             RESPONSE_COUNTER | ||||||
|  |                 .with_label_values(&[&resp.status().code.to_string(), &route.uri.to_string()]) | ||||||
|  |                 .inc(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										61
									
								
								web/src/routes/dashboard/api/guild/channels.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								web/src/routes/dashboard/api/guild/channels.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | |||||||
|  | use rocket::{http::CookieJar, serde::json::json, State}; | ||||||
|  | use serde::Serialize; | ||||||
|  | use serenity::{ | ||||||
|  |     client::Context, | ||||||
|  |     model::{ | ||||||
|  |         channel::GuildChannel, | ||||||
|  |         id::{ChannelId, GuildId}, | ||||||
|  |     }, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | use crate::{check_authorization, routes::JsonResult}; | ||||||
|  |  | ||||||
|  | #[derive(Serialize)] | ||||||
|  | struct ChannelInfo { | ||||||
|  |     id: String, | ||||||
|  |     name: String, | ||||||
|  |     webhook_avatar: Option<String>, | ||||||
|  |     webhook_name: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[get("/api/guild/<id>/channels")] | ||||||
|  | pub async fn get_guild_channels( | ||||||
|  |     id: u64, | ||||||
|  |     cookies: &CookieJar<'_>, | ||||||
|  |     ctx: &State<Context>, | ||||||
|  | ) -> JsonResult { | ||||||
|  |     offline!(Ok(json!(vec![ChannelInfo { | ||||||
|  |         name: "general".to_string(), | ||||||
|  |         id: "1".to_string(), | ||||||
|  |         webhook_avatar: None, | ||||||
|  |         webhook_name: None, | ||||||
|  |     }]))); | ||||||
|  |     check_authorization(cookies, ctx.inner(), id).await?; | ||||||
|  |  | ||||||
|  |     match GuildId(id).to_guild_cached(ctx.inner()) { | ||||||
|  |         Some(guild) => { | ||||||
|  |             let mut channels = guild | ||||||
|  |                 .channels | ||||||
|  |                 .iter() | ||||||
|  |                 .filter_map(|(id, channel)| channel.to_owned().guild().map(|c| (id.to_owned(), c))) | ||||||
|  |                 .filter(|(_, channel)| channel.is_text_based()) | ||||||
|  |                 .collect::<Vec<(ChannelId, GuildChannel)>>(); | ||||||
|  |  | ||||||
|  |             channels.sort_by(|(_, c1), (_, c2)| c1.position.cmp(&c2.position)); | ||||||
|  |  | ||||||
|  |             let channel_info = channels | ||||||
|  |                 .iter() | ||||||
|  |                 .map(|(channel_id, channel)| ChannelInfo { | ||||||
|  |                     name: channel.name.to_string(), | ||||||
|  |                     id: channel_id.to_string(), | ||||||
|  |                     webhook_avatar: None, | ||||||
|  |                     webhook_name: None, | ||||||
|  |                 }) | ||||||
|  |                 .collect::<Vec<ChannelInfo>>(); | ||||||
|  |  | ||||||
|  |             Ok(json!(channel_info)) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         None => json_err!("Bot not in guild"), | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										42
									
								
								web/src/routes/dashboard/api/guild/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								web/src/routes/dashboard/api/guild/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | |||||||
|  | mod channels; | ||||||
|  | mod reminders; | ||||||
|  | mod roles; | ||||||
|  | mod templates; | ||||||
|  |  | ||||||
|  | use std::env; | ||||||
|  |  | ||||||
|  | pub use channels::*; | ||||||
|  | pub use reminders::*; | ||||||
|  | use rocket::{http::CookieJar, serde::json::json, State}; | ||||||
|  | pub use roles::*; | ||||||
|  | use serenity::{ | ||||||
|  |     client::Context, | ||||||
|  |     model::id::{GuildId, RoleId}, | ||||||
|  | }; | ||||||
|  | pub use templates::*; | ||||||
|  |  | ||||||
|  | use crate::{check_authorization, routes::JsonResult}; | ||||||
|  |  | ||||||
|  | #[get("/api/guild/<id>")] | ||||||
|  | pub async fn get_guild_info(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult { | ||||||
|  |     offline!(Ok(json!({ "patreon": true, "name": "Guild" }))); | ||||||
|  |     check_authorization(cookies, ctx.inner(), id).await?; | ||||||
|  |  | ||||||
|  |     match GuildId(id).to_guild_cached(ctx.inner()) { | ||||||
|  |         Some(guild) => { | ||||||
|  |             let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap()) | ||||||
|  |                 .member(&ctx.inner(), guild.owner_id) | ||||||
|  |                 .await; | ||||||
|  |  | ||||||
|  |             let patreon = member_res.map_or(false, |member| { | ||||||
|  |                 member | ||||||
|  |                     .roles | ||||||
|  |                     .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap())) | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             Ok(json!({ "patreon": patreon, "name": guild.name })) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         None => json_err!("Bot not in guild"), | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										388
									
								
								web/src/routes/dashboard/api/guild/reminders.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										388
									
								
								web/src/routes/dashboard/api/guild/reminders.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,388 @@ | |||||||
|  | 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 | ||||||
|  |                 ]); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } else { | ||||||
|  |         sqlx::query!( | ||||||
|  |             " | ||||||
|  |             UPDATE reminders | ||||||
|  |             SET interval_seconds = NULL, interval_days = NULL, interval_months = NULL | ||||||
|  |             WHERE uid = ? | ||||||
|  |             ", | ||||||
|  |             reminder.uid | ||||||
|  |         ) | ||||||
|  |         .execute(transaction.executor()) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| { | ||||||
|  |             warn!("Error updating reminder interval: {:?}", e); | ||||||
|  |             json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) | ||||||
|  |         })?; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     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"})) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										35
									
								
								web/src/routes/dashboard/api/guild/roles.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								web/src/routes/dashboard/api/guild/roles.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | |||||||
|  | use rocket::{http::CookieJar, serde::json::json, State}; | ||||||
|  | use serde::Serialize; | ||||||
|  | use serenity::client::Context; | ||||||
|  |  | ||||||
|  | use crate::{check_authorization, routes::JsonResult}; | ||||||
|  |  | ||||||
|  | #[derive(Serialize)] | ||||||
|  | struct RoleInfo { | ||||||
|  |     id: String, | ||||||
|  |     name: String, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[get("/api/guild/<id>/roles")] | ||||||
|  | pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult { | ||||||
|  |     offline!(Ok(json!(vec![RoleInfo { name: "@everyone".to_string(), id: "1".to_string() }]))); | ||||||
|  |     check_authorization(cookies, ctx.inner(), id).await?; | ||||||
|  |  | ||||||
|  |     let roles_res = ctx.cache.guild_roles(id); | ||||||
|  |  | ||||||
|  |     match roles_res { | ||||||
|  |         Some(roles) => { | ||||||
|  |             let roles = roles | ||||||
|  |                 .iter() | ||||||
|  |                 .map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() }) | ||||||
|  |                 .collect::<Vec<RoleInfo>>(); | ||||||
|  |  | ||||||
|  |             Ok(json!(roles)) | ||||||
|  |         } | ||||||
|  |         None => { | ||||||
|  |             warn!("Could not fetch roles from {}", id); | ||||||
|  |  | ||||||
|  |             json_err!("Could not get roles") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										181
									
								
								web/src/routes/dashboard/api/guild/templates.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										181
									
								
								web/src/routes/dashboard/api/guild/templates.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,181 @@ | |||||||
|  | use rocket::{ | ||||||
|  |     http::CookieJar, | ||||||
|  |     serde::json::{json, Json}, | ||||||
|  |     State, | ||||||
|  | }; | ||||||
|  | use serenity::client::Context; | ||||||
|  | use sqlx::{MySql, Pool}; | ||||||
|  |  | ||||||
|  | use crate::{ | ||||||
|  |     check_authorization, | ||||||
|  |     consts::{ | ||||||
|  |         MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH, | ||||||
|  |         MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH, | ||||||
|  |         MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, | ||||||
|  |     }, | ||||||
|  |     routes::{ | ||||||
|  |         dashboard::{template_name_default, DeleteReminderTemplate, ReminderTemplate}, | ||||||
|  |         JsonResult, | ||||||
|  |     }, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | #[get("/api/guild/<id>/templates")] | ||||||
|  | pub async fn get_reminder_templates( | ||||||
|  |     id: u64, | ||||||
|  |     cookies: &CookieJar<'_>, | ||||||
|  |     ctx: &State<Context>, | ||||||
|  |     pool: &State<Pool<MySql>>, | ||||||
|  | ) -> JsonResult { | ||||||
|  |     check_authorization(cookies, ctx.inner(), id).await?; | ||||||
|  |  | ||||||
|  |     match sqlx::query_as_unchecked!( | ||||||
|  |         ReminderTemplate, | ||||||
|  |         "SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", | ||||||
|  |         id | ||||||
|  |     ) | ||||||
|  |     .fetch_all(pool.inner()) | ||||||
|  |     .await | ||||||
|  |     { | ||||||
|  |         Ok(templates) => Ok(json!(templates)), | ||||||
|  |         Err(e) => { | ||||||
|  |             warn!("Could not fetch templates from {}: {:?}", id, e); | ||||||
|  |  | ||||||
|  |             json_err!("Could not get templates") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[post("/api/guild/<id>/templates", data = "<reminder_template>")] | ||||||
|  | pub async fn create_reminder_template( | ||||||
|  |     id: u64, | ||||||
|  |     reminder_template: Json<ReminderTemplate>, | ||||||
|  |     cookies: &CookieJar<'_>, | ||||||
|  |     ctx: &State<Context>, | ||||||
|  |     pool: &State<Pool<MySql>>, | ||||||
|  | ) -> JsonResult { | ||||||
|  |     check_authorization(cookies, ctx.inner(), id).await?; | ||||||
|  |  | ||||||
|  |     // validate lengths | ||||||
|  |     check_length!(MAX_CONTENT_LENGTH, reminder_template.content); | ||||||
|  |     check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder_template.embed_description); | ||||||
|  |     check_length!(MAX_EMBED_TITLE_LENGTH, reminder_template.embed_title); | ||||||
|  |     check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder_template.embed_author); | ||||||
|  |     check_length!(MAX_EMBED_FOOTER_LENGTH, reminder_template.embed_footer); | ||||||
|  |     check_length_opt!(MAX_EMBED_FIELDS, reminder_template.embed_fields); | ||||||
|  |     if let Some(fields) = &reminder_template.embed_fields { | ||||||
|  |         for field in &fields.0 { | ||||||
|  |             check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value); | ||||||
|  |             check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     check_length_opt!(MAX_USERNAME_LENGTH, reminder_template.username); | ||||||
|  |     check_length_opt!( | ||||||
|  |         MAX_URL_LENGTH, | ||||||
|  |         reminder_template.embed_footer_url, | ||||||
|  |         reminder_template.embed_thumbnail_url, | ||||||
|  |         reminder_template.embed_author_url, | ||||||
|  |         reminder_template.embed_image_url, | ||||||
|  |         reminder_template.avatar | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     // validate urls | ||||||
|  |     check_url_opt!( | ||||||
|  |         reminder_template.embed_footer_url, | ||||||
|  |         reminder_template.embed_thumbnail_url, | ||||||
|  |         reminder_template.embed_author_url, | ||||||
|  |         reminder_template.embed_image_url, | ||||||
|  |         reminder_template.avatar | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     let name = if reminder_template.name.is_empty() { | ||||||
|  |         template_name_default() | ||||||
|  |     } else { | ||||||
|  |         reminder_template.name.clone() | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     match sqlx::query!( | ||||||
|  |         "INSERT INTO reminder_template | ||||||
|  |         (guild_id, | ||||||
|  |          name, | ||||||
|  |          attachment, | ||||||
|  |          attachment_name, | ||||||
|  |          avatar, | ||||||
|  |          content, | ||||||
|  |          embed_author, | ||||||
|  |          embed_author_url, | ||||||
|  |          embed_color, | ||||||
|  |          embed_description, | ||||||
|  |          embed_footer, | ||||||
|  |          embed_footer_url, | ||||||
|  |          embed_image_url, | ||||||
|  |          embed_thumbnail_url, | ||||||
|  |          embed_title, | ||||||
|  |          embed_fields, | ||||||
|  |          interval_seconds, | ||||||
|  |          interval_days, | ||||||
|  |          interval_months, | ||||||
|  |          tts, | ||||||
|  |          username | ||||||
|  |         ) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, | ||||||
|  |          ?, ?, ?, ?, ?, ?, ?)", | ||||||
|  |         id, | ||||||
|  |         name, | ||||||
|  |         reminder_template.attachment, | ||||||
|  |         reminder_template.attachment_name, | ||||||
|  |         reminder_template.avatar, | ||||||
|  |         reminder_template.content, | ||||||
|  |         reminder_template.embed_author, | ||||||
|  |         reminder_template.embed_author_url, | ||||||
|  |         reminder_template.embed_color, | ||||||
|  |         reminder_template.embed_description, | ||||||
|  |         reminder_template.embed_footer, | ||||||
|  |         reminder_template.embed_footer_url, | ||||||
|  |         reminder_template.embed_image_url, | ||||||
|  |         reminder_template.embed_thumbnail_url, | ||||||
|  |         reminder_template.embed_title, | ||||||
|  |         reminder_template.embed_fields, | ||||||
|  |         reminder_template.interval_seconds, | ||||||
|  |         reminder_template.interval_days, | ||||||
|  |         reminder_template.interval_months, | ||||||
|  |         reminder_template.tts, | ||||||
|  |         reminder_template.username, | ||||||
|  |     ) | ||||||
|  |     .fetch_all(pool.inner()) | ||||||
|  |     .await | ||||||
|  |     { | ||||||
|  |         Ok(_) => Ok(json!({})), | ||||||
|  |         Err(e) => { | ||||||
|  |             warn!("Could not create template for {}: {:?}", id, e); | ||||||
|  |  | ||||||
|  |             json_err!("Could not create template") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[delete("/api/guild/<id>/templates", data = "<delete_reminder_template>")] | ||||||
|  | pub async fn delete_reminder_template( | ||||||
|  |     id: u64, | ||||||
|  |     delete_reminder_template: Json<DeleteReminderTemplate>, | ||||||
|  |     cookies: &CookieJar<'_>, | ||||||
|  |     ctx: &State<Context>, | ||||||
|  |     pool: &State<Pool<MySql>>, | ||||||
|  | ) -> JsonResult { | ||||||
|  |     check_authorization(cookies, ctx.inner(), id).await?; | ||||||
|  |  | ||||||
|  |     match sqlx::query!( | ||||||
|  |         "DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?", | ||||||
|  |         id, delete_reminder_template.id | ||||||
|  |     ) | ||||||
|  |     .fetch_all(pool.inner()) | ||||||
|  |     .await | ||||||
|  |     { | ||||||
|  |         Ok(_) => { | ||||||
|  |             Ok(json!({})) | ||||||
|  |         } | ||||||
|  |         Err(e) => { | ||||||
|  |             warn!("Could not delete template from {}: {:?}", id, e); | ||||||
|  |  | ||||||
|  |             json_err!("Could not delete template") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										2
									
								
								web/src/routes/dashboard/api/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								web/src/routes/dashboard/api/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | |||||||
|  | pub mod guild; | ||||||
|  | pub mod user; | ||||||
							
								
								
									
										81
									
								
								web/src/routes/dashboard/api/user/guilds.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								web/src/routes/dashboard/api/user/guilds.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | |||||||
|  | use reqwest::Client; | ||||||
|  | use rocket::{ | ||||||
|  |     http::CookieJar, | ||||||
|  |     serde::json::{json, Value as JsonValue}, | ||||||
|  |     State, | ||||||
|  | }; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  | use serenity::model::{id::GuildId, permissions::Permissions}; | ||||||
|  |  | ||||||
|  | use crate::consts::DISCORD_API; | ||||||
|  |  | ||||||
|  | #[derive(Serialize)] | ||||||
|  | struct GuildInfo { | ||||||
|  |     id: String, | ||||||
|  |     name: String, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Deserialize)] | ||||||
|  | struct PartialGuild { | ||||||
|  |     pub id: GuildId, | ||||||
|  |     pub name: String, | ||||||
|  |     #[serde(default)] | ||||||
|  |     pub owner: bool, | ||||||
|  |     #[serde(rename = "permissions_new")] | ||||||
|  |     pub permissions: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[get("/api/user/guilds")] | ||||||
|  | pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue { | ||||||
|  |     offline!(json!(vec![GuildInfo { id: "1".to_string(), name: "Guild".to_string() }])); | ||||||
|  |  | ||||||
|  |     if let Some(access_token) = cookies.get_private("access_token") { | ||||||
|  |         let request_res = reqwest_client | ||||||
|  |             .get(format!("{}/users/@me/guilds", DISCORD_API)) | ||||||
|  |             .bearer_auth(access_token.value()) | ||||||
|  |             .send() | ||||||
|  |             .await; | ||||||
|  |  | ||||||
|  |         match request_res { | ||||||
|  |             Ok(response) => { | ||||||
|  |                 let guilds_res = response.json::<Vec<PartialGuild>>().await; | ||||||
|  |  | ||||||
|  |                 match guilds_res { | ||||||
|  |                     Ok(guilds) => { | ||||||
|  |                         let reduced_guilds = guilds | ||||||
|  |                             .iter() | ||||||
|  |                             .filter(|g| { | ||||||
|  |                                 g.owner | ||||||
|  |                                     || g.permissions.as_ref().map_or(false, |p| { | ||||||
|  |                                         let permissions = | ||||||
|  |                                             Permissions::from_bits_truncate(p.parse().unwrap()); | ||||||
|  |  | ||||||
|  |                                         permissions.manage_messages() | ||||||
|  |                                             || permissions.manage_guild() | ||||||
|  |                                             || permissions.administrator() | ||||||
|  |                                     }) | ||||||
|  |                             }) | ||||||
|  |                             .map(|g| GuildInfo { id: g.id.to_string(), name: g.name.to_string() }) | ||||||
|  |                             .collect::<Vec<GuildInfo>>(); | ||||||
|  |  | ||||||
|  |                         json!(reduced_guilds) | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     Err(e) => { | ||||||
|  |                         warn!("Error constructing user from request: {:?}", e); | ||||||
|  |  | ||||||
|  |                         json!({"error": "Could not get user details"}) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             Err(e) => { | ||||||
|  |                 warn!("Error getting user guilds: {:?}", e); | ||||||
|  |  | ||||||
|  |                 json!({"error": "Could not reach Discord"}) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } else { | ||||||
|  |         json!({"error": "Not authorized"}) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										97
									
								
								web/src/routes/dashboard/api/user/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								web/src/routes/dashboard/api/user/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | |||||||
|  | mod guilds; | ||||||
|  |  | ||||||
|  | use std::env; | ||||||
|  |  | ||||||
|  | use chrono_tz::Tz; | ||||||
|  | pub use guilds::*; | ||||||
|  | use rocket::{ | ||||||
|  |     http::CookieJar, | ||||||
|  |     serde::json::{json, Json, Value as JsonValue}, | ||||||
|  |     State, | ||||||
|  | }; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  | use serenity::{ | ||||||
|  |     client::Context, | ||||||
|  |     model::id::{GuildId, RoleId}, | ||||||
|  | }; | ||||||
|  | use sqlx::{MySql, Pool}; | ||||||
|  |  | ||||||
|  | #[derive(Serialize)] | ||||||
|  | struct UserInfo { | ||||||
|  |     name: String, | ||||||
|  |     patreon: bool, | ||||||
|  |     timezone: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Deserialize)] | ||||||
|  | pub struct UpdateUser { | ||||||
|  |     timezone: String, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[get("/api/user")] | ||||||
|  | pub async fn get_user_info( | ||||||
|  |     cookies: &CookieJar<'_>, | ||||||
|  |     ctx: &State<Context>, | ||||||
|  |     pool: &State<Pool<MySql>>, | ||||||
|  | ) -> JsonValue { | ||||||
|  |     offline!(json!(UserInfo { name: "Discord".to_string(), patreon: true, timezone: None })); | ||||||
|  |  | ||||||
|  |     if let Some(user_id) = | ||||||
|  |         cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten() | ||||||
|  |     { | ||||||
|  |         let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap()) | ||||||
|  |             .member(&ctx.inner(), user_id) | ||||||
|  |             .await; | ||||||
|  |  | ||||||
|  |         let timezone = sqlx::query!( | ||||||
|  |             "SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?", | ||||||
|  |             user_id | ||||||
|  |         ) | ||||||
|  |         .fetch_one(pool.inner()) | ||||||
|  |         .await | ||||||
|  |         .map_or(None, |q| Some(q.timezone)); | ||||||
|  |  | ||||||
|  |         let user_info = UserInfo { | ||||||
|  |             name: cookies | ||||||
|  |                 .get_private("username") | ||||||
|  |                 .map_or("DiscordUser#0000".to_string(), |c| c.value().to_string()), | ||||||
|  |             patreon: member_res.map_or(false, |member| { | ||||||
|  |                 member | ||||||
|  |                     .roles | ||||||
|  |                     .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap())) | ||||||
|  |             }), | ||||||
|  |             timezone, | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         json!(user_info) | ||||||
|  |     } else { | ||||||
|  |         json!({"error": "Not authorized"}) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[patch("/api/user", data = "<user>")] | ||||||
|  | pub async fn update_user_info( | ||||||
|  |     cookies: &CookieJar<'_>, | ||||||
|  |     user: Json<UpdateUser>, | ||||||
|  |     pool: &State<Pool<MySql>>, | ||||||
|  | ) -> JsonValue { | ||||||
|  |     if let Some(user_id) = | ||||||
|  |         cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten() | ||||||
|  |     { | ||||||
|  |         if user.timezone.parse::<Tz>().is_ok() { | ||||||
|  |             let _ = sqlx::query!( | ||||||
|  |                 "UPDATE users SET timezone = ? WHERE user = ?", | ||||||
|  |                 user.timezone, | ||||||
|  |                 user_id, | ||||||
|  |             ) | ||||||
|  |             .execute(pool.inner()) | ||||||
|  |             .await; | ||||||
|  |  | ||||||
|  |             json!({}) | ||||||
|  |         } else { | ||||||
|  |             json!({"error": "Timezone not recognized"}) | ||||||
|  |         } | ||||||
|  |     } else { | ||||||
|  |         json!({"error": "Not authorized"}) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										20
									
								
								web/src/routes/dashboard/api/user/models.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								web/src/routes/dashboard/api/user/models.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | use std::env; | ||||||
|  |  | ||||||
|  | use chrono_tz::Tz; | ||||||
|  | use reqwest::Client; | ||||||
|  | use rocket::{ | ||||||
|  |     http::CookieJar, | ||||||
|  |     serde::json::{json, Json, Value as JsonValue}, | ||||||
|  |     State, | ||||||
|  | }; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  | use serenity::{ | ||||||
|  |     client::Context, | ||||||
|  |     model::{ | ||||||
|  |         id::{GuildId, RoleId}, | ||||||
|  |         permissions::Permissions, | ||||||
|  |     }, | ||||||
|  | }; | ||||||
|  | use sqlx::{MySql, Pool}; | ||||||
|  |  | ||||||
|  | use crate::{consts::DISCORD_API, routes::JsonResult}; | ||||||
							
								
								
									
										29
									
								
								web/src/routes/dashboard/api/user/reminders.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								web/src/routes/dashboard/api/user/reminders.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | |||||||
|  | use std::env; | ||||||
|  |  | ||||||
|  | use chrono_tz::Tz; | ||||||
|  | use reqwest::Client; | ||||||
|  | use rocket::{ | ||||||
|  |     http::CookieJar, | ||||||
|  |     serde::json::{json, Json, Value as JsonValue}, | ||||||
|  |     State, | ||||||
|  | }; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  | use serenity::{ | ||||||
|  |     client::Context, | ||||||
|  |     model::{ | ||||||
|  |         id::{GuildId, RoleId}, | ||||||
|  |         permissions::Permissions, | ||||||
|  |     }, | ||||||
|  | }; | ||||||
|  | use sqlx::{MySql, Pool}; | ||||||
|  |  | ||||||
|  | use crate::{consts::DISCORD_API, routes::JsonResult}; | ||||||
|  |  | ||||||
|  | #[get("/api/user/reminders")] | ||||||
|  | pub async fn get_reminders( | ||||||
|  |     cookies: &CookieJar<'_>, | ||||||
|  |     ctx: &State<Context>, | ||||||
|  |     pool: &State<Pool<MySql>>, | ||||||
|  | ) -> JsonResult { | ||||||
|  |     Ok(json! {}) | ||||||
|  | } | ||||||
| @@ -6,16 +6,20 @@ use rocket::{ | |||||||
| }; | }; | ||||||
| use serenity::{ | use serenity::{ | ||||||
|     client::Context, |     client::Context, | ||||||
|     model::id::{ChannelId, GuildId}, |     model::id::{ChannelId, GuildId, UserId}, | ||||||
| }; | }; | ||||||
| use sqlx::{MySql, Pool}; | use sqlx::{MySql, Pool}; | ||||||
|  |  | ||||||
| use crate::routes::{ | use crate::{ | ||||||
|     dashboard::{ |     check_authorization, | ||||||
|         create_reminder, generate_uid, ImportBody, Reminder, ReminderCsv, ReminderTemplateCsv, |     guards::transaction::Transaction, | ||||||
|         TodoCsv, |     routes::{ | ||||||
|  |         dashboard::{ | ||||||
|  |             create_reminder, generate_uid, ImportBody, Reminder, ReminderCsv, ReminderTemplateCsv, | ||||||
|  |             TodoCsv, | ||||||
|  |         }, | ||||||
|  |         JsonResult, | ||||||
|     }, |     }, | ||||||
|     JsonResult, |  | ||||||
| }; | }; | ||||||
|  |  | ||||||
| #[get("/api/guild/<id>/export/reminders")] | #[get("/api/guild/<id>/export/reminders")] | ||||||
| @@ -25,7 +29,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); |     check_authorization(cookies, ctx.inner(), id).await?; | ||||||
|  |  | ||||||
|     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![]); | ||||||
|  |  | ||||||
| @@ -70,7 +74,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, ?)", |                 WHERE FIND_IN_SET(channels.channel, ?) AND status = 'pending'", | ||||||
|                 channels |                 channels | ||||||
|             ) |             ) | ||||||
|             .fetch_all(pool.inner()) |             .fetch_all(pool.inner()) | ||||||
| @@ -118,14 +122,14 @@ pub async fn export_reminders( | |||||||
| } | } | ||||||
|  |  | ||||||
| #[put("/api/guild/<id>/export/reminders", data = "<body>")] | #[put("/api/guild/<id>/export/reminders", data = "<body>")] | ||||||
| pub async fn import_reminders( | pub(crate) async fn import_reminders( | ||||||
|     id: u64, |     id: u64, | ||||||
|     cookies: &CookieJar<'_>, |     cookies: &CookieJar<'_>, | ||||||
|     body: Json<ImportBody>, |     body: Json<ImportBody>, | ||||||
|     ctx: &State<Context>, |     ctx: &State<Context>, | ||||||
|     pool: &State<Pool<MySql>>, |     mut transaction: Transaction<'_>, | ||||||
| ) -> JsonResult { | ) -> JsonResult { | ||||||
|     check_authorization!(cookies, ctx.inner(), id); |     check_authorization(cookies, ctx.inner(), id).await?; | ||||||
|  |  | ||||||
|     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(); | ||||||
| @@ -133,6 +137,7 @@ pub 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 { | ||||||
| @@ -145,7 +150,7 @@ pub async fn import_reminders( | |||||||
|                                     attachment: record.attachment, |                                     attachment: record.attachment, | ||||||
|                                     attachment_name: record.attachment_name, |                                     attachment_name: record.attachment_name, | ||||||
|                                     avatar: record.avatar, |                                     avatar: record.avatar, | ||||||
|                                     channel: Some(channel_id), |                                     channel: channel_id, | ||||||
|                                     content: record.content, |                                     content: record.content, | ||||||
|                                     embed_author: record.embed_author, |                                     embed_author: record.embed_author, | ||||||
|                                     embed_author_url: record.embed_author_url, |                                     embed_author_url: record.embed_author_url, | ||||||
| @@ -171,18 +176,18 @@ pub async fn import_reminders( | |||||||
|                                     uid: generate_uid(), |                                     uid: generate_uid(), | ||||||
|                                     username: record.username, |                                     username: record.username, | ||||||
|                                     utc_time: record.utc_time, |                                     utc_time: record.utc_time, | ||||||
|                                     status: "pending".to_string(), |  | ||||||
|                                     status_change_time: None, |  | ||||||
|                                 }; |                                 }; | ||||||
|  |  | ||||||
|                                 create_reminder( |                                 create_reminder( | ||||||
|                                     ctx.inner(), |                                     ctx.inner(), | ||||||
|                                     pool.inner(), |                                     &mut transaction, | ||||||
|                                     GuildId(id), |                                     GuildId(id), | ||||||
|                                     UserId(user_id), |                                     UserId(user_id), | ||||||
|                                     reminder, |                                     reminder, | ||||||
|                                 ) |                                 ) | ||||||
|                                 .await?; |                                 .await?; | ||||||
|  |  | ||||||
|  |                                 count += 1; | ||||||
|                             } |                             } | ||||||
|  |  | ||||||
|                             Err(_) => { |                             Err(_) => { | ||||||
| @@ -202,7 +207,16 @@ pub async fn import_reminders( | |||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             Ok(json!({})) |             match transaction.commit().await { | ||||||
|  |                 Ok(_) => Ok(json!({ | ||||||
|  |                     "message": format!("Imported {} reminders", count) | ||||||
|  |                 })), | ||||||
|  |  | ||||||
|  |                 Err(e) => { | ||||||
|  |                     warn!("Failed to commit transaction: {:?}", e); | ||||||
|  |                     json_err!("Couldn't commit transaction") | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         Err(_) => { |         Err(_) => { | ||||||
| @@ -218,7 +232,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); |     check_authorization(cookies, ctx.inner(), id).await?; | ||||||
|  |  | ||||||
|     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![]); | ||||||
|  |  | ||||||
| @@ -273,7 +287,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); |     check_authorization(cookies, ctx.inner(), id).await?; | ||||||
|  |  | ||||||
|     let channels_res = GuildId(id).channels(&ctx.inner()).await; |     let channels_res = GuildId(id).channels(&ctx.inner()).await; | ||||||
|  |  | ||||||
| @@ -368,7 +382,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); |     check_authorization(cookies, ctx.inner(), id).await?; | ||||||
|  |  | ||||||
|     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![]); | ||||||
|  |  | ||||||
| @@ -390,6 +404,9 @@ 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 = ?)", | ||||||
|   | |||||||
| @@ -1,618 +0,0 @@ | |||||||
| 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, 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<Reminder>, |  | ||||||
|     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 |  | ||||||
|         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 |  | ||||||
|         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"})) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,16 +1,20 @@ | |||||||
| use std::collections::HashMap; | use std::path::Path; | ||||||
|  |  | ||||||
| use chrono::{naive::NaiveDateTime, Utc}; | use chrono::{naive::NaiveDateTime, Utc}; | ||||||
| use rand::{rngs::OsRng, seq::IteratorRandom}; | use rand::{rngs::OsRng, seq::IteratorRandom}; | ||||||
| use rocket::{http::CookieJar, response::Redirect, serde::json::json}; | use rocket::{ | ||||||
| use rocket_dyn_templates::Template; |     fs::{relative, NamedFile}, | ||||||
| use serde::{Deserialize, Deserializer, Serialize}; |     http::CookieJar, | ||||||
|  |     response::Redirect, | ||||||
|  |     serde::json::json, | ||||||
|  | }; | ||||||
|  | use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; | ||||||
| use serenity::{ | use serenity::{ | ||||||
|     client::Context, |     client::Context, | ||||||
|     http::Http, |     http::Http, | ||||||
|     model::id::{ChannelId, GuildId, UserId}, |     model::id::{ChannelId, GuildId, UserId}, | ||||||
| }; | }; | ||||||
| use sqlx::{types::Json, Executor}; | use sqlx::types::Json; | ||||||
|  |  | ||||||
| use crate::{ | use crate::{ | ||||||
|     check_guild_subscription, check_subscription, |     check_guild_subscription, check_subscription, | ||||||
| @@ -20,13 +24,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, | ||||||
|     Database, Error, |     Error, | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | pub mod api; | ||||||
| pub mod export; | pub mod export; | ||||||
| pub mod guild; |  | ||||||
| pub mod user; |  | ||||||
|  |  | ||||||
| type Unset<T> = Option<T>; | type Unset<T> = Option<T>; | ||||||
|  |  | ||||||
| @@ -50,12 +54,27 @@ fn interval_default() -> Unset<Option<u32>> { | |||||||
|     None |     None | ||||||
| } | } | ||||||
|  |  | ||||||
| fn deserialize_optional_field<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error> | #[derive(sqlx::Type)] | ||||||
| where | #[sqlx(transparent)] | ||||||
|     D: Deserializer<'de>, | struct Attachment(Vec<u8>); | ||||||
|     T: Deserialize<'de>, |  | ||||||
| { | impl<'de> Deserialize<'de> for Attachment { | ||||||
|     Ok(Some(Option::deserialize(deserializer)?)) |     fn deserialize<D>(deserializer: D) -> Result<Attachment, D::Error> | ||||||
|  |     where | ||||||
|  |         D: Deserializer<'de>, | ||||||
|  |     { | ||||||
|  |         let string = String::deserialize(deserializer)?; | ||||||
|  |         Ok(Attachment(base64::decode(string).map_err(de::Error::custom)?)) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Serialize for Attachment { | ||||||
|  |     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> | ||||||
|  |     where | ||||||
|  |         S: Serializer, | ||||||
|  |     { | ||||||
|  |         serializer.collect_str(&base64::encode(&self.0)) | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Serialize, Deserialize)] | #[derive(Serialize, Deserialize)] | ||||||
| @@ -66,7 +85,7 @@ pub struct ReminderTemplate { | |||||||
|     guild_id: u32, |     guild_id: u32, | ||||||
|     #[serde(default = "template_name_default")] |     #[serde(default = "template_name_default")] | ||||||
|     name: String, |     name: String, | ||||||
|     attachment: Option<Vec<u8>>, |     attachment: Option<Attachment>, | ||||||
|     attachment_name: Option<String>, |     attachment_name: Option<String>, | ||||||
|     avatar: Option<String>, |     avatar: Option<String>, | ||||||
|     content: String, |     content: String, | ||||||
| @@ -80,6 +99,9 @@ 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>, | ||||||
| } | } | ||||||
| @@ -88,7 +110,7 @@ pub struct ReminderTemplate { | |||||||
| pub struct ReminderTemplateCsv { | pub struct ReminderTemplateCsv { | ||||||
|     #[serde(default = "template_name_default")] |     #[serde(default = "template_name_default")] | ||||||
|     name: String, |     name: String, | ||||||
|     attachment: Option<Vec<u8>>, |     attachment: Option<Attachment>, | ||||||
|     attachment_name: Option<String>, |     attachment_name: Option<String>, | ||||||
|     avatar: Option<String>, |     avatar: Option<String>, | ||||||
|     content: String, |     content: String, | ||||||
| @@ -102,6 +124,9 @@ 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>, | ||||||
| } | } | ||||||
| @@ -120,12 +145,11 @@ pub struct EmbedField { | |||||||
|  |  | ||||||
| #[derive(Serialize, Deserialize)] | #[derive(Serialize, Deserialize)] | ||||||
| pub struct Reminder { | pub struct Reminder { | ||||||
|     #[serde(with = "base64s")] |     attachment: Option<Attachment>, | ||||||
|     attachment: Option<Vec<u8>>, |  | ||||||
|     attachment_name: Option<String>, |     attachment_name: Option<String>, | ||||||
|     avatar: Option<String>, |     avatar: Option<String>, | ||||||
|     #[serde(with = "string_opt")] |     #[serde(with = "string")] | ||||||
|     channel: Option<u64>, |     channel: u64, | ||||||
|     content: String, |     content: String, | ||||||
|     embed_author: String, |     embed_author: String, | ||||||
|     embed_author_url: Option<String>, |     embed_author_url: Option<String>, | ||||||
| @@ -150,14 +174,11 @@ pub struct Reminder { | |||||||
|     uid: String, |     uid: String, | ||||||
|     username: Option<String>, |     username: Option<String>, | ||||||
|     utc_time: NaiveDateTime, |     utc_time: NaiveDateTime, | ||||||
|     status: String, |  | ||||||
|     status_change_time: Option<NaiveDateTime>, |  | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Serialize, Deserialize)] | #[derive(Serialize, Deserialize)] | ||||||
| pub struct ReminderCsv { | pub struct ReminderCsv { | ||||||
|     #[serde(with = "base64s")] |     attachment: Option<Attachment>, | ||||||
|     attachment: Option<Vec<u8>>, |  | ||||||
|     attachment_name: Option<String>, |     attachment_name: Option<String>, | ||||||
|     avatar: Option<String>, |     avatar: Option<String>, | ||||||
|     channel: String, |     channel: String, | ||||||
| @@ -190,7 +211,7 @@ pub struct PatchReminder { | |||||||
|     uid: String, |     uid: String, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     #[serde(deserialize_with = "deserialize_optional_field")] |     #[serde(deserialize_with = "deserialize_optional_field")] | ||||||
|     attachment: Unset<Option<String>>, |     attachment: Unset<Option<Attachment>>, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     #[serde(deserialize_with = "deserialize_optional_field")] |     #[serde(deserialize_with = "deserialize_optional_field")] | ||||||
|     attachment_name: Unset<Option<String>>, |     attachment_name: Unset<Option<String>>, | ||||||
| @@ -286,6 +307,14 @@ pub fn generate_uid() -> String { | |||||||
|         .join("") |         .join("") | ||||||
| } | } | ||||||
|  |  | ||||||
|  | fn deserialize_optional_field<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error> | ||||||
|  | where | ||||||
|  |     D: Deserializer<'de>, | ||||||
|  |     T: Deserialize<'de>, | ||||||
|  | { | ||||||
|  |     Ok(Some(Option::deserialize(deserializer)?)) | ||||||
|  | } | ||||||
|  |  | ||||||
| // https://github.com/serde-rs/json/issues/329#issuecomment-305608405 | // https://github.com/serde-rs/json/issues/329#issuecomment-305608405 | ||||||
| mod string { | mod string { | ||||||
|     use std::{fmt::Display, str::FromStr}; |     use std::{fmt::Display, str::FromStr}; | ||||||
| @@ -310,57 +339,6 @@ 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 { |  | ||||||
|     use serde::{de, Deserialize, Deserializer, Serializer}; |  | ||||||
|  |  | ||||||
|     pub fn serialize<S>(value: &Option<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error> |  | ||||||
|     where |  | ||||||
|         S: Serializer, |  | ||||||
|     { |  | ||||||
|         if let Some(opt) = value { |  | ||||||
|             serializer.collect_str(&base64::encode(opt)) |  | ||||||
|         } else { |  | ||||||
|             serializer.serialize_none() |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error> |  | ||||||
|     where |  | ||||||
|         D: Deserializer<'de>, |  | ||||||
|     { |  | ||||||
|         let string = Option::<String>::deserialize(deserializer)?; |  | ||||||
|         Some(string.map(|b| base64::decode(b).map_err(de::Error::custom))).flatten().transpose() |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[derive(Deserialize)] | #[derive(Deserialize)] | ||||||
| pub struct DeleteReminder { | pub struct DeleteReminder { | ||||||
|     uid: String, |     uid: String, | ||||||
| @@ -377,21 +355,21 @@ pub struct TodoCsv { | |||||||
|     channel_id: Option<String>, |     channel_id: Option<String>, | ||||||
| } | } | ||||||
|  |  | ||||||
| pub async fn create_reminder( | pub(crate) async fn create_reminder( | ||||||
|     ctx: &Context, |     ctx: &Context, | ||||||
|     pool: impl sqlx::Executor<'_, Database = Database> + Copy, |     transaction: &mut Transaction<'_>, | ||||||
|     guild_id: GuildId, |     guild_id: GuildId, | ||||||
|     user_id: UserId, |     user_id: UserId, | ||||||
|     reminder: Reminder, |     reminder: Reminder, | ||||||
| ) -> 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(pool) |         .fetch_one(transaction.executor()) | ||||||
|         .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(pool) |                 .execute(transaction.executor()) | ||||||
|                 .await |                 .await | ||||||
|                 .is_err() |                 .is_err() | ||||||
|             { |             { | ||||||
| @@ -402,7 +380,7 @@ pub async fn create_reminder( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     // validate channel |     // validate channel | ||||||
|     let channel = reminder.channel.map(|c| ChannelId(c).to_channel_cached(&ctx)).flatten(); |     let channel = ChannelId(reminder.channel).to_channel_cached(&ctx); | ||||||
|     let channel_exists = channel.is_some(); |     let channel_exists = channel.is_some(); | ||||||
|  |  | ||||||
|     let channel_matches_guild = |     let channel_matches_guild = | ||||||
| @@ -410,14 +388,14 @@ pub 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.unwrap()), pool).await; |     let channel = create_database_channel(&ctx, ChannelId(reminder.channel), transaction).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); | ||||||
| @@ -491,8 +469,6 @@ pub 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 | ||||||
| @@ -509,7 +485,6 @@ pub async fn create_reminder( | |||||||
|          attachment, |          attachment, | ||||||
|          attachment_name, |          attachment_name, | ||||||
|          channel_id, |          channel_id, | ||||||
|          guild_id, |  | ||||||
|          avatar, |          avatar, | ||||||
|          content, |          content, | ||||||
|          embed_author, |          embed_author, | ||||||
| @@ -532,12 +507,11 @@ pub async fn create_reminder( | |||||||
|          tts, |          tts, | ||||||
|          username, |          username, | ||||||
|          `utc_time` |          `utc_time` | ||||||
|         ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", |         ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | ||||||
|         new_uid, |         new_uid, | ||||||
|         attachment_data, |         reminder.attachment, | ||||||
|         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, | ||||||
| @@ -561,7 +535,7 @@ pub async fn create_reminder( | |||||||
|         username, |         username, | ||||||
|         reminder.utc_time, |         reminder.utc_time, | ||||||
|     ) |     ) | ||||||
|     .execute(pool) |     .execute(transaction.executor()) | ||||||
|     .await |     .await | ||||||
|     { |     { | ||||||
|         Ok(_) => sqlx::query_as_unchecked!( |         Ok(_) => sqlx::query_as_unchecked!( | ||||||
| @@ -592,15 +566,13 @@ pub 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 |  | ||||||
|             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(pool) |         .fetch_one(transaction.executor()) | ||||||
|         .await |         .await | ||||||
|         .map(|r| Ok(json!(r))) |         .map(|r| Ok(json!(r))) | ||||||
|         .unwrap_or_else(|e| { |         .unwrap_or_else(|e| { | ||||||
| @@ -620,11 +592,11 @@ pub 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, | ||||||
|     pool: impl Executor<'_, Database = Database> + Copy, |     transaction: &mut Transaction<'_>, | ||||||
| ) -> 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(pool) |             .fetch_one(transaction.executor()) | ||||||
|             .await; |             .await; | ||||||
|  |  | ||||||
|     match row { |     match row { | ||||||
| @@ -641,7 +613,7 @@ async fn create_database_channel( | |||||||
|                     webhook.token, |                     webhook.token, | ||||||
|                     channel.0 |                     channel.0 | ||||||
|                 ) |                 ) | ||||||
|                 .execute(pool) |                 .execute(transaction.executor()) | ||||||
|                 .await |                 .await | ||||||
|                 .map_err(|e| Error::SQLx(e))?; |                 .map_err(|e| Error::SQLx(e))?; | ||||||
|             } |             } | ||||||
| @@ -667,7 +639,7 @@ async fn create_database_channel( | |||||||
|                 webhook.token, |                 webhook.token, | ||||||
|                 channel.0 |                 channel.0 | ||||||
|             ) |             ) | ||||||
|             .execute(pool) |             .execute(transaction.executor()) | ||||||
|             .await |             .await | ||||||
|             .map_err(|e| Error::SQLx(e))?; |             .map_err(|e| Error::SQLx(e))?; | ||||||
|  |  | ||||||
| @@ -678,7 +650,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(pool) |         .fetch_one(transaction.executor()) | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| Error::SQLx(e))?; |         .map_err(|e| Error::SQLx(e))?; | ||||||
|  |  | ||||||
| @@ -686,30 +658,26 @@ 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<NamedFile, Redirect> { | ||||||
|     if cookies.get_private("userid").is_some() { |     if cookies.get_private("userid").is_some() { | ||||||
|         let map: HashMap<&str, String> = HashMap::new(); |         NamedFile::open(Path::new(relative!("static/index.html"))).await.map_err(|e| { | ||||||
|         Ok(Template::render("dashboard", &map)) |             warn!("Couldn't render dashboard: {:?}", e); | ||||||
|  |  | ||||||
|  |             Redirect::to("/login/discord") | ||||||
|  |         }) | ||||||
|     } else { |     } else { | ||||||
|         Err(Redirect::to("/login/discord")) |         Err(Redirect::to("/login/discord")) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| #[get("/<_>")] | #[get("/<_..>")] | ||||||
| pub async fn dashboard_1(cookies: &CookieJar<'_>) -> Result<Template, Redirect> { | pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<NamedFile, Redirect> { | ||||||
|     if cookies.get_private("userid").is_some() { |     if cookies.get_private("userid").is_some() { | ||||||
|         let map: HashMap<&str, String> = HashMap::new(); |         NamedFile::open(Path::new(relative!("static/index.html"))).await.map_err(|e| { | ||||||
|         Ok(Template::render("dashboard", &map)) |             warn!("Couldn't render dashboard: {:?}", e); | ||||||
|     } else { |  | ||||||
|         Err(Redirect::to("/login/discord")) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[get("/<_>/<_>")] |             Redirect::to("/login/discord") | ||||||
| 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)) |  | ||||||
|     } else { |     } else { | ||||||
|         Err(Redirect::to("/login/discord")) |         Err(Redirect::to("/login/discord")) | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,172 +0,0 @@ | |||||||
| use std::env; |  | ||||||
|  |  | ||||||
| use chrono_tz::Tz; |  | ||||||
| use reqwest::Client; |  | ||||||
| use rocket::{ |  | ||||||
|     http::CookieJar, |  | ||||||
|     serde::json::{json, Json, Value as JsonValue}, |  | ||||||
|     State, |  | ||||||
| }; |  | ||||||
| use serde::{Deserialize, Serialize}; |  | ||||||
| use serenity::{ |  | ||||||
|     client::Context, |  | ||||||
|     model::{ |  | ||||||
|         id::{GuildId, RoleId}, |  | ||||||
|         permissions::Permissions, |  | ||||||
|     }, |  | ||||||
| }; |  | ||||||
| use sqlx::{MySql, Pool}; |  | ||||||
|  |  | ||||||
| use crate::consts::DISCORD_API; |  | ||||||
|  |  | ||||||
| #[derive(Serialize)] |  | ||||||
| struct UserInfo { |  | ||||||
|     name: String, |  | ||||||
|     patreon: bool, |  | ||||||
|     timezone: Option<String>, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[derive(Deserialize)] |  | ||||||
| pub struct UpdateUser { |  | ||||||
|     timezone: String, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[derive(Serialize)] |  | ||||||
| struct GuildInfo { |  | ||||||
|     id: String, |  | ||||||
|     name: String, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[derive(Deserialize)] |  | ||||||
| pub struct PartialGuild { |  | ||||||
|     pub id: GuildId, |  | ||||||
|     pub icon: Option<String>, |  | ||||||
|     pub name: String, |  | ||||||
|     #[serde(default)] |  | ||||||
|     pub owner: bool, |  | ||||||
|     #[serde(rename = "permissions_new")] |  | ||||||
|     pub permissions: Option<String>, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[get("/api/user")] |  | ||||||
| pub async fn get_user_info( |  | ||||||
|     cookies: &CookieJar<'_>, |  | ||||||
|     ctx: &State<Context>, |  | ||||||
|     pool: &State<Pool<MySql>>, |  | ||||||
| ) -> JsonValue { |  | ||||||
|     offline!(json!(UserInfo { name: "Discord".to_string(), patreon: true, timezone: None })); |  | ||||||
|  |  | ||||||
|     if let Some(user_id) = |  | ||||||
|         cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten() |  | ||||||
|     { |  | ||||||
|         let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap()) |  | ||||||
|             .member(&ctx.inner(), user_id) |  | ||||||
|             .await; |  | ||||||
|  |  | ||||||
|         let timezone = sqlx::query!( |  | ||||||
|             "SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?", |  | ||||||
|             user_id |  | ||||||
|         ) |  | ||||||
|         .fetch_one(pool.inner()) |  | ||||||
|         .await |  | ||||||
|         .map_or(None, |q| Some(q.timezone)); |  | ||||||
|  |  | ||||||
|         let user_info = UserInfo { |  | ||||||
|             name: cookies |  | ||||||
|                 .get_private("username") |  | ||||||
|                 .map_or("DiscordUser#0000".to_string(), |c| c.value().to_string()), |  | ||||||
|             patreon: member_res.map_or(false, |member| { |  | ||||||
|                 member |  | ||||||
|                     .roles |  | ||||||
|                     .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap())) |  | ||||||
|             }), |  | ||||||
|             timezone, |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         json!(user_info) |  | ||||||
|     } else { |  | ||||||
|         json!({"error": "Not authorized"}) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[patch("/api/user", data = "<user>")] |  | ||||||
| pub async fn update_user_info( |  | ||||||
|     cookies: &CookieJar<'_>, |  | ||||||
|     user: Json<UpdateUser>, |  | ||||||
|     pool: &State<Pool<MySql>>, |  | ||||||
| ) -> JsonValue { |  | ||||||
|     if let Some(user_id) = |  | ||||||
|         cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten() |  | ||||||
|     { |  | ||||||
|         if user.timezone.parse::<Tz>().is_ok() { |  | ||||||
|             let _ = sqlx::query!( |  | ||||||
|                 "UPDATE users SET timezone = ? WHERE user = ?", |  | ||||||
|                 user.timezone, |  | ||||||
|                 user_id, |  | ||||||
|             ) |  | ||||||
|             .execute(pool.inner()) |  | ||||||
|             .await; |  | ||||||
|  |  | ||||||
|             json!({}) |  | ||||||
|         } else { |  | ||||||
|             json!({"error": "Timezone not recognized"}) |  | ||||||
|         } |  | ||||||
|     } else { |  | ||||||
|         json!({"error": "Not authorized"}) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[get("/api/user/guilds")] |  | ||||||
| pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue { |  | ||||||
|     offline!(json!(vec![GuildInfo { id: "1".to_string(), name: "Guild".to_string() }])); |  | ||||||
|  |  | ||||||
|     if let Some(access_token) = cookies.get_private("access_token") { |  | ||||||
|         let request_res = reqwest_client |  | ||||||
|             .get(format!("{}/users/@me/guilds", DISCORD_API)) |  | ||||||
|             .bearer_auth(access_token.value()) |  | ||||||
|             .send() |  | ||||||
|             .await; |  | ||||||
|  |  | ||||||
|         match request_res { |  | ||||||
|             Ok(response) => { |  | ||||||
|                 let guilds_res = response.json::<Vec<PartialGuild>>().await; |  | ||||||
|  |  | ||||||
|                 match guilds_res { |  | ||||||
|                     Ok(guilds) => { |  | ||||||
|                         let reduced_guilds = guilds |  | ||||||
|                             .iter() |  | ||||||
|                             .filter(|g| { |  | ||||||
|                                 g.owner |  | ||||||
|                                     || g.permissions.as_ref().map_or(false, |p| { |  | ||||||
|                                         let permissions = |  | ||||||
|                                             Permissions::from_bits_truncate(p.parse().unwrap()); |  | ||||||
|  |  | ||||||
|                                         permissions.manage_messages() |  | ||||||
|                                             || permissions.manage_guild() |  | ||||||
|                                             || permissions.administrator() |  | ||||||
|                                     }) |  | ||||||
|                             }) |  | ||||||
|                             .map(|g| GuildInfo { id: g.id.to_string(), name: g.name.to_string() }) |  | ||||||
|                             .collect::<Vec<GuildInfo>>(); |  | ||||||
|  |  | ||||||
|                         json!(reduced_guilds) |  | ||||||
|                     } |  | ||||||
|  |  | ||||||
|                     Err(e) => { |  | ||||||
|                         warn!("Error constructing user from request: {:?}", e); |  | ||||||
|  |  | ||||||
|                         json!({"error": "Could not get user details"}) |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             Err(e) => { |  | ||||||
|                 warn!("Error getting user guilds: {:?}", e); |  | ||||||
|  |  | ||||||
|                 json!({"error": "Could not reach Discord"}) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } else { |  | ||||||
|         json!({"error": "Not authorized"}) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -31,22 +31,20 @@ pub async fn discord_login( | |||||||
|  |  | ||||||
|     // store the pkce secret to verify the authorization later |     // store the pkce secret to verify the authorization later | ||||||
|     cookies.add_private( |     cookies.add_private( | ||||||
|         Cookie::build("verify", pkce_verifier.secret().to_string()) |         Cookie::build(("verify", pkce_verifier.secret().to_string())) | ||||||
|             .http_only(true) |             .http_only(true) | ||||||
|             .path("/login") |             .path("/login") | ||||||
|             .same_site(SameSite::Lax) |             .same_site(SameSite::Lax) | ||||||
|             .expires(Expiration::Session) |             .expires(Expiration::Session), | ||||||
|             .finish(), |  | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     // store the csrf token to verify no interference |     // store the csrf token to verify no interference | ||||||
|     cookies.add_private( |     cookies.add_private( | ||||||
|         Cookie::build("csrf", csrf_token.secret().to_string()) |         Cookie::build(("csrf", csrf_token.secret().to_string())) | ||||||
|             .http_only(true) |             .http_only(true) | ||||||
|             .path("/login") |             .path("/login") | ||||||
|             .same_site(SameSite::Lax) |             .same_site(SameSite::Lax) | ||||||
|             .expires(Expiration::Session) |             .expires(Expiration::Session), | ||||||
|             .finish(), |  | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     Redirect::to(auth_url.to_string()) |     Redirect::to(auth_url.to_string()) | ||||||
| @@ -54,9 +52,9 @@ pub async fn discord_login( | |||||||
|  |  | ||||||
| #[get("/discord/logout")] | #[get("/discord/logout")] | ||||||
| pub async fn discord_logout(cookies: &CookieJar<'_>) -> Redirect { | pub async fn discord_logout(cookies: &CookieJar<'_>) -> Redirect { | ||||||
|     cookies.remove_private(Cookie::named("username")); |     cookies.remove_private(Cookie::from("username")); | ||||||
|     cookies.remove_private(Cookie::named("userid")); |     cookies.remove_private(Cookie::from("userid")); | ||||||
|     cookies.remove_private(Cookie::named("access_token")); |     cookies.remove_private(Cookie::from("access_token")); | ||||||
|  |  | ||||||
|     Redirect::to(uri!(routes::index)) |     Redirect::to(uri!(routes::index)) | ||||||
| } | } | ||||||
| @@ -80,17 +78,16 @@ pub async fn discord_callback( | |||||||
|                 .request_async(async_http_client) |                 .request_async(async_http_client) | ||||||
|                 .await; |                 .await; | ||||||
|  |  | ||||||
|             cookies.remove_private(Cookie::named("verify")); |             cookies.remove_private(Cookie::from("verify")); | ||||||
|             cookies.remove_private(Cookie::named("csrf")); |             cookies.remove_private(Cookie::from("csrf")); | ||||||
|  |  | ||||||
|             match token_result { |             match token_result { | ||||||
|                 Ok(token) => { |                 Ok(token) => { | ||||||
|                     cookies.add_private( |                     cookies.add_private( | ||||||
|                         Cookie::build("access_token", token.access_token().secret().to_string()) |                         Cookie::build(("access_token", token.access_token().secret().to_string())) | ||||||
|                             .secure(true) |                             .secure(true) | ||||||
|                             .http_only(true) |                             .http_only(true) | ||||||
|                             .path("/dashboard") |                             .path("/dashboard"), | ||||||
|                             .finish(), |  | ||||||
|                     ); |                     ); | ||||||
|  |  | ||||||
|                     let request_res = reqwest_client |                     let request_res = reqwest_client | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								web/src/routes/metrics.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								web/src/routes/metrics.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | use prometheus; | ||||||
|  |  | ||||||
|  | use crate::metrics::REGISTRY; | ||||||
|  |  | ||||||
|  | #[get("/metrics")] | ||||||
|  | pub async fn metrics() -> String { | ||||||
|  |     let encoder = prometheus::TextEncoder::new(); | ||||||
|  |     let res_custom = encoder.encode_to_string(®ISTRY.gather()); | ||||||
|  |  | ||||||
|  |     match res_custom { | ||||||
|  |         Ok(s) => s, | ||||||
|  |         Err(e) => { | ||||||
|  |             warn!("Error encoding metrics: {:?}", e); | ||||||
|  |  | ||||||
|  |             String::new() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,6 +1,7 @@ | |||||||
| pub mod admin; | pub mod admin; | ||||||
| pub mod dashboard; | pub mod dashboard; | ||||||
| pub mod login; | pub mod login; | ||||||
|  | pub mod metrics; | ||||||
| pub mod report; | pub mod report; | ||||||
|  |  | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
|   | |||||||
| @@ -15,6 +15,22 @@ 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 { | ||||||
|  |     display: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | div.reminderContent.is-collapsed .button-row-edit { | ||||||
|  |     display: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | div.reminderContent.is-collapsed .reminder-topbar { | ||||||
|  |     padding-bottom: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
| div.reminderContent.is-collapsed .invert-collapses { | div.reminderContent.is-collapsed .invert-collapses { | ||||||
|     display: inline-flex; |     display: inline-flex; | ||||||
| } | } | ||||||
| @@ -129,6 +145,12 @@ div.split-controls { | |||||||
|     margin-top: 0 !important; |     margin-top: 0 !important; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .reminder-settings > .column { | ||||||
|  |     flex-grow: 0; | ||||||
|  |     flex-shrink: 0; | ||||||
|  |     flex-basis: 50%; | ||||||
|  | } | ||||||
|  |  | ||||||
| div.reminderContent { | div.reminderContent { | ||||||
|     margin-top: 10px; |     margin-top: 10px; | ||||||
|     margin-bottom: 10px; |     margin-bottom: 10px; | ||||||
| @@ -196,17 +218,16 @@ div.inset-content { | |||||||
|     margin-right: 10%; |     margin-right: 10%; | ||||||
| } | } | ||||||
|  |  | ||||||
| div.flash-message { | div.flash-container { | ||||||
|     position: fixed; |     position: fixed; | ||||||
|  |     width: 100%; | ||||||
|  |     bottom: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | div.flash-message { | ||||||
|     width: calc(100% - 32px); |     width: calc(100% - 32px); | ||||||
|     margin: 16px !important; |     margin: 16px !important; | ||||||
|     z-index: 99; |     z-index: 99; | ||||||
|     bottom: 0; |  | ||||||
|     display: none; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| div.flash-message.is-active { |  | ||||||
|     display: block; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| body { | body { | ||||||
| @@ -286,20 +307,18 @@ 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; | ||||||
|     overflow: scroll; |     overflow: auto; | ||||||
| } | } | ||||||
|  |  | ||||||
| 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 { | ||||||
| @@ -453,8 +472,7 @@ input.default-width { | |||||||
| .customizable.is-400x300 img { | .customizable.is-400x300 img { | ||||||
|     margin-top: 10px; |     margin-top: 10px; | ||||||
|     width: 100%; |     width: 100%; | ||||||
|     min-height: 100px; |     height: 100px; | ||||||
|     max-height: 400px; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| .customizable.is-32x32 img { | .customizable.is-32x32 img { | ||||||
| @@ -598,6 +616,14 @@ input.default-width { | |||||||
|     border-bottom: 1px solid #fff; |     border-bottom: 1px solid #fff; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .channel-selector { | ||||||
|  |     width: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .select { | ||||||
|  |     width: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
| li.highlight { | li.highlight { | ||||||
|     margin-bottom: 0 !important; |     margin-bottom: 0 !important; | ||||||
| } | } | ||||||
| @@ -606,6 +632,10 @@ li.highlight { | |||||||
|     display: flex; |     display: flex; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .button-row-edit > button { | ||||||
|  |     margin-right: 4px; | ||||||
|  | } | ||||||
|  |  | ||||||
| .button-row .button-row-reminder { | .button-row .button-row-reminder { | ||||||
|     flex-grow: 0; |     flex-grow: 0; | ||||||
|     padding: 2px; |     padding: 2px; | ||||||
| @@ -621,7 +651,22 @@ li.highlight { | |||||||
|     padding: 2px; |     padding: 2px; | ||||||
| } | } | ||||||
|  |  | ||||||
| @media only screen and (max-width: 1408px) { | @media only screen and (max-width: 1023px) { | ||||||
|  |     p.title.pageTitle { | ||||||
|  |         display: none; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .dashboard-frame { | ||||||
|  |         margin-top: 4rem !important; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .customizable.thumbnail img { | ||||||
|  |         width: 60px; | ||||||
|  |         height: 60px; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @media only screen and (max-width: 768px) { | ||||||
|     .button-row { |     .button-row { | ||||||
|         display: flex; |         display: flex; | ||||||
|         flex-direction: column; |         flex-direction: column; | ||||||
| @@ -639,37 +684,13 @@ li.highlight { | |||||||
|     .button-row button { |     .button-row button { | ||||||
|         width: 100%; |         width: 100%; | ||||||
|     } |     } | ||||||
| } |  | ||||||
|  |  | ||||||
| @media only screen and (max-width: 768px) { |     .reminder-settings { | ||||||
|     .button-row-edit { |         margin-bottom: 0 !important; | ||||||
|         display: flex; |  | ||||||
|         flex-direction: column; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     .button-row-edit > button { |     .tts-row { | ||||||
|         width: 100%; |         padding-bottom: 0; | ||||||
|         margin: 4px; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     p.title.pageTitle { |  | ||||||
|         display: none; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     .dashboard-frame { |  | ||||||
|         margin-top: 4rem !important; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @media only screen and (max-width: 768px) { |  | ||||||
|     .customizable.thumbnail img { |  | ||||||
|         width: 60px; |  | ||||||
|         height: 60px; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     .customizable.is-24x24 img { |  | ||||||
|         width: 16px; |  | ||||||
|         height: 16px; |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -758,6 +779,16 @@ div.reminderError .errorHead .reminderTime { | |||||||
|     border-style: solid; |     border-style: solid; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | div.reminderError .reminderMessage { | ||||||
|  |     font-size: 1rem; | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     justify-content: center; | ||||||
|  |     color: rgb(54, 54, 54); | ||||||
|  |     flex-grow: 1; | ||||||
|  |     font-style: italic; | ||||||
|  | } | ||||||
|  |  | ||||||
| /* other stuff */ | /* other stuff */ | ||||||
|  |  | ||||||
| .half-rem { | .half-rem { | ||||||
|   | |||||||
| @@ -18,7 +18,6 @@ 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 = {}; | ||||||
| @@ -34,11 +33,16 @@ let globalPatreon = false; | |||||||
| let guildPatreon = false; | let guildPatreon = false; | ||||||
|  |  | ||||||
| function guildId() { | function guildId() { | ||||||
|     return document.querySelector("li > a.is-active").parentElement.dataset["guild"]; |     return window.location.pathname.match(/dashboard\/(\d+)/)[1]; | ||||||
| } | } | ||||||
|  |  | ||||||
| function guildName() { | function pane() { | ||||||
|     return guildNames[guildId()]; |     const match = window.location.pathname.match(/dashboard\/\d+\/(.+)/); | ||||||
|  |     if (match === null) { | ||||||
|  |         return null; | ||||||
|  |     } else { | ||||||
|  |         return match[1]; | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| function colorToInt(r, g, b) { | function colorToInt(r, g, b) { | ||||||
| @@ -57,7 +61,7 @@ function switch_pane(selector) { | |||||||
|         el.classList.add("is-hidden"); |         el.classList.add("is-hidden"); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     document.querySelector(`*[data-name=${selector}]`).classList.remove("is-hidden"); |     document.getElementById(selector).classList.remove("is-hidden"); | ||||||
| } | } | ||||||
|  |  | ||||||
| function update_select(sel) { | function update_select(sel) { | ||||||
| @@ -101,7 +105,7 @@ function reset_guild_pane() { | |||||||
| } | } | ||||||
|  |  | ||||||
| async function fetch_patreon(guild_id) { | async function fetch_patreon(guild_id) { | ||||||
|     fetch(`/dashboard/api/guild/${guild_id}/patreon`) |     fetch(`/dashboard/api/guild/${guild_id}`) | ||||||
|         .then((response) => response.json()) |         .then((response) => response.json()) | ||||||
|         .then((data) => { |         .then((data) => { | ||||||
|             if (data.error) { |             if (data.error) { | ||||||
| @@ -228,11 +232,10 @@ async function fetch_reminders(guild_id) { | |||||||
| } | } | ||||||
|  |  | ||||||
| async function serialize_reminder(node, mode) { | async function serialize_reminder(node, mode) { | ||||||
|     let interval, utc_time, expiration_time; |     let 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"); | ||||||
| @@ -361,9 +364,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: mode !== "template" ? interval.seconds : null, |         interval_seconds: interval.seconds, | ||||||
|         interval_days: mode !== "template" ? interval.days : null, |         interval_days: interval.days, | ||||||
|         interval_months: mode !== "template" ? interval.months : null, |         interval_months: interval.months, | ||||||
|         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, | ||||||
| @@ -425,9 +428,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"; | ||||||
|  |  | ||||||
| @@ -454,19 +457,23 @@ 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 $li = document.querySelector(`li[data-guild="${e.detail.guild_id}"]`); |     let $anchor = document.querySelector( | ||||||
|  |         `.switch-pane[data-guild="${e.detail.guild_id}"]` | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     if ($li === null) { |     let hasError = false; | ||||||
|         switch_pane("user-error"); |  | ||||||
|         return; |     if (pane() === null) { | ||||||
|  |         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(); | ||||||
|     $li.querySelector("li > a").classList.add("is-active"); |  | ||||||
|     $li.querySelectorAll(`*[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 | ||||||
| @@ -474,26 +481,15 @@ document.addEventListener("guildSwitched", async (e) => { | |||||||
|             .forEach((el) => el.classList.remove("is-locked")); |             .forEach((el) => el.classList.remove("is-locked")); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const event = new CustomEvent("paneLoad", { |     hasError = await fetch_channels(e.detail.guild_id); | ||||||
|         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(ev.detail.guild_id); |         fetch_roles(e.detail.guild_id); | ||||||
|         fetch_templates(ev.detail.guild_id); |         fetch_templates(e.detail.guild_id); | ||||||
|         fetch_reminders(ev.detail.guild_id); |         fetch_reminders(e.detail.guild_id); | ||||||
|  |  | ||||||
|         document.querySelectorAll("p.pageTitle").forEach((el) => { |         document.querySelectorAll("p.pageTitle").forEach((el) => { | ||||||
|             el.textContent = `${guildName()} Reminders`; |             el.textContent = `${e.detail.guild_name} 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); | ||||||
| @@ -618,6 +614,16 @@ 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", () => { | ||||||
| @@ -698,56 +704,40 @@ 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`; | ||||||
|  |  | ||||||
|                         const $li = $anchor.parentElement; |                         $anchor.addEventListener("click", async (e) => { | ||||||
|                         $li.dataset["guild"] = guild.id; |                             e.preventDefault(); | ||||||
|  |                             window.history.pushState( | ||||||
|                         $li.querySelectorAll("a").forEach((el) => { |                                 {}, | ||||||
|                             el.addEventListener("click", (e) => { |                                 "", | ||||||
|                                 const pane = el.dataset["pane"]; |                                 `/dashboard/${guild.id}/reminders` | ||||||
|                                 const slug = el.dataset["slug"]; |                             ); | ||||||
|  |                             const event = new CustomEvent("guildSwitched", { | ||||||
|                                 if (pane !== undefined && slug !== undefined) { |                                 detail: { | ||||||
|                                     e.preventDefault(); |                                     guild_name: guild.name, | ||||||
|  |                                     guild_id: guild.id, | ||||||
|                                     switch_pane(pane); |                                 }, | ||||||
|  |  | ||||||
|                                     window.history.pushState( |  | ||||||
|                                         {}, |  | ||||||
|                                         "", |  | ||||||
|                                         `/dashboard/${guild.id}/${slug}` |  | ||||||
|                                     ); |  | ||||||
|                                     const event = new CustomEvent("guildSwitched", { |  | ||||||
|                                         detail: { |  | ||||||
|                                             guild_id: guild.id, |  | ||||||
|                                             pane, |  | ||||||
|                                         }, |  | ||||||
|                                     }); |  | ||||||
|  |  | ||||||
|                                     document.dispatchEvent(event); |  | ||||||
|                                 } |  | ||||||
|                             }); |                             }); | ||||||
|  |  | ||||||
|  |                             document.dispatchEvent(event); | ||||||
|                         }); |                         }); | ||||||
|  |  | ||||||
|                         element.append($clone); |                         element.append($clone); | ||||||
|                     }); |                     }); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 const matches = window.location.href.match( |                 const matches = window.location.href.match(/dashboard\/(\d+)/); | ||||||
|                     /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, |  | ||||||
|                         }, |                         }, | ||||||
|                     }); |                     }); | ||||||
|  |  | ||||||
| @@ -788,12 +778,26 @@ $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(() => { |         }) | ||||||
|             delete $uploader.files[0]; |             .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]; | ||||||
|  |                 fetch_reminders(guild); | ||||||
|  |             }); | ||||||
|     }); |     }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,43 +0,0 @@ | |||||||
| 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(".reminderTime").textContent = statusTime |  | ||||||
|                     .toLocal() |  | ||||||
|                     .toLocaleString(luxon.DateTime.DATETIME_MED); |  | ||||||
|  |  | ||||||
|                 container.appendChild(newRow); |  | ||||||
|             } |  | ||||||
|         }) |  | ||||||
|         .finally(() => { |  | ||||||
|             $loader.classList.add("is-hidden"); |  | ||||||
|         }); |  | ||||||
| }); |  | ||||||
| @@ -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"> |     <link rel="stylesheet" href="/static/css/style.css?v{{ version }}"> | ||||||
|     <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" /> | ||||||
|  |  | ||||||
| @@ -40,7 +40,7 @@ | |||||||
|     <div class="navbar-brand"> |     <div class="navbar-brand"> | ||||||
|         <a class="navbar-item" href="/"> |         <a class="navbar-item" href="/"> | ||||||
|             <figure class="image"> |             <figure class="image"> | ||||||
|                 <img src="/static/img/logo_nobg.webp" alt="Reminder Bot Logo"> |                 <img width="28px" height="28px" src="/static/img/logo_nobg.webp" alt="Reminder Bot Logo"> | ||||||
|             </figure> |             </figure> | ||||||
|         </a> |         </a> | ||||||
|  |  | ||||||
| @@ -76,6 +76,10 @@ | |||||||
|     <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"> | ||||||
| @@ -185,14 +189,6 @@ | |||||||
|                     </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"> | ||||||
| @@ -234,6 +230,7 @@ | |||||||
|         <a href="/"> |         <a href="/"> | ||||||
|             <div class="brand"> |             <div class="brand"> | ||||||
|                 <img src="/static/img/logo_nobg.webp" alt="Reminder bot logo" |                 <img src="/static/img/logo_nobg.webp" alt="Reminder bot logo" | ||||||
|  |                      width="52px" height="52px" | ||||||
|                      class="dashboard-brand"> |                      class="dashboard-brand"> | ||||||
|             </div> |             </div> | ||||||
|         </a> |         </a> | ||||||
| @@ -256,11 +253,9 @@ | |||||||
|                 </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> | ||||||
| @@ -302,11 +297,9 @@ | |||||||
|                 </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> | ||||||
| @@ -332,16 +325,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 data-name="reminders" class="is-hidden"> |         <section id="reminders" class="is-hidden"> | ||||||
|             {% include "reminder_dashboard/reminder_dashboard" %} |             {% include "reminder_dashboard/reminder_dashboard" %} | ||||||
|         </section> |         </section> | ||||||
|         <section data-name="errors" class="is-hidden"> |         <section id="reminder-errors" class="is-hidden"> | ||||||
|             {% include "reminder_dashboard/reminder_errors" %} |             {% include "reminder_dashboard/reminder_errors" %} | ||||||
|         </section> |         </section> | ||||||
|         <section data-name="guild-error" class="is-hidden"> |         <section id="guild-error" class="is-hidden"> | ||||||
|             {% include "reminder_dashboard/guild_error" %} |             {% include "reminder_dashboard/guild_error" %} | ||||||
|         </section> |         </section> | ||||||
|         <section data-name="user-error" class="is-hidden"> |         <section id="user-error" class="is-hidden"> | ||||||
|             {% include "reminder_dashboard/user_error" %} |             {% include "reminder_dashboard/user_error" %} | ||||||
|         </section> |         </section> | ||||||
|     </div> |     </div> | ||||||
| @@ -375,36 +368,22 @@ | |||||||
|  |  | ||||||
| <template id="guildListEntry"> | <template id="guildListEntry"> | ||||||
|     <li> |     <li> | ||||||
|         <a class="switch-pane" data-pane="reminders" data-slug="reminders"> |         <a class="switch-pane" data-pane="guild"> | ||||||
|             <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/templates/guild_reminder" %} |     {% include "reminder_dashboard/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"></script> | <script src="/static/js/interval.js?v{{ version }}"></script> | ||||||
| <script src="/static/js/timezone.js" defer></script> | <script src="/static/js/timezone.js?v{{ version }}" defer></script> | ||||||
| <script src="/static/js/main.js" defer></script> | <script src="/static/js/main.js?v{{ version }}" defer></script> | ||||||
|  |  | ||||||
| </body> | </body> | ||||||
| </html> | </html> | ||||||
|   | |||||||
| @@ -133,32 +133,27 @@ | |||||||
|             </article> |             </article> | ||||||
|         </div> |         </div> | ||||||
|         <div class="column settings"> |         <div class="column settings"> | ||||||
|             <div class="columns"> |             <div class="field channel-field"> | ||||||
|                 <div class="column"> |                 <div class="collapses"> | ||||||
|                     <div class="field channel-field"> |                     <label class="label" for="channelOption">Channel*</label> | ||||||
|                         <div class="collapses"> |                 </div> | ||||||
|                             <label class="label" for="channelOption">Channel*</label> |                 <div class="control has-icons-left"> | ||||||
|                         </div> |                     <div class="select"> | ||||||
|                         <div class="control has-icons-left"> |                         <select name="channel" class="channel-selector"> | ||||||
|                             <div class="select"> |                         </select> | ||||||
|                                 <select name="channel" class="channel-selector"> |                     </div> | ||||||
|                                 </select> |                     <div class="icon is-small is-left"> | ||||||
|                             </div> |                         <i class="fas fa-hashtag"></i> | ||||||
|                             <div class="icon is-small is-left"> |  | ||||||
|                                 <i class="fas fa-hashtag"></i> |  | ||||||
|                             </div> |  | ||||||
|                         </div> |  | ||||||
|                     </div> |                     </div> | ||||||
|                 </div> |                 </div> | ||||||
|                 <div class="column"> |             </div> | ||||||
|                     <div class="field"> | 
 | ||||||
|                         <div class="control"> |             <div class="field"> | ||||||
|                             <label class="label collapses"> |                 <div class="control"> | ||||||
|                                 Time* |                     <label class="label collapses"> | ||||||
|                                 <input class="input prefill-now" type="datetime-local" step="1" name="time"> |                         Time* | ||||||
|                             </label> |                         <input class="input prefill-now" type="datetime-local" step="1" name="time"> | ||||||
|                         </div> |                     </label> | ||||||
|                     </div> |  | ||||||
|                 </div> |                 </div> | ||||||
|             </div> |             </div> | ||||||
| 
 | 
 | ||||||
| @@ -236,40 +231,39 @@ | |||||||
|                         </div> |                         </div> | ||||||
|                     </div> |                     </div> | ||||||
|                 </div> |                 </div> | ||||||
| 
 |  | ||||||
|                 {% if creating %} |  | ||||||
|                     <div class="button-row"> |  | ||||||
|                         <div class="button-row-reminder"> |  | ||||||
|                             <button class="button is-success" id="createReminder"> |  | ||||||
|                                 <span>Create Reminder</span> <span class="icon"><i class="fas fa-sparkles"></i></span> |  | ||||||
|                             </button> |  | ||||||
|                         </div> |  | ||||||
|                         <div class="button-row-template"> |  | ||||||
|                             <div> |  | ||||||
|                                 <button class="button is-success is-outlined" id="createTemplate"> |  | ||||||
|                                     <span>Create Template</span> <span class="icon"><i class="fas fa-file-spreadsheet"></i></span> |  | ||||||
|                                 </button> |  | ||||||
|                             </div> |  | ||||||
|                             <div> |  | ||||||
|                                 <button class="button is-outlined show-modal is-pulled-right" data-modal="chooseTemplateModal"> |  | ||||||
|                                     Load Template |  | ||||||
|                                 </button> |  | ||||||
|                             </div> |  | ||||||
|                         </div> |  | ||||||
|                     </div> |  | ||||||
|                 {% else %} |  | ||||||
|                     <div class="button-row-edit"> |  | ||||||
|                         <button class="button is-success save-btn"> |  | ||||||
|                             <span>Save</span> <span class="icon"><i class="fas fa-save"></i></span> |  | ||||||
|                         </button> |  | ||||||
|                         <button class="button is-warning disable-enable"> |  | ||||||
|                         </button> |  | ||||||
|                         <button class="button is-danger delete-reminder"> |  | ||||||
|                             Delete |  | ||||||
|                         </button> |  | ||||||
|                     </div> |  | ||||||
|                 {% endif %} |  | ||||||
|             </div> |             </div> | ||||||
|         </div> |         </div> | ||||||
|     </div> |     </div> | ||||||
|  |     {% if creating %} | ||||||
|  |         <div class="button-row"> | ||||||
|  |             <div class="button-row-reminder"> | ||||||
|  |                 <button class="button is-success" id="createReminder"> | ||||||
|  |                     <span>Create Reminder</span> <span class="icon"><i class="fas fa-sparkles"></i></span> | ||||||
|  |                 </button> | ||||||
|  |             </div> | ||||||
|  |             <div class="button-row-template"> | ||||||
|  |                 <div> | ||||||
|  |                     <button class="button is-success is-outlined" id="createTemplate"> | ||||||
|  |                         <span>Create Template</span> <span class="icon"><i class="fas fa-file-spreadsheet"></i></span> | ||||||
|  |                     </button> | ||||||
|  |                 </div> | ||||||
|  |                 <div> | ||||||
|  |                     <button class="button is-outlined show-modal is-pulled-right" data-modal="chooseTemplateModal"> | ||||||
|  |                         Load Template | ||||||
|  |                     </button> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     {% else %} | ||||||
|  |         <div class="button-row-edit"> | ||||||
|  |             <button class="button is-success save-btn"> | ||||||
|  |                 <span>Save</span> <span class="icon"><i class="fas fa-save"></i></span> | ||||||
|  |             </button> | ||||||
|  |             <button class="button is-warning disable-enable"> | ||||||
|  |             </button> | ||||||
|  |             <button class="button is-danger delete-reminder"> | ||||||
|  |                 Delete | ||||||
|  |             </button> | ||||||
|  |         </div> | ||||||
|  |     {% endif %} | ||||||
| </div> | </div> | ||||||
| @@ -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/templates/guild_reminder" %} |         {% include "reminder_dashboard/guild_reminder" %} | ||||||
|         {% set creating = false %} |         {% set creating = false %} | ||||||
|     </div> |     </div> | ||||||
|     <br> |     <br> | ||||||
| @@ -46,10 +46,6 @@ | |||||||
|     <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> | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| <div id="reminderLog"> | <div> | ||||||
|  |  | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <script src="/static/js/reminder_errors.js"></script> | <!--<script src="/static/js/reminder_errors.js"></script>--> | ||||||
|   | |||||||
| @@ -1,17 +0,0 @@ | |||||||
| <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="reminderTime"> |  | ||||||
|  |  | ||||||
|         </div> |  | ||||||
|     </div> |  | ||||||
| </div> |  | ||||||
		Reference in New Issue
	
	Block a user