Move to FKs

This commit is contained in:
jude committed 2026-10-18 20:06:55 +01:00
1 parent 96bfef4575
commit a9cc0b8f0a
32 files changed
+875 -305

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"
}
+5
View File
@@ -85,6 +85,11 @@ Images can be uploaded directly when all of these settings are supplied:
* `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.
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
A `compose.yml` file is provided to aid in running the bot agnostically using docker.
+36 -1
View File
@@ -2,9 +2,44 @@ 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)
);
ALTER TABLE reminders
ADD COLUMN avatar_id BIGINT UNSIGNED DEFAULT NULL,
ADD INDEX reminders_avatar_id (avatar_id),
ADD FOREIGN KEY (avatar_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ADD COLUMN embed_image_id BIGINT UNSIGNED DEFAULT NULL,
ADD INDEX reminders_embed_image_id (embed_image_id),
ADD FOREIGN KEY (embed_image_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ADD COLUMN embed_thumbnail_id BIGINT UNSIGNED DEFAULT NULL,
ADD INDEX reminders_embed_thumbnail_id (embed_thumbnail_id),
ADD FOREIGN KEY (embed_thumbnail_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ADD COLUMN embed_author_id BIGINT UNSIGNED DEFAULT NULL,
ADD INDEX reminders_embed_author_id (embed_author_id),
ADD FOREIGN KEY (embed_author_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ADD COLUMN embed_footer_id BIGINT UNSIGNED DEFAULT NULL,
ADD INDEX reminders_embed_footer_id (embed_footer_id),
ADD FOREIGN KEY (embed_footer_id) REFERENCES image_uploads (id) ON DELETE RESTRICT;
ALTER TABLE reminder_template
ADD COLUMN avatar_id BIGINT UNSIGNED DEFAULT NULL,
ADD INDEX reminder_template_avatar_id (avatar_id),
ADD FOREIGN KEY (avatar_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ADD COLUMN embed_image_id BIGINT UNSIGNED DEFAULT NULL,
ADD INDEX reminder_template_embed_image_id (embed_image_id),
ADD FOREIGN KEY (embed_image_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ADD COLUMN embed_thumbnail_id BIGINT UNSIGNED DEFAULT NULL,
ADD INDEX reminder_template_embed_thumbnail_id (embed_thumbnail_id),
ADD FOREIGN KEY (embed_thumbnail_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ADD COLUMN embed_author_id BIGINT UNSIGNED DEFAULT NULL,
ADD INDEX reminder_template_embed_author_id (embed_author_id),
ADD FOREIGN KEY (embed_author_id) REFERENCES image_uploads (id) ON DELETE RESTRICT,
ADD COLUMN embed_footer_id BIGINT UNSIGNED DEFAULT NULL,
ADD INDEX reminder_template_embed_footer_id (embed_footer_id),
ADD FOREIGN KEY (embed_footer_id) REFERENCES image_uploads (id) ON DELETE RESTRICT;
+41 -51
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,36 +109,36 @@ 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();
let referenced: Option<u32> = sqlx::query_scalar(&format!(
"SELECT id FROM {table} WHERE BINARY ? IN ({columns}) LIMIT 1 FOR UPDATE"
))
.bind(&url)
.fetch_optional(&mut *transaction)
.await?;
if referenced.is_some() {
sqlx::query("UPDATE image_uploads SET orphaned_at = NULL WHERE id = ?")
.bind(id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
return Ok(false);
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 {column} = ? LIMIT 1 FOR UPDATE"
))
.bind(id)
.fetch_optional(&mut *transaction)
.await?;
if referenced.is_some() {
sqlx::query("UPDATE image_uploads SET orphaned_at = NULL WHERE id = ?")
.bind(id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
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;
+124
View File
@@ -0,0 +1,124 @@
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();
}
}
sqlx::raw_sql(include_str!("../../migrations/20261011120000_image_uploads.sql"))
.execute(&pool)
.await
.unwrap();
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())
+14 -7
View File
@@ -114,9 +114,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
{
@@ -304,11 +304,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")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row, (123456789012345678, url.to_string()));
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.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();
}
}