Compare commits
	
		
			10 Commits
		
	
	
		
			fc02eaea4a
			...
			jude/orpha
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 5f703e8538 | ||
|  | 2993505a47 | ||
|  | b225ad7e45 | ||
|  | ee89cb40c5 | ||
|  | b6b5e6d2b2 | ||
|  | adf29dca5d | ||
|  | ea3fe3f543 | ||
|  | 109cf16dbb | ||
|  | 6726ca0c2d | ||
| 38133be15d | 
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -3,5 +3,3 @@ | ||||
| /venv | ||||
| .cargo | ||||
| /.idea | ||||
| web/static/index.html | ||||
| web/static/assets | ||||
|   | ||||
							
								
								
									
										3
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
								
							| @@ -1,3 +0,0 @@ | ||||
| [submodule "reminder-dashboard"] | ||||
| 	path = reminder-dashboard | ||||
| 	url = gitea@gitea.jellypro.xyz:jude/reminder-dashboard | ||||
							
								
								
									
										1495
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1495
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										11
									
								
								Cargo.toml
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								Cargo.toml
									
									
									
									
									
								
							| @@ -1,18 +1,18 @@ | ||||
| [package] | ||||
| name = "reminder-rs" | ||||
| version = "1.6.48" | ||||
| version = "1.6.38" | ||||
| authors = ["Jude Southworth <judesouthworth@pm.me>"] | ||||
| edition = "2021" | ||||
| license = "AGPL-3.0 only" | ||||
| description = "Reminder Bot for Discord, now in Rust" | ||||
|  | ||||
| [dependencies] | ||||
| poise = "0.5" | ||||
| poise = "0.5.5" | ||||
| dotenv = "0.15" | ||||
| tokio = { version = "1", features = ["process", "full"] } | ||||
| reqwest = "0.11" | ||||
| lazy-regex = "3.0.2" | ||||
| regex = "1.9" | ||||
| lazy-regex = "2.3.0" | ||||
| regex = "1.6" | ||||
| log = "0.4" | ||||
| env_logger = "0.10" | ||||
| chrono = "0.4" | ||||
| @@ -25,7 +25,7 @@ serde_repr = "0.1" | ||||
| rmp-serde = "1.1" | ||||
| rand = "0.8" | ||||
| levenshtein = "1.0" | ||||
| sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]} | ||||
| sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]} | ||||
| base64 = "0.21.0" | ||||
|  | ||||
| [dependencies.postman] | ||||
| @@ -43,7 +43,6 @@ assets = [ | ||||
|     ["conf/default.env", "etc/reminder-rs/config.env", "600"], | ||||
|     ["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"], | ||||
|     ["web/static/**/*", "lib/reminder-rs/static", "644"], | ||||
|     ["reminder-dashboard/dist/static/**/*", "lib/reminder-rs/static", "644"], | ||||
|     ["web/templates/**/*", "lib/reminder-rs/templates", "644"], | ||||
|     ["healthcheck", "lib/reminder-rs/healthcheck", "755"], | ||||
|     ["cron.d/reminder_health", "etc/cron.d/reminder_health", "644"], | ||||
|   | ||||
							
								
								
									
										19
									
								
								migrations/20230812111348_orphan_reminders.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								migrations/20230812111348_orphan_reminders.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| -- Drop existing constraint | ||||
| ALTER TABLE `reminders` DROP CONSTRAINT `reminders_ibfk_1`; | ||||
|  | ||||
| ALTER TABLE `reminders` MODIFY COLUMN `channel_id` INT UNSIGNED; | ||||
| ALTER TABLE `reminders` ADD COLUMN `guild_id` INT UNSIGNED; | ||||
|  | ||||
| ALTER TABLE `reminders` | ||||
|     ADD CONSTRAINT `guild_id_fk` | ||||
|         FOREIGN KEY (`guild_id`) | ||||
|         REFERENCES `guilds`(`id`) | ||||
|         ON DELETE CASCADE; | ||||
|  | ||||
| ALTER TABLE `reminders` | ||||
|     ADD CONSTRAINT `channel_id_fk` | ||||
|         FOREIGN KEY (`channel_id`) | ||||
|         REFERENCES `channels`(`id`) | ||||
|         ON DELETE SET NULL; | ||||
|  | ||||
| UPDATE `reminders` SET `guild_id` = (SELECT guilds.`id` FROM `channels` INNER JOIN `guilds` ON channels.guild_id = guilds.id WHERE reminders.channel_id = channels.id); | ||||
							
								
								
									
										4
									
								
								migrations/20230903131153_reminder_status_timing.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								migrations/20230903131153_reminder_status_timing.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| ALTER TABLE reminders ADD COLUMN `status_change_time` DATETIME; | ||||
|  | ||||
| -- This is a best-guess as to the status change time. | ||||
| UPDATE reminders SET `status_change_time` = `utc_time`; | ||||
| @@ -1,3 +0,0 @@ | ||||
| ALTER TABLE `reminder_template` ADD COLUMN `interval_seconds` INT UNSIGNED; | ||||
| ALTER TABLE `reminder_template` ADD COLUMN `interval_days` INT UNSIGNED; | ||||
| ALTER TABLE `reminder_template` ADD COLUMN `interval_months` INT UNSIGNED; | ||||
| @@ -5,12 +5,12 @@ edition = "2021" | ||||
|  | ||||
| [dependencies] | ||||
| tokio = { version = "1", features = ["process", "full"] } | ||||
| regex = "1.9" | ||||
| regex = "1.4" | ||||
| log = "0.4" | ||||
| chrono = "0.4" | ||||
| chrono-tz = { version = "0.8", features = ["serde"] } | ||||
| chrono-tz = { version = "0.5", features = ["serde"] } | ||||
| lazy_static = "1.4" | ||||
| num-integer = "0.1" | ||||
| serde = "1.0" | ||||
| sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]} | ||||
| serenity = { version = "0.11", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } | ||||
| sqlx = { version = "0.6", 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"] } | ||||
|   | ||||
| @@ -310,6 +310,7 @@ WHERE | ||||
|             reminders | ||||
|         WHERE | ||||
|             reminders.`utc_time` <= NOW() AND | ||||
|             reminders.`channel_id` IS NOT NULL AND | ||||
|             `status` = 'pending' AND | ||||
|             ( | ||||
|                 reminders.`interval_seconds` IS NOT NULL | ||||
| @@ -471,10 +472,17 @@ WHERE | ||||
|     } | ||||
|  | ||||
|     async fn set_sent(&self, pool: impl Executor<'_, Database = Database> + Copy) { | ||||
|         sqlx::query!("UPDATE reminders SET `status` = 'sent' WHERE `id` = ?", self.id) | ||||
|             .execute(pool) | ||||
|             .await | ||||
|             .expect(&format!("Could not delete Reminder {}", self.id)); | ||||
|         sqlx::query!( | ||||
|             " | ||||
|             UPDATE reminders | ||||
|             SET `status` = 'sent', `status_change_time` = NOW() | ||||
|             WHERE `id` = ? | ||||
|             ", | ||||
|             self.id | ||||
|         ) | ||||
|         .execute(pool) | ||||
|         .await | ||||
|         .expect(&format!("Could not delete Reminder {}", self.id)); | ||||
|     } | ||||
|  | ||||
|     async fn set_failed( | ||||
|   | ||||
 Submodule reminder-dashboard deleted from 8ba7a39ce5
									
								
							| @@ -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 = ?)", | ||||
|         guild_id.0 | ||||
|     ) | ||||
|     .fetch_all(&mut *transaction) | ||||
|     .fetch_all(&mut transaction) | ||||
|     .await?; | ||||
|  | ||||
|     let mut added_aliases = 0; | ||||
| @@ -42,7 +42,7 @@ pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> { | ||||
|                     cmd_macro.description, | ||||
|                     cmd_macro.commands | ||||
|                 ) | ||||
|                 .execute(&mut *transaction) | ||||
|                 .execute(&mut transaction) | ||||
|                 .await?; | ||||
|  | ||||
|                 added_aliases += 1; | ||||
|   | ||||
| @@ -619,7 +619,7 @@ pub async fn multiline( | ||||
| )] | ||||
| pub async fn remind( | ||||
|     ctx: ApplicationContext<'_>, | ||||
|     #[description = "The time (and optionally date) to set the reminder for"] | ||||
|     #[description = "A description of the time to set the reminder for"] | ||||
|     #[autocomplete = "time_hint_autocomplete"] | ||||
|     time: String, | ||||
|     #[description = "The message content to send"] content: String, | ||||
| @@ -773,7 +773,7 @@ async fn create_reminder( | ||||
|                                     b.emoji(ReactionType::Unicode("📝".to_string())) | ||||
|                                         .label("Edit") | ||||
|                                         .style(ButtonStyle::Link) | ||||
|                                         .url("https://beta.reminder-bot.com/dashboard") | ||||
|                                         .url("https://reminder-bot.com/dashboard") | ||||
|                                 }) | ||||
|                             }) | ||||
|                         }) | ||||
|   | ||||
| @@ -166,15 +166,21 @@ impl ComponentDataModel { | ||||
|                     .await; | ||||
|             } | ||||
|             ComponentDataModel::DelSelector(selector) => { | ||||
|                 let selected_id = component.data.values.join(","); | ||||
|                 for id in &component.data.values { | ||||
|                     match id.parse::<u32>() { | ||||
|                         Ok(id) => { | ||||
|                             if let Some(reminder) = Reminder::from_id(&data.database, id).await { | ||||
|                                 reminder.delete(&data.database).await.unwrap(); | ||||
|                             } else { | ||||
|                                 warn!("Attempt to delete non-existent reminder"); | ||||
|                             } | ||||
|                         } | ||||
|  | ||||
|                 sqlx::query!( | ||||
|                     "UPDATE reminders SET `status` = 'deleted' WHERE FIND_IN_SET(id, ?)", | ||||
|                     selected_id | ||||
|                 ) | ||||
|                 .execute(&data.database) | ||||
|                 .await | ||||
|                 .unwrap(); | ||||
|                         Err(e) => { | ||||
|                             warn!("Error casting ID to integer: {:?}.", e); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 let reminders = Reminder::from_guild( | ||||
|                     &ctx, | ||||
|   | ||||
| @@ -150,7 +150,7 @@ impl<'a> Parser<'a> { | ||||
|             "hours" | "hour" | "hr" | "hrs" | "h" => (0, 0, n.mul(3600)?, 0), | ||||
|             "days" | "day" | "d" => (0, n, 0, 0), | ||||
|             "weeks" | "week" | "w" => (0, n.mul(7)?, 0, 0), | ||||
|             "months" | "month" => (n, 0, 0, 0), | ||||
|             "months" | "month" | "M" => (n, 0, 0, 0), | ||||
|             "years" | "year" | "y" => (n.mul(12)?, 0, 0, 0), | ||||
|             _ => { | ||||
|                 return Err(Error::UnknownUnit { | ||||
| @@ -255,7 +255,7 @@ impl<'a> Parser<'a> { | ||||
| /// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000))); | ||||
| /// ``` | ||||
| pub fn parse_duration(s: &str) -> Result<Interval, Error> { | ||||
|     Parser { iter: s.to_lowercase().chars(), src: &s.to_lowercase(), current: (0, 0, 0, 0) }.parse() | ||||
|     Parser { iter: s.chars(), src: s, current: (0, 0, 0, 0) }.parse() | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| @@ -324,13 +324,4 @@ mod tests { | ||||
|         assert_eq!(interval.day, 0); | ||||
|         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,6 +10,7 @@ pub struct ChannelData { | ||||
|     pub webhook_id: Option<u64>, | ||||
|     pub webhook_token: Option<String>, | ||||
|     pub paused: bool, | ||||
|     pub db_guild_id: Option<u32>, | ||||
|     pub paused_until: Option<NaiveDateTime>, | ||||
| } | ||||
|  | ||||
| @@ -22,7 +23,11 @@ impl ChannelData { | ||||
|  | ||||
|         if let Ok(c) = sqlx::query_as_unchecked!( | ||||
|             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 | ||||
|         ) | ||||
|         .fetch_one(pool) | ||||
| @@ -30,12 +35,18 @@ impl ChannelData { | ||||
|         { | ||||
|             Ok(c) | ||||
|         } else { | ||||
|             let props = channel.to_owned().guild().map(|g| (g.guild_id.as_u64().to_owned(), g.name)); | ||||
|             let props = | ||||
|                 channel.to_owned().guild().map(|g| (g.guild_id.as_u64().to_owned(), g.name)); | ||||
|  | ||||
|             let (guild_id, channel_name) = if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) }; | ||||
|             let (guild_id, channel_name) = | ||||
|                 if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) }; | ||||
|  | ||||
|             sqlx::query!( | ||||
|                 "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_name, | ||||
|                 guild_id | ||||
| @@ -46,7 +57,10 @@ impl ChannelData { | ||||
|             Ok(sqlx::query_as_unchecked!( | ||||
|                 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 | ||||
|             ) | ||||
| @@ -58,9 +72,10 @@ SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_u | ||||
|     pub async fn commit_changes(&self, pool: &MySqlPool) { | ||||
|         sqlx::query!( | ||||
|             " | ||||
| UPDATE channels SET name = ?, nudge = ?, blacklisted = ?, webhook_id = ?, webhook_token = ?, paused = ?, paused_until \ | ||||
|              = ? WHERE id = ? | ||||
|             ", | ||||
|             UPDATE channels | ||||
|             SET name = ?, nudge = ?, blacklisted = ?, webhook_id = ?, webhook_token = ?, | ||||
|                 paused = ?, paused_until = ? | ||||
|             WHERE id = ?", | ||||
|             self.name, | ||||
|             self.nudge, | ||||
|             self.blacklisted, | ||||
|   | ||||
| @@ -51,6 +51,7 @@ pub struct ReminderBuilder { | ||||
|     pool: MySqlPool, | ||||
|     uid: String, | ||||
|     channel: u32, | ||||
|     guild: Option<u32>, | ||||
|     thread_id: Option<u64>, | ||||
|     utc_time: NaiveDateTime, | ||||
|     timezone: String, | ||||
| @@ -86,6 +87,7 @@ impl ReminderBuilder { | ||||
| INSERT INTO reminders ( | ||||
|     `uid`, | ||||
|     `channel_id`, | ||||
|     `guild_id`, | ||||
|     `utc_time`, | ||||
|     `timezone`, | ||||
|     `interval_seconds`, | ||||
| @@ -110,11 +112,13 @@ INSERT INTO reminders ( | ||||
|     ?, | ||||
|     ?, | ||||
|     ?, | ||||
|     ?, | ||||
|     ? | ||||
| ) | ||||
|             ", | ||||
|                         self.uid, | ||||
|                         self.channel, | ||||
|                         self.guild, | ||||
|                         utc_time, | ||||
|                         self.timezone, | ||||
|                         self.interval_seconds, | ||||
| @@ -247,10 +251,10 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|                                 { | ||||
|                                     Err(ReminderError::UserBlockedDm) | ||||
|                                 } else { | ||||
|                                     Ok(user_data.dm_channel) | ||||
|                                     Ok((user_data.dm_channel, None)) | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 Ok(user_data.dm_channel) | ||||
|                                 Ok((user_data.dm_channel, None)) | ||||
|                             } | ||||
|                         } else { | ||||
|                             Err(ReminderError::InvalidTag) | ||||
| @@ -297,13 +301,13 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|                                                 .commit_changes(&self.ctx.data().database) | ||||
|                                                 .await; | ||||
|  | ||||
|                                             Ok(channel_data.id) | ||||
|                                             Ok((channel_data.id, channel_data.db_guild_id)) | ||||
|                                         } | ||||
|  | ||||
|                                         Err(e) => Err(ReminderError::DiscordError(e.to_string())), | ||||
|                                     } | ||||
|                                 } else { | ||||
|                                     Ok(channel_data.id) | ||||
|                                     Ok((channel_data.id, channel_data.db_guild_id)) | ||||
|                                 } | ||||
|                             } | ||||
|                         } else { | ||||
| @@ -317,7 +321,8 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|                         let builder = ReminderBuilder { | ||||
|                             pool: self.ctx.data().database.clone(), | ||||
|                             uid: generate_uid(), | ||||
|                             channel: c, | ||||
|                             channel: c.0, | ||||
|                             guild: c.1, | ||||
|                             thread_id, | ||||
|                             utc_time: self.utc_time, | ||||
|                             timezone: self.timezone.to_string(), | ||||
|   | ||||
| @@ -304,10 +304,13 @@ WHERE | ||||
|         &self, | ||||
|         db: impl Executor<'_, Database = Database>, | ||||
|     ) -> Result<(), sqlx::Error> { | ||||
|         sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", self.uid) | ||||
|             .execute(db) | ||||
|             .await | ||||
|             .map(|_| ()) | ||||
|         sqlx::query!( | ||||
|             "UPDATE reminders SET `status` = 'deleted', `status_change_time` = NOW() WHERE uid = ?", | ||||
|             self.uid | ||||
|         ) | ||||
|         .execute(db) | ||||
|         .await | ||||
|         .map(|_| ()) | ||||
|     } | ||||
|  | ||||
|     pub fn display_content(&self) -> &str { | ||||
|   | ||||
| @@ -1,22 +1,21 @@ | ||||
| [package] | ||||
| name = "reminder_web" | ||||
| version = "0.1.3" | ||||
| version = "0.1.0" | ||||
| authors = ["jellywx <judesouthworth@pm.me>"] | ||||
| edition = "2018" | ||||
|  | ||||
| [dependencies] | ||||
| 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"] } | ||||
| serenity = { version = "0.11", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } | ||||
| serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } | ||||
| oauth2 = "4" | ||||
| log = "0.4" | ||||
| reqwest = "0.11" | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] } | ||||
| sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] } | ||||
| chrono = "0.4" | ||||
| chrono-tz = "0.8" | ||||
| chrono-tz = "0.5" | ||||
| lazy_static = "1.4.0" | ||||
| rand = "0.8" | ||||
| rand = "0.7" | ||||
| base64 = "0.13" | ||||
| csv = "1.2" | ||||
| prometheus = "0.13.3" | ||||
| csv = "1.1" | ||||
|   | ||||
| @@ -1,40 +0,0 @@ | ||||
| use std::collections::HashMap; | ||||
|  | ||||
| use rocket::serde::json::json; | ||||
| use rocket_dyn_templates::Template; | ||||
|  | ||||
| use crate::JsonValue; | ||||
|  | ||||
| #[catch(403)] | ||||
| pub(crate) async fn forbidden() -> Template { | ||||
|     let map: HashMap<String, String> = HashMap::new(); | ||||
|     Template::render("errors/403", &map) | ||||
| } | ||||
|  | ||||
| #[catch(500)] | ||||
| pub(crate) async fn internal_server_error() -> Template { | ||||
|     let map: HashMap<String, String> = HashMap::new(); | ||||
|     Template::render("errors/500", &map) | ||||
| } | ||||
|  | ||||
| #[catch(401)] | ||||
| pub(crate) async fn not_authorized() -> Template { | ||||
|     let map: HashMap<String, String> = HashMap::new(); | ||||
|     Template::render("errors/401", &map) | ||||
| } | ||||
|  | ||||
| #[catch(404)] | ||||
| pub(crate) async fn not_found() -> Template { | ||||
|     let map: HashMap<String, String> = HashMap::new(); | ||||
|     Template::render("errors/404", &map) | ||||
| } | ||||
|  | ||||
| #[catch(413)] | ||||
| pub(crate) async fn payload_too_large() -> JsonValue { | ||||
|     json!({"error": "Data too large.", "errors": ["Data too large."]}) | ||||
| } | ||||
|  | ||||
| #[catch(422)] | ||||
| pub(crate) async fn unprocessable_entity() -> JsonValue { | ||||
|     json!({"error": "Invalid request.", "errors": ["Invalid request."]}) | ||||
| } | ||||
| @@ -1 +0,0 @@ | ||||
| pub(crate) mod transaction; | ||||
| @@ -1,44 +0,0 @@ | ||||
| use rocket::{ | ||||
|     http::Status, | ||||
|     request::{FromRequest, Outcome}, | ||||
|     Request, State, | ||||
| }; | ||||
| use sqlx::Pool; | ||||
|  | ||||
| use crate::Database; | ||||
|  | ||||
| pub struct Transaction<'a>(sqlx::Transaction<'a, Database>); | ||||
|  | ||||
| impl Transaction<'_> { | ||||
|     pub fn executor(&mut self) -> impl sqlx::Executor<'_, Database = Database> { | ||||
|         &mut *(self.0) | ||||
|     } | ||||
|  | ||||
|     pub async fn commit(self) -> Result<(), sqlx::Error> { | ||||
|         self.0.commit().await | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub enum TransactionError { | ||||
|     Error(sqlx::Error), | ||||
|     Missing, | ||||
| } | ||||
|  | ||||
| #[rocket::async_trait] | ||||
| impl<'r> FromRequest<'r> for Transaction<'r> { | ||||
|     type Error = TransactionError; | ||||
|  | ||||
|     async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> { | ||||
|         match request.guard::<&State<Pool<Database>>>().await { | ||||
|             Outcome::Success(pool) => match pool.begin().await { | ||||
|                 Ok(transaction) => Outcome::Success(Transaction(transaction)), | ||||
|                 Err(e) => { | ||||
|                     Outcome::Failure((Status::InternalServerError, TransactionError::Error(e))) | ||||
|                 } | ||||
|             }, | ||||
|             Outcome::Failure(e) => Outcome::Failure((e.0, TransactionError::Missing)), | ||||
|             Outcome::Forward(f) => Outcome::Forward(f), | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										158
									
								
								web/src/lib.rs
									
									
									
									
									
								
							
							
						
						
									
										158
									
								
								web/src/lib.rs
									
									
									
									
									
								
							| @@ -4,17 +4,13 @@ extern crate rocket; | ||||
| mod consts; | ||||
| #[macro_use] | ||||
| mod macros; | ||||
| mod catchers; | ||||
| mod guards; | ||||
| mod metrics; | ||||
| mod routes; | ||||
|  | ||||
| use std::{env, path::Path}; | ||||
| use std::{collections::HashMap, env, path::Path}; | ||||
|  | ||||
| use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; | ||||
| use rocket::{ | ||||
|     fs::FileServer, | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Value as JsonValue}, | ||||
|     tokio::sync::broadcast::Sender, | ||||
| }; | ||||
| @@ -26,10 +22,7 @@ use serenity::{ | ||||
| }; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::{ | ||||
|     consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES}, | ||||
|     metrics::{init_metrics, MetricProducer}, | ||||
| }; | ||||
| use crate::consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES}; | ||||
|  | ||||
| type Database = MySql; | ||||
|  | ||||
| @@ -39,6 +32,40 @@ enum 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( | ||||
|     kill_channel: Sender<()>, | ||||
|     serenity_context: Context, | ||||
| @@ -68,20 +95,17 @@ pub async fn initialize( | ||||
|     let static_path = | ||||
|         if Path::new("web/static").exists() { "web/static" } else { "/lib/reminder-rs/static" }; | ||||
|  | ||||
|     init_metrics(); | ||||
|  | ||||
|     rocket::build() | ||||
|         .attach(MetricProducer) | ||||
|         .attach(Template::fairing()) | ||||
|         .register( | ||||
|             "/", | ||||
|             catchers![ | ||||
|                 catchers::not_authorized, | ||||
|                 catchers::forbidden, | ||||
|                 catchers::not_found, | ||||
|                 catchers::internal_server_error, | ||||
|                 catchers::unprocessable_entity, | ||||
|                 catchers::payload_too_large, | ||||
|                 not_authorized, | ||||
|                 forbidden, | ||||
|                 not_found, | ||||
|                 internal_server_error, | ||||
|                 unprocessable_entity, | ||||
|                 payload_too_large, | ||||
|             ], | ||||
|         ) | ||||
|         .manage(oauth2_client) | ||||
| @@ -92,13 +116,12 @@ pub async fn initialize( | ||||
|         .mount( | ||||
|             "/", | ||||
|             routes![ | ||||
|                 routes::cookies, | ||||
|                 routes::index, | ||||
|                 routes::metrics::metrics, | ||||
|                 routes::cookies, | ||||
|                 routes::privacy, | ||||
|                 routes::report::report_error, | ||||
|                 routes::return_to_same_site, | ||||
|                 routes::terms, | ||||
|                 routes::return_to_same_site, | ||||
|                 routes::report::report_error, | ||||
|             ], | ||||
|         ) | ||||
|         .mount( | ||||
| @@ -127,21 +150,22 @@ pub async fn initialize( | ||||
|         .mount( | ||||
|             "/dashboard", | ||||
|             routes![ | ||||
|                 routes::dashboard::dashboard, | ||||
|                 routes::dashboard::dashboard_1, | ||||
|                 routes::dashboard::dashboard_2, | ||||
|                 routes::dashboard::dashboard_home, | ||||
|                 routes::dashboard::api::user::get_user_info, | ||||
|                 routes::dashboard::api::user::update_user_info, | ||||
|                 routes::dashboard::api::user::get_user_guilds, | ||||
|                 routes::dashboard::api::guild::get_guild_info, | ||||
|                 routes::dashboard::api::guild::get_guild_channels, | ||||
|                 routes::dashboard::api::guild::get_guild_roles, | ||||
|                 routes::dashboard::api::guild::get_reminder_templates, | ||||
|                 routes::dashboard::api::guild::create_reminder_template, | ||||
|                 routes::dashboard::api::guild::delete_reminder_template, | ||||
|                 routes::dashboard::api::guild::create_guild_reminder, | ||||
|                 routes::dashboard::api::guild::get_reminders, | ||||
|                 routes::dashboard::api::guild::edit_reminder, | ||||
|                 routes::dashboard::api::guild::delete_reminder, | ||||
|                 routes::dashboard::user::get_user_info, | ||||
|                 routes::dashboard::user::update_user_info, | ||||
|                 routes::dashboard::user::get_user_guilds, | ||||
|                 routes::dashboard::guild::get_guild_patreon, | ||||
|                 routes::dashboard::guild::get_guild_channels, | ||||
|                 routes::dashboard::guild::get_guild_roles, | ||||
|                 routes::dashboard::guild::get_reminder_templates, | ||||
|                 routes::dashboard::guild::create_reminder_template, | ||||
|                 routes::dashboard::guild::delete_reminder_template, | ||||
|                 routes::dashboard::guild::create_guild_reminder, | ||||
|                 routes::dashboard::guild::get_reminders, | ||||
|                 routes::dashboard::guild::edit_reminder, | ||||
|                 routes::dashboard::guild::delete_reminder, | ||||
|                 routes::dashboard::export::export_reminders, | ||||
|                 routes::dashboard::export::export_reminder_templates, | ||||
|                 routes::dashboard::export::export_todos, | ||||
| @@ -199,65 +223,3 @@ pub async fn check_guild_subscription( | ||||
|         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,6 +54,56 @@ macro_rules! check_url_opt { | ||||
|     }; | ||||
| } | ||||
|  | ||||
| macro_rules! check_authorization { | ||||
|     ($cookies:expr, $ctx:expr, $guild:expr) => { | ||||
|         use serenity::model::id::UserId; | ||||
|  | ||||
|         let user_id = $cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten(); | ||||
|  | ||||
|         if std::env::var("OFFLINE").map_or(true, |v| v != "1") { | ||||
|             match user_id { | ||||
|                 Some(user_id) => { | ||||
|                     match GuildId($guild).to_guild_cached($ctx) { | ||||
|                         Some(guild) => { | ||||
|                             let member_res = guild.member($ctx, UserId(user_id)).await; | ||||
|  | ||||
|                             match member_res { | ||||
|                                 Err(_) => { | ||||
|                                     return Err(json!({"error": "User not in guild"})); | ||||
|                                 } | ||||
|  | ||||
|                                 Ok(member) => { | ||||
|                                     let permissions_res = member.permissions($ctx); | ||||
|  | ||||
|                                     match permissions_res { | ||||
|                                         Err(_) => { | ||||
|                                             return Err(json!({"error": "Couldn't fetch permissions"})); | ||||
|                                         } | ||||
|  | ||||
|                                         Ok(permissions) => { | ||||
|                                             if !(permissions.manage_messages() || permissions.manage_guild() || permissions.administrator()) { | ||||
|                                                 return Err(json!({"error": "Incorrect permissions"})); | ||||
|                                             } | ||||
|                                         } | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|  | ||||
|                         None => { | ||||
|                             return Err(json!({"error": "Bot not in guild"})); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 None => { | ||||
|                     return Err(json!({"error": "User not authorized"})); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| macro_rules! update_field { | ||||
|     ($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => { | ||||
|         if let Some(value) = &$reminder.$field { | ||||
|   | ||||
| @@ -1,43 +0,0 @@ | ||||
| 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(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,61 +0,0 @@ | ||||
| use rocket::{http::CookieJar, serde::json::json, State}; | ||||
| use serde::Serialize; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::{ | ||||
|         channel::GuildChannel, | ||||
|         id::{ChannelId, GuildId}, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| use crate::{check_authorization, routes::JsonResult}; | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct ChannelInfo { | ||||
|     id: String, | ||||
|     name: String, | ||||
|     webhook_avatar: Option<String>, | ||||
|     webhook_name: Option<String>, | ||||
| } | ||||
|  | ||||
| #[get("/api/guild/<id>/channels")] | ||||
| pub async fn get_guild_channels( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
| ) -> JsonResult { | ||||
|     offline!(Ok(json!(vec![ChannelInfo { | ||||
|         name: "general".to_string(), | ||||
|         id: "1".to_string(), | ||||
|         webhook_avatar: None, | ||||
|         webhook_name: None, | ||||
|     }]))); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     match GuildId(id).to_guild_cached(ctx.inner()) { | ||||
|         Some(guild) => { | ||||
|             let mut channels = guild | ||||
|                 .channels | ||||
|                 .iter() | ||||
|                 .filter_map(|(id, channel)| channel.to_owned().guild().map(|c| (id.to_owned(), c))) | ||||
|                 .filter(|(_, channel)| channel.is_text_based()) | ||||
|                 .collect::<Vec<(ChannelId, GuildChannel)>>(); | ||||
|  | ||||
|             channels.sort_by(|(_, c1), (_, c2)| c1.position.cmp(&c2.position)); | ||||
|  | ||||
|             let channel_info = channels | ||||
|                 .iter() | ||||
|                 .map(|(channel_id, channel)| ChannelInfo { | ||||
|                     name: channel.name.to_string(), | ||||
|                     id: channel_id.to_string(), | ||||
|                     webhook_avatar: None, | ||||
|                     webhook_name: None, | ||||
|                 }) | ||||
|                 .collect::<Vec<ChannelInfo>>(); | ||||
|  | ||||
|             Ok(json!(channel_info)) | ||||
|         } | ||||
|  | ||||
|         None => json_err!("Bot not in guild"), | ||||
|     } | ||||
| } | ||||
| @@ -1,42 +0,0 @@ | ||||
| mod channels; | ||||
| mod reminders; | ||||
| mod roles; | ||||
| mod templates; | ||||
|  | ||||
| use std::env; | ||||
|  | ||||
| pub use channels::*; | ||||
| pub use reminders::*; | ||||
| use rocket::{http::CookieJar, serde::json::json, State}; | ||||
| pub use roles::*; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::id::{GuildId, RoleId}, | ||||
| }; | ||||
| pub use templates::*; | ||||
|  | ||||
| use crate::{check_authorization, routes::JsonResult}; | ||||
|  | ||||
| #[get("/api/guild/<id>")] | ||||
| pub async fn get_guild_info(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult { | ||||
|     offline!(Ok(json!({ "patreon": true, "name": "Guild" }))); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     match GuildId(id).to_guild_cached(ctx.inner()) { | ||||
|         Some(guild) => { | ||||
|             let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap()) | ||||
|                 .member(&ctx.inner(), guild.owner_id) | ||||
|                 .await; | ||||
|  | ||||
|             let patreon = member_res.map_or(false, |member| { | ||||
|                 member | ||||
|                     .roles | ||||
|                     .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap())) | ||||
|             }); | ||||
|  | ||||
|             Ok(json!({ "patreon": patreon, "name": guild.name })) | ||||
|         } | ||||
|  | ||||
|         None => json_err!("Bot not in guild"), | ||||
|     } | ||||
| } | ||||
| @@ -1,373 +0,0 @@ | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Json}, | ||||
|     State, | ||||
| }; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::id::{ChannelId, GuildId, UserId}, | ||||
| }; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::{ | ||||
|     check_authorization, check_guild_subscription, check_subscription, | ||||
|     consts::MIN_INTERVAL, | ||||
|     guards::transaction::Transaction, | ||||
|     routes::{ | ||||
|         dashboard::{ | ||||
|             create_database_channel, create_reminder, DeleteReminder, PatchReminder, Reminder, | ||||
|         }, | ||||
|         JsonResult, | ||||
|     }, | ||||
|     Database, | ||||
| }; | ||||
|  | ||||
| #[post("/api/guild/<id>/reminders", data = "<reminder>")] | ||||
| pub async fn create_guild_reminder( | ||||
|     id: u64, | ||||
|     reminder: Json<Reminder>, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     mut transaction: Transaction<'_>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     let user_id = | ||||
|         cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap(); | ||||
|  | ||||
|     match create_reminder( | ||||
|         ctx.inner(), | ||||
|         &mut transaction, | ||||
|         GuildId(id), | ||||
|         UserId(user_id), | ||||
|         reminder.into_inner(), | ||||
|     ) | ||||
|     .await | ||||
|     { | ||||
|         Ok(r) => match transaction.commit().await { | ||||
|             Ok(_) => Ok(r), | ||||
|             Err(e) => { | ||||
|                 warn!("Couldn't commit transaction: {:?}", e); | ||||
|                 json_err!("Couldn't commit transaction.") | ||||
|             } | ||||
|         }, | ||||
|  | ||||
|         Err(e) => Err(e), | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[get("/api/guild/<id>/reminders")] | ||||
| pub async fn get_reminders( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     let channels_res = GuildId(id).channels(&ctx.inner()).await; | ||||
|  | ||||
|     match channels_res { | ||||
|         Ok(channels) => { | ||||
|             let channels = channels | ||||
|                 .keys() | ||||
|                 .into_iter() | ||||
|                 .map(|k| k.as_u64().to_string()) | ||||
|                 .collect::<Vec<String>>() | ||||
|                 .join(","); | ||||
|  | ||||
|             sqlx::query_as_unchecked!( | ||||
|                 Reminder, | ||||
|                 "SELECT | ||||
|                  reminders.attachment, | ||||
|                  reminders.attachment_name, | ||||
|                  reminders.avatar, | ||||
|                  channels.channel, | ||||
|                  reminders.content, | ||||
|                  reminders.embed_author, | ||||
|                  reminders.embed_author_url, | ||||
|                  reminders.embed_color, | ||||
|                  reminders.embed_description, | ||||
|                  reminders.embed_footer, | ||||
|                  reminders.embed_footer_url, | ||||
|                  reminders.embed_image_url, | ||||
|                  reminders.embed_thumbnail_url, | ||||
|                  reminders.embed_title, | ||||
|                  IFNULL(reminders.embed_fields, '[]') AS embed_fields, | ||||
|                  reminders.enabled, | ||||
|                  reminders.expires, | ||||
|                  reminders.interval_seconds, | ||||
|                  reminders.interval_days, | ||||
|                  reminders.interval_months, | ||||
|                  reminders.name, | ||||
|                  reminders.restartable, | ||||
|                  reminders.tts, | ||||
|                  reminders.uid, | ||||
|                  reminders.username, | ||||
|                  reminders.utc_time | ||||
|                 FROM reminders | ||||
|                 LEFT JOIN channels ON channels.id = reminders.channel_id | ||||
|                 WHERE `status` = 'pending' AND FIND_IN_SET(channels.channel, ?)", | ||||
|                 channels | ||||
|             ) | ||||
|             .fetch_all(pool.inner()) | ||||
|             .await | ||||
|             .map(|r| Ok(json!(r))) | ||||
|             .unwrap_or_else(|e| { | ||||
|                 warn!("Failed to complete SQL query: {:?}", e); | ||||
|  | ||||
|                 json_err!("Could not load reminders") | ||||
|             }) | ||||
|         } | ||||
|         Err(e) => { | ||||
|             warn!("Could not fetch channels from {}: {:?}", id, e); | ||||
|  | ||||
|             Ok(json!([])) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[patch("/api/guild/<id>/reminders", data = "<reminder>")] | ||||
| pub async fn edit_reminder( | ||||
|     id: u64, | ||||
|     reminder: Json<PatchReminder>, | ||||
|     ctx: &State<Context>, | ||||
|     mut transaction: Transaction<'_>, | ||||
|     pool: &State<Pool<Database>>, | ||||
|     cookies: &CookieJar<'_>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     let mut error = vec![]; | ||||
|  | ||||
|     let user_id = | ||||
|         cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap(); | ||||
|  | ||||
|     if reminder.message_ok() { | ||||
|         update_field!(transaction.executor(), error, reminder.[ | ||||
|             content, | ||||
|             embed_author, | ||||
|             embed_description, | ||||
|             embed_footer, | ||||
|             embed_title, | ||||
|             embed_fields, | ||||
|             username | ||||
|         ]); | ||||
|     } else { | ||||
|         error.push("Message exceeds limits.".to_string()); | ||||
|     } | ||||
|  | ||||
|     update_field!(transaction.executor(), error, reminder.[ | ||||
|         attachment, | ||||
|         attachment_name, | ||||
|         avatar, | ||||
|         embed_author_url, | ||||
|         embed_color, | ||||
|         embed_footer_url, | ||||
|         embed_image_url, | ||||
|         embed_thumbnail_url, | ||||
|         enabled, | ||||
|         expires, | ||||
|         name, | ||||
|         restartable, | ||||
|         tts, | ||||
|         utc_time | ||||
|     ]); | ||||
|  | ||||
|     if reminder.interval_days.flatten().is_some() | ||||
|         || reminder.interval_months.flatten().is_some() | ||||
|         || reminder.interval_seconds.flatten().is_some() | ||||
|     { | ||||
|         if check_guild_subscription(&ctx.inner(), id).await | ||||
|             || check_subscription(&ctx.inner(), user_id).await | ||||
|         { | ||||
|             let new_interval_length = match reminder.interval_days { | ||||
|                 Some(interval) => interval.unwrap_or(0), | ||||
|                 None => sqlx::query!( | ||||
|                     "SELECT interval_days AS days FROM reminders WHERE uid = ?", | ||||
|                     reminder.uid | ||||
|                 ) | ||||
|                 .fetch_one(transaction.executor()) | ||||
|                 .await | ||||
|                 .map_err(|e| { | ||||
|                     warn!("Error updating reminder interval: {:?}", e); | ||||
|                     json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) | ||||
|                 })? | ||||
|                 .days | ||||
|                 .unwrap_or(0), | ||||
|             } * 86400 + match reminder.interval_months { | ||||
|                 Some(interval) => interval.unwrap_or(0), | ||||
|                 None => sqlx::query!( | ||||
|                     "SELECT interval_months AS months FROM reminders WHERE uid = ?", | ||||
|                     reminder.uid | ||||
|                 ) | ||||
|                 .fetch_one(transaction.executor()) | ||||
|                 .await | ||||
|                 .map_err(|e| { | ||||
|                     warn!("Error updating reminder interval: {:?}", e); | ||||
|                     json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) | ||||
|                 })? | ||||
|                 .months | ||||
|                 .unwrap_or(0), | ||||
|             } * 2592000 + match reminder.interval_seconds { | ||||
|                 Some(interval) => interval.unwrap_or(0), | ||||
|                 None => sqlx::query!( | ||||
|                     "SELECT interval_seconds AS seconds FROM reminders WHERE uid = ?", | ||||
|                     reminder.uid | ||||
|                 ) | ||||
|                 .fetch_one(transaction.executor()) | ||||
|                 .await | ||||
|                 .map_err(|e| { | ||||
|                     warn!("Error updating reminder interval: {:?}", e); | ||||
|                     json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) | ||||
|                 })? | ||||
|                 .seconds | ||||
|                 .unwrap_or(0), | ||||
|             }; | ||||
|  | ||||
|             if new_interval_length < *MIN_INTERVAL { | ||||
|                 error.push(String::from("New interval is too short.")); | ||||
|             } else { | ||||
|                 update_field!(transaction.executor(), error, reminder.[ | ||||
|                     interval_days, | ||||
|                     interval_months, | ||||
|                     interval_seconds | ||||
|                 ]); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if reminder.channel > 0 { | ||||
|         let channel = ChannelId(reminder.channel).to_channel_cached(&ctx.inner()); | ||||
|         match channel { | ||||
|             Some(channel) => { | ||||
|                 let channel_matches_guild = channel.guild().map_or(false, |c| c.guild_id.0 == id); | ||||
|  | ||||
|                 if !channel_matches_guild { | ||||
|                     warn!( | ||||
|                         "Error in `edit_reminder`: channel {:?} not found for guild {}", | ||||
|                         reminder.channel, id | ||||
|                     ); | ||||
|  | ||||
|                     return Err(json!({"error": "Channel not found"})); | ||||
|                 } | ||||
|  | ||||
|                 let channel = create_database_channel( | ||||
|                     ctx.inner(), | ||||
|                     ChannelId(reminder.channel), | ||||
|                     &mut transaction, | ||||
|                 ) | ||||
|                 .await; | ||||
|  | ||||
|                 if let Err(e) = channel { | ||||
|                     warn!("`create_database_channel` returned an error code: {:?}", e); | ||||
|  | ||||
|                     return Err( | ||||
|                         json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}), | ||||
|                     ); | ||||
|                 } | ||||
|  | ||||
|                 let channel = channel.unwrap(); | ||||
|  | ||||
|                 match sqlx::query!( | ||||
|                     "UPDATE reminders SET channel_id = ? WHERE uid = ?", | ||||
|                     channel, | ||||
|                     reminder.uid | ||||
|                 ) | ||||
|                 .execute(transaction.executor()) | ||||
|                 .await | ||||
|                 { | ||||
|                     Ok(_) => {} | ||||
|                     Err(e) => { | ||||
|                         warn!("Error setting channel: {:?}", e); | ||||
|  | ||||
|                         error.push("Couldn't set channel".to_string()) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             None => { | ||||
|                 warn!( | ||||
|                     "Error in `edit_reminder`: channel {:?} not found for guild {}", | ||||
|                     reminder.channel, id | ||||
|                 ); | ||||
|  | ||||
|                 return Err(json!({"error": "Channel not found"})); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if let Err(e) = transaction.commit().await { | ||||
|         warn!("Couldn't commit transaction: {:?}", e); | ||||
|         return json_err!("Couldn't commit transaction"); | ||||
|     } | ||||
|  | ||||
|     match sqlx::query_as_unchecked!( | ||||
|         Reminder, | ||||
|         "SELECT reminders.attachment, | ||||
|          reminders.attachment_name, | ||||
|          reminders.avatar, | ||||
|          channels.channel, | ||||
|          reminders.content, | ||||
|          reminders.embed_author, | ||||
|          reminders.embed_author_url, | ||||
|          reminders.embed_color, | ||||
|          reminders.embed_description, | ||||
|          reminders.embed_footer, | ||||
|          reminders.embed_footer_url, | ||||
|          reminders.embed_image_url, | ||||
|          reminders.embed_thumbnail_url, | ||||
|          reminders.embed_title, | ||||
|          reminders.embed_fields, | ||||
|          reminders.enabled, | ||||
|          reminders.expires, | ||||
|          reminders.interval_seconds, | ||||
|          reminders.interval_days, | ||||
|          reminders.interval_months, | ||||
|          reminders.name, | ||||
|          reminders.restartable, | ||||
|          reminders.tts, | ||||
|          reminders.uid, | ||||
|          reminders.username, | ||||
|          reminders.utc_time | ||||
|         FROM reminders | ||||
|         LEFT JOIN channels ON channels.id = reminders.channel_id | ||||
|         WHERE uid = ?", | ||||
|         reminder.uid | ||||
|     ) | ||||
|     .fetch_one(pool.inner()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})), | ||||
|  | ||||
|         Err(e) => { | ||||
|             warn!("Error exiting `edit_reminder': {:?}", e); | ||||
|  | ||||
|             Err(json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]})) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[delete("/api/guild/<id>/reminders", data = "<reminder>")] | ||||
| pub async fn delete_reminder( | ||||
|     cookies: &CookieJar<'_>, | ||||
|     id: u64, | ||||
|     reminder: Json<DeleteReminder>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     match sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", reminder.uid) | ||||
|         .execute(pool.inner()) | ||||
|         .await | ||||
|     { | ||||
|         Ok(_) => Ok(json!({})), | ||||
|  | ||||
|         Err(e) => { | ||||
|             warn!("Error in `delete_reminder`: {:?}", e); | ||||
|  | ||||
|             Err(json!({"error": "Could not delete reminder"})) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,35 +0,0 @@ | ||||
| use rocket::{http::CookieJar, serde::json::json, State}; | ||||
| use serde::Serialize; | ||||
| use serenity::client::Context; | ||||
|  | ||||
| use crate::{check_authorization, routes::JsonResult}; | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct RoleInfo { | ||||
|     id: String, | ||||
|     name: String, | ||||
| } | ||||
|  | ||||
| #[get("/api/guild/<id>/roles")] | ||||
| pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult { | ||||
|     offline!(Ok(json!(vec![RoleInfo { name: "@everyone".to_string(), id: "1".to_string() }]))); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     let roles_res = ctx.cache.guild_roles(id); | ||||
|  | ||||
|     match roles_res { | ||||
|         Some(roles) => { | ||||
|             let roles = roles | ||||
|                 .iter() | ||||
|                 .map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() }) | ||||
|                 .collect::<Vec<RoleInfo>>(); | ||||
|  | ||||
|             Ok(json!(roles)) | ||||
|         } | ||||
|         None => { | ||||
|             warn!("Could not fetch roles from {}", id); | ||||
|  | ||||
|             json_err!("Could not get roles") | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,181 +0,0 @@ | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Json}, | ||||
|     State, | ||||
| }; | ||||
| use serenity::client::Context; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::{ | ||||
|     check_authorization, | ||||
|     consts::{ | ||||
|         MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH, | ||||
|         MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH, | ||||
|         MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, | ||||
|     }, | ||||
|     routes::{ | ||||
|         dashboard::{template_name_default, DeleteReminderTemplate, ReminderTemplate}, | ||||
|         JsonResult, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| #[get("/api/guild/<id>/templates")] | ||||
| pub async fn get_reminder_templates( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     match sqlx::query_as_unchecked!( | ||||
|         ReminderTemplate, | ||||
|         "SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", | ||||
|         id | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(templates) => Ok(json!(templates)), | ||||
|         Err(e) => { | ||||
|             warn!("Could not fetch templates from {}: {:?}", id, e); | ||||
|  | ||||
|             json_err!("Could not get templates") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[post("/api/guild/<id>/templates", data = "<reminder_template>")] | ||||
| pub async fn create_reminder_template( | ||||
|     id: u64, | ||||
|     reminder_template: Json<ReminderTemplate>, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     // validate lengths | ||||
|     check_length!(MAX_CONTENT_LENGTH, reminder_template.content); | ||||
|     check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder_template.embed_description); | ||||
|     check_length!(MAX_EMBED_TITLE_LENGTH, reminder_template.embed_title); | ||||
|     check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder_template.embed_author); | ||||
|     check_length!(MAX_EMBED_FOOTER_LENGTH, reminder_template.embed_footer); | ||||
|     check_length_opt!(MAX_EMBED_FIELDS, reminder_template.embed_fields); | ||||
|     if let Some(fields) = &reminder_template.embed_fields { | ||||
|         for field in &fields.0 { | ||||
|             check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value); | ||||
|             check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title); | ||||
|         } | ||||
|     } | ||||
|     check_length_opt!(MAX_USERNAME_LENGTH, reminder_template.username); | ||||
|     check_length_opt!( | ||||
|         MAX_URL_LENGTH, | ||||
|         reminder_template.embed_footer_url, | ||||
|         reminder_template.embed_thumbnail_url, | ||||
|         reminder_template.embed_author_url, | ||||
|         reminder_template.embed_image_url, | ||||
|         reminder_template.avatar | ||||
|     ); | ||||
|  | ||||
|     // validate urls | ||||
|     check_url_opt!( | ||||
|         reminder_template.embed_footer_url, | ||||
|         reminder_template.embed_thumbnail_url, | ||||
|         reminder_template.embed_author_url, | ||||
|         reminder_template.embed_image_url, | ||||
|         reminder_template.avatar | ||||
|     ); | ||||
|  | ||||
|     let name = if reminder_template.name.is_empty() { | ||||
|         template_name_default() | ||||
|     } else { | ||||
|         reminder_template.name.clone() | ||||
|     }; | ||||
|  | ||||
|     match sqlx::query!( | ||||
|         "INSERT INTO reminder_template | ||||
|         (guild_id, | ||||
|          name, | ||||
|          attachment, | ||||
|          attachment_name, | ||||
|          avatar, | ||||
|          content, | ||||
|          embed_author, | ||||
|          embed_author_url, | ||||
|          embed_color, | ||||
|          embed_description, | ||||
|          embed_footer, | ||||
|          embed_footer_url, | ||||
|          embed_image_url, | ||||
|          embed_thumbnail_url, | ||||
|          embed_title, | ||||
|          embed_fields, | ||||
|          interval_seconds, | ||||
|          interval_days, | ||||
|          interval_months, | ||||
|          tts, | ||||
|          username | ||||
|         ) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, | ||||
|          ?, ?, ?, ?, ?, ?, ?)", | ||||
|         id, | ||||
|         name, | ||||
|         reminder_template.attachment, | ||||
|         reminder_template.attachment_name, | ||||
|         reminder_template.avatar, | ||||
|         reminder_template.content, | ||||
|         reminder_template.embed_author, | ||||
|         reminder_template.embed_author_url, | ||||
|         reminder_template.embed_color, | ||||
|         reminder_template.embed_description, | ||||
|         reminder_template.embed_footer, | ||||
|         reminder_template.embed_footer_url, | ||||
|         reminder_template.embed_image_url, | ||||
|         reminder_template.embed_thumbnail_url, | ||||
|         reminder_template.embed_title, | ||||
|         reminder_template.embed_fields, | ||||
|         reminder_template.interval_seconds, | ||||
|         reminder_template.interval_days, | ||||
|         reminder_template.interval_months, | ||||
|         reminder_template.tts, | ||||
|         reminder_template.username, | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(_) => Ok(json!({})), | ||||
|         Err(e) => { | ||||
|             warn!("Could not create template for {}: {:?}", id, e); | ||||
|  | ||||
|             json_err!("Could not create template") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[delete("/api/guild/<id>/templates", data = "<delete_reminder_template>")] | ||||
| pub async fn delete_reminder_template( | ||||
|     id: u64, | ||||
|     delete_reminder_template: Json<DeleteReminderTemplate>, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     match sqlx::query!( | ||||
|         "DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?", | ||||
|         id, delete_reminder_template.id | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(_) => { | ||||
|             Ok(json!({})) | ||||
|         } | ||||
|         Err(e) => { | ||||
|             warn!("Could not delete template from {}: {:?}", id, e); | ||||
|  | ||||
|             json_err!("Could not delete template") | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,2 +0,0 @@ | ||||
| pub mod guild; | ||||
| pub mod user; | ||||
| @@ -1,81 +0,0 @@ | ||||
| use reqwest::Client; | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Value as JsonValue}, | ||||
|     State, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serenity::model::{id::GuildId, permissions::Permissions}; | ||||
|  | ||||
| use crate::consts::DISCORD_API; | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct GuildInfo { | ||||
|     id: String, | ||||
|     name: String, | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| struct PartialGuild { | ||||
|     pub id: GuildId, | ||||
|     pub name: String, | ||||
|     #[serde(default)] | ||||
|     pub owner: bool, | ||||
|     #[serde(rename = "permissions_new")] | ||||
|     pub permissions: Option<String>, | ||||
| } | ||||
|  | ||||
| #[get("/api/user/guilds")] | ||||
| pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue { | ||||
|     offline!(json!(vec![GuildInfo { id: "1".to_string(), name: "Guild".to_string() }])); | ||||
|  | ||||
|     if let Some(access_token) = cookies.get_private("access_token") { | ||||
|         let request_res = reqwest_client | ||||
|             .get(format!("{}/users/@me/guilds", DISCORD_API)) | ||||
|             .bearer_auth(access_token.value()) | ||||
|             .send() | ||||
|             .await; | ||||
|  | ||||
|         match request_res { | ||||
|             Ok(response) => { | ||||
|                 let guilds_res = response.json::<Vec<PartialGuild>>().await; | ||||
|  | ||||
|                 match guilds_res { | ||||
|                     Ok(guilds) => { | ||||
|                         let reduced_guilds = guilds | ||||
|                             .iter() | ||||
|                             .filter(|g| { | ||||
|                                 g.owner | ||||
|                                     || g.permissions.as_ref().map_or(false, |p| { | ||||
|                                         let permissions = | ||||
|                                             Permissions::from_bits_truncate(p.parse().unwrap()); | ||||
|  | ||||
|                                         permissions.manage_messages() | ||||
|                                             || permissions.manage_guild() | ||||
|                                             || permissions.administrator() | ||||
|                                     }) | ||||
|                             }) | ||||
|                             .map(|g| GuildInfo { id: g.id.to_string(), name: g.name.to_string() }) | ||||
|                             .collect::<Vec<GuildInfo>>(); | ||||
|  | ||||
|                         json!(reduced_guilds) | ||||
|                     } | ||||
|  | ||||
|                     Err(e) => { | ||||
|                         warn!("Error constructing user from request: {:?}", e); | ||||
|  | ||||
|                         json!({"error": "Could not get user details"}) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             Err(e) => { | ||||
|                 warn!("Error getting user guilds: {:?}", e); | ||||
|  | ||||
|                 json!({"error": "Could not reach Discord"}) | ||||
|             } | ||||
|         } | ||||
|     } else { | ||||
|         json!({"error": "Not authorized"}) | ||||
|     } | ||||
| } | ||||
| @@ -1,97 +0,0 @@ | ||||
| mod guilds; | ||||
|  | ||||
| use std::env; | ||||
|  | ||||
| use chrono_tz::Tz; | ||||
| pub use guilds::*; | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Json, Value as JsonValue}, | ||||
|     State, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::id::{GuildId, RoleId}, | ||||
| }; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct UserInfo { | ||||
|     name: String, | ||||
|     patreon: bool, | ||||
|     timezone: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| pub struct UpdateUser { | ||||
|     timezone: String, | ||||
| } | ||||
|  | ||||
| #[get("/api/user")] | ||||
| pub async fn get_user_info( | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonValue { | ||||
|     offline!(json!(UserInfo { name: "Discord".to_string(), patreon: true, timezone: None })); | ||||
|  | ||||
|     if let Some(user_id) = | ||||
|         cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten() | ||||
|     { | ||||
|         let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap()) | ||||
|             .member(&ctx.inner(), user_id) | ||||
|             .await; | ||||
|  | ||||
|         let timezone = sqlx::query!( | ||||
|             "SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?", | ||||
|             user_id | ||||
|         ) | ||||
|         .fetch_one(pool.inner()) | ||||
|         .await | ||||
|         .map_or(None, |q| Some(q.timezone)); | ||||
|  | ||||
|         let user_info = UserInfo { | ||||
|             name: cookies | ||||
|                 .get_private("username") | ||||
|                 .map_or("DiscordUser#0000".to_string(), |c| c.value().to_string()), | ||||
|             patreon: member_res.map_or(false, |member| { | ||||
|                 member | ||||
|                     .roles | ||||
|                     .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap())) | ||||
|             }), | ||||
|             timezone, | ||||
|         }; | ||||
|  | ||||
|         json!(user_info) | ||||
|     } else { | ||||
|         json!({"error": "Not authorized"}) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[patch("/api/user", data = "<user>")] | ||||
| pub async fn update_user_info( | ||||
|     cookies: &CookieJar<'_>, | ||||
|     user: Json<UpdateUser>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonValue { | ||||
|     if let Some(user_id) = | ||||
|         cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten() | ||||
|     { | ||||
|         if user.timezone.parse::<Tz>().is_ok() { | ||||
|             let _ = sqlx::query!( | ||||
|                 "UPDATE users SET timezone = ? WHERE user = ?", | ||||
|                 user.timezone, | ||||
|                 user_id, | ||||
|             ) | ||||
|             .execute(pool.inner()) | ||||
|             .await; | ||||
|  | ||||
|             json!({}) | ||||
|         } else { | ||||
|             json!({"error": "Timezone not recognized"}) | ||||
|         } | ||||
|     } else { | ||||
|         json!({"error": "Not authorized"}) | ||||
|     } | ||||
| } | ||||
| @@ -1,20 +0,0 @@ | ||||
| use std::env; | ||||
|  | ||||
| use chrono_tz::Tz; | ||||
| use reqwest::Client; | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Json, Value as JsonValue}, | ||||
|     State, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::{ | ||||
|         id::{GuildId, RoleId}, | ||||
|         permissions::Permissions, | ||||
|     }, | ||||
| }; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::{consts::DISCORD_API, routes::JsonResult}; | ||||
| @@ -1,29 +0,0 @@ | ||||
| use std::env; | ||||
|  | ||||
| use chrono_tz::Tz; | ||||
| use reqwest::Client; | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Json, Value as JsonValue}, | ||||
|     State, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::{ | ||||
|         id::{GuildId, RoleId}, | ||||
|         permissions::Permissions, | ||||
|     }, | ||||
| }; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::{consts::DISCORD_API, routes::JsonResult}; | ||||
|  | ||||
| #[get("/api/user/reminders")] | ||||
| pub async fn get_reminders( | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     Ok(json! {}) | ||||
| } | ||||
| @@ -6,20 +6,16 @@ use rocket::{ | ||||
| }; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::id::{ChannelId, GuildId, UserId}, | ||||
|     model::id::{ChannelId, GuildId}, | ||||
| }; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::{ | ||||
|     check_authorization, | ||||
|     guards::transaction::Transaction, | ||||
|     routes::{ | ||||
|         dashboard::{ | ||||
|             create_reminder, generate_uid, ImportBody, Reminder, ReminderCsv, ReminderTemplateCsv, | ||||
|             TodoCsv, | ||||
|         }, | ||||
|         JsonResult, | ||||
| use crate::routes::{ | ||||
|     dashboard::{ | ||||
|         create_reminder, generate_uid, ImportBody, Reminder, ReminderCsv, ReminderTemplateCsv, | ||||
|         TodoCsv, | ||||
|     }, | ||||
|     JsonResult, | ||||
| }; | ||||
|  | ||||
| #[get("/api/guild/<id>/export/reminders")] | ||||
| @@ -29,7 +25,7 @@ pub async fn export_reminders( | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|  | ||||
|     let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]); | ||||
|  | ||||
| @@ -74,7 +70,7 @@ pub async fn export_reminders( | ||||
|                  reminders.utc_time | ||||
|                 FROM reminders | ||||
|                 LEFT JOIN channels ON channels.id = reminders.channel_id | ||||
|                 WHERE FIND_IN_SET(channels.channel, ?) AND status = 'pending'", | ||||
|                 WHERE FIND_IN_SET(channels.channel, ?)", | ||||
|                 channels | ||||
|             ) | ||||
|             .fetch_all(pool.inner()) | ||||
| @@ -122,14 +118,14 @@ pub async fn export_reminders( | ||||
| } | ||||
|  | ||||
| #[put("/api/guild/<id>/export/reminders", data = "<body>")] | ||||
| pub(crate) async fn import_reminders( | ||||
| pub async fn import_reminders( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     body: Json<ImportBody>, | ||||
|     ctx: &State<Context>, | ||||
|     mut transaction: Transaction<'_>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|  | ||||
|     let user_id = | ||||
|         cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap(); | ||||
| @@ -137,7 +133,6 @@ pub(crate) async fn import_reminders( | ||||
|     match base64::decode(&body.body) { | ||||
|         Ok(body) => { | ||||
|             let mut reader = csv::Reader::from_reader(body.as_slice()); | ||||
|             let mut count = 0; | ||||
|  | ||||
|             for result in reader.deserialize::<ReminderCsv>() { | ||||
|                 match result { | ||||
| @@ -150,7 +145,7 @@ pub(crate) async fn import_reminders( | ||||
|                                     attachment: record.attachment, | ||||
|                                     attachment_name: record.attachment_name, | ||||
|                                     avatar: record.avatar, | ||||
|                                     channel: channel_id, | ||||
|                                     channel: Some(channel_id), | ||||
|                                     content: record.content, | ||||
|                                     embed_author: record.embed_author, | ||||
|                                     embed_author_url: record.embed_author_url, | ||||
| @@ -176,18 +171,18 @@ pub(crate) async fn import_reminders( | ||||
|                                     uid: generate_uid(), | ||||
|                                     username: record.username, | ||||
|                                     utc_time: record.utc_time, | ||||
|                                     status: "pending".to_string(), | ||||
|                                     status_change_time: None, | ||||
|                                 }; | ||||
|  | ||||
|                                 create_reminder( | ||||
|                                     ctx.inner(), | ||||
|                                     &mut transaction, | ||||
|                                     pool.inner(), | ||||
|                                     GuildId(id), | ||||
|                                     UserId(user_id), | ||||
|                                     reminder, | ||||
|                                 ) | ||||
|                                 .await?; | ||||
|  | ||||
|                                 count += 1; | ||||
|                             } | ||||
|  | ||||
|                             Err(_) => { | ||||
| @@ -207,16 +202,7 @@ pub(crate) async fn import_reminders( | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             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") | ||||
|                 } | ||||
|             } | ||||
|             Ok(json!({})) | ||||
|         } | ||||
|  | ||||
|         Err(_) => { | ||||
| @@ -232,7 +218,7 @@ pub async fn export_todos( | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|  | ||||
|     let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]); | ||||
|  | ||||
| @@ -287,7 +273,7 @@ pub async fn import_todos( | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|  | ||||
|     let channels_res = GuildId(id).channels(&ctx.inner()).await; | ||||
|  | ||||
| @@ -382,7 +368,7 @@ pub async fn export_reminder_templates( | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|  | ||||
|     let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]); | ||||
|  | ||||
| @@ -404,9 +390,6 @@ pub async fn export_reminder_templates( | ||||
|          embed_thumbnail_url, | ||||
|          embed_title, | ||||
|          embed_fields, | ||||
|          interval_seconds, | ||||
|          interval_days, | ||||
|          interval_months, | ||||
|          tts, | ||||
|          username | ||||
|         FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", | ||||
|   | ||||
							
								
								
									
										618
									
								
								web/src/routes/dashboard/guild.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										618
									
								
								web/src/routes/dashboard/guild.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,618 @@ | ||||
| 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,20 +1,16 @@ | ||||
| use std::path::Path; | ||||
| use std::collections::HashMap; | ||||
|  | ||||
| use chrono::{naive::NaiveDateTime, Utc}; | ||||
| use rand::{rngs::OsRng, seq::IteratorRandom}; | ||||
| use rocket::{ | ||||
|     fs::{relative, NamedFile}, | ||||
|     http::CookieJar, | ||||
|     response::Redirect, | ||||
|     serde::json::json, | ||||
| }; | ||||
| use rocket::{http::CookieJar, response::Redirect, serde::json::json}; | ||||
| use rocket_dyn_templates::Template; | ||||
| use serde::{Deserialize, Deserializer, Serialize}; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     http::Http, | ||||
|     model::id::{ChannelId, GuildId, UserId}, | ||||
| }; | ||||
| use sqlx::types::Json; | ||||
| use sqlx::{types::Json, Executor}; | ||||
|  | ||||
| use crate::{ | ||||
|     check_guild_subscription, check_subscription, | ||||
| @@ -24,13 +20,13 @@ use crate::{ | ||||
|         MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, | ||||
|         MAX_NAME_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL, | ||||
|     }, | ||||
|     guards::transaction::Transaction, | ||||
|     routes::JsonResult, | ||||
|     Error, | ||||
|     Database, Error, | ||||
| }; | ||||
|  | ||||
| pub mod api; | ||||
| pub mod export; | ||||
| pub mod guild; | ||||
| pub mod user; | ||||
|  | ||||
| type Unset<T> = Option<T>; | ||||
|  | ||||
| @@ -84,9 +80,6 @@ pub struct ReminderTemplate { | ||||
|     embed_thumbnail_url: Option<String>, | ||||
|     embed_title: String, | ||||
|     embed_fields: Option<Json<Vec<EmbedField>>>, | ||||
|     interval_seconds: Option<u32>, | ||||
|     interval_days: Option<u32>, | ||||
|     interval_months: Option<u32>, | ||||
|     tts: bool, | ||||
|     username: Option<String>, | ||||
| } | ||||
| @@ -109,9 +102,6 @@ pub struct ReminderTemplateCsv { | ||||
|     embed_thumbnail_url: Option<String>, | ||||
|     embed_title: String, | ||||
|     embed_fields: Option<String>, | ||||
|     interval_seconds: Option<u32>, | ||||
|     interval_days: Option<u32>, | ||||
|     interval_months: Option<u32>, | ||||
|     tts: bool, | ||||
|     username: Option<String>, | ||||
| } | ||||
| @@ -134,8 +124,8 @@ pub struct Reminder { | ||||
|     attachment: Option<Vec<u8>>, | ||||
|     attachment_name: Option<String>, | ||||
|     avatar: Option<String>, | ||||
|     #[serde(with = "string")] | ||||
|     channel: u64, | ||||
|     #[serde(with = "string_opt")] | ||||
|     channel: Option<u64>, | ||||
|     content: String, | ||||
|     embed_author: String, | ||||
|     embed_author_url: Option<String>, | ||||
| @@ -160,6 +150,8 @@ pub struct Reminder { | ||||
|     uid: String, | ||||
|     username: Option<String>, | ||||
|     utc_time: NaiveDateTime, | ||||
|     status: String, | ||||
|     status_change_time: Option<NaiveDateTime>, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize)] | ||||
| @@ -318,6 +310,34 @@ mod string { | ||||
|     } | ||||
| } | ||||
|  | ||||
| mod string_opt { | ||||
|     use std::{fmt::Display, str::FromStr}; | ||||
|  | ||||
|     use serde::{de, Deserialize, Deserializer, Serializer}; | ||||
|  | ||||
|     pub fn serialize<T, S>(value: &Option<T>, serializer: S) -> Result<S::Ok, S::Error> | ||||
|     where | ||||
|         T: Display, | ||||
|         S: Serializer, | ||||
|     { | ||||
|         match value { | ||||
|             Some(value) => serializer.collect_str(value), | ||||
|             None => serializer.serialize_none(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error> | ||||
|     where | ||||
|         T: FromStr, | ||||
|         T::Err: Display, | ||||
|         D: Deserializer<'de>, | ||||
|     { | ||||
|         Option::deserialize(deserializer)? | ||||
|             .map(|d: String| d.parse().map_err(de::Error::custom)) | ||||
|             .transpose() | ||||
|     } | ||||
| } | ||||
|  | ||||
| mod base64s { | ||||
|     use serde::{de, Deserialize, Deserializer, Serializer}; | ||||
|  | ||||
| @@ -357,21 +377,21 @@ pub struct TodoCsv { | ||||
|     channel_id: Option<String>, | ||||
| } | ||||
|  | ||||
| pub(crate) async fn create_reminder( | ||||
| pub async fn create_reminder( | ||||
|     ctx: &Context, | ||||
|     transaction: &mut Transaction<'_>, | ||||
|     pool: impl sqlx::Executor<'_, Database = Database> + Copy, | ||||
|     guild_id: GuildId, | ||||
|     user_id: UserId, | ||||
|     reminder: Reminder, | ||||
| ) -> JsonResult { | ||||
|     // check guild in db | ||||
|     match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.0) | ||||
|         .fetch_one(transaction.executor()) | ||||
|         .fetch_one(pool) | ||||
|         .await | ||||
|     { | ||||
|         Err(sqlx::Error::RowNotFound) => { | ||||
|             if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.0) | ||||
|                 .execute(transaction.executor()) | ||||
|                 .execute(pool) | ||||
|                 .await | ||||
|                 .is_err() | ||||
|             { | ||||
| @@ -382,7 +402,7 @@ pub(crate) async fn create_reminder( | ||||
|     } | ||||
|  | ||||
|     // validate channel | ||||
|     let channel = ChannelId(reminder.channel).to_channel_cached(&ctx); | ||||
|     let channel = reminder.channel.map(|c| ChannelId(c).to_channel_cached(&ctx)).flatten(); | ||||
|     let channel_exists = channel.is_some(); | ||||
|  | ||||
|     let channel_matches_guild = | ||||
| @@ -390,14 +410,14 @@ pub(crate) async fn create_reminder( | ||||
|  | ||||
|     if !channel_matches_guild || !channel_exists { | ||||
|         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 | ||||
|         ); | ||||
|  | ||||
|         return Err(json!({"error": "Channel not found"})); | ||||
|     } | ||||
|  | ||||
|     let channel = create_database_channel(&ctx, ChannelId(reminder.channel), transaction).await; | ||||
|     let channel = create_database_channel(&ctx, ChannelId(reminder.channel.unwrap()), pool).await; | ||||
|  | ||||
|     if let Err(e) = channel { | ||||
|         warn!("`create_database_channel` returned an error code: {:?}", e); | ||||
| @@ -471,6 +491,8 @@ pub(crate) async fn create_reminder( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // base64 decode error dropped here | ||||
|     let attachment_data = reminder.attachment.as_ref().map(|s| base64::decode(s).ok()).flatten(); | ||||
|     let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() }; | ||||
|     let username = if reminder.username.as_ref().map(|s| s.is_empty()).unwrap_or(true) { | ||||
|         None | ||||
| @@ -487,6 +509,7 @@ pub(crate) async fn create_reminder( | ||||
|          attachment, | ||||
|          attachment_name, | ||||
|          channel_id, | ||||
|          guild_id, | ||||
|          avatar, | ||||
|          content, | ||||
|          embed_author, | ||||
| @@ -509,11 +532,12 @@ pub(crate) async fn create_reminder( | ||||
|          tts, | ||||
|          username, | ||||
|          `utc_time` | ||||
|         ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | ||||
|         ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | ||||
|         new_uid, | ||||
|         reminder.attachment, | ||||
|         attachment_data, | ||||
|         reminder.attachment_name, | ||||
|         channel, | ||||
|         guild_id.0, | ||||
|         reminder.avatar, | ||||
|         reminder.content, | ||||
|         reminder.embed_author, | ||||
| @@ -537,7 +561,7 @@ pub(crate) async fn create_reminder( | ||||
|         username, | ||||
|         reminder.utc_time, | ||||
|     ) | ||||
|     .execute(transaction.executor()) | ||||
|     .execute(pool) | ||||
|     .await | ||||
|     { | ||||
|         Ok(_) => sqlx::query_as_unchecked!( | ||||
| @@ -568,13 +592,15 @@ pub(crate) async fn create_reminder( | ||||
|              reminders.tts, | ||||
|              reminders.uid, | ||||
|              reminders.username, | ||||
|              reminders.utc_time | ||||
|              reminders.utc_time, | ||||
|              reminders.status, | ||||
|              reminders.status_change_time | ||||
|             FROM reminders | ||||
|             LEFT JOIN channels ON channels.id = reminders.channel_id | ||||
|             WHERE uid = ?", | ||||
|             new_uid | ||||
|         ) | ||||
|         .fetch_one(transaction.executor()) | ||||
|         .fetch_one(pool) | ||||
|         .await | ||||
|         .map(|r| Ok(json!(r))) | ||||
|         .unwrap_or_else(|e| { | ||||
| @@ -594,11 +620,11 @@ pub(crate) async fn create_reminder( | ||||
| async fn create_database_channel( | ||||
|     ctx: impl AsRef<Http>, | ||||
|     channel: ChannelId, | ||||
|     transaction: &mut Transaction<'_>, | ||||
|     pool: impl Executor<'_, Database = Database> + Copy, | ||||
| ) -> Result<u32, crate::Error> { | ||||
|     let row = | ||||
|         sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0) | ||||
|             .fetch_one(transaction.executor()) | ||||
|             .fetch_one(pool) | ||||
|             .await; | ||||
|  | ||||
|     match row { | ||||
| @@ -615,7 +641,7 @@ async fn create_database_channel( | ||||
|                     webhook.token, | ||||
|                     channel.0 | ||||
|                 ) | ||||
|                 .execute(transaction.executor()) | ||||
|                 .execute(pool) | ||||
|                 .await | ||||
|                 .map_err(|e| Error::SQLx(e))?; | ||||
|             } | ||||
| @@ -641,7 +667,7 @@ async fn create_database_channel( | ||||
|                 webhook.token, | ||||
|                 channel.0 | ||||
|             ) | ||||
|             .execute(transaction.executor()) | ||||
|             .execute(pool) | ||||
|             .await | ||||
|             .map_err(|e| Error::SQLx(e))?; | ||||
|  | ||||
| @@ -652,7 +678,7 @@ async fn create_database_channel( | ||||
|     }?; | ||||
|  | ||||
|     let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.0) | ||||
|         .fetch_one(transaction.executor()) | ||||
|         .fetch_one(pool) | ||||
|         .await | ||||
|         .map_err(|e| Error::SQLx(e))?; | ||||
|  | ||||
| @@ -660,26 +686,30 @@ async fn create_database_channel( | ||||
| } | ||||
|  | ||||
| #[get("/")] | ||||
| pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<NamedFile, Redirect> { | ||||
| pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Redirect> { | ||||
|     if cookies.get_private("userid").is_some() { | ||||
|         NamedFile::open(Path::new(relative!("static/index.html"))).await.map_err(|e| { | ||||
|             warn!("Couldn't render dashboard: {:?}", e); | ||||
|  | ||||
|             Redirect::to("/login/discord") | ||||
|         }) | ||||
|         let map: HashMap<&str, String> = HashMap::new(); | ||||
|         Ok(Template::render("dashboard", &map)) | ||||
|     } else { | ||||
|         Err(Redirect::to("/login/discord")) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[get("/<_..>")] | ||||
| pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<NamedFile, Redirect> { | ||||
| #[get("/<_>")] | ||||
| pub async fn dashboard_1(cookies: &CookieJar<'_>) -> Result<Template, Redirect> { | ||||
|     if cookies.get_private("userid").is_some() { | ||||
|         NamedFile::open(Path::new(relative!("static/index.html"))).await.map_err(|e| { | ||||
|             warn!("Couldn't render dashboard: {:?}", e); | ||||
|  | ||||
|             Redirect::to("/login/discord") | ||||
|         }) | ||||
|         let map: HashMap<&str, String> = HashMap::new(); | ||||
|         Ok(Template::render("dashboard", &map)) | ||||
|     } else { | ||||
|         Err(Redirect::to("/login/discord")) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[get("/<_>/<_>")] | ||||
| pub async fn dashboard_2(cookies: &CookieJar<'_>) -> Result<Template, Redirect> { | ||||
|     if cookies.get_private("userid").is_some() { | ||||
|         let map: HashMap<&str, String> = HashMap::new(); | ||||
|         Ok(Template::render("dashboard", &map)) | ||||
|     } else { | ||||
|         Err(Redirect::to("/login/discord")) | ||||
|     } | ||||
|   | ||||
							
								
								
									
										172
									
								
								web/src/routes/dashboard/user.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								web/src/routes/dashboard/user.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | ||||
| use std::env; | ||||
|  | ||||
| use chrono_tz::Tz; | ||||
| use reqwest::Client; | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Json, Value as JsonValue}, | ||||
|     State, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::{ | ||||
|         id::{GuildId, RoleId}, | ||||
|         permissions::Permissions, | ||||
|     }, | ||||
| }; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::consts::DISCORD_API; | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct UserInfo { | ||||
|     name: String, | ||||
|     patreon: bool, | ||||
|     timezone: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| pub struct UpdateUser { | ||||
|     timezone: String, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct GuildInfo { | ||||
|     id: String, | ||||
|     name: String, | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| pub struct PartialGuild { | ||||
|     pub id: GuildId, | ||||
|     pub icon: Option<String>, | ||||
|     pub name: String, | ||||
|     #[serde(default)] | ||||
|     pub owner: bool, | ||||
|     #[serde(rename = "permissions_new")] | ||||
|     pub permissions: Option<String>, | ||||
| } | ||||
|  | ||||
| #[get("/api/user")] | ||||
| pub async fn get_user_info( | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonValue { | ||||
|     offline!(json!(UserInfo { name: "Discord".to_string(), patreon: true, timezone: None })); | ||||
|  | ||||
|     if let Some(user_id) = | ||||
|         cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten() | ||||
|     { | ||||
|         let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap()) | ||||
|             .member(&ctx.inner(), user_id) | ||||
|             .await; | ||||
|  | ||||
|         let timezone = sqlx::query!( | ||||
|             "SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?", | ||||
|             user_id | ||||
|         ) | ||||
|         .fetch_one(pool.inner()) | ||||
|         .await | ||||
|         .map_or(None, |q| Some(q.timezone)); | ||||
|  | ||||
|         let user_info = UserInfo { | ||||
|             name: cookies | ||||
|                 .get_private("username") | ||||
|                 .map_or("DiscordUser#0000".to_string(), |c| c.value().to_string()), | ||||
|             patreon: member_res.map_or(false, |member| { | ||||
|                 member | ||||
|                     .roles | ||||
|                     .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap())) | ||||
|             }), | ||||
|             timezone, | ||||
|         }; | ||||
|  | ||||
|         json!(user_info) | ||||
|     } else { | ||||
|         json!({"error": "Not authorized"}) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[patch("/api/user", data = "<user>")] | ||||
| pub async fn update_user_info( | ||||
|     cookies: &CookieJar<'_>, | ||||
|     user: Json<UpdateUser>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonValue { | ||||
|     if let Some(user_id) = | ||||
|         cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten() | ||||
|     { | ||||
|         if user.timezone.parse::<Tz>().is_ok() { | ||||
|             let _ = sqlx::query!( | ||||
|                 "UPDATE users SET timezone = ? WHERE user = ?", | ||||
|                 user.timezone, | ||||
|                 user_id, | ||||
|             ) | ||||
|             .execute(pool.inner()) | ||||
|             .await; | ||||
|  | ||||
|             json!({}) | ||||
|         } else { | ||||
|             json!({"error": "Timezone not recognized"}) | ||||
|         } | ||||
|     } else { | ||||
|         json!({"error": "Not authorized"}) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[get("/api/user/guilds")] | ||||
| pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue { | ||||
|     offline!(json!(vec![GuildInfo { id: "1".to_string(), name: "Guild".to_string() }])); | ||||
|  | ||||
|     if let Some(access_token) = cookies.get_private("access_token") { | ||||
|         let request_res = reqwest_client | ||||
|             .get(format!("{}/users/@me/guilds", DISCORD_API)) | ||||
|             .bearer_auth(access_token.value()) | ||||
|             .send() | ||||
|             .await; | ||||
|  | ||||
|         match request_res { | ||||
|             Ok(response) => { | ||||
|                 let guilds_res = response.json::<Vec<PartialGuild>>().await; | ||||
|  | ||||
|                 match guilds_res { | ||||
|                     Ok(guilds) => { | ||||
|                         let reduced_guilds = guilds | ||||
|                             .iter() | ||||
|                             .filter(|g| { | ||||
|                                 g.owner | ||||
|                                     || g.permissions.as_ref().map_or(false, |p| { | ||||
|                                         let permissions = | ||||
|                                             Permissions::from_bits_truncate(p.parse().unwrap()); | ||||
|  | ||||
|                                         permissions.manage_messages() | ||||
|                                             || permissions.manage_guild() | ||||
|                                             || permissions.administrator() | ||||
|                                     }) | ||||
|                             }) | ||||
|                             .map(|g| GuildInfo { id: g.id.to_string(), name: g.name.to_string() }) | ||||
|                             .collect::<Vec<GuildInfo>>(); | ||||
|  | ||||
|                         json!(reduced_guilds) | ||||
|                     } | ||||
|  | ||||
|                     Err(e) => { | ||||
|                         warn!("Error constructing user from request: {:?}", e); | ||||
|  | ||||
|                         json!({"error": "Could not get user details"}) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             Err(e) => { | ||||
|                 warn!("Error getting user guilds: {:?}", e); | ||||
|  | ||||
|                 json!({"error": "Could not reach Discord"}) | ||||
|             } | ||||
|         } | ||||
|     } else { | ||||
|         json!({"error": "Not authorized"}) | ||||
|     } | ||||
| } | ||||
| @@ -1,18 +0,0 @@ | ||||
| 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,7 +1,6 @@ | ||||
| pub mod admin; | ||||
| pub mod dashboard; | ||||
| pub mod login; | ||||
| pub mod metrics; | ||||
| pub mod report; | ||||
|  | ||||
| use std::collections::HashMap; | ||||
|   | ||||
| @@ -15,22 +15,6 @@ div.reminderContent.is-collapsed .column.settings { | ||||
|     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 { | ||||
|     display: inline-flex; | ||||
| } | ||||
| @@ -145,12 +129,6 @@ div.split-controls { | ||||
|     margin-top: 0 !important; | ||||
| } | ||||
|  | ||||
| .reminder-settings > .column { | ||||
|     flex-grow: 0; | ||||
|     flex-shrink: 0; | ||||
|     flex-basis: 50%; | ||||
| } | ||||
|  | ||||
| div.reminderContent { | ||||
|     margin-top: 10px; | ||||
|     margin-bottom: 10px; | ||||
| @@ -218,16 +196,17 @@ div.inset-content { | ||||
|     margin-right: 10%; | ||||
| } | ||||
|  | ||||
| div.flash-container { | ||||
|     position: fixed; | ||||
|     width: 100%; | ||||
|     bottom: 0; | ||||
| } | ||||
|  | ||||
| div.flash-message { | ||||
|     position: fixed; | ||||
|     width: calc(100% - 32px); | ||||
|     margin: 16px !important; | ||||
|     z-index: 99; | ||||
|     bottom: 0; | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| div.flash-message.is-active { | ||||
|     display: block; | ||||
| } | ||||
|  | ||||
| body { | ||||
| @@ -307,18 +286,20 @@ div.dashboard-sidebar { | ||||
|     padding-right: 0; | ||||
| } | ||||
|  | ||||
| div.dashboard-sidebar:not(.mobile-sidebar) { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
|  | ||||
| ul.guildList { | ||||
|     flex-grow: 1; | ||||
|     flex-shrink: 1; | ||||
|     overflow: auto; | ||||
|     overflow: scroll; | ||||
| } | ||||
|  | ||||
| div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer { | ||||
|     flex-shrink: 0; | ||||
|     flex-grow: 0; | ||||
|     position: fixed; | ||||
|     bottom: 0; | ||||
|     width: 226px; | ||||
| } | ||||
|  | ||||
| div.dashboard-sidebar svg { | ||||
| @@ -472,7 +453,8 @@ input.default-width { | ||||
| .customizable.is-400x300 img { | ||||
|     margin-top: 10px; | ||||
|     width: 100%; | ||||
|     height: 100px; | ||||
|     min-height: 100px; | ||||
|     max-height: 400px; | ||||
| } | ||||
|  | ||||
| .customizable.is-32x32 img { | ||||
| @@ -616,14 +598,6 @@ input.default-width { | ||||
|     border-bottom: 1px solid #fff; | ||||
| } | ||||
|  | ||||
| .channel-selector { | ||||
|     width: 100%; | ||||
| } | ||||
|  | ||||
| .select { | ||||
|     width: 100%; | ||||
| } | ||||
|  | ||||
| li.highlight { | ||||
|     margin-bottom: 0 !important; | ||||
| } | ||||
| @@ -632,10 +606,6 @@ li.highlight { | ||||
|     display: flex; | ||||
| } | ||||
|  | ||||
| .button-row-edit > button { | ||||
|     margin-right: 4px; | ||||
| } | ||||
|  | ||||
| .button-row .button-row-reminder { | ||||
|     flex-grow: 0; | ||||
|     padding: 2px; | ||||
| @@ -651,22 +621,7 @@ li.highlight { | ||||
|     padding: 2px; | ||||
| } | ||||
|  | ||||
| @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) { | ||||
| @media only screen and (max-width: 1408px) { | ||||
|     .button-row { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
| @@ -684,13 +639,37 @@ li.highlight { | ||||
|     .button-row button { | ||||
|         width: 100%; | ||||
|     } | ||||
| } | ||||
|  | ||||
|     .reminder-settings { | ||||
|         margin-bottom: 0 !important; | ||||
| @media only screen and (max-width: 768px) { | ||||
|     .button-row-edit { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|     } | ||||
|  | ||||
|     .tts-row { | ||||
|         padding-bottom: 0; | ||||
|     .button-row-edit > button { | ||||
|         width: 100%; | ||||
|         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; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -779,16 +758,6 @@ div.reminderError .errorHead .reminderTime { | ||||
|     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 */ | ||||
|  | ||||
| .half-rem { | ||||
|   | ||||
| @@ -18,6 +18,7 @@ const $downloader = document.querySelector("a#downloader"); | ||||
| const $uploader = document.querySelector("input#uploader"); | ||||
|  | ||||
| let channels = []; | ||||
| let reminderErrors = []; | ||||
| let guildNames = {}; | ||||
| let roles = []; | ||||
| let templates = {}; | ||||
| @@ -33,16 +34,11 @@ let globalPatreon = false; | ||||
| let guildPatreon = false; | ||||
|  | ||||
| function guildId() { | ||||
|     return window.location.pathname.match(/dashboard\/(\d+)/)[1]; | ||||
|     return document.querySelector("li > a.is-active").parentElement.dataset["guild"]; | ||||
| } | ||||
|  | ||||
| function pane() { | ||||
|     const match = window.location.pathname.match(/dashboard\/\d+\/(.+)/); | ||||
|     if (match === null) { | ||||
|         return null; | ||||
|     } else { | ||||
|         return match[1]; | ||||
|     } | ||||
| function guildName() { | ||||
|     return guildNames[guildId()]; | ||||
| } | ||||
|  | ||||
| function colorToInt(r, g, b) { | ||||
| @@ -61,7 +57,7 @@ function switch_pane(selector) { | ||||
|         el.classList.add("is-hidden"); | ||||
|     }); | ||||
|  | ||||
|     document.getElementById(selector).classList.remove("is-hidden"); | ||||
|     document.querySelector(`*[data-name=${selector}]`).classList.remove("is-hidden"); | ||||
| } | ||||
|  | ||||
| function update_select(sel) { | ||||
| @@ -105,7 +101,7 @@ function reset_guild_pane() { | ||||
| } | ||||
|  | ||||
| async function fetch_patreon(guild_id) { | ||||
|     fetch(`/dashboard/api/guild/${guild_id}`) | ||||
|     fetch(`/dashboard/api/guild/${guild_id}/patreon`) | ||||
|         .then((response) => response.json()) | ||||
|         .then((data) => { | ||||
|             if (data.error) { | ||||
| @@ -232,10 +228,11 @@ async function fetch_reminders(guild_id) { | ||||
| } | ||||
|  | ||||
| async function serialize_reminder(node, mode) { | ||||
|     let utc_time, expiration_time; | ||||
|     let interval = get_interval(node); | ||||
|     let interval, utc_time, expiration_time; | ||||
|  | ||||
|     if (mode !== "template") { | ||||
|         interval = get_interval(node); | ||||
|  | ||||
|         utc_time = luxon.DateTime.fromISO( | ||||
|             node.querySelector('input[name="time"]').value | ||||
|         ).setZone("UTC"); | ||||
| @@ -364,9 +361,9 @@ async function serialize_reminder(node, mode) { | ||||
|         embed_title: embed_title, | ||||
|         embed_fields: fields, | ||||
|         expires: expiration_time, | ||||
|         interval_seconds: interval.seconds, | ||||
|         interval_days: interval.days, | ||||
|         interval_months: interval.months, | ||||
|         interval_seconds: mode !== "template" ? interval.seconds : null, | ||||
|         interval_days: mode !== "template" ? interval.days : null, | ||||
|         interval_months: mode !== "template" ? interval.months : null, | ||||
|         name: node.querySelector('input[name="name"]').value, | ||||
|         tts: node.querySelector('input[name="tts"]').checked, | ||||
|         username: node.querySelector('input[name="username"]').value, | ||||
| @@ -428,9 +425,9 @@ function deserialize_reminder(reminder, frame, mode) { | ||||
|             .insertBefore(embed_field, lastChild); | ||||
|     } | ||||
|  | ||||
|     if (reminder["interval_seconds"]) update_interval(frame); | ||||
|  | ||||
|     if (mode !== "template") { | ||||
|         if (reminder["interval_seconds"]) update_interval(frame); | ||||
|  | ||||
|         let $enableBtn = frame.querySelector(".disable-enable"); | ||||
|         $enableBtn.dataset["action"] = reminder["enabled"] ? "disable" : "enable"; | ||||
|  | ||||
| @@ -457,23 +454,19 @@ document.addEventListener("guildSwitched", async (e) => { | ||||
|         .querySelectorAll(".patreon-only") | ||||
|         .forEach((el) => el.classList.add("is-locked")); | ||||
|  | ||||
|     let $anchor = document.querySelector( | ||||
|         `.switch-pane[data-guild="${e.detail.guild_id}"]` | ||||
|     ); | ||||
|     let $li = document.querySelector(`li[data-guild="${e.detail.guild_id}"]`); | ||||
|  | ||||
|     let hasError = false; | ||||
|  | ||||
|     if (pane() === null) { | ||||
|         window.history.replaceState({}, "", `/dashboard/${guildId()}/reminders`); | ||||
|     } | ||||
|  | ||||
|     switch_pane(pane()); | ||||
|  | ||||
|     if ($anchor !== null) { | ||||
|         $anchor.classList.add("is-active"); | ||||
|     if ($li === null) { | ||||
|         switch_pane("user-error"); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     switch_pane(e.detail.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))) { | ||||
|         document | ||||
| @@ -481,15 +474,26 @@ document.addEventListener("guildSwitched", async (e) => { | ||||
|             .forEach((el) => el.classList.remove("is-locked")); | ||||
|     } | ||||
|  | ||||
|     hasError = await fetch_channels(e.detail.guild_id); | ||||
|     const event = new CustomEvent("paneLoad", { | ||||
|         detail: { | ||||
|             guild_id: e.detail.guild_id, | ||||
|             pane: e.detail.pane, | ||||
|         }, | ||||
|     }); | ||||
|     document.dispatchEvent(event); | ||||
| }); | ||||
|  | ||||
| document.addEventListener("paneLoad", async (ev) => { | ||||
|     const hasError = await fetch_channels(ev.detail.guild_id); | ||||
|     if (!hasError) { | ||||
|         fetch_roles(e.detail.guild_id); | ||||
|         fetch_templates(e.detail.guild_id); | ||||
|         fetch_reminders(e.detail.guild_id); | ||||
|         fetch_roles(ev.detail.guild_id); | ||||
|         fetch_templates(ev.detail.guild_id); | ||||
|         fetch_reminders(ev.detail.guild_id); | ||||
|  | ||||
|         document.querySelectorAll("p.pageTitle").forEach((el) => { | ||||
|             el.textContent = `${e.detail.guild_name} Reminders`; | ||||
|             el.textContent = `${guildName()} Reminders`; | ||||
|         }); | ||||
|  | ||||
|         document.querySelectorAll("select.channel-selector").forEach((el) => { | ||||
|             el.addEventListener("change", (e) => { | ||||
|                 update_select(e.target); | ||||
| @@ -614,16 +618,6 @@ function show_error(error) { | ||||
|     }, 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.addEventListener("input", () => { | ||||
| @@ -704,40 +698,56 @@ document.addEventListener("DOMContentLoaded", async () => { | ||||
|                             "%guildname%", | ||||
|                             guild.name | ||||
|                         ); | ||||
|                         $anchor.dataset["guild"] = guild.id; | ||||
|                         $anchor.dataset["name"] = guild.name; | ||||
|                         $anchor.href = `/dashboard/${guild.id}/reminders`; | ||||
|  | ||||
|                         $anchor.addEventListener("click", async (e) => { | ||||
|                             e.preventDefault(); | ||||
|                             window.history.pushState( | ||||
|                                 {}, | ||||
|                                 "", | ||||
|                                 `/dashboard/${guild.id}/reminders` | ||||
|                             ); | ||||
|                             const event = new CustomEvent("guildSwitched", { | ||||
|                                 detail: { | ||||
|                                     guild_name: guild.name, | ||||
|                                     guild_id: guild.id, | ||||
|                                 }, | ||||
|                             }); | ||||
|                         const $li = $anchor.parentElement; | ||||
|                         $li.dataset["guild"] = guild.id; | ||||
|  | ||||
|                             document.dispatchEvent(event); | ||||
|                         $li.querySelectorAll("a").forEach((el) => { | ||||
|                             el.addEventListener("click", (e) => { | ||||
|                                 const pane = el.dataset["pane"]; | ||||
|                                 const slug = el.dataset["slug"]; | ||||
|  | ||||
|                                 if (pane !== undefined && slug !== undefined) { | ||||
|                                     e.preventDefault(); | ||||
|  | ||||
|                                     switch_pane(pane); | ||||
|  | ||||
|                                     window.history.pushState( | ||||
|                                         {}, | ||||
|                                         "", | ||||
|                                         `/dashboard/${guild.id}/${slug}` | ||||
|                                     ); | ||||
|                                     const event = new CustomEvent("guildSwitched", { | ||||
|                                         detail: { | ||||
|                                             guild_id: guild.id, | ||||
|                                             pane, | ||||
|                                         }, | ||||
|                                     }); | ||||
|  | ||||
|                                     document.dispatchEvent(event); | ||||
|                                 } | ||||
|                             }); | ||||
|                         }); | ||||
|  | ||||
|                         element.append($clone); | ||||
|                     }); | ||||
|                 } | ||||
|  | ||||
|                 const matches = window.location.href.match(/dashboard\/(\d+)/); | ||||
|                 const matches = window.location.href.match( | ||||
|                     /dashboard\/(\d+)(\/)?([a-zA-Z\-]+)?/ | ||||
|                 ); | ||||
|                 if (matches) { | ||||
|                     let id = matches[1]; | ||||
|                     let kind = matches[3]; | ||||
|                     let name = guildNames[id]; | ||||
|  | ||||
|                     const event = new CustomEvent("guildSwitched", { | ||||
|                         detail: { | ||||
|                             guild_name: name, | ||||
|                             guild_id: id, | ||||
|                             pane: kind, | ||||
|                         }, | ||||
|                     }); | ||||
|  | ||||
| @@ -778,26 +788,12 @@ $uploader.addEventListener("change", (ev) => { | ||||
|         fileReader.onload = (e) => resolve(fileReader.result); | ||||
|         fileReader.readAsDataURL($uploader.files[0]); | ||||
|     }).then((dataUrl) => { | ||||
|         $importBtn.setAttribute("disabled", true); | ||||
|  | ||||
|         fetch(`/dashboard/api/guild/${guildId()}/export/${urlTail}`, { | ||||
|             method: "PUT", | ||||
|             body: JSON.stringify({ body: dataUrl.split(",")[1] }), | ||||
|         }) | ||||
|             .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); | ||||
|             }); | ||||
|         }).then(() => { | ||||
|             delete $uploader.files[0]; | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
|   | ||||
							
								
								
									
										43
									
								
								web/static/js/reminder_errors.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								web/static/js/reminder_errors.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| 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/fa.css"> | ||||
|     <link rel="stylesheet" href="/static/css/font.css"> | ||||
|     <link rel="stylesheet" href="/static/css/style.css?v{{ version }}"> | ||||
|     <link rel="stylesheet" href="/static/css/style.css"> | ||||
|     <link rel="stylesheet" href="/static/css/dtsel.css"> | ||||
|     <link rel="stylesheet" href="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"> | ||||
|         <a class="navbar-item" href="/"> | ||||
|             <figure class="image"> | ||||
|                 <img width="28px" height="28px" src="/static/img/logo_nobg.webp" alt="Reminder Bot Logo"> | ||||
|                 <img src="/static/img/logo_nobg.webp" alt="Reminder Bot Logo"> | ||||
|             </figure> | ||||
|         </a> | ||||
|  | ||||
| @@ -76,10 +76,6 @@ | ||||
|     <span class="icon"><i class="far fa-exclamation-circle"></i></span> <span class="error-message"></span> | ||||
| </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-background"></div> | ||||
|     <div class="modal-card"> | ||||
| @@ -189,6 +185,14 @@ | ||||
|                     </label> | ||||
|                 </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> | ||||
|             <div class="has-text-centered"> | ||||
|                 <div style="color: red"> | ||||
| @@ -230,7 +234,6 @@ | ||||
|         <a href="/"> | ||||
|             <div class="brand"> | ||||
|                 <img src="/static/img/logo_nobg.webp" alt="Reminder bot logo" | ||||
|                      width="52px" height="52px" | ||||
|                      class="dashboard-brand"> | ||||
|             </div> | ||||
|         </a> | ||||
| @@ -253,9 +256,11 @@ | ||||
|                 </p> | ||||
|                 <ul class="menu-list"> | ||||
|                     <li> | ||||
|                         {# | ||||
|                         <a class="show-modal" data-modal="dataManagerModal"> | ||||
|                             <span class="icon"><i class="fas fa-exchange"></i></span> Import/Export | ||||
|                         </a> | ||||
|                         #} | ||||
|                         <a class="show-modal" data-modal="chooseTimezoneModal"> | ||||
|                             <span class="icon"><i class="fas fa-map-marked"></i></span> Timezone | ||||
|                         </a> | ||||
| @@ -297,9 +302,11 @@ | ||||
|                 </p> | ||||
|                 <ul class="menu-list"> | ||||
|                     <li> | ||||
|                         {# | ||||
|                         <a class="show-modal" data-modal="dataManagerModal"> | ||||
|                             <span class="icon"><i class="fas fa-exchange"></i></span> Import/Export | ||||
|                         </a> | ||||
|                         #} | ||||
|                         <a class="show-modal" data-modal="chooseTimezoneModal"> | ||||
|                             <span class="icon"><i class="fas fa-map-marked"></i></span> Timezone | ||||
|                         </a> | ||||
| @@ -325,16 +332,16 @@ | ||||
|                 <p class="subtitle is-hidden-desktop">Press the <span class="icon"><i class="fal fa-bars"></i></span> to get started</p> | ||||
|             </div> | ||||
|         </section> | ||||
|         <section id="reminders" class="is-hidden"> | ||||
|         <section data-name="reminders" class="is-hidden"> | ||||
|             {% include "reminder_dashboard/reminder_dashboard" %} | ||||
|         </section> | ||||
|         <section id="reminder-errors" class="is-hidden"> | ||||
|         <section data-name="errors" class="is-hidden"> | ||||
|             {% include "reminder_dashboard/reminder_errors" %} | ||||
|         </section> | ||||
|         <section id="guild-error" class="is-hidden"> | ||||
|         <section data-name="guild-error" class="is-hidden"> | ||||
|             {% include "reminder_dashboard/guild_error" %} | ||||
|         </section> | ||||
|         <section id="user-error" class="is-hidden"> | ||||
|         <section data-name="user-error" class="is-hidden"> | ||||
|             {% include "reminder_dashboard/user_error" %} | ||||
|         </section> | ||||
|     </div> | ||||
| @@ -368,22 +375,36 @@ | ||||
|  | ||||
| <template id="guildListEntry"> | ||||
|     <li> | ||||
|         <a class="switch-pane" data-pane="guild"> | ||||
|         <a class="switch-pane" data-pane="reminders" data-slug="reminders"> | ||||
|             <span class="icon"><i class="fas fa-map-pin"></i></span> <span class="guild-name">%guildname%</span> | ||||
|         </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> | ||||
| </template> | ||||
|  | ||||
| <template id="guildReminder"> | ||||
|     {% include "reminder_dashboard/guild_reminder" %} | ||||
|     {% include "reminder_dashboard/templates/guild_reminder" %} | ||||
| </template> | ||||
|  | ||||
| <template id="reminderError"> | ||||
|     {% include "reminder_dashboard/templates/reminder_error" %} | ||||
| </template> | ||||
|  | ||||
| <script src="/static/js/iro.js"></script> | ||||
| <script src="/static/js/dtsel.js"></script> | ||||
|  | ||||
| <script src="/static/js/interval.js?v{{ version }}"></script> | ||||
| <script src="/static/js/timezone.js?v{{ version }}" defer></script> | ||||
| <script src="/static/js/main.js?v{{ version }}" defer></script> | ||||
| <script src="/static/js/interval.js"></script> | ||||
| <script src="/static/js/timezone.js" defer></script> | ||||
| <script src="/static/js/main.js" defer></script> | ||||
|  | ||||
| </body> | ||||
| </html> | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|     <strong>Create Reminder</strong> | ||||
|     <div id="reminderCreator"> | ||||
|         {% set creating = true %} | ||||
|         {% include "reminder_dashboard/guild_reminder" %} | ||||
|         {% include "reminder_dashboard/templates/guild_reminder" %} | ||||
|         {% set creating = false %} | ||||
|     </div> | ||||
|     <br> | ||||
| @@ -46,6 +46,10 @@ | ||||
|     <div id="guildReminders"> | ||||
|  | ||||
|     </div> | ||||
|  | ||||
|     <div id="guildErrors"> | ||||
|  | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <script src="/static/js/sort.js"></script> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <div> | ||||
| <div id="reminderLog"> | ||||
|  | ||||
| </div> | ||||
|  | ||||
| <!--<script src="/static/js/reminder_errors.js"></script>--> | ||||
| <script src="/static/js/reminder_errors.js"></script> | ||||
|   | ||||
| @@ -133,27 +133,32 @@ | ||||
|             </article> | ||||
|         </div> | ||||
|         <div class="column settings"> | ||||
|             <div class="field channel-field"> | ||||
|                 <div class="collapses"> | ||||
|                     <label class="label" for="channelOption">Channel*</label> | ||||
|                 </div> | ||||
|                 <div class="control has-icons-left"> | ||||
|                     <div class="select"> | ||||
|                         <select name="channel" class="channel-selector"> | ||||
|                         </select> | ||||
|                     </div> | ||||
|                     <div class="icon is-small is-left"> | ||||
|                         <i class="fas fa-hashtag"></i> | ||||
|             <div class="columns"> | ||||
|                 <div class="column"> | ||||
|                     <div class="field channel-field"> | ||||
|                         <div class="collapses"> | ||||
|                             <label class="label" for="channelOption">Channel*</label> | ||||
|                         </div> | ||||
|                         <div class="control has-icons-left"> | ||||
|                             <div class="select"> | ||||
|                                 <select name="channel" class="channel-selector"> | ||||
|                                 </select> | ||||
|                             </div> | ||||
|                             <div class="icon is-small is-left"> | ||||
|                                 <i class="fas fa-hashtag"></i> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
| 
 | ||||
|             <div class="field"> | ||||
|                 <div class="control"> | ||||
|                     <label class="label collapses"> | ||||
|                         Time* | ||||
|                         <input class="input prefill-now" type="datetime-local" step="1" name="time"> | ||||
|                     </label> | ||||
|                 <div class="column"> | ||||
|                     <div class="field"> | ||||
|                         <div class="control"> | ||||
|                             <label class="label collapses"> | ||||
|                                 Time* | ||||
|                                 <input class="input prefill-now" type="datetime-local" step="1" name="time"> | ||||
|                             </label> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
| 
 | ||||
| @@ -231,39 +236,40 @@ | ||||
|                         </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> | ||||
|     {% 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> | ||||
| @@ -0,0 +1,17 @@ | ||||
| <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