Compare commits

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

No files matched your search

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