Compare commits
	
		
			131 Commits
		
	
	
		
			98191d29ee
			...
			jude/react
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 1a03c2471b | ||
| a476f43f28 | |||
| 17192b0f89 | |||
|  | 0419863afa | ||
|  | 827a982a40 | ||
|  | 6e435bfc2e | ||
| 8ba0f02b98 | |||
| d36438c6ce | |||
| e0c60e2ce3 | |||
|  | e7160215b0 | ||
|  | 6eaa6f0f28 | ||
|  | 9db0fa2513 | ||
|  | ca13fd4fa7 | ||
|  | 55acc8fd16 | ||
|  | 145711fa5d | ||
|  | 5524215786 | ||
|  | e8bd05893f | ||
|  | e3d3418f99 | ||
|  | 2681280a39 | ||
|  | 00579428a1 | ||
|  | b8ef999710 | ||
|  | e8f84e281a | ||
|  | 8ddff698e5 | ||
|  | 541633270c | ||
|  | 25286da5e0 | ||
|  | 4bad1324b9 | ||
|  | bd1462a00c | ||
|  | 56ffc43616 | ||
|  | 52cf642455 | ||
|  | 0bf578357a | ||
|  | 6e9eccb62e | ||
|  | 6ea28284ce | ||
|  | a6525f3052 | ||
|  | 348639270d | ||
|  | 37177c2431 | ||
|  | 8587bed703 | ||
|  | 6c9af1ae8e | ||
|  | 7695b7a476 | ||
| 651da7b28e | |||
| eb086146bf | |||
| 4ebd705e5e | |||
| 5a85f1d83a | |||
| 68ba25886a | |||
|  | e25bf6b828 | ||
|  | 5a386daa9d | ||
|  | 0d4a02fb1e | ||
|  | e135a74a9b | ||
|  | 77f17c8dc2 | ||
|  | 6a94f990cf | ||
|  | 3aa5bd37aa | ||
|  | fa83fed1af | ||
|  | 666cb7fa2f | ||
|  | a5678e15dc | ||
|  | 9405cfcee9 | ||
|  | cb25d02cdf | ||
|  | bfe651a125 | ||
|  | dc5e52d9ce | ||
|  | 229ada83e1 | ||
|  | 13171d6744 | ||
|  | 2ad941c94c | ||
|  | 924d31e978 | ||
|  | f9a1b23212 | ||
|  | ae5795a7ea | ||
|  | ee36c38eda | ||
|  | eca7df3d9f | ||
|  | 902b7e1b4a | ||
|  | db1a53a797 | ||
|  | 3605d71b73 | ||
|  | ea2cea573e | ||
|  | d5fa8036e8 | ||
|  | b8707bbc9a | ||
|  | 99eea16f62 | ||
|  | 88737302f3 | ||
|  | 213e3a5100 | ||
|  | 8fa1402ecc | ||
|  | e63996bb61 | ||
|  | 9ede879630 | ||
|  | 88e9826a62 | ||
|  | 5d655c7e6d | ||
|  | 51c9d8a7ae | ||
|  | 90df265114 | ||
|  | e65429aa9c | ||
|  | 8d2232f0da | ||
|  | a58b9866ea | ||
|  | b1f25be5d7 | ||
|  | f0f9787326 | ||
|  | 302f5835e6 | ||
|  | 58c778632e | ||
|  | 5671fd462b | ||
|  | 5ac9733f15 | ||
|  | 01dc0334fd | ||
|  | 4a17aac15c | ||
|  | 8ce4fc9c6d | ||
|  | b4f07cfc1c | ||
|  | 8799089b2d | ||
|  | 88c4830209 | ||
|  | 4dd3df5cc2 | ||
|  | 369a325a46 | ||
|  | 1a1a0fdefb | ||
|  | dda8bd3e10 | ||
|  | edbfc92cb9 | ||
|  | 6de11f09db | ||
|  | 284bfcd9ad | ||
|  | 3d627b5bf0 | ||
|  | c3c0dbbbae | ||
|  | 64dd81e941 | ||
|  | 799298ca34 | ||
|  | fa542bb24f | ||
|  | e025d945cf | ||
|  | bb1c61d0b9 | ||
|  | 1519474f93 | ||
|  | 9d8622f418 | ||
|  | a66db37b33 | ||
|  | c8c1a171d4 | ||
|  | 88cfb829e3 | ||
|  | 16be7a328e | ||
|  | 04babf7930 | ||
|  | 96bc09e8b5 | ||
|  | 976fb91ecc | ||
|  | 1305b6e64e | ||
|  | cdfe44d958 | ||
|  | c824a36832 | ||
|  | c4bd2c1d18 | ||
|  | 561555ab7e | ||
|  | 115fbd44cb | ||
|  | aa931328b0 | ||
| 4b42966284 | |||
| 523ab7f03a | |||
| 6e831c8253 | |||
|  | 4416e5d175 | ||
|  | 734a39a001 | 
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -2,6 +2,6 @@ | ||||
| .env | ||||
| /venv | ||||
| .cargo | ||||
| assets | ||||
| out.json | ||||
| /.idea | ||||
| web/static/index.html | ||||
| web/static/assets | ||||
|   | ||||
							
								
								
									
										2329
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2329
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										27
									
								
								Cargo.toml
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								Cargo.toml
									
									
									
									
									
								
							| @@ -1,18 +1,18 @@ | ||||
| [package] | ||||
| name = "reminder-rs" | ||||
| version = "1.6.10" | ||||
| version = "1.6.50" | ||||
| authors = ["Jude Southworth <judesouthworth@pm.me>"] | ||||
| edition = "2021" | ||||
| license = "AGPL-3.0 only" | ||||
| description = "Reminder Bot for Discord, now in Rust" | ||||
|  | ||||
| [dependencies] | ||||
| poise = "0.4" | ||||
| poise = "0.5" | ||||
| dotenv = "0.15" | ||||
| tokio = { version = "1", features = ["process", "full"] } | ||||
| reqwest = "0.11" | ||||
| lazy-regex = "2.3.0" | ||||
| regex = "1.6" | ||||
| lazy-regex = "3.0.2" | ||||
| regex = "1.9" | ||||
| log = "0.4" | ||||
| env_logger = "0.10" | ||||
| chrono = "0.4" | ||||
| @@ -25,8 +25,8 @@ serde_repr = "0.1" | ||||
| rmp-serde = "1.1" | ||||
| rand = "0.8" | ||||
| levenshtein = "1.0" | ||||
| sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]} | ||||
| base64 = "0.13" | ||||
| sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]} | ||||
| base64 = "0.21.0" | ||||
|  | ||||
| [dependencies.postman] | ||||
| path = "postman" | ||||
| @@ -35,15 +35,24 @@ path = "postman" | ||||
| path = "web" | ||||
|  | ||||
| [package.metadata.deb] | ||||
| depends = "$auto, python3-dateparser" | ||||
| depends = "$auto, python3-dateparser (>= 1.0.0)" | ||||
| suggests = "mysql-server-8.0, nginx" | ||||
| maintainer-scripts = "debian" | ||||
| assets = [ | ||||
|     ["target/release/reminder-rs", "usr/bin/reminder-rs", "755"], | ||||
|     ["conf/default.env", "etc/reminder-rs/default.env", "600"], | ||||
| #    ["web/static/", "var/www/reminder-rs/static", "755"], | ||||
|     ["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"], | ||||
| #    ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"] | ||||
| ] | ||||
| conf-files = [ | ||||
|     "/etc/reminder-rs/config.env", | ||||
|     "/etc/reminder-rs/Rocket.toml", | ||||
| ] | ||||
|  | ||||
| [package.metadata.deb.systemd-units] | ||||
| unit-scripts = "systemd" | ||||
|   | ||||
| @@ -5,5 +5,5 @@ ENV RUSTUP_HOME=/usr/local/rustup \ | ||||
|     PATH=/usr/local/cargo/bin:$PATH | ||||
|  | ||||
| RUN apt update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt install -y gcc gcc-multilib cmake pkg-config libssl-dev curl mysql-client-8.0 | ||||
| RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal | ||||
| RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal --default-toolchain nightly | ||||
| RUN cargo install cargo-deb | ||||
|   | ||||
							
								
								
									
										48
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										48
									
								
								README.md
									
									
									
									
									
								
							| @@ -7,30 +7,36 @@ reminders are paid on the hosted version of the bot. Keep reading if you want to | ||||
|  | ||||
| You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust) | ||||
|  | ||||
| ### Compiling | ||||
| Install build requirements:  | ||||
| ### Build APT package | ||||
|  | ||||
| Recommended method. | ||||
|  | ||||
| By default, this builds targeting Ubuntu 20.04. Modify the Containerfile if you wish to target a different platform. These instructions are written using `podman`, but `docker` should work too. | ||||
|  | ||||
| 1. Install container software: `sudo apt install podman`. | ||||
| 2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders` | ||||
| 3. Install SQLx CLI: `cargo install sqlx-cli` | ||||
| 4. From the source code directory, execute `sqlx migrate run` | ||||
| 5. Build container image: `podman build -t reminder-rs .` | ||||
| 6. Build with podman: `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`  | ||||
|  | ||||
|  | ||||
| ### Compiling for other target | ||||
|  | ||||
| 1. Install requirements:  | ||||
| `sudo apt install gcc gcc-multilib cmake libssl-dev build-essential python3-dateparser` | ||||
| 2. Install rustup from https://rustup.rs | ||||
| 3. Install the nightly toolchain: `rustup toolchain default nightly` | ||||
| 4. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`. | ||||
| 5. Install `sqlx-cli`: `cargo install sqlx-cli`. | ||||
| 6. Run migrations: `sqlx migrate run`. | ||||
| 7. Set environment variables: | ||||
|    * `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`) | ||||
| 8. Build: `cargo build --release` | ||||
|  | ||||
| Install Rust from https://rustup.rs | ||||
|  | ||||
| Reminder Bot can then be built by running `cargo build --release` in the top level directory. It is necessary to create a  | ||||
| folder called 'assets' containing an image file with its name specified in the environment as `WEBHOOK_AVATAR`, of  | ||||
| dimensions 128x128px to be used as the webhook avatar. | ||||
| ### Configuring | ||||
|  | ||||
| #### Compilation environment variables | ||||
| These environment variables must be provided when compiling the bot | ||||
| * `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`) | ||||
| * `WEBHOOK_AVATAR` - accepts the name of an image file located in `$CARGO_MANIFEST_DIR/assets/` to be used as the avatar when creating webhooks. **IMPORTANT: image file must be 128x128 or smaller in size** | ||||
|  | ||||
| ### Setting up database | ||||
| Use MySQL 8. MariaDB is confirmed not working at the moment. | ||||
|  | ||||
| Load the SQL files in order from "migrations" to generate the database schema. | ||||
|  | ||||
| ### Setting up Python | ||||
| Reminder Bot uses `python3-dateparser` to handle dates. This depends on Python 3. | ||||
|  | ||||
| ### Environment Variables | ||||
| Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory. | ||||
|  | ||||
| __Required Variables__ | ||||
| @@ -42,5 +48,5 @@ __Other Variables__ | ||||
| * `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor | ||||
| * `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users | ||||
| * `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to | ||||
| * `PYTHON_LOCATION` - default `venv/bin/python3`. Can be changed if your Python executable is located somewhere else | ||||
| * `PYTHON_LOCATION` - default `/usr/bin/python3`. Can be changed if your Python executable is located somewhere else | ||||
| * `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds  | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								assets/webhook.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/webhook.jpg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 21 KiB | 
							
								
								
									
										8
									
								
								conf/Rocket.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								conf/Rocket.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| [default] | ||||
| address = "127.0.0.1" | ||||
| port = 18920 | ||||
| template_dir = "/lib/reminder-rs/templates" | ||||
| limits = { json = "10MiB" } | ||||
|  | ||||
| [release] | ||||
| # secret_key = "" | ||||
| @@ -7,10 +7,13 @@ PATREON_ROLE_ID= | ||||
| LOCAL_TIMEZONE= | ||||
| MIN_INTERVAL= | ||||
| PYTHON_LOCATION=/usr/bin/python3 | ||||
| DONTRUN=web | ||||
| DONTRUN= | ||||
| SECRET_KEY= | ||||
|  | ||||
| REMIND_INTERVAL= | ||||
| OAUTH2_DISCORD_CALLBACK= | ||||
| OAUTH2_CLIENT_ID= | ||||
| OAUTH2_CLIENT_SECRET= | ||||
|  | ||||
| REPORT_EMAIL= | ||||
| LOG_TO_DATABASE=1 | ||||
|   | ||||
							
								
								
									
										1
									
								
								cron.d/reminder_health
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								cron.d/reminder_health
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| */10 * * * * reminder /lib/reminder-rs/healthcheck | ||||
							
								
								
									
										6
									
								
								debian/postinst
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								debian/postinst
									
									
									
									
										vendored
									
									
								
							| @@ -4,10 +4,6 @@ set -e | ||||
|  | ||||
| id -u reminder &>/dev/null || useradd -r -M reminder | ||||
|  | ||||
| if [ ! -f /etc/reminder-rs/config.env ]; then | ||||
|   cp /etc/reminder-rs/default.env /etc/reminder-rs/config.env | ||||
| fi | ||||
|  | ||||
| chown reminder /etc/reminder-rs/config.env | ||||
| chown -R reminder /etc/reminder-rs | ||||
|  | ||||
| #DEBHELPER# | ||||
|   | ||||
							
								
								
									
										4
									
								
								debian/postrm
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								debian/postrm
									
									
									
									
										vendored
									
									
								
							| @@ -4,8 +4,4 @@ set -e | ||||
|  | ||||
| id -u reminder &>/dev/null || userdel reminder | ||||
|  | ||||
| if [ -f /etc/reminder-rs/config.env ]; then | ||||
|   rm /etc/reminder-rs/config.env | ||||
| fi | ||||
|  | ||||
| #DEBHELPER# | ||||
|   | ||||
							
								
								
									
										13
									
								
								healthcheck
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										13
									
								
								healthcheck
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| #!/bin/bash | ||||
|  | ||||
| export $(grep -v '^#' /etc/reminder-rs/config.env | xargs -d '\n') | ||||
|  | ||||
| REGEX='mysql://([A-Za-z]+)@(.+)/(.+)' | ||||
| [[ $DATABASE_URL =~ $REGEX ]] | ||||
|  | ||||
| VAR=$(mysql -u "${BASH_REMATCH[1]}" -h "${BASH_REMATCH[2]}" -N -D "${BASH_REMATCH[3]}" -e "SELECT COUNT(1) FROM reminders WHERE utc_time < NOW() - INTERVAL 10 MINUTE AND enabled = 1 AND status = 'pending'") | ||||
|  | ||||
| if [ "$VAR" -gt 0 ] | ||||
| then | ||||
|   echo "This is to inform that there is a reminder backlog which must be resolved." | mail -s "Backlog: $VAR" "$REPORT_EMAIL" | ||||
| fi | ||||
							
								
								
									
										1
									
								
								migrations/20230511125236_reminder_threads.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								migrations/20230511125236_reminder_threads.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| ALTER TABLE reminders ADD COLUMN `thread_id` BIGINT DEFAULT NULL; | ||||
							
								
								
									
										1
									
								
								migrations/20230511180231_ephemeral_confirmations.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								migrations/20230511180231_ephemeral_confirmations.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| ALTER TABLE guilds ADD COLUMN ephemeral_confirmations BOOL NOT NULL DEFAULT 0; | ||||
							
								
								
									
										2
									
								
								migrations/20230722130906_increase_reminder_name.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								migrations/20230722130906_increase_reminder_name.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| ALTER TABLE reminders MODIFY `name` VARCHAR(100) NOT NULL DEFAULT 'Reminder'; | ||||
| ALTER TABLE reminder_template MODIFY `name` VARCHAR(100) NOT NULL DEFAULT 'Reminder'; | ||||
							
								
								
									
										9
									
								
								migrations/20230730134827_stats.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								migrations/20230730134827_stats.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| CREATE TABLE stat ( | ||||
|     `id` BIGINT NOT NULL AUTO_INCREMENT, | ||||
|     `utc_time` DATETIME NOT NULL DEFAULT NOW(), | ||||
|     `type` ENUM('reminder_sent', 'reminder_failed'), | ||||
|     `reminder_id` INT UNSIGNED, | ||||
|     `message` TEXT, | ||||
|  | ||||
|     PRIMARY KEY (`id`) | ||||
| ); | ||||
							
								
								
									
										2
									
								
								migrations/20230731170452_reminder_archive.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								migrations/20230731170452_reminder_archive.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| ALTER TABLE reminders ADD COLUMN `status` ENUM ('pending', 'sent', 'failed', 'deleted') NOT NULL DEFAULT 'pending'; | ||||
