Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f35c5082f1 | ||
|
|
19cfacffe5 |
No files matched your search
+2
-4
@@ -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
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "DELETE FROM patreon_link WHERE user_id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0402e16b1ec89a96d893d43f6b40500ccbde3c619116a702c87954df49898e23"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
-24
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+134
@@ -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"
|
||||
}
|
||||
+26
-6
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
-24
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
+264
@@ -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"
|
||||
}
|
||||
+24
@@ -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"
|
||||
}
|
||||
+26
-6
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+264
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+2
-12
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,83 @@
|
||||
-- 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;
|
||||
|
||||
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;
|
||||
DROP TABLE todos_old;
|
||||
|
||||
-- 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 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)
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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,84 +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[]) => {
|
||||
if (!uploadsEnabled || uploadInProgress.current) return;
|
||||
setError("");
|
||||
if (files.length !== 1) {
|
||||
setError("Please upload one image at a time.");
|
||||
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]);
|
||||
|
||||
if (blocked) {
|
||||
return (
|
||||
<Modal
|
||||
setModalOpen={() => setBlocked(false)}
|
||||
title="Image upload blocked"
|
||||
onSubmit={() => setBlocked(false)}
|
||||
onSubmitText="OK"
|
||||
>
|
||||
<p>
|
||||
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>
|
||||
The image you attempted to upload <strong>has not been stored.</strong>
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
const ImagePickerModal = ({ setModalOpen, setImage }) => {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -158,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
|
||||
@@ -169,50 +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" : ""}`}
|
||||
>
|
||||
<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">
|
||||
PNG, JPEG, GIF or WebP, up to 8 MiB. All uploads are converted to PNGs.
|
||||
</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>
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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> {
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = ?),
|
||||
?
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
@@ -87,7 +87,7 @@ 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 = ?",
|
||||
"DELETE FROM reminder_template WHERE guild_id = ? AND id = ?",
|
||||
id, delete_reminder_template.id
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
pub mod guild;
|
||||
pub mod images;
|
||||
pub mod user;
|
||||
|
||||
use log::warn;
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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>() {
|
||||
|
||||
@@ -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"}));
|
||||
}
|
||||
}
|
||||
@@ -668,7 +659,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 +716,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 +747,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 +806,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),
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/home/jude/reminder-bot/reminder-dashboard/dist/static/assets
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/home/jude/reminder-bot/reminder-dashboard/dist/index.html
|
||||
@@ -1,11 +0,0 @@
|
||||
[Unit]
|
||||
Description=Clean orphaned image uploads from Reminder Bot
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
User=reminder
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/reminder-rs clean-images
|
||||
WorkingDirectory=/etc/reminder-rs
|
||||
Environment="RUST_LOG=reminder_rs=info"
|
||||
@@ -1,10 +0,0 @@
|
||||
[Unit]
|
||||
Description=Clean orphaned Reminder Bot image uploads hourly
|
||||
|
||||
[Timer]
|
||||
OnCalendar=hourly
|
||||
Persistent=true
|
||||
Unit=reminder-rs-clean-images.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -19,24 +19,6 @@
|
||||
Fill out the "time" and "content" fields. If you wish, press on "Optional" to view other options
|
||||
for the reminder.
|
||||
</p>
|
||||
<p class="subtitle">Time</p>
|
||||
<p class="content">
|
||||
The bot will take a "best-guess" at what time you entered. It will favour UK date formats
|
||||
over US date formats (MM/DD/YY) where possible.
|
||||
<br>
|
||||
You can also use <code>cron</code>-like syntax to specify the time. For example, using
|
||||
<code>0 0 1 * *</code> will send the reminder at midnight on the first of the next month.
|
||||
For more information on cron syntax, see <a href="https://crontab.guru/">crontab.guru</a>.
|
||||
<br>
|
||||
<strong>Cron syntax is not repeating</strong>. Please use the optional "interval" field to specify a repetition interval.
|
||||
</p>
|
||||
<p class="subtitle">Pings</p>
|
||||
<p class="content">
|
||||
Roles and users can be pinged by including their @ mention in the "content" field.
|
||||
To ping a role, the role must be set as mentionable, and the bot must have permissions to mention the role.
|
||||
<br>
|
||||
Please note that when using the dashboard, roles can only be pinged in the "Content..." field and not the embed fields.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -55,40 +37,4 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hero is-small">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<p class="title">Custom formatting rules</p>
|
||||
<p class="content">
|
||||
Reminder content can be customized using formatting rules.
|
||||
</p>
|
||||
<p class="subtitle">timefrom</p>
|
||||
<p class="content">
|
||||
The <code>timefrom</code> formatting rule will display a formatted difference
|
||||
between the time the reminder sends and a specified time.
|
||||
<br>
|
||||
For example, if the current time is 1755800000 (UNIX time), the format string
|
||||
<code><<timefrom:1755803600>></code> would display "1 hour"
|
||||
</p>
|
||||
<p class="subtitle">timenow</p>
|
||||
<p class="content">
|
||||
The <code>timenow</code> formatting rule displays the current time or an offset
|
||||
from the current time in a given timezone in a custom format.
|
||||
<br>
|
||||
For example, if the current time is 1755800000 (UNIX time), the format string
|
||||
<code><<timenow:UTC:%H:%M:%S>></code> would display "18:13:20"
|
||||
<br>
|
||||
Optionally, an offset can be provided to display a time from your current time.
|
||||
For example, if the current time is 1755800000 (UNIX time), the format string
|
||||
<code><<timenow+120:UTC:%H:%M:%S>></code> would display "18:15:20",
|
||||
or <code><<timenow-120:UTC:%H:%M:%S>></code> would display "18:11:20"
|
||||
<br>
|
||||
You can use this feature alongside Discord's timestamp formatting. The following
|
||||
will show the text "in 2 minutes" for all users as a Discord timestamp:
|
||||
<code><t:<<timenow+120:UTC:%s>>:R></code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user