Files
reminder-bot/src/web/routes/dashboard/mod.rs
T
2026-10-18 20:06:55 +01:00

1031 lines
33 KiB
Rust

use std::path::Path;
use base64::{prelude::BASE64_STANDARD, Engine};
use chrono::{naive::NaiveDateTime, Utc};
use log::warn;
use rand::{rngs::OsRng, seq::IteratorRandom};
use rocket::{
fs::NamedFile, get, http::CookieJar, response::Redirect, serde::json::json, Responder,
};
use rocket_dyn_templates::Template;
use secrecy::ExposeSecret;
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use serenity::http::HttpError;
use serenity::{
all::CacheHttp,
builder::CreateWebhook,
client::Context,
model::id::{ChannelId, GuildId, UserId},
};
use sqlx::types::Json;
use sqlx::FromRow;
use crate::image_upload::ImageReference;
use crate::utils::check_subscription;
use crate::web::{
catchers::internal_server_error,
consts::{
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_NAME_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL,
},
guards::transaction::Transaction,
routes::JsonResult,
string, Error,
};
pub mod api;
pub mod export;
type Unset<T> = Option<T>;
fn image_reference_error(error: sqlx::Error) -> serde_json::Value {
warn!("Could not resolve image upload: {:?}", error);
json!({"error": "Could not resolve image upload"})
}
fn name_default() -> String {
"Reminder".to_string()
}
fn template_name_default() -> String {
"Template".to_string()
}
fn channel_default() -> u64 {
0
}
fn id_default() -> u32 {
0
}
fn interval_default() -> Unset<Option<u32>> {
None
}
#[derive(sqlx::Type)]
#[sqlx(transparent)]
pub 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_STANDARD.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_STANDARD.encode(&self.0))
}
}
#[derive(Serialize, Deserialize)]
pub struct ReminderTemplate {
#[serde(default = "id_default")]
id: u32,
#[serde(default = "id_default")]
guild_id: u32,
#[serde(default = "template_name_default")]
name: String,
attachment: Option<Attachment>,
attachment_name: Option<String>,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
avatar: Option<String>,
content: String,
embed_author: String,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
embed_author_url: Option<String>,
embed_color: u32,
embed_description: String,
embed_footer: String,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
embed_footer_url: Option<String>,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
embed_image_url: Option<String>,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
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>,
}
#[derive(Serialize, Deserialize)]
pub struct ReminderTemplateCsv {
#[serde(default = "template_name_default")]
name: String,
attachment: Option<Attachment>,
attachment_name: Option<String>,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
avatar: Option<String>,
content: String,
embed_author: String,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
embed_author_url: Option<String>,
embed_color: u32,
embed_description: String,
embed_footer: String,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
embed_footer_url: Option<String>,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
embed_image_url: Option<String>,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
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>,
}
#[derive(Deserialize)]
pub struct DeleteReminderTemplate {
id: u32,
}
#[derive(Serialize, Deserialize)]
pub struct EmbedField {
title: String,
value: String,
inline: bool,
}
#[derive(Deserialize)]
pub struct CreateReminder {
attachment: Option<Attachment>,
attachment_name: Option<String>,
avatar: Option<String>,
#[serde(with = "string")]
channel: u64,
content: String,
embed_author: String,
embed_author_url: Option<String>,
embed_color: u32,
embed_description: String,
embed_footer: String,
embed_footer_url: Option<String>,
embed_image_url: Option<String>,
embed_thumbnail_url: Option<String>,
embed_title: String,
embed_fields: Option<Json<Vec<EmbedField>>>,
enabled: bool,
expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>,
#[serde(default = "name_default")]
name: String,
restartable: bool,
tts: bool,
username: Option<String>,
utc_time: NaiveDateTime,
}
#[derive(Serialize, FromRow)]
pub struct GetReminder {
attachment_name: Option<String>,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
avatar: Option<String>,
#[serde(with = "string")]
channel: u64,
content: String,
embed_author: String,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
embed_author_url: Option<String>,
embed_color: u32,
embed_description: String,
embed_footer: String,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
embed_footer_url: Option<String>,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
embed_image_url: Option<String>,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
embed_thumbnail_url: Option<String>,
embed_title: String,
embed_fields: Option<Json<Vec<EmbedField>>>,
enabled: bool,
expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>,
#[serde(default = "name_default")]
name: String,
restartable: bool,
tts: bool,
#[serde(default)]
uid: String,
username: Option<String>,
utc_time: NaiveDateTime,
}
#[derive(Serialize, Deserialize, FromRow)]
pub struct ReminderCsv {
attachment: Option<Attachment>,
attachment_name: Option<String>,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
avatar: Option<String>,
channel: String,
content: String,
embed_author: String,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
embed_author_url: Option<String>,
embed_color: u32,
embed_description: String,
embed_footer: String,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
embed_footer_url: Option<String>,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
embed_image_url: Option<String>,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
embed_thumbnail_url: Option<String>,
embed_title: String,
embed_fields: Option<String>,
enabled: bool,
expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>,
#[serde(default = "name_default")]
name: String,
restartable: bool,
tts: bool,
username: Option<String>,
utc_time: NaiveDateTime,
}
#[derive(Deserialize)]
pub struct PatchReminder {
uid: String,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
attachment: Unset<Option<Attachment>>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
attachment_name: Unset<Option<String>>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
avatar: Unset<Option<String>>,
#[serde(default = "channel_default")]
#[serde(with = "string")]
channel: u64,
#[serde(default)]
content: Unset<String>,
#[serde(default)]
embed_author: Unset<String>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
embed_author_url: Unset<Option<String>>,
#[serde(default)]
embed_color: Unset<u32>,
#[serde(default)]
embed_description: Unset<String>,
#[serde(default)]
embed_footer: Unset<String>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
embed_footer_url: Unset<Option<String>>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
embed_image_url: Unset<Option<String>>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
embed_thumbnail_url: Unset<Option<String>>,
#[serde(default)]
embed_title: Unset<String>,
#[serde(default)]
embed_fields: Unset<Json<Vec<EmbedField>>>,
#[serde(default)]
enabled: Unset<bool>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
expires: Unset<Option<NaiveDateTime>>,
#[serde(default = "interval_default")]
#[serde(deserialize_with = "deserialize_optional_field")]
interval_seconds: Unset<Option<u32>>,
#[serde(default = "interval_default")]
#[serde(deserialize_with = "deserialize_optional_field")]
interval_days: Unset<Option<u32>>,
#[serde(default = "interval_default")]
#[serde(deserialize_with = "deserialize_optional_field")]
interval_months: Unset<Option<u32>>,
#[serde(default)]
name: Unset<String>,
#[serde(default)]
restartable: Unset<bool>,
#[serde(default)]
tts: Unset<bool>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
username: Unset<Option<String>>,
#[serde(default)]
utc_time: Unset<NaiveDateTime>,
}
impl PatchReminder {
fn message_ok(&self) -> bool {
self.content.as_ref().map_or(true, |c| c.len() <= MAX_CONTENT_LENGTH)
&& self.embed_author.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_AUTHOR_LENGTH)
&& self
.embed_description
.as_ref()
.map_or(true, |c| c.len() <= MAX_EMBED_DESCRIPTION_LENGTH)
&& self.embed_footer.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_FOOTER_LENGTH)
&& self.embed_title.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_TITLE_LENGTH)
&& self.embed_fields.as_ref().map_or(true, |c| {
c.0.len() <= MAX_EMBED_FIELDS
&& c.0.iter().all(|f| {
f.title.len() <= MAX_EMBED_FIELD_TITLE_LENGTH
&& f.value.len() <= MAX_EMBED_FIELD_VALUE_LENGTH
})
})
&& self
.username
.as_ref()
.map_or(true, |c| c.as_ref().map_or(true, |v| v.len() <= MAX_USERNAME_LENGTH))
}
}
pub fn generate_uid() -> String {
let mut generator: OsRng = Default::default();
(0..64)
.map(|_| CHARACTERS.chars().choose(&mut generator).unwrap().to_owned().to_string())
.collect::<Vec<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)?))
}
#[derive(Deserialize)]
pub struct DeleteReminder {
uid: String,
}
#[derive(Deserialize)]
pub struct ImportBody {
body: String,
}
#[derive(Serialize, Deserialize)]
pub struct TodoCsv {
value: String,
channel_id: Option<String>,
}
pub(crate) async fn create_reminder(
ctx: &Context,
transaction: &mut Transaction<'_>,
guild_id: GuildId,
user_id: UserId,
reminder: CreateReminder,
) -> JsonResult {
// check guild in db
match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.get())
.fetch_one(transaction.executor())
.await
{
Err(sqlx::Error::RowNotFound) => {
if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.get())
.execute(transaction.executor())
.await
.is_err()
{
return Err(json!({"error": "Guild could not be created"}));
}
}
_ => {}
}
if !check_channel_matches_guild(ctx, ChannelId::new(reminder.channel), guild_id) {
warn!(
"Error in `create_reminder`: channel {} not found for guild {}",
reminder.channel, guild_id
);
return Err(json!({"error": "Channel not found"}));
}
let channel =
create_database_channel(&ctx, ChannelId::new(reminder.channel), transaction).await;
if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e);
// Provide more specific error messages based on the error type
let error_msg = match e {
Error::MissingDiscordPermission(permission) => {
format!(
"Please ensure the bot has the \"{}\" permission in the channel",
permission
)
}
_ => "Failed to configure channel for reminders. Please check the bot permissions"
.to_string(),
};
return Err(json!({"error": error_msg}));
}
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);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
if let Some(fields) = &reminder.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.username);
check_length_opt!(
MAX_URL_LENGTH,
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url,
reminder.avatar
);
// validate urls
check_url_opt!(
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url,
reminder.avatar
);
// validate time and interval
if reminder.utc_time < Utc::now().naive_utc() {
return Err(json!({"error": "Time must be in the future"}));
}
if reminder.interval_seconds.is_some()
|| reminder.interval_days.is_some()
|| reminder.interval_months.is_some()
{
if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32
+ reminder.interval_days.unwrap_or(0) * DAY as u32
+ reminder.interval_seconds.unwrap_or(0)
< *MIN_INTERVAL
{
return Err(json!({"error": "Interval too short"}));
}
}
// check patreon if necessary
if reminder.interval_seconds.is_some()
|| reminder.interval_days.is_some()
|| reminder.interval_months.is_some()
{
if !check_subscription(&ctx, transaction.executor(), user_id, Some(guild_id)).await {
return Err(json!({"error": "Patreon is required to set intervals"}));
}
}
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
} else {
reminder.username
};
let new_uid = generate_uid();
let avatar = ImageReference::from_url(transaction.executor(), reminder.avatar.as_deref())
.await
.map_err(image_reference_error)?;
let embed_image_url =
ImageReference::from_url(transaction.executor(), reminder.embed_image_url.as_deref())
.await
.map_err(image_reference_error)?;
let embed_thumbnail_url =
ImageReference::from_url(transaction.executor(), reminder.embed_thumbnail_url.as_deref())
.await
.map_err(image_reference_error)?;
let embed_author_url =
ImageReference::from_url(transaction.executor(), reminder.embed_author_url.as_deref())
.await
.map_err(image_reference_error)?;
let embed_footer_url =
ImageReference::from_url(transaction.executor(), reminder.embed_footer_url.as_deref())
.await
.map_err(image_reference_error)?;
// write to db
match sqlx::query!(
"INSERT INTO reminders (
uid,
attachment,
attachment_name,
channel_id,
avatar,
avatar_id,
content,
embed_author,
embed_author_url,
embed_author_id,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_footer_id,
embed_image_url,
embed_image_id,
embed_thumbnail_url,
embed_thumbnail_id,
embed_title,
embed_fields,
enabled,
expires,
interval_seconds,
interval_days,
interval_months,
name,
restartable,
tts,
username,
`utc_time`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new_uid,
reminder.attachment,
reminder.attachment_name,
channel,
avatar.url,
avatar.id,
reminder.content,
reminder.embed_author,
embed_author_url.url,
embed_author_url.id,
reminder.embed_color,
reminder.embed_description,
reminder.embed_footer,
embed_footer_url.url,
embed_footer_url.id,
embed_image_url.url,
embed_image_url.id,
embed_thumbnail_url.url,
embed_thumbnail_url.id,
reminder.embed_title,
reminder.embed_fields,
reminder.enabled,
reminder.expires,
reminder.interval_seconds,
reminder.interval_days,
reminder.interval_months,
name,
reminder.restartable,
reminder.tts,
username,
reminder.utc_time,
)
.execute(transaction.executor())
.await
{
Ok(_) => sqlx::query_as_unchecked!(
GetReminder,
"
SELECT
reminders.attachment_name,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.avatar_id), reminders.avatar) AS avatar,
channels.channel,
reminders.content,
reminders.embed_author,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_author_id), reminders.embed_author_url) AS embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_footer_id), reminders.embed_footer_url) AS embed_footer_url,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_image_id), reminders.embed_image_url) AS embed_image_url,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_thumbnail_id), reminders.embed_thumbnail_url) AS embed_thumbnail_url,
reminders.embed_title,
reminders.embed_fields,
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months,
reminders.name,
reminders.restartable,
reminders.tts,
reminders.uid,
reminders.username,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE uid = ?",
new_uid
)
.fetch_one(transaction.executor())
.await
.map(|r| Ok(json!(r)))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
Err(json!({"error": "Could not load reminder"}))
}),
Err(e) => {
warn!("Error in `create_reminder`: Could not execute query: {:?}", e);
Err(json!({"error": "Unknown error"}))
}
}
}
pub(crate) async fn create_reminder_template(
ctx: &Context,
transaction: &mut Transaction<'_>,
guild_id: GuildId,
reminder_template: ReminderTemplate,
) -> JsonResult {
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()
};
let avatar =
ImageReference::from_url(transaction.executor(), reminder_template.avatar.as_deref())
.await
.map_err(image_reference_error)?;
let embed_image_url = ImageReference::from_url(
transaction.executor(),
reminder_template.embed_image_url.as_deref(),
)
.await
.map_err(image_reference_error)?;
let embed_thumbnail_url = ImageReference::from_url(
transaction.executor(),
reminder_template.embed_thumbnail_url.as_deref(),
)
.await
.map_err(image_reference_error)?;
let embed_author_url = ImageReference::from_url(
transaction.executor(),
reminder_template.embed_author_url.as_deref(),
)
.await
.map_err(image_reference_error)?;
let embed_footer_url = ImageReference::from_url(
transaction.executor(),
reminder_template.embed_footer_url.as_deref(),
)
.await
.map_err(image_reference_error)?;
match sqlx::query!(
"INSERT INTO reminder_template
(guild_id,
name,
attachment,
attachment_name,
avatar,
avatar_id,
content,
embed_author,
embed_author_url,
embed_author_id,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_footer_id,
embed_image_url,
embed_image_id,
embed_thumbnail_url,
embed_thumbnail_id,
embed_title,
embed_fields,
interval_seconds,
interval_days,
interval_months,
tts,
username
) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
guild_id.get(),
name,
reminder_template.attachment,
reminder_template.attachment_name,
avatar.url,
avatar.id,
reminder_template.content,
reminder_template.embed_author,
embed_author_url.url,
embed_author_url.id,
reminder_template.embed_color,
reminder_template.embed_description,
reminder_template.embed_footer,
embed_footer_url.url,
embed_footer_url.id,
embed_image_url.url,
embed_image_url.id,
embed_thumbnail_url.url,
embed_thumbnail_url.id,
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(transaction.executor())
.await
{
Ok(_) => Ok(json!({})),
Err(e) => {
warn!("Could not create template for {}: {:?}", guild_id.get(), e);
json_err!("Could not create template")
}
}
}
fn check_channel_matches_guild(ctx: &Context, channel_id: ChannelId, guild_id: GuildId) -> bool {
match ctx.cache.guild(guild_id) {
Some(guild) => guild.channels.get(&channel_id).is_some(),
None => false,
}
}
async fn create_database_channel(
ctx: impl CacheHttp,
channel: ChannelId,
transaction: &mut Transaction<'_>,
) -> Result<u32, Error> {
let row = sqlx::query!(
"SELECT webhook_token, webhook_id FROM channels WHERE channel = ?",
channel.get()
)
.fetch_one(transaction.executor())
.await;
match row {
Ok(row) => {
let is_dm = channel
.to_channel(&ctx)
.await
.map_err(|e| {
if let serenity::Error::Http(http_error) = &e {
if let HttpError::UnsuccessfulRequest(response) = http_error {
if response.error.code == 50001 {
return Error::MissingDiscordPermission("View Channel");
}
}
}
Error::Serenity(e)
})?
.private()
.is_some();
if !is_dm && (row.webhook_token.is_none() || row.webhook_id.is_none()) {
let webhook = channel
.create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR))
.await
.map_err(|e| match &e {
serenity::Error::Http(HttpError::UnsuccessfulRequest(response)) => {
match response.error.code {
50001 => Error::MissingDiscordPermission("View Channel"),
50013 => Error::MissingDiscordPermission("Manage Webhooks"),
_ => Error::Serenity(e),
}
}
_ => Error::Serenity(e),
})?;
let token = webhook.token.unwrap();
sqlx::query!(
"
UPDATE channels SET webhook_id = ?, webhook_token = ? WHERE channel = ?
",
webhook.id.get(),
token.expose_secret(),
channel.get()
)
.execute(transaction.executor())
.await
.map_err(|e| Error::SQLx(e))?;
}
Ok(())
}
Err(sqlx::Error::RowNotFound) => {
// create webhook
let webhook = channel
.create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR))
.await
.map_err(|e| match &e {
serenity::Error::Http(HttpError::UnsuccessfulRequest(response)) => {
match response.error.code {
50001 => Error::MissingDiscordPermission("View Channel"),
50013 => Error::MissingDiscordPermission("Manage Webhooks"),
_ => Error::Serenity(e),
}
}
_ => Error::Serenity(e),
})?;
let token = webhook.token.unwrap();
// create database entry
sqlx::query!(
"
INSERT INTO channels (
webhook_id,
webhook_token,
channel
) VALUES (?, ?, ?)
",
webhook.id.get(),
token.expose_secret(),
channel.get()
)
.execute(transaction.executor())
.await
.map_err(|e| Error::SQLx(e))?;
Ok(())
}
Err(e) => Err(Error::SQLx(e)),
}?;
let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.get())
.fetch_one(transaction.executor())
.await
.map_err(|e| Error::SQLx(e))?;
Ok(row.id)
}
#[derive(Responder)]
pub enum DashboardPage {
#[response(status = 200)]
Ok(NamedFile),
#[response(status = 302)]
Unauthorised(Redirect),
#[response(status = 500)]
NotConfigured(Template),
}
// Legacy route to maintain compatibility with old dashboard routing
#[get("/?<id>")]
pub async fn reminders_redirect(id: &str) -> Redirect {
Redirect::to(format!("/dashboard/{}/reminders", id))
}
// Legacy route to maintain compatibility with old dashboard routing
#[get("/todo?<id>")]
pub async fn todos_redirect(id: &str) -> Redirect {
Redirect::to(format!("/dashboard/{}/todos", id))
}
#[get("/")]
pub async fn dashboard_home(cookies: &CookieJar<'_>) -> DashboardPage {
render_dashboard(cookies).await
}
#[get("/<_..>")]
pub async fn dashboard(cookies: &CookieJar<'_>) -> DashboardPage {
render_dashboard(cookies).await
}
async fn render_dashboard(cookies: &CookieJar<'_>) -> DashboardPage {
if cookies.get_private("userid").is_some() {
match NamedFile::open(Path::new(path!("static/index.html"))).await {
Ok(f) => DashboardPage::Ok(f),
Err(e) => {
warn!("Couldn't render dashboard: {:?}", e);
DashboardPage::NotConfigured(internal_server_error().await)
}
}
} else {
DashboardPage::Unauthorised(Redirect::to("/login/discord"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::{MySql, Pool};
#[sqlx::test]
async fn image_edits_distinguish_omission_replacement_and_removal(pool: Pool<MySql>) {
sqlx::query("INSERT INTO channels (id, channel) VALUES (1, 123)")
.execute(&pool)
.await
.unwrap();
let image = sqlx::query("INSERT INTO image_uploads (uploader, path) VALUES (123, ?)")
.bind(format!("uploads/{:064x}.png", 1))
.execute(&pool)
.await
.unwrap()
.last_insert_id();
sqlx::query("INSERT INTO reminders (uid, channel_id, `utc_time`, embed_image_id) VALUES ('image-edit', 1, UTC_TIMESTAMP(), ?)")
.bind(image).execute(&pool).await.unwrap();
let mut transaction = pool.begin().await.unwrap();
let mut errors = vec![];
let reminder: PatchReminder = serde_json::from_value(json!({"uid": "image-edit"})).unwrap();
update_image_field!(&mut *transaction, errors, reminder, embed_image_url, embed_image_id);
let stored: (Option<String>, Option<u64>) = sqlx::query_as(
"SELECT embed_image_url, embed_image_id FROM reminders WHERE uid = 'image-edit'",
)
.fetch_one(&mut *transaction)
.await
.unwrap();
assert_eq!(stored, (None, Some(image)));
let external = "https://external.example/new.png";
let reminder: PatchReminder = serde_json::from_value(json!({
"uid": "image-edit", "embed_image_url": external
}))
.unwrap();
update_image_field!(&mut *transaction, errors, reminder, embed_image_url, embed_image_id);
let stored: (Option<String>, Option<u64>) = sqlx::query_as(
"SELECT embed_image_url, embed_image_id FROM reminders WHERE uid = 'image-edit'",
)
.fetch_one(&mut *transaction)
.await
.unwrap();
assert_eq!(stored, (Some(external.into()), None));
sqlx::query("UPDATE reminders SET embed_image_url = NULL, embed_image_id = ? WHERE uid = 'image-edit'")
.bind(image).execute(&mut *transaction).await.unwrap();
let reminder: PatchReminder = serde_json::from_value(json!({
"uid": "image-edit", "embed_image_url": null
}))
.unwrap();
update_image_field!(&mut *transaction, errors, reminder, embed_image_url, embed_image_id);
let stored: (Option<String>, Option<u64>) = sqlx::query_as(
"SELECT embed_image_url, embed_image_id FROM reminders WHERE uid = 'image-edit'",
)
.fetch_one(&mut *transaction)
.await
.unwrap();
assert_eq!(stored, (None, None));
assert!(errors.is_empty());
transaction.commit().await.unwrap();
}
}