| ALTER TABLE reminders ADD COLUMN `status_message` TEXT; | ||||
| @@ -0,0 +1,3 @@ | ||||
| ALTER TABLE `reminder_template` ADD COLUMN `interval_seconds` INT UNSIGNED; | ||||
| ALTER TABLE `reminder_template` ADD COLUMN `interval_days` INT UNSIGNED; | ||||
| ALTER TABLE `reminder_template` ADD COLUMN `interval_months` INT UNSIGNED; | ||||
| @@ -5,12 +5,12 @@ edition = "2021" | ||||
|  | ||||
| [dependencies] | ||||
| tokio = { version = "1", features = ["process", "full"] } | ||||
| regex = "1.4" | ||||
| regex = "1.9" | ||||
| log = "0.4" | ||||
| chrono = "0.4" | ||||
| chrono-tz = { version = "0.5", features = ["serde"] } | ||||
| chrono-tz = { version = "0.8", features = ["serde"] } | ||||
| lazy_static = "1.4" | ||||
| num-integer = "0.1" | ||||
| serde = "1.0" | ||||
| 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"] } | ||||
| 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"] } | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| use std::env; | ||||
|  | ||||
| use chrono::{DateTime, Days, Duration, Months}; | ||||
| use chrono_tz::Tz; | ||||
| use lazy_static::lazy_static; | ||||
| @@ -7,7 +9,7 @@ use regex::{Captures, Regex}; | ||||
| use serde::Deserialize; | ||||
| use serenity::{ | ||||
|     builder::CreateEmbed, | ||||
|     http::{CacheHttp, Http, HttpError, StatusCode}, | ||||
|     http::{CacheHttp, Http, HttpError}, | ||||
|     model::{ | ||||
|         channel::{Channel, Embed as SerenityEmbed}, | ||||
|         id::ChannelId, | ||||
| @@ -30,6 +32,7 @@ lazy_static! { | ||||
|         Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap(); | ||||
|     pub static ref TIMENOW_REGEX: Regex = | ||||
|         Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap(); | ||||
|     pub static ref LOG_TO_DATABASE: bool = env::var("LOG_TO_DATABASE").map_or(true, |v| v == "1"); | ||||
| } | ||||
|  | ||||
| fn fmt_displacement(format: &str, seconds: u64) -> String { | ||||
| @@ -151,7 +154,7 @@ impl Embed { | ||||
|                 embed.description = substitute(&embed.description); | ||||
|                 embed.footer = substitute(&embed.footer); | ||||
|  | ||||
|                 embed.fields.iter_mut().for_each(|mut field| { | ||||
|                 embed.fields.iter_mut().for_each(|field| { | ||||
|                     field.title = substitute(&field.title); | ||||
|                     field.value = substitute(&field.value); | ||||
|                 }); | ||||
| @@ -299,16 +302,19 @@ INNER JOIN | ||||
| ON | ||||
|     reminders.channel_id = channels.id | ||||
| WHERE | ||||
|     reminders.`status` = 'pending' AND | ||||
|     reminders.`id` IN ( | ||||
|         SELECT | ||||
|             MIN(id) | ||||
|         FROM | ||||
|             reminders | ||||
|         WHERE | ||||
|             reminders.`utc_time` <= NOW() | ||||
|             AND ( | ||||
|             reminders.`utc_time` <= NOW() AND | ||||
|             `status` = 'pending' AND | ||||
|             ( | ||||
|                 reminders.`interval_seconds` IS NOT NULL | ||||
|                 OR reminders.`interval_months` IS NOT NULL | ||||
|                 OR reminders.`interval_days` IS NOT NULL | ||||
|                 OR reminders.enabled | ||||
|             ) | ||||
|         GROUP BY channel_id | ||||
| @@ -345,40 +351,68 @@ WHERE | ||||
|     } | ||||
|  | ||||
|     async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) { | ||||
|         if self.interval_seconds.is_some() || self.interval_months.is_some() { | ||||
|         if self.interval_seconds.is_some() | ||||
|             || self.interval_months.is_some() | ||||
|             || self.interval_days.is_some() | ||||
|         { | ||||
|             // If all intervals are zero then dont care | ||||
|             if self.interval_seconds == Some(0) | ||||
|                 && self.interval_days == Some(0) | ||||
|                 && self.interval_months == Some(0) | ||||
|             { | ||||
|                 self.set_sent(pool).await; | ||||
|             } | ||||
|  | ||||
|             let now = Utc::now(); | ||||
|             let mut updated_reminder_time = | ||||
|                 self.utc_time.with_timezone(&self.timezone.parse().unwrap_or(Tz::UTC)); | ||||
|             let mut fail_count = 0; | ||||
|  | ||||
|             while updated_reminder_time < now { | ||||
|             while updated_reminder_time < now && fail_count < 4 { | ||||
|                 if let Some(interval) = self.interval_months { | ||||
|                     updated_reminder_time = updated_reminder_time | ||||
|                         .checked_add_months(Months::new(interval)) | ||||
|                         .unwrap_or_else(|| { | ||||
|                             warn!("Could not add months to a reminder"); | ||||
|                     if interval != 0 { | ||||
|                         updated_reminder_time = updated_reminder_time | ||||
|                             .checked_add_months(Months::new(interval)) | ||||
|                             .unwrap_or_else(|| { | ||||
|                                 warn!( | ||||
|                                     "{}: Could not add {} months to a reminder", | ||||
|                                     interval, self.id | ||||
|                                 ); | ||||
|                                 fail_count += 1; | ||||
|  | ||||
|                             updated_reminder_time | ||||
|                         }); | ||||
|                                 updated_reminder_time | ||||
|                             }); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 if let Some(interval) = self.interval_days { | ||||
|                     updated_reminder_time = updated_reminder_time | ||||
|                         .checked_add_days(Days::new(interval as u64)) | ||||
|                         .unwrap_or_else(|| { | ||||
|                             warn!("Could not add days to a reminder"); | ||||
|                     if interval != 0 { | ||||
|                         updated_reminder_time = updated_reminder_time | ||||
|                             .checked_add_days(Days::new(interval as u64)) | ||||
|                             .unwrap_or_else(|| { | ||||
|                                 warn!("{}: Could not add {} days to a reminder", self.id, interval); | ||||
|                                 fail_count += 1; | ||||
|  | ||||
|                             updated_reminder_time | ||||
|                         }); | ||||
|                                 updated_reminder_time | ||||
|                             }) | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 if let Some(interval) = self.interval_seconds { | ||||
|                     updated_reminder_time = | ||||
|                         updated_reminder_time + Duration::seconds(interval as i64); | ||||
|                     updated_reminder_time += Duration::seconds(interval as i64); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if self.expires.map_or(false, |expires| updated_reminder_time > expires) { | ||||
|                 self.force_delete(pool).await; | ||||
|             if fail_count >= 4 { | ||||
|                 self.log_error( | ||||
|                     pool, | ||||
|                     "Failed to update 4 times and so is being deleted", | ||||
|                     None::<&'static str>, | ||||
|                 ) | ||||
|                 .await; | ||||
|                 self.set_failed(pool, "Failed to update 4 times and so is being deleted").await; | ||||
|             } else if self.expires.map_or(false, |expires| updated_reminder_time > expires) { | ||||
|                 self.set_sent(pool).await; | ||||
|             } else { | ||||
|                 sqlx::query!( | ||||
|                     "UPDATE reminders SET `utc_time` = ? WHERE `id` = ?", | ||||
| @@ -390,17 +424,74 @@ WHERE | ||||
|                 .expect(&format!("Could not update time on Reminder {}", self.id)); | ||||
|             } | ||||
|         } else { | ||||
|             self.force_delete(pool).await; | ||||
|             self.set_sent(pool).await; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) { | ||||
|         sqlx::query!("DELETE FROM reminders WHERE `id` = ?", self.id) | ||||
|     async fn log_error( | ||||
|         &self, | ||||
|         pool: impl Executor<'_, Database = Database> + Copy, | ||||
|         error: &'static str, | ||||
|         debug_info: Option<impl std::fmt::Debug>, | ||||
|     ) { | ||||
|         let message = match debug_info { | ||||
|             Some(info) => format!( | ||||
|                 "{} | ||||
| {:?}", | ||||
|                 error, info | ||||
|             ), | ||||
|  | ||||
|             None => error.to_string(), | ||||
|         }; | ||||
|  | ||||
|         error!("[Reminder {}] {}", self.id, message); | ||||
|  | ||||
|         if *LOG_TO_DATABASE { | ||||
|             sqlx::query!( | ||||
|                 "INSERT INTO stat (type, reminder_id, message) VALUES ('reminder_failed', ?, ?)", | ||||
|                 self.id, | ||||
|                 message, | ||||
|             ) | ||||
|             .execute(pool) | ||||
|             .await | ||||
|             .expect("Could not log error to database"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async fn log_success(&self, pool: impl Executor<'_, Database = Database> + Copy) { | ||||
|         if *LOG_TO_DATABASE { | ||||
|             sqlx::query!( | ||||
|                 "INSERT INTO stat (type, reminder_id) VALUES ('reminder_sent', ?)", | ||||
|                 self.id, | ||||
|             ) | ||||
|             .execute(pool) | ||||
|             .await | ||||
|             .expect("Could not log success to database"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     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)); | ||||
|     } | ||||
|  | ||||
|     async fn set_failed( | ||||
|         &self, | ||||
|         pool: impl Executor<'_, Database = Database> + Copy, | ||||
|         message: &'static str, | ||||
|     ) { | ||||
|         sqlx::query!( | ||||
|             "UPDATE reminders SET `status` = 'failed', `status_message` = ? WHERE `id` = ?", | ||||
|             message, | ||||
|             self.id | ||||
|         ) | ||||
|         .execute(pool) | ||||
|         .await | ||||
|         .expect(&format!("Could not delete Reminder {}", self.id)); | ||||
|     } | ||||
|  | ||||
|     async fn pin_message<M: Into<u64>>(&self, message_id: M, http: impl AsRef<Http>) { | ||||
|         let _ = http.as_ref().pin_message(self.channel_id, message_id.into(), None).await; | ||||
|     } | ||||
| @@ -555,7 +646,7 @@ WHERE | ||||
|                 if let Ok(webhook) = webhook_res { | ||||
|                     send_to_webhook(cache_http, &self, webhook, embed).await | ||||
|                 } else { | ||||
|                     warn!("Webhook vanished: {:?}", webhook_res); | ||||
|                     warn!("Webhook vanished for reminder {}: {:?}", self.id, webhook_res); | ||||
|  | ||||
|                     self.reset_webhook(pool).await; | ||||
|                     send_to_channel(cache_http, &self, embed).await | ||||
| @@ -565,24 +656,84 @@ WHERE | ||||
|             }; | ||||
|  | ||||
|             if let Err(e) = result { | ||||
|                 error!("Error sending reminder {}: {:?}", self.id, e); | ||||
|  | ||||
|                 if let Error::Http(error) = e { | ||||
|                     if error.status_code() == Some(StatusCode::NOT_FOUND) { | ||||
|                         warn!("Seeing channel is deleted. Removing reminder"); | ||||
|                         self.force_delete(pool).await; | ||||
|                     } else if let HttpError::UnsuccessfulRequest(error) = *error { | ||||
|                         if error.error.code == 50007 { | ||||
|                             warn!("User cannot receive DMs"); | ||||
|                             self.force_delete(pool).await; | ||||
|                         } else { | ||||
|                             self.refresh(pool).await; | ||||
|                     if let HttpError::UnsuccessfulRequest(http_error) = *error { | ||||
|                         match http_error.error.code { | ||||
|                             10003 => { | ||||
|                                 self.log_error( | ||||
|                                     pool, | ||||
|                                     "Could not be sent as channel does not exist", | ||||
|                                     None::<&'static str>, | ||||
|                                 ) | ||||
|                                 .await; | ||||
|                                 self.set_failed( | ||||
|                                     pool, | ||||
|                                     "Could not be sent as channel does not exist", | ||||
|                                 ) | ||||
|                                 .await; | ||||
|                             } | ||||
|                             10004 => { | ||||
|                                 self.log_error( | ||||
|                                     pool, | ||||
|                                     "Could not be sent as guild does not exist", | ||||
|                                     None::<&'static str>, | ||||
|                                 ) | ||||
|                                 .await; | ||||
|                                 self.set_failed(pool, "Could not be sent as guild does not exist") | ||||
|                                     .await; | ||||
|                             } | ||||
|                             50001 => { | ||||
|                                 self.log_error( | ||||
|                                     pool, | ||||
|                                     "Could not be sent as missing access", | ||||
|                                     None::<&'static str>, | ||||
|                                 ) | ||||
|                                 .await; | ||||
|                                 self.set_failed(pool, "Could not be sent as missing access").await; | ||||
|                             } | ||||
|                             50007 => { | ||||
|                                 self.log_error( | ||||
|                                     pool, | ||||
|                                     "Could not be sent as user has DMs disabled", | ||||
|                                     None::<&'static str>, | ||||
|                                 ) | ||||
|                                 .await; | ||||
|                                 self.set_failed(pool, "Could not be sent as user has DMs disabled") | ||||
|                                     .await; | ||||
|                             } | ||||
|                             50013 => { | ||||
|                                 self.log_error( | ||||
|                                     pool, | ||||
|                                     "Could not be sent as permissions are invalid", | ||||
|                                     None::<&'static str>, | ||||
|                                 ) | ||||
|                                 .await; | ||||
|                                 self.set_failed( | ||||
|                                     pool, | ||||
|                                     "Could not be sent as permissions are invalid", | ||||
|                                 ) | ||||
|                                 .await; | ||||
|                             } | ||||
|                             _ => { | ||||
|                                 self.log_error( | ||||
|                                     pool, | ||||
|                                     "HTTP error sending reminder", | ||||
|                                     Some(http_error), | ||||
|                                 ) | ||||
|                                 .await; | ||||
|                                 self.refresh(pool).await; | ||||
|                             } | ||||
|                         } | ||||
|                     } else { | ||||
|                         self.log_error(pool, "(Likely) a parsing error", Some(error)).await; | ||||
|                         self.refresh(pool).await; | ||||
|                     } | ||||
|                 } else { | ||||
|                     self.log_error(pool, "Non-HTTP error", Some(e)).await; | ||||
|                     self.refresh(pool).await; | ||||
|                 } | ||||
|             } else { | ||||
|                 self.log_success(pool).await; | ||||
|                 self.refresh(pool).await; | ||||
|             } | ||||
|         } else { | ||||
|   | ||||
| @@ -55,7 +55,7 @@ pub async fn time_hint_autocomplete( | ||||
|                     if diff < 0 { | ||||
|                         vec![AutocompleteChoice { | ||||
|                             name: "Time is in the past".to_string(), | ||||
|                             value: "now".to_string(), | ||||
|                             value: "1 year ago".to_string(), | ||||
|                         }] | ||||
|                     } else { | ||||
|                         if diff > 86400 { | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -6,8 +6,8 @@ use crate::{models::CtxData, Context, Error, THEME_COLOR}; | ||||
| fn footer( | ||||
|     ctx: Context<'_>, | ||||
| ) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter { | ||||
|     let shard_count = ctx.discord().cache.shard_count(); | ||||
|     let shard = ctx.discord().shard_id; | ||||
|     let shard_count = ctx.serenity_context().cache.shard_count(); | ||||
|     let shard = ctx.serenity_context().shard_id; | ||||
|  | ||||
|     move |f| { | ||||
|         f.text(format!( | ||||
|   | ||||
| @@ -102,6 +102,78 @@ You may want to use one of the popular timezones below, otherwise click [here](h | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Configure server settings | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "settings", | ||||
|     identifying_name = "settings", | ||||
|     guild_only = true | ||||
| )] | ||||
| pub async fn settings(_ctx: Context<'_>) -> Result<(), Error> { | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Configure ephemeral setup | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "ephemeral", | ||||
|     identifying_name = "ephemeral_confirmations", | ||||
|     guild_only = true | ||||
| )] | ||||
| pub async fn ephemeral_confirmations(_ctx: Context<'_>) -> Result<(), Error> { | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Set reminder confirmations to be sent "ephemerally" (private and cleared automatically) | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "on", | ||||
|     identifying_name = "set_ephemeral_confirmations", | ||||
|     guild_only = true | ||||
| )] | ||||
| pub async fn set_ephemeral_confirmations(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let mut guild_data = ctx.guild_data().await.unwrap()?; | ||||
|     guild_data.ephemeral_confirmations = true; | ||||
|     guild_data.commit_changes(&ctx.data().database).await; | ||||
|  | ||||
|     ctx.send(|r| { | ||||
|         r.ephemeral(true).embed(|e| { | ||||
|             e.title("Confirmations ephemeral") | ||||
|                 .description("Reminder confirmations will be sent privately, and removed when your client restarts.") | ||||
|                 .color(*THEME_COLOR) | ||||
|         }) | ||||
|     }) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Set reminder confirmations to persist indefinitely | ||||
| #[poise::command( | ||||
|     slash_command, | ||||
|     rename = "off", | ||||
|     identifying_name = "unset_ephemeral_confirmations", | ||||
|     guild_only = true | ||||
| )] | ||||
| pub async fn unset_ephemeral_confirmations(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let mut guild_data = ctx.guild_data().await.unwrap()?; | ||||
|     guild_data.ephemeral_confirmations = false; | ||||
|     guild_data.commit_changes(&ctx.data().database).await; | ||||
|  | ||||
|     ctx.send(|r| { | ||||
|         r.ephemeral(true).embed(|e| { | ||||
|             e.title("Confirmations public") | ||||
|                 .description( | ||||
|                     "Reminder confirmations will be sent as regular messages, and won't be removed automatically.", | ||||
|                 ) | ||||
|                 .color(*THEME_COLOR) | ||||
|         }) | ||||
|     }) | ||||
|     .await?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Configure whether other users can set reminders to your direct messages | ||||
| #[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")] | ||||
| pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> { | ||||
| @@ -109,7 +181,7 @@ pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> { | ||||
| } | ||||
|  | ||||
| /// Allow other users to set reminders in your direct messages | ||||
| #[poise::command(slash_command, rename = "allow", identifying_name = "allowed_dm")] | ||||
| #[poise::command(slash_command, rename = "allow", identifying_name = "set_allowed_dm")] | ||||
| pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let mut user_data = ctx.author_data().await?; | ||||
|     user_data.allowed_dm = true; | ||||
| @@ -128,7 +200,7 @@ pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> { | ||||
| } | ||||
|  | ||||
| /// Block other users from setting reminders in your direct messages | ||||
| #[poise::command(slash_command, rename = "block", identifying_name = "allowed_dm")] | ||||
| #[poise::command(slash_command, rename = "block", identifying_name = "unset_allowed_dm")] | ||||
| pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let mut user_data = ctx.author_data().await?; | ||||
|     user_data.allowed_dm = false; | ||||
|   | ||||
| @@ -2,6 +2,7 @@ use std::{collections::HashSet, string::ToString}; | ||||
|  | ||||
| use chrono::{DateTime, NaiveDateTime, Utc}; | ||||
| use chrono_tz::Tz; | ||||
| use log::warn; | ||||
| use num_integer::Integer; | ||||
| use poise::{ | ||||
|     serenity_prelude::{ | ||||
| @@ -113,6 +114,8 @@ pub async fn offset( | ||||
|     #[description = "Number of minutes to offset by"] minutes: Option<isize>, | ||||
|     #[description = "Number of seconds to offset by"] seconds: Option<isize>, | ||||
| ) -> Result<(), Error> { | ||||
|     ctx.defer().await?; | ||||
|  | ||||
|     let combined_time = hours.map_or(0, |h| h * HOUR as isize) | ||||
|         + minutes.map_or(0, |m| m * MINUTE as isize) | ||||
|         + seconds.map_or(0, |s| s); | ||||
| @@ -215,7 +218,7 @@ pub async fn look( | ||||
|         }), | ||||
|     }; | ||||
|  | ||||
|     let channel_opt = ctx.channel_id().to_channel_cached(&ctx.discord()); | ||||
|     let channel_opt = ctx.channel_id().to_channel_cached(&ctx); | ||||
|  | ||||
|     let channel_id = if let Some(Channel::Guild(channel)) = channel_opt { | ||||
|         if Some(channel.guild_id) == ctx.guild_id() { | ||||
| @@ -227,12 +230,11 @@ pub async fn look( | ||||
|         ctx.channel_id() | ||||
|     }; | ||||
|  | ||||
|     let channel_name = | ||||
|         if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx.discord()) { | ||||
|             Some(channel.name) | ||||
|         } else { | ||||
|             None | ||||
|         }; | ||||
|     let channel_name = if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx) { | ||||
|         Some(channel.name) | ||||
|     } else { | ||||
|         None | ||||
|     }; | ||||
|  | ||||
|     let reminders = Reminder::from_channel(&ctx.data().database, channel_id, &flags).await; | ||||
|  | ||||
| @@ -294,8 +296,7 @@ pub async fn delete(ctx: Context<'_>) -> Result<(), Error> { | ||||
|     let timezone = ctx.timezone().await; | ||||
|  | ||||
|     let reminders = | ||||
|         Reminder::from_guild(&ctx.discord(), &ctx.data().database, ctx.guild_id(), ctx.author().id) | ||||
|             .await; | ||||
|         Reminder::from_guild(&ctx, &ctx.data().database, ctx.guild_id(), ctx.author().id).await; | ||||
|  | ||||
|     let resp = show_delete_page(&reminders, 0, timezone); | ||||
|  | ||||
| @@ -585,19 +586,31 @@ pub async fn multiline( | ||||
|     timezone: Option<String>, | ||||
| ) -> Result<(), Error> { | ||||
|     let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten(); | ||||
|     let data = ContentModal::execute(ctx).await?; | ||||
|     let data_opt = ContentModal::execute(ctx).await?; | ||||
|  | ||||
|     create_reminder( | ||||
|         Context::Application(ctx), | ||||
|         time, | ||||
|         data.content, | ||||
|         channels, | ||||
|         interval, | ||||
|         expires, | ||||
|         tts, | ||||
|         tz, | ||||
|     ) | ||||
|     .await | ||||
|     match data_opt { | ||||
|         Some(data) => { | ||||
|             create_reminder( | ||||
|                 Context::Application(ctx), | ||||
|                 time, | ||||
|                 data.content, | ||||
|                 channels, | ||||
|                 interval, | ||||
|                 expires, | ||||
|                 tts, | ||||
|                 tz, | ||||
|             ) | ||||
|             .await | ||||
|         } | ||||
|  | ||||
|         None => { | ||||
|             warn!("Unexpected None encountered in /multiline"); | ||||
|             Ok(Context::Application(ctx) | ||||
|                 .send(|m| m.content("Unexpected error.").ephemeral(true)) | ||||
|                 .await | ||||
|                 .map(|_| ())?) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Create a reminder. Press "+4 more" for other options. Use "/multiline" for multiline content. | ||||
| @@ -608,7 +621,7 @@ pub async fn multiline( | ||||
| )] | ||||
| pub async fn remind( | ||||
|     ctx: ApplicationContext<'_>, | ||||
|     #[description = "A description of the time to set the reminder for"] | ||||
|     #[description = "The time (and optionally date) to set the reminder for"] | ||||
|     #[autocomplete = "time_hint_autocomplete"] | ||||
|     time: String, | ||||
|     #[description = "The message content to send"] content: String, | ||||
| @@ -645,7 +658,13 @@ async fn create_reminder( | ||||
|         return Ok(()); | ||||
|     } | ||||
|  | ||||
|     ctx.defer().await?; | ||||
|     let ephemeral = | ||||
|         ctx.guild_data().await.map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations)); | ||||
|     if ephemeral { | ||||
|         ctx.defer_ephemeral().await?; | ||||
|     } else { | ||||
|         ctx.defer().await?; | ||||
|     } | ||||
|  | ||||
|     let user_data = ctx.author_data().await.unwrap(); | ||||
|     let timezone = timezone.unwrap_or(ctx.timezone().await); | ||||
| @@ -675,9 +694,9 @@ async fn create_reminder( | ||||
|             }; | ||||
|  | ||||
|             let (processed_interval, processed_expires) = if let Some(repeat) = &interval { | ||||
|                 if check_subscription(&ctx.discord(), ctx.author().id).await | ||||
|                 if check_subscription(&ctx, ctx.author().id).await | ||||
|                     || (ctx.guild_id().is_some() | ||||
|                         && check_guild_subscription(&ctx.discord(), ctx.guild_id().unwrap()).await) | ||||
|                         && check_guild_subscription(&ctx, ctx.guild_id().unwrap()).await) | ||||
|                 { | ||||
|                     ( | ||||
|                         parse_duration(repeat) | ||||
| @@ -692,9 +711,10 @@ async fn create_reminder( | ||||
|                         }, | ||||
|                     ) | ||||
|                 } else { | ||||
|                     ctx.say( | ||||
|                         "`repeat` is only available to Patreon subscribers or self-hosted users", | ||||
|                     ) | ||||
|                     ctx.send(|b| { | ||||
|                         b.content( | ||||
|                         "`repeat` is only available to Patreon subscribers or self-hosted users") | ||||
|                     }) | ||||
|                     .await?; | ||||
|  | ||||
|                     return Ok(()); | ||||
| @@ -704,13 +724,18 @@ async fn create_reminder( | ||||
|             }; | ||||
|  | ||||
|             if processed_interval.is_none() && interval.is_some() { | ||||
|                 ctx.say( | ||||
|                     "Repeat interval could not be processed. Try similar to `1 hour` or `4 days`", | ||||
|                 ) | ||||
|                 ctx.send(|b| { | ||||
|                     b.content( | ||||
|                     "Repeat interval could not be processed. Try similar to `1 hour` or `4 days`") | ||||
|                 }) | ||||
|                 .await?; | ||||
|             } else if processed_expires.is_none() && expires.is_some() { | ||||
|                 ctx.say("Expiry time failed to process. Please make it as clear as possible") | ||||
|                     .await?; | ||||
|                 ctx.send(|b| { | ||||
|                     b.ephemeral(true).content( | ||||
|                         "Expiry time failed to process. Please make it as clear as possible", | ||||
|                     ) | ||||
|                 }) | ||||
|                 .await?; | ||||
|             } else { | ||||
|                 let mut builder = MultiReminderBuilder::new(&ctx, ctx.guild_id()) | ||||
|                     .author(user_data) | ||||
| @@ -750,7 +775,7 @@ async fn create_reminder( | ||||
|                                     b.emoji(ReactionType::Unicode("📝".to_string())) | ||||
|                                         .label("Edit") | ||||
|                                         .style(ButtonStyle::Link) | ||||
|                                         .url("https://reminder-bot.com/dashboard") | ||||
|                                         .url("https://beta.reminder-bot.com/dashboard") | ||||
|                                 }) | ||||
|                             }) | ||||
|                         }) | ||||
|   | ||||
| @@ -2,6 +2,7 @@ pub(crate) mod pager; | ||||
|  | ||||
| use std::io::Cursor; | ||||
|  | ||||
| use base64::{engine::general_purpose, Engine}; | ||||
| use chrono_tz::Tz; | ||||
| use log::warn; | ||||
| use poise::{ | ||||
| @@ -51,11 +52,12 @@ impl ComponentDataModel { | ||||
|     pub fn to_custom_id(&self) -> String { | ||||
|         let mut buf = Vec::new(); | ||||
|         self.serialize(&mut Serializer::new(&mut buf)).unwrap(); | ||||
|         base64::encode(buf) | ||||
|         general_purpose::STANDARD.encode(buf) | ||||
|     } | ||||
|  | ||||
|     pub fn from_custom_id(data: &String) -> Self { | ||||
|         let buf = base64::decode(data) | ||||
|         let buf = general_purpose::STANDARD | ||||
|             .decode(data) | ||||
|             .map_err(|e| format!("Could not decode `custom_id' {}: {:?}", data, e)) | ||||
|             .unwrap(); | ||||
|         let cur = Cursor::new(buf); | ||||
| @@ -166,10 +168,13 @@ impl ComponentDataModel { | ||||
|             ComponentDataModel::DelSelector(selector) => { | ||||
|                 let selected_id = component.data.values.join(","); | ||||
|  | ||||
|                 sqlx::query!("DELETE FROM reminders WHERE FIND_IN_SET(id, ?)", selected_id) | ||||
|                     .execute(&data.database) | ||||
|                     .await | ||||
|                     .unwrap(); | ||||
|                 sqlx::query!( | ||||
|                     "UPDATE reminders SET `status` = 'deleted' WHERE FIND_IN_SET(id, ?)", | ||||
|                     selected_id | ||||
|                 ) | ||||
|                 .execute(&data.database) | ||||
|                 .await | ||||
|                 .unwrap(); | ||||
|  | ||||
|                 let reminders = Reminder::from_guild( | ||||
|                     &ctx, | ||||
|   | ||||
| @@ -17,12 +17,8 @@ use regex::Regex; | ||||
|  | ||||
| lazy_static! { | ||||
|     pub static ref DEFAULT_AVATAR: AttachmentType<'static> = ( | ||||
|         include_bytes!(concat!( | ||||
|             env!("CARGO_MANIFEST_DIR"), | ||||
|             "/assets/", | ||||
|             env!("WEBHOOK_AVATAR", "WEBHOOK_AVATAR not provided for compilation") | ||||
|         )) as &[u8], | ||||
|         env!("WEBHOOK_AVATAR"), | ||||
|         include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/webhook.jpg")) as &[u8], | ||||
|         "webhook.jpg", | ||||
|     ) | ||||
|         .into(); | ||||
|     pub static ref REGEX_CHANNEL_USER: Regex = Regex::new(r#"\s*<(#|@)(?:!)?(\d+)>\s*"#).unwrap(); | ||||
| @@ -48,5 +44,5 @@ lazy_static! { | ||||
|         .map_or(THEME_COLOR_FALLBACK, |inner| u32::from_str_radix(&inner, 16) | ||||
|             .unwrap_or(THEME_COLOR_FALLBACK)); | ||||
|     pub static ref PYTHON_LOCATION: String = | ||||
|         env::var("PYTHON_LOCATION").unwrap_or_else(|_| "venv/bin/python3".to_string()); | ||||
|         env::var("PYTHON_LOCATION").unwrap_or_else(|_| "/usr/bin/python3".to_string()); | ||||
| } | ||||
|   | ||||
							
								
								
									
										23
									
								
								src/hooks.rs
									
									
									
									
									
								
							
							
						
						
									
										23
									
								
								src/hooks.rs
									
									
									
									
									
								
							| @@ -47,25 +47,26 @@ async fn macro_check(ctx: Context<'_>) -> bool { | ||||
|  | ||||
| async fn check_self_permissions(ctx: Context<'_>) -> bool { | ||||
|     if let Some(guild) = ctx.guild() { | ||||
|         let user_id = ctx.discord().cache.current_user_id(); | ||||
|         let user_id = ctx.serenity_context().cache.current_user_id(); | ||||
|  | ||||
|         let manage_webhooks = | ||||
|             guild.member_permissions(&ctx, user_id).await.map_or(false, |p| p.manage_webhooks()); | ||||
|  | ||||
|         let manage_webhooks = guild | ||||
|             .member_permissions(&ctx.discord(), user_id) | ||||
|             .await | ||||
|             .map_or(false, |p| p.manage_webhooks()); | ||||
|         let (view_channel, send_messages, embed_links) = ctx | ||||
|             .channel_id() | ||||
|             .to_channel_cached(&ctx.discord()) | ||||
|             .to_channel(&ctx) | ||||
|             .await | ||||
|             .ok() | ||||
|             .and_then(|c| { | ||||
|                 if let Channel::Guild(channel) = c { | ||||
|                     channel.permissions_for_user(&ctx.discord(), user_id).ok() | ||||
|                     let perms = channel.permissions_for_user(&ctx, user_id).ok()?; | ||||
|  | ||||
|                     Some((perms.view_channel(), perms.send_messages(), perms.embed_links())) | ||||
|                 } else { | ||||
|                     None | ||||
|                 } | ||||
|             }) | ||||
|             .map_or((false, false, false), |p| { | ||||
|                 (p.view_channel(), p.send_messages(), p.embed_links()) | ||||
|             }); | ||||
|             .unwrap_or((false, false, false)); | ||||
|  | ||||
|         if manage_webhooks && send_messages && embed_links { | ||||
|             true | ||||
| @@ -81,8 +82,8 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool { | ||||
| {}     **Manage Webhooks**", | ||||
|                         if view_channel { "✅" } else { "❌" }, | ||||
|                         if send_messages { "✅" } else { "❌" }, | ||||
|                         if manage_webhooks { "✅" } else { "❌" }, | ||||
|                         if embed_links { "✅" } else { "❌" }, | ||||
|                         if manage_webhooks { "✅" } else { "❌" }, | ||||
|                     )) | ||||
|                 }) | ||||
|                 .await; | ||||
|   | ||||
| @@ -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" | "M" => (n, 0, 0, 0), | ||||
|             "months" | "month" => (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.chars(), src: s, current: (0, 0, 0, 0) }.parse() | ||||
|     Parser { iter: s.to_lowercase().chars(), src: &s.to_lowercase(), current: (0, 0, 0, 0) }.parse() | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| @@ -324,4 +324,13 @@ 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); | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										30
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										30
									
								
								src/main.rs
									
									
									
									
									
								
							| @@ -90,6 +90,8 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> { | ||||
|  | ||||
|     if Path::new("/etc/reminder-rs/config.env").exists() { | ||||
|         dotenv::from_path("/etc/reminder-rs/config.env")?; | ||||
|     } else { | ||||
|         let _ = dotenv::dotenv(); | ||||
|     } | ||||
|  | ||||
|     let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment"); | ||||
| @@ -110,6 +112,16 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> { | ||||
|                 ], | ||||
|                 ..moderation_cmds::allowed_dm() | ||||
|             }, | ||||
|             poise::Command { | ||||
|                 subcommands: vec![poise::Command { | ||||
|                     subcommands: vec![ | ||||
|                         moderation_cmds::set_ephemeral_confirmations(), | ||||
|                         moderation_cmds::unset_ephemeral_confirmations(), | ||||
|                     ], | ||||
|                     ..moderation_cmds::ephemeral_confirmations() | ||||
|                 }], | ||||
|                 ..moderation_cmds::settings() | ||||
|             }, | ||||
|             moderation_cmds::webhook(), | ||||
|             poise::Command { | ||||
|                 subcommands: vec![ | ||||
| @@ -163,7 +175,21 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> { | ||||
|         ], | ||||
|         allowed_mentions: None, | ||||
|         command_check: Some(|ctx| Box::pin(all_checks(ctx))), | ||||
|         listener: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)), | ||||
|         event_handler: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)), | ||||
|         on_error: |error| { | ||||
|             Box::pin(async move { | ||||
|                 match error { | ||||
|                     poise::FrameworkError::CommandCheckFailed { .. } => { | ||||
|                         // suppress error | ||||
|                     } | ||||
|                     error => { | ||||
|                         if let Err(e) = poise::builtins::on_error(error).await { | ||||
|                             log::error!("Error while handling error: {}", e); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             }) | ||||
|         }, | ||||
|         ..Default::default() | ||||
|     }; | ||||
|  | ||||
| @@ -189,7 +215,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> { | ||||
|  | ||||
|     poise::Framework::builder() | ||||
|         .token(discord_token) | ||||
|         .user_data_setup(move |ctx, _bot, framework| { | ||||
|         .setup(move |ctx, _bot, framework| { | ||||
|             Box::pin(async move { | ||||
|                 register_application_commands(ctx, framework, None).await.unwrap(); | ||||
|  | ||||
|   | ||||
| @@ -22,9 +22,7 @@ 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 FROM channels WHERE channel = ?", | ||||
|             channel_id | ||||
|         ) | ||||
|         .fetch_one(pool) | ||||
| @@ -37,9 +35,7 @@ SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_u | ||||
|             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 | ||||
|   | ||||
							
								
								
									
										48
									
								
								src/models/guild_data.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/models/guild_data.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| use poise::serenity_prelude::GuildId; | ||||
| use sqlx::MySqlPool; | ||||
|  | ||||
| pub struct GuildData { | ||||
|     pub ephemeral_confirmations: bool, | ||||
|     pub id: u32, | ||||
| } | ||||
|  | ||||
| impl GuildData { | ||||
|     pub async fn from_guild( | ||||
|         guild_id: GuildId, | ||||
|         pool: &MySqlPool, | ||||
|     ) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> { | ||||
|         if let Ok(c) = sqlx::query_as_unchecked!( | ||||
|             Self, | ||||
|             "SELECT id, ephemeral_confirmations FROM guilds WHERE guild = ?", | ||||
|             guild_id.0 | ||||
|         ) | ||||
|         .fetch_one(pool) | ||||
|         .await | ||||
|         { | ||||
|             Ok(c) | ||||
|         } else { | ||||
|             sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id.0) | ||||
|                 .execute(&pool.clone()) | ||||
|                 .await?; | ||||
|  | ||||
|             Ok(sqlx::query_as_unchecked!( | ||||
|                 Self, | ||||
|                 "SELECT id, ephemeral_confirmations FROM guilds WHERE guild = ?", | ||||
|                 guild_id.0 | ||||
|             ) | ||||
|             .fetch_one(pool) | ||||
|             .await?) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub async fn commit_changes(&self, pool: &MySqlPool) { | ||||
|         sqlx::query!( | ||||
|             "UPDATE guilds SET ephemeral_confirmations = ? WHERE id = ?", | ||||
|             self.ephemeral_confirmations, | ||||
|             self.id | ||||
|         ) | ||||
|         .execute(pool) | ||||
|         .await | ||||
|         .unwrap(); | ||||
|     } | ||||
| } | ||||
| @@ -1,14 +1,15 @@ | ||||
| pub mod channel_data; | ||||
| pub mod command_macro; | ||||
| pub mod guild_data; | ||||
| pub mod reminder; | ||||
| pub mod timer; | ||||
| pub mod user_data; | ||||
|  | ||||
| use chrono_tz::Tz; | ||||
| use poise::serenity_prelude::{async_trait, model::id::UserId}; | ||||
| use poise::serenity_prelude::{async_trait, model::id::UserId, ChannelType}; | ||||
|  | ||||
| use crate::{ | ||||
|     models::{channel_data::ChannelData, user_data::UserData}, | ||||
|     models::{channel_data::ChannelData, guild_data::GuildData, user_data::UserData}, | ||||
|     CommandMacro, Context, Data, Error, GuildId, | ||||
| }; | ||||
|  | ||||
| @@ -18,6 +19,8 @@ pub trait CtxData { | ||||
|  | ||||
|     async fn author_data(&self) -> Result<UserData, Error>; | ||||
|  | ||||
|     async fn guild_data(&self) -> Option<Result<GuildData, Error>>; | ||||
|  | ||||
|     async fn timezone(&self) -> Tz; | ||||
|  | ||||
|     async fn channel_data(&self) -> Result<ChannelData, Error>; | ||||
| @@ -27,15 +30,21 @@ pub trait CtxData { | ||||
|  | ||||
| #[async_trait] | ||||
| impl CtxData for Context<'_> { | ||||
|     async fn user_data<U: Into<UserId> + Send>( | ||||
|         &self, | ||||
|         user_id: U, | ||||
|     ) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> { | ||||
|         UserData::from_user(user_id, &self.discord(), &self.data().database).await | ||||
|     async fn user_data<U: Into<UserId> + Send>(&self, user_id: U) -> Result<UserData, Error> { | ||||
|         UserData::from_user(user_id, &self.serenity_context(), &self.data().database).await | ||||
|     } | ||||
|  | ||||
|     async fn author_data(&self) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> { | ||||
|         UserData::from_user(&self.author().id, &self.discord(), &self.data().database).await | ||||
|     async fn author_data(&self) -> Result<UserData, Error> { | ||||
|         UserData::from_user(&self.author().id, &self.serenity_context(), &self.data().database) | ||||
|             .await | ||||
|     } | ||||
|  | ||||
|     async fn guild_data(&self) -> Option<Result<GuildData, Error>> { | ||||
|         if let Some(guild_id) = self.guild_id() { | ||||
|             Some(GuildData::from_guild(guild_id, &self.data().database).await) | ||||
|         } else { | ||||
|             None | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async fn timezone(&self) -> Tz { | ||||
| @@ -43,7 +52,20 @@ impl CtxData for Context<'_> { | ||||
|     } | ||||
|  | ||||
|     async fn channel_data(&self) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>> { | ||||
|         let channel = self.channel_id().to_channel_cached(&self.discord()).unwrap(); | ||||
|         // If we're in a thread, get the parent channel. | ||||
|         let recv_channel = self.channel_id().to_channel(&self).await?; | ||||
|  | ||||
|         let channel = match recv_channel.guild() { | ||||
|             Some(guild_channel) => { | ||||
|                 if guild_channel.kind == ChannelType::PublicThread { | ||||
|                     guild_channel.parent_id.unwrap().to_channel_cached(&self).unwrap() | ||||
|                 } else { | ||||
|                     self.channel_id().to_channel_cached(&self).unwrap() | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             None => self.channel_id().to_channel_cached(&self).unwrap(), | ||||
|         }; | ||||
|  | ||||
|         ChannelData::from_channel(&channel, &self.data().database).await | ||||
|     } | ||||
|   | ||||
| @@ -9,7 +9,7 @@ use poise::serenity_prelude::{ | ||||
|         id::{ChannelId, GuildId, UserId}, | ||||
|         webhook::Webhook, | ||||
|     }, | ||||
|     Result as SerenityResult, | ||||
|     ChannelType, Result as SerenityResult, | ||||
| }; | ||||
| use sqlx::MySqlPool; | ||||
|  | ||||
| @@ -51,6 +51,7 @@ pub struct ReminderBuilder { | ||||
|     pool: MySqlPool, | ||||
|     uid: String, | ||||
|     channel: u32, | ||||
|     thread_id: Option<u64>, | ||||
|     utc_time: NaiveDateTime, | ||||
|     timezone: String, | ||||
|     interval_seconds: Option<i64>, | ||||
| @@ -226,19 +227,20 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|             errors.insert(ReminderError::LongInterval); | ||||
|         } else { | ||||
|             for scope in self.scopes { | ||||
|                 let thread_id = None; | ||||
|                 let db_channel_id = match scope { | ||||
|                     ReminderScope::User(user_id) => { | ||||
|                         if let Ok(user) = UserId(user_id).to_user(&self.ctx.discord()).await { | ||||
|                         if let Ok(user) = UserId(user_id).to_user(&self.ctx).await { | ||||
|                             let user_data = UserData::from_user( | ||||
|                                 &user, | ||||
|                                 &self.ctx.discord(), | ||||
|                                 &self.ctx.serenity_context(), | ||||
|                                 &self.ctx.data().database, | ||||
|                             ) | ||||
|                             .await | ||||
|                             .unwrap(); | ||||
|  | ||||
|                             if let Some(guild_id) = self.guild_id { | ||||
|                                 if guild_id.member(&self.ctx.discord(), user).await.is_err() { | ||||
|                                 if guild_id.member(&self.ctx, user).await.is_err() { | ||||
|                                     Err(ReminderError::InvalidTag) | ||||
|                                 } else if self.set_by.map_or(true, |i| i != user_data.id) | ||||
|                                     && !user_data.allowed_dm | ||||
| @@ -255,27 +257,36 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|                         } | ||||
|                     } | ||||
|                     ReminderScope::Channel(channel_id) => { | ||||
|                         let channel = | ||||
|                             ChannelId(channel_id).to_channel(&self.ctx.discord()).await.unwrap(); | ||||
|                         let channel = ChannelId(channel_id).to_channel(&self.ctx).await.unwrap(); | ||||
|  | ||||
|                         if let Some(guild_channel) = channel.clone().guild() { | ||||
|                         if let Some(mut guild_channel) = channel.clone().guild() { | ||||
|                             if Some(guild_channel.guild_id) != self.guild_id { | ||||
|                                 Err(ReminderError::InvalidTag) | ||||
|                             } else { | ||||
|                                 let mut channel_data = | ||||
|                                     ChannelData::from_channel(&channel, &self.ctx.data().database) | ||||
|                                 let mut channel_data = if guild_channel.kind | ||||
|                                     == ChannelType::PublicThread | ||||
|                                 { | ||||
|                                     // fixme jesus christ | ||||
|                                     let parent = guild_channel | ||||
|                                         .parent_id | ||||
|                                         .unwrap() | ||||
|                                         .to_channel(&self.ctx) | ||||
|                                         .await | ||||
|                                         .unwrap(); | ||||
|                                     guild_channel = parent.clone().guild().unwrap(); | ||||
|                                     ChannelData::from_channel(&parent, &self.ctx.data().database) | ||||
|                                         .await | ||||
|                                         .unwrap() | ||||
|                                 } else { | ||||
|                                     ChannelData::from_channel(&channel, &self.ctx.data().database) | ||||
|                                         .await | ||||
|                                         .unwrap() | ||||
|                                 }; | ||||
|  | ||||
|                                 if channel_data.webhook_id.is_none() | ||||
|                                     || channel_data.webhook_token.is_none() | ||||
|                                 { | ||||
|                                     match create_webhook( | ||||
|                                         &self.ctx.discord(), | ||||
|                                         guild_channel, | ||||
|                                         "Reminder", | ||||
|                                     ) | ||||
|                                     .await | ||||
|                                     match create_webhook(&self.ctx, guild_channel, "Reminder").await | ||||
|                                     { | ||||
|                                         Ok(webhook) => { | ||||
|                                             channel_data.webhook_id = | ||||
| @@ -307,6 +318,7 @@ impl<'a> MultiReminderBuilder<'a> { | ||||
|                             pool: self.ctx.data().database.clone(), | ||||
|                             uid: generate_uid(), | ||||
|                             channel: c, | ||||
|                             thread_id, | ||||
|                             utc_time: self.utc_time, | ||||
|                             timezone: self.timezone.to_string(), | ||||
|                             interval_seconds: self.interval.map(|i| i.sec as i64), | ||||
|   | ||||
| @@ -159,6 +159,7 @@ LEFT JOIN | ||||
| ON | ||||
|     reminders.set_by = users.id | ||||
| WHERE | ||||
|     `status` = 'pending' AND | ||||
|     channels.channel = ? AND | ||||
|     FIND_IN_SET(reminders.enabled, ?) | ||||
| ORDER BY | ||||
| @@ -217,6 +218,7 @@ LEFT JOIN | ||||
| ON | ||||
|     reminders.set_by = users.id | ||||
| WHERE | ||||
|     `status` = 'pending' AND | ||||
|     FIND_IN_SET(channels.channel, ?) | ||||
|                 ", | ||||
|                     channels | ||||
| @@ -251,6 +253,7 @@ LEFT JOIN | ||||
| ON | ||||
|     reminders.set_by = users.id | ||||
| WHERE | ||||
|     `status` = 'pending' AND | ||||
|     channels.guild_id = (SELECT id FROM guilds WHERE guild = ?) | ||||
|                 ", | ||||
|                     guild_id.as_u64() | ||||
| @@ -286,6 +289,7 @@ LEFT JOIN | ||||
| ON | ||||
|     reminders.set_by = users.id | ||||
| WHERE | ||||
|     `status` = 'pending' AND | ||||
|     channels.id = (SELECT dm_channel FROM users WHERE user = ?) | ||||
|             ", | ||||
|                 user.as_u64() | ||||
| @@ -300,7 +304,10 @@ WHERE | ||||
|         &self, | ||||
|         db: impl Executor<'_, Database = Database>, | ||||
|     ) -> Result<(), sqlx::Error> { | ||||
|         sqlx::query!("DELETE FROM reminders WHERE uid = ?", self.uid).execute(db).await.map(|_| ()) | ||||
|         sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", self.uid) | ||||
|             .execute(db) | ||||
|             .await | ||||
|             .map(|_| ()) | ||||
|     } | ||||
|  | ||||
|     pub fn display_content(&self) -> &str { | ||||
|   | ||||
| @@ -83,7 +83,7 @@ pub fn send_as_initial_response( | ||||
|         components, | ||||
|         ephemeral, | ||||
|         allowed_mentions, | ||||
|         reference_message: _, // can't reply to a message in interactions | ||||
|         reply: _, | ||||
|     } = data; | ||||
|  | ||||
|     if let Some(content) = content { | ||||
|   | ||||
| @@ -5,9 +5,10 @@ Description=Reminder Bot | ||||
| User=reminder | ||||
| Type=simple | ||||
| ExecStart=/usr/bin/reminder-rs | ||||
| WorkingDirectory=/etc/reminder-rs | ||||
| Restart=always | ||||
| RestartSec=4 | ||||
| # Environment="RUST_LOG=warn,reminder_rs=info,postman=info" | ||||
| Environment="reminder_rs=warn,postman=warn" | ||||
|  | ||||
| [Install] | ||||
| WantedBy=multi-user.target | ||||
|   | ||||
| @@ -1,21 +1,22 @@ | ||||
| [package] | ||||
| name = "reminder_web" | ||||
| version = "0.1.0" | ||||
| version = "0.1.4" | ||||
| 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.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } | ||||
| serenity = { version = "0.11", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] } | ||||
| oauth2 = "4" | ||||
| log = "0.4" | ||||
| reqwest = "0.11" | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] } | ||||
| sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] } | ||||
| chrono = "0.4" | ||||
| chrono-tz = "0.5" | ||||
| chrono-tz = "0.8" | ||||
| lazy_static = "1.4.0" | ||||
| rand = "0.7" | ||||
| rand = "0.8" | ||||
| base64 = "0.13" | ||||
| csv = "1.1" | ||||
| csv = "1.2" | ||||
| prometheus = "0.13.3" | ||||
|   | ||||
							
								
								
									
										40
									
								
								web/src/catchers.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								web/src/catchers.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| use std::collections::HashMap; | ||||
|  | ||||
| use rocket::serde::json::json; | ||||
| use rocket_dyn_templates::Template; | ||||
|  | ||||
| use crate::JsonValue; | ||||
|  | ||||
| #[catch(403)] | ||||
| pub(crate) async fn forbidden() -> Template { | ||||
|     let map: HashMap<String, String> = HashMap::new(); | ||||
|     Template::render("errors/403", &map) | ||||
| } | ||||
|  | ||||
| #[catch(500)] | ||||
| pub(crate) async fn internal_server_error() -> Template { | ||||
|     let map: HashMap<String, String> = HashMap::new(); | ||||
|     Template::render("errors/500", &map) | ||||
| } | ||||
|  | ||||
| #[catch(401)] | ||||
| pub(crate) async fn not_authorized() -> Template { | ||||
|     let map: HashMap<String, String> = HashMap::new(); | ||||
|     Template::render("errors/401", &map) | ||||
| } | ||||
|  | ||||
| #[catch(404)] | ||||
| pub(crate) async fn not_found() -> Template { | ||||
|     let map: HashMap<String, String> = HashMap::new(); | ||||
|     Template::render("errors/404", &map) | ||||
| } | ||||
|  | ||||
| #[catch(413)] | ||||
| pub(crate) async fn payload_too_large() -> JsonValue { | ||||
|     json!({"error": "Data too large.", "errors": ["Data too large."]}) | ||||
| } | ||||
|  | ||||
| #[catch(422)] | ||||
| pub(crate) async fn unprocessable_entity() -> JsonValue { | ||||
|     json!({"error": "Invalid request.", "errors": ["Invalid request."]}) | ||||
| } | ||||
| @@ -2,6 +2,7 @@ pub const DISCORD_OAUTH_TOKEN: &'static str = "https://discord.com/api/oauth2/to | ||||
| pub const DISCORD_OAUTH_AUTHORIZE: &'static str = "https://discord.com/api/oauth2/authorize"; | ||||
| pub const DISCORD_API: &'static str = "https://discord.com/api"; | ||||
|  | ||||
| pub const MAX_NAME_LENGTH: usize = 100; | ||||
| pub const MAX_CONTENT_LENGTH: usize = 2000; | ||||
| pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096; | ||||
| pub const MAX_EMBED_TITLE_LENGTH: usize = 256; | ||||
|   | ||||
							
								
								
									
										1
									
								
								web/src/guards/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								web/src/guards/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| pub(crate) mod transaction; | ||||
							
								
								
									
										42
									
								
								web/src/guards/transaction.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								web/src/guards/transaction.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| use rocket::{ | ||||
|     http::Status, | ||||
|     request::{FromRequest, Outcome}, | ||||
|     Request, State, | ||||
| }; | ||||
| use sqlx::Pool; | ||||
|  | ||||
| use crate::Database; | ||||
|  | ||||
| pub struct Transaction<'a>(sqlx::Transaction<'a, Database>); | ||||
|  | ||||
| impl Transaction<'_> { | ||||
|     pub fn executor(&mut self) -> impl sqlx::Executor<'_, Database = Database> { | ||||
|         &mut *(self.0) | ||||
|     } | ||||
|  | ||||
|     pub async fn commit(self) -> Result<(), sqlx::Error> { | ||||
|         self.0.commit().await | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub enum TransactionError { | ||||
|     Error(sqlx::Error), | ||||
|     Missing, | ||||
| } | ||||
|  | ||||
| #[rocket::async_trait] | ||||
| impl<'r> FromRequest<'r> for Transaction<'r> { | ||||
|     type Error = TransactionError; | ||||
|  | ||||
|     async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> { | ||||
|         match request.guard::<&State<Pool<Database>>>().await { | ||||
|             Outcome::Success(pool) => match pool.begin().await { | ||||
|                 Ok(transaction) => Outcome::Success(Transaction(transaction)), | ||||
|                 Err(e) => Outcome::Error((Status::InternalServerError, TransactionError::Error(e))), | ||||
|             }, | ||||
|             Outcome::Error(e) => Outcome::Error((e.0, TransactionError::Missing)), | ||||
|             Outcome::Forward(f) => Outcome::Forward(f), | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										185
									
								
								web/src/lib.rs
									
									
									
									
									
								
							
							
						
						
									
										185
									
								
								web/src/lib.rs
									
									
									
									
									
								
							| @@ -4,13 +4,17 @@ extern crate rocket; | ||||
| mod consts; | ||||
| #[macro_use] | ||||
| mod macros; | ||||
| mod catchers; | ||||
| mod guards; | ||||
| mod metrics; | ||||
| mod routes; | ||||
|  | ||||
| use std::{collections::HashMap, env}; | ||||
| use std::{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, | ||||
| }; | ||||
| @@ -22,7 +26,10 @@ use serenity::{ | ||||
| }; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES}; | ||||
| use crate::{ | ||||
|     consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES}, | ||||
|     metrics::{init_metrics, MetricProducer}, | ||||
| }; | ||||
|  | ||||
| type Database = MySql; | ||||
|  | ||||
| @@ -32,50 +39,20 @@ 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, | ||||
|     db_pool: Pool<Database>, | ||||
| ) -> Result<(), Box<dyn std::error::Error>> { | ||||
|     info!("Checking environment variables..."); | ||||
|     env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied"); | ||||
|     env::var("OAUTH2_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied"); | ||||
|     env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied"); | ||||
|     env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD_ID' not supplied"); | ||||
|  | ||||
|     if env::var("OFFLINE").map_or(true, |v| v != "1") { | ||||
|         env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied"); | ||||
|         env::var("OAUTH2_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied"); | ||||
|         env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied"); | ||||
|         env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD_ID' not supplied"); | ||||
|     } | ||||
|  | ||||
|     info!("Done!"); | ||||
|  | ||||
|     let oauth2_client = BasicClient::new( | ||||
| @@ -88,32 +65,40 @@ pub async fn initialize( | ||||
|  | ||||
|     let reqwest_client = reqwest::Client::new(); | ||||
|  | ||||
|     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![ | ||||
|                 not_authorized, | ||||
|                 forbidden, | ||||
|                 not_found, | ||||
|                 internal_server_error, | ||||
|                 unprocessable_entity, | ||||
|                 payload_too_large, | ||||
|                 catchers::not_authorized, | ||||
|                 catchers::forbidden, | ||||
|                 catchers::not_found, | ||||
|                 catchers::internal_server_error, | ||||
|                 catchers::unprocessable_entity, | ||||
|                 catchers::payload_too_large, | ||||
|             ], | ||||
|         ) | ||||
|         .manage(oauth2_client) | ||||
|         .manage(reqwest_client) | ||||
|         .manage(serenity_context) | ||||
|         .manage(db_pool) | ||||
|         .mount("/static", FileServer::from(concat!(env!("CARGO_MANIFEST_DIR"), "/static"))) | ||||
|         .mount("/static", FileServer::from(static_path)) | ||||
|         .mount( | ||||
|             "/", | ||||
|             routes![ | ||||
|                 routes::index, | ||||
|                 routes::cookies, | ||||
|                 routes::index, | ||||
|                 routes::metrics::metrics, | ||||
|                 routes::privacy, | ||||
|                 routes::report::report_error, | ||||
|                 routes::return_to_same_site, | ||||
|                 routes::terms, | ||||
|                 routes::return_to_same_site | ||||
|             ], | ||||
|         ) | ||||
|         .mount( | ||||
| @@ -131,25 +116,32 @@ pub async fn initialize( | ||||
|                 routes::help_iemanager, | ||||
|             ], | ||||
|         ) | ||||
|         .mount("/login", routes![routes::login::discord_login, routes::login::discord_callback]) | ||||
|         .mount( | ||||
|             "/login", | ||||
|             routes![ | ||||
|                 routes::login::discord_login, | ||||
|                 routes::login::discord_logout, | ||||
|                 routes::login::discord_callback | ||||
|             ], | ||||
|         ) | ||||
|         .mount( | ||||
|             "/dashboard", | ||||
|             routes![ | ||||
|                 routes::dashboard::dashboard, | ||||
|                 routes::dashboard::dashboard_home, | ||||
|                 routes::dashboard::user::get_user_info, | ||||
|                 routes::dashboard::user::update_user_info, | ||||
|                 routes::dashboard::user::get_user_guilds, | ||||
|                 routes::dashboard::guild::get_guild_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::api::user::get_user_info, | ||||
|                 routes::dashboard::api::user::update_user_info, | ||||
|                 routes::dashboard::api::user::get_user_guilds, | ||||
|                 routes::dashboard::api::guild::get_guild_info, | ||||
|                 routes::dashboard::api::guild::get_guild_channels, | ||||
|                 routes::dashboard::api::guild::get_guild_roles, | ||||
|                 routes::dashboard::api::guild::get_reminder_templates, | ||||
|                 routes::dashboard::api::guild::create_reminder_template, | ||||
|                 routes::dashboard::api::guild::delete_reminder_template, | ||||
|                 routes::dashboard::api::guild::create_guild_reminder, | ||||
|                 routes::dashboard::api::guild::get_reminders, | ||||
|                 routes::dashboard::api::guild::edit_reminder, | ||||
|                 routes::dashboard::api::guild::delete_reminder, | ||||
|                 routes::dashboard::export::export_reminders, | ||||
|                 routes::dashboard::export::export_reminder_templates, | ||||
|                 routes::dashboard::export::export_todos, | ||||
| @@ -157,6 +149,7 @@ pub async fn initialize( | ||||
|                 routes::dashboard::export::import_todos, | ||||
|             ], | ||||
|         ) | ||||
|         .mount("/admin", routes![routes::admin::admin_dashboard_home, routes::admin::bot_data]) | ||||
|         .launch() | ||||
|         .await?; | ||||
|  | ||||
| @@ -173,6 +166,8 @@ pub async fn initialize( | ||||
| } | ||||
|  | ||||
| pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool { | ||||
|     offline!(true); | ||||
|  | ||||
|     if let Some(subscription_guild) = *CNC_GUILD { | ||||
|         let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await; | ||||
|  | ||||
| @@ -194,6 +189,8 @@ pub async fn check_guild_subscription( | ||||
|     cache_http: impl CacheHttp, | ||||
|     guild_id: impl Into<GuildId>, | ||||
| ) -> bool { | ||||
|     offline!(true); | ||||
|  | ||||
|     if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) { | ||||
|         let owner = guild.owner_id; | ||||
|  | ||||
| @@ -202,3 +199,65 @@ 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(()) | ||||
| } | ||||
|   | ||||
| @@ -1,3 +1,11 @@ | ||||
| macro_rules! offline { | ||||
|     ($field:expr) => { | ||||
|         if std::env::var("OFFLINE").map_or(false, |v| v == "1") { | ||||
|             return $field; | ||||
|         } | ||||
|     }; | ||||
| } | ||||
|  | ||||
| macro_rules! check_length { | ||||
|     ($max:ident, $field:expr) => { | ||||
|         if $field.len() > $max { | ||||
| @@ -46,40 +54,6 @@ macro_rules! check_url_opt { | ||||
|     }; | ||||
| } | ||||
|  | ||||
| macro_rules! check_authorization { | ||||
|     ($cookies:expr, $ctx:expr, $guild:expr) => { | ||||
|         use serenity::model::id::UserId; | ||||
|  | ||||
|         let user_id = $cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten(); | ||||
|  | ||||
|         match user_id { | ||||
|             Some(user_id) => { | ||||
|                 match GuildId($guild).to_guild_cached($ctx) { | ||||
|                     Some(guild) => { | ||||
|                         let member = guild.member($ctx, UserId(user_id)).await; | ||||
|  | ||||
|                         match member { | ||||
|                             Err(_) => { | ||||
|                                 return Err(json!({"error": "User not in guild"})); | ||||
|                             } | ||||
|  | ||||
|                             Ok(_) => {} | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     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 { | ||||
|   | ||||
							
								
								
									
										43
									
								
								web/src/metrics.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								web/src/metrics.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| use lazy_static::lazy_static; | ||||
| use prometheus::{IntCounterVec, Opts, Registry}; | ||||
| use rocket::{ | ||||
|     fairing::{Fairing, Info, Kind}, | ||||
|     Data, Request, Response, | ||||
| }; | ||||
|  | ||||
| lazy_static! { | ||||
|     pub static ref REGISTRY: Registry = Registry::new(); | ||||
|     static ref REQUEST_COUNTER: IntCounterVec = | ||||
|         IntCounterVec::new(Opts::new("requests", "Requests"), &["method", "route"]).unwrap(); | ||||
|     static ref RESPONSE_COUNTER: IntCounterVec = | ||||
|         IntCounterVec::new(Opts::new("responses", "Responses"), &["status", "route"]).unwrap(); | ||||
| } | ||||
|  | ||||
| pub fn init_metrics() { | ||||
|     REGISTRY.register(Box::new(REQUEST_COUNTER.clone())).unwrap(); | ||||
| } | ||||
|  | ||||
| pub struct MetricProducer; | ||||
|  | ||||
| #[rocket::async_trait] | ||||
| impl Fairing for MetricProducer { | ||||
|     fn info(&self) -> Info { | ||||
|         Info { name: "Metrics fairing", kind: Kind::Request } | ||||
|     } | ||||
|  | ||||
|     async fn on_request(&self, req: &mut Request<'_>, _data: &mut Data<'_>) { | ||||
|         if let Some(route) = req.route() { | ||||
|             REQUEST_COUNTER | ||||
|                 .with_label_values(&[req.method().as_str(), &route.uri.to_string()]) | ||||
|                 .inc(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async fn on_response<'r>(&self, req: &'r Request<'_>, resp: &mut Response<'r>) { | ||||
|         if let Some(route) = req.route() { | ||||
|             RESPONSE_COUNTER | ||||
|                 .with_label_values(&[&resp.status().code.to_string(), &route.uri.to_string()]) | ||||
|                 .inc(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										218
									
								
								web/src/routes/admin.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										218
									
								
								web/src/routes/admin.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,218 @@ | ||||
| use std::{collections::HashMap, env}; | ||||
|  | ||||
| use chrono::{DateTime, Utc}; | ||||
| use rocket::{ | ||||
|     http::{CookieJar, Status}, | ||||
|     serde::json::json, | ||||
|     State, | ||||
| }; | ||||
| use rocket_dyn_templates::Template; | ||||
| use serde::Serialize; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::routes::JsonResult; | ||||
|  | ||||
| fn is_admin(cookies: &CookieJar<'_>) -> bool { | ||||
|     cookies | ||||
|         .get_private("userid") | ||||
|         .map_or(false, |cookie| Some(cookie.value().to_string()) == env::var("ADMIN_ID").ok()) | ||||
| } | ||||
|  | ||||
| #[get("/")] | ||||
| pub async fn admin_dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Status> { | ||||
|     if let Some(cookie) = cookies.get_private("userid") { | ||||
|         let map: HashMap<&str, String> = HashMap::new(); | ||||
|         if Some(cookie.value().to_string()) == env::var("ADMIN_ID").ok() { | ||||
|             Ok(Template::render("admin_dashboard", &map)) | ||||
|         } else { | ||||
|             Err(Status::Forbidden) | ||||
|         } | ||||
|     } else { | ||||
|         Err(Status::Unauthorized) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct TimeFrame { | ||||
|     time_key: DateTime<Utc>, | ||||
|     count: i64, | ||||
| } | ||||
|  | ||||
| #[get("/data")] | ||||
| pub async fn bot_data(cookies: &CookieJar<'_>, pool: &State<Pool<MySql>>) -> JsonResult { | ||||
|     if !is_admin(cookies) { | ||||
|         return json_err!("Not authorized"); | ||||
|     } | ||||
|  | ||||
|     let backlog = sqlx::query!( | ||||
|         "SELECT COUNT(1) AS backlog FROM reminders WHERE `utc_time` < NOW() AND enabled = 1 AND `status` = 'pending'" | ||||
|     ) | ||||
|     .fetch_one(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     let schedule_once = sqlx::query_as_unchecked!( | ||||
|         TimeFrame, | ||||
|         "SELECT | ||||
|             FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 300) * 300) AS `time_key`, | ||||
|             COUNT(1) AS `count` | ||||
|         FROM reminders | ||||
|         WHERE | ||||
|             `utc_time` < DATE_ADD(NOW(), INTERVAL 1 DAY) AND | ||||
|             `utc_time` >= NOW() AND | ||||
|             `enabled` = 1 AND | ||||
|             `status` = 'pending' AND | ||||
|             `interval_seconds` IS NULL AND | ||||
|             `interval_months` IS NULL AND | ||||
|             `interval_days` IS NULL | ||||
|         GROUP BY `time_key` | ||||
|         ORDER BY `time_key`" | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     let schedule_interval = sqlx::query_as_unchecked!( | ||||
|         TimeFrame, | ||||
|         "SELECT | ||||
|             FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 300) * 300) AS `time_key`, | ||||
|             COUNT(1) AS `count` | ||||
|         FROM reminders | ||||
|         WHERE | ||||
|             `utc_time` < DATE_ADD(NOW(), INTERVAL 1 DAY) AND | ||||
|             `utc_time` >= NOW() AND | ||||
|             `status` = 'pending' AND | ||||
|             `enabled` = 1 AND ( | ||||
|                 `interval_seconds` IS NOT NULL OR | ||||
|                 `interval_months` IS NOT NULL OR | ||||
|                 `interval_days` IS NOT NULL | ||||
|             ) | ||||
|         GROUP BY `time_key` | ||||
|         ORDER BY `time_key`" | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     let schedule_once_long = sqlx::query_as_unchecked!( | ||||
|         TimeFrame, | ||||
|         "SELECT | ||||
|             FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`, | ||||
|             COUNT(1) AS `count` | ||||
|         FROM reminders | ||||
|         WHERE | ||||
|             `utc_time` < DATE_ADD(NOW(), INTERVAL 31 DAY) AND | ||||
|             `utc_time` >= NOW() AND | ||||
|             `enabled` = 1 AND | ||||
|             `status` = 'pending' AND | ||||
|             `interval_seconds` IS NULL AND | ||||
|             `interval_months` IS NULL AND | ||||
|             `interval_days` IS NULL | ||||
|         GROUP BY `time_key` | ||||
|         ORDER BY `time_key`" | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     let schedule_interval_long = sqlx::query_as_unchecked!( | ||||
|         TimeFrame, | ||||
|         "SELECT | ||||
|             FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`, | ||||
|             COUNT(1) AS `count` | ||||
|         FROM reminders | ||||
|         WHERE | ||||
|             `utc_time` < DATE_ADD(NOW(), INTERVAL 31 DAY) AND | ||||
|             `utc_time` >= NOW() AND | ||||
|             `status` = 'pending' AND | ||||
|             `enabled` = 1 AND ( | ||||
|                 `interval_seconds` IS NOT NULL OR | ||||
|                 `interval_months` IS NOT NULL OR | ||||
|                 `interval_days` IS NOT NULL | ||||
|             ) | ||||
|         GROUP BY `time_key` | ||||
|         ORDER BY `time_key`" | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     let history = sqlx::query_as_unchecked!( | ||||
|         TimeFrame, | ||||
|         "SELECT | ||||
|             FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`, | ||||
|             COUNT(1) AS `count` | ||||
|         FROM stat | ||||
|         WHERE | ||||
|             `utc_time` > DATE_SUB(NOW(), INTERVAL 31 DAY) AND | ||||
|             `type` = 'reminder_sent' | ||||
|         GROUP BY `time_key` | ||||
|         ORDER BY `time_key`" | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     let history_failed = sqlx::query_as_unchecked!( | ||||
|         TimeFrame, | ||||
|         "SELECT | ||||
|             FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`, | ||||
|             COUNT(1) AS `count` | ||||
|         FROM stat | ||||
|         WHERE | ||||
|             `utc_time` > DATE_SUB(NOW(), INTERVAL 31 DAY) AND | ||||
|             `type` = 'reminder_failed' | ||||
|         GROUP BY `time_key` | ||||
|         ORDER BY `time_key`" | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     let interval_count = sqlx::query!( | ||||
|         "SELECT COUNT(1) AS count | ||||
|         FROM reminders | ||||
|         WHERE | ||||
|             `status` = 'pending' AND ( | ||||
|                 `interval_seconds` IS NOT NULL OR | ||||
|                 `interval_months` IS NOT NULL OR | ||||
|                 `interval_days` IS NOT NULL | ||||
|             )" | ||||
|     ) | ||||
|     .fetch_one(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     let reminder_count = sqlx::query!( | ||||
|         "SELECT COUNT(1) AS count | ||||
|         FROM reminders | ||||
|         WHERE | ||||
|             `status` = 'pending' AND | ||||
|             `interval_seconds` IS NULL AND | ||||
|             `interval_months` IS NULL AND | ||||
|             `interval_days` IS NULL" | ||||
|     ) | ||||
|     .fetch_one(pool.inner()) | ||||
|     .await | ||||
|     .unwrap(); | ||||
|  | ||||
|     Ok(json!({ | ||||
|         "backlog": backlog.backlog, | ||||
|         "scheduleShort": { | ||||
|             "once": schedule_once, | ||||
|             "interval": schedule_interval | ||||
|         }, | ||||
|         "scheduleLong": { | ||||
|             "once": schedule_once_long, | ||||
|             "interval": schedule_interval_long, | ||||
|         }, | ||||
|         "historyLong": { | ||||
|             "sent": history, | ||||
|             "failed": history_failed, | ||||
|         }, | ||||
|         "count": { | ||||
|             "reminders": reminder_count.count, | ||||
|             "intervals": interval_count.count, | ||||
|         } | ||||
|     })) | ||||
| } | ||||
							
								
								
									
										61
									
								
								web/src/routes/dashboard/api/guild/channels.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								web/src/routes/dashboard/api/guild/channels.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| use rocket::{http::CookieJar, serde::json::json, State}; | ||||
| use serde::Serialize; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::{ | ||||
|         channel::GuildChannel, | ||||
|         id::{ChannelId, GuildId}, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| use crate::{check_authorization, routes::JsonResult}; | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct ChannelInfo { | ||||
|     id: String, | ||||
|     name: String, | ||||
|     webhook_avatar: Option<String>, | ||||
|     webhook_name: Option<String>, | ||||
| } | ||||
|  | ||||
| #[get("/api/guild/<id>/channels")] | ||||
| pub async fn get_guild_channels( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
| ) -> JsonResult { | ||||
|     offline!(Ok(json!(vec![ChannelInfo { | ||||
|         name: "general".to_string(), | ||||
|         id: "1".to_string(), | ||||
|         webhook_avatar: None, | ||||
|         webhook_name: None, | ||||
|     }]))); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     match GuildId(id).to_guild_cached(ctx.inner()) { | ||||
|         Some(guild) => { | ||||
|             let mut channels = guild | ||||
|                 .channels | ||||
|                 .iter() | ||||
|                 .filter_map(|(id, channel)| channel.to_owned().guild().map(|c| (id.to_owned(), c))) | ||||
|                 .filter(|(_, channel)| channel.is_text_based()) | ||||
|                 .collect::<Vec<(ChannelId, GuildChannel)>>(); | ||||
|  | ||||
|             channels.sort_by(|(_, c1), (_, c2)| c1.position.cmp(&c2.position)); | ||||
|  | ||||
|             let channel_info = channels | ||||
|                 .iter() | ||||
|                 .map(|(channel_id, channel)| ChannelInfo { | ||||
|                     name: channel.name.to_string(), | ||||
|                     id: channel_id.to_string(), | ||||
|                     webhook_avatar: None, | ||||
|                     webhook_name: None, | ||||
|                 }) | ||||
|                 .collect::<Vec<ChannelInfo>>(); | ||||
|  | ||||
|             Ok(json!(channel_info)) | ||||
|         } | ||||
|  | ||||
|         None => json_err!("Bot not in guild"), | ||||
|     } | ||||
| } | ||||
							
								
								
									
										42
									
								
								web/src/routes/dashboard/api/guild/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								web/src/routes/dashboard/api/guild/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| mod channels; | ||||
| mod reminders; | ||||
| mod roles; | ||||
| mod templates; | ||||
|  | ||||
| use std::env; | ||||
|  | ||||
| pub use channels::*; | ||||
| pub use reminders::*; | ||||
| use rocket::{http::CookieJar, serde::json::json, State}; | ||||
| pub use roles::*; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::id::{GuildId, RoleId}, | ||||
| }; | ||||
| pub use templates::*; | ||||
|  | ||||
| use crate::{check_authorization, routes::JsonResult}; | ||||
|  | ||||
| #[get("/api/guild/<id>")] | ||||
| pub async fn get_guild_info(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult { | ||||
|     offline!(Ok(json!({ "patreon": true, "name": "Guild" }))); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     match GuildId(id).to_guild_cached(ctx.inner()) { | ||||
|         Some(guild) => { | ||||
|             let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap()) | ||||
|                 .member(&ctx.inner(), guild.owner_id) | ||||
|                 .await; | ||||
|  | ||||
|             let patreon = member_res.map_or(false, |member| { | ||||
|                 member | ||||
|                     .roles | ||||
|                     .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap())) | ||||
|             }); | ||||
|  | ||||
|             Ok(json!({ "patreon": patreon, "name": guild.name })) | ||||
|         } | ||||
|  | ||||
|         None => json_err!("Bot not in guild"), | ||||
|     } | ||||
| } | ||||
| @@ -1,314 +1,70 @@ | ||||
| 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}, | ||||
|     }, | ||||
|     model::id::{ChannelId, GuildId, UserId}, | ||||
| }; | ||||
| 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, JsonResult, PatchReminder, Reminder, ReminderTemplate, | ||||
|     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, | ||||
| }; | ||||
| 
 | ||||
| #[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 { | ||||
|     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 { | ||||
|     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 { | ||||
|     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>>, | ||||
|     ctx: &State<Context>, | ||||
|     mut transaction: Transaction<'_>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, serenity_context.inner(), id); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
| 
 | ||||
|     let user_id = | ||||
|         cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap(); | ||||
| 
 | ||||
|     create_reminder( | ||||
|         serenity_context.inner(), | ||||
|         pool.inner(), | ||||
|     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, ctx: &State<Context>, pool: &State<Pool<MySql>>) -> JsonResult { | ||||
| 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 { | ||||
| @@ -337,7 +93,7 @@ pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySq | ||||
|                  reminders.embed_image_url, | ||||
|                  reminders.embed_thumbnail_url, | ||||
|                  reminders.embed_title, | ||||
|                  reminders.embed_fields, | ||||
|                  IFNULL(reminders.embed_fields, '[]') AS embed_fields, | ||||
|                  reminders.enabled, | ||||
|                  reminders.expires, | ||||
|                  reminders.interval_seconds, | ||||
| @@ -351,7 +107,7 @@ pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySq | ||||
|                  reminders.utc_time | ||||
|                 FROM reminders | ||||
|                 LEFT JOIN channels ON channels.id = reminders.channel_id | ||||
|                 WHERE FIND_IN_SET(channels.channel, ?)",
 | ||||
|                 WHERE `status` = 'pending' AND FIND_IN_SET(channels.channel, ?)",
 | ||||
|                 channels | ||||
|             ) | ||||
|             .fetch_all(pool.inner()) | ||||
| @@ -375,11 +131,12 @@ pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySq | ||||
| pub async fn edit_reminder( | ||||
|     id: u64, | ||||
|     reminder: Json<PatchReminder>, | ||||
|     serenity_context: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
|     ctx: &State<Context>, | ||||
|     mut transaction: Transaction<'_>, | ||||
|     pool: &State<Pool<Database>>, | ||||
|     cookies: &CookieJar<'_>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, serenity_context.inner(), id); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
| 
 | ||||
|     let mut error = vec![]; | ||||
| 
 | ||||
| @@ -387,7 +144,7 @@ pub async fn edit_reminder( | ||||
|         cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap(); | ||||
| 
 | ||||
|     if reminder.message_ok() { | ||||
|         update_field!(pool.inner(), error, reminder.[ | ||||
|         update_field!(transaction.executor(), error, reminder.[ | ||||
|             content, | ||||
|             embed_author, | ||||
|             embed_description, | ||||
| @@ -400,7 +157,7 @@ pub async fn edit_reminder( | ||||
|         error.push("Message exceeds limits.".to_string()); | ||||
|     } | ||||
| 
 | ||||
|     update_field!(pool.inner(), error, reminder.[ | ||||
|     update_field!(transaction.executor(), error, reminder.[ | ||||
|         attachment, | ||||
|         attachment_name, | ||||
|         avatar, | ||||
| @@ -421,8 +178,8 @@ pub async fn edit_reminder( | ||||
|         || 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 | ||||
|         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), | ||||
| @@ -430,7 +187,7 @@ pub async fn edit_reminder( | ||||
|                     "SELECT interval_days AS days FROM reminders WHERE uid = ?", | ||||
|                     reminder.uid | ||||
|                 ) | ||||
|                 .fetch_one(pool.inner()) | ||||
|                 .fetch_one(transaction.executor()) | ||||
|                 .await | ||||
|                 .map_err(|e| { | ||||
|                     warn!("Error updating reminder interval: {:?}", e); | ||||
| @@ -438,13 +195,13 @@ pub async fn edit_reminder( | ||||
|                 })? | ||||
|                 .days | ||||
|                 .unwrap_or(0), | ||||
|             } + match reminder.interval_months { | ||||
|             } * 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()) | ||||
|                 .fetch_one(transaction.executor()) | ||||
|                 .await | ||||
|                 .map_err(|e| { | ||||
|                     warn!("Error updating reminder interval: {:?}", e); | ||||
| @@ -452,13 +209,13 @@ pub async fn edit_reminder( | ||||
|                 })? | ||||
|                 .months | ||||
|                 .unwrap_or(0), | ||||
|             } + match reminder.interval_seconds { | ||||
|             } * 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()) | ||||
|                 .fetch_one(transaction.executor()) | ||||
|                 .await | ||||
|                 .map_err(|e| { | ||||
|                     warn!("Error updating reminder interval: {:?}", e); | ||||
| @@ -471,17 +228,32 @@ pub async fn edit_reminder( | ||||
|             if new_interval_length < *MIN_INTERVAL { | ||||
|                 error.push(String::from("New interval is too short.")); | ||||
|             } else { | ||||
|                 update_field!(pool.inner(), error, reminder.[ | ||||
|                 update_field!(transaction.executor(), error, reminder.[ | ||||
|                     interval_days, | ||||
|                     interval_months, | ||||
|                     interval_seconds | ||||
|                 ]); | ||||
|             } | ||||
|         } | ||||
|     } else { | ||||
|         sqlx::query!( | ||||
|             " | ||||
|             UPDATE reminders | ||||
|             SET interval_seconds = NULL, interval_days = NULL, interval_months = NULL | ||||
|             WHERE uid = ? | ||||
|             ",
 | ||||
|             reminder.uid | ||||
|         ) | ||||
|         .execute(transaction.executor()) | ||||
|         .await | ||||
|         .map_err(|e| { | ||||
|             warn!("Error updating reminder interval: {:?}", e); | ||||
|             json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] }) | ||||
|         })?; | ||||
|     } | ||||
| 
 | ||||
|     if reminder.channel > 0 { | ||||
|         let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner()); | ||||
|         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); | ||||
| @@ -496,9 +268,9 @@ pub async fn edit_reminder( | ||||
|                 } | ||||
| 
 | ||||
|                 let channel = create_database_channel( | ||||
|                     serenity_context.inner(), | ||||
|                     ctx.inner(), | ||||
|                     ChannelId(reminder.channel), | ||||
|                     pool.inner(), | ||||
|                     &mut transaction, | ||||
|                 ) | ||||
|                 .await; | ||||
| 
 | ||||
| @@ -517,7 +289,7 @@ pub async fn edit_reminder( | ||||
|                     channel, | ||||
|                     reminder.uid | ||||
|                 ) | ||||
|                 .execute(pool.inner()) | ||||
|                 .execute(transaction.executor()) | ||||
|                 .await | ||||
|                 { | ||||
|                     Ok(_) => {} | ||||
| @@ -540,6 +312,11 @@ pub async fn edit_reminder( | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     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,
 | ||||
| @@ -586,12 +363,17 @@ pub async fn edit_reminder( | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[delete("/api/guild/<_>/reminders", data = "<reminder>")] | ||||
| #[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 { | ||||
|     match sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid) | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
| 
 | ||||
|     match sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", reminder.uid) | ||||
|         .execute(pool.inner()) | ||||
|         .await | ||||
|     { | ||||
							
								
								
									
										35
									
								
								web/src/routes/dashboard/api/guild/roles.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								web/src/routes/dashboard/api/guild/roles.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| use rocket::{http::CookieJar, serde::json::json, State}; | ||||
| use serde::Serialize; | ||||
| use serenity::client::Context; | ||||
|  | ||||
| use crate::{check_authorization, routes::JsonResult}; | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct RoleInfo { | ||||
|     id: String, | ||||
|     name: String, | ||||
| } | ||||
|  | ||||
| #[get("/api/guild/<id>/roles")] | ||||
| pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult { | ||||
|     offline!(Ok(json!(vec![RoleInfo { name: "@everyone".to_string(), id: "1".to_string() }]))); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     let roles_res = ctx.cache.guild_roles(id); | ||||
|  | ||||
|     match roles_res { | ||||
|         Some(roles) => { | ||||
|             let roles = roles | ||||
|                 .iter() | ||||
|                 .map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() }) | ||||
|                 .collect::<Vec<RoleInfo>>(); | ||||
|  | ||||
|             Ok(json!(roles)) | ||||
|         } | ||||
|         None => { | ||||
|             warn!("Could not fetch roles from {}", id); | ||||
|  | ||||
|             json_err!("Could not get roles") | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										181
									
								
								web/src/routes/dashboard/api/guild/templates.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										181
									
								
								web/src/routes/dashboard/api/guild/templates.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,181 @@ | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Json}, | ||||
|     State, | ||||
| }; | ||||
| use serenity::client::Context; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::{ | ||||
|     check_authorization, | ||||
|     consts::{ | ||||
|         MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH, | ||||
|         MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH, | ||||
|         MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, | ||||
|     }, | ||||
|     routes::{ | ||||
|         dashboard::{template_name_default, DeleteReminderTemplate, ReminderTemplate}, | ||||
|         JsonResult, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| #[get("/api/guild/<id>/templates")] | ||||
| pub async fn get_reminder_templates( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     match sqlx::query_as_unchecked!( | ||||
|         ReminderTemplate, | ||||
|         "SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", | ||||
|         id | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(templates) => Ok(json!(templates)), | ||||
|         Err(e) => { | ||||
|             warn!("Could not fetch templates from {}: {:?}", id, e); | ||||
|  | ||||
|             json_err!("Could not get templates") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[post("/api/guild/<id>/templates", data = "<reminder_template>")] | ||||
| pub async fn create_reminder_template( | ||||
|     id: u64, | ||||
|     reminder_template: Json<ReminderTemplate>, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     // validate lengths | ||||
|     check_length!(MAX_CONTENT_LENGTH, reminder_template.content); | ||||
|     check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder_template.embed_description); | ||||
|     check_length!(MAX_EMBED_TITLE_LENGTH, reminder_template.embed_title); | ||||
|     check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder_template.embed_author); | ||||
|     check_length!(MAX_EMBED_FOOTER_LENGTH, reminder_template.embed_footer); | ||||
|     check_length_opt!(MAX_EMBED_FIELDS, reminder_template.embed_fields); | ||||
|     if let Some(fields) = &reminder_template.embed_fields { | ||||
|         for field in &fields.0 { | ||||
|             check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value); | ||||
|             check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title); | ||||
|         } | ||||
|     } | ||||
|     check_length_opt!(MAX_USERNAME_LENGTH, reminder_template.username); | ||||
|     check_length_opt!( | ||||
|         MAX_URL_LENGTH, | ||||
|         reminder_template.embed_footer_url, | ||||
|         reminder_template.embed_thumbnail_url, | ||||
|         reminder_template.embed_author_url, | ||||
|         reminder_template.embed_image_url, | ||||
|         reminder_template.avatar | ||||
|     ); | ||||
|  | ||||
|     // validate urls | ||||
|     check_url_opt!( | ||||
|         reminder_template.embed_footer_url, | ||||
|         reminder_template.embed_thumbnail_url, | ||||
|         reminder_template.embed_author_url, | ||||
|         reminder_template.embed_image_url, | ||||
|         reminder_template.avatar | ||||
|     ); | ||||
|  | ||||
|     let name = if reminder_template.name.is_empty() { | ||||
|         template_name_default() | ||||
|     } else { | ||||
|         reminder_template.name.clone() | ||||
|     }; | ||||
|  | ||||
|     match sqlx::query!( | ||||
|         "INSERT INTO reminder_template | ||||
|         (guild_id, | ||||
|          name, | ||||
|          attachment, | ||||
|          attachment_name, | ||||
|          avatar, | ||||
|          content, | ||||
|          embed_author, | ||||
|          embed_author_url, | ||||
|          embed_color, | ||||
|          embed_description, | ||||
|          embed_footer, | ||||
|          embed_footer_url, | ||||
|          embed_image_url, | ||||
|          embed_thumbnail_url, | ||||
|          embed_title, | ||||
|          embed_fields, | ||||
|          interval_seconds, | ||||
|          interval_days, | ||||
|          interval_months, | ||||
|          tts, | ||||
|          username | ||||
|         ) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, | ||||
|          ?, ?, ?, ?, ?, ?, ?)", | ||||
|         id, | ||||
|         name, | ||||
|         reminder_template.attachment, | ||||
|         reminder_template.attachment_name, | ||||
|         reminder_template.avatar, | ||||
|         reminder_template.content, | ||||
|         reminder_template.embed_author, | ||||
|         reminder_template.embed_author_url, | ||||
|         reminder_template.embed_color, | ||||
|         reminder_template.embed_description, | ||||
|         reminder_template.embed_footer, | ||||
|         reminder_template.embed_footer_url, | ||||
|         reminder_template.embed_image_url, | ||||
|         reminder_template.embed_thumbnail_url, | ||||
|         reminder_template.embed_title, | ||||
|         reminder_template.embed_fields, | ||||
|         reminder_template.interval_seconds, | ||||
|         reminder_template.interval_days, | ||||
|         reminder_template.interval_months, | ||||
|         reminder_template.tts, | ||||
|         reminder_template.username, | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(_) => Ok(json!({})), | ||||
|         Err(e) => { | ||||
|             warn!("Could not create template for {}: {:?}", id, e); | ||||
|  | ||||
|             json_err!("Could not create template") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[delete("/api/guild/<id>/templates", data = "<delete_reminder_template>")] | ||||
| pub async fn delete_reminder_template( | ||||
|     id: u64, | ||||
|     delete_reminder_template: Json<DeleteReminderTemplate>, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     match sqlx::query!( | ||||
|         "DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?", | ||||
|         id, delete_reminder_template.id | ||||
|     ) | ||||
|     .fetch_all(pool.inner()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(_) => { | ||||
|             Ok(json!({})) | ||||
|         } | ||||
|         Err(e) => { | ||||
|             warn!("Could not delete template from {}: {:?}", id, e); | ||||
|  | ||||
|             json_err!("Could not delete template") | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										2
									
								
								web/src/routes/dashboard/api/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								web/src/routes/dashboard/api/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| pub mod guild; | ||||
| pub mod user; | ||||
							
								
								
									
										81
									
								
								web/src/routes/dashboard/api/user/guilds.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								web/src/routes/dashboard/api/user/guilds.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | ||||
| use reqwest::Client; | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Value as JsonValue}, | ||||
|     State, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serenity::model::{id::GuildId, permissions::Permissions}; | ||||
|  | ||||
| use crate::consts::DISCORD_API; | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct GuildInfo { | ||||
|     id: String, | ||||
|     name: String, | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| struct PartialGuild { | ||||
|     pub id: GuildId, | ||||
|     pub name: String, | ||||
|     #[serde(default)] | ||||
|     pub owner: bool, | ||||
|     #[serde(rename = "permissions_new")] | ||||
|     pub permissions: Option<String>, | ||||
| } | ||||
|  | ||||
| #[get("/api/user/guilds")] | ||||
| pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue { | ||||
|     offline!(json!(vec![GuildInfo { id: "1".to_string(), name: "Guild".to_string() }])); | ||||
|  | ||||
|     if let Some(access_token) = cookies.get_private("access_token") { | ||||
|         let request_res = reqwest_client | ||||
|             .get(format!("{}/users/@me/guilds", DISCORD_API)) | ||||
|             .bearer_auth(access_token.value()) | ||||
|             .send() | ||||
|             .await; | ||||
|  | ||||
|         match request_res { | ||||
|             Ok(response) => { | ||||
|                 let guilds_res = response.json::<Vec<PartialGuild>>().await; | ||||
|  | ||||
|                 match guilds_res { | ||||
|                     Ok(guilds) => { | ||||
|                         let reduced_guilds = guilds | ||||
|                             .iter() | ||||
|                             .filter(|g| { | ||||
|                                 g.owner | ||||
|                                     || g.permissions.as_ref().map_or(false, |p| { | ||||
|                                         let permissions = | ||||
|                                             Permissions::from_bits_truncate(p.parse().unwrap()); | ||||
|  | ||||
|                                         permissions.manage_messages() | ||||
|                                             || permissions.manage_guild() | ||||
|                                             || permissions.administrator() | ||||
|                                     }) | ||||
|                             }) | ||||
|                             .map(|g| GuildInfo { id: g.id.to_string(), name: g.name.to_string() }) | ||||
|                             .collect::<Vec<GuildInfo>>(); | ||||
|  | ||||
|                         json!(reduced_guilds) | ||||
|                     } | ||||
|  | ||||
|                     Err(e) => { | ||||
|                         warn!("Error constructing user from request: {:?}", e); | ||||
|  | ||||
|                         json!({"error": "Could not get user details"}) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             Err(e) => { | ||||
|                 warn!("Error getting user guilds: {:?}", e); | ||||
|  | ||||
|                 json!({"error": "Could not reach Discord"}) | ||||
|             } | ||||
|         } | ||||
|     } else { | ||||
|         json!({"error": "Not authorized"}) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										97
									
								
								web/src/routes/dashboard/api/user/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								web/src/routes/dashboard/api/user/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | ||||
| mod guilds; | ||||
|  | ||||
| use std::env; | ||||
|  | ||||
| use chrono_tz::Tz; | ||||
| pub use guilds::*; | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Json, Value as JsonValue}, | ||||
|     State, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::id::{GuildId, RoleId}, | ||||
| }; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct UserInfo { | ||||
|     name: String, | ||||
|     patreon: bool, | ||||
|     timezone: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| pub struct UpdateUser { | ||||
|     timezone: String, | ||||
| } | ||||
|  | ||||
| #[get("/api/user")] | ||||
| pub async fn get_user_info( | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonValue { | ||||
|     offline!(json!(UserInfo { name: "Discord".to_string(), patreon: true, timezone: None })); | ||||
|  | ||||
|     if let Some(user_id) = | ||||
|         cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten() | ||||
|     { | ||||
|         let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap()) | ||||
|             .member(&ctx.inner(), user_id) | ||||
|             .await; | ||||
|  | ||||
|         let timezone = sqlx::query!( | ||||
|             "SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?", | ||||
|             user_id | ||||
|         ) | ||||
|         .fetch_one(pool.inner()) | ||||
|         .await | ||||
|         .map_or(None, |q| Some(q.timezone)); | ||||
|  | ||||
|         let user_info = UserInfo { | ||||
|             name: cookies | ||||
|                 .get_private("username") | ||||
|                 .map_or("DiscordUser#0000".to_string(), |c| c.value().to_string()), | ||||
|             patreon: member_res.map_or(false, |member| { | ||||
|                 member | ||||
|                     .roles | ||||
|                     .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap())) | ||||
|             }), | ||||
|             timezone, | ||||
|         }; | ||||
|  | ||||
|         json!(user_info) | ||||
|     } else { | ||||
|         json!({"error": "Not authorized"}) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[patch("/api/user", data = "<user>")] | ||||
| pub async fn update_user_info( | ||||
|     cookies: &CookieJar<'_>, | ||||
|     user: Json<UpdateUser>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonValue { | ||||
|     if let Some(user_id) = | ||||
|         cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten() | ||||
|     { | ||||
|         if user.timezone.parse::<Tz>().is_ok() { | ||||
|             let _ = sqlx::query!( | ||||
|                 "UPDATE users SET timezone = ? WHERE user = ?", | ||||
|                 user.timezone, | ||||
|                 user_id, | ||||
|             ) | ||||
|             .execute(pool.inner()) | ||||
|             .await; | ||||
|  | ||||
|             json!({}) | ||||
|         } else { | ||||
|             json!({"error": "Timezone not recognized"}) | ||||
|         } | ||||
|     } else { | ||||
|         json!({"error": "Not authorized"}) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										20
									
								
								web/src/routes/dashboard/api/user/models.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								web/src/routes/dashboard/api/user/models.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| use std::env; | ||||
|  | ||||
| use chrono_tz::Tz; | ||||
| use reqwest::Client; | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Json, Value as JsonValue}, | ||||
|     State, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::{ | ||||
|         id::{GuildId, RoleId}, | ||||
|         permissions::Permissions, | ||||
|     }, | ||||
| }; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::{consts::DISCORD_API, routes::JsonResult}; | ||||
							
								
								
									
										29
									
								
								web/src/routes/dashboard/api/user/reminders.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								web/src/routes/dashboard/api/user/reminders.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| use std::env; | ||||
|  | ||||
| use chrono_tz::Tz; | ||||
| use reqwest::Client; | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Json, Value as JsonValue}, | ||||
|     State, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::{ | ||||
|         id::{GuildId, RoleId}, | ||||
|         permissions::Permissions, | ||||
|     }, | ||||
| }; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::{consts::DISCORD_API, routes::JsonResult}; | ||||
|  | ||||
| #[get("/api/user/reminders")] | ||||
| pub async fn get_reminders( | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     Ok(json! {}) | ||||
| } | ||||
| @@ -6,13 +6,20 @@ use rocket::{ | ||||
| }; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::id::{ChannelId, GuildId}, | ||||
|     model::id::{ChannelId, GuildId, UserId}, | ||||
| }; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::routes::dashboard::{ | ||||
|     create_reminder, generate_uid, ImportBody, JsonResult, Reminder, ReminderCsv, | ||||
|     ReminderTemplateCsv, TodoCsv, | ||||
| use crate::{ | ||||
|     check_authorization, | ||||
|     guards::transaction::Transaction, | ||||
|     routes::{ | ||||
|         dashboard::{ | ||||
|             create_reminder, generate_uid, ImportBody, Reminder, ReminderCsv, ReminderTemplateCsv, | ||||
|             TodoCsv, | ||||
|         }, | ||||
|         JsonResult, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| #[get("/api/guild/<id>/export/reminders")] | ||||
| @@ -22,7 +29,7 @@ pub async fn export_reminders( | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]); | ||||
|  | ||||
| @@ -67,7 +74,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, ?)", | ||||
|                 WHERE FIND_IN_SET(channels.channel, ?) AND status = 'pending'", | ||||
|                 channels | ||||
|             ) | ||||
|             .fetch_all(pool.inner()) | ||||
| @@ -115,14 +122,14 @@ pub async fn export_reminders( | ||||
| } | ||||
|  | ||||
| #[put("/api/guild/<id>/export/reminders", data = "<body>")] | ||||
| pub async fn import_reminders( | ||||
| pub(crate) async fn import_reminders( | ||||
|     id: u64, | ||||
|     cookies: &CookieJar<'_>, | ||||
|     body: Json<ImportBody>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
|     mut transaction: Transaction<'_>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     let user_id = | ||||
|         cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap(); | ||||
| @@ -130,6 +137,7 @@ pub 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 { | ||||
| @@ -172,12 +180,14 @@ pub async fn import_reminders( | ||||
|  | ||||
|                                 create_reminder( | ||||
|                                     ctx.inner(), | ||||
|                                     pool.inner(), | ||||
|                                     &mut transaction, | ||||
|                                     GuildId(id), | ||||
|                                     UserId(user_id), | ||||
|                                     reminder, | ||||
|                                 ) | ||||
|                                 .await?; | ||||
|  | ||||
|                                 count += 1; | ||||
|                             } | ||||
|  | ||||
|                             Err(_) => { | ||||
| @@ -197,7 +207,16 @@ pub async fn import_reminders( | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             Ok(json!({})) | ||||
|             match transaction.commit().await { | ||||
|                 Ok(_) => Ok(json!({ | ||||
|                     "message": format!("Imported {} reminders", count) | ||||
|                 })), | ||||
|  | ||||
|                 Err(e) => { | ||||
|                     warn!("Failed to commit transaction: {:?}", e); | ||||
|                     json_err!("Couldn't commit transaction") | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         Err(_) => { | ||||
| @@ -213,7 +232,7 @@ pub async fn export_todos( | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]); | ||||
|  | ||||
| @@ -268,7 +287,7 @@ pub async fn import_todos( | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     let channels_res = GuildId(id).channels(&ctx.inner()).await; | ||||
|  | ||||
| @@ -363,7 +382,7 @@ pub async fn export_reminder_templates( | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonResult { | ||||
|     check_authorization!(cookies, ctx.inner(), id); | ||||
|     check_authorization(cookies, ctx.inner(), id).await?; | ||||
|  | ||||
|     let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]); | ||||
|  | ||||
| @@ -385,6 +404,9 @@ 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 = ?)", | ||||
|   | ||||
| @@ -1,20 +1,20 @@ | ||||
| use std::collections::HashMap; | ||||
| use std::path::Path; | ||||
|  | ||||
| use chrono::{naive::NaiveDateTime, Utc}; | ||||
| use rand::{rngs::OsRng, seq::IteratorRandom}; | ||||
| use rocket::{ | ||||
|     fs::{relative, NamedFile}, | ||||
|     http::CookieJar, | ||||
|     response::Redirect, | ||||
|     serde::json::{json, Value as JsonValue}, | ||||
|     serde::json::json, | ||||
| }; | ||||
| use rocket_dyn_templates::Template; | ||||
| use serde::{Deserialize, Deserializer, Serialize}; | ||||
| use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     http::Http, | ||||
|     model::id::{ChannelId, GuildId, UserId}, | ||||
| }; | ||||
| use sqlx::{types::Json, Executor}; | ||||
| use sqlx::types::Json; | ||||
|  | ||||
| use crate::{ | ||||
|     check_guild_subscription, check_subscription, | ||||
| @@ -22,16 +22,16 @@ use crate::{ | ||||
|         CHARACTERS, DAY, DEFAULT_AVATAR, 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, | ||||
|         MAX_NAME_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL, | ||||
|     }, | ||||
|     Database, Error, | ||||
|     guards::transaction::Transaction, | ||||
|     routes::JsonResult, | ||||
|     Error, | ||||
| }; | ||||
|  | ||||
| pub mod api; | ||||
| pub mod export; | ||||
| pub mod guild; | ||||
| pub mod user; | ||||
|  | ||||
| pub type JsonResult = Result<JsonValue, JsonValue>; | ||||
| type Unset<T> = Option<T>; | ||||
|  | ||||
| fn name_default() -> String { | ||||
| @@ -54,12 +54,27 @@ fn interval_default() -> Unset<Option<u32>> { | ||||
|     None | ||||
| } | ||||
|  | ||||
| fn deserialize_optional_field<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error> | ||||
| where | ||||
|     D: Deserializer<'de>, | ||||
|     T: Deserialize<'de>, | ||||
| { | ||||
|     Ok(Some(Option::deserialize(deserializer)?)) | ||||
| #[derive(sqlx::Type)] | ||||
| #[sqlx(transparent)] | ||||
| struct Attachment(Vec<u8>); | ||||
|  | ||||
| impl<'de> Deserialize<'de> for Attachment { | ||||
|     fn deserialize<D>(deserializer: D) -> Result<Attachment, D::Error> | ||||
|     where | ||||
|         D: Deserializer<'de>, | ||||
|     { | ||||
|         let string = String::deserialize(deserializer)?; | ||||
|         Ok(Attachment(base64::decode(string).map_err(de::Error::custom)?)) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Serialize for Attachment { | ||||
|     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> | ||||
|     where | ||||
|         S: Serializer, | ||||
|     { | ||||
|         serializer.collect_str(&base64::encode(&self.0)) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize)] | ||||
| @@ -70,7 +85,7 @@ pub struct ReminderTemplate { | ||||
|     guild_id: u32, | ||||
|     #[serde(default = "template_name_default")] | ||||
|     name: String, | ||||
|     attachment: Option<Vec<u8>>, | ||||
|     attachment: Option<Attachment>, | ||||
|     attachment_name: Option<String>, | ||||
|     avatar: Option<String>, | ||||
|     content: String, | ||||
| @@ -84,6 +99,9 @@ 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>, | ||||
| } | ||||
| @@ -92,7 +110,7 @@ pub struct ReminderTemplate { | ||||
| pub struct ReminderTemplateCsv { | ||||
|     #[serde(default = "template_name_default")] | ||||
|     name: String, | ||||
|     attachment: Option<Vec<u8>>, | ||||
|     attachment: Option<Attachment>, | ||||
|     attachment_name: Option<String>, | ||||
|     avatar: Option<String>, | ||||
|     content: String, | ||||
| @@ -106,6 +124,9 @@ 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>, | ||||
| } | ||||
| @@ -124,8 +145,7 @@ pub struct EmbedField { | ||||
|  | ||||
| #[derive(Serialize, Deserialize)] | ||||
| pub struct Reminder { | ||||
|     #[serde(with = "base64s")] | ||||
|     attachment: Option<Vec<u8>>, | ||||
|     attachment: Option<Attachment>, | ||||
|     attachment_name: Option<String>, | ||||
|     avatar: Option<String>, | ||||
|     #[serde(with = "string")] | ||||
| @@ -158,8 +178,7 @@ pub struct Reminder { | ||||
|  | ||||
| #[derive(Serialize, Deserialize)] | ||||
| pub struct ReminderCsv { | ||||
|     #[serde(with = "base64s")] | ||||
|     attachment: Option<Vec<u8>>, | ||||
|     attachment: Option<Attachment>, | ||||
|     attachment_name: Option<String>, | ||||
|     avatar: Option<String>, | ||||
|     channel: String, | ||||
| @@ -192,7 +211,7 @@ pub struct PatchReminder { | ||||
|     uid: String, | ||||
|     #[serde(default)] | ||||
|     #[serde(deserialize_with = "deserialize_optional_field")] | ||||
|     attachment: Unset<Option<String>>, | ||||
|     attachment: Unset<Option<Attachment>>, | ||||
|     #[serde(default)] | ||||
|     #[serde(deserialize_with = "deserialize_optional_field")] | ||||
|     attachment_name: Unset<Option<String>>, | ||||
| @@ -288,6 +307,14 @@ pub fn generate_uid() -> String { | ||||
|         .join("") | ||||
| } | ||||
|  | ||||
| fn deserialize_optional_field<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error> | ||||
| where | ||||
|     D: Deserializer<'de>, | ||||
|     T: Deserialize<'de>, | ||||
| { | ||||
|     Ok(Some(Option::deserialize(deserializer)?)) | ||||
| } | ||||
|  | ||||
| // https://github.com/serde-rs/json/issues/329#issuecomment-305608405 | ||||
| mod string { | ||||
|     use std::{fmt::Display, str::FromStr}; | ||||
| @@ -312,29 +339,6 @@ mod string { | ||||
|     } | ||||
| } | ||||
|  | ||||
| mod base64s { | ||||
|     use serde::{de, Deserialize, Deserializer, Serializer}; | ||||
|  | ||||
|     pub fn serialize<S>(value: &Option<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error> | ||||
|     where | ||||
|         S: Serializer, | ||||
|     { | ||||
|         if let Some(opt) = value { | ||||
|             serializer.collect_str(&base64::encode(opt)) | ||||
|         } else { | ||||
|             serializer.serialize_none() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error> | ||||
|     where | ||||
|         D: Deserializer<'de>, | ||||
|     { | ||||
|         let string = Option::<String>::deserialize(deserializer)?; | ||||
|         Some(string.map(|b| base64::decode(b).map_err(de::Error::custom))).flatten().transpose() | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| pub struct DeleteReminder { | ||||
|     uid: String, | ||||
| @@ -351,21 +355,21 @@ pub struct TodoCsv { | ||||
|     channel_id: Option<String>, | ||||
| } | ||||
|  | ||||
| pub async fn create_reminder( | ||||
| pub(crate) async fn create_reminder( | ||||
|     ctx: &Context, | ||||
|     pool: impl sqlx::Executor<'_, Database = Database> + Copy, | ||||
|     transaction: &mut Transaction<'_>, | ||||
|     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(pool) | ||||
|         .fetch_one(transaction.executor()) | ||||
|         .await | ||||
|     { | ||||
|         Err(sqlx::Error::RowNotFound) => { | ||||
|             if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.0) | ||||
|                 .execute(pool) | ||||
|                 .execute(transaction.executor()) | ||||
|                 .await | ||||
|                 .is_err() | ||||
|             { | ||||
| @@ -391,7 +395,7 @@ pub async fn create_reminder( | ||||
|         return Err(json!({"error": "Channel not found"})); | ||||
|     } | ||||
|  | ||||
|     let channel = create_database_channel(&ctx, ChannelId(reminder.channel), pool).await; | ||||
|     let channel = create_database_channel(&ctx, ChannelId(reminder.channel), transaction).await; | ||||
|  | ||||
|     if let Err(e) = channel { | ||||
|         warn!("`create_database_channel` returned an error code: {:?}", e); | ||||
| @@ -404,6 +408,7 @@ pub async fn create_reminder( | ||||
|     let channel = channel.unwrap(); | ||||
|  | ||||
|     // validate lengths | ||||
|     check_length!(MAX_NAME_LENGTH, reminder.name); | ||||
|     check_length!(MAX_CONTENT_LENGTH, reminder.content); | ||||
|     check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description); | ||||
|     check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title); | ||||
| @@ -464,8 +469,6 @@ pub async fn create_reminder( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // base64 decode error dropped here | ||||
|     let attachment_data = reminder.attachment.as_ref().map(|s| base64::decode(s).ok()).flatten(); | ||||
|     let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() }; | ||||
|     let username = if reminder.username.as_ref().map(|s| s.is_empty()).unwrap_or(true) { | ||||
|         None | ||||
| @@ -506,7 +509,7 @@ pub async fn create_reminder( | ||||
|          `utc_time` | ||||
|         ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | ||||
|         new_uid, | ||||
|         attachment_data, | ||||
|         reminder.attachment, | ||||
|         reminder.attachment_name, | ||||
|         channel, | ||||
|         reminder.avatar, | ||||
| @@ -532,7 +535,7 @@ pub async fn create_reminder( | ||||
|         username, | ||||
|         reminder.utc_time, | ||||
|     ) | ||||
|     .execute(pool) | ||||
|     .execute(transaction.executor()) | ||||
|     .await | ||||
|     { | ||||
|         Ok(_) => sqlx::query_as_unchecked!( | ||||
| @@ -569,7 +572,7 @@ pub async fn create_reminder( | ||||
|             WHERE uid = ?", | ||||
|             new_uid | ||||
|         ) | ||||
|         .fetch_one(pool) | ||||
|         .fetch_one(transaction.executor()) | ||||
|         .await | ||||
|         .map(|r| Ok(json!(r))) | ||||
|         .unwrap_or_else(|e| { | ||||
| @@ -589,11 +592,11 @@ pub async fn create_reminder( | ||||
| async fn create_database_channel( | ||||
|     ctx: impl AsRef<Http>, | ||||
|     channel: ChannelId, | ||||
|     pool: impl Executor<'_, Database = Database> + Copy, | ||||
|     transaction: &mut Transaction<'_>, | ||||
| ) -> Result<u32, crate::Error> { | ||||
|     let row = | ||||
|         sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0) | ||||
|             .fetch_one(pool) | ||||
|             .fetch_one(transaction.executor()) | ||||
|             .await; | ||||
|  | ||||
|     match row { | ||||
| @@ -610,7 +613,7 @@ async fn create_database_channel( | ||||
|                     webhook.token, | ||||
|                     channel.0 | ||||
|                 ) | ||||
|                 .execute(pool) | ||||
|                 .execute(transaction.executor()) | ||||
|                 .await | ||||
|                 .map_err(|e| Error::SQLx(e))?; | ||||
|             } | ||||
| @@ -636,7 +639,7 @@ async fn create_database_channel( | ||||
|                 webhook.token, | ||||
|                 channel.0 | ||||
|             ) | ||||
|             .execute(pool) | ||||
|             .execute(transaction.executor()) | ||||
|             .await | ||||
|             .map_err(|e| Error::SQLx(e))?; | ||||
|  | ||||
| @@ -647,7 +650,7 @@ async fn create_database_channel( | ||||
|     }?; | ||||
|  | ||||
|     let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.0) | ||||
|         .fetch_one(pool) | ||||
|         .fetch_one(transaction.executor()) | ||||
|         .await | ||||
|         .map_err(|e| Error::SQLx(e))?; | ||||
|  | ||||
| @@ -655,20 +658,26 @@ async fn create_database_channel( | ||||
| } | ||||
|  | ||||
| #[get("/")] | ||||
| pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Redirect> { | ||||
| pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<NamedFile, Redirect> { | ||||
|     if cookies.get_private("userid").is_some() { | ||||
|         let map: HashMap<&str, String> = HashMap::new(); | ||||
|         Ok(Template::render("dashboard", &map)) | ||||
|         NamedFile::open(Path::new(relative!("static/index.html"))).await.map_err(|e| { | ||||
|             warn!("Couldn't render dashboard: {:?}", e); | ||||
|  | ||||
|             Redirect::to("/login/discord") | ||||
|         }) | ||||
|     } else { | ||||
|         Err(Redirect::to("/login/discord")) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[get("/<_>")] | ||||
| pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<Template, Redirect> { | ||||
| #[get("/<_..>")] | ||||
| pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<NamedFile, Redirect> { | ||||
|     if cookies.get_private("userid").is_some() { | ||||
|         let map: HashMap<&str, String> = HashMap::new(); | ||||
|         Ok(Template::render("dashboard", &map)) | ||||
|         NamedFile::open(Path::new(relative!("static/index.html"))).await.map_err(|e| { | ||||
|             warn!("Couldn't render dashboard: {:?}", e); | ||||
|  | ||||
|             Redirect::to("/login/discord") | ||||
|         }) | ||||
|     } else { | ||||
|         Err(Redirect::to("/login/discord")) | ||||
|     } | ||||
|   | ||||
| @@ -1,168 +0,0 @@ | ||||
| use std::env; | ||||
|  | ||||
| use chrono_tz::Tz; | ||||
| use reqwest::Client; | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::json::{json, Json, Value as JsonValue}, | ||||
|     State, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serenity::{ | ||||
|     client::Context, | ||||
|     model::{ | ||||
|         id::{GuildId, RoleId}, | ||||
|         permissions::Permissions, | ||||
|     }, | ||||
| }; | ||||
| use sqlx::{MySql, Pool}; | ||||
|  | ||||
| use crate::consts::DISCORD_API; | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct UserInfo { | ||||
|     name: String, | ||||
|     patreon: bool, | ||||
|     timezone: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| pub struct UpdateUser { | ||||
|     timezone: String, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| struct GuildInfo { | ||||
|     id: String, | ||||
|     name: String, | ||||
| } | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| pub struct PartialGuild { | ||||
|     pub id: GuildId, | ||||
|     pub icon: Option<String>, | ||||
|     pub name: String, | ||||
|     #[serde(default)] | ||||
|     pub owner: bool, | ||||
|     #[serde(rename = "permissions_new")] | ||||
|     pub permissions: Option<String>, | ||||
| } | ||||
|  | ||||
| #[get("/api/user")] | ||||
| pub async fn get_user_info( | ||||
|     cookies: &CookieJar<'_>, | ||||
|     ctx: &State<Context>, | ||||
|     pool: &State<Pool<MySql>>, | ||||
| ) -> JsonValue { | ||||
|     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 { | ||||
|     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"}) | ||||
|     } | ||||
| } | ||||
| @@ -11,7 +11,7 @@ use rocket::{ | ||||
| }; | ||||
| use serenity::model::user::User; | ||||
|  | ||||
| use crate::consts::DISCORD_API; | ||||
| use crate::{consts::DISCORD_API, routes}; | ||||
|  | ||||
| #[get("/discord")] | ||||
| pub async fn discord_login( | ||||
| @@ -31,27 +31,34 @@ pub async fn discord_login( | ||||
|  | ||||
|     // store the pkce secret to verify the authorization later | ||||
|     cookies.add_private( | ||||
|         Cookie::build("verify", pkce_verifier.secret().to_string()) | ||||
|         Cookie::build(("verify", pkce_verifier.secret().to_string())) | ||||
|             .http_only(true) | ||||
|             .path("/login") | ||||
|             .same_site(SameSite::Lax) | ||||
|             .expires(Expiration::Session) | ||||
|             .finish(), | ||||
|             .expires(Expiration::Session), | ||||
|     ); | ||||
|  | ||||
|     // store the csrf token to verify no interference | ||||
|     cookies.add_private( | ||||
|         Cookie::build("csrf", csrf_token.secret().to_string()) | ||||
|         Cookie::build(("csrf", csrf_token.secret().to_string())) | ||||
|             .http_only(true) | ||||
|             .path("/login") | ||||
|             .same_site(SameSite::Lax) | ||||
|             .expires(Expiration::Session) | ||||
|             .finish(), | ||||
|             .expires(Expiration::Session), | ||||
|     ); | ||||
|  | ||||
|     Redirect::to(auth_url.to_string()) | ||||
| } | ||||
|  | ||||
| #[get("/discord/logout")] | ||||
| pub async fn discord_logout(cookies: &CookieJar<'_>) -> Redirect { | ||||
|     cookies.remove_private(Cookie::from("username")); | ||||
|     cookies.remove_private(Cookie::from("userid")); | ||||
|     cookies.remove_private(Cookie::from("access_token")); | ||||
|  | ||||
|     Redirect::to(uri!(routes::index)) | ||||
| } | ||||
|  | ||||
| #[get("/discord/authorized?<code>&<state>")] | ||||
| pub async fn discord_callback( | ||||
|     code: &str, | ||||
| @@ -71,17 +78,16 @@ pub async fn discord_callback( | ||||
|                 .request_async(async_http_client) | ||||
|                 .await; | ||||
|  | ||||
|             cookies.remove_private(Cookie::named("verify")); | ||||
|             cookies.remove_private(Cookie::named("csrf")); | ||||
|             cookies.remove_private(Cookie::from("verify")); | ||||
|             cookies.remove_private(Cookie::from("csrf")); | ||||
|  | ||||
|             match token_result { | ||||
|                 Ok(token) => { | ||||
|                     cookies.add_private( | ||||
|                         Cookie::build("access_token", token.access_token().secret().to_string()) | ||||
|                         Cookie::build(("access_token", token.access_token().secret().to_string())) | ||||
|                             .secure(true) | ||||
|                             .http_only(true) | ||||
|                             .path("/dashboard") | ||||
|                             .finish(), | ||||
|                             .path("/dashboard"), | ||||
|                     ); | ||||
|  | ||||
|                     let request_res = reqwest_client | ||||
|   | ||||
							
								
								
									
										18
									
								
								web/src/routes/metrics.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								web/src/routes/metrics.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| use prometheus; | ||||
|  | ||||
| use crate::metrics::REGISTRY; | ||||
|  | ||||
| #[get("/metrics")] | ||||
| pub async fn metrics() -> String { | ||||
|     let encoder = prometheus::TextEncoder::new(); | ||||
|     let res_custom = encoder.encode_to_string(®ISTRY.gather()); | ||||
|  | ||||
|     match res_custom { | ||||
|         Ok(s) => s, | ||||
|         Err(e) => { | ||||
|             warn!("Error encoding metrics: {:?}", e); | ||||
|  | ||||
|             String::new() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,11 +1,16 @@ | ||||
| pub mod admin; | ||||
| pub mod dashboard; | ||||
| pub mod login; | ||||
| pub mod metrics; | ||||
| pub mod report; | ||||
|  | ||||
| use std::collections::HashMap; | ||||
|  | ||||
| use rocket::request::FlashMessage; | ||||
| use rocket::{request::FlashMessage, serde::json::Value as JsonValue}; | ||||
| use rocket_dyn_templates::Template; | ||||
|  | ||||
| pub type JsonResult = Result<JsonValue, JsonValue>; | ||||
|  | ||||
| #[get("/")] | ||||
| pub async fn index(flash: Option<FlashMessage<'_>>) -> Template { | ||||
|     let mut map: HashMap<&str, String> = HashMap::new(); | ||||
|   | ||||
							
								
								
									
										48
									
								
								web/src/routes/report.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								web/src/routes/report.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| use rocket::{ | ||||
|     http::CookieJar, | ||||
|     serde::{ | ||||
|         json::{json, Json}, | ||||
|         Deserialize, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| use crate::routes::JsonResult; | ||||
|  | ||||
| #[derive(Deserialize)] | ||||
| pub struct ClientError { | ||||
|     #[serde(rename = "reporterId")] | ||||
|     reporter_id: String, | ||||
|     url: String, | ||||
|     #[serde(rename = "relativeTimestamp")] | ||||
|     relative_timestamp: i64, | ||||
|     #[serde(rename = "errorMessage")] | ||||
|     error_message: String, | ||||
|     #[serde(rename = "errorLine")] | ||||
|     error_line: u64, | ||||
|     #[serde(rename = "errorFile")] | ||||
|     error_file: String, | ||||
|     #[serde(rename = "errorType")] | ||||
|     error_type: String, | ||||
| } | ||||
|  | ||||
| #[post("/report", data = "<client_error>")] | ||||
| pub async fn report_error(cookies: &CookieJar<'_>, client_error: Json<ClientError>) -> JsonResult { | ||||
|     if let Some(user_id) = cookies.get_private("userid") { | ||||
|         error!( | ||||
|             "User {} reports a client-side error. | ||||
| {}, {}:{} at {}ms | ||||
| {}: {} | ||||
| Chain: {}", | ||||
|             user_id, | ||||
|             client_error.url, | ||||
|             client_error.error_file, | ||||
|             client_error.error_line, | ||||
|             client_error.relative_timestamp, | ||||
|             client_error.error_type, | ||||
|             client_error.error_message, | ||||
|             client_error.reporter_id | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     Ok(json!({})) | ||||
| } | ||||
| @@ -11,10 +11,26 @@ div.reminderContent.is-collapsed .column.discord-frame { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| div.reminderContent.is-collapsed .collapses { | ||||
| 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; | ||||
| } | ||||
| @@ -23,42 +39,42 @@ div.reminderContent .invert-collapses { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| div.reminderContent.is-collapsed .settings { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     padding-bottom: 0; | ||||
| } | ||||
|  | ||||
| div.reminderContent.is-collapsed .channel-field { | ||||
|     display: inline-flex; | ||||
|     order: 1; | ||||
| } | ||||
|  | ||||
| div.reminderContent.is-collapsed .reminder-topbar { | ||||
|     display: inline-flex; | ||||
|     margin-bottom: 0px; | ||||
|     flex-grow: 1; | ||||
|     order: 2; | ||||
| } | ||||
|  | ||||
| div.reminderContent.is-collapsed input[name="name"] { | ||||
|     display: inline-flex; | ||||
|     flex-grow: 1; | ||||
|     border: none; | ||||
|     font-weight: 700; | ||||
|     background: none; | ||||
|     box-shadow: none; | ||||
|     opacity: 1; | ||||
| } | ||||
|  | ||||
| div.reminderContent.is-collapsed button.hide-box { | ||||
| div.reminderContent.is-collapsed .hide-box { | ||||
|     display: inline-flex; | ||||
| } | ||||
|  | ||||
| div.reminderContent.is-collapsed button.hide-box i { | ||||
| div.reminderContent.is-collapsed .hide-box i { | ||||
|     transform: rotate(90deg); | ||||
| } | ||||
| /* END */ | ||||
|  | ||||
| /* dashboard styles */ | ||||
| .hide-box { | ||||
|     border: none; | ||||
|     background: none; | ||||
| } | ||||
|  | ||||
| .hide-box:focus { | ||||
|     outline: none; | ||||
|     box-shadow: none !important; | ||||
| } | ||||
|  | ||||
| .channel-bar { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     flex-direction: column; | ||||
|     font-weight: bold; | ||||
| } | ||||
|  | ||||
| button.inline-btn { | ||||
|     height: 100%; | ||||
|     padding: 5px; | ||||
| @@ -85,18 +101,86 @@ div.discord-embed { | ||||
|     position: relative; | ||||
| } | ||||
|  | ||||
| div.reminderContent { | ||||
|     padding: 2px; | ||||
|     background-color: #f5f5f5; | ||||
|     border-radius: 8px; | ||||
|     margin: 8px; | ||||
| div.split-controls { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     justify-content: space-between; | ||||
|     flex-grow: 2; | ||||
| } | ||||
|  | ||||
| div.interval-group > button { | ||||
|     margin-left: auto; | ||||
| .reminder-topbar > div { | ||||
|     padding-left: 6px; | ||||
|     padding-right: 6px; | ||||
| } | ||||
|  | ||||
| .settings { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
|  | ||||
| .name-bar { | ||||
|     flex-grow: 1; | ||||
|     flex-shrink: 1; | ||||
| } | ||||
|  | ||||
| .hide-button-bar { | ||||
|     flex-grow: 0; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .patreon-only { | ||||
|     padding-bottom: 16px; | ||||
| } | ||||
|  | ||||
| .tts-row { | ||||
|     padding-bottom: 10px; | ||||
| } | ||||
|  | ||||
| .reminder-topbar { | ||||
|     display: flex; | ||||
|     margin-bottom: 0 !important; | ||||
| } | ||||
|  | ||||
| .reminder-settings { | ||||
|     margin-top: 0 !important; | ||||
| } | ||||
|  | ||||
| .reminder-settings > .column { | ||||
|     flex-grow: 0; | ||||
|     flex-shrink: 0; | ||||
|     flex-basis: 50%; | ||||
| } | ||||
|  | ||||
| div.reminderContent { | ||||
|     margin-top: 10px; | ||||
|     margin-bottom: 10px; | ||||
|     padding: 14px; | ||||
|     background-color: #f5f5f5; | ||||
|     border-radius: 8px; | ||||
| } | ||||
|  | ||||
| /* Interval inputs */ | ||||
| div.interval-group { | ||||
|     height: unset !important; | ||||
| } | ||||
|  | ||||
| div.interval-group .clear:focus { | ||||
|     outline: none; | ||||
|     box-shadow: none !important; | ||||
| } | ||||
|  | ||||
| div.interval-group .no-break { | ||||
|     text-wrap: avoid; | ||||
|     white-space: nowrap; | ||||
| } | ||||
|  | ||||
| div.interval-group .clear { | ||||
|     border: none; | ||||
|     background: none; | ||||
|     padding: 1px; | ||||
|     margin-right: -3px; | ||||
| } | ||||
|  | ||||
| div.interval-group > .interval-group-left input { | ||||
|     -webkit-appearance: none; | ||||
|     border-style: none; | ||||
| @@ -110,12 +194,13 @@ div.interval-group > .interval-group-left input.w2 { | ||||
| } | ||||
|  | ||||
| div.interval-group > .interval-group-left input.w3 { | ||||
|     width: 6ch; | ||||
|     width: 3ch; | ||||
| } | ||||
|  | ||||
| div.interval-group { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     justify-content: space-between; | ||||
| } | ||||
| /* !Interval inputs */ | ||||
|  | ||||
| @@ -133,17 +218,16 @@ div.inset-content { | ||||
|     margin-right: 10%; | ||||
| } | ||||
|  | ||||
| div.flash-message { | ||||
| div.flash-container { | ||||
|     position: fixed; | ||||
|     width: 100%; | ||||
|     bottom: 0; | ||||
| } | ||||
|  | ||||
| div.flash-message { | ||||
|     width: calc(100% - 32px); | ||||
|     margin: 16px !important; | ||||
|     z-index: 99; | ||||
|     bottom: 0; | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| div.flash-message.is-active { | ||||
|     display: block; | ||||
| } | ||||
|  | ||||
| body { | ||||
| @@ -180,6 +264,23 @@ div#pageNavbar a { | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| .navbar-burger { | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .navbar-item.pageTitle { | ||||
|     flex-shrink: 1; | ||||
|     white-space: nowrap; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .dashboard-burger, .dashboard-burger:active, .dashboard-burger.is-active { | ||||
|     background-color: #adc99c !important; | ||||
|     border-radius: 14px; | ||||
|     padding: 6px; | ||||
|     background-clip: content-box; | ||||
| } | ||||
|  | ||||
| div#pageNavbar a:hover { | ||||
|     background-color: #4a4a4a; | ||||
| } | ||||
| @@ -206,17 +307,24 @@ 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; | ||||
| } | ||||
|  | ||||
| div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer { | ||||
|     flex-shrink: 0; | ||||
|     flex-grow: 0; | ||||
|     position: fixed; | ||||
|     bottom: 0; | ||||
|     width: 226px; | ||||
| } | ||||
|  | ||||
| div.dashboard-sidebar svg { | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| div.mobile-sidebar { | ||||
|     z-index: 100; | ||||
|     min-height: 100vh; | ||||
| @@ -293,10 +401,7 @@ input.default-width { | ||||
| } | ||||
|  | ||||
| .message-input:placeholder-shown { | ||||
|     border-top: none; | ||||
|     border-left: none; | ||||
|     border-right: none; | ||||
|     border-bottom-style: dashed; | ||||
|     font-style: italic; | ||||
|     background-color: #40444b; | ||||
|     color: #fff; | ||||
| } | ||||
| @@ -367,8 +472,7 @@ input.default-width { | ||||
| .customizable.is-400x300 img { | ||||
|     margin-top: 10px; | ||||
|     width: 100%; | ||||
|     min-height: 100px; | ||||
|     max-height: 400px; | ||||
|     height: 100px; | ||||
| } | ||||
|  | ||||
| .customizable.is-32x32 img { | ||||
| @@ -462,6 +566,7 @@ input.default-width { | ||||
|     flex-grow: 1; | ||||
|     flex-shrink: 1; | ||||
|     flex-basis: auto; | ||||
|     margin-right: 4px; | ||||
| } | ||||
|  | ||||
| .embed-body input, .embed-body textarea { | ||||
| @@ -511,21 +616,88 @@ input.default-width { | ||||
|     border-bottom: 1px solid #fff; | ||||
| } | ||||
|  | ||||
| @media only screen and (max-width: 768px) { | ||||
| .channel-selector { | ||||
|     width: 100%; | ||||
| } | ||||
|  | ||||
| .select { | ||||
|     width: 100%; | ||||
| } | ||||
|  | ||||
| li.highlight { | ||||
|     margin-bottom: 0 !important; | ||||
| } | ||||
|  | ||||
| .button-row { | ||||
|     display: flex; | ||||
| } | ||||
|  | ||||
| .button-row-edit > button { | ||||
|     margin-right: 4px; | ||||
| } | ||||
|  | ||||
| .button-row .button-row-reminder { | ||||
|     flex-grow: 0; | ||||
|     padding: 2px; | ||||
| } | ||||
|  | ||||
| .button-row-template { | ||||
|     display: flex; | ||||
|     flex-grow: 1; | ||||
|     justify-content: space-between; | ||||
| } | ||||
|  | ||||
| .button-row .button-row-template > div { | ||||
|     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; | ||||
|     } | ||||
| } | ||||
|  | ||||
|     .customizable.is-24x24 img { | ||||
|         width: 16px; | ||||
|         height: 16px; | ||||
| @media only screen and (max-width: 768px) { | ||||
|     .button-row { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|     } | ||||
|  | ||||
|     .button-row .button-row-reminder { | ||||
|         width: 100%; | ||||
|     } | ||||
|  | ||||
|     .button-row .button-row-template > div { | ||||
|         flex-basis: 0; | ||||
|         flex-grow: 1; | ||||
|     } | ||||
|  | ||||
|     .button-row button { | ||||
|         width: 100%; | ||||
|     } | ||||
|  | ||||
|     .reminder-settings { | ||||
|         margin-bottom: 0 !important; | ||||
|     } | ||||
|  | ||||
|     .tts-row { | ||||
|         padding-bottom: 0; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* loader */ | ||||
| #loader { | ||||
|     position: fixed; | ||||
|     top: 0; | ||||
|     background-color: rgba(255, 255, 255, 0.8); | ||||
|     width: 100vw; | ||||
|     z-index: 999; | ||||
| @@ -537,6 +709,86 @@ input.default-width { | ||||
|  | ||||
| /* END */ | ||||
|  | ||||
| div.reminderError { | ||||
|     margin: 10px; | ||||
|     padding: 14px; | ||||
|     background-color: #f5f5f5; | ||||
|     border-radius: 8px; | ||||
| } | ||||
|  | ||||
| div.reminderError .errorHead { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
| } | ||||
|  | ||||
| div.reminderError .errorIcon { | ||||
|     padding: 8px; | ||||
|     border-radius: 4px; | ||||
|     margin-right: 12px; | ||||
| } | ||||
|  | ||||
| div.reminderError .errorIcon .fas { | ||||
|     display: none | ||||
| } | ||||
|  | ||||
| div.reminderError[data-case="deleted"] .errorIcon { | ||||
|     background-color: #e7e5e4; | ||||
| } | ||||
|  | ||||
| div.reminderError[data-case="failed"] .errorIcon { | ||||
|     background-color: #fecaca; | ||||
| } | ||||
|  | ||||
| div.reminderError[data-case="sent"] .errorIcon { | ||||
|     background-color: #d9f99d; | ||||
| } | ||||
|  | ||||
| div.reminderError[data-case="deleted"] .errorIcon .fas.fa-trash { | ||||
|     display: block; | ||||
| } | ||||
|  | ||||
| div.reminderError[data-case="failed"] .errorIcon .fas.fa-exclamation-triangle { | ||||
|     display: block; | ||||
| } | ||||
|  | ||||
| div.reminderError[data-case="sent"] .errorIcon .fas.fa-check { | ||||
|     display: block; | ||||
| } | ||||
|  | ||||
| div.reminderError .errorHead .reminderName { | ||||
|     font-size: 1rem; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     justify-content: center; | ||||
|     color: rgb(54, 54, 54); | ||||
|     flex-grow: 1; | ||||
| } | ||||
|  | ||||
| div.reminderError .errorHead .reminderTime { | ||||
|     font-size: 1rem; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     flex-shrink: 1; | ||||
|     justify-content: center; | ||||
|     color: rgb(54, 54, 54); | ||||
|     background-color: #ffffff; | ||||
|     padding: 8px; | ||||
|     border-radius: 4px; | ||||
|     border-color: #e5e5e5; | ||||
|     border-width: 1px; | ||||
|     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 { | ||||
| @@ -568,11 +820,44 @@ input.default-width { | ||||
|     background-color: white; | ||||
| } | ||||
|  | ||||
| a.switch-pane { | ||||
|     white-space: nowrap; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
| } | ||||
|  | ||||
| .guild-submenu { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| .guild-submenu li { | ||||
|     font-size: 0.8rem; | ||||
| } | ||||
|  | ||||
| a.switch-pane.is-active ~ .guild-submenu { | ||||
|     display: block; | ||||
| } | ||||
|  | ||||
| .feedback { | ||||
|     background-color: #5865F2; | ||||
| } | ||||
|  | ||||
| .is-locked { | ||||
|     pointer-events: none; | ||||
| } | ||||
|  | ||||
| .is-locked > :not(.patreon-invert) { | ||||
|     opacity: 0.4; | ||||
| } | ||||
|  | ||||
| .is-locked .patreon-invert { | ||||
|     display: block; | ||||
| } | ||||
|  | ||||
| .patreon-invert { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| .is-locked .foreground { | ||||
|     pointer-events: auto; | ||||
| } | ||||
| @@ -580,3 +865,27 @@ input.default-width { | ||||
| .is-locked .field:last-of-type { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| .stat-row { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
| } | ||||
|  | ||||
| .stat-box { | ||||
|     flex-grow: 1; | ||||
|     border-radius: 6px; | ||||
|     background-color: #fcfcfc; | ||||
|     border-color: #efefef; | ||||
|     border-style: solid; | ||||
|     border-width: 1px; | ||||
|     margin: 4px; | ||||
|     padding: 4px; | ||||
| } | ||||
|  | ||||
| .figure { | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| .figure-num { | ||||
|     font-size: 2rem; | ||||
| } | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								web/static/img/logo_nobg.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								web/static/img/logo_nobg.webp
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 81 KiB | 
							
								
								
									
										131
									
								
								web/static/js/admin.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								web/static/js/admin.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,131 @@ | ||||
| document.addEventListener("DOMContentLoaded", () => { | ||||
|     fetch("/admin/data") | ||||
|         .then((resp) => resp.json()) | ||||
|         .then((data) => { | ||||
|             document.querySelector("#backlog").textContent = data.backlog; | ||||
|             document.querySelector("#reminders").textContent = data.count.reminders; | ||||
|             document.querySelector("#intervals").textContent = data.count.intervals; | ||||
|  | ||||
|             let historySent = data.historyLong.sent.reduce( | ||||
|                 (iv, frame) => iv + frame.count, | ||||
|                 0 | ||||
|             ); | ||||
|             let historyFailed = data.historyLong.failed.reduce( | ||||
|                 (iv, frame) => iv + frame.count, | ||||
|                 0 | ||||
|             ); | ||||
|             let rate = historyFailed / (historySent + historyFailed); | ||||
|             let formatted = Math.round(rate * 10000) / 100; | ||||
|  | ||||
|             document.querySelector("#historySent").textContent = historySent; | ||||
|             document.querySelector("#historyFailed").textContent = historyFailed; | ||||
|             document.querySelector("#failRate").textContent = `${formatted}%`; | ||||
|  | ||||
|             new Chart(document.getElementById("schedule"), { | ||||
|                 type: "bar", | ||||
|                 data: { | ||||
|                     labels: [ | ||||
|                         ...data.scheduleShort.once, | ||||
|                         ...data.scheduleShort.interval, | ||||
|                     ].map((row) => luxon.DateTime.fromISO(row.time_key)), | ||||
|                     datasets: [ | ||||
|                         { | ||||
|                             label: "Reminders", | ||||
|                             data: data.scheduleShort.once.map((row) => row.count), | ||||
|                         }, | ||||
|                         { | ||||
|                             label: "Intervals", | ||||
|                             data: data.scheduleShort.interval.map((row) => row.count), | ||||
|                         }, | ||||
|                     ], | ||||
|                 }, | ||||
|                 options: { | ||||
|                     responsive: true, | ||||
|                     maintainAspectRatio: false, | ||||
|                     scales: { | ||||
|                         x: { | ||||
|                             stacked: true, | ||||
|                             type: "time", | ||||
|                             time: { | ||||
|                                 unit: "minute", | ||||
|                             }, | ||||
|                         }, | ||||
|                         y: { | ||||
|                             stacked: true, | ||||
|                         }, | ||||
|                     }, | ||||
|                 }, | ||||
|             }); | ||||
|  | ||||
|             new Chart(document.getElementById("scheduleLong"), { | ||||
|                 type: "bar", | ||||
|                 data: { | ||||
|                     labels: [ | ||||
|                         ...data.scheduleLong.once, | ||||
|                         ...data.scheduleLong.interval, | ||||
|                     ].map((row) => luxon.DateTime.fromISO(row.time_key)), | ||||
|                     datasets: [ | ||||
|                         { | ||||
|                             label: "Reminders", | ||||
|                             data: data.scheduleLong.once.map((row) => row.count), | ||||
|                         }, | ||||
|                         { | ||||
|                             label: "Intervals", | ||||
|                             data: data.scheduleLong.interval.map((row) => row.count), | ||||
|                         }, | ||||
|                     ], | ||||
|                 }, | ||||
|                 options: { | ||||
|                     responsive: true, | ||||
|                     maintainAspectRatio: false, | ||||
|                     scales: { | ||||
|                         x: { | ||||
|                             stacked: true, | ||||
|                             type: "time", | ||||
|                             time: { | ||||
|                                 unit: "day", | ||||
|                             }, | ||||
|                         }, | ||||
|                         y: { | ||||
|                             stacked: true, | ||||
|                         }, | ||||
|                     }, | ||||
|                 }, | ||||
|             }); | ||||
|  | ||||
|             new Chart(document.getElementById("historyLong"), { | ||||
|                 type: "bar", | ||||
|                 data: { | ||||
|                     labels: [...data.historyLong.sent, ...data.historyLong.failed].map( | ||||
|                         (row) => luxon.DateTime.fromISO(row.time_key) | ||||
|                     ), | ||||
|                     datasets: [ | ||||
|                         { | ||||
|                             label: "Success", | ||||
|                             data: data.historyLong.sent.map((row) => row.count), | ||||
|                         }, | ||||
|                         { | ||||
|                             label: "Fail", | ||||
|                             data: data.historyLong.failed.map((row) => row.count), | ||||
|                         }, | ||||
|                     ], | ||||
|                 }, | ||||
|                 options: { | ||||
|                     responsive: true, | ||||
|                     maintainAspectRatio: false, | ||||
|                     scales: { | ||||
|                         x: { | ||||
|                             stacked: true, | ||||
|                             type: "time", | ||||
|                             time: { | ||||
|                                 unit: "day", | ||||
|                             }, | ||||
|                         }, | ||||
|                         y: { | ||||
|                             stacked: true, | ||||
|                         }, | ||||
|                     }, | ||||
|                 }, | ||||
|             }); | ||||
|         }); | ||||
| }); | ||||
							
								
								
									
										20
									
								
								web/static/js/chart.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								web/static/js/chart.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										7
									
								
								web/static/js/chartjs-adapter-luxon.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								web/static/js/chartjs-adapter-luxon.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| /*! | ||||
|  * chartjs-adapter-luxon v1.3.1 | ||||
|  * https://www.chartjs.org | ||||
|  * (c) 2023 chartjs-adapter-luxon Contributors | ||||
|  * Released under the MIT license | ||||
|  */ | ||||
| !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(require("chart.js"),require("luxon")):"function"==typeof define&&define.amd?define(["chart.js","luxon"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Chart,e.luxon)}(this,(function(e,t){"use strict";const n={datetime:t.DateTime.DATETIME_MED_WITH_SECONDS,millisecond:"h:mm:ss.SSS a",second:t.DateTime.TIME_WITH_SECONDS,minute:t.DateTime.TIME_SIMPLE,hour:{hour:"numeric"},day:{day:"numeric",month:"short"},week:"DD",month:{month:"short",year:"numeric"},quarter:"'Q'q - yyyy",year:{year:"numeric"}};e._adapters._date.override({_id:"luxon",_create:function(e){return t.DateTime.fromMillis(e,this.options)},init(e){this.options.locale||(this.options.locale=e.locale)},formats:function(){return n},parse:function(e,n){const i=this.options,r=typeof e;return null===e||"undefined"===r?null:("number"===r?e=this._create(e):"string"===r?e="string"==typeof n?t.DateTime.fromFormat(e,n,i):t.DateTime.fromISO(e,i):e instanceof Date?e=t.DateTime.fromJSDate(e,i):"object"!==r||e instanceof t.DateTime||(e=t.DateTime.fromObject(e,i)),e.isValid?e.valueOf():null)},format:function(e,t){const n=this._create(e);return"string"==typeof t?n.toFormat(t):n.toLocaleString(t)},add:function(e,t,n){const i={};return i[n]=t,this._create(e).plus(i).valueOf()},diff:function(e,t,n){return this._create(e).diff(this._create(t)).as(n).valueOf()},startOf:function(e,t,n){if("isoWeek"===t){n=Math.trunc(Math.min(Math.max(0,n),6));const t=this._create(e);return t.minus({days:(t.weekday-n+7)%7}).startOf("day").valueOf()}return t?this._create(e).startOf(t).valueOf():e},endOf:function(e,t){return this._create(e).endOf(t).valueOf()}})})); | ||||
| @@ -33,7 +33,16 @@ let globalPatreon = false; | ||||
| let guildPatreon = false; | ||||
|  | ||||
| function guildId() { | ||||
|     return document.querySelector(".guildList a.is-active").dataset["guild"]; | ||||
|     return window.location.pathname.match(/dashboard\/(\d+)/)[1]; | ||||
| } | ||||
|  | ||||
| function pane() { | ||||
|     const match = window.location.pathname.match(/dashboard\/\d+\/(.+)/); | ||||
|     if (match === null) { | ||||
|         return null; | ||||
|     } else { | ||||
|         return match[1]; | ||||
|     } | ||||
| } | ||||
|  | ||||
| function colorToInt(r, g, b) { | ||||
| @@ -56,19 +65,36 @@ function switch_pane(selector) { | ||||
| } | ||||
|  | ||||
| function update_select(sel) { | ||||
|     if (sel.selectedOptions[0].dataset["webhookAvatar"]) { | ||||
|         sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = | ||||
|             sel.selectedOptions[0].dataset["webhookAvatar"]; | ||||
|     } else { | ||||
|         sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = | ||||
|             "/static/img/icon.png"; | ||||
|     let channelDisplay = sel.closest("div.reminderContent").querySelector(".channel-bar"); | ||||
|  | ||||
|     if (channelDisplay !== null) { | ||||
|         channelDisplay.textContent = `#${sel.selectedOptions[0].textContent}`; | ||||
|     } | ||||
|     if (sel.selectedOptions[0].dataset["webhookName"]) { | ||||
|         sel.closest("div.reminderContent").querySelector("input.discord-username").value = | ||||
|             sel.selectedOptions[0].dataset["webhookName"]; | ||||
|     } else { | ||||
|         sel.closest("div.reminderContent").querySelector("input.discord-username").value = | ||||
|             "Reminder"; | ||||
|  | ||||
|     if (sel.selectedOptions[0] === undefined) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const avatarInput = sel.closest("div.reminderContent").querySelector("img.avatar"); | ||||
|  | ||||
|     if (!avatarInput.dataset["set"]) { | ||||
|         if (sel.selectedOptions[0].dataset["webhookAvatar"]) { | ||||
|             avatarInput.src = sel.selectedOptions[0].dataset["webhookAvatar"]; | ||||
|         } else { | ||||
|             avatarInput.src = "/static/img/icon.png"; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     const usernameInput = sel | ||||
|         .closest("div.reminderContent") | ||||
|         .querySelector("input.discord-username"); | ||||
|  | ||||
|     if (usernameInput.value.length === 0) { | ||||
|         if (sel.selectedOptions[0].dataset["webhookName"]) { | ||||
|             usernameInput.value = sel.selectedOptions[0].dataset["webhookName"]; | ||||
|         } else { | ||||
|             usernameInput.value = "Reminder"; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -79,7 +105,7 @@ function reset_guild_pane() { | ||||
| } | ||||
|  | ||||
| async function fetch_patreon(guild_id) { | ||||
|     fetch(`/dashboard/api/guild/${guild_id}/patreon`) | ||||
|     fetch(`/dashboard/api/guild/${guild_id}`) | ||||
|         .then((response) => response.json()) | ||||
|         .then((data) => { | ||||
|             if (data.error) { | ||||
| @@ -139,12 +165,18 @@ async function fetch_channels(guild_id) { | ||||
|     const event = new Event("channelsLoading"); | ||||
|     document.dispatchEvent(event); | ||||
|  | ||||
|     let hasError = false; | ||||
|  | ||||
|     await fetch(`/dashboard/api/guild/${guild_id}/channels`) | ||||
|         .then((response) => response.json()) | ||||
|         .then((data) => { | ||||
|             if (data.error) { | ||||
|                 if (data.error === "Bot not in guild") { | ||||
|                     switch_pane("guild-error"); | ||||
|                     hasError = true; | ||||
|                 } else if (data.error === "Incorrect permissions") { | ||||
|                     switch_pane("user-error"); | ||||
|                     hasError = true; | ||||
|                 } else { | ||||
|                     show_error(data.error); | ||||
|                 } | ||||
| @@ -156,6 +188,8 @@ async function fetch_channels(guild_id) { | ||||
|             const event = new Event("channelsLoaded"); | ||||
|             document.dispatchEvent(event); | ||||
|         }); | ||||
|  | ||||
|     return hasError; | ||||
| } | ||||
|  | ||||
| async function fetch_reminders(guild_id) { | ||||
| @@ -198,30 +232,39 @@ async function fetch_reminders(guild_id) { | ||||
| } | ||||
|  | ||||
| async function serialize_reminder(node, mode) { | ||||
|     let interval, utc_time, expiration_time; | ||||
|     let utc_time, expiration_time; | ||||
|     let interval = get_interval(node); | ||||
|  | ||||
|     if (mode !== "template") { | ||||
|         interval = get_interval(node); | ||||
|  | ||||
|         utc_time = luxon.DateTime.fromISO( | ||||
|             node.querySelector('input[name="time"]').value | ||||
|         ).setZone("UTC"); | ||||
|  | ||||
|         if (utc_time.invalid) { | ||||
|             return { error: "Time provided invalid." }; | ||||
|         } else { | ||||
|             utc_time = utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"); | ||||
|         } | ||||
|  | ||||
|         expiration_time = luxon.DateTime.fromISO( | ||||
|             node.querySelector('input[name="time"]').value | ||||
|         ).setZone("UTC"); | ||||
|         if (expiration_time.invalid) { | ||||
|             return { error: "Expiration provided invalid." }; | ||||
|         } else { | ||||
|             expiration_time = expiration_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"); | ||||
|         let expiration = node.querySelector('input[name="expiration"]').value; | ||||
|  | ||||
|         if (expiration) { | ||||
|             expiration_time = luxon.DateTime.fromISO( | ||||
|                 node.querySelector('input[name="expiration"]').value | ||||
|             ).setZone("UTC"); | ||||
|             if (expiration_time.invalid) { | ||||
|                 return { error: "Expiration provided invalid." }; | ||||
|             } else { | ||||
|                 expiration_time = expiration_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     let name = node.querySelector('input[name="name"]').value; | ||||
|     if (name.length > 100) { | ||||
|         return { error: "Name exceeds maximum length (100)." }; | ||||
|     } | ||||
|  | ||||
|     let rgb_color = window.getComputedStyle( | ||||
|         node.querySelector("div.discord-embed") | ||||
|     ).borderLeftColor; | ||||
| @@ -284,15 +327,17 @@ async function serialize_reminder(node, mode) { | ||||
|     const embed_title = node.querySelector('textarea[name="embed_title"]').value; | ||||
|  | ||||
|     if ( | ||||
|         attachment === null && | ||||
|         content.length == 0 && | ||||
|         content.length === 0 && | ||||
|         embed_author.length === 0 && | ||||
|         embed_title.length === 0 && | ||||
|         embed_description.length === 0 && | ||||
|         embed_footer.length === 0 && | ||||
|         embed_author_url === null && | ||||
|         embed_author.length == 0 && | ||||
|         embed_description.length == 0 && | ||||
|         embed_footer.length == 0 && | ||||
|         embed_footer_url === null && | ||||
|         embed_image_url === null && | ||||
|         embed_thumbnail_url === null | ||||
|         embed_thumbnail_url === null && | ||||
|         fields.length === 0 && | ||||
|         attachment === null | ||||
|     ) { | ||||
|         return { error: "Reminder needs content." }; | ||||
|     } | ||||
| @@ -305,7 +350,7 @@ async function serialize_reminder(node, mode) { | ||||
|         restartable: false, | ||||
|         attachment: attachment, | ||||
|         attachment_name: attachment_name, | ||||
|         avatar: has_source(node.querySelector("img.discord-avatar").src), | ||||
|         avatar: has_source(node.querySelector("img.avatar").src), | ||||
|         channel: node.querySelector("select.channel-selector").value, | ||||
|         content: content, | ||||
|         embed_author_url: embed_author_url, | ||||
| @@ -319,9 +364,9 @@ async function serialize_reminder(node, mode) { | ||||
|         embed_title: embed_title, | ||||
|         embed_fields: fields, | ||||
|         expires: expiration_time, | ||||
|         interval_seconds: mode !== "template" ? interval.seconds : null, | ||||
|         interval_days: mode !== "template" ? interval.days : null, | ||||
|         interval_months: mode !== "template" ? interval.months : null, | ||||
|         interval_seconds: interval.seconds, | ||||
|         interval_days: interval.days, | ||||
|         interval_months: interval.months, | ||||
|         name: node.querySelector('input[name="name"]').value, | ||||
|         tts: node.querySelector('input[name="tts"]').checked, | ||||
|         username: node.querySelector('input[name="username"]').value, | ||||
| @@ -350,17 +395,27 @@ function deserialize_reminder(reminder, frame, mode) { | ||||
|                 if ($input !== null) { | ||||
|                     $input.value = reminder[prop]; | ||||
|                 } else if ($image !== null) { | ||||
|                     console.log(`loading img ${prop}`); | ||||
|                     $image.src = reminder[prop]; | ||||
|                     $image.dataset["set"] = "1"; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     update_interval(frame); | ||||
|     update_select(frame.querySelector(".channel-selector")); | ||||
|  | ||||
|     const lastChild = frame.querySelector("div.embed-multifield-box .embed-field-box"); | ||||
|     const lastChild = frame.querySelector( | ||||
|         "div.embed-multifield-box .embed-field-box:last-child" | ||||
|     ); | ||||
|  | ||||
|     for (let field of reminder["embed_fields"]) { | ||||
|     // Drop existing fields | ||||
|     frame | ||||
|         .querySelectorAll(".embed-field-box:not(:last-child)") | ||||
|         .forEach((el) => el.remove()); | ||||
|  | ||||
|     for (let field of reminder["embed_fields"] || []) { | ||||
|         let embed_field = $embedFieldTemplate.content.cloneNode(true); | ||||
|         embed_field.querySelector("textarea.discord-field-title").value = field["title"]; | ||||
|         embed_field.querySelector("textarea.discord-field-value").value = field["value"]; | ||||
| @@ -373,9 +428,9 @@ function deserialize_reminder(reminder, frame, mode) { | ||||
|             .insertBefore(embed_field, lastChild); | ||||
|     } | ||||
|  | ||||
|     if (mode !== "template") { | ||||
|         if (reminder["interval_seconds"]) update_interval(frame); | ||||
|     if (reminder["interval_seconds"]) update_interval(frame); | ||||
|  | ||||
|     if (mode !== "template") { | ||||
|         let $enableBtn = frame.querySelector(".disable-enable"); | ||||
|         $enableBtn.dataset["action"] = reminder["enabled"] ? "disable" : "enable"; | ||||
|  | ||||
| @@ -386,7 +441,7 @@ function deserialize_reminder(reminder, frame, mode) { | ||||
|         timeInput.value = localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss"); | ||||
|  | ||||
|         if (reminder["expires"]) { | ||||
|             let expiresInput = frame.querySelector('input[name="time"]'); | ||||
|             let expiresInput = frame.querySelector('input[name="expiration"]'); | ||||
|             let expiresTime = luxon.DateTime.fromISO(reminder["expires"], { | ||||
|                 zone: "UTC", | ||||
|             }).setZone(timezone); | ||||
| @@ -406,9 +461,19 @@ document.addEventListener("guildSwitched", async (e) => { | ||||
|         `.switch-pane[data-guild="${e.detail.guild_id}"]` | ||||
|     ); | ||||
|  | ||||
|     switch_pane($anchor.dataset["pane"]); | ||||
|     let hasError = false; | ||||
|  | ||||
|     if (pane() === null) { | ||||
|         window.history.replaceState({}, "", `/dashboard/${guildId()}/reminders`); | ||||
|     } | ||||
|  | ||||
|     switch_pane(pane()); | ||||
|  | ||||
|     if ($anchor !== null) { | ||||
|         $anchor.classList.add("is-active"); | ||||
|     } | ||||
|  | ||||
|     reset_guild_pane(); | ||||
|     $anchor.classList.add("is-active"); | ||||
|  | ||||
|     if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) { | ||||
|         document | ||||
| @@ -416,19 +481,21 @@ document.addEventListener("guildSwitched", async (e) => { | ||||
|             .forEach((el) => el.classList.remove("is-locked")); | ||||
|     } | ||||
|  | ||||
|     fetch_roles(e.detail.guild_id); | ||||
|     fetch_templates(e.detail.guild_id); | ||||
|     await fetch_channels(e.detail.guild_id); | ||||
|     fetch_reminders(e.detail.guild_id); | ||||
|     hasError = await fetch_channels(e.detail.guild_id); | ||||
|     if (!hasError) { | ||||
|         fetch_roles(e.detail.guild_id); | ||||
|         fetch_templates(e.detail.guild_id); | ||||
|         fetch_reminders(e.detail.guild_id); | ||||
|  | ||||
|     document.querySelectorAll("p.pageTitle").forEach((el) => { | ||||
|         el.textContent = `${e.detail.guild_name} Reminders`; | ||||
|     }); | ||||
|     document.querySelectorAll("select.channel-selector").forEach((el) => { | ||||
|         el.addEventListener("change", (e) => { | ||||
|             update_select(e.target); | ||||
|         document.querySelectorAll("p.pageTitle").forEach((el) => { | ||||
|             el.textContent = `${e.detail.guild_name} Reminders`; | ||||
|         }); | ||||
|     }); | ||||
|         document.querySelectorAll("select.channel-selector").forEach((el) => { | ||||
|             el.addEventListener("change", (e) => { | ||||
|                 update_select(e.target); | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     $loader.classList.add("is-hidden"); | ||||
| }); | ||||
| @@ -440,6 +507,12 @@ document.addEventListener("channelsLoaded", () => { | ||||
| document.addEventListener("remindersLoaded", (event) => { | ||||
|     const guild = guildId(); | ||||
|  | ||||
|     document.querySelectorAll("select.channel-selector").forEach((el) => { | ||||
|         el.addEventListener("change", (e) => { | ||||
|             update_select(e.target); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     for (let reminder of event.detail) { | ||||
|         let node = reminder.node; | ||||
|  | ||||
| @@ -467,9 +540,9 @@ document.addEventListener("remindersLoaded", (event) => { | ||||
|                     if (data.error) { | ||||
|                         show_error(data.error); | ||||
|                     } else { | ||||
|                         enableBtn.dataset["action"] = data["enabled"] | ||||
|                             ? "enable" | ||||
|                             : "disable"; | ||||
|                         enableBtn.dataset["action"] = data.reminder["enabled"] | ||||
|                             ? "disable" | ||||
|                             : "enable"; | ||||
|                     } | ||||
|                 }); | ||||
|         }); | ||||
| @@ -541,6 +614,16 @@ 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", () => { | ||||
| @@ -566,7 +649,7 @@ document.querySelectorAll(".show-modal").forEach((element) => { | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| document.addEventListener("DOMContentLoaded", () => { | ||||
| document.addEventListener("DOMContentLoaded", async () => { | ||||
|     $loader.classList.remove("is-hidden"); | ||||
|  | ||||
|     mentions.attach(document.querySelectorAll("textarea")); | ||||
| @@ -586,7 +669,7 @@ document.addEventListener("DOMContentLoaded", () => { | ||||
|         hideBox.closest(".reminderContent").classList.toggle("is-collapsed"); | ||||
|     }); | ||||
|  | ||||
|     fetch("/dashboard/api/user") | ||||
|     await fetch("/dashboard/api/user") | ||||
|         .then((response) => response.json()) | ||||
|         .then((data) => { | ||||
|             if (data.error) { | ||||
| @@ -600,7 +683,7 @@ document.addEventListener("DOMContentLoaded", () => { | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|     fetch("/dashboard/api/user/guilds") | ||||
|     await fetch("/dashboard/api/user/guilds") | ||||
|         .then((response) => response.json()) | ||||
|         .then((data) => { | ||||
|             if (data.error) { | ||||
| @@ -623,11 +706,15 @@ document.addEventListener("DOMContentLoaded", () => { | ||||
|                         ); | ||||
|                         $anchor.dataset["guild"] = guild.id; | ||||
|                         $anchor.dataset["name"] = guild.name; | ||||
|                         $anchor.href = `/dashboard/${guild.id}?name=${guild.name}`; | ||||
|                         $anchor.href = `/dashboard/${guild.id}/reminders`; | ||||
|  | ||||
|                         $anchor.addEventListener("click", async (e) => { | ||||
|                             e.preventDefault(); | ||||
|                             window.history.pushState({}, "", `/dashboard/${guild.id}`); | ||||
|                             window.history.pushState( | ||||
|                                 {}, | ||||
|                                 "", | ||||
|                                 `/dashboard/${guild.id}/reminders` | ||||
|                             ); | ||||
|                             const event = new CustomEvent("guildSwitched", { | ||||
|                                 detail: { | ||||
|                                     guild_name: guild.name, | ||||
| @@ -691,12 +778,26 @@ $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(() => { | ||||
|             delete $uploader.files[0]; | ||||
|         }); | ||||
|         }) | ||||
|             .then((response) => response.json()) | ||||
|             .then((data) => { | ||||
|                 $importBtn.removeAttribute("disabled"); | ||||
|  | ||||
|                 if (data.error) { | ||||
|                     show_error(data.error); | ||||
|                 } else { | ||||
|                     show_success(data.message); | ||||
|                 } | ||||
|             }) | ||||
|             .then(() => { | ||||
|                 delete $uploader.files[0]; | ||||
|                 fetch_reminders(guild); | ||||
|             }); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| @@ -782,6 +883,14 @@ $createTemplateBtn.addEventListener("click", async () => { | ||||
|     ]; | ||||
|  | ||||
|     let reminder = await serialize_reminder($createReminder, "template"); | ||||
|     if (reminder.error) { | ||||
|         show_error(reminder.error); | ||||
|         $createTemplateBtn.querySelector("span.icon > i").classList = [ | ||||
|             "fas fa-file-spreadsheet", | ||||
|         ]; | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     let guild = guildId(); | ||||
|  | ||||
|     fetch(`/dashboard/api/guild/${guild}/templates`, { | ||||
| @@ -823,23 +932,25 @@ $loadTemplateBtn.addEventListener("click", (ev) => { | ||||
| }); | ||||
|  | ||||
| $deleteTemplateBtn.addEventListener("click", (ev) => { | ||||
|     fetch(`/dashboard/api/guild/${guildId()}/templates`, { | ||||
|         method: "DELETE", | ||||
|         headers: { | ||||
|             "Content-Type": "application/json", | ||||
|         }, | ||||
|         body: JSON.stringify({ id: parseInt($templateSelect.value) }), | ||||
|     }) | ||||
|         .then((response) => response.json()) | ||||
|         .then((data) => { | ||||
|             if (data.error) { | ||||
|                 show_error(data.error); | ||||
|             } else { | ||||
|                 $templateSelect | ||||
|                     .querySelector(`option[value="${$templateSelect.value}"]`) | ||||
|                     .remove(); | ||||
|             } | ||||
|         }); | ||||
|     if (parseInt($templateSelect.value) !== null) { | ||||
|         fetch(`/dashboard/api/guild/${guildId()}/templates`, { | ||||
|             method: "DELETE", | ||||
|             headers: { | ||||
|                 "Content-Type": "application/json", | ||||
|             }, | ||||
|             body: JSON.stringify({ id: parseInt($templateSelect.value) }), | ||||
|         }) | ||||
|             .then((response) => response.json()) | ||||
|             .then((data) => { | ||||
|                 if (data.error) { | ||||
|                     show_error(data.error); | ||||
|                 } else { | ||||
|                     $templateSelect | ||||
|                         .querySelector(`option[value="${$templateSelect.value}"]`) | ||||
|                         .remove(); | ||||
|                 } | ||||
|             }); | ||||
|     } | ||||
| }); | ||||
|  | ||||
| let $img; | ||||
| @@ -979,6 +1090,13 @@ document.addEventListener("click", (ev) => { | ||||
|     if (ev.target.closest("button.inline-btn") !== null) { | ||||
|         let inlined = ev.target.closest(".embed-field-box").dataset["inlined"]; | ||||
|         ev.target.closest(".embed-field-box").dataset["inlined"] = | ||||
|             inlined == "1" ? "0" : "1"; | ||||
|             inlined === "1" ? "0" : "1"; | ||||
|     } | ||||
| }); | ||||
|  | ||||
| document.addEventListener("DOMContentLoaded", () => { | ||||
|     let now = luxon.DateTime.now().setZone(timezone); | ||||
|     document.querySelectorAll(".prefill-now").forEach((el) => { | ||||
|         el.value = now.toFormat("yyyy-LL-dd'T'HH:mm:ss"); | ||||
|     }); | ||||
| }); | ||||
|   | ||||
							
								
								
									
										16
									
								
								web/static/js/reporter.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								web/static/js/reporter.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| const REPORTER_ID = crypto.randomUUID(); | ||||
|  | ||||
| window.addEventListener("error", async (ev) => { | ||||
|     await fetch("/report", { | ||||
|         method: "POST", | ||||
|         body: JSON.stringify({ | ||||
|             reporterId: REPORTER_ID, | ||||
|             url: window.location.href, | ||||
|             relativeTimestamp: ev.timeStamp, | ||||
|             errorMessage: ev.message, | ||||
|             errorLine: ev.lineno, | ||||
|             errorFile: ev.filename, | ||||
|             errorType: ev.type, | ||||
|         }), | ||||
|     }); | ||||
| }); | ||||
| @@ -1,14 +1,15 @@ | ||||
| { | ||||
|     "name": "", | ||||
|     "short_name": "", | ||||
|     "name": "Reminder Bot Dashboard", | ||||
|     "short_name": "Reminders", | ||||
|     "start_url": "/dashboard", | ||||
|     "icons": [ | ||||
|         { | ||||
|             "src": "/android-chrome-192x192.png", | ||||
|             "src": "/static/favicon/android-chrome-192x192.png", | ||||
|             "sizes": "192x192", | ||||
|             "type": "image/png" | ||||
|         }, | ||||
|         { | ||||
|             "src": "/android-chrome-512x512.png", | ||||
|             "src": "/static/favicon/android-chrome-512x512.png", | ||||
|             "sizes": "512x512", | ||||
|             "type": "image/png" | ||||
|         } | ||||
							
								
								
									
										89
									
								
								web/templates/admin_dashboard.html.tera
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								web/templates/admin_dashboard.html.tera
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="EN"> | ||||
| <head> | ||||
|     <script src="/static/js/reporter.js" type="application/javascript"></script> | ||||
|  | ||||
|     <meta name="description" content="The most powerful Discord Reminders Bot"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|     <meta charset="UTF-8"> | ||||
|     <meta name="yandex-verification" content="bb77b8681eb64a90"/> | ||||
|     <meta name="google-site-verification" content="7h7UVTeEe0AOzHiH3cFtsqMULYGN-zCZdMT_YCkW1Ho"/> | ||||
|     <!-- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *; font-src fonts.gstatic.com 'self'"> --> | ||||
|  | ||||
|     <!-- favicon --> | ||||
|     <link rel="apple-touch-icon" sizes="180x180" | ||||
|           href="/static/favicon/apple-touch-icon.png"> | ||||
|     <link rel="icon" type="image/png" sizes="32x32" | ||||
|           href="/static/favicon/favicon-32x32.png"> | ||||
|     <link rel="icon" type="image/png" sizes="16x16" | ||||
|           href="/static/favicon/favicon-16x16.png"> | ||||
|     <link rel="manifest" href="/static/favicon/site.webmanifest"> | ||||
|     <meta name="msapplication-TileColor" content="#da532c"> | ||||
|     <meta name="theme-color" content="#ffffff"> | ||||
|  | ||||
|     <title>Reminder Bot | Admin</title> | ||||
|  | ||||
|     <!-- styles --> | ||||
|     <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"> | ||||
|     <link rel="stylesheet" href="/static/css/dtsel.css"> | ||||
|  | ||||
|     <script src="/static/js/luxon.min.js"></script> | ||||
| </head> | ||||
| <body style="width: 100%;"> | ||||
|  | ||||
| <p class="title pageTitle">Admin dashboard</p> | ||||
| <section id="main"> | ||||
|     <div class="stat-row"> | ||||
|         <div class="stat-box" style="height: 400px;"> | ||||
|             <canvas id="schedule"></canvas> | ||||
|         </div> | ||||
|     </div> | ||||
|     <div class="stat-row"> | ||||
|         <div class="stat-box figure"> | ||||
|             <p>Backlog</p> | ||||
|             <p class="figure-num" id="backlog">?</p> | ||||
|         </div> | ||||
|         <div class="stat-box figure"> | ||||
|             <p>Reminders</p> | ||||
|             <p class="figure-num" id="reminders">?</p> | ||||
|         </div> | ||||
|         <div class="stat-box figure"> | ||||
|             <p>Intervals</p> | ||||
|             <p class="figure-num" id="intervals">?</p> | ||||
|         </div> | ||||
|     </div> | ||||
|     <div class="stat-row"> | ||||
|         <div class="stat-box" style="height: 400px;"> | ||||
|             <canvas id="scheduleLong"></canvas> | ||||
|         </div> | ||||
|     </div> | ||||
|     <div class="stat-row"> | ||||
|         <div class="stat-box figure"> | ||||
|             <p>Last 31 days (success)</p> | ||||
|             <p class="figure-num" id="historySent">?</p> | ||||
|         </div> | ||||
|         <div class="stat-box figure"> | ||||
|             <p>Last 31 days (failed)</p> | ||||
|             <p class="figure-num" id="historyFailed">?</p> | ||||
|         </div> | ||||
|         <div class="stat-box figure"> | ||||
|             <p>Last 31 days (failure rate)</p> | ||||
|             <p class="figure-num" id="failRate">?</p> | ||||
|         </div> | ||||
|     </div> | ||||
|     <div class="stat-row"> | ||||
|         <div class="stat-box" style="height: 400px;"> | ||||
|             <canvas id="historyLong"></canvas> | ||||
|         </div> | ||||
|     </div> | ||||
| </section> | ||||
|  | ||||
| <script src="/static/js/chart.js" defer></script> | ||||
| <script src="/static/js/chartjs-adapter-luxon.js" defer></script> | ||||
| <script src="/static/js/admin.js" defer></script> | ||||
|  | ||||
| </body> | ||||
| </html> | ||||
| @@ -13,7 +13,7 @@ | ||||
|     <link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png"> | ||||
|     <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon/favicon-32x32.png"> | ||||
|     <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon/favicon-16x16.png"> | ||||
|     <link rel="manifest" href="/static/favicon/site.webmanifest"> | ||||
|     <link rel="manifest" href="/static/site.webmanifest"> | ||||
|     <meta name="msapplication-TileColor" content="#da532c"> | ||||
|     <meta name="theme-color" content="#ffffff"> | ||||
|  | ||||
| @@ -51,8 +51,8 @@ | ||||
|                 <a class="navbar-item" href="https://invite.reminder-bot.com"> | ||||
|                     <i class="fas fa-plus"></i> | ||||
|                 </a> | ||||
|                 <a class="navbar-item" href="https://github.com/jellywx"> | ||||
|                     <i class="fab fa-github"></i> | ||||
|                 <a class="navbar-item" href="https://gitea.jellypro.xyz/jude"> | ||||
|                     <i class="fab fa-git-square"></i> | ||||
|                 </a> | ||||
|                 <a class="navbar-item" href="https://discord.jellywx.com"> | ||||
|                     <i class="fab fa-discord"></i> | ||||
| @@ -128,7 +128,7 @@ | ||||
|                 </div> | ||||
|             {% elif show_login %} | ||||
|                 <div class="hero-foot has-text-centered"> | ||||
|                     <a class="button is-size-4 is-rounded is-light" href="/oauth/login"> | ||||
|                     <a class="button is-size-4 is-rounded is-light" href="/login/discord"> | ||||
|                         <p class="is-size-4"> | ||||
|                             <span>Login with Discord</span> <span class="icon"><i class="fas fa-chevron-right"></i></span> | ||||
|                         </p> | ||||
| @@ -155,7 +155,7 @@ | ||||
|                 <br> | ||||
|                 <a href="/cookies">Cookies</a> | <a href="/privacy">Privacy Policy</a> | <a href="/terms">Terms of Service</a> | ||||
|                 <br> | ||||
|                 <a href="https://patreon.com/jellywx"><strong>Patreon</strong></a> | <a href="https://discord.jellywx.com"><strong>Discord</strong></a> | <a href="https://github.com/JellyWX"><strong>GitHub</strong></a> | ||||
|                 <a href="https://patreon.com/jellywx"><strong>Patreon</strong></a> | <a href="https://discord.jellywx.com"><strong>Discord</strong></a> | <a href="https://gitea.jellypro.xyz/jude"><strong>Gitea</strong></a> | ||||
|                 <br> | ||||
|                 or, <a href="mailto:jude@jellywx.com">Email me</a> | ||||
|             </p> | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="EN"> | ||||
| <head> | ||||
|     <script src="/static/js/reporter.js" type="application/javascript"></script> | ||||
|  | ||||
|     <meta name="description" content="The most powerful Discord Reminders Bot"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|     <meta charset="UTF-8"> | ||||
| @@ -25,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"> | ||||
|     <link rel="stylesheet" href="/static/css/style.css?v{{ version }}"> | ||||
|     <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" /> | ||||
|  | ||||
| @@ -38,14 +40,14 @@ | ||||
|     <div class="navbar-brand"> | ||||
|         <a class="navbar-item" href="/"> | ||||
|             <figure class="image"> | ||||
|                 <img src="/static/img/logo_flat.webp" alt="Reminder Bot Logo"> | ||||
|                 <img width="28px" height="28px" src="/static/img/logo_nobg.webp" alt="Reminder Bot Logo"> | ||||
|             </figure> | ||||
|         </a> | ||||
|  | ||||
|         <p class="navbar-item pageTitle"> | ||||
|         </p> | ||||
|  | ||||
|         <a role="button" class="navbar-burger is-right" aria-label="menu" aria-expanded="false" | ||||
|         <a role="button" class="dashboard-burger navbar-burger is-right" aria-label="menu" aria-expanded="false" | ||||
|            data-target="mobileSidebar"> | ||||
|             <span aria-hidden="true"></span> | ||||
|             <span aria-hidden="true"></span> | ||||
| @@ -74,6 +76,10 @@ | ||||
|     <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"> | ||||
| @@ -183,14 +189,6 @@ | ||||
|                     </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"> | ||||
| @@ -231,7 +229,8 @@ | ||||
|     <div class="column is-2 is-sidebar-menu dashboard-sidebar is-hidden-touch"> | ||||
|         <a href="/"> | ||||
|             <div class="brand"> | ||||
|                 <img src="/static/img/logo_flat.webp" alt="Reminder bot logo" | ||||
|                 <img src="/static/img/logo_nobg.webp" alt="Reminder bot logo" | ||||
|                      width="52px" height="52px" | ||||
|                      class="dashboard-brand"> | ||||
|             </div> | ||||
|         </a> | ||||
| @@ -250,7 +249,7 @@ | ||||
|             </ul> | ||||
|             <div class="aside-footer"> | ||||
|                 <p class="menu-label"> | ||||
|                     Settings | ||||
|                     Options | ||||
|                 </p> | ||||
|                 <ul class="menu-list"> | ||||
|                     <li> | ||||
| @@ -260,6 +259,12 @@ | ||||
|                         <a class="show-modal" data-modal="chooseTimezoneModal"> | ||||
|                             <span class="icon"><i class="fas fa-map-marked"></i></span> Timezone | ||||
|                         </a> | ||||
|                         <a href="/login/discord/logout"> | ||||
|                             <span class="icon"><i class="fas fa-sign-out"></i></span> Log out | ||||
|                         </a> | ||||
|                         <a href="https://discord.jellywx.com" class="feedback"> | ||||
|                             <span class="icon"><i class="fab fa-discord"></i></span> Give feedback | ||||
|                         </a> | ||||
|                     </li> | ||||
|                 </ul> | ||||
|             </div> | ||||
| @@ -269,7 +274,7 @@ | ||||
|     <div class="dashboard-sidebar mobile-sidebar is-hidden-desktop" id="mobileSidebar"> | ||||
|         <a href="/"> | ||||
|             <div class="brand"> | ||||
|                 <img src="/static/img/logo_flat.webp" alt="Reminder bot logo" | ||||
|                 <img src="/static/img/logo_nobg.webp" alt="Reminder bot logo" | ||||
|                      class="dashboard-brand"> | ||||
|             </div> | ||||
|         </a> | ||||
| @@ -298,6 +303,12 @@ | ||||
|                         <a class="show-modal" data-modal="chooseTimezoneModal"> | ||||
|                             <span class="icon"><i class="fas fa-map-marked"></i></span> Timezone | ||||
|                         </a> | ||||
|                         <a href="/login/discord/logout"> | ||||
|                             <span class="icon"><i class="fas fa-sign-out"></i></span> Log out | ||||
|                         </a> | ||||
|                         <a href="https://discord.jellywx.com/" class="feedback"> | ||||
|                             <span class="icon"><i class="fab fa-discord"></i></span> Give feedback | ||||
|                         </a> | ||||
|                     </li> | ||||
|                 </ul> | ||||
|             </div> | ||||
| @@ -314,25 +325,17 @@ | ||||
|                 <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="guild" class="is-hidden"> | ||||
|         <section id="reminders" class="is-hidden"> | ||||
|             {% include "reminder_dashboard/reminder_dashboard" %} | ||||
|         </section> | ||||
|         <section id="guild-error" class="is-hidden hero is-fullheight"> | ||||
|             <div class="hero-body"> | ||||
|                 <div class="container has-text-centered"> | ||||
|                     <p class="title"> | ||||
|                         We couldn't get this server's data | ||||
|                     </p> | ||||
|                     <p class="subtitle"> | ||||
|                         Please check Reminder Bot is in the server, and has correct permissions. | ||||
|                     </p> | ||||
|                     <a class="button is-size-4 is-rounded is-success" href="https://invite.reminder-bot.com"> | ||||
|                         <p class="is-size-4"> | ||||
|                             <span>Add to Server</span> <span class="icon"><i class="fas fa-chevron-right"></i></span> | ||||
|                         </p> | ||||
|                     </a> | ||||
|                 </div> | ||||
|             </div> | ||||
|         <section id="reminder-errors" class="is-hidden"> | ||||
|             {% include "reminder_dashboard/reminder_errors" %} | ||||
|         </section> | ||||
|         <section id="guild-error" class="is-hidden"> | ||||
|             {% include "reminder_dashboard/guild_error" %} | ||||
|         </section> | ||||
|         <section id="user-error" class="is-hidden"> | ||||
|             {% include "reminder_dashboard/user_error" %} | ||||
|         </section> | ||||
|     </div> | ||||
|     <!-- /main content --> | ||||
| @@ -378,9 +381,9 @@ | ||||
| <script src="/static/js/iro.js"></script> | ||||
| <script src="/static/js/dtsel.js"></script> | ||||
|  | ||||
| <script src="/static/js/interval.js"></script> | ||||
| <script src="/static/js/timezone.js" defer></script> | ||||
| <script src="/static/js/main.js" defer></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> | ||||
|  | ||||
| </body> | ||||
| </html> | ||||
|   | ||||
| @@ -108,8 +108,9 @@ | ||||
|                 </article> | ||||
|             </div> | ||||
|             <div class="tile is-parent is-vertical"> | ||||
|                 {# | ||||
|                 <article class="tile is-child notification"> | ||||
|                     <p class="title">Import/Export</p> | ||||
|                     <p class="title">Import/export</p> | ||||
|                     <p class="subtitle">Learn how to import and export data from the dashboard</p> | ||||
|                     <div class="content has-text-centered"> | ||||
|                         <a class="button is-size-4 is-rounded is-light" href="/help/iemanager"> | ||||
| @@ -119,19 +120,22 @@ | ||||
|                         </a> | ||||
|                     </div> | ||||
|                 </article> | ||||
|                 #} | ||||
|             </div> | ||||
|             <div class="tile is-parent"> | ||||
| <!--                <article class="tile is-child notification">--> | ||||
| <!--                    <p class="title">Dashboard</p>--> | ||||
| <!--                    <p class="subtitle">Learn to use the interactive web dashboard</p>--> | ||||
| <!--                    <div class="content has-text-centered">--> | ||||
| <!--                        <a class="button is-size-4 is-rounded is-light" href="/help/dashboard">--> | ||||
| <!--                            <p class="is-size-4">--> | ||||
| <!--                                Read <span class="icon"><i class="fas fa-chevron-right"></i></span>--> | ||||
| <!--                            </p>--> | ||||
| <!--                        </a>--> | ||||
| <!--                    </div>--> | ||||
| <!--                </article>--> | ||||
|                 {# | ||||
|                 <article class="tile is-child notification"> | ||||
|                     <p class="title">Dashboard</p> | ||||
|                     <p class="subtitle">Learn to use the interactive web dashboard</p> | ||||
|                     <div class="content has-text-centered"> | ||||
|                         <a class="button is-size-4 is-rounded is-light" href="/help/dashboard"> | ||||
|                             <p class="is-size-4"> | ||||
|                                 Read <span class="icon"><i class="fas fa-chevron-right"></i></span> | ||||
|                             </p> | ||||
|                         </a> | ||||
|                     </div> | ||||
|                 </article> | ||||
|                 #} | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| @@ -141,14 +145,14 @@ | ||||
|             <div class="container has-text-centered"> | ||||
|                 <p class="title">Need more help?</p> | ||||
|                 <p class="content"> | ||||
|                     Feel free to come and ask us! | ||||
|                     Please come and ask us! | ||||
|                 </p> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div class="hero-foot has-text-centered"> | ||||
|             <a class="button is-size-6 is-rounded is-primary" href="https://discord.jellywx.com"> | ||||
|                 <p class="is-size-6"> | ||||
|                     Join Discord <span class="icon"><i class="fas fa-chevron-right"></i></span> | ||||
|                     <span>Join Discord</span> <span class="icon"><i class="fas fa-chevron-right"></i></span> | ||||
|                 </p> | ||||
|             </a> | ||||
|         </div> | ||||
|   | ||||
| @@ -16,7 +16,7 @@ | ||||
|             <div class="tile is-parent"> | ||||
|                 <article class="tile is-child notification"> | ||||
|                     <p class="title">Slash-command Ready <svg aria-hidden="false" width="28" height="28" viewBox="0 0 24 24"><path fill="#777" fill-rule="evenodd" clip-rule="evenodd" d="M5 3C3.89543 3 3 3.89543 3 5V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3H5ZM16.8995 8.41419L15.4853 6.99998L7 15.4853L8.41421 16.8995L16.8995 8.41419Z"></path></svg></p> | ||||
|                     <p class="subtitle">Set reminders easily and quickly from anywhere</p> | ||||
|                     <p class="subtitle">Set reminders easily and quickly from anywhere.</p> | ||||
|                     <figure class="image"> | ||||
|                         <img class="rounded-corners" src="/static/img/slash-commands.png" alt="Discord slash commands demonstration"> | ||||
|                     </figure> | ||||
| @@ -25,7 +25,7 @@ | ||||
|             <div class="tile is-parent"> | ||||
|                 <article class="tile is-child notification"> | ||||
|                     <p class="title">Advanced Options <span class="icon"><i class="fad fa-palette"></i></span></p> | ||||
|                     <p class="subtitle">Decorate your announcements with our web dashboard</p> | ||||
|                     <p class="subtitle">Decorate your announcements with our web dashboard.</p> | ||||
|                     <figure class="image"> | ||||
|                         <img class="rounded-corners" src="/static/img/tournament-demo.png" alt="Advanced options demonstration"> | ||||
|                     </figure> | ||||
| @@ -34,32 +34,62 @@ | ||||
|             <div class="tile is-parent is-vertical"> | ||||
|                 <article class="tile is-child notification"> | ||||
|                     <p class="title">Unlimited Reminders <span class="icon"><i class="far fa-infinity"></i></span></p> | ||||
|                     <p class="subtitle">Never forget a thing</p> | ||||
|                     <p class="subtitle">Never forget a thing.</p> | ||||
|                 </article> | ||||
|                 <article class="tile is-child notification"> | ||||
|                     <p class="title">Repeating Reminders <span class="icon"><i class="fas fa-repeat"></i></span></p> | ||||
|                     <p class="subtitle">Available to <a href="https://patreon.com/jellywx"><span class="patreon-color">Patreon <span class="icon"><i class="fab fa-patreon"></i></span></span></a> subscribers at <strong>$2/month</strong></p> | ||||
|                     <p class="subtitle">Available to <a href="https://patreon.com/jellywx"><span class="patreon-color">Patreon <span class="icon"><i class="fab fa-patreon"></i></span></span></a> subscribers at <strong>$2/month</strong>.</p> | ||||
|                 </article> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <section class="hero is-small"> | ||||
|     <section class="hero is-medium"> | ||||
|         <div class="hero-body"> | ||||
|             <div class="container has-text-centered"> | ||||
|                 <p class="title">Ready to go?</p> | ||||
|                 <p class="content"> | ||||
|                     Add the bot to get started! | ||||
|                 </p> | ||||
|             <div class="columns"> | ||||
|                 <div class="column"> | ||||
|                     <div class="container has-text-centered"> | ||||
|                         <p class="title">Technically-minded?</p> | ||||
|                         <p class="content"> | ||||
|                             Install the bot on your own computer | ||||
|                         </p> | ||||
|                         <a class="button is-size-6 is-rounded is-link" href="https://gitea.jellypro.xyz/jude/reminder-bot"> | ||||
|                             <p class="is-size-6"> | ||||
|                                 <span>Install</span> <span class="icon"><i class="fas fa-chevron-right"></i></span> | ||||
|                             </p> | ||||
|                         </a> | ||||
|                     </div> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="column"> | ||||
|                     <div class="container has-text-centered"> | ||||
|                         <p class="title">Ready to go?</p> | ||||
|                         <p class="content"> | ||||
|                             Add the bot to get started | ||||
|                         </p> | ||||
|                         <a class="button is-size-6 is-rounded is-success" href="https://invite.reminder-bot.com"> | ||||
|                             <p class="is-size-6"> | ||||
|                                 <span>Add Now</span> <span class="icon"><i class="fas fa-chevron-right"></i></span> | ||||
|                             </p> | ||||
|                         </a> | ||||
|                     </div> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="column"> | ||||
|                     <div class="container has-text-centered"> | ||||
|                         <p class="title">Need support?</p> | ||||
|                         <p class="content"> | ||||
|                             Check out our guides, or join our Discord | ||||
|                         </p> | ||||
|                         <a class="button is-size-6 is-rounded is-primary" href="/help"> | ||||
|                             <p class="is-size-6"> | ||||
|                                 <span>Guides</span> <span class="icon"><i class="fas fa-chevron-right"></i></span> | ||||
|                             </p> | ||||
|                         </a> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div class="hero-foot has-text-centered"> | ||||
|             <a class="button is-size-6 is-rounded is-success" href="https://invite.reminder-bot.com"> | ||||
|                 <p class="is-size-6"> | ||||
|                     Add Now <span class="icon"><i class="fas fa-chevron-right"></i></span> | ||||
|                 </p> | ||||
|             </a> | ||||
|         </div> | ||||
|     </section> | ||||
|  | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -13,7 +13,7 @@ | ||||
|     <section class="section"> | ||||
|         <div class="container"> | ||||
|             <h2 class="title">Who we are</h2> | ||||
|             <p class="is-size-5 pl-6"> | ||||
|             <p> | ||||
|                 Reminder Bot is operated solely by Jude Southworth. You can contact me by email at | ||||
|                 <a href="mailto:jude@jellywx.com">jude@jellywx.com</a>, or via private/public message on Discord at | ||||
|                 <a href="https://discord.jellywx.com">https://discord.jellywx.com</a>. | ||||
| @@ -24,12 +24,16 @@ | ||||
|     <section class="section"> | ||||
|         <div class="container"> | ||||
|             <h2 class="title">What data we collect</h2> | ||||
|             <p class="is-size-5 pl-6"> | ||||
|             <p> | ||||
|                 Reminder Bot stores limited data necessary for the function of the bot. This data | ||||
|                 is your <strong>unique user ID</strong>, <strong>timezone</strong>, and <strong>direct message channel</strong>. | ||||
|                 <br> | ||||
|                 <br> | ||||
|                 Timezones are provided by the user or the user's browser. | ||||
|                 <br><br> | ||||
|                 Some  additional information is collected by the dashboard for the purpose of debugging.   This is your | ||||
|                 <strong>time spent on the website</strong>, <strong>current URL</strong>, <strong>unique user ID</strong>, | ||||
|                 <strong>unique session token</strong>, <strong>contents of any client errors</strong>. | ||||
|             </p> | ||||
|         </div> | ||||
|     </section> | ||||
| @@ -37,10 +41,12 @@ | ||||
|     <section class="section"> | ||||
|         <div class="container"> | ||||
|             <h2 class="title">Why we collect this data</h2> | ||||
|             <p class="is-size-5 pl-6"> | ||||
|             <p> | ||||
|                 Unique user IDs are stored to <strong>keep track of who sets reminders</strong>. User timezones are | ||||
|                 stored to allow users to set reminders in their local timezone. Direct message channels are stored to | ||||
|                 allow the setting of reminders for your direct message channel. | ||||
|                 <br> | ||||
|                 Information collected  by the dashboard is for resolving bugs. | ||||
|             </p> | ||||
|         </div> | ||||
|     </section> | ||||
| @@ -48,7 +54,7 @@ | ||||
|     <section class="section"> | ||||
|         <div class="container"> | ||||
|             <h2 class="title">Who your data is shared with</h2> | ||||
|             <p class="is-size-5 pl-6"> | ||||
|             <p> | ||||
|                 Your data is also guarded by the privacy policies of <strong>MEGA</strong>, our backup provider, and | ||||
|                 <strong>Hetzner</strong>, our hosting provider. | ||||
|             </p> | ||||
| @@ -58,17 +64,13 @@ | ||||
|     <section class="section"> | ||||
|         <div class="container"> | ||||
|             <h2 class="title">Accessing or removing your data</h2> | ||||
|             <p class="is-size-5 pl-6"> | ||||
|             <p> | ||||
|                 Your timezone can be removed with the command <strong>/timezone UTC</strong>. Other data can be removed | ||||
|                 on request. Please contact me. | ||||
|                 <br> | ||||
|                 <br> | ||||
|                 Reminders created in a guild/channel will be removed automatically when the bot is removed from the | ||||
|                 guild, the guild is deleted, or channel is deleted. Data is otherwise not removed automatically. | ||||
|                 <br> | ||||
|                 <br> | ||||
|                 Reminders deleted with <strong>/del</strong> or via the dashboard are removed from the live database | ||||
|                 instantly, but may persist in backups for up to a year. | ||||
|             </p> | ||||
|         </div> | ||||
|     </section> | ||||
|   | ||||
							
								
								
									
										17
									
								
								web/templates/reminder_dashboard/guild_error.html.tera
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								web/templates/reminder_dashboard/guild_error.html.tera
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| <div class="hero is-fullheight"> | ||||
|     <div class="hero-body"> | ||||
|         <div class="container has-text-centered"> | ||||
|             <p class="title"> | ||||
|                 We couldn't get this server's data | ||||
|             </p> | ||||
|             <p class="subtitle"> | ||||
|                 Please check Reminder Bot is in the server, and has correct permissions. | ||||
|             </p> | ||||
|             <a class="button is-size-4 is-rounded is-success" href="https://invite.reminder-bot.com"> | ||||
|                 <p class="is-size-4"> | ||||
|                     <span>Add to Server</span> <span class="icon"><i class="fas fa-chevron-right"></i></span> | ||||
|                 </p> | ||||
|             </a> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| @@ -1,251 +1,269 @@ | ||||
| <div class="columns reminderContent {% if creating %}creator{% endif %}"> | ||||
|     <div class="column discord-frame"> | ||||
|         <article class="media"> | ||||
|             <figure class="media-left"> | ||||
|                 <p class="image is-32x32 customizable"> | ||||
|                     <a> | ||||
|                         <img class="is-rounded discord-avatar" src="/static/img/bg.webp" alt="Image for discord avatar"> | ||||
|                     </a> | ||||
|                 </p> | ||||
|             </figure> | ||||
|             <div class="media-content"> | ||||
|                 <div class="content"> | ||||
|                     <div class="discord-message-header"> | ||||
|                         <label class="is-sr-only">Username Override</label> | ||||
|                         <input class="discord-username message-input" placeholder="Username Override" | ||||
|                                maxlength="32" name="username"> | ||||
|                     </div> | ||||
|                     <label class="is-sr-only">Message</label> | ||||
|                     <textarea class="message-input autoresize discord-content" | ||||
|                               placeholder="Message Content..." | ||||
|                               maxlength="2000" name="content" rows="1"></textarea> | ||||
| <div class="reminderContent {% if creating %}creator{% endif %}"> | ||||
|     <div class="columns is-mobile column reminder-topbar"> | ||||
|         {% if not creating %} | ||||
|         <div class="invert-collapses channel-bar"> | ||||
|             #channel | ||||
|         </div> | ||||
|         {% endif %} | ||||
|         <div class="name-bar"> | ||||
|             <div class="field"> | ||||
|                 <div class="control"> | ||||
|                     <label class="label sr-only">Reminder Name</label> | ||||
|                     <input class="input" type="text" name="name" placeholder="Reminder Name" maxlength="100"> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div class="hide-button-bar"> | ||||
|             <button class="button hide-box"> | ||||
|                 <span class="is-sr-only">Hide reminder</span><i class="fas fa-chevron-down"></i> | ||||
|             </button> | ||||
|         </div> | ||||
|     </div> | ||||
|     <div class="columns reminder-settings"> | ||||
|         <div class="column discord-frame"> | ||||
|             <article class="media"> | ||||
|                 <figure class="media-left"> | ||||
|                     <p class="image is-32x32 customizable"> | ||||
|                         <a> | ||||
|                             <img class="is-rounded avatar" src="/static/img/bg.webp" alt="Image for discord avatar"> | ||||
|                         </a> | ||||
|                     </p> | ||||
|                 </figure> | ||||
|                 <div class="media-content"> | ||||
|                     <div class="content"> | ||||
|                         <div class="discord-message-header"> | ||||
|                             <label class="is-sr-only">Username Override</label> | ||||
|                             <input class="discord-username message-input" placeholder="Username Override" | ||||
|                                    maxlength="32" name="username"> | ||||
|                         </div> | ||||
|                         <label class="is-sr-only">Message</label> | ||||
|                         <textarea class="message-input autoresize discord-content" | ||||
|                                   placeholder="Message Content..." | ||||
|                                   maxlength="2000" name="content" rows="1"></textarea> | ||||
|  | ||||
|                     <div class="discord-embed"> | ||||
|                         <div class="embed-body"> | ||||
|                             <button class="change-color button is-rounded is-small"> | ||||
|                                 <span class="is-sr-only">Choose embed color</span><i class="fas fa-eye-dropper"></i> | ||||
|                             </button> | ||||
|                             <div class="a"> | ||||
|                                 <div class="embed-author-box"> | ||||
|                                     <div class="a"> | ||||
|                                         <p class="image is-24x24 customizable"> | ||||
|                                             <a> | ||||
|                                                 <img class="is-rounded embed_author_url" src="/static/img/bg.webp" alt="Image for embed author"> | ||||
|                                             </a> | ||||
|                                         </p> | ||||
|                                     </div> | ||||
|  | ||||
|                                     <div class="b"> | ||||
|                                         <label class="is-sr-only" for="embedAuthor">Embed Author</label> | ||||
|                                         <textarea | ||||
|                                                 class="discord-embed-author message-input autoresize" | ||||
|                                                 placeholder="Embed Author..." rows="1" maxlength="256" | ||||
|                                                 name="embed_author"></textarea> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|  | ||||
|                                 <label class="is-sr-only" for="embedTitle">Embed Title</label> | ||||
|                                 <textarea class="discord-title message-input  autoresize" | ||||
|                                           placeholder="Embed Title..." | ||||
|                                           maxlength="256" rows="1" | ||||
|                                           name="embed_title"></textarea> | ||||
|                                 <br> | ||||
|                                 <label class="is-sr-only" for="embedDescription">Embed Description</label> | ||||
|                                 <textarea class="discord-description message-input autoresize " | ||||
|                                           placeholder="Embed Description..." | ||||
|                                           maxlength="4096" name="embed_description" | ||||
|                                           rows="1"></textarea> | ||||
|                                 <br> | ||||
|  | ||||
|                                 <div class="embed-multifield-box"> | ||||
|                                     <div data-inlined="1" class="embed-field-box"> | ||||
|                                         <label class="is-sr-only" for="embedFieldTitle">Field Title</label> | ||||
|                                         <div class="is-flex"> | ||||
|                                             <textarea class="discord-field-title field-input message-input autoresize" | ||||
|                                                       placeholder="Field Title..." rows="1" | ||||
|                                                       maxlength="256" name="embed_field_title[]"></textarea> | ||||
|                                             <button class="button is-small inline-btn"> | ||||
|                                                 <span class="is-sr-only">Toggle field inline</span><i class="fas fa-arrows-h"></i> | ||||
|                                             </button> | ||||
|                         <div class="discord-embed"> | ||||
|                             <div class="embed-body"> | ||||
|                                 <button class="change-color button is-rounded is-small"> | ||||
|                                     <span class="is-sr-only">Choose embed color</span><i class="fas fa-eye-dropper"></i> | ||||
|                                 </button> | ||||
|                                 <div class="a"> | ||||
|                                     <div class="embed-author-box"> | ||||
|                                         <div class="a"> | ||||
|                                             <p class="image is-24x24 customizable"> | ||||
|                                                 <a> | ||||
|                                                     <img class="is-rounded embed_author_url" src="/static/img/bg.webp" alt="Image for embed author"> | ||||
|                                                 </a> | ||||
|                                             </p> | ||||
|                                         </div> | ||||
|  | ||||
|                                         <label class="is-sr-only" for="embedFieldValue">Field Value</label> | ||||
|                                         <textarea | ||||
|                                                 class="discord-field-value field-input message-input autoresize " | ||||
|                                                 placeholder="Field Value..." | ||||
|                                                 maxlength="1024" name="embed_field_value[]" | ||||
|                                                 rows="1"></textarea> | ||||
|                                         <div class="b"> | ||||
|                                             <label class="is-sr-only" for="embedAuthor">Embed Author</label> | ||||
|                                             <textarea | ||||
|                                                     class="discord-embed-author message-input autoresize" | ||||
|                                                     placeholder="Embed Author..." rows="1" maxlength="256" | ||||
|                                                     name="embed_author"></textarea> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|  | ||||
|                                     <label class="is-sr-only" for="embedTitle">Embed Title</label> | ||||
|                                     <textarea class="discord-title message-input  autoresize" | ||||
|                                               placeholder="Embed Title..." | ||||
|                                               maxlength="256" rows="1" | ||||
|                                               name="embed_title"></textarea> | ||||
|                                     <br> | ||||
|                                     <label class="is-sr-only" for="embedDescription">Embed Description</label> | ||||
|                                     <textarea class="discord-description message-input autoresize " | ||||
|                                               placeholder="Embed Description..." | ||||
|                                               maxlength="4096" name="embed_description" | ||||
|                                               rows="1"></textarea> | ||||
|                                     <br> | ||||
|  | ||||
|                                     <div class="embed-multifield-box"> | ||||
|                                         <div data-inlined="1" class="embed-field-box"> | ||||
|                                             <label class="is-sr-only" for="embedFieldTitle">Field Title</label> | ||||
|                                             <div class="is-flex"> | ||||
|                                                 <textarea class="discord-field-title field-input message-input autoresize" | ||||
|                                                           placeholder="Field Title..." rows="1" | ||||
|                                                           maxlength="256" name="embed_field_title[]"></textarea> | ||||
|                                                 <button class="button is-small inline-btn"> | ||||
|                                                     <span class="is-sr-only">Toggle field inline</span><i class="fas fa-arrows-h"></i> | ||||
|                                                 </button> | ||||
|                                             </div> | ||||
|  | ||||
|                                             <label class="is-sr-only" for="embedFieldValue">Field Value</label> | ||||
|                                             <textarea | ||||
|                                                     class="discord-field-value field-input message-input autoresize " | ||||
|                                                     placeholder="Field Value..." | ||||
|                                                     maxlength="1024" name="embed_field_value[]" | ||||
|                                                     rows="1"></textarea> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|  | ||||
|                                 <div class="b"> | ||||
|                                     <p class="image thumbnail customizable"> | ||||
|                                         <a> | ||||
|                                             <img class="embed_thumbnail_url" src="/static/img/bg.webp" alt="Square thumbnail embedded image"> | ||||
|                                         </a> | ||||
|                                     </p> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|  | ||||
|                             <div class="b"> | ||||
|                                 <p class="image thumbnail customizable"> | ||||
|                                     <a> | ||||
|                                         <img class="embed_thumbnail_url" src="/static/img/bg.webp" alt="Square thumbnail embedded image"> | ||||
|                                     </a> | ||||
|                                 </p> | ||||
|                             </div> | ||||
|                         </div> | ||||
|  | ||||
|                         <p class="image is-400x300 customizable"> | ||||
|                             <a> | ||||
|                                 <img class="embed_image_url" src="/static/img/bg.webp" alt="Large embedded image"> | ||||
|                             </a> | ||||
|                         </p> | ||||
|  | ||||
|                         <div class="embed-footer-box"> | ||||
|                             <p class="image is-20x20 customizable"> | ||||
|                             <p class="image is-400x300 customizable"> | ||||
|                                 <a> | ||||
|                                     <img class="is-rounded embed_footer_url" src="/static/img/bg.webp" alt="Footer profile-like image"> | ||||
|                                     <img class="embed_image_url" src="/static/img/bg.webp" alt="Large embedded image"> | ||||
|                                 </a> | ||||
|                             </p> | ||||
|  | ||||
|                             <label class="is-sr-only" for="embedFooter">Embed Footer text</label> | ||||
|                             <textarea class="discord-embed-footer message-input autoresize " | ||||
|                                       placeholder="Embed Footer..." | ||||
|                                       maxlength="2048" name="embed_footer" rows="1"></textarea> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </article> | ||||
|     </div> | ||||
|     <div class="column settings"> | ||||
|         <div class="columns is-mobile reminder-topbar"> | ||||
|             <div class="column"> | ||||
|                 <div class="field"> | ||||
|                     <div class="control"> | ||||
|                         <label class="label sr-only">Reminder Name</label> | ||||
|                         <input class="input" type="text" name="name" placeholder="Reminder Name"> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="column is-narrow"> | ||||
|                 <button class="button is-rounded hide-box"> | ||||
|                     <span class="is-sr-only">Hide reminder</span><i class="fas fa-chevron-down"></i> | ||||
|                 </button> | ||||
|             </div> | ||||
|         </div> | ||||
|                             <div class="embed-footer-box"> | ||||
|                                 <p class="image is-20x20 customizable"> | ||||
|                                     <a> | ||||
|                                         <img class="is-rounded embed_footer_url" src="/static/img/bg.webp" alt="Footer profile-like image"> | ||||
|                                     </a> | ||||
|                                 </p> | ||||
|  | ||||
|         <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> | ||||
|                                 <label class="is-sr-only" for="embedFooter">Embed Footer text</label> | ||||
|                                 <textarea class="discord-embed-footer message-input autoresize " | ||||
|                                           placeholder="Embed Footer..." | ||||
|                                           maxlength="2048" name="embed_footer" rows="1"></textarea> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="column"> | ||||
|                 <div class="field"> | ||||
|                     <div class="control"> | ||||
|                         <label class="label collapses"> | ||||
|                             Time* | ||||
|                             <input class="input" type="datetime-local" step="1" name="time"> | ||||
|                         </label> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|             </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> | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|         <div class="collapses"> | ||||
|             <div class="patreon-only"> | ||||
|                 <div class="field"> | ||||
|                     <label class="label">Interval <a class="foreground" href="/help/intervals"><i class="fas fa-question-circle"></i></a></label> | ||||
|                     <div class="control intervalSelector" style="min-width: 400px;" > | ||||
|                         <div class="input interval-group"> | ||||
|                             <div class="interval-group-left"> | ||||
|                                 <label> | ||||
|                                     <span class="is-sr-only">Interval months</span> | ||||
|                                     <input class="w2" type="text" pattern="\d*" name="interval_months" maxlength="2" placeholder=""> <span class="half-rem"></span> months, <span class="half-rem"></span> | ||||
|                                 </label> | ||||
|                                 <label> | ||||
|                                     <span class="is-sr-only">Interval days</span> | ||||
|                                     <input class="w3" type="text" pattern="\d*" name="interval_days" maxlength="4" placeholder=""> <span class="half-rem"></span> days, <span class="half-rem"></span> | ||||
|                                 </label> | ||||
|                                 <label> | ||||
|                                     <span class="is-sr-only">Interval hours</span> | ||||
|                                     <input class="w2" type="text" pattern="\d*" name="interval_hours" maxlength="2" placeholder="HH">: | ||||
|                                 </label> | ||||
|                                 <label> | ||||
|                                     <span class="is-sr-only">Interval minutes</span> | ||||
|                                     <input class="w2" type="text" pattern="\d*" name="interval_minutes" maxlength="2" placeholder="MM">: | ||||
|                                 </label> | ||||
|                                 <label> | ||||
|                                     <span class="is-sr-only">Interval seconds</span> | ||||
|                                     <input class="w2" type="text" pattern="\d*" name="interval_seconds" maxlength="2" placeholder="SS"> | ||||
|             <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 class="collapses split-controls"> | ||||
|                 <div> | ||||
|                     <div class="patreon-only"> | ||||
|                         <div class="patreon-invert foreground"> | ||||
|                             Intervals available on <a href="https://patreon.com/jellywx">Patreon</a> or <a href="https://gitea.jellypro.xyz/jude/reminder-bot">self-hosting</a> | ||||
|                         </div> | ||||
|                         <div class="field"> | ||||
|                             <label class="label">Interval <a class="foreground" href="/help/intervals"><i class="fas fa-question-circle"></i></a></label> | ||||
|                             <div class="control intervalSelector"> | ||||
|                                 <div class="input interval-group"> | ||||
|                                     <div class="interval-group-left"> | ||||
|                                         <span class="no-break"> | ||||
|                                             <label> | ||||
|                                                 <span class="is-sr-only">Interval months</span> | ||||
|                                                 <input class="w2" type="text" pattern="\d*" name="interval_months" maxlength="2" placeholder=""> <span class="half-rem"></span> months, <span class="half-rem"></span> | ||||
|                                             </label> | ||||
|                                             <label> | ||||
|                                                 <span class="is-sr-only">Interval days</span> | ||||
|                                                 <input class="w3" type="text" pattern="\d*" name="interval_days" maxlength="4" placeholder=""> <span class="half-rem"></span> days, <span class="half-rem"></span> | ||||
|                                             </label> | ||||
|                                         </span> | ||||
|                                         <span class="no-break"> | ||||
|                                             <label> | ||||
|                                                 <span class="is-sr-only">Interval hours</span> | ||||
|                                                 <input class="w2" type="text" pattern="\d*" name="interval_hours" maxlength="2" placeholder="HH">: | ||||
|                                             </label> | ||||
|                                             <label> | ||||
|                                                 <span class="is-sr-only">Interval minutes</span> | ||||
|                                                 <input class="w2" type="text" pattern="\d*" name="interval_minutes" maxlength="2" placeholder="MM">: | ||||
|                                             </label> | ||||
|                                             <label> | ||||
|                                                 <span class="is-sr-only">Interval seconds</span> | ||||
|                                                 <input class="w2" type="text" pattern="\d*" name="interval_seconds" maxlength="2" placeholder="SS"> | ||||
|                                             </label> | ||||
|                                         </span> | ||||
|                                     </div> | ||||
|                                     <button class="clear"><span class="is-sr-only">Clear interval</span><span class="icon"><i class="fas fa-trash"></i></span></button> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|  | ||||
|                         <div class="field"> | ||||
|                             <div class="control"> | ||||
|                                 <label class="label"> | ||||
|                                     Expiration | ||||
|                                     <input class="input" type="datetime-local" step="1" name="expiration"> | ||||
|                                 </label> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="columns is-mobile tts-row"> | ||||
|                         <div class="column has-text-centered"> | ||||
|                             <div class="is-boxed"> | ||||
|                                 <label class="label">Enable TTS <input type="checkbox" name="tts"></label> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                         <div class="column has-text-centered"> | ||||
|                             <div class="file is-small is-boxed"> | ||||
|                                 <label class="file-label"> | ||||
|                                     <input class="file-input" type="file" name="attachment"> | ||||
|                                     <span class="file-cta"> | ||||
|                                         <span class="file-label"> | ||||
|                                             Add Attachment | ||||
|                                         </span> | ||||
|                                         <span class="file-icon"> | ||||
|                                             <i class="fas fa-upload"></i> | ||||
|                                         </span> | ||||
|                                     </span> | ||||
|                                 </label> | ||||
|                             </div> | ||||
|                             <button class="clear"><span class="is-sr-only">Clear interval</span><span class="icon"><i class="fas fa-trash"></i></span></button> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="field"> | ||||
|                     <div class="control"> | ||||
|                         <label class="label"> | ||||
|                             Expiration | ||||
|                             <input class="input" type="datetime-local" step="1" name="expiration"> | ||||
|                         </label> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="columns"> | ||||
|                 <div class="column has-text-centered"> | ||||
|                     <div class="is-boxed"> | ||||
|                         <label class="label">Enable TTS <input type="checkbox" name="tts"></label> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 <div class="column has-text-centered"> | ||||
|                     <div class="file is-small is-boxed"> | ||||
|                         <label class="file-label"> | ||||
|                             <input class="file-input" type="file" name="attachment"> | ||||
|                             <span class="file-cta"> | ||||
|                                 <span class="file-label"> | ||||
|                                     Add Attachment | ||||
|                                 </span> | ||||
|                                 <span class="file-icon"> | ||||
|                                     <i class="fas fa-upload"></i> | ||||
|                                 </span> | ||||
|                             </span> | ||||
|                         </label> | ||||
|                     </div> | ||||
|                 </div> | ||||
|         </div> | ||||
|     </div> | ||||
|     {% if creating %} | ||||
|         <div class="button-row"> | ||||
|             <div class="button-row-reminder"> | ||||
|                 <button class="button is-success" id="createReminder"> | ||||
|                     <span>Create Reminder</span> <span class="icon"><i class="fas fa-sparkles"></i></span> | ||||
|                 </button> | ||||
|             </div> | ||||
|  | ||||
|             <div> | ||||
|                 <span class="pad-left"></span> | ||||
|                 {% if creating %} | ||||
|                     <button class="button is-success" id="createReminder"> | ||||
|                         <span>Create Reminder</span> <span class="icon"><i class="fas fa-sparkles"></i></span> | ||||
|                     </button> | ||||
|             <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> | ||||
|                 {% else %} | ||||
|                     <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> | ||||
|                 {% endif %} | ||||
|                 </div> | ||||
|             </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,5 @@ | ||||
| <div> | ||||
|  | ||||
| </div> | ||||
|  | ||||
| <!--<script src="/static/js/reminder_errors.js"></script>--> | ||||
							
								
								
									
										12
									
								
								web/templates/reminder_dashboard/user_error.html.tera
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								web/templates/reminder_dashboard/user_error.html.tera
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| <div class="hero is-fullheight"> | ||||
|     <div class="hero-body"> | ||||
|         <div class="container has-text-centered"> | ||||
|             <p class="title"> | ||||
|                 You do not have permissions for this server | ||||
|             </p> | ||||
|             <p class="subtitle"> | ||||
|                 Ask an admin to grant you the "Manage Messages" permission. | ||||
|             </p> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| @@ -13,8 +13,8 @@ | ||||
|     <section class="section"> | ||||
|         <div class="container"> | ||||
|             <h2 class="title">Outline</h2> | ||||
|             <p class="is-size-5 pl-6"> | ||||
|                 The Terms of Service apply whenever you use <strong>Reminder Bot</strong> and the | ||||
|             <p class=""> | ||||
|                 The Terms of Service apply whenever you use the hosted edition of <strong>Reminder Bot</strong> and the | ||||
|                 <strong>JellyWX's Home</strong> Discord server. | ||||
|                 <br> | ||||
|                 <br> | ||||
| @@ -25,7 +25,7 @@ | ||||
|                 <br> | ||||
|                 <br> | ||||
|                 The Terms of Service may be updated. Notice will be provided via the Discord server. You | ||||
|                 should consider the Terms of Service to be a strong for appropriate behaviour. | ||||
|                 should consider the Terms of Service to be a guide for appropriate behaviour. | ||||
|             </p> | ||||
|         </div> | ||||
|     </section> | ||||
| @@ -33,32 +33,43 @@ | ||||
|     <section class="section"> | ||||
|         <div class="container"> | ||||
|             <h2 class="title">Reminder Bot</h2> | ||||
|             <ul class="is-size-5 pl-6"> | ||||
|                 <li>Reasonably disclose potential exploits or bugs to me by email or by Discord private message</li> | ||||
|                 <li>Do not use the bot to harass other Discord users</li> | ||||
|                 <li>Do not use the bot to transmit malware or other illegal content</li> | ||||
|                 <li>Do not use the bot to send more than 15 messages during a 60 second period</li> | ||||
|             <p> | ||||
|                 The Terms of Service <strong>do not</strong> apply to self-hosting users who are using the source code | ||||
|                 or pre-packaged Debian files to run their own instance of Reminder Bot. | ||||
|             </p> | ||||
|             <br> | ||||
|             <h3 class="subtitle">Your access to Reminder Bot may be restricted if you:</h3> | ||||
|             <ul class="pl-6" style="list-style: disc"> | ||||
|                 <li>Abuse exploits or bugs in Reminder Bot.</li> | ||||
|                 <li>Use the bot to harass other Discord users.</li> | ||||
|                 <li>Use the bot to transmit malware or other illegal content.</li> | ||||
|                 <li>Use the bot to send more than 15 messages during a 60 second period.</li> | ||||
|                 <li> | ||||
|                     Do not attempt to circumvent restrictions imposed by the bot or website, including trying to access | ||||
|                     Attempt to circumvent restrictions imposed by the bot or website, including trying to access | ||||
|                     data of other users, circumvent Patreon restrictions, or uploading files and creating reminders that | ||||
|                     are too large for the bot to send or process. Some or all of these actions may be illegal in your | ||||
|                     country | ||||
|                     are too large for the bot to send or process. | ||||
|                 </li> | ||||
|             </ul> | ||||
|             <br> | ||||
|             <p> | ||||
|                 Some or all of these actions may be illegal in your country. | ||||
|             </p> | ||||
|         </div> | ||||
|     </section> | ||||
|  | ||||
|     <section class="section"> | ||||
|         <div class="container"> | ||||
|             <h2 class="title">JellyWX's Home</h2> | ||||
|             <ul class="is-size-5 pl-6"> | ||||
|                 <li>Do not discuss politics, harass other users, or use language intended to upset other users</li> | ||||
|                 <li>Do not share personal information about yourself or any other user. This includes but is not | ||||
|             <h3 class="subtitle">Your access to the JellyWX's Home Discord server may be restricted if you:</h3> | ||||
|             <ul class="pl-6" style="list-style: disc"> | ||||
|                 <li>Discuss politics, harass other users, or use language intended to upset other users.</li> | ||||
|                 <li>Abuse any exploits.</li> | ||||
|                 <li>Share personal information about yourself or any other user. This includes but is not | ||||
|                     limited to real names<sup>1</sup>, addresses, phone numbers, country of origin<sup>2</sup>, religion, email address, | ||||
|                     IP address.</li> | ||||
|                 <li>Do not send malicious links or attachments</li> | ||||
|                 <li>Do not advertise</li> | ||||
|                 <li>Do not send unwarranted direct messages</li> | ||||
|                 <li>Send malicious links or attachments.</li> | ||||
|                 <li>Advertise without permission.</li> | ||||
|                 <li>Send unwarranted direct messages.</li> | ||||
|             </ul> | ||||
|             <p class="small"> | ||||
|                 <sup>1</sup> Some users may use their real name on their account. In this case, do not assert that | ||||
|   | ||||
		Reference in New Issue
	
	Block a user