Compare commits

..
Author SHA1 Message Date
jude 192e316926 Restructure guilds table 2025-06-25 20:08:16 +01:00
jude 19cfacffe5 Restructure database tables 2025-05-08 20:55:17 +01:00
137 changed files with 1317 additions and 17090 deletions

No files matched your search

+2 -4
View File
@@ -1,11 +1,10 @@
target
.env
/venv
__pycache__/
.cargo
.idea
static/index.html
static/assets
web/static/index.html
web/static/assets
# Logs
logs
*.log
@@ -29,4 +28,3 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.junie
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "DELETE FROM patreon_link WHERE user_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "0402e16b1ec89a96d893d43f6b40500ccbde3c619116a702c87954df49898e23"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n UPDATE reminders SET `status` = 'deleted' WHERE FIND_IN_SET(id, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "09f6269e5df3acc01e8e2660532b5fcab21c0cd4fd126b580176d24578932d7e"
}
@@ -1,24 +0,0 @@
{
"db_name": "MySQL",
"query": "\n SELECT IFNULL(timezone, 'UTC') AS timezone\n FROM users\n WHERE timezone IS NOT NULL\n GROUP BY timezone\n ORDER BY COUNT(timezone) DESC\n LIMIT 21\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "timezone",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 128
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "19bc60a2ff67ce6e169985a76405af51d7d16d4d7b84d1c239de5af79da93268"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n UPDATE channels\n SET\n name = ?,\n nudge = ?,\n blacklisted = ?,\n webhook_id = ?,\n webhook_token = ?,\n paused = ?,\n paused_until = ?\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "1bd6f70d57d5e913f97025cf6fc5be795a1302485ef1c07fe13eb246f184a1d1"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\n DELETE FROM reminders\n WHERE `utc_time` < NOW() - INTERVAL ? DAY\n AND status != 'pending'\n ORDER BY `utc_time`\n LIMIT 1000\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "2d780695fe98347ea4ab2cb745462f0a9c55cf913c71d4d822b91958f4f8a729"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n UPDATE reminders\n INNER JOIN `channels`\n ON `channels`.id = reminders.channel_id\n SET reminders.`utc_time` = reminders.`utc_time` + ?\n WHERE channels.`channel` = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "2db489e076c93a5a2baf2dd48eb3278d68296aea93097a642e2bbb5112d51fe8"
}
@@ -0,0 +1,134 @@
{
"db_name": "MySQL",
"query": "\n SELECT\n reminders.id,\n reminders.uid,\n channels.channel,\n reminders.utc_time,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.expires,\n reminders.enabled,\n reminders.content,\n reminders.embed_description,\n reminders.set_by\n FROM\n reminders\n LEFT JOIN\n channels\n ON\n channels.id = reminders.channel_id\n WHERE\n `status` = 'pending' AND\n FIND_IN_SET(channels.channel, ?)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"max_size": 10
}
},
{
"ordinal": 1,
"name": "uid",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | NO_DEFAULT_VALUE",
"max_size": 256
}
},
{
"ordinal": 2,
"name": "channel",
"type_info": {
"type": "LongLong",
"flags": "UNIQUE_KEY | UNSIGNED | NO_DEFAULT_VALUE",
"max_size": 20
}
},
{
"ordinal": 3,
"name": "utc_time",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 19
}
},
{
"ordinal": 4,
"name": "interval_seconds",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 5,
"name": "interval_days",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 6,
"name": "interval_months",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 7,
"name": "expires",
"type_info": {
"type": "Datetime",
"flags": "BINARY",
"max_size": 19
}
},
{
"ordinal": 8,
"name": "enabled",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 9,
"name": "content",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 10,
"name": "embed_description",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 11,
"name": "set_by",
"type_info": {
"type": "LongLong",
"flags": "MULTIPLE_KEY | UNSIGNED",
"max_size": 20
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
true,
true,
true,
true,
false,
false,
false,
true
]
},
"hash": "3695f95cea95c075b2b3becdf1b5d75bf1ccace3b9a176086faa4ad76c0a0fbd"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\n SELECT id, channel, nudge, webhook_id, webhook_token, paused,\n paused_until\n FROM channels\n WHERE channel = ?\n ",
"query": "\n SELECT id, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until\n FROM channels\n WHERE channel = ?\n ",
"describe": {
"columns": [
{
@@ -23,6 +23,15 @@
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 400
}
},
{
"ordinal": 3,
"name": "nudge",
"type_info": {
"type": "Short",
@@ -31,7 +40,16 @@
}
},
{
"ordinal": 3,
"ordinal": 4,
"name": "blacklisted",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 5,
"name": "webhook_id",
"type_info": {
"type": "LongLong",
@@ -40,7 +58,7 @@
}
},
{
"ordinal": 4,
"ordinal": 6,
"name": "webhook_token",
"type_info": {
"type": "Blob",
@@ -49,7 +67,7 @@
}
},
{
"ordinal": 5,
"ordinal": 7,
"name": "paused",
"type_info": {
"type": "Tiny",
@@ -58,7 +76,7 @@
}
},
{
"ordinal": 6,
"ordinal": 8,
"name": "paused_until",
"type_info": {
"type": "Timestamp",
@@ -73,6 +91,8 @@
"nullable": [
false,
false,
true,
false,
false,
true,
true,
@@ -80,5 +100,5 @@
true
]
},
"hash": "017b3f73101926a3fe029ed75c412ec902aa74fee334c23fd75fe0cde1e324ca"
"hash": "4d26e92088234552f9159b876c7fdd3bb557a7f918820e289bd39742de553883"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "INSERT IGNORE INTO channels (channel, guild_id) VALUES (?, (SELECT id FROM guilds WHERE guild = ?))",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "5d7a787e3a6f487c3fe00bd39d308c9350c82e58b0ad1aae0b92824375786f02"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n UPDATE reminders\n INNER JOIN `channels`\n ON `channels`.id = reminders.channel_id\n SET reminders.`utc_time` = DATE_ADD(reminders.`utc_time`, INTERVAL ? SECOND)\n WHERE FIND_IN_SET(channels.`channel`, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "65496ff85dd92b5aaf12519628afdc16ca7d70131744c9c53880dc56b92991d9"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n DELETE FROM todos WHERE FIND_IN_SET(id, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "6e00a27fa770d1aa8cac48cd8878e712ef536c67eeb4fb9a4a801459ada35715"
}
@@ -1,24 +0,0 @@
{
"db_name": "MySQL",
"query": "SELECT user_id FROM patreon_link WHERE guild_id = ?",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_id",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | MULTIPLE_KEY | UNSIGNED | NO_DEFAULT_VALUE",
"max_size": 20
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "92cdd6af01e398b22112ffe88b9ff63d9cc61faaf0dee9eda974efbc8bf84173"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\n DELETE FROM reminders\n WHERE `utc_time` < NOW() - INTERVAL ? DAY\n ORDER BY `utc_time`\n LIMIT 1000\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "93897198be27266cd9de90063ee67594cf65c1216c9b9787fc96cd8ffcc1cdef"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\n UPDATE reminders\n INNER JOIN `channels`\n ON `channels`.id = reminders.channel_id\n SET reminders.`utc_time` = reminders.`utc_time` + ?\n WHERE channels.`channel` = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "9b871f08d294555453696808185c6d29d4753619fbee6295a053cefaa9dcc0ae"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "INSERT INTO patreon_link (user_id, guild_id, linked_at) VALUES (?, ?, NOW())\n ON DUPLICATE KEY UPDATE guild_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "a647934dc5485cfbf430c77b71f7b181f888b0961d5274621e5e1dd76417080e"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\n UPDATE channels\n SET\n nudge = ?,\n webhook_id = ?,\n webhook_token = ?,\n paused = ?,\n paused_until = ?\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "af08f3524c316f438feb62a24c67b96197e3655a87efe128914ed386be20d900"
}
@@ -0,0 +1,264 @@
{
"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 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.restartable,\n reminders.tts,\n reminders.uid,\n reminders.username,\n reminders.utc_time\n FROM reminders\n INNER JOIN channels ON channels.id = reminders.channel_id\n WHERE `status` = 'pending' AND FIND_IN_SET(channels.channel, ?)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "attachment_name",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 1040
}
},
{
"ordinal": 1,
"name": "avatar",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 2,
"name": "channel",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | NO_DEFAULT_VALUE",
"max_size": 20
}
},
{
"ordinal": 3,
"name": "content",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 4,
"name": "embed_author",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 1024
}
},
{
"ordinal": 5,
"name": "embed_author_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 6,
"name": "embed_color",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 7,
"name": "embed_description",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 8,
"name": "embed_footer",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 9,
"name": "embed_footer_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 10,
"name": "embed_image_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 11,
"name": "embed_thumbnail_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 12,
"name": "embed_title",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 1024
}
},
{
"ordinal": 13,
"name": "embed_fields",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY",
"max_size": 4294967292
}
},
{
"ordinal": 14,
"name": "enabled",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 15,
"name": "expires",
"type_info": {
"type": "Datetime",
"flags": "BINARY",
"max_size": 19
}
},
{
"ordinal": 16,
"name": "interval_seconds",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 17,
"name": "interval_days",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 18,
"name": "interval_months",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 19,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 400
}
},
{
"ordinal": 20,
"name": "restartable",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 21,
"name": "tts",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 22,
"name": "uid",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | NO_DEFAULT_VALUE",
"max_size": 256
}
},
{
"ordinal": 23,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 128
}
},
{
"ordinal": 24,
"name": "utc_time",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
true,
false,
false,
false,
true,
false,
false,
false,
true,
true,
true,
false,
false,
false,
true,
true,
true,
true,
false,
false,
false,
false,
true,
false
]
},
"hash": "af5bf4c6b30ffd316ecebc2dd53554e41f0d4f40cad63736930d20cb18159b38"
}
@@ -0,0 +1,24 @@
{
"db_name": "MySQL",
"query": "\n SELECT IFNULL(timezone, 'UTC') AS timezone\n FROM users\n WHERE timezone IS NOT NULL\n GROUP BY timezone\n ORDER BY COUNT(timezone) DESC\n LIMIT 21\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "timezone",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 128
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "bbdd4bd7ebffb97efab8ba7e829159e104615260929341ec0e961b4d5cd6ca0c"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\n SELECT id, channel, nudge, webhook_id, webhook_token, paused, paused_until\n FROM channels\n WHERE channel = ?\n ",
"query": "\n SELECT id, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused,\n paused_until\n FROM channels\n WHERE channel = ?\n ",
"describe": {
"columns": [
{
@@ -23,6 +23,15 @@
},
{
"ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 400
}
},
{
"ordinal": 3,
"name": "nudge",
"type_info": {
"type": "Short",
@@ -31,7 +40,16 @@
}
},
{
"ordinal": 3,
"ordinal": 4,
"name": "blacklisted",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 5,
"name": "webhook_id",
"type_info": {
"type": "LongLong",
@@ -40,7 +58,7 @@
}
},
{
"ordinal": 4,
"ordinal": 6,
"name": "webhook_token",
"type_info": {
"type": "Blob",
@@ -49,7 +67,7 @@
}
},
{
"ordinal": 5,
"ordinal": 7,
"name": "paused",
"type_info": {
"type": "Tiny",
@@ -58,7 +76,7 @@
}
},
{
"ordinal": 6,
"ordinal": 8,
"name": "paused_until",
"type_info": {
"type": "Timestamp",
@@ -73,6 +91,8 @@
"nullable": [
false,
false,
true,
false,
false,
true,
true,
@@ -80,5 +100,5 @@
true
]
},
"hash": "21b660ecbeffcac31ef283f59e420360a28b344759407024a11dc4a04da9341d"
"hash": "d3e41b7c9df5823c418181f46aa78999159afbd458dcd05ffb9e032182ec493b"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\n SELECT\n reminders.id,\n reminders.uid,\n channels.channel,\n reminders.utc_time,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.expires,\n reminders.enabled,\n reminders.content,\n reminders.embed_description,\n reminders.set_by\n FROM\n reminders\n INNER JOIN\n channels\n ON\n reminders.channel_id = channels.id\n WHERE\n `status` = 'pending' AND\n channels.channel = ? AND\n reminders.enabled >= ?\n ORDER BY\n reminders.utc_time\n ",
"query": "\n SELECT\n reminders.id,\n reminders.uid,\n channels.channel,\n reminders.utc_time,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.expires,\n reminders.enabled,\n reminders.content,\n reminders.embed_description,\n reminders.set_by\n FROM\n reminders\n INNER JOIN\n channels\n ON\n reminders.channel_id = channels.id\n WHERE\n `status` = 'pending' AND\n channels.channel = ? AND\n FIND_IN_SET(reminders.enabled, ?)\n ORDER BY\n reminders.utc_time\n ",
"describe": {
"columns": [
{
@@ -130,5 +130,5 @@
true
]
},
"hash": "d2921961627fef0e12892dbbcd4b891e58c0e52c20897aab3d95365774c01bda"
"hash": "d60ea641070dbd882cd53878fa109d08bd3f65e0da8c263e78fe0b200228bc2b"
}
@@ -0,0 +1,264 @@
{
"db_name": "MySQL",
"query": "SELECT\n reminders.attachment,\n reminders.attachment_name,\n reminders.avatar,\n CONCAT('#', channels.channel) AS 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.username,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE FIND_IN_SET(channels.channel, ?) AND status = 'pending'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "attachment",
"type_info": {
"type": "Blob",
"flags": "BLOB | BINARY",
"max_size": 16777215
}
},
{
"ordinal": 1,
"name": "attachment_name",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 1040
}
},
{
"ordinal": 2,
"name": "avatar",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 3,
"name": "channel",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 84
}
},
{
"ordinal": 4,
"name": "content",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 5,
"name": "embed_author",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 1024
}
},
{
"ordinal": 6,
"name": "embed_author_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"ordinal": 7,
"name": "embed_color",
"type_info": {
"type": "Long",
"flags": "NOT_NULL | UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 8,
"name": "embed_description",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 9,
"name": "embed_footer",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 8192
}
},
{
"ordinal": 10,
"name": "embed_footer_url",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 2048
}
},
{
"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_title",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 1024
}
},
{
"ordinal": 14,
"name": "embed_fields",
"type_info": {
"type": "Json",
"flags": "BLOB | BINARY",
"max_size": 4294967295
}
},
{
"ordinal": 15,
"name": "enabled",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 16,
"name": "expires",
"type_info": {
"type": "Datetime",
"flags": "BINARY",
"max_size": 19
}
},
{
"ordinal": 17,
"name": "interval_seconds",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 18,
"name": "interval_days",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 19,
"name": "interval_months",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"max_size": 10
}
},
{
"ordinal": 20,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL",
"max_size": 400
}
},
{
"ordinal": 21,
"name": "restartable",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 22,
"name": "tts",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 23,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 128
}
},
{
"ordinal": 24,
"name": "utc_time",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
true,
true,
true,
false,
false,
true,
false,
false,
false,
true,
true,
true,
false,
true,
false,
true,
true,
true,
true,
false,
false,
false,
true,
false
]
},
"hash": "db69def9391283efb9bf915223d7d6b2d169203b7dc45481a509137f7590d9a6"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?))",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "f8e99a39fb87d47bcdf70f38e00c4dbe4d6832b1b073efddfa79a8cd756d8dc6"
}
-24
View File
@@ -1,24 +0,0 @@
# Caddy v2 configuration for local development (HTTPS on localhost)
# Reverse-proxy to the bot service and serve static assets.
# Uses Caddy's internal CA to generate a self-signed certificate for localhost.
# HTTP -> HTTPS redirect for local development
:80 {
redir https://localhost{uri}
}
# Local HTTPS site with self-signed cert
localhost {
encode zstd gzip
# Issue a locally-trusted certificate via Caddy's internal CA
tls internal
# Serve static files under /static from the mounted volume
handle_path /static* {
root * /var/www/reminder-rs/static
file_server
}
# Proxy everything else to the bot service inside the Docker network
reverse_proxy bot:18920
}
Generated
+81 -1330
View File
File diff suppressed because it is too large. Load diff
+2 -12
View File
@@ -1,6 +1,6 @@
[package]
name = "reminder-rs"
version = "1.7.45"
version = "1.7.37"
authors = ["Jude Southworth <judesouthworth@pm.me>"]
edition = "2021"
license = "AGPL-3.0 only"
@@ -35,10 +35,6 @@ serenity = { version = "0.12", default-features = false, features = ["builder",
oauth2 = "4"
csv = "1.2"
sd-notify = "0.4.1"
cron-parser = "0.10"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
nsfw = { version = "0.2", default-features = false }
rust-s3 = { version = "0.38", default-features = false, features = ["tokio-rustls-tls"] }
[dependencies.extract_derive]
path = "extract_derive"
@@ -71,12 +67,6 @@ conf-files = [
"/etc/reminder-rs/Rocket.toml",
]
[[package.metadata.deb.systemd-units]]
[package.metadata.deb.systemd-units]
unit-scripts = "systemd"
unit-name = "reminder-rs"
start = false
[[package.metadata.deb.systemd-units]]
unit-scripts = "systemd"
unit-name = "reminder-rs-clean-images"
start = false
+10 -16
View File
@@ -1,10 +1,11 @@
FROM alpine:latest AS build
FROM ubuntu:24.04
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN apk add gcc build-base pkgconfig openssl-dev openssl-libs-static curl mysql-client npm
RUN apt update
RUN DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt install -y gcc gcc-multilib pkg-config libssl-dev curl mysql-client-8.0 npm
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal --default-toolchain nightly
WORKDIR /usr/src/reminder-rs
@@ -16,7 +17,13 @@ COPY ./conf ./conf
COPY ./extract_derive ./extract_derive
COPY ./migrations ./migrations
COPY ./recordable_derive ./recordable_derive
COPY ./reminder-dashboard ./reminder-dashboard
COPY ./reminder-dashboard/public ./reminder-dashboard/public
COPY ./reminder-dashboard/src ./reminder-dashboard/src
COPY ./reminder-dashboard/index.html ./reminder-dashboard/
COPY ./reminder-dashboard/package.json ./reminder-dashboard/
COPY ./reminder-dashboard/package-lock.json ./reminder-dashboard/
COPY ./reminder-dashboard/tsconfig.json ./reminder-dashboard/
COPY ./reminder-dashboard/vite.config.ts ./reminder-dashboard/
COPY ./src ./src
COPY ./static ./static
COPY ./templates ./templates
@@ -25,19 +32,6 @@ COPY ./Cargo.lock ./
COPY ./Cargo.toml ./
COPY ./dp.py ./
# Build and install the Rust binary
RUN cargo install --path .
FROM alpine:latest AS runtime
WORKDIR /usr/src/reminder-rs
COPY --from=build /usr/local/cargo/bin/reminder-rs /usr/local/bin/reminder-rs
COPY --from=build /usr/src/reminder-rs/static /usr/src/reminder-rs/static
COPY --from=build /usr/src/reminder-rs/templates /usr/src/reminder-rs/templates
RUN apk add python3 py3-pip
RUN pip3 install --no-cache --break-system-packages dateparser
EXPOSE 18920
CMD ["reminder-rs"]
-22
View File
@@ -1,22 +0,0 @@
.DEFAULT_GOAL := build
CARGO ?= cargo
PODMAN ?= podman
IMAGE ?= reminder-rs
.PHONY: build prepare test image
# Keep the stages ordered even when make is run with -j.
build: image
$(PODMAN) run --rm -v "$(CURDIR):/mnt" -w /mnt \
-e SQLX_OFFLINE=true "$(IMAGE)" cargo deb
image: test
$(PODMAN) build -t "$(IMAGE)" .
test: prepare
SQLX_OFFLINE=true $(CARGO) test --locked
# Include test-only queries in the metadata used by offline compilation.
prepare:
SQLX_OFFLINE=false $(CARGO) sqlx prepare -- --all-targets --locked
+23 -126
View File
@@ -1,43 +1,25 @@
# reminder-rs
Reminder Bot for Discord.
## How do I use it?
I offer a hosted version of the bot. You can invite it with: **https://invite.reminder-bot.com**.
The catch is that repeating
I offer a hosted version of the bot. You can invite it with: **https://invite.reminder-bot.com**. The catch is that repeating
reminders are paid on the hosted version of the bot. Keep reading if you want to host it yourself.
You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable
replacement for dateparser in Rust)
You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust)
### Build APT package
Recommended method.
By default, this builds targeting Ubuntu 20.04. Modify the Containerfile if you wish to target a
different platform. These instructions are written using `podman`, but `docker` should work too.
By default, this builds targeting Ubuntu 20.04. Modify the Containerfile if you wish to target a different platform. These instructions are written using `podman`, but `docker` should work too.
1. Install container software and Make: `sudo apt install podman make`.
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called
`reminders`
3. Install SQLx CLI matching the project's SQLx version: `cargo install sqlx-cli --version 0.8.2 --locked`.
4. Install the local build requirements (Rust, Node.js/npm, GCC, CMake, pkg-config, and OpenSSL
development headers), then install dashboard dependencies: `npm ci --prefix reminder-dashboard`.
5. Export the URL of your build/test database:
`export DATABASE_URL="mysql://user[:password]@localhost/reminders"`.
6. From the source code directory, execute `sqlx migrate run`.
7. Run `make` (or `make build`). This prepares SQLx query metadata in `.sqlx`, runs the Rust tests,
builds the `reminder-rs` container image, and runs `cargo deb` inside it. The resulting package is
written to `target/debian/`.
1. Install container software: `sudo apt install podman`.
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`
3. Install SQLx CLI: `cargo install sqlx-cli`
4. From the source code directory, execute `sqlx migrate run`
5. Build container image: `podman build -t reminder-rs .`
6. Build with podman: `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`
Preparation and database-dependent tests use the local database. The container packaging step uses
the prepared `.sqlx` metadata with `SQLX_OFFLINE=true`, so it does not need database access, including
when a `.env` file is present in the mounted source directory.
The stages can also be run separately: `make prepare`, `make test` (includes preparation), and
`make image` (includes preparation and tests). Override `IMAGE` to change the container image tag,
or `PODMAN` to use another container command, for example `make PODMAN=docker`.
### Compiling for other target
@@ -45,111 +27,26 @@ or `PODMAN` to use another container command, for example `make PODMAN=docker`.
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential python3-dateparser`
2. Install rustup from https://rustup.rs
3. Install the nightly toolchain: `rustup toolchain default nightly`
4. Install database server: `sudo apt install mysql-server-8.0`. Create a database called
`reminders`.
4. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`.
5. Install `sqlx-cli`: `cargo install sqlx-cli`.
6. Run migrations: `sqlx migrate run`.
7. Set environment variables:
- `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
8. Build: `cargo build --release`
### Configuring
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded
fallbacks. Environment variables can be loaded from a .env file in the working directory.
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory.
**Required Variables**
__Required Variables__
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
* `DISCORD_TOKEN` - your application's bot user's authorization token
- `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
- `DISCORD_TOKEN` - your application's bot user's authorization token
**Other Variables**
- `MIN_INTERVAL` - default `600`, defines the shortest interval the bot should accept
- `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
- `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to
subscribed users
- `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the
subscription roles belong to
- `PYTHON_LOCATION` - default `/usr/bin/python3`. Can be changed if your Python executable is
located somewhere else
- `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message
embeds
### Dashboard image uploads
Image URL inputs offer an **Upload image** button when all of these settings are supplied:
- `S3_BUCKET` — destination bucket name.
- `S3_REGION` — bucket region, for example `eu-west-2`.
- `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` — credentials with `s3:PutObject` and
`s3:DeleteObject` permissions for the bucket's `uploads/` prefix.
- `S3_PUBLIC_URL` — public base URL for the bucket or CDN, for example
`https://my-bucket.s3.eu-west-2.amazonaws.com`. Objects must be publicly readable through
this URL; the application does not change bucket policies or set object ACLs.
`NSFW_CHECK_ENABLED` defaults to `true`, and enables the use of a small model to block uploading
of NSFW images. Set it to `false` to skip NSFW moderation and model initialization. Empty or
unrecognized values keep checks enabled.
The Debian package includes `reminder-rs-clean-images.timer`, an hourly cleanup job. Enable it
after configuring S3 and starting the bot (which applies database migrations):
```sh
sudo systemctl enable --now reminder-rs-clean-images.timer
```
Each run records the first observed orphan time in `image_uploads.orphaned_at` (UTC). An upload
is orphaned when neither a reminder (including archived reminders) nor a template uses its URL
as an avatar, image, thumbnail, author icon or footer icon. Referenced uploads have this timestamp
cleared. `IMAGE_UPLOAD_ORPHAN_RETENTION_SECONDS` controls the additional retention time after
detection; it defaults to `604800` (seven days) and must be a positive integer. Unsaved uploads
are also covered. Cleanup rechecks references before deleting and removes the tracking row only
after S3 confirms deletion; failed deletions are retried on later runs. Each run attempts up to
1,000 expired uploads from the configured public URL's `uploads/` prefix.
For other deployments, schedule `/path/to/reminder-rs clean-images` hourly with cron, using the
same working directory and configuration as the bot. The command requires no Discord token
and skips NSFW model initialization. With no S3 settings it exits successfully without work;
partial S3 settings or invalid retention values fail the job.
## Running with Docker
### RustFS for local development
Start the local object store and initialize the image upload bucket:
```sh
docker compose --env-file conf/rustfs.env -f compose.rustfs.yaml up -d
```
Add the settings from `conf/rustfs.env` to your local `.env`, then restart the bot running on
your host. RustFS serves the S3 API at `http://127.0.0.1:9000` and its console at
`http://127.0.0.1:9001`. Log into the console with the access key and secret key from
`conf/rustfs.env`. Both ports bind to localhost. The named data volume persists across restarts.
The initializer creates `reminder-images` if needed and permits anonymous reads of its
`uploads/` objects. Uploads, deletes and bucket listing require credentials. These settings
enable dashboard image uploads locally; `NSFW_CHECK_ENABLED` keeps its configured value.
`S3_ENDPOINT` uses path-style addressing for RustFS, and `S3_PUBLIC_URL` includes the bucket
name because the browser fetches objects through that public URL.
```sh
docker compose --env-file conf/rustfs.env -f compose.rustfs.yaml logs rustfs-init
docker compose --env-file conf/rustfs.env -f compose.rustfs.yaml down
```
`down` keeps the data volume. This development stack is separate from the bot/database stack
below. The [RustFS Docker documentation](https://docs.rustfs.com/en/installation/container/docker)
describes the upstream container configuration.
A `compose.yml` file is provided to aid in running the bot agnostically using docker.
- Populate a `.env` file as in `conf/default.env`
- Add the additional variable `ROCKET_SECRET_KEY` with a key generated from
`head -c64 /dev/urandom | base64`
- Run `docker compose up`
Please note that this is _not_ production-ready when run via compose. We do not offer a way for
backing up of your data, or a way to run the dashboard securely via HTTPS, which is required for
OAuth.
__Other Variables__
* `MIN_INTERVAL` - default `600`, defines the shortest interval the bot should accept
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
* `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to
* `PYTHON_LOCATION` - default `/usr/bin/python3`. Can be changed if your Python executable is located somewhere else
* `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds
-7
View File
@@ -1,7 +0,0 @@
# Bundled NSFW model
`model.onnx` is the pretrained model distributed with
[Fyko/nsfw v0.2.0](https://github.com/fyko/nsfw/releases/tag/v0.2.0), based on
[GantMan/nsfw_model](https://github.com/GantMan/nsfw_model).
Source: https://github.com/fyko/nsfw/releases/download/v0.2.0/model.onnx
Binary file not shown.
+1 -21
View File
@@ -8,26 +8,6 @@ fn main() {
.arg("run")
.arg("build")
.current_dir(Path::new("reminder-dashboard"))
.env("VITE_VERSION", env!("CARGO_PKG_VERSION"))
.spawn()
.expect("Failed to build NPM")
.wait()
.expect("Failed to wait for NPM build");
Command::new("cp")
.arg("reminder-dashboard/dist/index.html")
.arg("static/index.html")
.spawn()
.expect("Failed to copy index.html")
.wait()
.expect("Failed to wait for index.html copy");
Command::new("cp")
.arg("-r")
.arg("reminder-dashboard/dist/static/assets")
.arg("static/")
.spawn()
.expect("Failed to copy assets")
.wait()
.expect("Failed to wait for assets copy");
.expect("Failed to build NPM");
}
-63
View File
@@ -1,63 +0,0 @@
name: reminder-rustfs-dev
services:
rustfs-permissions:
image: busybox:1.37
user: "0:0"
volumes:
- rustfs-data:/data
- rustfs-logs:/logs
command: ["sh", "-c", "chown 10001:10001 /data /logs"]
restart: "no"
rustfs:
image: rustfs/rustfs:latest
restart: unless-stopped
depends_on:
rustfs-permissions:
condition: service_completed_successfully
environment:
RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY_ID:-REMINDERDEV}
RUSTFS_SECRET_KEY: ${S3_SECRET_ACCESS_KEY:-reminder-rustfs-local-secret}
RUSTFS_REGION: ${S3_REGION:-us-east-1}
RUSTFS_VOLUMES: /data
RUSTFS_ADDRESS: "0.0.0.0:9000"
RUSTFS_CONSOLE_ADDRESS: "0.0.0.0:9001"
RUSTFS_CONSOLE_ENABLE: "true"
RUSTFS_OBS_LOGGER_LEVEL: warn
RUSTFS_OBS_LOG_DIRECTORY: /logs
# Local single-volume development storage.
RUSTFS_UNSAFE_BYPASS_DISK_CHECK: "true"
volumes:
- rustfs-data:/data
- rustfs-logs:/logs
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9000/health"]
interval: 3s
timeout: 5s
retries: 20
start_period: 10s
rustfs-init:
image: amazon/aws-cli:latest
depends_on:
rustfs:
condition: service_healthy
environment:
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-REMINDERDEV}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-reminder-rustfs-local-secret}
AWS_DEFAULT_REGION: ${S3_REGION:-us-east-1}
AWS_EC2_METADATA_DISABLED: "true"
AWS_PAGER: ""
S3_BUCKET: ${S3_BUCKET:-reminder-images}
volumes:
- ./scripts/init-rustfs.sh:/init-rustfs.sh:ro
entrypoint: ["/bin/sh", "/init-rustfs.sh"]
restart: "no"
volumes:
rustfs-data:
rustfs-logs:
+14 -52
View File
@@ -1,59 +1,21 @@
version: '3.3'
services:
bot:
build: ./Containerfile.run
image: reminder-rs-run
restart: always
expose:
- '80'
database:
image: mysql:8.0
restart: always
command: --log-bin-trust-function-creators=1
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: reminders
MYSQL_USER: reminder-bot
MYSQL_PASSWORD: password
MYSQL_DATABASE: 'reminders'
MYSQL_USER: 'reminder-bot'
ports:
- '3306:3306'
expose:
- '3306'
volumes:
- reminders:/var/lib/mysql
bot:
build:
context: .
dockerfile: Containerfile.run
image: reminder-rs-run
restart: always
depends_on:
- database
env_file:
- .env
environment:
DATABASE_URL: "mysql://reminder-bot:password@database/reminders"
DISCORD_TOKEN:
PATREON_GUILD_ID:
PATREON_ROLE_ID:
LOCAL_TIMEZONE:
MIN_INTERVAL:
ROCKET_SECRET_KEY:
ROCKET_ADDRESS: "0.0.0.0"
ROCKET_PORT: "18920"
REMIND_INTERVAL:
OAUTH2_DISCORD_CALLBACK:
OAUTH2_CLIENT_ID:
OAUTH2_CLIENT_SECRET:
ports:
- "18920:18920"
proxy:
image: caddy:2.4.6-alpine
restart: always
depends_on:
- bot
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./static:/var/www/reminder-rs/static:ro
- caddy_data:/data
- caddy_config:/config
volumes:
reminders:
caddy_data:
caddy_config:
-15
View File
@@ -20,18 +20,3 @@ REPORT_EMAIL=
SENT_CLEAN_AGE=
# Delete all reminders after this many months
TOTAL_CLEAN_AGE=
# Optional dashboard image uploads. Leave these blank to use image URLs only.
# All five S3 settings are required. The bucket/CDN must serve objects publicly.
S3_BUCKET=
S3_REGION=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_PUBLIC_URL=
# Optional S3-compatible endpoint (uses path-style addressing).
S3_ENDPOINT=
# NSFW moderation is enabled by default. Set false (or 0/off) to disable it.
NSFW_CHECK_ENABLED=true
# Seconds to retain an upload after cleanup first detects that it is orphaned.
# Must be positive. Defaults to seven days when absent or blank.
IMAGE_UPLOAD_ORPHAN_RETENTION_SECONDS=604800
-7
View File
@@ -1,7 +0,0 @@
# Local development only. These credentials match compose.rustfs.yaml defaults.
S3_BUCKET=reminder-images
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=REMINDERDEV
S3_SECRET_ACCESS_KEY=reminder-rustfs-local-secret
S3_ENDPOINT=http://127.0.0.1:9000
S3_PUBLIC_URL=http://127.0.0.1:9000/reminder-images
-12676
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,84 @@
-- Drop all old tables
DROP TABLE IF EXISTS users_old;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS embeds;
DROP TABLE IF EXISTS embed_fields;
DROP TABLE IF EXISTS command_aliases;
DROP TABLE IF EXISTS macro;
DROP TABLE IF EXISTS command_restrictions;
DROP TABLE IF EXISTS roles;
SET FOREIGN_KEY_CHECKS = 0;
-- Drop columns from channels that are no longer used
ALTER TABLE channels DROP COLUMN `name`;
ALTER TABLE channels DROP COLUMN `blacklisted`;
-- Drop columns from guilds table that are no longer used and rebuild table
CREATE TABLE guilds_new (
id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
ephemeral_confirmations BOOLEAN NOT NULL DEFAULT 0
);
INSERT INTO guilds_new (id, ephemeral_confirmations) SELECT guild, ephemeral_confirmations FROM guilds;
RENAME TABLE guilds TO guilds_old;
RENAME TABLE guilds_new TO guilds;
-- Update fk on channels to point at new guild table
ALTER TABLE channels
DROP FOREIGN KEY `channels_ibfk_1`,
MODIFY COLUMN guild_id BIGINT UNSIGNED,
ADD FOREIGN KEY `fk_guild_id` (`guild_id`)
REFERENCES `guilds` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
UPDATE channels SET guild_id = (SELECT guild FROM guilds_old WHERE id = guild_id);
-- Rebuild todos table
CREATE TABLE `todos_new` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`guild_id` BIGINT UNSIGNED DEFAULT NULL,
`channel_id` INT UNSIGNED DEFAULT NULL,
`user_id` BIGINT UNSIGNED DEFAULT NULL,
`value` VARCHAR(2000) NOT NULL,
PRIMARY KEY (`id`),
FOREIGN KEY `fk_channel_id` (`channel_id`)
REFERENCES `channels` (`id`)
ON DELETE SET NULL
ON UPDATE CASCADE,
FOREIGN KEY `fk_guild_id` (`guild_id`)
REFERENCES `guilds` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
FOREIGN KEY `fk_user_id` (`user_id`)
REFERENCES `users` (`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
);
INSERT INTO todos_new (id, guild_id, channel_id, user_id, value) SELECT id, (SELECT guild FROM guilds_old WHERE id = guild_id), channel_id, user_id, value FROM todos;
RENAME TABLE todos TO todos_old;
RENAME TABLE todos_new TO todos;
-- Update fk on reminder_template to point at new guild table
ALTER TABLE reminder_template
DROP FOREIGN KEY `reminder_template_ibfk_1`,
MODIFY COLUMN guild_id BIGINT UNSIGNED NOT NULL,
ADD FOREIGN KEY `fk_guild_id` (`guild_id`)
REFERENCES `guilds` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
UPDATE reminder_template SET guild_id = (SELECT guild FROM guilds_old WHERE id = guild_id);
-- Update fk on command_macro to point at new guild table
ALTER TABLE command_macro
DROP FOREIGN KEY `command_macro_ibfk_1`,
MODIFY COLUMN guild_id BIGINT UNSIGNED NOT NULL,
ADD FOREIGN KEY `fk_guild_id` (`guild_id`)
REFERENCES `guilds` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
UPDATE command_macro SET guild_id = (SELECT guild FROM guilds_old WHERE id = guild_id);
DROP TABLE todos_old;
DROP TABLE guilds_old;
SET FOREIGN_KEY_CHECKS = 1;
@@ -1,11 +0,0 @@
CREATE TABLE patreon_link
(
user_id BIGINT UNSIGNED NOT NULL,
guild_id BIGINT UNSIGNED NOT NULL,
linked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id),
INDEX `idx_user_id` (user_id),
INDEX `idx_guild_id` (guild_id),
INDEX `idx_linked_at` (linked_at)
);
@@ -1,13 +0,0 @@
SET foreign_key_checks = 0;
-- Drop all old tables
DROP TABLE IF EXISTS users_old;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS embeds;
DROP TABLE IF EXISTS embed_fields;
DROP TABLE IF EXISTS command_aliases;
DROP TABLE IF EXISTS macro;
DROP TABLE IF EXISTS roles;
DROP TABLE IF EXISTS command_restrictions;
SET foreign_key_checks = 1;
@@ -1,10 +0,0 @@
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,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
orphaned_at DATETIME DEFAULT NULL,
INDEX image_uploads_uploader (uploader),
INDEX image_uploads_orphaned_at (orphaned_at)
);
+1 -2
View File
@@ -5,8 +5,7 @@
"scripts": {
"dev": "vite build --watch --mode development",
"build": "vite build",
"preview": "vite preview",
"prettier": "prettier -w src/"
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.5.1",
+1 -9
View File
@@ -9,7 +9,6 @@ enum ColorScheme {
type UserInfo = {
name: string;
patreon: boolean;
image_uploads: boolean;
preferences: {
timezone: string | null;
reset_inputs_on_create: boolean;
@@ -117,7 +116,7 @@ type Template = {
embed_fields: EmbedField[] | null;
};
const USER_INFO_STALE_TIME = 86_400_000;
const USER_INFO_STALE_TIME = 120_000;
const GUILD_INFO_STALE_TIME = 300_000;
const OTHER_STALE_TIME = 120_000;
@@ -127,13 +126,6 @@ export const fetchUserInfo = () => ({
staleTime: USER_INFO_STALE_TIME,
});
export const uploadImage = (file: File) =>
axios
.post<{ url: string }>("/dashboard/api/images", file, {
headers: { "Content-Type": "application/octet-stream" },
})
.then((response) => response.data.url);
export const patchUserInfo = () => ({
mutationFn: (userInfo: UpdateUserInfo) => axios.patch(`/dashboard/api/user`, userInfo),
});
@@ -1,43 +0,0 @@
import { createContext } from "preact";
import { useContext } from "preact/compat";
import { useEffect, useState } from "preact/hooks";
import { fetchUserInfo } from "../../api";
import { useQuery } from "react-query";
type TColorScheme = "light" | "dark";
type TColorSchemeContext = {
colorScheme: TColorScheme;
};
const ColorSchemeContext = createContext({ colorScheme: "light" } as TColorSchemeContext);
export const ColorSchemeProvider = ({ children }) => {
const { data } = useQuery({ ...fetchUserInfo() });
const [activeScheme, setActiveScheme] = useState<TColorScheme>("light");
useEffect(() => {
const preference = data?.preferences?.dashboard_color_scheme || "system";
if (preference === "system") {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
setActiveScheme(mediaQuery.matches ? "dark" : "light");
};
handleChange();
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
} else {
setActiveScheme(preference as TColorScheme);
}
}, [data]);
return (
<ColorSchemeContext.Provider value={{ colorScheme: activeScheme }}>
{children}
</ColorSchemeContext.Provider>
);
};
export const useColorScheme = () => useContext(ColorSchemeContext);
+20 -27
View File
@@ -8,36 +8,42 @@ import {TimezoneProvider} from "./TimezoneProvider";
import { User } from "../User";
import { GuildReminders } from "../Guild/GuildReminders";
import { GuildTodos } from "../Guild/GuildTodos";
import {ColorSchemeProvider, useColorScheme} from "./ColorSchemeProvider";
import {useEffect} from "preact/hooks";
const InnerApp = () => {
const {colorScheme} = useColorScheme();
export function App() {
const queryClient = new QueryClient();
useEffect(() => {
const body = document.querySelector("body");
body.className = body.className.replace(/scheme-\w+/g, "");
body.classList.add(`scheme-${colorScheme}`);
}, [colorScheme]);
let scheme = "light";
// if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
// scheme = "dark";
// }
return (
<QueryClientProvider client={queryClient}>
<TimezoneProvider>
<FlashProvider>
<Router base={"/dashboard"}>
<div class={`columns is-gapless dashboard-frame scheme-${colorScheme}`}>
<div class={`columns is-gapless dashboard-frame scheme-${scheme}`}>
<Sidebar />
<div class="column is-main-content">
<div style={{ margin: "0 12px 12px 12px" }}>
<Switch>
<Route path={"/@me/reminders"} component={User}></Route>
<Route path={"/:guild/reminders"}>
<Route
path={"/:guild/reminders"}
component={() => (
<Guild>
<GuildReminders />
</Guild>
</Route>
<Route path={"/:guild/todos"}>
)}
></Route>
<Route
path={"/:guild/todos"}
component={() => (
<Guild>
<GuildTodos />
</Guild>
</Route>
)}
></Route>
<Route>
<Welcome />
</Route>
@@ -46,21 +52,8 @@ const InnerApp = () => {
</div>
</div>
</Router>
);
};
const queryClient = new QueryClient();
export function App() {
return (
<QueryClientProvider client={queryClient}>
<ColorSchemeProvider>
<TimezoneProvider>
<FlashProvider>
<InnerApp/>
</FlashProvider>
</TimezoneProvider>
</ColorSchemeProvider>
</QueryClientProvider>
);
}
@@ -2,22 +2,14 @@ import { JSX } from "preact";
import {createPortal} from "preact/compat";
type Props = {
setModalOpen: (open: boolean) => void;
setModalOpen: (open: boolean) => never;
title: string | JSX.Element;
onSubmitText?: string;
onSubmit?: () => void;
onSubmitDisabled?: boolean;
children: string | JSX.Element | JSX.Element[] | (() => JSX.Element);
};
export const Modal = ({
setModalOpen,
title,
onSubmit,
onSubmitText,
onSubmitDisabled,
children,
}: Props) => {
export const Modal = ({setModalOpen, title, onSubmit, onSubmitText, children}: Props) => {
const body = document.querySelector("body");
return createPortal(
@@ -42,11 +34,7 @@ export const Modal = ({
<section class="modal-card-body">{children}</section>
{onSubmit && (
<footer class="modal-card-foot">
<button
class="button is-success"
onClick={onSubmit}
disabled={onSubmitDisabled}
>
<button class="button is-success" onClick={onSubmit}>
{onSubmitText || "Save"}
</button>
<button
@@ -1,65 +1,13 @@
import { Modal } from "../Modal";
import { useEffect, useRef, useState } from "preact/hooks";
import { useQuery } from "react-query";
import axios from "axios";
import { fetchUserInfo, uploadImage } from "../../api";
const useImageDrop = (enabled: boolean, onFiles: (files: File[]) => void, busy = false) => {
const [dragging, setDragging] = useState(false);
const dragDepth = useRef(0);
const acceptsDrag = (event: DragEvent) =>
enabled && event.dataTransfer?.types.includes("Files");
return {
dragging,
handlers: {
onDragEnter: (event: DragEvent) => {
if (!acceptsDrag(event)) return;
event.preventDefault();
event.stopPropagation();
dragDepth.current += 1;
if (!busy) setDragging(true);
},
onDragOver: (event: DragEvent) => {
if (!acceptsDrag(event)) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = busy ? "none" : "copy";
},
onDragLeave: () => {
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragging(false);
},
onDrop: (event: DragEvent) => {
if (!acceptsDrag(event)) return;
event.preventDefault();
event.stopPropagation();
dragDepth.current = 0;
setDragging(false);
if (!busy) onFiles(Array.from(event.dataTransfer.files));
},
},
};
};
import { useState } from "preact/hooks";
export const ImagePicker = ({ alt, url, setImage, ...props }) => {
const [modalOpen, setModalOpen] = useState(false);
const [droppedFiles, setDroppedFiles] = useState<File[] | null>(null);
const { data: userInfo } = useQuery(fetchUserInfo());
const uploadsEnabled = userInfo?.image_uploads === true;
const { dragging, handlers } = useImageDrop(uploadsEnabled, (files) => {
setDroppedFiles(files);
setModalOpen(true);
});
return (
<>
<a
{...handlers}
class={dragging ? "image-picker-dragging" : ""}
title={uploadsEnabled ? "Click to edit or drop an image to upload" : undefined}
onClick={() => {
setDroppedFiles(null);
setModalOpen(true);
}}
role={"button"}
@@ -70,64 +18,14 @@ export const ImagePicker = ({ alt, url, setImage, ...props }) => {
<ImagePickerModal
setModalOpen={setModalOpen}
setImage={setImage}
url={url}
uploadsEnabled={uploadsEnabled}
droppedFiles={droppedFiles}
></ImagePickerModal>
)}
</>
);
};
const ImagePickerModal = ({ setModalOpen, setImage, url, uploadsEnabled, droppedFiles }) => {
const [value, setValue] = useState(url || "");
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
const [blocked, setBlocked] = useState(false);
const fileInput = useRef<HTMLInputElement>(null);
const uploadInProgress = useRef(false);
const upload = async (files: File[]) => {
setBlocked(false);
if (!uploadsEnabled || uploadInProgress.current) return;
setError("");
if (files.length !== 1) {
setError("Please upload only one image.");
return;
}
const file = files[0];
if (file.size > 8 * 1024 * 1024) {
setError("Images must be no larger than 8 MiB.");
return;
}
uploadInProgress.current = true;
setUploading(true);
try {
setValue(await uploadImage(file));
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 451) {
setBlocked(true);
} else {
setError(
axios.isAxiosError(error) && typeof error.response?.data?.error === "string"
? error.response.data.error
: "Could not upload image. Please try again.",
);
}
} finally {
uploadInProgress.current = false;
setUploading(false);
}
};
const { dragging, handlers } = useImageDrop(
uploadsEnabled,
(files) => void upload(files),
uploading,
);
useEffect(() => {
if (droppedFiles) void upload(droppedFiles);
}, [droppedFiles]);
const ImagePickerModal = ({ setModalOpen, setImage }) => {
const [value, setValue] = useState("");
return (
<Modal
@@ -138,7 +36,6 @@ const ImagePickerModal = ({ setModalOpen, setImage, url, uploadsEnabled, dropped
setModalOpen(false);
}}
onSubmitText={"Save"}
onSubmitDisabled={uploading}
>
<p>
Please note: if you attach an image directly from Discord, it will not be visible in
@@ -149,65 +46,10 @@ const ImagePickerModal = ({ setModalOpen, setImage, url, uploadsEnabled, dropped
class="input"
id="urlInput"
placeholder="Image URL..."
value={value}
disabled={uploading}
onInput={(ev) => {
setValue(ev.currentTarget.value);
}}
></input>
{uploadsEnabled && (
<div
{...handlers}
class={`mt-4 image-upload-dropzone ${dragging ? "image-picker-dragging" : ""}`}
>
<p>or…</p>
<input
ref={fileInput}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
hidden
disabled={uploading}
aria-label="Choose an image to upload"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
if (file) void upload([file]);
}}
/>
<button
type="button"
class={`button is-info ${uploading ? "is-loading" : ""}`}
disabled={uploading}
onClick={() => fileInput.current?.click()}
>
Upload image
</button>
<p class="help">Or drag and drop an image here.</p>
<p class="help">
Static PNG, JPEG, GIF or WebP, up to 8 MiB. All uploads are converted to
PNGs.
</p>
{blocked && (
<>
<p class="help" style="color: red;">
This image was flagged as potentially NSFW. Unfortunately, due to
the UK's Online Safety Act, we cannot handle explicit content.
Please defer to using Imgur or another image storage platform who
have the resources to deal with idiotic laws.
</p>
<p class="help" style="color: red;">
The image you attempted to upload has not been stored.
</p>
</>
)}
{uploading && <p role="status">Uploading image…</p>}
</div>
)}
{error && (
<p class="help is-danger" role="alert">
{error}
</p>
)}
</Modal>
);
};
@@ -34,24 +34,3 @@ div.reminderContent {
background-color: #f5f5f5;
border-radius: 8px;
}
.image-upload-dropzone {
border: 2px dashed #b5b5b5;
border-radius: 4px;
padding: 1rem;
text-align: center;
}
.image-picker-dragging {
outline: 3px solid #3e8ed0;
outline-offset: 2px;
img {
opacity: 0.6;
}
&.image-upload-dropzone {
background-color: #eff5fb;
border-color: #3e8ed0;
}
}
@@ -5,12 +5,6 @@ import { useRef, useState } from "preact/hooks";
import { ICON_FLASH_TIME } from "../../consts";
import { useFlash } from "../App/FlashContext";
enum ColorScheme {
System = "system",
Dark = "dark",
Light = "light",
}
export const UserPreferences = () => {
const [modalOpen, setModalOpen] = useState(false);
@@ -102,40 +96,6 @@ const PreferencesModal = ({ setModalOpen }) => {
</label>
</div>
<br></br>
<div style={{ display: "flex", flexDirection: "row", alignContent: "center" }}>
<label>
<div class={"is-inline-block"} style={{ marginRight: "6px" }}>
Dashboard Color Scheme:
</div>
<div class={"control"}>
<div class={"is-inline-block select"}>
{isLoading && <i class={"fa fa-spinner"} />}
{isSuccess && (
<select
class={"channel-selector"}
value={
updatedSettings.dashboard_color_scheme === undefined
? data.preferences.dashboard_color_scheme
: updatedSettings.dashboard_color_scheme
}
onChange={(e) =>
setUpdatedSettings((s) => ({
...s,
dashboard_color_scheme: (e.target as HTMLSelectElement)
.value as ColorScheme,
}))
}
>
<option value={ColorScheme.System}>System default</option>
<option value={ColorScheme.Light}>Light</option>
<option value={ColorScheme.Dark}>Dark</option>
</select>
)}
</div>
</div>
</label>
</div>
<br></br>
<div class="has-text-centered">
<button
class="button is-success is-outlined"
@@ -14,9 +14,6 @@ export const Welcome = () => (
<p>
<strong>Please report bugs!</strong> I can't fix issues if I am unaware of them.
</p>
<p>
Client version: {import.meta.env.VITE_VERSION}
</p>
</div>
</section>
);
-222
View File
@@ -57,225 +57,3 @@ div.reminderContent.is-collapsed .hide-box i {
.button.is-outlined.is-success:not(.is-focused, :hover, :focus) {
background-color: white;
}
@import "vars";
.scheme-light {
background-color: $primary-background-light;
color: $primary-text-light;
.column.is-main-content {
background-color: $primary-background-light;
}
.reminderContent {
background-color: $secondary-background-light;
}
}
.scheme-dark {
background-color: $secondary-background-dark;
color: $primary-text-dark;
.column.is-main-content {
background-color: $secondary-background-dark;
}
.reminderContent {
background-color: $primary-background-dark;
color: $primary-text-dark;
}
.title,
.subtitle,
.label,
.help,
strong {
color: $primary-text-dark;
}
input,
textarea,
select,
.input,
.textarea {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
&::placeholder {
color: #aaa;
}
}
// Buttons
.button.is-outlined.is-success:not(.is-focused, :hover, :focus) {
background-color: $primary-background-dark;
color: #48c78e;
border-color: #48c78e;
}
.button.is-outlined.is-danger:not(.is-focused, :hover, :focus) {
background-color: $primary-background-dark;
}
.button.is-outlined.is-warning:not(.is-focused, :hover, :focus) {
background-color: $primary-background-dark;
}
.button:not(.is-success, .is-danger, .is-warning, .is-light) {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
.button.is-light {
background-color: $secondary-background-dark;
color: $primary-text-dark;
}
// Modal
.modal-card-head,
.modal-card-foot {
background-color: $contrast-background-dark;
border-color: $contrast-background-dark;
}
.modal-card-head .modal-card-title {
color: $primary-text-dark;
}
.modal-card-body {
background-color: $primary-background-dark;
color: $primary-text-dark;
}
// Navbar
.navbar {
background-color: $contrast-background-dark;
}
.navbar-item,
.navbar-burger {
color: $primary-text-dark;
}
// Select dropdown arrow
.select:not(.is-multiple):not(.is-loading)::after {
border-color: $primary-text-dark;
}
.select select {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
// Interval selector inputs
.interval-group input {
background-color: $secondary-background-dark !important;
color: $primary-text-dark;
}
// Notification
.notification {
background-color: $secondary-background-dark;
color: $primary-text-dark;
}
// Links
a:not(.menu a):not(.button) {
color: #7eaef1;
}
// Content area
.content {
color: $primary-text-dark;
}
// Checkbox
.checkbox {
color: $primary-text-dark;
}
// Message input
.message-input {
background-color: $secondary-background-dark;
color: $primary-text-dark;
}
// Box
.box {
background-color: $secondary-background-dark;
color: $primary-text-dark;
}
// Panel
.panel {
background-color: $secondary-background-dark;
.panel-heading {
background-color: $contrast-background-dark;
color: $primary-text-dark;
}
.panel-block {
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
}
// Horizontal rule
hr {
background-color: $contrast-background-dark;
}
// Tag
.tag:not(.is-success, .is-danger, .is-warning, .is-info) {
background-color: $contrast-background-dark;
color: $primary-text-dark;
}
// Todo
.todo {
color: $primary-text-dark;
}
// Page links
.page-links .button {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
// Loading overlay
.load-screen {
background-color: rgba(36, 36, 36, 0.8);
color: $primary-text-dark;
}
// File/attachment input
.file-cta {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
.file-name {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
// Date/time inputs color scheme
input[type="date"],
input[type="time"],
input[type="datetime-local"] {
color-scheme: dark;
}
// Modal background overlay
.modal-background {
background-color: rgba(10, 10, 10, 0.86);
}
}
-29
View File
@@ -1,29 +0,0 @@
#!/bin/sh
set -eu
endpoint=http://rustfs:9000
if ! aws --endpoint-url "$endpoint" s3api head-bucket --bucket "$S3_BUCKET" >/dev/null 2>&1; then
if [ "$AWS_DEFAULT_REGION" = us-east-1 ]; then
aws --endpoint-url "$endpoint" s3api create-bucket --bucket "$S3_BUCKET"
else
aws --endpoint-url "$endpoint" s3api create-bucket --bucket "$S3_BUCKET" \
--create-bucket-configuration "LocationConstraint=$AWS_DEFAULT_REGION"
fi
fi
# Dashboard images need public object reads. Listing and writes remain authenticated.
cat > /tmp/public-images-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::$S3_BUCKET/uploads/*"
}]
}
EOF
aws --endpoint-url "$endpoint" s3api put-bucket-policy --bucket "$S3_BUCKET" \
--policy file:///tmp/public-images-policy.json
echo "RustFS development bucket ready: $S3_BUCKET"
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env python3
"""Split an image into two lossless, random-looking PNG fixture shares."""
from __future__ import annotations
import argparse
from io import BytesIO
from pathlib import Path
import secrets
from PIL import Image
def split_image(input_path: Path, output_dir: Path, index: int | None = None) -> tuple[Path, Path]:
if index is None:
index = 1
while any((output_dir / f"image_{index}{part}.png").exists() for part in ("a", "b")):
index += 1
if index < 1:
raise ValueError("Image index must be a positive integer")
paths = (
output_dir / f"image_{index}a.png",
output_dir / f"image_{index}b.png",
)
for path in paths:
if path.exists():
raise FileExistsError(f"Refusing to overwrite {path}; choose another --index")
# RGBA shares preserve every decoded pixel, including transparency. For animated
# inputs, use the first frame, matching the dashboard's static image upload behavior.
with Image.open(input_path) as source:
image = source.convert("RGBA")
pixels = image.tobytes()
first = secrets.token_bytes(len(pixels))
second = bytes(pixel ^ mask for pixel, mask in zip(pixels, first))
output_dir.mkdir(parents=True, exist_ok=True)
for path, share in zip(paths, (first, second)):
# Exclusive creation also prevents overwriting a file created during splitting.
encoded = BytesIO()
Image.frombytes("RGBA", image.size, share).save(encoded, format="PNG")
with path.open("xb") as output:
output.write(encoded.getvalue())
return paths
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input_image", type=Path)
parser.add_argument("--output-dir", type=Path, default=Path("."))
parser.add_argument("--index", type=int, help="Fixture number (default: first unused positive index)")
args = parser.parse_args()
try:
paths = split_image(args.input_image, args.output_dir, args.index)
except (OSError, ValueError) as error:
parser.exit(1, f"Could not split image: {error}\n")
for path in paths:
print(path)
if __name__ == "__main__":
main()
+2 -9
View File
@@ -3,7 +3,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
use chrono_tz::TZ_VARIANTS;
use poise::serenity_prelude::AutocompleteChoice;
use crate::time_parser::cron_next_timestamp;
use crate::{models::CtxData, time_parser::natural_parser, Context};
pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
@@ -25,7 +24,7 @@ pub async fn macro_name_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<Str
SELECT name
FROM command_macro
WHERE
guild_id = (SELECT id FROM guilds WHERE guild = ?)
guild_id = ?
AND name LIKE CONCAT(?, '%')
",
ctx.guild_id().unwrap().get(),
@@ -43,13 +42,7 @@ pub async fn time_hint_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<Auto
if partial.is_empty() {
vec![AutocompleteChoice::new("Start typing a time...".to_string(), "now".to_string())]
} else {
let timezone = ctx.timezone().await;
let timestamp = match cron_next_timestamp(partial, timezone) {
Some(ts) => Some(ts),
None => natural_parser(partial, &timezone.to_string()).await,
};
match timestamp {
match natural_parser(partial, &ctx.timezone().await.to_string()).await {
Some(timestamp) => match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(now) => {
let diff = timestamp - now.as_secs() as i64;
+4 -6
View File
@@ -17,12 +17,10 @@ pub async fn delete_macro(
) -> Result<(), Error> {
match sqlx::query!(
"
SELECT m.id
FROM command_macro m
INNER JOIN guilds
ON guilds.id = m.guild_id
WHERE guild = ?
AND m.name = ?
SELECT id
FROM command_macro
WHERE guild_id = ?
AND name = ?
",
ctx.guild_id().unwrap().get(),
name
+1 -1
View File
@@ -32,7 +32,7 @@ pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
let json = serde_json::to_string(&command_macro.commands).unwrap();
sqlx::query!(
"INSERT INTO command_macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
"INSERT INTO command_macro (guild_id, name, description, commands) VALUES (?, ?, ?, ?)",
command_macro.guild_id.get(),
command_macro.name,
command_macro.description,
+3 -3
View File
@@ -35,7 +35,7 @@ pub async fn record_macro(
"
SELECT 1 as _e
FROM command_macro
WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)
WHERE guild_id = ?
AND name = ?
",
guild_id.get(),
@@ -75,8 +75,8 @@ Please select a unique name for your macro.",
CreateEmbed::new()
.title("Macro Recording Started")
.description(
"Run up to 5 commands to record in this macro. Use `/macro finish` to stop recording at any point.
Any commands performed during recording won't take any actual action- they are only captured for the macro.",
"Run up to 5 commands, or type `/macro finish` to stop at any point.
Any commands ran as part of recording will be inconsequential",
)
.color(*THEME_COLOR),
),
@@ -15,8 +15,8 @@ impl Recordable for Options {
let footer = footer(ctx);
ctx.send(CreateReply::default().embed(CreateEmbed::new().title("Donate")
.description("Thinking of subscribing?
Click below for my Patreon and official bot server
.description("Thinking of adding a monthly contribution?
Click below for my Patreon and official bot server :)
**https://www.patreon.com/jellywx/**
**https://discord.jellywx.com/**
@@ -26,7 +26,7 @@ With your new rank, you'll be able to:
• Set repeating reminders with `/remind` or the dashboard
• Use unlimited uploads on SoundFX
Members of servers you __own__ will be able to set repeating reminders via commands. You can also choose to share your membership with one other server.
(Also, members of servers you __own__ will be able to set repeating reminders via commands)
Just $2 USD/month!
@@ -41,8 +41,8 @@ Just $2 USD/month!
}
}
/// Show Patreon information
#[poise::command(slash_command, rename = "info", identifying_name = "patreon_info")]
pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
/// Details on supporting the bot and Patreon benefits
#[poise::command(slash_command, rename = "patreon", identifying_name = "patreon")]
pub async fn command(ctx: Context<'_>) -> Result<(), Error> {
(Options {}).run(ctx).await
}
+3 -2
View File
@@ -56,7 +56,7 @@ impl Recordable for Options {
}),
};
let channel_id = if let Some(channel) = ctx.cache().channel(ctx.channel_id()) {
let channel_id = if let Some(channel) = ctx.channel_id().to_channel_cached(&ctx.cache()) {
if Some(channel.guild_id) == ctx.guild_id() {
flags.channel_id.unwrap_or_else(|| ctx.channel_id())
} else {
@@ -66,7 +66,8 @@ impl Recordable for Options {
ctx.channel_id()
};
let channel_name = ctx.cache().channel(channel_id).map(|channel| channel.name.clone());
let channel_name =
channel_id.to_channel_cached(&ctx.cache()).map(|channel| channel.name.clone());
let reminders = Reminder::from_channel(&ctx.data().database, channel_id, &flags).await;
+2 -2
View File
@@ -12,6 +12,8 @@ pub mod dashboard;
#[cfg(not(test))]
pub mod delete;
#[cfg(not(test))]
pub mod donate;
#[cfg(not(test))]
pub mod help;
#[cfg(not(test))]
pub mod info;
@@ -24,8 +26,6 @@ pub mod nudge;
#[cfg(not(test))]
pub mod offset;
#[cfg(not(test))]
pub mod patreon;
#[cfg(not(test))]
pub mod pause;
#[cfg(not(test))]
pub mod remind;
-57
View File
@@ -1,57 +0,0 @@
use poise::CreateReply;
use serde::{Deserialize, Serialize};
use crate::{
utils::{check_user_subscription, Extract, Recordable},
Context, Error,
};
#[derive(Serialize, Deserialize, Extract)]
pub struct Options;
impl Recordable for Options {
async fn run(self, ctx: Context<'_>) -> Result<(), Error> {
let guild_id = ctx.guild_id().ok_or("This command must be used in a server")?;
let user_id = ctx.author().id;
// Check if user has Patreon subscription
if !check_user_subscription(ctx, user_id).await {
ctx.send(CreateReply::default()
.content("❌ You must be a Patreon subscriber to use this command. Use `/patreon info` for more information.")
.ephemeral(true)
).await?;
return Ok(());
}
// Insert or update the patreon_link entry
sqlx::query!(
"INSERT INTO patreon_link (user_id, guild_id, linked_at) VALUES (?, ?, NOW())
ON DUPLICATE KEY UPDATE guild_id = ?",
user_id.get(),
guild_id.get(),
guild_id.get()
)
.execute(&ctx.data().database)
.await?;
ctx.send(
CreateReply::default()
.content("✅ Successfully linked your Patreon subscription to this server!")
.ephemeral(true),
)
.await?;
Ok(())
}
}
/// Link your Patreon subscription to this server to allow other users Patreon access.
#[poise::command(
slash_command,
rename = "link",
identifying_name = "patreon_link",
guild_only = true
)]
pub async fn link(ctx: Context<'_>) -> Result<(), Error> {
(Options {}).run(ctx).await
}
-11
View File
@@ -1,11 +0,0 @@
pub mod info;
pub mod link;
pub mod unlink;
use crate::{Context, Error};
/// Manage Patreon subscription features
#[poise::command(slash_command, rename = "patreon", identifying_name = "patreon")]
pub async fn command(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
-50
View File
@@ -1,50 +0,0 @@
use poise::CreateReply;
use serde::{Deserialize, Serialize};
use crate::{
utils::{Extract, Recordable},
Context, Error,
};
#[derive(Serialize, Deserialize, Extract)]
pub struct Options;
impl Recordable for Options {
async fn run(self, ctx: Context<'_>) -> Result<(), Error> {
let user_id = ctx.author().id;
// Remove the patreon_link entry
let result = sqlx::query!("DELETE FROM patreon_link WHERE user_id = ?", user_id.get())
.execute(&ctx.data().database)
.await?;
if result.rows_affected() > 0 {
ctx.send(
CreateReply::default()
.content("✅ Successfully unlinked your Patreon subscription!")
.ephemeral(true),
)
.await?;
} else {
ctx.send(
CreateReply::default()
.content("❌ No existing Patreon link found.")
.ephemeral(true),
)
.await?;
}
Ok(())
}
}
/// Unlink your Patreon subscription
#[poise::command(
slash_command,
rename = "unlink",
identifying_name = "patreon_unlink",
guild_only = false
)]
pub async fn unlink(ctx: Context<'_>) -> Result<(), Error> {
(Options {}).run(ctx).await
}
+1 -1
View File
@@ -21,7 +21,7 @@ impl Recordable for Options {
"
INSERT INTO todos (guild_id, channel_id, value)
VALUES (
(SELECT id FROM guilds WHERE guild = ?),
?,
(SELECT id FROM channels WHERE channel = ?),
?
)
+1 -1
View File
@@ -18,7 +18,7 @@ impl Recordable for Options {
"
INSERT INTO todos (guild_id, value)
VALUES (
(SELECT id FROM guilds WHERE guild = ?), ?
?, ?
)
",
ctx.guild_id().unwrap().get(),
+2 -3
View File
@@ -13,9 +13,8 @@ impl Recordable for Options {
async fn run(self, ctx: Context<'_>) -> Result<(), Error> {
let values = sqlx::query!(
"
SELECT todos.id, value FROM todos
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?
SELECT id, value FROM todos
WHERE guild_id = ?
",
ctx.guild_id().unwrap().get(),
)
+6 -8
View File
@@ -63,7 +63,7 @@ impl ComponentDataModel {
let flags = pager.flags;
let channel_id = {
let channel_opt = ctx.cache.channel(component.channel_id);
let channel_opt = component.channel_id.to_channel_cached(&ctx.cache);
if let Some(channel) = channel_opt {
if Some(channel.guild_id) == component.guild_id {
@@ -85,7 +85,7 @@ impl ComponentDataModel {
.div_ceil(EMBED_DESCRIPTION_MAX_LENGTH);
let channel_name =
ctx.cache.channel(channel_id).map(|channel| channel.name.clone());
channel_id.to_channel_cached(&ctx.cache).map(|channel| channel.name.clone());
let next_page = pager.next_page(pages);
@@ -219,9 +219,8 @@ impl ComponentDataModel {
} else {
sqlx::query!(
"
SELECT todos.id, value FROM todos
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?
SELECT id, value FROM todos
WHERE guild_id = ?
",
pager.guild_id,
)
@@ -311,9 +310,8 @@ impl ComponentDataModel {
} else {
sqlx::query!(
"
SELECT todos.id, value FROM todos
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?
SELECT id, value FROM todos
WHERE guild_id = ?
",
selector.guild_id,
)
+2 -2
View File
@@ -23,7 +23,7 @@ pub async fn listener(
if is_new.unwrap_or(false) {
let guild_id = guild.id.get().to_owned();
sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id)
sqlx::query!("INSERT IGNORE INTO guilds (id) VALUES (?)", guild_id)
.execute(&data.database)
.await?;
@@ -56,7 +56,7 @@ To stay up to date on the latest features and fixes, join our [Discord](https://
}
FullEvent::GuildDelete { incomplete, .. } => {
if !incomplete.unavailable {
let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.get())
let _ = sqlx::query!("DELETE FROM guilds WHERE id = ?", incomplete.id.get())
.execute(&data.database)
.await;
}
+12 -45
View File
@@ -1,6 +1,4 @@
use crate::consts::THEME_COLOR;
use poise::{serenity_prelude::CreateEmbed, CommandInteractionType, CreateReply};
use serenity::builder::CreateEmbedFooter;
use poise::{CommandInteractionType, CreateReply};
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
@@ -20,18 +18,7 @@ async fn macro_check(ctx: Context<'_>) -> bool {
.send(
CreateReply::default()
.ephemeral(true)
.embed(CreateEmbed::new()
.title("💾 Currently recording macro")
.description(
format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS),
)
.footer(
CreateEmbedFooter::new(
"Any commands performed during recording won't take any actual action- they are only captured for the macro"
)
)
.color(*THEME_COLOR),
),
.content(format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS))
)
.await;
} else {
@@ -41,19 +28,9 @@ async fn macro_check(ctx: Context<'_>) -> bool {
let _ = ctx
.send(
CreateReply::default().ephemeral(true).embed(
CreateEmbed::new()
.title("💾 Currently recording macro")
.description(
"Command recorded. Use `/macro finish` to end recording.",
)
.footer(
CreateEmbedFooter::new(
"Any commands performed during recording won't take any actual action- they are only captured for the macro"
)
)
.color(*THEME_COLOR),
),
CreateReply::default()
.ephemeral(true)
.content("Command recorded to macro"),
)
.await;
}
@@ -61,18 +38,8 @@ async fn macro_check(ctx: Context<'_>) -> bool {
None => {
let _ = ctx
.send(
CreateReply::default().ephemeral(true).embed(
CreateEmbed::new()
.title("💾 Currently recording macro")
.description(
"This command is not supported in macros, so it hasn't been recorded. Use `/macro finish` to end recording.",
)
.footer(
CreateEmbedFooter::new(
"Any commands performed during recording won't take any actual action- they are only captured for the macro"
)
)
.color(*THEME_COLOR),
CreateReply::default().ephemeral(true).content(
"This command is not supported in macros yet.",
),
)
.await;
@@ -107,7 +74,6 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
return if permissions.send_messages()
&& permissions.embed_links()
&& manage_webhooks
&& permissions.view_channel()
{
true
} else {
@@ -115,13 +81,12 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
.send(CreateReply::default().content(format!(
"The bot appears to be missing some permissions:
{} **View Channels**
{} **Send Message**
{} **Embed Links**
{} **Manage Webhooks**
Please check the bot's roles, and any channel overrides. Alternatively, giving the bot \"Administrator\" will bypass permission checks",
if permissions.view_channel() { "✅" } else { "❌" },
Please check the bot's roles, and any channel overrides. Alternatively, giving the bot
\"Administrator\" will bypass permission checks",
if permissions.send_messages() { "✅" } else { "❌" },
if permissions.embed_links() { "✅" } else { "❌" },
if manage_webhooks { "✅" } else { "❌" },
@@ -135,7 +100,9 @@ Please check the bot's roles, and any channel overrides. Alternatively, giving t
manage_webhooks
}
None => true,
None => {
return true;
}
}
}
-165
View File
@@ -1,165 +0,0 @@
use std::env;
use log::{info, warn};
use sqlx::{Acquire, MySql, Pool};
use crate::{web::image_uploads::ImageUploadService, Error};
const DEFAULT_RETENTION_SECONDS: u32 = 7 * 24 * 60 * 60;
const BATCH_SIZE: u32 = 1000;
const IMAGE_COLUMNS: &str =
"avatar, embed_image_url, embed_thumbnail_url, embed_author_url, embed_footer_url";
fn binary_image_columns() -> String {
// Existing URL columns and image_uploads use different collations. S3 URLs must
// also match case-sensitively, so compare their bytes rather than text collation.
IMAGE_COLUMNS
.split(", ")
.map(|column| format!("BINARY {column}"))
.collect::<Vec<_>>()
.join(", ")
}
fn retention_seconds(value: Option<&str>) -> Result<u32, Error> {
match value.map(str::trim).filter(|value| !value.is_empty()) {
None => Ok(DEFAULT_RETENTION_SECONDS),
Some(value) => value.parse::<u32>().ok().filter(|value| *value > 0).ok_or_else(|| {
"IMAGE_UPLOAD_ORPHAN_RETENTION_SECONDS must be a positive integer of seconds".into()
}),
}
}
// 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)
}
async fn mark_orphans(pool: &Pool<MySql>) -> Result<(), Error> {
let columns = binary_image_columns();
sqlx::query(&format!(
"UPDATE image_uploads AS upload SET orphaned_at = CASE
WHEN NOT EXISTS (SELECT 1 FROM reminders
WHERE BINARY upload.url IN ({columns}))
AND NOT EXISTS (SELECT 1 FROM reminder_template
WHERE BINARY upload.url IN ({columns}))
THEN COALESCE(upload.orphaned_at, UTC_TIMESTAMP(6)) ELSE NULL END"
))
.execute(pool)
.await?;
Ok(())
}
pub(crate) async fn run(pool: &Pool<MySql>) -> Result<(), Error> {
let retention =
retention_seconds(env::var("IMAGE_UPLOAD_ORPHAN_RETENTION_SECONDS").ok().as_deref())?;
let Some(storage) = ImageUploadService::for_cleanup()? else {
info!("Image cleanup skipped: S3 uploads are not configured");
return Ok(());
};
cleanup(pool, &storage, retention).await
}
async fn cleanup(
pool: &Pool<MySql>,
storage: &ImageUploadService,
retention: u32,
) -> Result<(), Error> {
mark_orphans(pool).await?;
let candidates: Vec<u64> = sqlx::query_scalar(
"SELECT id FROM image_uploads
WHERE orphaned_at <= UTC_TIMESTAMP(6) - INTERVAL ? SECOND
ORDER BY orphaned_at, id LIMIT ?",
)
.bind(retention)
.bind(BATCH_SIZE)
.fetch_all(pool)
.await?;
let mut deleted = 0;
let mut failed = 0;
for id in candidates {
match delete_orphan(pool, storage, retention, id).await {
Ok(true) => deleted += 1,
Ok(false) => {}
Err(error) => {
failed += 1;
warn!("Could not clean up image upload {}: {}", id, error);
}
}
}
info!("Image cleanup deleted {} uploads; {} failed", deleted, failed);
if failed > 0 {
return Err(format!("Image cleanup failed for {failed} uploads; retained for retry").into());
}
Ok(())
}
async fn delete_orphan(
pool: &Pool<MySql>,
storage: &ImageUploadService,
retention: u32,
id: u64,
) -> Result<bool, Error> {
let mut connection = pool.acquire().await?;
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
.execute(&mut *connection)
.await?;
let mut transaction = connection.begin().await?;
// Serialize overlapping cleanup jobs and recheck the retention time under the row lock.
let url: Option<String> = sqlx::query_scalar(
"SELECT url FROM image_uploads
WHERE id = ? AND orphaned_at <= UTC_TIMESTAMP(6) - INTERVAL ? SECOND
FOR UPDATE",
)
.bind(id)
.bind(retention)
.fetch_optional(&mut *transaction)
.await?;
let Some(url) = url 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")?;
for table in ["reminders", "reminder_template"] {
// Locking reads see current committed references, rather than a stale snapshot.
// InnoDB's REPEATABLE READ next-key locks also prevent a reference from
// being inserted while the object is being removed. Keep this transaction short:
// it covers one S3 request with the upload service's 30-second timeout.
let columns = binary_image_columns();
let referenced: Option<u32> = sqlx::query_scalar(&format!(
"SELECT id FROM {table} WHERE BINARY ? IN ({columns}) LIMIT 1 FOR UPDATE"
))
.bind(&url)
.fetch_optional(&mut *transaction)
.await?;
if referenced.is_some() {
sqlx::query("UPDATE image_uploads SET orphaned_at = NULL WHERE id = ?")
.bind(id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
return Ok(false);
}
}
let response = storage.bucket.delete_object(key).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());
}
sqlx::query("DELETE FROM image_uploads WHERE id = ?")
.bind(id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(true)
}
#[cfg(test)]
mod tests;
-258
View File
@@ -1,258 +0,0 @@
use std::{sync::Arc, time::Duration};
use chrono::NaiveDateTime;
use s3::{creds::Credentials, Bucket, Region};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
sync::Semaphore,
task::JoinHandle,
};
use super::*;
const BASE_URL: &str = "https://images.example.com/bucket";
fn upload_url(number: u64) -> String {
format!("{BASE_URL}/uploads/{number:064x}.png")
}
async fn upload(pool: &Pool<MySql>, number: u64) -> u64 {
sqlx::query("INSERT INTO image_uploads (uploader, url) VALUES (123, ?)")
.bind(upload_url(number))
.execute(pool)
.await
.unwrap()
.last_insert_id()
}
async fn orphaned_at(pool: &Pool<MySql>, id: u64) -> Option<NaiveDateTime> {
sqlx::query_scalar("SELECT orphaned_at FROM image_uploads WHERE id = ?")
.bind(id)
.fetch_one(pool)
.await
.unwrap()
}
async fn expire(pool: &Pool<MySql>, id: u64) {
sqlx::query(
"UPDATE image_uploads SET orphaned_at = UTC_TIMESTAMP(6) - INTERVAL 2 DAY WHERE id = ?",
)
.bind(id)
.execute(pool)
.await
.unwrap();
}
async fn reference_tables(pool: &Pool<MySql>) -> (u64, u64) {
let guild = sqlx::query("INSERT INTO guilds (guild) VALUES (123)")
.execute(pool)
.await
.unwrap()
.last_insert_id();
let channel = sqlx::query("INSERT INTO channels (channel, guild_id) VALUES (123, ?)")
.bind(guild)
.execute(pool)
.await
.unwrap()
.last_insert_id();
(guild, channel)
}
async fn storage(status: u16) -> (ImageUploadService, JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (mut socket, _) =
tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().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]);
}
socket
.write_all(
format!("HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
.as_bytes(),
)
.await
.unwrap();
String::from_utf8(received).unwrap()
});
let credentials = Credentials::new(Some("test"), Some("test"), None, None, None).unwrap();
let mut bucket =
Bucket::new("images", Region::Custom { region: "local".into(), endpoint }, credentials)
.unwrap()
.with_path_style();
bucket.set_request_timeout(Some(Duration::from_secs(2)));
(
ImageUploadService {
bucket,
public_url: BASE_URL.into(),
model: None,
slots: Arc::new(Semaphore::new(2)),
},
task,
)
}
#[test]
fn retention_defaults_and_rejects_invalid_settings() {
for value in [None, Some(""), Some(" ")] {
assert_eq!(retention_seconds(value).unwrap(), 604800);
}
assert_eq!(retention_seconds(Some(" 3600 ")).unwrap(), 3600);
for value in ["0", "-1", "1.5", "forever", "4294967296"] {
assert!(retention_seconds(Some(value)).is_err());
}
}
#[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"),
] {
assert!(object_key(BASE_URL, &url).is_none(), "{url}");
}
}
#[sqlx::test]
async fn orphan_timestamp_survives_repeated_runs_and_resets_on_reuse(pool: Pool<MySql>) {
let id = upload(&pool, 1).await;
assert!(orphaned_at(&pool, id).await.is_none());
mark_orphans(&pool).await.unwrap();
let first = orphaned_at(&pool, id).await.unwrap();
mark_orphans(&pool).await.unwrap();
assert_eq!(orphaned_at(&pool, id).await, Some(first));
let (guild, _) = reference_tables(&pool).await;
let template =
sqlx::query("INSERT INTO reminder_template (guild_id, embed_image_url) VALUES (?, ?)")
.bind(guild)
.bind(upload_url(1))
.execute(&pool)
.await
.unwrap()
.last_insert_id();
mark_orphans(&pool).await.unwrap();
assert!(orphaned_at(&pool, id).await.is_none());
sqlx::query("DELETE FROM reminder_template WHERE id = ?")
.bind(template)
.execute(&pool)
.await
.unwrap();
mark_orphans(&pool).await.unwrap();
assert!(orphaned_at(&pool, id).await.unwrap() > first);
}
#[sqlx::test]
async fn protects_all_image_fields_in_reminders_and_templates(pool: Pool<MySql>) {
let (guild, channel) = reference_tables(&pool).await;
let mut ids = Vec::new();
for (index, column) in IMAGE_COLUMNS.split(", ").enumerate() {
for table in ["reminders", "reminder_template"] {
let number = ids.len() as u64 + 1;
ids.push(upload(&pool, number).await);
let query = if table == "reminders" {
// Archived/disabled reminders still retain their image URLs.
format!(
"INSERT INTO reminders (uid, channel_id, `utc_time`, status, enabled, {column})
VALUES ('field-{index}', {channel}, UTC_TIMESTAMP(), 'sent', 0, ?)"
)
} else {
format!("INSERT INTO reminder_template (guild_id, {column}) VALUES ({guild}, ?)")
};
sqlx::query(&query).bind(upload_url(number)).execute(&pool).await.unwrap();
}
}
let unused = upload(&pool, 100).await;
// Previously orphaned files are unmarked as soon as cleanup observes a reference.
for id in &ids {
expire(&pool, *id).await;
}
mark_orphans(&pool).await.unwrap();
for id in ids {
assert!(orphaned_at(&pool, id).await.is_none());
}
assert!(orphaned_at(&pool, unused).await.is_some());
}
#[sqlx::test]
async fn only_expired_orphans_are_deleted_and_rows_removed_after_success(pool: Pool<MySql>) {
let old = upload(&pool, 1).await;
let fresh = upload(&pool, 2).await;
expire(&pool, old).await;
let (storage, request) = storage(204).await;
cleanup(&pool, &storage, 86400).await.unwrap();
assert!(request.await.unwrap().starts_with(&format!("DELETE /images/uploads/{:064x}.png ", 1)));
let ids: Vec<u64> = sqlx::query_scalar("SELECT id FROM image_uploads ORDER BY id")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(ids, vec![fresh]);
assert!(orphaned_at(&pool, fresh).await.is_some());
}
#[sqlx::test]
async fn failed_deletions_keep_the_original_timestamp_and_retry_missing_objects(pool: Pool<MySql>) {
let id = upload(&pool, 1).await;
expire(&pool, id).await;
let original = orphaned_at(&pool, id).await;
let (failed_storage, failed_request) = storage(403).await;
assert!(cleanup(&pool, &failed_storage, 86400).await.is_err());
failed_request.await.unwrap();
assert_eq!(orphaned_at(&pool, id).await, original);
let (retry_storage, retry_request) = storage(404).await;
cleanup(&pool, &retry_storage, 86400).await.unwrap();
retry_request.await.unwrap();
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM image_uploads").fetch_one(&pool).await.unwrap();
assert_eq!(count, 0);
}
#[sqlx::test]
async fn rechecks_references_before_deleting_a_candidate(pool: Pool<MySql>) {
let id = upload(&pool, 1).await;
expire(&pool, id).await;
let (guild, _) = reference_tables(&pool).await;
sqlx::query("INSERT INTO reminder_template (guild_id, avatar) VALUES (?, ?)")
.bind(guild)
.bind(upload_url(1))
.execute(&pool)
.await
.unwrap();
let (storage, request) = storage(204).await;
assert!(!delete_orphan(&pool, &storage, 86400, id).await.unwrap());
assert!(orphaned_at(&pool, id).await.is_none());
request.abort();
}
#[sqlx::test]
async fn foreign_urls_are_retained_without_contacting_s3(pool: Pool<MySql>) {
let id = upload(&pool, 1).await;
sqlx::query(
"UPDATE image_uploads SET url = 'https://elsewhere.example.com/file.png' WHERE id = ?",
)
.bind(id)
.execute(&pool)
.await
.unwrap();
expire(&pool, id).await;
let (storage, request) = storage(204).await;
assert!(cleanup(&pool, &storage, 86400).await.is_err());
assert!(orphaned_at(&pool, id).await.is_some());
request.abort();
}
+3 -12
View File
@@ -9,7 +9,6 @@ mod consts;
mod event_handlers;
#[cfg(not(test))]
mod hooks;
mod image_cleanup;
mod interval_parser;
mod metrics;
#[cfg(not(test))]
@@ -49,8 +48,8 @@ use crate::test::TestContext;
use crate::{
commands::{
allowed_dm, clock, clock_context_menu::clock_context_menu, command_macro, dashboard,
delete, help, info, look, multiline, nudge, offset, patreon, pause, remind, settings,
timer, timezone, todo, webhook,
delete, donate, help, info, look, multiline, nudge, offset, pause, remind, settings, timer,
timezone, todo, webhook,
},
consts::THEME_COLOR,
event_handlers::listener,
@@ -122,7 +121,6 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
match cmd_word {
Some("clean-images") => image_cleanup::run(&database).await,
Some("clean") => {
let sent_clean_age = env::var("SENT_CLEAN_AGE").expect("No SENT_CLEAN_AGE provided");
if sent_clean_age.is_empty() {
@@ -167,14 +165,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
help::command(),
info::command(),
clock::command(),
poise::Command {
subcommands: vec![
patreon::link::link(),
patreon::unlink::unlink(),
patreon::info::info(),
],
..patreon::command()
},
donate::command(),
clock_context_menu(),
dashboard::command(),
timezone::command(),
+2 -5
View File
@@ -37,13 +37,10 @@ impl ChannelData {
{
Ok(c)
} else {
let props = channel.to_owned().guild().map(|g| (g.guild_id.get().to_owned(), g.name));
let (guild_id, channel_name) =
if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) };
let guild_id = channel.to_owned().guild().map(|g| g.guild_id.get().to_owned());
sqlx::query!(
"INSERT IGNORE INTO channels (channel, guild_id) VALUES (?, (SELECT id FROM guilds WHERE guild = ?))",
"INSERT IGNORE INTO channels (channel, guild_id) VALUES (?, ?)",
channel_id,
guild_id
)
+5 -7
View File
@@ -43,7 +43,7 @@ pub enum RecordedCommand {
#[serde(rename = "delete")]
Delete(crate::commands::delete::Options),
#[serde(rename = "donate")]
Donate(crate::commands::patreon::info::Options),
Donate(crate::commands::donate::Options),
#[serde(rename = "help")]
Help(crate::commands::help::Options),
#[serde(rename = "info")]
@@ -111,7 +111,7 @@ impl RecordedCommand {
"clock" => Some(Self::Clock(crate::commands::clock::Options::extract(ctx))),
"dashboard" => Some(Self::Dashboard(crate::commands::dashboard::Options::extract(ctx))),
"delete" => Some(Self::Delete(crate::commands::delete::Options::extract(ctx))),
"donate" => Some(Self::Donate(crate::commands::patreon::info::Options::extract(ctx))),
"donate" => Some(Self::Donate(crate::commands::donate::Options::extract(ctx))),
"help" => Some(Self::Help(crate::commands::help::Options::extract(ctx))),
"info" => Some(Self::Info(crate::commands::info::Options::extract(ctx))),
"look" => Some(Self::Look(crate::commands::look::Options::extract(ctx))),
@@ -144,12 +144,10 @@ pub async fn guild_command_macro(ctx: &Context<'_>, name: &str) -> Option<Comman
let row = sqlx::query_as!(
Row,
"
SELECT m.name, m.description, m.commands
SELECT name, description, commands
FROM command_macro m
INNER JOIN guilds g
ON g.id = m.guild_id
WHERE guild = ?
AND m.name = ?
WHERE guild_id = ?
AND name = ?
",
ctx.guild_id().unwrap().get(),
name
+5 -5
View File
@@ -3,7 +3,7 @@ use sqlx::MySqlPool;
pub struct GuildData {
pub ephemeral_confirmations: bool,
pub id: u32,
pub guild_id: u64,
}
impl GuildData {
@@ -13,7 +13,7 @@ impl GuildData {
) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
if let Ok(c) = sqlx::query_as_unchecked!(
Self,
"SELECT id, ephemeral_confirmations FROM guilds WHERE guild = ?",
"SELECT ephemeral_confirmations, id as guild_id FROM guilds WHERE id = ?",
guild_id.get()
)
.fetch_one(pool)
@@ -21,13 +21,13 @@ impl GuildData {
{
Ok(c)
} else {
sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id.get())
sqlx::query!("INSERT IGNORE INTO guilds (id) VALUES (?)", guild_id.get())
.execute(&pool.clone())
.await?;
Ok(sqlx::query_as_unchecked!(
Self,
"SELECT id, ephemeral_confirmations FROM guilds WHERE guild = ?",
"SELECT ephemeral_confirmations, id as guild_id FROM guilds WHERE id = ?",
guild_id.get()
)
.fetch_one(pool)
@@ -39,7 +39,7 @@ impl GuildData {
sqlx::query!(
"UPDATE guilds SET ephemeral_confirmations = ? WHERE id = ?",
self.ephemeral_confirmations,
self.id
self.guild_id
)
.execute(pool)
.await
+6 -3
View File
@@ -68,16 +68,19 @@ impl Data {
guild_id: GuildId,
) -> Result<Vec<CommandMacro>, Error> {
let rows = sqlx::query!(
"SELECT name, description, commands FROM command_macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
"SELECT name, description, commands FROM command_macro WHERE guild_id = ?",
guild_id.get()
)
.fetch_all(&self.database)
.await?.iter().map(|row| CommandMacro {
.await?
.iter()
.map(|row| CommandMacro {
guild_id,
name: row.name.clone(),
description: row.description.clone(),
commands: serde_json::from_str(&row.commands.to_string()).unwrap(),
}).collect();
})
.collect();
Ok(rows)
}
+8 -8
View File
@@ -215,17 +215,17 @@ impl<'a> MultiReminderBuilder<'a> {
for scope in self.scopes {
let db_channel_id = match scope {
ReminderScope::User(user_id) => {
let user_id = UserId::new(user_id);
match UserData::from_user(
&user_id,
if let Ok(user) = UserId::new(user_id).to_user(&self.ctx).await {
let user_data = UserData::from_user(
&user,
&self.ctx.serenity_context(),
&self.ctx.data().database,
)
.await
{
Ok(user_data) => {
.unwrap();
if let Some(guild_id) = self.guild_id {
if guild_id.member(&self.ctx, user_id).await.is_err() {
if guild_id.member(&self.ctx, user).await.is_err() {
Err(ReminderError::InvalidTag)
} else if self.set_by.map_or(true, |i| i != user_data.id)
&& !user_data.allowed_dm
@@ -237,8 +237,8 @@ impl<'a> MultiReminderBuilder<'a> {
} else {
Ok((user_data.dm_channel, None))
}
}
Err(_) => Err(ReminderError::InvalidTag),
} else {
Err(ReminderError::InvalidTag)
}
}
ReminderScope::Channel(channel_with_thread) => {
+8 -10
View File
@@ -16,8 +16,8 @@ use crate::{
},
CtxData,
},
time_parser::{cron_next_timestamp, natural_parser},
utils::check_subscription,
time_parser::natural_parser,
utils::{check_guild_subscription, check_subscription},
Context, Database, Error,
};
use chrono::{DateTime, NaiveDateTime, Utc};
@@ -262,7 +262,7 @@ impl Reminder {
channels.id = reminders.channel_id
WHERE
`status` = 'pending' AND
channels.guild_id = (SELECT id FROM guilds WHERE guild = ?)
channels.guild_id = ?
",
guild_id.get()
)
@@ -486,10 +486,7 @@ pub async fn create_reminder(
let user_data = ctx.author_data().await.unwrap();
let timezone = timezone.unwrap_or(ctx.timezone().await);
let time = match cron_next_timestamp(&time, timezone) {
Some(ts) => Some(ts),
None => natural_parser(&time, &timezone.to_string()).await,
};
let time = natural_parser(&time, &timezone.to_string()).await;
match time {
Some(time) => {
@@ -532,8 +529,9 @@ pub async fn create_reminder(
};
let (processed_interval, processed_expires) = if let Some(repeat) = &interval {
if check_subscription(&ctx, &ctx.data().database, ctx.author().id, ctx.guild_id())
.await
if check_subscription(&ctx, ctx.author().id).await
|| (ctx.guild_id().is_some()
&& check_guild_subscription(&ctx, ctx.guild_id().unwrap()).await)
{
(
parse_duration(repeat)
@@ -549,7 +547,7 @@ pub async fn create_reminder(
)
} else {
ctx.send(CreateReply::default().content(
"`interval` is only available to Patreon subscribers or self-hosted users",
"`repeat` is only available to Patreon subscribers or self-hosted users",
))
.await?;
+18 -113
View File
@@ -34,10 +34,8 @@ use crate::{
lazy_static! {
pub static ref TIMEFROM_REGEX: Regex =
Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
pub static ref TIMENOW_REGEX: Regex = Regex::new(
r#"<<timenow(?:(?P<sign>[+-])(?P<offset>\d+))?:(?P<timezone>(?:\w|/|_)+?):(?P<format>.+?)?>>"#
)
.unwrap();
pub static ref TIMENOW_REGEX: Regex =
Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap();
pub static ref LOG_TO_DATABASE: bool = env::var("LOG_TO_DATABASE").map_or(true, |v| v == "1");
}
@@ -66,7 +64,7 @@ fn fmt_displacement(format: &str, seconds: u64) -> String {
}
pub fn substitute(string: &str) -> String {
let new = TIMEFROM_REGEX.replace_all(string, |caps: &Captures| {
let new = TIMEFROM_REGEX.replace(string, |caps: &Captures| {
let final_time = caps.name("time").map(|m| m.as_str().parse::<i64>().ok()).flatten();
let format = caps.name("format").map(|m| m.as_str());
@@ -94,26 +92,12 @@ pub fn substitute(string: &str) -> String {
});
TIMENOW_REGEX
.replace_all(&new, |caps: &Captures| {
.replace(&new, |caps: &Captures| {
let timezone = caps.name("timezone").map(|m| m.as_str().parse::<Tz>().ok()).flatten();
let format = caps.name("format").map(|m| m.as_str());
let sign = caps.name("sign").map(|m| m.as_str());
let offset = caps.name("offset").map(|m| m.as_str().parse::<i64>().ok()).flatten();
if let (Some(timezone), Some(format)) = (timezone, format) {
let mut now = Utc::now().with_timezone(&timezone);
if let (Some(sign), Some(offset)) = (sign, offset) {
now = now
.checked_add_signed(TimeDelta::seconds(
offset * {
match sign {
"-" => -1,
_ => 1,
}
},
))
.unwrap_or(now)
}
let now = Utc::now().with_timezone(&timezone);
now.format(format).to_string()
} else {
@@ -195,100 +179,21 @@ impl Embed {
}
pub fn has_content(&self) -> bool {
// Discord reports "description required" for empty embeds, but descriptions are optional.
// An author icon alone does not count as content; a footer icon does.
!self.title.trim().is_empty()
|| !self.description.trim().is_empty()
|| self.image_url.as_ref().is_some_and(|url| !url.trim().is_empty())
|| self.thumbnail_url.as_ref().is_some_and(|url| !url.trim().is_empty())
|| !self.footer.trim().is_empty()
|| self.footer_url.as_ref().is_some_and(|url| !url.trim().is_empty())
|| !self.author.trim().is_empty()
|| !self.fields.0.is_empty()
if self.title.is_empty()
&& self.description.is_empty()
&& self.image_url.is_none()
&& self.thumbnail_url.is_none()
&& self.footer.is_empty()
&& self.footer_url.is_none()
&& self.author.is_empty()
&& self.author_url.is_none()
&& self.fields.0.is_empty()
{
false
} else {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn embed(description: &str) -> Embed {
Embed {
title: String::new(),
description: description.to_string(),
image_url: None,
thumbnail_url: None,
footer: String::new(),
footer_url: None,
author: String::new(),
author_url: None,
color: 0,
fields: Json(vec![]),
}
}
#[test]
fn embed_without_description_can_have_content() {
let url = Some("https://example.com/image.png".to_string());
let embeds = [
Embed { title: "Reminder".to_string(), ..embed("") },
Embed { image_url: url.clone(), ..embed("") },
Embed { thumbnail_url: url.clone(), ..embed("") },
Embed { footer: "Footer".to_string(), ..embed("") },
Embed { footer_url: url, ..embed("") },
Embed { author: "Author".to_string(), ..embed("") },
Embed {
fields: Json(vec![EmbedField {
title: String::new(),
value: String::new(),
inline: false,
}]),
..embed("")
},
];
for embed in embeds {
assert!(embed.has_content());
}
}
#[test]
fn empty_embed_after_substitution_has_no_content() {
let mut embed = embed("<<timenow:Invalid/Timezone:%H>>");
embed.author_url = Some("https://example.com/image.png".to_string());
embed.description = substitute(&embed.description);
assert!(embed.description.is_empty());
assert!(!embed.has_content());
}
#[test]
fn embed_with_only_description_has_content() {
assert!(embed("Remember to do the thing").has_content());
}
#[test]
fn empty_or_whitespace_embed_has_no_content() {
assert!(!embed("").has_content());
let embed = Embed {
title: " \n\t ".to_string(),
image_url: Some(String::new()),
thumbnail_url: Some(" ".to_string()),
footer: " \n\t ".to_string(),
footer_url: Some(String::new()),
author: " \n\t ".to_string(),
color: 12345,
..embed(" \n\t ")
};
assert!(!embed.has_content());
}
#[test]
fn author_icon_alone_has_no_content() {
let embed =
Embed { author_url: Some("https://example.com/image.png".to_string()), ..embed("") };
assert!(!embed.has_content());
}
}
impl Into<CreateEmbed> for Embed {
-8
View File
@@ -3,8 +3,6 @@ use poise::{
CreateReply,
};
use serde_json::Value;
use serenity::all::Http;
use serenity::http::CacheHttp;
use tokio::sync::Mutex;
use crate::{Data, Error};
@@ -21,12 +19,6 @@ pub(crate) struct TestContext<'a> {
pub(crate) shard_id: usize,
}
impl CacheHttp for TestContext<'_> {
fn http(&self) -> &Http {
todo!()
}
}
pub(crate) struct MockUser {
pub(crate) id: UserId,
}
-5
View File
@@ -6,7 +6,6 @@ use std::{
use chrono::{DateTime, Datelike, Timelike, Utc};
use chrono_tz::Tz;
use cron_parser::parse;
use tokio::process::Command;
use crate::consts::{LOCAL_TIMEZONE, PYTHON_LOCATION};
@@ -220,7 +219,3 @@ pub async fn natural_parser(time: &str, timezone: &str) -> Option<i64> {
})
.and_then(|inner| if inner < 0 { None } else { Some(inner) })
}
pub fn cron_next_timestamp(expr: &str, timezone: Tz) -> Option<i64> {
parse(expr, &Utc::now().with_timezone(&timezone)).ok().map(|next| next.timestamp() as i64)
}
+13 -55
View File
@@ -9,62 +9,10 @@ use poise::{
use crate::{
consts::{CNC_GUILD, SUBSCRIPTION_ROLES},
ApplicationContext, Context, Database, Error,
ApplicationContext, Context, Error,
};
/// Check if this user/guild combination should be considered subscribed.
/// If the guild has a patreon linked, check the user involved in the link.
/// Otherwise, check the user and the guild's owner
pub async fn check_subscription(
ctx: impl CacheHttp,
database: impl Executor<'_, Database = Database>,
user_id: UserId,
guild_id: Option<GuildId>,
) -> bool {
if let Some(subscription_guild) = *CNC_GUILD {
let user_subscribed = check_user_subscription(&ctx, user_id).await;
let owner_subscribed = match guild_id {
Some(guild_id) => {
if let Some(owner) = ctx.cache().unwrap().guild(guild_id).map(|g| g.owner_id) {
check_user_subscription(&ctx, owner).await
} else {
false
}
}
None => false,
};
let link_subscribed = match guild_id {
Some(guild_id) => {
if let Ok(row) = sqlx::query!(
"SELECT user_id FROM patreon_link WHERE guild_id = ?",
guild_id.get()
)
.fetch_one(database)
.await
{
check_user_subscription(&ctx, row.user_id).await
} else {
false
}
}
None => false,
};
user_subscribed || owner_subscribed || link_subscribed
} else {
true
}
}
/// Check a user's subscription status, ignoring Patreon linkage
pub async fn check_user_subscription(
cache_http: impl CacheHttp,
user_id: impl Into<UserId>,
) -> bool {
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
if let Some(subscription_guild) = *CNC_GUILD {
let guild_member = GuildId::new(subscription_guild).member(cache_http, user_id).await;
@@ -82,6 +30,17 @@ pub async fn check_user_subscription(
}
}
pub async fn check_guild_subscription(
cache_http: impl CacheHttp,
guild_id: impl Into<GuildId>,
) -> bool {
if let Some(owner) = cache_http.cache().unwrap().guild(guild_id).map(|g| g.owner_id) {
check_subscription(&cache_http, owner).await
} else {
false
}
}
pub fn reply_to_interaction_response_message(
reply: CreateReply,
) -> CreateInteractionResponseMessage {
@@ -117,7 +76,6 @@ pub trait Extract {
}
pub use extract_derive::Extract;
use sqlx::Executor;
macro_rules! extract_arg {
($ctx:ident, $name:ident, String) => {
-298
View File
@@ -1,298 +0,0 @@
use std::{env, io::Cursor, sync::Arc};
use image::{ImageFormat, ImageReader, Limits};
use log::{info, warn};
use nsfw::model::{Classification, Metric};
use s3::{creds::Credentials, Bucket, Region};
use tokio::sync::Semaphore;
pub const MAX_UPLOAD_BYTES: u64 = 8 * 1024 * 1024;
const MAX_IMAGE_PIXELS: u64 = 16_000_000;
const NSFW_THRESHOLD: f32 = 0.5;
const MODEL_BYTES: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/nsfw/model.onnx"));
#[cfg(test)]
mod fixture_tests;
// An empty configuration keeps the dashboard's existing URL-only behavior.
pub struct ImageUploads(pub Option<Arc<ImageUploadService>>);
pub struct ImageUploadService {
pub bucket: Box<Bucket>,
pub public_url: String,
pub model: Option<nsfw::Model>,
pub slots: Arc<Semaphore>,
}
impl ImageUploads {
pub fn from_env() -> Self {
let names =
["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", "S3_PUBLIC_URL"];
let values = names.map(|name| env::var(name).unwrap_or_default());
Self::from_settings(&values)
}
fn from_settings(values: &[String; 5]) -> Self {
if values.iter().all(|value| value.trim().is_empty()) {
return Self(None);
}
if values.iter().any(|value| value.trim().is_empty()) {
warn!("Image uploads disabled: all S3 upload settings must be supplied");
return Self(None);
}
let check_nsfw = nsfw_check_enabled(env::var("NSFW_CHECK_ENABLED").ok().as_deref());
match ImageUploadService::new(values, check_nsfw) {
Ok(service) => {
info!("Dashboard image uploads enabled");
Self(Some(Arc::new(service)))
}
Err(error) => {
warn!("Image uploads disabled: {}", error);
Self(None)
}
}
}
}
impl ImageUploadService {
// Cleanup uses the same storage configuration without loading the moderation model.
pub(crate) fn for_cleanup() -> Result<Option<Self>, Box<dyn std::error::Error + Send + Sync>> {
let values =
["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", "S3_PUBLIC_URL"]
.map(|name| env::var(name).unwrap_or_default());
if values.iter().all(|value| value.trim().is_empty()) {
return Ok(None);
}
if values.iter().any(|value| value.trim().is_empty()) {
return Err("Image cleanup requires all S3 upload settings".into());
}
Self::new(&values, false).map(Some).map_err(|error| error.to_string().into())
}
fn new(values: &[String; 5], check_nsfw: bool) -> Result<Self, Box<dyn std::error::Error>> {
let url = reqwest::Url::parse(&values[4])?;
let public_url = url.as_str().trim_end_matches('/').to_string();
if !matches!(url.scheme(), "https" | "http")
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
|| public_url.len() > 400
{
return Err("S3_PUBLIC_URL must be an HTTP(S) base URL of at most 400 bytes".into());
}
let endpoint = env::var("S3_ENDPOINT").ok().filter(|value| !value.trim().is_empty());
let region = match &endpoint {
Some(endpoint) => {
Region::Custom { region: values[1].clone(), endpoint: endpoint.clone() }
}
None => values[1].parse()?,
};
let credentials = Credentials::new(Some(&values[2]), Some(&values[3]), None, None, None)?;
let mut bucket = Bucket::new(&values[0], region, credentials)?;
if endpoint.is_some() {
bucket = bucket.with_path_style();
}
bucket.set_request_timeout(Some(std::time::Duration::from_secs(30)));
let model = if check_nsfw {
Some(nsfw::create_model(MODEL_BYTES)?)
} else {
info!("NSFW checks disabled for dashboard image uploads");
None
};
Ok(Self { bucket, public_url, model, slots: Arc::new(Semaphore::new(2)) })
}
}
fn nsfw_check_enabled(value: Option<&str>) -> bool {
match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
None | Some("" | "true" | "1" | "on") => true,
Some("false" | "0" | "off") => false,
Some(_) => {
warn!("Unrecognized NSFW_CHECK_ENABLED value; keeping NSFW checks enabled");
true
}
}
}
#[derive(Debug, PartialEq)]
pub enum ImageError {
InvalidImage,
Nsfw,
ModerationFailed,
}
fn probable_nsfw(predictions: &[Classification]) -> Result<bool, ImageError> {
if predictions.len() != 5
|| predictions.iter().any(|prediction| {
!prediction.score.is_finite() || !(0.0..=1.0).contains(&prediction.score)
})
{
return Err(ImageError::ModerationFailed);
}
let score: f32 = predictions
.iter()
.filter(|prediction| {
matches!(prediction.metric, Metric::Porn | Metric::Hentai | Metric::Sexy)
})
.map(|prediction| prediction.score)
.sum();
Ok(score >= NSFW_THRESHOLD)
}
// Publish only the decoded pixels, which are moderated when NSFW checks are enabled.
// Static PNGs strip metadata and prevent unchecked animation frames from being published.
fn decode_image(bytes: &[u8]) -> Result<image::RgbaImage, ImageError> {
let mut reader = ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(|_| ImageError::InvalidImage)?;
if !matches!(
reader.format(),
Some(ImageFormat::Jpeg | ImageFormat::Png | ImageFormat::Gif | ImageFormat::WebP)
) {
return Err(ImageError::InvalidImage);
}
let mut limits = Limits::default();
limits.max_image_width = Some(8192);
limits.max_image_height = Some(8192);
limits.max_alloc = Some(128 * 1024 * 1024);
reader.limits(limits);
let decoder = reader.into_decoder().map_err(|_| ImageError::InvalidImage)?;
use image::ImageDecoder;
let (width, height) = decoder.dimensions();
if width == 0 || height == 0 || u64::from(width) * u64::from(height) > MAX_IMAGE_PIXELS {
return Err(ImageError::InvalidImage);
}
image::DynamicImage::from_decoder(decoder)
.map(|image| image.to_rgba8())
.map_err(|_| ImageError::InvalidImage)
}
pub fn prepare_image(model: Option<&nsfw::Model>, bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
let image = decode_image(bytes)?;
// Flatten transparency so the classifier sees exactly the published image.
let mut visible = image::RgbImage::new(image.width(), image.height());
for (target, source) in visible.pixels_mut().zip(image.pixels()) {
for channel in 0..3 {
let alpha = u32::from(source[3]);
target[channel] =
((u32::from(source[channel]) * alpha + 255 * (255 - alpha)) / 255) as u8;
}
}
let visible = image::DynamicImage::ImageRgb8(visible);
if let Some(model) = model {
let predictions = nsfw::examine(model, &visible.to_rgba8()).map_err(|error| {
warn!("Image moderation failed: {}", error);
ImageError::ModerationFailed
})?;
if probable_nsfw(&predictions)? {
return Err(ImageError::Nsfw);
}
}
let mut output = Cursor::new(Vec::new());
visible.write_to(&mut output, ImageFormat::Png).map_err(|_| ImageError::InvalidImage)?;
Ok(output.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
fn predictions(scores: [f32; 5]) -> Vec<Classification> {
[Metric::Drawings, Metric::Hentai, Metric::Neutral, Metric::Porn, Metric::Sexy]
.into_iter()
.zip(scores)
.map(|(metric, score)| Classification { metric, score })
.collect()
}
#[test]
fn combines_unsafe_categories_and_rejects_invalid_scores() {
assert_eq!(probable_nsfw(&predictions([0.1, 0.2, 0.4, 0.2, 0.1])), Ok(true));
assert_eq!(probable_nsfw(&predictions([0.1, 0.1, 0.7, 0.05, 0.05])), Ok(false));
assert_eq!(
probable_nsfw(&predictions([0.0, 0.0, f32::NAN, 0.0, 0.0])),
Err(ImageError::ModerationFailed)
);
assert_eq!(probable_nsfw(&[]), Err(ImageError::ModerationFailed));
assert_eq!(
probable_nsfw(&predictions([0.0, -0.5, 1.0, 0.0, 0.0])),
Err(ImageError::ModerationFailed)
);
}
#[test]
fn absent_or_partial_configuration_disables_uploads() {
let mut settings = std::array::from_fn(|_| String::new());
assert!(ImageUploads::from_settings(&settings).0.is_none());
settings[0] = "images".into();
assert!(ImageUploads::from_settings(&settings).0.is_none());
}
#[test]
fn disabling_nsfw_checks_skips_model_initialization() {
let settings = ["images", "eu-west-2", "test", "test", "https://images.example.com"]
.map(str::to_string);
assert!(ImageUploadService::new(&settings, false).unwrap().model.is_none());
}
#[test]
fn disabled_moderation_allows_flagged_images_but_still_validates_files() {
let model = nsfw::create_model(
&include_bytes!("../../tests/fixtures/image-upload-unsafe.onnx")[..],
)
.unwrap();
let mut bytes = Cursor::new(Vec::new());
image::DynamicImage::new_rgb8(2, 2).write_to(&mut bytes, ImageFormat::Png).unwrap();
assert_eq!(prepare_image(Some(&model), bytes.get_ref()), Err(ImageError::Nsfw));
let published = prepare_image(None, bytes.get_ref()).unwrap();
assert_eq!(image::load_from_memory(&published).unwrap().width(), 2);
assert_eq!(prepare_image(None, b"not an image"), Err(ImageError::InvalidImage));
}
#[test]
fn bundled_model_loads_and_classifies_an_image() {
let model = nsfw::create_model(MODEL_BYTES).unwrap();
let image = image::RgbaImage::from_pixel(224, 224, image::Rgba([255, 255, 255, 255]));
let predictions = nsfw::examine(&model, &image).unwrap();
assert!(probable_nsfw(&predictions).is_ok());
}
#[test]
fn rejects_non_images_and_truncated_images() {
assert_eq!(decode_image(b"<svg></svg>"), Err(ImageError::InvalidImage));
assert_eq!(decode_image(b"\x89PNG\r\n\x1a\n"), Err(ImageError::InvalidImage));
}
#[test]
fn decodes_real_images_without_trusting_the_filename() {
let image = image::DynamicImage::new_rgb8(2, 2);
for format in [ImageFormat::Png, ImageFormat::Jpeg, ImageFormat::Gif, ImageFormat::WebP] {
let mut bytes = Cursor::new(Vec::new());
image.write_to(&mut bytes, format).unwrap();
assert_eq!(decode_image(bytes.get_ref()).unwrap().dimensions(), (2, 2));
}
}
#[test]
fn rejects_excessive_image_dimensions() {
let image = image::DynamicImage::new_rgb8(8193, 1);
let mut bytes = Cursor::new(Vec::new());
image.write_to(&mut bytes, ImageFormat::Png).unwrap();
assert_eq!(decode_image(bytes.get_ref()), Err(ImageError::InvalidImage));
}
}
-131
View File
@@ -1,131 +0,0 @@
use std::{
collections::BTreeSet,
fs,
io::Cursor,
path::{Path, PathBuf},
};
use image::{ImageFormat, RgbaImage};
use super::{prepare_image, ImageError, MODEL_BYTES};
const NSFW_IMAGES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/nsfw");
const NON_NSFW_IMAGES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/non_nsfw");
fn fixture_pairs(directory: &Path) -> Vec<(PathBuf, PathBuf)> {
let mut indices = BTreeSet::new();
for entry in fs::read_dir(directory).expect("Could not read image fixture directory") {
let entry = entry.expect("Could not read image fixture entry");
let path = entry.path();
if path.extension().and_then(|extension| extension.to_str()) != Some("png") {
continue;
}
let name = path.file_name().unwrap().to_str().expect("Fixture name is not valid UTF-8");
let index = name
.strip_prefix("image_")
.and_then(|name| name.strip_suffix("a.png").or_else(|| name.strip_suffix("b.png")))
.filter(|index| index.parse::<u32>().is_ok_and(|index| index > 0))
.unwrap_or_else(|| {
panic!("Invalid fixture name {name}; expected image_1a.png/image_1b.png")
});
indices.insert(index.to_string());
}
assert!(
!indices.is_empty(),
"No paired fixtures in {}. Add images with scripts/split_nsfw_test_image.py.",
directory.display()
);
indices
.into_iter()
.map(|index| {
let first = directory.join(format!("image_{index}a.png"));
let second = directory.join(format!("image_{index}b.png"));
assert!(
first.is_file() && second.is_file(),
"Both fixture parts must exist: {} and {}",
first.display(),
second.display()
);
(first, second)
})
.collect()
}
fn recombine_parts(first: &RgbaImage, second: &RgbaImage) -> Result<RgbaImage, &'static str> {
if first.dimensions() != second.dimensions() {
return Err("Fixture parts must have identical dimensions");
}
let pixels = first.as_raw().iter().zip(second.as_raw()).map(|(a, b)| a ^ b).collect();
RgbaImage::from_raw(first.width(), first.height(), pixels).ok_or("Invalid fixture pixel data")
}
fn check_directory(directory: &str, expected_nsfw: bool) {
let pairs = fixture_pairs(Path::new(directory));
let model = nsfw::create_model(MODEL_BYTES).expect("Could not load bundled NSFW model");
for (first, second) in pairs {
let a = image::open(&first).expect("Could not decode first fixture part").to_rgba8();
let b = image::open(&second).expect("Could not decode second fixture part").to_rgba8();
let reconstructed = recombine_parts(&a, &b).expect("Could not recombine fixture parts");
let mut bytes = Cursor::new(Vec::new());
reconstructed
.write_to(&mut bytes, ImageFormat::Png)
.expect("Could not encode reconstructed fixture");
// Use the production path, including flattening transparency and the same
// combined category threshold. Never write the reconstructed image to disk.
let result = prepare_image(Some(&model), bytes.get_ref());
if expected_nsfw {
assert_eq!(
result.as_ref().err(),
Some(&ImageError::Nsfw),
"Expected {} to be blocked as NSFW, got {:?}",
first.display(),
result.as_ref().err()
);
} else {
assert!(
result.is_ok(),
"Expected {} to be accepted, got {:?}",
first.display(),
result.as_ref().err()
);
}
// The toggle must allow either category, while retaining image validation.
assert!(
prepare_image(None, bytes.get_ref()).is_ok(),
"Expected {} to be accepted with moderation disabled",
first.display()
);
}
}
#[test]
#[ignore = "Add local split fixtures to tests/fixtures/nsfw, then run with --ignored"]
fn nsfw_directory_images_are_blocked() {
check_directory(NSFW_IMAGES, true);
}
#[test]
#[ignore = "Add local split fixtures to tests/fixtures/non_nsfw, then run with --ignored"]
fn non_nsfw_directory_images_are_allowed() {
check_directory(NON_NSFW_IMAGES, false);
}
#[test]
fn recombining_parts_restores_every_pixel_and_alpha_channel() {
let original = [0, 17, 254, 255, 45, 99, 128, 0];
let random = vec![18, 201, 50, 34, 166, 76, 11, 220];
let masked = original.iter().zip(&random).map(|(pixel, mask)| pixel ^ mask).collect();
let first = RgbaImage::from_raw(2, 1, random).unwrap();
let second = RgbaImage::from_raw(2, 1, masked).unwrap();
assert_eq!(recombine_parts(&first, &second).unwrap().as_raw(), &original);
}
#[test]
fn rejects_parts_with_different_dimensions() {
assert_eq!(
recombine_parts(&RgbaImage::new(1, 1), &RgbaImage::new(2, 1)),
Err("Fixture parts must have identical dimensions")
);
}
+1 -1
View File
@@ -42,7 +42,7 @@ impl IpBlocking {
fn contains<I: Into<u32>>(&self, ip: I) -> bool {
let ip: u32 = ip.into();
let _prev_index = self.upper_ips.len() - 1;
let mut prev_index = self.upper_ips.len() - 1;
let mut index = self.upper_ips.len() / 2;
loop {
if self.upper_ips[index] <= ip && self.lower_ips[index] >= ip {
+43 -12
View File
@@ -4,7 +4,6 @@ mod macros;
mod catchers;
mod fairings;
mod guards;
pub(crate) mod image_uploads;
mod ip_blocking;
mod routes;
@@ -64,6 +63,7 @@ use log::{error, info, warn};
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
use poise::serenity_prelude::{
client::Context,
http::CacheHttp,
model::id::{GuildId, UserId},
};
use rocket::{
@@ -76,10 +76,13 @@ use rocket::{
};
use rocket_dyn_templates::Template;
use sqlx::{MySql, Pool};
use std::net::Ipv4Addr;
use std::{env, path::Path};
use crate::web::consts::{DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN};
use crate::web::fairings::metrics::MetricProducer;
use crate::web::{
consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES},
fairings::metrics::MetricProducer,
};
type Database = MySql;
@@ -89,8 +92,6 @@ enum Error {
SQLx(sqlx::Error),
#[allow(unused)]
Serenity(serenity::Error),
#[allow(unused)]
MissingDiscordPermission(&'static str),
}
pub async fn initialize(
@@ -123,10 +124,8 @@ pub async fn initialize(
let static_path =
if Path::new("static").exists() { "static" } else { "/lib/reminder-rs/static" };
info!("Using static path: {}", static_path);
let image_uploads = image_uploads::ImageUploads::from_env();
let rocket = rocket::build()
rocket::build()
.attach(MetricProducer)
.attach(Template::fairing())
.register(
@@ -216,10 +215,9 @@ pub async fn initialize(
routes::dashboard::export::todos::export,
routes::dashboard::export::todos::import,
],
);
let rocket = routes::dashboard::api::images::mount(rocket, image_uploads);
rocket.launch().await?;
)
.launch()
.await?;
warn!("Exiting rocket runtime");
// distribute kill signal
@@ -233,6 +231,39 @@ pub async fn initialize(
Ok(())
}
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
offline!(true);
if let Some(subscription_guild) = *CNC_GUILD {
let guild_member = GuildId::new(subscription_guild).member(cache_http, user_id).await;
if let Ok(member) = guild_member {
for role in member.roles {
if SUBSCRIPTION_ROLES.contains(&role.get()) {
return true;
}
}
}
false
} else {
true
}
}
pub async fn check_guild_subscription(
cache_http: impl CacheHttp,
guild_id: impl Into<GuildId>,
) -> bool {
offline!(true);
if let Some(owner) = cache_http.cache().unwrap().guild(guild_id).map(|guild| guild.owner_id) {
check_subscription(&cache_http, owner).await
} else {
false
}
}
pub async fn check_authorization(
cookies: &CookieJar<'_>,
ctx: &Context,
+22 -22
View File
@@ -5,41 +5,41 @@ mod roles;
mod templates;
pub mod todos;
use crate::utils::check_subscription;
use crate::web::guards::transaction::Transaction;
use crate::web::{check_authorization, routes::JsonResult};
use std::env;
pub use channels::get_guild_channels;
pub use emojis::get_guild_emojis;
pub use reminders::*;
use rocket::{get, http::CookieJar, serde::json::json, State};
pub use roles::get_guild_roles;
use serenity::all::UserId;
use serenity::{client::Context, model::id::GuildId};
use serenity::{
client::Context,
model::id::{GuildId, RoleId},
};
pub use templates::*;
use crate::web::{check_authorization, routes::JsonResult};
#[get("/api/guild/<id>")]
pub async fn get_guild_info(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
mut transaction: Transaction<'_>,
) -> JsonResult {
pub async fn get_guild_info(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
offline!(Ok(json!({ "patreon": true, "name": "Guild" })));
check_authorization(cookies, ctx.inner(), id).await?;
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
match GuildId::new(id).to_guild_cached(ctx.inner()).map(|guild| guild.name.clone()) {
Some(name) => {
let patreon = check_subscription(
ctx.inner(),
transaction.executor(),
UserId::from(user_id),
Some(GuildId::from(id)),
)
match GuildId::new(id)
.to_guild_cached(ctx.inner())
.map(|guild| (guild.owner_id, guild.name.clone()))
{
Some((owner_id, name)) => {
let member_res = GuildId::new(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
.member(&ctx.inner(), owner_id)
.await;
let patreon = member_res.map_or(false, |member| {
member
.roles
.contains(&RoleId::new(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
});
Ok(json!({ "patreon": patreon, "name": name }))
}
@@ -12,9 +12,8 @@ use serenity::{
};
use sqlx::{MySql, Pool};
use crate::utils::check_subscription;
use crate::web::{
check_authorization,
check_authorization, check_guild_subscription, check_subscription,
consts::MIN_INTERVAL,
guards::transaction::Transaction,
routes::{
@@ -187,13 +186,8 @@ pub async fn edit_reminder(
|| reminder.interval_months.flatten().is_some()
|| reminder.interval_seconds.flatten().is_some()
{
if check_subscription(
ctx.inner(),
transaction.executor(),
UserId::from(user_id),
Some(GuildId::from(id)),
)
.await
if check_guild_subscription(&ctx.inner(), id).await
|| check_subscription(&ctx.inner(), user_id).await
{
let new_interval_length = match reminder.interval_days {
Some(interval) => interval.unwrap_or(0),
@@ -267,8 +261,9 @@ pub async fn edit_reminder(
}
if reminder.channel > 0 {
let channel_guild =
ctx.cache.channel(ChannelId::new(reminder.channel)).map(|channel| channel.guild_id);
let channel_guild = ChannelId::new(reminder.channel)
.to_channel_cached(&ctx.cache)
.map(|channel| channel.guild_id);
match channel_guild {
Some(channel_guild) => {
let channel_matches_guild = channel_guild.get() == id;
@@ -310,19 +305,11 @@ pub async fn edit_reminder(
Err(e) => {
warn!("`create_database_channel` returned an error code: {:?}", e);
// Provide more specific error messages based on the error type
match e {
crate::web::Error::MissingDiscordPermission(permission) => {
error.push(format!("Please ensure the bot has the \"{}\" permission in the channel", permission));
}
_ => {
error.push("Failed to configure channel for reminders. Please check the bot permissions".to_string());
}
}
}
}
}
}
None => {
warn!(
@@ -31,7 +31,7 @@ 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 * FROM reminder_template WHERE guild_id = ?",
id
)
.fetch_all(pool.inner())
@@ -57,7 +57,6 @@ pub async fn create_guild_reminder_template(
check_authorization(cookies, ctx.inner(), id).await?;
match create_reminder_template(
ctx.inner(),
&mut transaction,
GuildId::new(id),
reminder_template.into_inner(),
@@ -87,15 +86,14 @@ pub async fn delete_reminder_template(
check_authorization(cookies, ctx.inner(), id).await?;
match sqlx::query!(
"DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?",
id, delete_reminder_template.id
"DELETE FROM reminder_template WHERE guild_id = ? AND id = ?",
id,
delete_reminder_template.id
)
.fetch_all(pool.inner())
.await
{
Ok(_) => {
Ok(json!({}))
}
Ok(_) => Ok(json!({})),
Err(e) => {
warn!("Could not delete template from {}: {:?}", id, e);
+5 -7
View File
@@ -66,7 +66,7 @@ pub async fn create_todo(
"
INSERT INTO todos (guild_id, channel_id, value)
VALUES (
(SELECT id FROM guilds WHERE guild = ?),
?,
(SELECT id FROM channels WHERE channel = ?),
?
)
@@ -88,7 +88,7 @@ pub async fn create_todo(
"
INSERT INTO todos (guild_id, channel_id, value)
VALUES (
(SELECT id FROM guilds WHERE guild = ?),
?,
NULL,
?
)
@@ -130,11 +130,9 @@ pub async fn get_todo(
channels.channel AS channel_id,
value
FROM todos
INNER JOIN guilds
ON guilds.id = todos.guild_id
LEFT JOIN channels
ON channels.id = todos.channel_id
WHERE guilds.guild = ?
WHERE todos.guild_id = ?
",
id
)
@@ -167,7 +165,7 @@ pub async fn update_todo(
"
UPDATE todos
SET value = ?
WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)
WHERE guild_id = ?
AND id = ?
",
todo.value,
@@ -202,7 +200,7 @@ pub async fn delete_todo(
sqlx::query!(
"
DELETE FROM todos
WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)
WHERE guild_id = ?
AND id = ?
",
guild_id,
-319
View File
@@ -1,319 +0,0 @@
use log::warn;
use rand::{rngs::OsRng, RngCore};
use rocket::{
data::{Data, ToByteUnit},
http::{CookieJar, Status},
post,
request::{FromRequest, Outcome},
serde::json::{json, Json, Value},
Request, State,
};
use sqlx::{MySql, Pool};
use crate::web::image_uploads::{prepare_image, ImageError, ImageUploads, MAX_UPLOAD_BYTES};
pub fn mount(
rocket: rocket::Rocket<rocket::Build>,
uploads: ImageUploads,
) -> rocket::Rocket<rocket::Build> {
let enabled = uploads.0.is_some();
let rocket = rocket.manage(uploads);
if enabled {
rocket.mount("/dashboard", rocket::routes![upload_image])
} else {
rocket
}
}
pub struct Uploader(u64);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Uploader {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
match uploader_id(request.cookies()) {
Some(id) => Outcome::Success(Self(id)),
None => Outcome::Error((Status::Unauthorized, ())),
}
}
}
fn uploader_id(cookies: &CookieJar<'_>) -> Option<u64> {
cookies.get_private("userid")?.value().parse().ok().filter(|id| *id != 0)
}
type UploadResult = Result<Json<Value>, (Status, Json<Value>)>;
fn error(status: Status, message: &str) -> (Status, Json<Value>) {
(status, Json(json!({ "error": message })))
}
// Requiring a non-form content type prevents cross-site HTML form submissions.
// Authorization is a request guard, so it runs before the body is read.
#[post("/api/images", format = "application/octet-stream", data = "<data>")]
pub async fn upload_image(
uploader: Uploader,
uploads: &State<ImageUploads>,
pool: &State<Pool<MySql>>,
data: Data<'_>,
) -> UploadResult {
let service =
uploads.0.as_ref().ok_or_else(|| error(Status::NotFound, "Image uploads disabled"))?;
let _permit = service
.slots
.clone()
.try_acquire_owned()
.map_err(|_| error(Status::TooManyRequests, "Image uploads busy. Please try again."))?;
let bytes = data
.open(MAX_UPLOAD_BYTES.bytes())
.into_bytes()
.await
.map_err(|_| error(Status::BadRequest, "Could not read image"))?;
if !bytes.is_complete() {
return Err(error(Status::PayloadTooLarge, "Images must be no larger than 8 MiB"));
}
let service_for_moderation = service.clone();
let moderated = tokio::task::spawn_blocking(move || {
prepare_image(service_for_moderation.model.as_ref(), &bytes.into_inner())
})
.await
.map_err(|err| {
warn!("Image moderation task failed: {}", err);
error(Status::ServiceUnavailable, "Image moderation unavailable. Please try again.")
})?;
let image = moderated.map_err(|err| match err {
ImageError::InvalidImage => error(
Status::UnprocessableEntity,
"Please upload a valid PNG, JPEG, GIF or WebP image (at most 16 million pixels)",
),
ImageError::Nsfw => error(
Status::UnavailableForLegalReasons,
"This image was flagged as probably NSFW and cannot be uploaded.",
),
ImageError::ModerationFailed => {
error(Status::ServiceUnavailable, "Image moderation unavailable. Please try again.")
}
})?;
let mut random = [0u8; 32];
OsRng.fill_bytes(&mut random);
let key = format!(
"uploads/{}.png",
random.iter().map(|byte| format!("{:02x}", byte)).collect::<String>()
);
let url = format!("{}/{}", service.public_url, key);
let response =
service.bucket.put_object_with_content_type(&key, &image, "image/png").await.map_err(
|err| {
warn!("Could not upload image to S3: {}", err);
error(Status::BadGateway, "Could not store image. Please try again.")
},
)?;
if !(200..300).contains(&response.status_code()) {
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 (?, ?)")
.bind(uploader.0)
.bind(&url)
.execute(pool.inner())
.await
{
warn!("Could not record image upload: {}", err);
match service.bucket.delete_object(&key).await {
Ok(response) if (200..300).contains(&response.status_code()) => {}
_ => warn!("Could not clean up untracked image object {}", key),
}
return Err(error(
Status::InternalServerError,
"Could not record image upload. Please try again.",
));
}
Ok(Json(json!({ "url": url })))
}
#[cfg(test)]
mod tests {
use std::{io::Cursor, sync::Arc};
use rocket::{http::ContentType, local::asynchronous::Client};
use s3::{creds::Credentials, Bucket, Region};
use tokio::sync::Semaphore;
use super::*;
use crate::web::image_uploads::ImageUploadService;
fn service(unsafe_image: bool, endpoint: &str) -> ImageUploads {
let model: &[u8] = if unsafe_image {
include_bytes!("../../../../../tests/fixtures/image-upload-unsafe.onnx")
} else {
include_bytes!("../../../../../tests/fixtures/image-upload-safe.onnx")
};
let bucket = Bucket::new(
"images",
Region::Custom { region: "eu-west-2".into(), endpoint: endpoint.into() },
Credentials::new(Some("test"), Some("test"), None, None, None).unwrap(),
)
.unwrap()
.with_path_style();
ImageUploads(Some(Arc::new(ImageUploadService {
bucket,
public_url: "https://images.example.com".into(),
model: Some(nsfw::create_model(model).unwrap()),
slots: Arc::new(Semaphore::new(2)),
})))
}
fn image_bytes() -> Vec<u8> {
let mut bytes = Cursor::new(Vec::new());
image::DynamicImage::new_rgb8(2, 2).write_to(&mut bytes, image::ImageFormat::Png).unwrap();
bytes.into_inner()
}
async fn client(uploads: ImageUploads) -> Client {
let pool = sqlx::mysql::MySqlPoolOptions::new()
.connect_lazy("mysql://unused:unused@127.0.0.1:1/unused")
.unwrap();
Client::tracked(mount(rocket::build().manage(pool), uploads)).await.unwrap()
}
#[rocket::async_test]
async fn disabled_upload_route_is_not_available() {
let client = client(ImageUploads(None)).await;
assert_eq!(
client
.post("/dashboard/api/images")
.header(ContentType::Binary)
.body(image_bytes())
.dispatch()
.await
.status(),
Status::NotFound
);
}
#[rocket::async_test]
async fn upload_requires_an_authenticated_private_cookie() {
let client = client(service(false, "http://127.0.0.1:1")).await;
for cookie in [None, Some("not-an-id"), Some("0")] {
let mut request = client
.post("/dashboard/api/images")
.header(ContentType::Binary)
.body(image_bytes());
if let Some(cookie) = cookie {
request = request.private_cookie(("userid", cookie));
}
assert_eq!(request.dispatch().await.status(), Status::Unauthorized);
}
}
#[rocket::async_test]
async fn unsafe_images_return_451_without_contacting_storage_or_database() {
let client = client(service(true, "http://127.0.0.1:1")).await;
let response = client
.post("/dashboard/api/images")
.header(ContentType::Binary)
.private_cookie(("userid", "123456789012345678"))
.body(image_bytes())
.dispatch()
.await;
assert_eq!(response.status(), Status::UnavailableForLegalReasons);
assert!(response.into_json::<Value>().await.unwrap()["error"]
.as_str()
.unwrap()
.contains("NSFW"));
}
#[rocket::async_test]
async fn rejects_invalid_oversized_and_form_uploads() {
let client = client(service(false, "http://127.0.0.1:1")).await;
for (body, status) in [
(b"this is not an image".to_vec(), Status::UnprocessableEntity),
(vec![0; MAX_UPLOAD_BYTES as usize + 1], Status::PayloadTooLarge),
] {
let response = client
.post("/dashboard/api/images")
.header(ContentType::Binary)
.private_cookie(("userid", "123456789012345678"))
.body(body)
.dispatch()
.await;
assert_eq!(response.status(), status);
}
let response = client
.post("/dashboard/api/images")
.header(ContentType::Form)
.private_cookie(("userid", "123456789012345678"))
.body("file=anything")
.dispatch()
.await;
assert_eq!(response.status(), Status::NotFound);
}
#[sqlx::test]
async fn stores_moderated_image_and_tracks_uploader(pool: Pool<MySql>) {
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
let storage = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut received = Vec::new();
let mut buffer = [0; 4096];
let (headers, body) = loop {
let count = socket.read(&mut buffer).await.unwrap();
assert!(count > 0);
received.extend_from_slice(&buffer[..count]);
if let Some(end) = received.windows(4).position(|window| window == b"\r\n\r\n") {
let headers = String::from_utf8(received[..end].to_vec()).unwrap();
let length: usize = headers
.lines()
.find_map(|line| {
line.to_lowercase()
.strip_prefix("content-length:")
.map(|length| length.trim().parse().unwrap())
})
.unwrap();
if received.len() >= end + 4 + length {
break (headers, received[end + 4..end + 4 + length].to_vec());
}
}
};
socket
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
.await
.unwrap();
(headers, body)
});
let client =
Client::tracked(mount(rocket::build().manage(pool.clone()), service(false, &endpoint)))
.await
.unwrap();
let response = client
.post("/dashboard/api/images")
.header(ContentType::Binary)
.private_cookie(("userid", "123456789012345678"))
.body(image_bytes())
.dispatch()
.await;
assert_eq!(response.status(), Status::Ok);
let response = response.into_json::<Value>().await.unwrap();
let url = response["url"].as_str().unwrap();
let row: (u64, String) = sqlx::query_as("SELECT uploader, url FROM image_uploads")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row, (123456789012345678, url.to_string()));
let (headers, body) = storage.await.unwrap();
assert!(headers.starts_with("PUT /images/uploads/"));
assert!(headers.to_lowercase().contains("content-type: image/png"));
assert_eq!(image::load_from_memory(&body).unwrap().width(), 2);
assert!(url.starts_with("https://images.example.com/uploads/"));
assert!(url.ends_with(".png"));
}
}
-1
View File
@@ -1,5 +1,4 @@
pub mod guild;
pub mod images;
pub mod user;
use log::warn;
-5
View File
@@ -23,7 +23,6 @@ use serenity::{
use sqlx::{MySql, Pool};
use crate::web::guards::transaction::Transaction;
use crate::web::image_uploads::ImageUploads;
#[derive(Serialize)]
struct UserPreferences {
@@ -37,7 +36,6 @@ struct UserPreferences {
struct UserInfo {
name: String,
patreon: bool,
image_uploads: bool,
preferences: UserPreferences,
}
@@ -54,12 +52,10 @@ pub async fn get_user_info(
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
uploads: &State<ImageUploads>,
) -> JsonValue {
offline!(json!(UserInfo {
name: "Discord".to_string(),
patreon: true,
image_uploads: uploads.0.is_some(),
preferences: UserPreferences {
timezone: "UTC".to_string(),
use_browser_timezone: true,
@@ -105,7 +101,6 @@ pub async fn get_user_info(
);
let user_info = UserInfo {
image_uploads: uploads.0.is_some(),
name: cookies
.get_private("username")
.map_or("Discord User".to_string(), |c| c.value().to_string()),
+3 -12
View File
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
use serenity::{client::Context, model::id::UserId};
use sqlx::types::Json;
use crate::utils::check_subscription;
use crate::web::{
check_subscription,
consts::{
DAY, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
@@ -65,16 +65,7 @@ pub async fn create_reminder(
if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e);
// Provide more specific error messages based on the error type
let error_msg = match e {
Error::MissingDiscordPermission(permission) => format!(
"Please ensure the bot has the \"{}\" permission in the channel",
permission
),
_ => "Failed to configure channel for reminders.".to_string(),
};
return Err(json!({"error": error_msg}));
return Err(json!({"error": "Failed to configure channel for reminders."}));
}
let channel = channel.unwrap();
@@ -131,7 +122,7 @@ pub async fn create_reminder(
|| reminder.interval_days.is_some()
|| reminder.interval_months.is_some()
{
if !check_subscription(&ctx, transaction.executor(), user_id, None).await {
if !check_subscription(&ctx, user_id).await {
return Err(json!({"error": "Patreon is required to set intervals"}));
}
}
@@ -9,8 +9,8 @@ use rocket::{
use serenity::{client::Context, model::id::UserId};
use sqlx::{MySql, Pool};
use crate::utils::check_subscription;
use crate::web::{
check_subscription,
guards::transaction::Transaction,
routes::{
dashboard::{
@@ -162,9 +162,7 @@ pub async fn edit_reminder(
|| reminder.interval_months.flatten().is_some()
|| reminder.interval_seconds.flatten().is_some()
{
if check_subscription(&ctx.inner(), transaction.executor(), UserId::from(user_id), None)
.await
{
if check_subscription(&ctx.inner(), user_id).await {
let new_interval_length = match reminder.interval_days {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
@@ -3,10 +3,13 @@ use crate::web::{
check_authorization,
guards::transaction::Transaction,
routes::{
dashboard::{ImportBody, ReminderTemplateCsv},
dashboard::{
create_reminder, CreateReminder, ImportBody, ReminderCsv, ReminderTemplateCsv, TodoCsv,
},
JsonResult,
},
};
use crate::Database;
use base64::{prelude::BASE64_STANDARD, Engine};
use csv::{QuoteStyle, WriterBuilder};
use log::warn;
@@ -17,7 +20,10 @@ use rocket::{
serde::json::{json, Json},
State,
};
use serenity::{client::Context, model::id::GuildId};
use serenity::{
client::Context,
model::id::{ChannelId, GuildId, UserId},
};
use sqlx::{MySql, Pool};
#[get("/api/guild/<id>/export/reminder_templates")]
@@ -33,7 +39,8 @@ pub async fn export(
match sqlx::query_as_unchecked!(
ReminderTemplateCsv,
"SELECT
"
SELECT
name,
attachment,
attachment_name,
@@ -54,7 +61,9 @@ pub async fn export(
interval_months,
tts,
username
FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
FROM reminder_template
WHERE guild_id = ?
",
id
)
.fetch_all(pool.inner())
@@ -138,7 +147,6 @@ pub async fn import(
};
create_reminder_template(
ctx.inner(),
&mut transaction,
GuildId::new(id),
reminder_template,
+4 -2
View File
@@ -2,7 +2,9 @@ use crate::web::{
check_authorization,
guards::transaction::Transaction,
routes::{
dashboard::{create_reminder, CreateReminder, ImportBody, ReminderCsv},
dashboard::{
create_reminder, CreateReminder, ImportBody, ReminderCsv, ReminderTemplateCsv, TodoCsv,
},
JsonResult,
},
};
@@ -19,7 +21,7 @@ use rocket::{
};
use serenity::{
client::Context,
model::id::{GuildId, UserId},
model::id::{ChannelId, GuildId, UserId},
};
use sqlx::{MySql, Pool};
+11 -6
View File
@@ -1,10 +1,14 @@
use crate::web::{
check_authorization,
guards::transaction::Transaction,
routes::{
dashboard::{ImportBody, TodoCsv},
dashboard::{
create_reminder, CreateReminder, ImportBody, ReminderCsv, ReminderTemplateCsv, TodoCsv,
},
JsonResult,
},
};
use crate::Database;
use base64::{prelude::BASE64_STANDARD, Engine};
use csv::{QuoteStyle, WriterBuilder};
use log::warn;
@@ -17,7 +21,7 @@ use rocket::{
};
use serenity::{
client::Context,
model::id::{ChannelId, GuildId},
model::id::{ChannelId, GuildId, UserId},
};
use sqlx::{MySql, Pool};
@@ -34,10 +38,11 @@ pub async fn export(
match sqlx::query_as_unchecked!(
TodoCsv,
"SELECT value, CONCAT('#', channels.channel) AS channel_id FROM todos
"
SELECT value, CONCAT('#', channels.channel) AS channel_id FROM todos
LEFT JOIN channels ON todos.channel_id = channels.id
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?",
INNER JOIN guilds ON todos.guild_id = ?
",
id
)
.fetch_all(pool.inner())
@@ -92,7 +97,7 @@ pub async fn import(
Ok(body) => {
let mut reader = csv::Reader::from_reader(body.as_slice());
let query_placeholder = "(?, (SELECT id FROM channels WHERE channel = ?), (SELECT id FROM guilds WHERE guild = ?))";
let query_placeholder = "(?, (SELECT id FROM channels WHERE channel = ?), ?)";
let mut query_params = vec![];
for result in reader.deserialize::<TodoCsv>() {
+30 -65
View File
@@ -10,7 +10,6 @@ use rocket::{
use rocket_dyn_templates::Template;
use secrecy::ExposeSecret;
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use serenity::http::HttpError;
use serenity::{
all::CacheHttp,
builder::CreateWebhook,
@@ -20,9 +19,9 @@ use serenity::{
use sqlx::types::Json;
use sqlx::FromRow;
use crate::utils::check_subscription;
use crate::web::{
catchers::internal_server_error,
check_guild_subscription, check_subscription,
consts::{
CHARACTERS, DAY, DEFAULT_AVATAR, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH,
MAX_EMBED_DESCRIPTION_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH,
@@ -374,12 +373,12 @@ pub(crate) async fn create_reminder(
reminder: CreateReminder,
) -> JsonResult {
// check guild in db
match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.get())
match sqlx::query!("SELECT 1 as A FROM guilds WHERE id = ?", guild_id.get())
.fetch_one(transaction.executor())
.await
{
Err(sqlx::Error::RowNotFound) => {
if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.get())
if sqlx::query!("INSERT INTO guilds (id) VALUES (?)", guild_id.get())
.execute(transaction.executor())
.await
.is_err()
@@ -405,19 +404,9 @@ pub(crate) async fn create_reminder(
if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e);
// Provide more specific error messages based on the error type
let error_msg = match e {
Error::MissingDiscordPermission(permission) => {
format!(
"Please ensure the bot has the \"{}\" permission in the channel",
permission
)
}
_ => "Failed to configure channel for reminders. Please check the bot permissions"
.to_string(),
};
return Err(json!({"error": error_msg}));
return Err(
json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
);
}
let channel = channel.unwrap();
@@ -477,7 +466,9 @@ pub(crate) async fn create_reminder(
|| reminder.interval_days.is_some()
|| reminder.interval_months.is_some()
{
if !check_subscription(&ctx, transaction.executor(), user_id, Some(guild_id)).await {
if !check_guild_subscription(&ctx, guild_id).await
&& !check_subscription(&ctx, user_id).await
{
return Err(json!({"error": "Patreon is required to set intervals"}));
}
}
@@ -603,7 +594,6 @@ pub(crate) async fn create_reminder(
}
pub(crate) async fn create_reminder_template(
ctx: &Context,
transaction: &mut Transaction<'_>,
guild_id: GuildId,
reminder_template: ReminderTemplate,
@@ -668,7 +658,7 @@ pub(crate) async fn create_reminder_template(
interval_months,
tts,
username
) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?)",
guild_id.get(),
name,
@@ -725,36 +715,13 @@ async fn create_database_channel(
match row {
Ok(row) => {
let is_dm = channel
.to_channel(&ctx)
.await
.map_err(|e| {
if let serenity::Error::Http(http_error) = &e {
if let HttpError::UnsuccessfulRequest(response) = http_error {
if response.error.code == 50001 {
return Error::MissingDiscordPermission("View Channel");
}
}
}
Error::Serenity(e)
})?
.private()
.is_some();
let is_dm =
channel.to_channel(&ctx).await.map_err(|e| Error::Serenity(e))?.private().is_some();
if !is_dm && (row.webhook_token.is_none() || row.webhook_id.is_none()) {
let webhook = channel
.create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR))
.await
.map_err(|e| match &e {
serenity::Error::Http(HttpError::UnsuccessfulRequest(response)) => {
match response.error.code {
50001 => Error::MissingDiscordPermission("View Channel"),
50013 => Error::MissingDiscordPermission("Manage Webhooks"),
_ => Error::Serenity(e),
}
}
_ => Error::Serenity(e),
})?;
.map_err(|e| Error::Serenity(e))?;
let token = webhook.token.unwrap();
@@ -779,16 +746,7 @@ async fn create_database_channel(
let webhook = channel
.create_webhook(&ctx, CreateWebhook::new("Reminder").avatar(&*DEFAULT_AVATAR))
.await
.map_err(|e| match &e {
serenity::Error::Http(HttpError::UnsuccessfulRequest(response)) => {
match response.error.code {
50001 => Error::MissingDiscordPermission("View Channel"),
50013 => Error::MissingDiscordPermission("Manage Webhooks"),
_ => Error::Serenity(e),
}
}
_ => Error::Serenity(e),
})?;
.map_err(|e| Error::Serenity(e))?;
let token = webhook.token.unwrap();
@@ -847,15 +805,22 @@ pub async fn todos_redirect(id: &str) -> Redirect {
#[get("/")]
pub async fn dashboard_home(cookies: &CookieJar<'_>) -> DashboardPage {
render_dashboard(cookies).await
}
#[get("/<_..>")]
pub async fn dashboard(cookies: &CookieJar<'_>) -> DashboardPage {
render_dashboard(cookies).await
}
async fn render_dashboard(cookies: &CookieJar<'_>) -> DashboardPage {
if cookies.get_private("userid").is_some() {
match NamedFile::open(Path::new(path!("static/index.html"))).await {
Ok(f) => DashboardPage::Ok(f),
Err(e) => {
warn!("Couldn't render dashboard: {:?}", e);
DashboardPage::NotConfigured(internal_server_error().await)
}
}
} else {
DashboardPage::Unauthorised(Redirect::to("/login/discord"))
}
}
#[get("/<_..>")]
pub async fn dashboard(cookies: &CookieJar<'_>) -> DashboardPage {
if cookies.get_private("userid").is_some() {
match NamedFile::open(Path::new(path!("static/index.html"))).await {
Ok(f) => DashboardPage::Ok(f),
+1
View File
@@ -0,0 +1 @@
/home/jude/reminder-bot/reminder-dashboard/dist/static/assets
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more