From a9cc0b8f0a61ea337f9eb3555591da762eba049e Mon Sep 17 00:00:00 2001 From: jude Date: Sun, 18 Oct 2026 20:06:55 +0100 Subject: [PATCH] Move to FKs --- ...972ac69f48ca5ce40498bc6a0d7687d28c34.json} | 4 +- ...5071cfb0bdc1de4a557cd44441df3508a0bd2.json | 12 ++ ...af1fb15b0e235bc8d5a3b95f5ee8feeae055.json} | 4 +- ...87cf36d11a61bc5f94ed173adfd4ea68565d.json} | 4 +- ...aa16df8c4f24de0966b26b268efab97e205e3.json | 12 -- ...1685ed176c9b6979ea208d47e421b97540d3.json} | 4 +- ...b156f95a28389fcc9c489fdb228446f88342e.json | 12 -- ...cab967d17224ffe4929be16be4a7b5c8e3ea.json} | 4 +- ...701ef1ee2db4779af504858b9cd9d602df9df.json | 12 ++ ...4dda9609c22b41507a652408669bd17fe6ea.json} | 192 ++++++++--------- ...d4198e3b581f3dfe1df76c250cafa2b9ce0f.json} | 4 +- ...3d919a2c29f5d7c86cf1a23866e110dc8f33.json} | 4 +- ...8d643683baf65e94dcc7f431290099de95d7.json} | 4 +- ...e789d1aeb9209405013df0e81560a42ca39b2.json | 12 ++ ...94b35332cc37b09a176165a839c342ba5354c.json | 12 -- README.md | 5 + migrations/20261011120000_image_uploads.sql | 37 +++- src/image_cleanup.rs | 92 ++++----- src/image_cleanup/tests.rs | 110 +++++++--- src/image_upload.rs | 94 +++++++++ src/image_upload/tests.rs | 124 +++++++++++ src/main.rs | 1 + src/postman/sender.rs | 16 +- src/web/macros.rs | 31 +++ .../routes/dashboard/api/guild/reminders.rs | 37 ++-- .../routes/dashboard/api/guild/templates.rs | 23 ++- src/web/routes/dashboard/api/images.rs | 21 +- src/web/routes/dashboard/api/user/models.rs | 49 ++++- .../routes/dashboard/api/user/reminders.rs | 31 +-- .../dashboard/export/reminder_templates.rs | 10 +- src/web/routes/dashboard/export/reminders.rs | 10 +- src/web/routes/dashboard/mod.rs | 193 ++++++++++++++++-- 32 files changed, 875 insertions(+), 305 deletions(-) rename .sqlx/{query-a33ef7167746183766b6a9b4e31784dd3712fa2b4f72482d4b681e0a9fdbaf1d.json => query-253f96647b53ee3dd2f00e6e12c5972ac69f48ca5ce40498bc6a0d7687d28c34.json} (83%) create mode 100644 .sqlx/query-2796be7484d8872eaae3655599c5071cfb0bdc1de4a557cd44441df3508a0bd2.json rename .sqlx/{query-fa58f079f00bec4c3f9cbb2ee0a607f63c5b586654c541bff6b99eff83ae0a44.json => query-332c615185b316e1b3dab4b81565af1fb15b0e235bc8d5a3b95f5ee8feeae055.json} (76%) rename .sqlx/{query-53db321bc70b45800f696115b65da8550de208104017f8e0d0a163781488a534.json => query-45e6381b3b68ad03f6459db8472587cf36d11a61bc5f94ed173adfd4ea68565d.json} (75%) delete mode 100644 .sqlx/query-4cc3ff2bbdf8502f6e739a002c7aa16df8c4f24de0966b26b268efab97e205e3.json rename .sqlx/{query-98ce62a260f411b834ab4b5374b49ea79850cce49e23b71d0eee917609beabd2.json => query-7bbf5875b7e0edcf19f4fea7ad6f1685ed176c9b6979ea208d47e421b97540d3.json} (70%) delete mode 100644 .sqlx/query-7f44451c441507ccd7f8d41633fb156f95a28389fcc9c489fdb228446f88342e.json rename .sqlx/{query-2613618c00941649726b31f7c50b5ef50d85323a73def5c87bab2a58c80bf6cd.json => query-89452b34238cb0d885044fdbfc5ecab967d17224ffe4929be16be4a7b5c8e3ea.json} (79%) create mode 100644 .sqlx/query-91d6a89be74bc242e5fafbe8bbb701ef1ee2db4779af504858b9cd9d602df9df.json rename .sqlx/{query-a8b3563796f18e8ac28166771892226dbf8f730535e7f2d1225ff9eb6b1d9361.json => query-bb845163adb4f6dc61c068f58bcc4dda9609c22b41507a652408669bd17fe6ea.json} (77%) rename .sqlx/{query-48a9268e9106a3a05e86ab36551944ba855a07b0241e912c6b1adee779c6f0ea.json => query-e01fef8182e50d65459b03e1e93ed4198e3b581f3dfe1df76c250cafa2b9ce0f.json} (77%) rename .sqlx/{query-ecbea4acb61da864a4f83004b1163391068456a366fee95daff5f46413bcf494.json => query-e32fca1af5eb399768aa1ca0f0073d919a2c29f5d7c86cf1a23866e110dc8f33.json} (79%) rename .sqlx/{query-be4c9a0fffa3ecaad4eb8ae59229ebfed685cf069b309ab874db4db069d14a3c.json => query-efac2582e5b7de7e0923f9ebc1bc8d643683baf65e94dcc7f431290099de95d7.json} (78%) create mode 100644 .sqlx/query-f3364e9792d1886eff3d2dec0c3e789d1aeb9209405013df0e81560a42ca39b2.json delete mode 100644 .sqlx/query-f64d9a37720b8459ffbdd66186194b35332cc37b09a176165a839c342ba5354c.json create mode 100644 src/image_upload.rs create mode 100644 src/image_upload/tests.rs diff --git a/.sqlx/query-a33ef7167746183766b6a9b4e31784dd3712fa2b4f72482d4b681e0a9fdbaf1d.json b/.sqlx/query-253f96647b53ee3dd2f00e6e12c5972ac69f48ca5ce40498bc6a0d7687d28c34.json similarity index 83% rename from .sqlx/query-a33ef7167746183766b6a9b4e31784dd3712fa2b4f72482d4b681e0a9fdbaf1d.json rename to .sqlx/query-253f96647b53ee3dd2f00e6e12c5972ac69f48ca5ce40498bc6a0d7687d28c34.json index cf85f9a..de0a6e4 100644 --- a/.sqlx/query-a33ef7167746183766b6a9b4e31784dd3712fa2b4f72482d4b681e0a9fdbaf1d.json +++ b/.sqlx/query-253f96647b53ee3dd2f00e6e12c5972ac69f48ca5ce40498bc6a0d7687d28c34.json @@ -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" } diff --git a/.sqlx/query-2796be7484d8872eaae3655599c5071cfb0bdc1de4a557cd44441df3508a0bd2.json b/.sqlx/query-2796be7484d8872eaae3655599c5071cfb0bdc1de4a557cd44441df3508a0bd2.json new file mode 100644 index 0000000..e65b2a3 --- /dev/null +++ b/.sqlx/query-2796be7484d8872eaae3655599c5071cfb0bdc1de4a557cd44441df3508a0bd2.json @@ -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" +} diff --git a/.sqlx/query-fa58f079f00bec4c3f9cbb2ee0a607f63c5b586654c541bff6b99eff83ae0a44.json b/.sqlx/query-332c615185b316e1b3dab4b81565af1fb15b0e235bc8d5a3b95f5ee8feeae055.json similarity index 76% rename from .sqlx/query-fa58f079f00bec4c3f9cbb2ee0a607f63c5b586654c541bff6b99eff83ae0a44.json rename to .sqlx/query-332c615185b316e1b3dab4b81565af1fb15b0e235bc8d5a3b95f5ee8feeae055.json index 9b62686..37105e9 100644 --- a/.sqlx/query-fa58f079f00bec4c3f9cbb2ee0a607f63c5b586654c541bff6b99eff83ae0a44.json +++ b/.sqlx/query-332c615185b316e1b3dab4b81565af1fb15b0e235bc8d5a3b95f5ee8feeae055.json @@ -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" } diff --git a/.sqlx/query-53db321bc70b45800f696115b65da8550de208104017f8e0d0a163781488a534.json b/.sqlx/query-45e6381b3b68ad03f6459db8472587cf36d11a61bc5f94ed173adfd4ea68565d.json similarity index 75% rename from .sqlx/query-53db321bc70b45800f696115b65da8550de208104017f8e0d0a163781488a534.json rename to .sqlx/query-45e6381b3b68ad03f6459db8472587cf36d11a61bc5f94ed173adfd4ea68565d.json index 52b524b..5815d91 100644 --- a/.sqlx/query-53db321bc70b45800f696115b65da8550de208104017f8e0d0a163781488a534.json +++ b/.sqlx/query-45e6381b3b68ad03f6459db8472587cf36d11a61bc5f94ed173adfd4ea68565d.json @@ -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" } diff --git a/.sqlx/query-4cc3ff2bbdf8502f6e739a002c7aa16df8c4f24de0966b26b268efab97e205e3.json b/.sqlx/query-4cc3ff2bbdf8502f6e739a002c7aa16df8c4f24de0966b26b268efab97e205e3.json deleted file mode 100644 index 15c3449..0000000 --- a/.sqlx/query-4cc3ff2bbdf8502f6e739a002c7aa16df8c4f24de0966b26b268efab97e205e3.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-98ce62a260f411b834ab4b5374b49ea79850cce49e23b71d0eee917609beabd2.json b/.sqlx/query-7bbf5875b7e0edcf19f4fea7ad6f1685ed176c9b6979ea208d47e421b97540d3.json similarity index 70% rename from .sqlx/query-98ce62a260f411b834ab4b5374b49ea79850cce49e23b71d0eee917609beabd2.json rename to .sqlx/query-7bbf5875b7e0edcf19f4fea7ad6f1685ed176c9b6979ea208d47e421b97540d3.json index 33a0445..a1ae86b 100644 --- a/.sqlx/query-98ce62a260f411b834ab4b5374b49ea79850cce49e23b71d0eee917609beabd2.json +++ b/.sqlx/query-7bbf5875b7e0edcf19f4fea7ad6f1685ed176c9b6979ea208d47e421b97540d3.json @@ -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" } diff --git a/.sqlx/query-7f44451c441507ccd7f8d41633fb156f95a28389fcc9c489fdb228446f88342e.json b/.sqlx/query-7f44451c441507ccd7f8d41633fb156f95a28389fcc9c489fdb228446f88342e.json deleted file mode 100644 index b742b65..0000000 --- a/.sqlx/query-7f44451c441507ccd7f8d41633fb156f95a28389fcc9c489fdb228446f88342e.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-2613618c00941649726b31f7c50b5ef50d85323a73def5c87bab2a58c80bf6cd.json b/.sqlx/query-89452b34238cb0d885044fdbfc5ecab967d17224ffe4929be16be4a7b5c8e3ea.json similarity index 79% rename from .sqlx/query-2613618c00941649726b31f7c50b5ef50d85323a73def5c87bab2a58c80bf6cd.json rename to .sqlx/query-89452b34238cb0d885044fdbfc5ecab967d17224ffe4929be16be4a7b5c8e3ea.json index 7412dc1..c05293c 100644 --- a/.sqlx/query-2613618c00941649726b31f7c50b5ef50d85323a73def5c87bab2a58c80bf6cd.json +++ b/.sqlx/query-89452b34238cb0d885044fdbfc5ecab967d17224ffe4929be16be4a7b5c8e3ea.json @@ -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" } diff --git a/.sqlx/query-91d6a89be74bc242e5fafbe8bbb701ef1ee2db4779af504858b9cd9d602df9df.json b/.sqlx/query-91d6a89be74bc242e5fafbe8bbb701ef1ee2db4779af504858b9cd9d602df9df.json new file mode 100644 index 0000000..dd46a16 --- /dev/null +++ b/.sqlx/query-91d6a89be74bc242e5fafbe8bbb701ef1ee2db4779af504858b9cd9d602df9df.json @@ -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" +} diff --git a/.sqlx/query-a8b3563796f18e8ac28166771892226dbf8f730535e7f2d1225ff9eb6b1d9361.json b/.sqlx/query-bb845163adb4f6dc61c068f58bcc4dda9609c22b41507a652408669bd17fe6ea.json similarity index 77% rename from .sqlx/query-a8b3563796f18e8ac28166771892226dbf8f730535e7f2d1225ff9eb6b1d9361.json rename to .sqlx/query-bb845163adb4f6dc61c068f58bcc4dda9609c22b41507a652408669bd17fe6ea.json index 5f5e304..9875d2c 100644 --- a/.sqlx/query-a8b3563796f18e8ac28166771892226dbf8f730535e7f2d1225ff9eb6b1d9361.json +++ b/.sqlx/query-bb845163adb4f6dc61c068f58bcc4dda9609c22b41507a652408669bd17fe6ea.json @@ -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" } diff --git a/.sqlx/query-48a9268e9106a3a05e86ab36551944ba855a07b0241e912c6b1adee779c6f0ea.json b/.sqlx/query-e01fef8182e50d65459b03e1e93ed4198e3b581f3dfe1df76c250cafa2b9ce0f.json similarity index 77% rename from .sqlx/query-48a9268e9106a3a05e86ab36551944ba855a07b0241e912c6b1adee779c6f0ea.json rename to .sqlx/query-e01fef8182e50d65459b03e1e93ed4198e3b581f3dfe1df76c250cafa2b9ce0f.json index 3f27fe2..c2dc1a6 100644 --- a/.sqlx/query-48a9268e9106a3a05e86ab36551944ba855a07b0241e912c6b1adee779c6f0ea.json +++ b/.sqlx/query-e01fef8182e50d65459b03e1e93ed4198e3b581f3dfe1df76c250cafa2b9ce0f.json @@ -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" } diff --git a/.sqlx/query-ecbea4acb61da864a4f83004b1163391068456a366fee95daff5f46413bcf494.json b/.sqlx/query-e32fca1af5eb399768aa1ca0f0073d919a2c29f5d7c86cf1a23866e110dc8f33.json similarity index 79% rename from .sqlx/query-ecbea4acb61da864a4f83004b1163391068456a366fee95daff5f46413bcf494.json rename to .sqlx/query-e32fca1af5eb399768aa1ca0f0073d919a2c29f5d7c86cf1a23866e110dc8f33.json index a2c694d..461b740 100644 --- a/.sqlx/query-ecbea4acb61da864a4f83004b1163391068456a366fee95daff5f46413bcf494.json +++ b/.sqlx/query-e32fca1af5eb399768aa1ca0f0073d919a2c29f5d7c86cf1a23866e110dc8f33.json @@ -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" } diff --git a/.sqlx/query-be4c9a0fffa3ecaad4eb8ae59229ebfed685cf069b309ab874db4db069d14a3c.json b/.sqlx/query-efac2582e5b7de7e0923f9ebc1bc8d643683baf65e94dcc7f431290099de95d7.json similarity index 78% rename from .sqlx/query-be4c9a0fffa3ecaad4eb8ae59229ebfed685cf069b309ab874db4db069d14a3c.json rename to .sqlx/query-efac2582e5b7de7e0923f9ebc1bc8d643683baf65e94dcc7f431290099de95d7.json index 20e15b5..904e113 100644 --- a/.sqlx/query-be4c9a0fffa3ecaad4eb8ae59229ebfed685cf069b309ab874db4db069d14a3c.json +++ b/.sqlx/query-efac2582e5b7de7e0923f9ebc1bc8d643683baf65e94dcc7f431290099de95d7.json @@ -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" } diff --git a/.sqlx/query-f3364e9792d1886eff3d2dec0c3e789d1aeb9209405013df0e81560a42ca39b2.json b/.sqlx/query-f3364e9792d1886eff3d2dec0c3e789d1aeb9209405013df0e81560a42ca39b2.json new file mode 100644 index 0000000..bb641c2 --- /dev/null +++ b/.sqlx/query-f3364e9792d1886eff3d2dec0c3e789d1aeb9209405013df0e81560a42ca39b2.json @@ -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" +} diff --git a/.sqlx/query-f64d9a37720b8459ffbdd66186194b35332cc37b09a176165a839c342ba5354c.json b/.sqlx/query-f64d9a37720b8459ffbdd66186194b35332cc37b09a176165a839c342ba5354c.json deleted file mode 100644 index cf2a6b3..0000000 --- a/.sqlx/query-f64d9a37720b8459ffbdd66186194b35332cc37b09a176165a839c342ba5354c.json +++ /dev/null @@ -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" -} diff --git a/README.md b/README.md index 56f7be0..13d4275 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/migrations/20261011120000_image_uploads.sql b/migrations/20261011120000_image_uploads.sql index 3bebe08..793b1f7 100644 --- a/migrations/20261011120000_image_uploads.sql +++ b/migrations/20261011120000_image_uploads.sql @@ -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; diff --git a/src/image_cleanup.rs b/src/image_cleanup.rs index 98ae27b..f203e16 100644 --- a/src/image_cleanup.rs +++ b/src/image_cleanup.rs @@ -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::>() - .join(", ") -} - fn retention_seconds(value: Option<&str>) -> Result { 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 { } } -// 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::>() + .join(" AND ") } async fn mark_orphans(pool: &Pool) -> 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 = sqlx::query_scalar( - "SELECT url FROM image_uploads + let path: Option = 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 = 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 = 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()); diff --git a/src/image_cleanup/tests.rs b/src/image_cleanup/tests.rs index f36c882..d23f2b6 100644 --- a/src/image_cleanup/tests.rs +++ b/src/image_cleanup/tests.rs @@ -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, 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) { 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) } 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) { 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) { } #[sqlx::test] -async fn foreign_urls_are_retained_without_contacting_s3(pool: Pool) { +async fn invalid_paths_are_retained_without_contacting_s3(pool: Pool) { 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) { assert!(orphaned_at(&pool, id).await.is_some()); request.abort(); } + +#[sqlx::test] +async fn cleanup_blocks_new_references_until_deletion_finishes(pool: Pool) { + 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(); +} diff --git a/src/image_upload.rs b/src/image_upload.rs new file mode 100644 index 0000000..7b90cb0 --- /dev/null +++ b/src/image_upload.rs @@ -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 { + 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 { + value.and_then(|value| resolve_with_base(value, &public_url())) +} + +pub fn serialize_url(value: &Option, serializer: S) -> Result +where + S: Serializer, +{ + resolve_url(value.as_deref()).serialize(serializer) +} + +pub struct ImageReference { + pub url: Option, + pub id: Option, +} + +impl ImageReference { + pub async fn from_url( + executor: impl Executor<'_, Database = Database>, + url: Option<&str>, + ) -> Result { + Self::with_base(executor, url, &public_url()).await + } + + async fn with_base( + executor: impl Executor<'_, Database = Database>, + url: Option<&str>, + base: &str, + ) -> Result { + 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; diff --git a/src/image_upload/tests.rs b/src/image_upload/tests.rs new file mode 100644 index 0000000..8d9f689 --- /dev/null +++ b/src/image_upload/tests.rs @@ -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) { + 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) { + 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, Option) = 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()); +} diff --git a/src/main.rs b/src/main.rs index b971d5a..dcb29b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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))] diff --git a/src/postman/sender.rs b/src/postman/sender.rs index f9e2133..1adc60e 100644 --- a/src/postman/sender.rs +++ b/src/postman/sender.rs @@ -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 }) diff --git a/src/web/macros.rs b/src/web/macros.rs index eff787f..de19997 100644 --- a/src/web/macros.rs +++ b/src/web/macros.rs @@ -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))); + } + } + } + }; +} diff --git a/src/web/routes/dashboard/api/guild/reminders.rs b/src/web/routes/dashboard/api/guild/reminders.rs index 9a44dd7..82737a8 100644 --- a/src/web/routes/dashboard/api/guild/reminders.rs +++ b/src/web/routes/dashboard/api/guild/reminders.rs @@ -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, diff --git a/src/web/routes/dashboard/api/guild/templates.rs b/src/web/routes/dashboard/api/guild/templates.rs index 278290b..28eef69 100644 --- a/src/web/routes/dashboard/api/guild/templates.rs +++ b/src/web/routes/dashboard/api/guild/templates.rs @@ -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()) diff --git a/src/web/routes/dashboard/api/images.rs b/src/web/routes/dashboard/api/images.rs index 43fe5af..487b1e6 100644 --- a/src/web/routes/dashboard/api/images.rs +++ b/src/web/routes/dashboard/api/images.rs @@ -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::().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")); diff --git a/src/web/routes/dashboard/api/user/models.rs b/src/web/routes/dashboard/api/user/models.rs index f6f4fc0..bae99bb 100644 --- a/src/web/routes/dashboard/api/user/models.rs +++ b/src/web/routes/dashboard/api/user/models.rs @@ -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, pub content: String, pub embed_author: String, + #[serde(serialize_with = "crate::image_upload::serialize_url")] pub embed_author_url: Option, 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, + #[serde(serialize_with = "crate::image_upload::serialize_url")] pub embed_image_url: Option, + #[serde(serialize_with = "crate::image_upload::serialize_url")] pub embed_thumbnail_url: Option, pub embed_title: String, pub embed_fields: Option>>, @@ -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, diff --git a/src/web/routes/dashboard/api/user/reminders.rs b/src/web/routes/dashboard/api/user/reminders.rs index ec663ff..7853cb9 100644 --- a/src/web/routes/dashboard/api/user/reminders.rs +++ b/src/web/routes/dashboard/api/user/reminders.rs @@ -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, diff --git a/src/web/routes/dashboard/export/reminder_templates.rs b/src/web/routes/dashboard/export/reminder_templates.rs index e8305f8..82f4c15 100644 --- a/src/web/routes/dashboard/export/reminder_templates.rs +++ b/src/web/routes/dashboard/export/reminder_templates.rs @@ -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, diff --git a/src/web/routes/dashboard/export/reminders.rs b/src/web/routes/dashboard/export/reminders.rs index 680caf1..87edaf5 100644 --- a/src/web/routes/dashboard/export/reminders.rs +++ b/src/web/routes/dashboard/export/reminders.rs @@ -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, diff --git a/src/web/routes/dashboard/mod.rs b/src/web/routes/dashboard/mod.rs index 1ee5c74..847fbe8 100644 --- a/src/web/routes/dashboard/mod.rs +++ b/src/web/routes/dashboard/mod.rs @@ -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 = Option; +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_name: Option, + #[serde(serialize_with = "crate::image_upload::serialize_url")] avatar: Option, content: String, embed_author: String, + #[serde(serialize_with = "crate::image_upload::serialize_url")] embed_author_url: Option, embed_color: u32, embed_description: String, embed_footer: String, + #[serde(serialize_with = "crate::image_upload::serialize_url")] embed_footer_url: Option, + #[serde(serialize_with = "crate::image_upload::serialize_url")] embed_image_url: Option, + #[serde(serialize_with = "crate::image_upload::serialize_url")] embed_thumbnail_url: Option, embed_title: String, embed_fields: Option>>, @@ -117,15 +128,20 @@ pub struct ReminderTemplateCsv { name: String, attachment: Option, attachment_name: Option, + #[serde(serialize_with = "crate::image_upload::serialize_url")] avatar: Option, content: String, embed_author: String, + #[serde(serialize_with = "crate::image_upload::serialize_url")] embed_author_url: Option, embed_color: u32, embed_description: String, embed_footer: String, + #[serde(serialize_with = "crate::image_upload::serialize_url")] embed_footer_url: Option, + #[serde(serialize_with = "crate::image_upload::serialize_url")] embed_image_url: Option, + #[serde(serialize_with = "crate::image_upload::serialize_url")] embed_thumbnail_url: Option, embed_title: String, embed_fields: Option, @@ -182,17 +198,22 @@ pub struct CreateReminder { #[derive(Serialize, FromRow)] pub struct GetReminder { attachment_name: Option, + #[serde(serialize_with = "crate::image_upload::serialize_url")] avatar: Option, #[serde(with = "string")] channel: u64, content: String, embed_author: String, + #[serde(serialize_with = "crate::image_upload::serialize_url")] embed_author_url: Option, embed_color: u32, embed_description: String, embed_footer: String, + #[serde(serialize_with = "crate::image_upload::serialize_url")] embed_footer_url: Option, + #[serde(serialize_with = "crate::image_upload::serialize_url")] embed_image_url: Option, + #[serde(serialize_with = "crate::image_upload::serialize_url")] embed_thumbnail_url: Option, embed_title: String, embed_fields: Option>>, @@ -215,16 +236,21 @@ pub struct GetReminder { pub struct ReminderCsv { attachment: Option, attachment_name: Option, + #[serde(serialize_with = "crate::image_upload::serialize_url")] avatar: Option, channel: String, content: String, embed_author: String, + #[serde(serialize_with = "crate::image_upload::serialize_url")] embed_author_url: Option, embed_color: u32, embed_description: String, embed_footer: String, + #[serde(serialize_with = "crate::image_upload::serialize_url")] embed_footer_url: Option, + #[serde(serialize_with = "crate::image_upload::serialize_url")] embed_image_url: Option, + #[serde(serialize_with = "crate::image_upload::serialize_url")] embed_thumbnail_url: Option, embed_title: String, embed_fields: Option, @@ -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) { + 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, Option) = 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, Option) = 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, Option) = 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(); + } +}