Compare commits

..
Author SHA1 Message Date
claude Bot b818a051bc Adjust migration 2026-10-28 19:56:12 +00:00
claude Bot a9cc0b8f0a Move to FKs 2026-10-18 20:06:55 +01:00
claude Bot 96bfef4575 Add support for S3-backed image storage 2026-10-18 19:12:50 +01:00
35 changed files with 1013 additions and 469 deletions

No files matched your search

@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\n SELECT\n reminders.`id` AS id,\n\n channels.`channel` AS channel_id,\n reminders.`thread_id` AS thread_id,\n channels.`webhook_id` AS webhook_id,\n channels.`webhook_token` AS webhook_token,\n\n channels.`paused` AS 'channel_paused',\n channels.`paused_until` AS 'channel_paused_until',\n reminders.`enabled` AS 'enabled',\n\n reminders.`tts` AS tts,\n reminders.`pin` AS pin,\n reminders.`content` AS content,\n reminders.`attachment` AS attachment,\n reminders.`attachment_name` AS attachment_name,\n\n reminders.`utc_time` AS 'utc_time',\n reminders.`timezone` AS timezone,\n reminders.`restartable` AS restartable,\n reminders.`expires` AS 'expires',\n reminders.`interval_seconds` AS 'interval_seconds',\n reminders.`interval_days` AS 'interval_days',\n reminders.`interval_months` AS 'interval_months',\n\n reminders.`avatar` AS avatar,\n reminders.`username` AS username\n FROM\n reminders\n INNER JOIN\n channels\n ON\n reminders.channel_id = channels.id\n WHERE\n reminders.`status` = 'pending' AND\n reminders.`id` IN (\n SELECT\n MIN(id)\n FROM\n reminders\n WHERE\n reminders.`utc_time` <= NOW() AND\n `status` = 'pending' AND\n (\n reminders.`interval_seconds` IS NOT NULL\n OR reminders.`interval_months` IS NOT NULL\n OR reminders.`interval_days` IS NOT NULL\n OR reminders.enabled\n )\n GROUP BY channel_id\n )\n ",
"query": "\n SELECT\n reminders.`id` AS id,\n\n channels.`channel` AS channel_id,\n reminders.`thread_id` AS thread_id,\n channels.`webhook_id` AS webhook_id,\n channels.`webhook_token` AS webhook_token,\n\n channels.`paused` AS 'channel_paused',\n channels.`paused_until` AS 'channel_paused_until',\n reminders.`enabled` AS 'enabled',\n\n reminders.`tts` AS tts,\n reminders.`pin` AS pin,\n reminders.`content` AS content,\n reminders.`attachment` AS attachment,\n reminders.`attachment_name` AS attachment_name,\n\n reminders.`utc_time` AS 'utc_time',\n reminders.`timezone` AS timezone,\n reminders.`restartable` AS restartable,\n reminders.`expires` AS 'expires',\n reminders.`interval_seconds` AS 'interval_seconds',\n reminders.`interval_days` AS 'interval_days',\n reminders.`interval_months` AS 'interval_months',\n\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.avatar_id), reminders.avatar) AS avatar,\n reminders.`username` AS username\n FROM\n reminders\n INNER JOIN\n channels\n ON\n reminders.channel_id = channels.id\n WHERE\n reminders.`status` = 'pending' AND\n reminders.`id` IN (\n SELECT\n MIN(id)\n FROM\n reminders\n WHERE\n reminders.`utc_time` <= NOW() AND\n `status` = 'pending' AND\n (\n reminders.`interval_seconds` IS NOT NULL\n OR reminders.`interval_months` IS NOT NULL\n OR reminders.`interval_days` IS NOT NULL\n OR reminders.enabled\n )\n GROUP BY channel_id\n )\n ",
"describe": {
"columns": [
{
@@ -230,5 +230,5 @@
true
]
},
"hash": "a33ef7167746183766b6a9b4e31784dd3712fa2b4f72482d4b681e0a9fdbaf1d"
"hash": "253f96647b53ee3dd2f00e6e12c5972ac69f48ca5ce40498bc6a0d7687d28c34"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "INSERT INTO reminder_template\n (guild_id,\n name,\n attachment,\n attachment_name,\n avatar,\n avatar_id,\n content,\n embed_author,\n embed_author_url,\n embed_author_id,\n embed_color,\n embed_description,\n embed_footer,\n embed_footer_url,\n embed_footer_id,\n embed_image_url,\n embed_image_id,\n embed_thumbnail_url,\n embed_thumbnail_id,\n embed_title,\n embed_fields,\n interval_seconds,\n interval_days,\n interval_months,\n tts,\n username\n ) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,\n ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 26
},
"nullable": []
},
"hash": "2796be7484d8872eaae3655599c5071cfb0bdc1de4a557cd44441df3508a0bd2"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\n SELECT\n reminders.attachment_name,\n reminders.avatar,\n channels.channel,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.restartable,\n reminders.tts,\n reminders.uid,\n reminders.username,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?",
"query": "\n SELECT\n reminders.attachment_name,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.avatar_id), reminders.avatar) AS avatar,\n channels.channel,\n reminders.content,\n reminders.embed_author,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_author_id), reminders.embed_author_url) AS embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_footer_id), reminders.embed_footer_url) AS embed_footer_url,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_image_id), reminders.embed_image_url) AS embed_image_url,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_thumbnail_id), reminders.embed_thumbnail_url) AS embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.restartable,\n reminders.tts,\n reminders.uid,\n reminders.username,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?",
"describe": {
"columns": [
{
@@ -260,5 +260,5 @@
false
]
},
"hash": "fa58f079f00bec4c3f9cbb2ee0a607f63c5b586654c541bff6b99eff83ae0a44"
"hash": "332c615185b316e1b3dab4b81565af1fb15b0e235bc8d5a3b95f5ee8feeae055"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\n SELECT\n reminders.attachment_name,\n reminders.avatar,\n channels.channel,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.restartable,\n reminders.tts,\n reminders.uid,\n reminders.username,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?",
"query": "\n SELECT\n reminders.attachment_name,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.avatar_id), reminders.avatar) AS avatar,\n channels.channel,\n reminders.content,\n reminders.embed_author,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_author_id), reminders.embed_author_url) AS embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_footer_id), reminders.embed_footer_url) AS embed_footer_url,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_image_id), reminders.embed_image_url) AS embed_image_url,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_thumbnail_id), reminders.embed_thumbnail_url) AS embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.restartable,\n reminders.tts,\n reminders.uid,\n reminders.username,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?",
"describe": {
"columns": [
{
@@ -260,5 +260,5 @@
false
]
},
"hash": "53db321bc70b45800f696115b65da8550de208104017f8e0d0a163781488a534"
"hash": "45e6381b3b68ad03f6459db8472587cf36d11a61bc5f94ed173adfd4ea68565d"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "INSERT INTO reminders (\n uid,\n attachment,\n attachment_name,\n channel_id,\n avatar,\n content,\n embed_author,\n embed_author_url,\n embed_color,\n embed_description,\n embed_footer,\n embed_footer_url,\n embed_image_url,\n embed_thumbnail_url,\n embed_title,\n embed_fields,\n enabled,\n expires,\n interval_seconds,\n interval_days,\n interval_months,\n name,\n restartable,\n tts,\n username,\n `utc_time`\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 26
},
"nullable": []
},
"hash": "4cc3ff2bbdf8502f6e739a002c7aa16df8c4f24de0966b26b268efab97e205e3"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\n SELECT\n `embed_title` AS title,\n `embed_description` AS description,\n `embed_image_url` AS image_url,\n `embed_thumbnail_url` AS thumbnail_url,\n `embed_footer` AS footer,\n `embed_footer_url` AS footer_url,\n `embed_author` AS author,\n `embed_author_url` AS author_url,\n `embed_color` AS color,\n IFNULL(`embed_fields`, '[]') AS \"fields:_\"\n FROM reminders\n WHERE `id` = ?",
"query": "\n SELECT\n `embed_title` AS title,\n `embed_description` AS description,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_image_id), `embed_image_url`) AS image_url,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_thumbnail_id), `embed_thumbnail_url`) AS thumbnail_url,\n `embed_footer` AS footer,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_footer_id), `embed_footer_url`) AS footer_url,\n `embed_author` AS author,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_author_id), `embed_author_url`) AS author_url,\n `embed_color` AS color,\n IFNULL(`embed_fields`, '[]') AS \"fields:_\"\n FROM reminders\n WHERE `id` = ?",
"describe": {
"columns": [
{
@@ -110,5 +110,5 @@
false
]
},
"hash": "98ce62a260f411b834ab4b5374b49ea79850cce49e23b71d0eee917609beabd2"
"hash": "7bbf5875b7e0edcf19f4fea7ad6f1685ed176c9b6979ea208d47e421b97540d3"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "INSERT INTO reminders (\n uid,\n attachment,\n attachment_name,\n channel_id,\n content,\n embed_author,\n embed_author_url,\n embed_color,\n embed_description,\n embed_footer,\n embed_footer_url,\n embed_image_url,\n embed_thumbnail_url,\n embed_title,\n embed_fields,\n enabled,\n expires,\n interval_seconds,\n interval_days,\n interval_months,\n name,\n tts,\n `utc_time`\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 23
},
"nullable": []
},
"hash": "7f44451c441507ccd7f8d41633fb156f95a28389fcc9c489fdb228446f88342e"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "SELECT\n reminders.attachment,\n reminders.attachment_name,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n WHERE uid = ?",
"query": "SELECT\n reminders.attachment,\n reminders.attachment_name,\n reminders.content,\n reminders.embed_author,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_author_id), reminders.embed_author_url) AS embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_footer_id), reminders.embed_footer_url) AS embed_footer_url,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_image_id), reminders.embed_image_url) AS embed_image_url,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_thumbnail_id), reminders.embed_thumbnail_url) AS embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n WHERE uid = ?",
"describe": {
"columns": [
{
@@ -230,5 +230,5 @@
false
]
},
"hash": "2613618c00941649726b31f7c50b5ef50d85323a73def5c87bab2a58c80bf6cd"
"hash": "89452b34238cb0d885044fdbfc5ecab967d17224ffe4929be16be4a7b5c8e3ea"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "INSERT INTO reminders (\n uid,\n attachment,\n attachment_name,\n channel_id,\n content,\n embed_author,\n embed_author_url,\n embed_author_id,\n embed_color,\n embed_description,\n embed_footer,\n embed_footer_url,\n embed_footer_id,\n embed_image_url,\n embed_image_id,\n embed_thumbnail_url,\n embed_thumbnail_id,\n embed_title,\n embed_fields,\n enabled,\n expires,\n interval_seconds,\n interval_days,\n interval_months,\n name,\n tts,\n `utc_time`\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 27
},
"nullable": []
},
"hash": "91d6a89be74bc242e5fafbe8bbb701ef1ee2db4779af504858b9cd9d602df9df"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
"query": "SELECT id,\n guild_id,\n name,\n attachment,\n attachment_name,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.avatar_id), avatar) AS avatar,\n content,\n embed_author,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.embed_author_id), embed_author_url) AS embed_author_url,\n embed_color,\n embed_description,\n embed_footer,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.embed_footer_id), embed_footer_url) AS embed_footer_url,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.embed_image_id), embed_image_url) AS embed_image_url,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.embed_thumbnail_id), embed_thumbnail_url) AS embed_thumbnail_url,\n embed_title,\n embed_fields,\n interval_seconds,\n interval_days,\n interval_months,\n tts,\n username FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
"describe": {
"columns": [
{
@@ -14,15 +14,6 @@
},
{
"ordinal": 1,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 400
}
},
{
"ordinal": 2,
"name": "guild_id",
"type_info": {
"type": "Long",
@@ -30,44 +21,17 @@
"max_size": 10
}
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 400
}
},
{
"ordinal": 3,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 128
}
},
{
"ordinal": 4,
"name": "avatar",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 5,
"name": "content",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 6,
"name": "tts",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 7,
"name": "attachment",
"type_info": {
"type": "Blob",
@@ -76,7 +40,7 @@
}
},
{
"ordinal": 8,
"ordinal": 4,
"name": "attachment_name",
"type_info": {
"type": "VarString",
@@ -85,14 +49,50 @@
}
},
{
"ordinal": 9,
"name": "embed_title",
"ordinal": 5,
"name": "avatar",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 6,
"name": "content",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 7,
"name": "embed_author",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 1024
}
},
{
"ordinal": 8,
"name": "embed_author_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 9,
"name": "embed_color",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 10,
"name": "embed_description",
@@ -104,24 +104,6 @@
},
{
"ordinal": 11,
"name": "embed_image_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 12,
"name": "embed_thumbnail_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 13,
"name": "embed_footer",
"type_info": {
"type": "VarString",
@@ -130,7 +112,7 @@
}
},
{
"ordinal": 14,
"ordinal": 12,
"name": "embed_footer_url",
"type_info": {
"type": "VarString",
@@ -138,9 +120,27 @@
"max_size": 2048
}
},
{
"ordinal": 13,
"name": "embed_image_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 14,
"name": "embed_thumbnail_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 15,
"name": "embed_author",
"name": "embed_title",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
@@ -149,24 +149,6 @@
},
{
"ordinal": 16,
"name": "embed_author_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 17,
"name": "embed_color",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 18,
"name": "embed_fields",
"type_info": {
"type": "Json",
@@ -175,7 +157,7 @@
}
},
{
"ordinal": 19,
"ordinal": 17,
"name": "interval_seconds",
"type_info": {
"type": "Long",
@@ -184,7 +166,7 @@
}
},
{
"ordinal": 20,
"ordinal": 18,
"name": "interval_days",
"type_info": {
"type": "Long",
@@ -193,13 +175,31 @@
}
},
{
"ordinal": 21,
"ordinal": 19,
"name": "interval_months",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 20,
"name": "tts",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 21,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 128
}
}
],
"parameters": {
@@ -211,24 +211,24 @@
false,
true,
true,
false,
false,
true,
true,
false,
false,
true,
true,
false,
false,
false,
true,
false,
true,
true,
false,
true,
true,
true,
true,
false,
true
]
},
"hash": "a8b3563796f18e8ac28166771892226dbf8f730535e7f2d1225ff9eb6b1d9361"
"hash": "bb845163adb4f6dc61c068f58bcc4dda9609c22b41507a652408669bd17fe6ea"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\n SELECT\n reminders.attachment,\n reminders.attachment_name,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n IFNULL(reminders.embed_fields, '[]') AS embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n INNER JOIN channels ON channels.id = reminders.channel_id\n WHERE `status` = 'pending' AND channels.channel = ?\n ",
"query": "\n SELECT\n reminders.attachment,\n reminders.attachment_name,\n reminders.content,\n reminders.embed_author,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_author_id), reminders.embed_author_url) AS embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_footer_id), reminders.embed_footer_url) AS embed_footer_url,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_image_id), reminders.embed_image_url) AS embed_image_url,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_thumbnail_id), reminders.embed_thumbnail_url) AS embed_thumbnail_url,\n reminders.embed_title,\n IFNULL(reminders.embed_fields, '[]') AS embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n INNER JOIN channels ON channels.id = reminders.channel_id\n WHERE `status` = 'pending' AND channels.channel = ?\n ",
"describe": {
"columns": [
{
@@ -230,5 +230,5 @@
false
]
},
"hash": "48a9268e9106a3a05e86ab36551944ba855a07b0241e912c6b1adee779c6f0ea"
"hash": "e01fef8182e50d65459b03e1e93ed4198e3b581f3dfe1df76c250cafa2b9ce0f"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\n SELECT reminders.attachment,\n reminders.attachment_name,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?\n ",
"query": "\n SELECT reminders.attachment,\n reminders.attachment_name,\n reminders.content,\n reminders.embed_author,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_author_id), reminders.embed_author_url) AS embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_footer_id), reminders.embed_footer_url) AS embed_footer_url,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_image_id), reminders.embed_image_url) AS embed_image_url,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_thumbnail_id), reminders.embed_thumbnail_url) AS embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?\n ",
"describe": {
"columns": [
{
@@ -230,5 +230,5 @@
false
]
},
"hash": "ecbea4acb61da864a4f83004b1163391068456a366fee95daff5f46413bcf494"
"hash": "e32fca1af5eb399768aa1ca0f0073d919a2c29f5d7c86cf1a23866e110dc8f33"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "SELECT\n name,\n attachment,\n attachment_name,\n avatar,\n content,\n embed_author,\n embed_author_url,\n embed_color,\n embed_description,\n embed_footer,\n embed_footer_url,\n embed_image_url,\n embed_thumbnail_url,\n embed_title,\n embed_fields,\n interval_seconds,\n interval_days,\n interval_months,\n tts,\n username\n FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
"query": "SELECT\n name,\n attachment,\n attachment_name,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.avatar_id), avatar) AS avatar,\n content,\n embed_author,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.embed_author_id), embed_author_url) AS embed_author_url,\n embed_color,\n embed_description,\n embed_footer,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.embed_footer_id), embed_footer_url) AS embed_footer_url,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.embed_image_id), embed_image_url) AS embed_image_url,\n COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.embed_thumbnail_id), embed_thumbnail_url) AS embed_thumbnail_url,\n embed_title,\n embed_fields,\n interval_seconds,\n interval_days,\n interval_months,\n tts,\n username\n FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
"describe": {
"columns": [
{
@@ -210,5 +210,5 @@
true
]
},
"hash": "be4c9a0fffa3ecaad4eb8ae59229ebfed685cf069b309ab874db4db069d14a3c"
"hash": "efac2582e5b7de7e0923f9ebc1bc8d643683baf65e94dcc7f431290099de95d7"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "INSERT INTO reminders (\n uid,\n attachment,\n attachment_name,\n channel_id,\n avatar,\n avatar_id,\n content,\n embed_author,\n embed_author_url,\n embed_author_id,\n embed_color,\n embed_description,\n embed_footer,\n embed_footer_url,\n embed_footer_id,\n embed_image_url,\n embed_image_id,\n embed_thumbnail_url,\n embed_thumbnail_id,\n embed_title,\n embed_fields,\n enabled,\n expires,\n interval_seconds,\n interval_days,\n interval_months,\n name,\n restartable,\n tts,\n username,\n `utc_time`\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 31
},
"nullable": []
},
"hash": "f3364e9792d1886eff3d2dec0c3e789d1aeb9209405013df0e81560a42ca39b2"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "INSERT INTO reminder_template\n (guild_id,\n name,\n attachment,\n attachment_name,\n avatar,\n content,\n embed_author,\n embed_author_url,\n embed_color,\n embed_description,\n embed_footer,\n embed_footer_url,\n embed_image_url,\n embed_thumbnail_url,\n embed_title,\n embed_fields,\n interval_seconds,\n interval_days,\n interval_months,\n tts,\n username\n ) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,\n ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 21
},
"nullable": []
},
"hash": "f64d9a37720b8459ffbdd66186194b35332cc37b09a176165a839c342ba5354c"
}
+25 -76
View File
@@ -50,7 +50,7 @@ or `PODMAN` to use another container command, for example `make PODMAN=docker`.
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`)
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
8. Build: `cargo build --release`
### Configuring
@@ -58,97 +58,46 @@ or `PODMAN` to use another container command, for example `make PODMAN=docker`.
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**
__Required Variables__
- `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
- `DISCORD_TOKEN` - your application's bot user's authorization token
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
* `DISCORD_TOKEN` - your application's bot user's authorization token
**Other Variables**
__Other Variables__
- `MIN_INTERVAL` - default `600`, defines the shortest interval the bot should accept
- `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
* `MIN_INTERVAL` - default `600`, defines the shortest interval the bot should accept
* `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
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the
subscription roles belong to
- `PYTHON_LOCATION` - default `/usr/bin/python3`. Can be changed if your Python executable is
* `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
* `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message
embeds
### Dashboard image uploads
Images can be uploaded directly when all of these settings are supplied:
Image URL inputs offer an **Upload image** button when all of these settings are supplied:
* `S3_BUCKET` - bucket
* `S3_REGION` - bucket region
* `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` - credentials
* `S3_PUBLIC_URL` - public base URL. Objects must be publicly readable through this URL.
* `NSFW_CHECK_ENABLED` - enables the use of a small model to block uploading
of NSFW images. Set it to `false` to skip NSFW moderation and model initialization.
- `S3_BUCKET` — destination bucket name.
- `S3_REGION` — bucket region, for example `eu-west-2`.
- `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` — credentials with `s3:PutObject` and
`s3:DeleteObject` permissions for the bucket's `uploads/` prefix.
- `S3_PUBLIC_URL` — public base URL for the bucket or CDN, for example
`https://my-bucket.s3.eu-west-2.amazonaws.com`. Objects must be publicly readable through
this URL; the application does not change bucket policies or set object ACLs.
`NSFW_CHECK_ENABLED` defaults to `true`, and enables the use of a small model to block uploading
of NSFW images. Set it to `false` to skip NSFW moderation and model initialization. Empty or
unrecognized values keep checks enabled.
The Debian package includes `reminder-rs-clean-images.timer`, an hourly cleanup job. Enable it
after configuring S3 and starting the bot (which applies database migrations):
```sh
sudo systemctl enable --now reminder-rs-clean-images.timer
```
Each run records the first observed orphan time in `image_uploads.orphaned_at` (UTC). An upload
is orphaned when neither a reminder (including archived reminders) nor a template uses its URL
as an avatar, image, thumbnail, author icon or footer icon. Referenced uploads have this timestamp
cleared. `IMAGE_UPLOAD_ORPHAN_RETENTION_SECONDS` controls the additional retention time after
detection; it defaults to `604800` (seven days) and must be a positive integer. Unsaved uploads
are also covered. Cleanup rechecks references before deleting and removes the tracking row only
after S3 confirms deletion; failed deletions are retried on later runs. Each run attempts up to
1,000 expired uploads from the configured public URL's `uploads/` prefix.
For other deployments, schedule `/path/to/reminder-rs clean-images` hourly with cron, using the
same working directory and configuration as the bot. The command requires no Discord token
and skips NSFW model initialization. With no S3 settings it exits successfully without work;
partial S3 settings or invalid retention values fail the job.
Uploaded images are stored as bucket paths in `image_uploads`. Reminders and templates
reference uploads through indexed foreign keys; external image URLs remain supported.
Public URLs are resolved from `S3_PUBLIC_URL` for delivery, dashboard previews, and exports.
Changing that base URL does not require updating stored reminders.
## Running with Docker
### RustFS for local development
Start the local object store and initialize the image upload bucket:
```sh
docker compose --env-file conf/rustfs.env -f compose.rustfs.yaml up -d
```
Add the settings from `conf/rustfs.env` to your local `.env`, then restart the bot running on
your host. RustFS serves the S3 API at `http://127.0.0.1:9000` and its console at
`http://127.0.0.1:9001`. Log into the console with the access key and secret key from
`conf/rustfs.env`. Both ports bind to localhost. The named data volume persists across restarts.
The initializer creates `reminder-images` if needed and permits anonymous reads of its
`uploads/` objects. Uploads, deletes and bucket listing require credentials. These settings
enable dashboard image uploads locally; `NSFW_CHECK_ENABLED` keeps its configured value.
`S3_ENDPOINT` uses path-style addressing for RustFS, and `S3_PUBLIC_URL` includes the bucket
name because the browser fetches objects through that public URL.
```sh
docker compose --env-file conf/rustfs.env -f compose.rustfs.yaml logs rustfs-init
docker compose --env-file conf/rustfs.env -f compose.rustfs.yaml down
```
`down` keeps the data volume. This development stack is separate from the bot/database stack
below. The [RustFS Docker documentation](https://docs.rustfs.com/en/installation/container/docker)
describes the upstream container configuration.
A `compose.yml` file is provided to aid in running the bot agnostically using docker.
- Populate a `.env` file as in `conf/default.env`
- Add the additional variable `ROCKET_SECRET_KEY` with a key generated from
* Populate a `.env` file as in `conf/default.env`
* Add the additional variable `ROCKET_SECRET_KEY` with a key generated from
`head -c64 /dev/urandom | base64`
- Run `docker compose up`
* Run `docker compose up`
Please note that this is _not_ production-ready when run via compose. We do not offer a way for
backing up of your data, or a way to run the dashboard securely via HTTPS, which is required for
-63
View File
@@ -1,63 +0,0 @@
name: reminder-rustfs-dev
services:
rustfs-permissions:
image: busybox:1.37
user: "0:0"
volumes:
- rustfs-data:/data
- rustfs-logs:/logs
command: ["sh", "-c", "chown 10001:10001 /data /logs"]
restart: "no"
rustfs:
image: rustfs/rustfs:latest
restart: unless-stopped
depends_on:
rustfs-permissions:
condition: service_completed_successfully
environment:
RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY_ID:-REMINDERDEV}
RUSTFS_SECRET_KEY: ${S3_SECRET_ACCESS_KEY:-reminder-rustfs-local-secret}
RUSTFS_REGION: ${S3_REGION:-us-east-1}
RUSTFS_VOLUMES: /data
RUSTFS_ADDRESS: "0.0.0.0:9000"
RUSTFS_CONSOLE_ADDRESS: "0.0.0.0:9001"
RUSTFS_CONSOLE_ENABLE: "true"
RUSTFS_OBS_LOGGER_LEVEL: warn
RUSTFS_OBS_LOG_DIRECTORY: /logs
# Local single-volume development storage.
RUSTFS_UNSAFE_BYPASS_DISK_CHECK: "true"
volumes:
- rustfs-data:/data
- rustfs-logs:/logs
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9000/health"]
interval: 3s
timeout: 5s
retries: 20
start_period: 10s
rustfs-init:
image: amazon/aws-cli:latest
depends_on:
rustfs:
condition: service_healthy
environment:
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-REMINDERDEV}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-reminder-rustfs-local-secret}
AWS_DEFAULT_REGION: ${S3_REGION:-us-east-1}
AWS_EC2_METADATA_DISABLED: "true"
AWS_PAGER: ""
S3_BUCKET: ${S3_BUCKET:-reminder-images}
volumes:
- ./scripts/init-rustfs.sh:/init-rustfs.sh:ro
entrypoint: ["/bin/sh", "/init-rustfs.sh"]
restart: "no"
volumes:
rustfs-data:
rustfs-logs:
-7
View File
@@ -1,7 +0,0 @@
# Local development only. These credentials match compose.rustfs.yaml defaults.
S3_BUCKET=reminder-images
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=REMINDERDEV
S3_SECRET_ACCESS_KEY=reminder-rustfs-local-secret
S3_ENDPOINT=http://127.0.0.1:9000
S3_PUBLIC_URL=http://127.0.0.1:9000/reminder-images
+54 -1
View File
@@ -2,9 +2,62 @@ CREATE TABLE image_uploads (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
-- Discord user ID from the authenticated session, including guild-only users.
uploader BIGINT UNSIGNED NOT NULL,
url VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL UNIQUE,
path VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
orphaned_at DATETIME DEFAULT NULL,
UNIQUE INDEX image_uploads_path (path),
INDEX image_uploads_uploader (uploader),
INDEX image_uploads_orphaned_at (orphaned_at)
);
-- Keep column additions separate so existing rows do not need to be rebuilt.
ALTER TABLE reminders
ADD COLUMN avatar_id BIGINT UNSIGNED DEFAULT NULL,
ADD COLUMN embed_image_id BIGINT UNSIGNED DEFAULT NULL,
ADD COLUMN embed_thumbnail_id BIGINT UNSIGNED DEFAULT NULL,
ADD COLUMN embed_author_id BIGINT UNSIGNED DEFAULT NULL,
ADD COLUMN embed_footer_id BIGINT UNSIGNED DEFAULT NULL,
ALGORITHM=INSTANT;
ALTER TABLE reminder_template
ADD COLUMN avatar_id BIGINT UNSIGNED DEFAULT NULL,
ADD COLUMN embed_image_id BIGINT UNSIGNED DEFAULT NULL,
ADD COLUMN embed_thumbnail_id BIGINT UNSIGNED DEFAULT NULL,
ADD COLUMN embed_author_id BIGINT UNSIGNED DEFAULT NULL,
ADD COLUMN embed_footer_id BIGINT UNSIGNED DEFAULT NULL,
ALGORITHM=INSTANT;
-- Run before the application populates the new columns: existing values are NULL.
-- Disabling checks on this connection permits online foreign key creation.
SET @image_uploads_foreign_key_checks = @@SESSION.foreign_key_checks;
SET SESSION foreign_key_checks = 0;
ALTER TABLE reminders
ADD INDEX reminders_avatar_id (avatar_id),
ADD FOREIGN KEY (avatar_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ADD INDEX reminders_embed_image_id (embed_image_id),
ADD FOREIGN KEY (embed_image_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ADD INDEX reminders_embed_thumbnail_id (embed_thumbnail_id),
ADD FOREIGN KEY (embed_thumbnail_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ADD INDEX reminders_embed_author_id (embed_author_id),
ADD FOREIGN KEY (embed_author_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ADD INDEX reminders_embed_footer_id (embed_footer_id),
ADD FOREIGN KEY (embed_footer_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ALGORITHM=INPLACE,
LOCK=NONE;
ALTER TABLE reminder_template
ADD INDEX reminder_template_avatar_id (avatar_id),
ADD FOREIGN KEY (avatar_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ADD INDEX reminder_template_embed_image_id (embed_image_id),
ADD FOREIGN KEY (embed_image_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ADD INDEX reminder_template_embed_thumbnail_id (embed_thumbnail_id),
ADD FOREIGN KEY (embed_thumbnail_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ADD INDEX reminder_template_embed_author_id (embed_author_id),
ADD FOREIGN KEY (embed_author_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ADD INDEX reminder_template_embed_footer_id (embed_footer_id),
ADD FOREIGN KEY (embed_footer_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ALGORITHM=INPLACE,
LOCK=NONE;
SET SESSION foreign_key_checks = @image_uploads_foreign_key_checks;
-29
View File
@@ -1,29 +0,0 @@
#!/bin/sh
set -eu
endpoint=http://rustfs:9000
if ! aws --endpoint-url "$endpoint" s3api head-bucket --bucket "$S3_BUCKET" >/dev/null 2>&1; then
if [ "$AWS_DEFAULT_REGION" = us-east-1 ]; then
aws --endpoint-url "$endpoint" s3api create-bucket --bucket "$S3_BUCKET"
else
aws --endpoint-url "$endpoint" s3api create-bucket --bucket "$S3_BUCKET" \
--create-bucket-configuration "LocationConstraint=$AWS_DEFAULT_REGION"
fi
fi
# Dashboard images need public object reads. Listing and writes remain authenticated.
cat > /tmp/public-images-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::$S3_BUCKET/uploads/*"
}]
}
EOF
aws --endpoint-url "$endpoint" s3api put-bucket-policy --bucket "$S3_BUCKET" \
--policy file:///tmp/public-images-policy.json
echo "RustFS development bucket ready: $S3_BUCKET"
+30 -40
View File
@@ -3,23 +3,14 @@ use std::env;
use log::{info, warn};
use sqlx::{Acquire, MySql, Pool};
use crate::{web::image_uploads::ImageUploadService, Error};
use crate::{
image_upload::{is_upload_path, IMAGE_REFERENCES},
web::image_uploads::ImageUploadService,
Error,
};
const DEFAULT_RETENTION_SECONDS: u32 = 7 * 24 * 60 * 60;
const BATCH_SIZE: u32 = 1000;
const IMAGE_COLUMNS: &str =
"avatar, embed_image_url, embed_thumbnail_url, embed_author_url, embed_footer_url";
fn binary_image_columns() -> String {
// Existing URL columns and image_uploads use different collations. S3 URLs must
// also match case-sensitively, so compare their bytes rather than text collation.
IMAGE_COLUMNS
.split(", ")
.map(|column| format!("BINARY {column}"))
.collect::<Vec<_>>()
.join(", ")
}
fn retention_seconds(value: Option<&str>) -> Result<u32, Error> {
match value.map(str::trim).filter(|value| !value.is_empty()) {
None => Ok(DEFAULT_RETENTION_SECONDS),
@@ -29,24 +20,23 @@ fn retention_seconds(value: Option<&str>) -> Result<u32, Error> {
}
}
// Only keys generated by the upload route in the configured bucket may be deleted.
fn object_key<'a>(public_url: &str, url: &'a str) -> Option<&'a str> {
let key = url.strip_prefix(public_url)?.strip_prefix('/')?;
let name = key.strip_prefix("uploads/")?.strip_suffix(".png")?;
(name.len() == 64
&& name.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
.then_some(key)
fn unreferenced_predicate() -> String {
["reminders", "reminder_template"]
.into_iter()
.flat_map(|table| {
IMAGE_REFERENCES.into_iter().map(move |(_, column)| {
format!("NOT EXISTS (SELECT 1 FROM {table} WHERE {column} = upload.id)")
})
})
.collect::<Vec<_>>()
.join(" AND ")
}
async fn mark_orphans(pool: &Pool<MySql>) -> Result<(), Error> {
let columns = binary_image_columns();
sqlx::query(&format!(
"UPDATE image_uploads AS upload SET orphaned_at = CASE
WHEN NOT EXISTS (SELECT 1 FROM reminders
WHERE BINARY upload.url IN ({columns}))
AND NOT EXISTS (SELECT 1 FROM reminder_template
WHERE BINARY upload.url IN ({columns}))
THEN COALESCE(upload.orphaned_at, UTC_TIMESTAMP(6)) ELSE NULL END"
WHEN {} THEN COALESCE(upload.orphaned_at, UTC_TIMESTAMP(6)) ELSE NULL END",
unreferenced_predicate()
))
.execute(pool)
.await?;
@@ -110,8 +100,8 @@ async fn delete_orphan(
.await?;
let mut transaction = connection.begin().await?;
// Serialize overlapping cleanup jobs and recheck the retention time under the row lock.
let url: Option<String> = sqlx::query_scalar(
"SELECT url FROM image_uploads
let path: Option<String> = sqlx::query_scalar(
"SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads
WHERE id = ? AND orphaned_at <= UTC_TIMESTAMP(6) - INTERVAL ? SECOND
FOR UPDATE",
)
@@ -119,23 +109,22 @@ async fn delete_orphan(
.bind(retention)
.fetch_optional(&mut *transaction)
.await?;
let Some(url) = url else {
let Some(path) = path else {
transaction.rollback().await?;
return Ok(false);
};
let key = object_key(&storage.public_url, &url)
.ok_or("Upload URL does not match a generated key in the configured S3 public URL")?;
if !is_upload_path(&path) {
return Err("Upload path does not match a generated S3 object key".into());
}
for table in ["reminders", "reminder_template"] {
// Locking reads see current committed references, rather than a stale snapshot.
// InnoDB's REPEATABLE READ next-key locks also prevent a reference from
// being inserted while the object is being removed. Keep this transaction short:
// it covers one S3 request with the upload service's 30-second timeout.
let columns = binary_image_columns();
for (_, column) in IMAGE_REFERENCES {
// Each probe uses the FK index. Locking reads see committed references.
// The exclusive upload row lock blocks new FK references until commit.
let referenced: Option<u32> = sqlx::query_scalar(&format!(
"SELECT id FROM {table} WHERE BINARY ? IN ({columns}) LIMIT 1 FOR UPDATE"
"SELECT id FROM {table} WHERE {column} = ? LIMIT 1 FOR UPDATE"
))
.bind(&url)
.bind(id)
.fetch_optional(&mut *transaction)
.await?;
if referenced.is_some() {
@@ -147,8 +136,9 @@ async fn delete_orphan(
return Ok(false);
}
}
}
let response = storage.bucket.delete_object(key).await?;
let response = storage.bucket.delete_object(&path).await?;
// A missing object is already cleaned up, including a retry after a DB commit failure.
if !(200..300).contains(&response.status_code()) && response.status_code() != 404 {
return Err(format!("S3 deletion returned HTTP {}", response.status_code()).into());
+81 -29
View File
@@ -13,13 +13,13 @@ use super::*;
const BASE_URL: &str = "https://images.example.com/bucket";
fn upload_url(number: u64) -> String {
format!("{BASE_URL}/uploads/{number:064x}.png")
fn upload_path(number: u64) -> String {
format!("uploads/{number:064x}.png")
}
async fn upload(pool: &Pool<MySql>, number: u64) -> u64 {
sqlx::query("INSERT INTO image_uploads (uploader, url) VALUES (123, ?)")
.bind(upload_url(number))
sqlx::query("INSERT INTO image_uploads (uploader, path) VALUES (123, ?)")
.bind(upload_path(number))
.execute(pool)
.await
.unwrap()
@@ -110,21 +110,17 @@ fn retention_defaults_and_rejects_invalid_settings() {
}
#[test]
fn restricts_deletion_to_generated_keys_in_the_configured_url() {
assert_eq!(
object_key(BASE_URL, &upload_url(1)),
Some(format!("uploads/{:064x}.png", 1).as_str())
);
for url in [
upload_url(1).replace("images.example.com", "other.example.com"),
upload_url(1).replace("/bucket/", "/bucket-other/"),
upload_url(1).replace("/uploads/", "/private/"),
format!("{BASE_URL}/uploads/../private.png"),
format!("{BASE_URL}/uploads/{}.png", "g".repeat(64)),
format!("{}?download=true", upload_url(1)),
format!("{BASE_URL}/uploads/short.png"),
fn restricts_deletion_to_generated_bucket_paths() {
assert!(is_upload_path(&upload_path(1)));
for path in [
format!("{BASE_URL}/{}", upload_path(1)),
upload_path(1).replace("uploads/", "private/"),
"uploads/../private.png".into(),
format!("uploads/{}.png", "g".repeat(64)),
format!("{}?download=true", upload_path(1)),
"uploads/short.png".into(),
] {
assert!(object_key(BASE_URL, &url).is_none(), "{url}");
assert!(!is_upload_path(&path), "{path}");
}
}
@@ -139,9 +135,9 @@ async fn orphan_timestamp_survives_repeated_runs_and_resets_on_reuse(pool: Pool<
let (guild, _) = reference_tables(&pool).await;
let template =
sqlx::query("INSERT INTO reminder_template (guild_id, embed_image_url) VALUES (?, ?)")
sqlx::query("INSERT INTO reminder_template (guild_id, embed_image_id) VALUES (?, ?)")
.bind(guild)
.bind(upload_url(1))
.bind(id)
.execute(&pool)
.await
.unwrap()
@@ -154,19 +150,20 @@ async fn orphan_timestamp_survives_repeated_runs_and_resets_on_reuse(pool: Pool<
.await
.unwrap();
mark_orphans(&pool).await.unwrap();
assert!(orphaned_at(&pool, id).await.unwrap() > first);
assert!(orphaned_at(&pool, id).await.unwrap() >= first);
}
#[sqlx::test]
async fn protects_all_image_fields_in_reminders_and_templates(pool: Pool<MySql>) {
let (guild, channel) = reference_tables(&pool).await;
let mut ids = Vec::new();
for (index, column) in IMAGE_COLUMNS.split(", ").enumerate() {
for (index, (_, column)) in IMAGE_REFERENCES.into_iter().enumerate() {
for table in ["reminders", "reminder_template"] {
let number = ids.len() as u64 + 1;
ids.push(upload(&pool, number).await);
let id = upload(&pool, number).await;
ids.push(id);
let query = if table == "reminders" {
// Archived/disabled reminders still retain their image URLs.
// Archived/disabled reminders still retain their image references.
format!(
"INSERT INTO reminders (uid, channel_id, `utc_time`, status, enabled, {column})
VALUES ('field-{index}', {channel}, UTC_TIMESTAMP(), 'sent', 0, ?)"
@@ -174,7 +171,7 @@ async fn protects_all_image_fields_in_reminders_and_templates(pool: Pool<MySql>)
} else {
format!("INSERT INTO reminder_template (guild_id, {column}) VALUES ({guild}, ?)")
};
sqlx::query(&query).bind(upload_url(number)).execute(&pool).await.unwrap();
sqlx::query(&query).bind(id).execute(&pool).await.unwrap();
}
}
let unused = upload(&pool, 100).await;
@@ -228,9 +225,9 @@ async fn rechecks_references_before_deleting_a_candidate(pool: Pool<MySql>) {
let id = upload(&pool, 1).await;
expire(&pool, id).await;
let (guild, _) = reference_tables(&pool).await;
sqlx::query("INSERT INTO reminder_template (guild_id, avatar) VALUES (?, ?)")
sqlx::query("INSERT INTO reminder_template (guild_id, avatar_id) VALUES (?, ?)")
.bind(guild)
.bind(upload_url(1))
.bind(id)
.execute(&pool)
.await
.unwrap();
@@ -241,10 +238,10 @@ async fn rechecks_references_before_deleting_a_candidate(pool: Pool<MySql>) {
}
#[sqlx::test]
async fn foreign_urls_are_retained_without_contacting_s3(pool: Pool<MySql>) {
async fn invalid_paths_are_retained_without_contacting_s3(pool: Pool<MySql>) {
let id = upload(&pool, 1).await;
sqlx::query(
"UPDATE image_uploads SET url = 'https://elsewhere.example.com/file.png' WHERE id = ?",
"UPDATE image_uploads SET path = 'https://elsewhere.example.com/file.png' WHERE id = ?",
)
.bind(id)
.execute(&pool)
@@ -256,3 +253,58 @@ async fn foreign_urls_are_retained_without_contacting_s3(pool: Pool<MySql>) {
assert!(orphaned_at(&pool, id).await.is_some());
request.abort();
}
#[sqlx::test]
async fn cleanup_blocks_new_references_until_deletion_finishes(pool: Pool<MySql>) {
let id = upload(&pool, 1).await;
expire(&pool, id).await;
let (guild, _) = reference_tables(&pool).await;
let (mut storage, unused_request) = storage(204).await;
unused_request.abort();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
storage.bucket = Bucket::new(
"images",
Region::Custom { region: "local".into(), endpoint },
Credentials::new(Some("test"), Some("test"), None, None, None).unwrap(),
)
.unwrap()
.with_path_style();
let (request_started, wait_for_request) = tokio::sync::oneshot::channel();
let (finish_delete, wait_to_finish) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut received = Vec::new();
let mut buffer = [0; 4096];
while !received.windows(4).any(|window| window == b"\r\n\r\n") {
let count = socket.read(&mut buffer).await.unwrap();
assert!(count > 0);
received.extend_from_slice(&buffer[..count]);
}
request_started.send(()).unwrap();
wait_to_finish.await.unwrap();
socket
.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
.await
.unwrap();
});
let cleanup_pool = pool.clone();
let cleanup =
tokio::spawn(async move { delete_orphan(&cleanup_pool, &storage, 86400, id).await });
tokio::time::timeout(Duration::from_secs(5), wait_for_request).await.unwrap().unwrap();
let mut writer = tokio::spawn(async move {
sqlx::query("INSERT INTO reminder_template (guild_id, embed_image_id) VALUES (?, ?)")
.bind(guild)
.bind(id)
.execute(&pool)
.await
});
assert!(tokio::time::timeout(Duration::from_millis(100), &mut writer).await.is_err());
finish_delete.send(()).unwrap();
assert!(cleanup.await.unwrap().unwrap());
// The writer resumes after cleanup commits, and the FK rejects the deleted upload.
let error = writer.await.unwrap().unwrap_err();
assert_eq!(error.as_database_error().unwrap().code().as_deref(), Some("23000"));
server.await.unwrap();
}
+94
View File
@@ -0,0 +1,94 @@
use std::env;
use serde::{Serialize, Serializer};
use sqlx::Executor;
use crate::Database;
pub const IMAGE_REFERENCES: [(&str, &str); 5] = [
("avatar", "avatar_id"),
("embed_image_url", "embed_image_id"),
("embed_thumbnail_url", "embed_thumbnail_id"),
("embed_author_url", "embed_author_id"),
("embed_footer_url", "embed_footer_id"),
];
// Only paths generated by the upload route belong to managed storage.
pub fn is_upload_path(path: &str) -> bool {
path.strip_prefix("uploads/").and_then(|name| name.strip_suffix(".png")).is_some_and(|name| {
name.len() == 64
&& name.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
})
}
fn public_url() -> String {
env::var("S3_PUBLIC_URL")
.ok()
.and_then(|value| reqwest::Url::parse(&value).ok())
.map(|url| url.as_str().trim_end_matches('/').to_string())
.unwrap_or_default()
}
fn resolve_with_base(value: &str, base: &str) -> Option<String> {
if is_upload_path(value) {
(!base.is_empty()).then(|| format!("{}/{value}", base.trim_end_matches('/')))
} else {
Some(value.to_string())
}
}
// Read queries select a bucket path for managed images and the original URL otherwise.
// They explicitly collate paths as text for SQLx and legacy URL column collations.
// Resolve at the boundary so changing S3_PUBLIC_URL never requires rewriting reminders.
pub fn resolve_url(value: Option<&str>) -> Option<String> {
value.and_then(|value| resolve_with_base(value, &public_url()))
}
pub fn serialize_url<S>(value: &Option<String>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
resolve_url(value.as_deref()).serialize(serializer)
}
pub struct ImageReference {
pub url: Option<String>,
pub id: Option<u64>,
}
impl ImageReference {
pub async fn from_url(
executor: impl Executor<'_, Database = Database>,
url: Option<&str>,
) -> Result<Self, sqlx::Error> {
Self::with_base(executor, url, &public_url()).await
}
async fn with_base(
executor: impl Executor<'_, Database = Database>,
url: Option<&str>,
base: &str,
) -> Result<Self, sqlx::Error> {
let path = url
.filter(|_| !base.is_empty())
.and_then(|url| url.strip_prefix(base.trim_end_matches('/')))
.and_then(|path| path.strip_prefix('/'))
.filter(|path| is_upload_path(path));
if let Some(path) = path {
// Hold a shared upload lock until the caller commits its reference. Cleanup
// locks that same row exclusively before checking references and deleting S3.
let id = sqlx::query_scalar(
"SELECT id FROM image_uploads WHERE path = ? LOCK IN SHARE MODE",
)
.bind(path)
.fetch_one(executor)
.await?;
Ok(Self { url: None, id: Some(id) })
} else {
Ok(Self { url: url.map(str::to_string), id: None })
}
}
}
#[cfg(test)]
mod tests;
+135
View File
@@ -0,0 +1,135 @@
use super::*;
use sqlx::{MySql, Pool};
#[test]
fn resolves_paths_using_the_current_base_and_preserves_external_urls() {
let path = format!("uploads/{:064x}.png", 1);
assert_eq!(
resolve_with_base(&path, "https://new.example/bucket/"),
Some(format!("https://new.example/bucket/{path}"))
);
assert_eq!(resolve_with_base(&path, ""), None);
assert_eq!(
resolve_with_base("https://external.example/a.png", "https://new.example"),
Some("https://external.example/a.png".into())
);
for invalid in ["uploads/../private.png", "uploads/short.png", "uploads/ABC.png"] {
assert!(!is_upload_path(invalid));
}
}
#[sqlx::test]
async fn maps_managed_urls_to_ids_and_rejects_deleted_uploads(pool: Pool<MySql>) {
let path = format!("uploads/{:064x}.png", 1);
let id = sqlx::query("INSERT INTO image_uploads (uploader, path) VALUES (123, ?)")
.bind(&path)
.execute(&pool)
.await
.unwrap()
.last_insert_id();
let base = "https://images.example/bucket";
let reference =
ImageReference::with_base(&pool, Some(&format!("{base}/{path}")), base).await.unwrap();
assert_eq!(reference.id, Some(id));
assert_eq!(reference.url, None);
for url in [
None,
Some("https://external.example/a.png"),
Some("https://images.example/bucket-other/a.png"),
] {
let reference = ImageReference::with_base(&pool, url, base).await.unwrap();
assert_eq!(reference.id, None);
assert_eq!(reference.url.as_deref(), url);
}
let missing = format!("{base}/uploads/{:064x}.png", 2);
assert!(matches!(
ImageReference::with_base(&pool, Some(&missing), base).await,
Err(sqlx::Error::RowNotFound)
));
}
#[sqlx::test(migrations = false)]
async fn image_migration_preserves_external_urls_and_adds_indexed_references(pool: Pool<MySql>) {
let migrations = sqlx::migrate!();
let mut connection = pool.acquire().await.unwrap();
for migration in migrations.iter().filter(|migration| migration.version < 20261011120000) {
sqlx::raw_sql(&migration.sql).execute(&mut *connection).await.unwrap();
}
drop(connection);
sqlx::query("INSERT INTO guilds (id, guild) VALUES (1, 123)").execute(&pool).await.unwrap();
sqlx::query("INSERT INTO channels (id, channel, guild_id) VALUES (1, 123, 1)")
.execute(&pool)
.await
.unwrap();
let external = "https://external.example/image.png";
for (index, (column, _)) in IMAGE_REFERENCES.into_iter().enumerate() {
for table in ["reminders", "reminder_template"] {
let sql = if table == "reminders" {
format!("INSERT INTO reminders (uid, channel_id, `utc_time`, {column}) VALUES ('field-{index}', 1, UTC_TIMESTAMP(), ?)")
} else {
format!("INSERT INTO reminder_template (guild_id, {column}) VALUES (1, ?)")
};
sqlx::query(&sql).bind(external).execute(&pool).await.unwrap();
}
}
let mut connection = pool.acquire().await.unwrap();
let foreign_key_checks: i64 = sqlx::query_scalar("SELECT @@SESSION.foreign_key_checks")
.fetch_one(&mut *connection)
.await
.unwrap();
sqlx::raw_sql(include_str!("../../migrations/20261011120000_image_uploads.sql"))
.execute(&mut *connection)
.await
.unwrap();
let restored_checks: i64 = sqlx::query_scalar("SELECT @@SESSION.foreign_key_checks")
.fetch_one(&mut *connection)
.await
.unwrap();
assert_eq!(restored_checks, foreign_key_checks);
drop(connection);
let path = format!("uploads/{:064x}.png", 1);
let upload = sqlx::query("INSERT INTO image_uploads (uploader, path) VALUES (123, ?)")
.bind(&path)
.execute(&pool)
.await
.unwrap()
.last_insert_id();
let stored: String = sqlx::query_scalar(
"SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = ?",
)
.bind(upload)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(stored, path);
for (column, id_column) in IMAGE_REFERENCES {
for table in ["reminders", "reminder_template"] {
let values: (Option<String>, Option<u64>) = sqlx::query_as(&format!(
"SELECT {column}, {id_column} FROM {table} WHERE {column} = ?"
))
.bind(external)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(values, (Some(external.into()), None));
let indexed: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? AND seq_in_index = 1"
).bind(table).bind(id_column).fetch_one(&pool).await.unwrap();
assert!(indexed > 0);
sqlx::query(&format!(
"UPDATE {table} SET {column} = NULL, {id_column} = ? WHERE {column} = ?"
))
.bind(upload)
.bind(external)
.execute(&pool)
.await
.unwrap();
}
}
// Referenced uploads cannot be removed even outside the cleanup code.
assert!(sqlx::query("DELETE FROM image_uploads WHERE id = ?")
.bind(upload)
.execute(&pool)
.await
.is_err());
}
+1
View File
@@ -10,6 +10,7 @@ mod event_handlers;
#[cfg(not(test))]
mod hooks;
mod image_cleanup;
mod image_upload;
mod interval_parser;
mod metrics;
#[cfg(not(test))]
+11 -5
View File
@@ -154,12 +154,12 @@ impl Embed {
SELECT
`embed_title` AS title,
`embed_description` AS description,
`embed_image_url` AS image_url,
`embed_thumbnail_url` AS thumbnail_url,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_image_id), `embed_image_url`) AS image_url,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_thumbnail_id), `embed_thumbnail_url`) AS thumbnail_url,
`embed_footer` AS footer,
`embed_footer_url` AS footer_url,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_footer_id), `embed_footer_url`) AS footer_url,
`embed_author` AS author,
`embed_author_url` AS author_url,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.embed_author_id), `embed_author_url`) AS author_url,
`embed_color` AS color,
IFNULL(`embed_fields`, '[]') AS "fields:_"
FROM reminders
@@ -179,6 +179,11 @@ impl Embed {
field.value = substitute(&field.value);
});
embed.image_url = crate::image_upload::resolve_url(embed.image_url.as_deref());
embed.thumbnail_url = crate::image_upload::resolve_url(embed.thumbnail_url.as_deref());
embed.author_url = crate::image_upload::resolve_url(embed.author_url.as_deref());
embed.footer_url = crate::image_upload::resolve_url(embed.footer_url.as_deref());
if embed.has_content() {
Some(embed)
} else {
@@ -387,7 +392,7 @@ impl Reminder {
reminders.`interval_days` AS 'interval_days',
reminders.`interval_months` AS 'interval_months',
reminders.`avatar` AS avatar,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.avatar_id), reminders.avatar) AS avatar,
reminders.`username` AS username
FROM
reminders
@@ -422,6 +427,7 @@ impl Reminder {
.into_iter()
.map(|mut rem| {
rem.content = substitute(&rem.content);
rem.avatar = crate::image_upload::resolve_url(rem.avatar.as_deref());
rem
})
+31
View File
@@ -109,3 +109,34 @@ macro_rules! path {
}
}};
}
macro_rules! update_image_field {
($pool:expr, $error:ident, $reminder:ident, $field:ident, $id:ident) => {
if let Some(value) = &$reminder.$field {
match crate::image_upload::ImageReference::from_url($pool, value.as_deref()).await {
Ok(image) => {
if let Err(e) = sqlx::query(concat!(
"UPDATE reminders SET `",
stringify!($field),
"` = ?, `",
stringify!($id),
"` = ? WHERE uid = ?"
))
.bind(image.url)
.bind(image.id)
.bind(&$reminder.uid)
.execute($pool)
.await
{
warn!("Could not update image reference: {:?}", e);
$error.push(format!("Error setting field {}", stringify!($field)));
}
}
Err(e) => {
warn!("Could not resolve image upload: {:?}", e);
$error.push(format!("Error setting field {}", stringify!($field)));
}
}
}
};
}
+22 -15
View File
@@ -81,17 +81,17 @@ pub async fn get_reminders(
"
SELECT
reminders.attachment_name,
reminders.avatar,
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,
reminders.embed_author_url,
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,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
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,
IFNULL(reminders.embed_fields, '[]') AS embed_fields,
reminders.enabled,
@@ -169,12 +169,7 @@ pub async fn edit_reminder(
update_field!(transaction.executor(), error, reminder.[
attachment,
attachment_name,
avatar,
embed_author_url,
embed_color,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
enabled,
expires,
name,
@@ -183,6 +178,18 @@ pub async fn edit_reminder(
utc_time
]);
update_image_field!(transaction.executor(), error, reminder, avatar, avatar_id);
update_image_field!(transaction.executor(), error, reminder, embed_image_url, embed_image_id);
update_image_field!(
transaction.executor(),
error,
reminder,
embed_thumbnail_url,
embed_thumbnail_id
);
update_image_field!(transaction.executor(), error, reminder, embed_author_url, embed_author_id);
update_image_field!(transaction.executor(), error, reminder, embed_footer_url, embed_footer_id);
if reminder.interval_days.flatten().is_some()
|| reminder.interval_months.flatten().is_some()
|| reminder.interval_seconds.flatten().is_some()
@@ -347,17 +354,17 @@ pub async fn edit_reminder(
"
SELECT
reminders.attachment_name,
reminders.avatar,
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,
reminders.embed_author_url,
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,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
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,
@@ -31,7 +31,28 @@ pub async fn get_reminder_templates(
match sqlx::query_as_unchecked!(
ReminderTemplate,
"SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
"SELECT id,
guild_id,
name,
attachment,
attachment_name,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.avatar_id), avatar) AS avatar,
content,
embed_author,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.embed_author_id), embed_author_url) AS embed_author_url,
embed_color,
embed_description,
embed_footer,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.embed_footer_id), embed_footer_url) AS embed_footer_url,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.embed_image_id), embed_image_url) AS embed_image_url,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.embed_thumbnail_id), embed_thumbnail_url) AS 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 = ?)",
id
)
.fetch_all(pool.inner())
+114 -7
View File
@@ -1,3 +1,5 @@
use std::time::Duration;
use log::warn;
use rand::{rngs::OsRng, RngCore};
use rocket::{
@@ -12,6 +14,8 @@ use sqlx::{MySql, Pool};
use crate::web::image_uploads::{prepare_image, ImageError, ImageUploads, MAX_UPLOAD_BYTES};
const UPLOAD_READ_TIMEOUT: Duration = Duration::from_secs(30);
pub fn mount(
rocket: rocket::Rocket<rocket::Build>,
uploads: ImageUploads,
@@ -65,10 +69,13 @@ pub async fn upload_image(
.clone()
.try_acquire_owned()
.map_err(|_| error(Status::TooManyRequests, "Image uploads busy. Please try again."))?;
let bytes = data
.open(MAX_UPLOAD_BYTES.bytes())
.into_bytes()
// Bound the entire body read so stalled or trickling uploads release their slot.
let bytes =
tokio::time::timeout(UPLOAD_READ_TIMEOUT, data.open(MAX_UPLOAD_BYTES.bytes()).into_bytes())
.await
.map_err(|_| {
error(Status::RequestTimeout, "Image upload timed out. Please try again.")
})?
.map_err(|_| error(Status::BadRequest, "Could not read image"))?;
if !bytes.is_complete() {
return Err(error(Status::PayloadTooLarge, "Images must be no larger than 8 MiB"));
@@ -114,9 +121,9 @@ pub async fn upload_image(
warn!("S3 image upload returned {}", response.status_code());
return Err(error(Status::BadGateway, "Could not store image. Please try again."));
}
if let Err(err) = sqlx::query("INSERT INTO image_uploads (uploader, url) VALUES (?, ?)")
if let Err(err) = sqlx::query("INSERT INTO image_uploads (uploader, path) VALUES (?, ?)")
.bind(uploader.0)
.bind(&url)
.bind(&key)
.execute(pool.inner())
.await
{
@@ -179,6 +186,11 @@ mod tests {
Client::tracked(mount(rocket::build().manage(pool), uploads)).await.unwrap()
}
#[rocket::get("/test-login")]
fn login(cookies: &CookieJar<'_>) {
cookies.add_private(("userid", "123"));
}
#[rocket::async_test]
async fn disabled_upload_route_is_not_available() {
let client = client(ImageUploads(None)).await;
@@ -252,6 +264,94 @@ mod tests {
assert_eq!(response.status(), Status::NotFound);
}
#[rocket::async_test]
async fn stalled_uploads_time_out_and_release_global_slots() {
use rocket::{config::LogLevel, fairing::AdHoc, Config};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpStream,
sync::oneshot,
time::{sleep, timeout},
};
let uploads = service(false, "http://127.0.0.1:1");
let slots = uploads.0.as_ref().unwrap().slots.clone();
let pool = sqlx::mysql::MySqlPoolOptions::new()
.connect_lazy("mysql://unused:unused@127.0.0.1:1/unused")
.unwrap();
let config = Config {
address: "127.0.0.1".parse().unwrap(),
port: 0,
log_level: LogLevel::Off,
..Config::debug_default()
};
let (send_port, receive_port) = oneshot::channel();
let rocket =
mount(rocket::custom(config).manage(pool).mount("/", rocket::routes![login]), uploads)
.attach(AdHoc::on_liftoff("test address", |rocket| {
Box::pin(async move { send_port.send(rocket.config().port).unwrap() })
}))
.ignite()
.await
.unwrap();
let shutdown = rocket.shutdown();
let server = tokio::spawn(rocket.launch());
let port = timeout(Duration::from_secs(5), receive_port).await.unwrap().unwrap();
let base = format!("http://127.0.0.1:{port}");
let http =
reqwest::Client::builder().no_proxy().timeout(Duration::from_secs(5)).build().unwrap();
let response = http.get(format!("{base}/test-login")).send().await.unwrap();
let cookie = response.headers()["set-cookie"]
.to_str()
.unwrap()
.split(';')
.next()
.unwrap()
.to_string();
let mut stalled = Vec::new();
for _ in 0..2 {
let mut socket = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
socket.write_all(format!(
"POST /dashboard/api/images HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/octet-stream\r\nContent-Length: 1024\r\nCookie: {cookie}\r\nConnection: close\r\n\r\nincomplete image"
).as_bytes()).await.unwrap();
stalled.push(socket);
}
timeout(Duration::from_secs(5), async {
while slots.available_permits() != 0 {
sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
let upload = || {
http.post(format!("{base}/dashboard/api/images"))
.header("cookie", &cookie)
.header("content-type", "application/octet-stream")
.body("not an image")
.send()
};
assert_eq!(upload().await.unwrap().status(), Status::TooManyRequests.code);
for mut socket in stalled {
let mut response = String::new();
timeout(
UPLOAD_READ_TIMEOUT + Duration::from_secs(5),
socket.read_to_string(&mut response),
)
.await
.unwrap()
.unwrap();
assert!(response.starts_with("HTTP/1.1 408"), "{response}");
assert!(response.contains("Image upload timed out"), "{response}");
}
assert_eq!(slots.available_permits(), 2);
assert_eq!(upload().await.unwrap().status(), Status::UnprocessableEntity.code);
drop(http);
shutdown.notify();
timeout(Duration::from_secs(5), server).await.unwrap().unwrap().unwrap();
}
#[sqlx::test]
async fn stores_moderated_image_and_tracks_uploader(pool: Pool<MySql>) {
use tokio::{
@@ -304,11 +404,18 @@ mod tests {
assert_eq!(response.status(), Status::Ok);
let response = response.into_json::<Value>().await.unwrap();
let url = response["url"].as_str().unwrap();
let row: (u64, String) = sqlx::query_as("SELECT uploader, url FROM image_uploads")
let row: (u64, String) =
sqlx::query_as("SELECT uploader, path COLLATE utf8mb4_unicode_ci FROM image_uploads")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row, (123456789012345678, url.to_string()));
assert_eq!(
row,
(
123456789012345678,
url.strip_prefix("https://images.example.com/").unwrap().to_string()
)
);
let (headers, body) = storage.await.unwrap();
assert!(headers.starts_with("PUT /images/uploads/"));
assert!(headers.to_lowercase().contains("content-type: image/png"));
+40 -9
View File
@@ -6,7 +6,9 @@ use serde::{Deserialize, Serialize};
use serenity::{client::Context, model::id::UserId};
use sqlx::types::Json;
use crate::image_upload::ImageReference;
use crate::utils::check_subscription;
use crate::web::routes::dashboard::image_reference_error;
use crate::web::{
consts::{
DAY, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
@@ -28,12 +30,16 @@ pub struct Reminder {
pub attachment_name: Option<String>,
pub content: String,
pub embed_author: String,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
pub embed_author_url: Option<String>,
pub embed_color: u32,
pub embed_description: String,
pub embed_footer: String,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
pub embed_footer_url: Option<String>,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
pub embed_image_url: Option<String>,
#[serde(serialize_with = "crate::image_upload::serialize_url")]
pub embed_thumbnail_url: Option<String>,
pub embed_title: String,
pub embed_fields: Option<Json<Vec<EmbedField>>>,
@@ -139,6 +145,23 @@ pub async fn create_reminder(
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
let new_uid = generate_uid();
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 (
@@ -149,12 +172,16 @@ pub async fn create_reminder(
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,
@@ -165,20 +192,24 @@ pub async fn create_reminder(
name,
tts,
`utc_time`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new_uid,
reminder.attachment,
reminder.attachment_name,
channel,
reminder.content,
reminder.embed_author,
reminder.embed_author_url,
embed_author_url.url,
embed_author_url.id,
reminder.embed_color,
reminder.embed_description,
reminder.embed_footer,
reminder.embed_footer_url,
reminder.embed_image_url,
reminder.embed_thumbnail_url,
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,
@@ -200,13 +231,13 @@ pub async fn create_reminder(
reminders.attachment_name,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
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,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
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,
+19 -12
View File
@@ -71,13 +71,13 @@ pub async fn get_reminders(
reminders.attachment_name,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
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,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
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,
IFNULL(reminders.embed_fields, '[]') AS embed_fields,
reminders.enabled,
@@ -146,11 +146,7 @@ pub async fn edit_reminder(
update_field!(transaction.executor(), error, reminder.[
attachment,
attachment_name,
embed_author_url,
embed_color,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
enabled,
expires,
name,
@@ -158,6 +154,17 @@ pub async fn edit_reminder(
utc_time
]);
update_image_field!(transaction.executor(), error, reminder, embed_image_url, embed_image_id);
update_image_field!(
transaction.executor(),
error,
reminder,
embed_thumbnail_url,
embed_thumbnail_id
);
update_image_field!(transaction.executor(), error, reminder, embed_author_url, embed_author_id);
update_image_field!(transaction.executor(), error, reminder, embed_footer_url, embed_footer_id);
if reminder.interval_days.flatten().is_some()
|| reminder.interval_months.flatten().is_some()
|| reminder.interval_seconds.flatten().is_some()
@@ -248,13 +255,13 @@ pub async fn edit_reminder(
reminders.attachment_name,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
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,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
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,
@@ -37,16 +37,16 @@ pub async fn export(
name,
attachment,
attachment_name,
avatar,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.avatar_id), avatar) AS avatar,
content,
embed_author,
embed_author_url,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.embed_author_id), embed_author_url) AS embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.embed_footer_id), embed_footer_url) AS embed_footer_url,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.embed_image_id), embed_image_url) AS embed_image_url,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminder_template.embed_thumbnail_id), embed_thumbnail_url) AS embed_thumbnail_url,
embed_title,
embed_fields,
interval_seconds,
+5 -5
View File
@@ -46,17 +46,17 @@ pub async fn export(
SELECT
reminders.attachment,
reminders.attachment_name,
reminders.avatar,
COALESCE((SELECT path COLLATE utf8mb4_unicode_ci FROM image_uploads WHERE id = reminders.avatar_id), reminders.avatar) AS avatar,
CONCAT('#', channels.channel) AS channel,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
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,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
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,
CONVERT(CAST(embed_fields AS BINARY) USING utf8mb4) AS embed_fields,
reminders.enabled,
+176 -17
View File
@@ -20,6 +20,7 @@ use serenity::{
use sqlx::types::Json;
use sqlx::FromRow;
use crate::image_upload::ImageReference;
use crate::utils::check_subscription;
use crate::web::{
catchers::internal_server_error,
@@ -39,6 +40,11 @@ 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()
}
@@ -92,15 +98,20 @@ pub struct ReminderTemplate {
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>>>,
@@ -117,15 +128,20 @@ pub struct ReminderTemplateCsv {
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>,
@@ -182,17 +198,22 @@ pub struct CreateReminder {
#[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>>>,
@@ -215,16 +236,21 @@ pub struct GetReminder {
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>,
@@ -491,6 +517,26 @@ pub(crate) async fn create_reminder(
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 (
@@ -499,15 +545,20 @@ pub(crate) async fn create_reminder(
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,
@@ -520,21 +571,26 @@ pub(crate) async fn create_reminder(
tts,
username,
`utc_time`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new_uid,
reminder.attachment,
reminder.attachment_name,
channel,
reminder.avatar,
avatar.url,
avatar.id,
reminder.content,
reminder.embed_author,
reminder.embed_author_url,
embed_author_url.url,
embed_author_url.id,
reminder.embed_color,
reminder.embed_description,
reminder.embed_footer,
reminder.embed_footer_url,
reminder.embed_image_url,
reminder.embed_thumbnail_url,
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,
@@ -556,17 +612,17 @@ pub(crate) async fn create_reminder(
"
SELECT
reminders.attachment_name,
reminders.avatar,
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,
reminders.embed_author_url,
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,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
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,
@@ -645,6 +701,35 @@ pub(crate) async fn create_reminder_template(
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,
@@ -652,15 +737,20 @@ pub(crate) async fn create_reminder_template(
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,
@@ -669,21 +759,26 @@ pub(crate) async fn create_reminder_template(
tts,
username
) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?)",
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
guild_id.get(),
name,
reminder_template.attachment,
reminder_template.attachment_name,
reminder_template.avatar,
avatar.url,
avatar.id,
reminder_template.content,
reminder_template.embed_author,
reminder_template.embed_author_url,
embed_author_url.url,
embed_author_url.id,
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,
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,
@@ -869,3 +964,67 @@ async fn render_dashboard(cookies: &CookieJar<'_>) -> DashboardPage {
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();
}
}