Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
439575acd0 | ||
|
|
2665c61d33 |
No files matched your search
@@ -1,7 +1,6 @@
|
|||||||
target
|
target
|
||||||
.env
|
.env
|
||||||
/venv
|
/venv
|
||||||
__pycache__/
|
|
||||||
.cargo
|
.cargo
|
||||||
.idea
|
.idea
|
||||||
static/index.html
|
static/index.html
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
{
|
{
|
||||||
"db_name": "MySQL",
|
"db_name": "MySQL",
|
||||||
"query": "\n SELECT reminders.attachment,\n reminders.attachment_name,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?\n ",
|
"query": "\n SELECT\n reminders.attachment_name,\n reminders.avatar,\n channels.channel,\n reminders.message_json,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.restartable,\n reminders.tts,\n reminders.uid,\n reminders.username,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "attachment",
|
|
||||||
"type_info": {
|
|
||||||
"type": "Blob",
|
|
||||||
"flags": "BLOB | BINARY",
|
|
||||||
"max_size": 16777215
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "attachment_name",
|
"name": "attachment_name",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -21,8 +12,35 @@
|
|||||||
"max_size": 1040
|
"max_size": 1040
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "avatar",
|
||||||
|
"type_info": {
|
||||||
|
"type": "VarString",
|
||||||
|
"flags": "",
|
||||||
|
"max_size": 2048
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
|
"name": "channel",
|
||||||
|
"type_info": {
|
||||||
|
"type": "LongLong",
|
||||||
|
"flags": "UNIQUE_KEY | UNSIGNED | NO_DEFAULT_VALUE",
|
||||||
|
"max_size": 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "message_json",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Json",
|
||||||
|
"flags": "BLOB | BINARY",
|
||||||
|
"max_size": 4294967295
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
"name": "content",
|
"name": "content",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -31,7 +49,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 5,
|
||||||
"name": "embed_author",
|
"name": "embed_author",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -40,7 +58,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 6,
|
||||||
"name": "embed_author_url",
|
"name": "embed_author_url",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -49,7 +67,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 7,
|
||||||
"name": "embed_color",
|
"name": "embed_color",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -58,7 +76,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 8,
|
||||||
"name": "embed_description",
|
"name": "embed_description",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -67,7 +85,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 9,
|
||||||
"name": "embed_footer",
|
"name": "embed_footer",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -76,7 +94,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 8,
|
"ordinal": 10,
|
||||||
"name": "embed_footer_url",
|
"name": "embed_footer_url",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -85,7 +103,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 9,
|
"ordinal": 11,
|
||||||
"name": "embed_image_url",
|
"name": "embed_image_url",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -94,7 +112,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 12,
|
||||||
"name": "embed_thumbnail_url",
|
"name": "embed_thumbnail_url",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -103,7 +121,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 13,
|
||||||
"name": "embed_title",
|
"name": "embed_title",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -112,7 +130,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 12,
|
"ordinal": 14,
|
||||||
"name": "embed_fields",
|
"name": "embed_fields",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Json",
|
"type": "Json",
|
||||||
@@ -121,7 +139,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 13,
|
"ordinal": 15,
|
||||||
"name": "enabled",
|
"name": "enabled",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Tiny",
|
"type": "Tiny",
|
||||||
@@ -130,7 +148,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 14,
|
"ordinal": 16,
|
||||||
"name": "expires",
|
"name": "expires",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Datetime",
|
"type": "Datetime",
|
||||||
@@ -139,7 +157,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 15,
|
"ordinal": 17,
|
||||||
"name": "interval_seconds",
|
"name": "interval_seconds",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -148,7 +166,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 16,
|
"ordinal": 18,
|
||||||
"name": "interval_days",
|
"name": "interval_days",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -157,7 +175,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 17,
|
"ordinal": 19,
|
||||||
"name": "interval_months",
|
"name": "interval_months",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -166,7 +184,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 18,
|
"ordinal": 20,
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -175,7 +193,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 19,
|
"ordinal": 21,
|
||||||
|
"name": "restartable",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Tiny",
|
||||||
|
"flags": "NOT_NULL",
|
||||||
|
"max_size": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 22,
|
||||||
"name": "tts",
|
"name": "tts",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Tiny",
|
"type": "Tiny",
|
||||||
@@ -184,7 +211,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 20,
|
"ordinal": 23,
|
||||||
"name": "uid",
|
"name": "uid",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -193,7 +220,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 21,
|
"ordinal": 24,
|
||||||
|
"name": "username",
|
||||||
|
"type_info": {
|
||||||
|
"type": "VarString",
|
||||||
|
"flags": "",
|
||||||
|
"max_size": 128
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 25,
|
||||||
"name": "utc_time",
|
"name": "utc_time",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Datetime",
|
"type": "Datetime",
|
||||||
@@ -206,6 +242,8 @@
|
|||||||
"Right": 1
|
"Right": 1
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
|
true,
|
||||||
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
@@ -227,8 +265,10 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "ecbea4acb61da864a4f83004b1163391068456a366fee95daff5f46413bcf494"
|
"hash": "168e34b942db6025a5674dca06ac154309cc8f86341272e7c771abfad1b61003"
|
||||||
}
|
}
|
||||||
@@ -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 +1,12 @@
|
|||||||
{
|
{
|
||||||
"db_name": "MySQL",
|
"db_name": "MySQL",
|
||||||
"query": "\n INSERT INTO reminders (\n `uid`,\n `channel_id`,\n `thread_id`,\n `utc_time`,\n `timezone`,\n `interval_seconds`,\n `interval_days`,\n `interval_months`,\n `expires`,\n `content`,\n `tts`,\n `attachment_name`,\n `attachment`,\n `set_by`\n ) VALUES (\n ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?\n )\n ",
|
"query": "\n INSERT INTO reminders (\n `uid`,\n `channel_id`,\n `thread_id`,\n `utc_time`,\n `timezone`,\n `interval_seconds`,\n `interval_days`,\n `interval_months`,\n `expires`,\n `message_json`,\n `attachment_name`,\n `attachment`,\n `set_by`\n ) VALUES (\n ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?\n )\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Right": 14
|
"Right": 13
|
||||||
},
|
},
|
||||||
"nullable": []
|
"nullable": []
|
||||||
},
|
},
|
||||||
"hash": "f9d2d9f6809b6c852297e5c60b6c356af5176c6b81b86cd701c1b8af8212f194"
|
"hash": "22ea7fbff927d2696e5c3dc78f714d1cb5dadcc560854e22f15def122c64fc0c"
|
||||||
}
|
}
|
||||||
@@ -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 reminders.id = ?\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.message_json,\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 reminders.id = ?\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -86,6 +86,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 9,
|
"ordinal": 9,
|
||||||
|
"name": "message_json",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Json",
|
||||||
|
"flags": "BLOB | BINARY",
|
||||||
|
"max_size": 4294967295
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
"name": "content",
|
"name": "content",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -94,7 +103,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 11,
|
||||||
"name": "embed_description",
|
"name": "embed_description",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -103,7 +112,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 12,
|
||||||
"name": "set_by",
|
"name": "set_by",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "LongLong",
|
"type": "LongLong",
|
||||||
@@ -125,10 +134,11 @@
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "67b970d7b85f7bf3e71a58af10bedf7281cf0d8601b149383037d604a22a64c7"
|
"hash": "2753eb51d5f4b51771b696555752cc59d8d0c78737fc35210c5f6108d32f3540"
|
||||||
}
|
}
|
||||||
@@ -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 reminders.uid = ?\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.message_json,\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 reminders.uid = ?\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -86,6 +86,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 9,
|
"ordinal": 9,
|
||||||
|
"name": "message_json",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Json",
|
||||||
|
"flags": "BLOB | BINARY",
|
||||||
|
"max_size": 4294967295
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
"name": "content",
|
"name": "content",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -94,7 +103,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 11,
|
||||||
"name": "embed_description",
|
"name": "embed_description",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -103,7 +112,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 12,
|
||||||
"name": "set_by",
|
"name": "set_by",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "LongLong",
|
"type": "LongLong",
|
||||||
@@ -125,10 +134,11 @@
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "d7f469dd1c7f37f1aca643e9018083b48da1cf988030b9efeae451e77e54a23d"
|
"hash": "40ceeb037b5266e743a77a4a657d222b769e871b521d3a1745caed0292e9e6cc"
|
||||||
}
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "MySQL",
|
|
||||||
"query": "INSERT INTO reminders (\n uid,\n attachment,\n attachment_name,\n channel_id,\n avatar,\n content,\n embed_author,\n embed_author_url,\n embed_color,\n embed_description,\n embed_footer,\n embed_footer_url,\n embed_image_url,\n embed_thumbnail_url,\n embed_title,\n embed_fields,\n enabled,\n expires,\n interval_seconds,\n interval_days,\n interval_months,\n name,\n restartable,\n tts,\n username,\n `utc_time`\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 26
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "4cc3ff2bbdf8502f6e739a002c7aa16df8c4f24de0966b26b268efab97e205e3"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"db_name": "MySQL",
|
||||||
|
"query": "\n SELECT id, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until\n FROM channels\n WHERE channel = ?\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Long",
|
||||||
|
"flags": "NOT_NULL | PRIMARY_KEY | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||||
|
"max_size": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "channel",
|
||||||
|
"type_info": {
|
||||||
|
"type": "LongLong",
|
||||||
|
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | NO_DEFAULT_VALUE",
|
||||||
|
"max_size": 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": {
|
||||||
|
"type": "VarString",
|
||||||
|
"flags": "",
|
||||||
|
"max_size": 400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "nudge",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Short",
|
||||||
|
"flags": "NOT_NULL",
|
||||||
|
"max_size": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "blacklisted",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Tiny",
|
||||||
|
"flags": "NOT_NULL",
|
||||||
|
"max_size": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "webhook_id",
|
||||||
|
"type_info": {
|
||||||
|
"type": "LongLong",
|
||||||
|
"flags": "UNIQUE_KEY | UNSIGNED",
|
||||||
|
"max_size": 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "webhook_token",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Blob",
|
||||||
|
"flags": "BLOB",
|
||||||
|
"max_size": 262140
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "paused",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Tiny",
|
||||||
|
"flags": "NOT_NULL",
|
||||||
|
"max_size": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "paused_until",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Timestamp",
|
||||||
|
"flags": "BINARY",
|
||||||
|
"max_size": 19
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "4d26e92088234552f9159b876c7fdd3bb557a7f918820e289bd39742de553883"
|
||||||
|
}
|
||||||
@@ -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 channels.id = reminders.channel_id\n WHERE\n `status` = 'pending' AND\n channels.id = (SELECT dm_channel FROM users WHERE id = ?)\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.message_json,\n reminders.content,\n reminders.embed_description,\n reminders.set_by\n FROM\n reminders\n INNER JOIN\n channels\n ON\n channels.id = reminders.channel_id\n WHERE\n `status` = 'pending' AND\n channels.id = (SELECT dm_channel FROM users WHERE id = ?)\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -86,6 +86,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 9,
|
"ordinal": 9,
|
||||||
|
"name": "message_json",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Json",
|
||||||
|
"flags": "BLOB | BINARY",
|
||||||
|
"max_size": 4294967295
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
"name": "content",
|
"name": "content",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -94,7 +103,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 11,
|
||||||
"name": "embed_description",
|
"name": "embed_description",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -103,7 +112,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 12,
|
||||||
"name": "set_by",
|
"name": "set_by",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "LongLong",
|
"type": "LongLong",
|
||||||
@@ -125,10 +134,11 @@
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "45e555a2a0664662f55cc738f91abf8c51648fb687f9568c4dd5f48e9273253a"
|
"hash": "5575971e2a460476adcc70147810caa91fb07f52d788af0f7ec319a15ee5020c"
|
||||||
}
|
}
|
||||||
@@ -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 LEFT JOIN\n channels\n ON\n channels.id = reminders.channel_id\n WHERE\n `status` = 'pending' AND\n channels.guild_id = (SELECT id FROM guilds WHERE guild = ?)\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.message_json,\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 channels.guild_id = (SELECT id FROM guilds WHERE guild = ?)\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -86,6 +86,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 9,
|
"ordinal": 9,
|
||||||
|
"name": "message_json",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Json",
|
||||||
|
"flags": "BLOB | BINARY",
|
||||||
|
"max_size": 4294967295
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
"name": "content",
|
"name": "content",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -94,7 +103,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 11,
|
||||||
"name": "embed_description",
|
"name": "embed_description",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -103,7 +112,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 12,
|
||||||
"name": "set_by",
|
"name": "set_by",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "LongLong",
|
"type": "LongLong",
|
||||||
@@ -125,10 +134,11 @@
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "4dc78a4ebdad0d9fb721389013537093dd814c313bc8ecc80fc9767420afb798"
|
"hash": "5efa565633215c62ae826b670140c2f36ccc019d56db7a0182ec00f1a2713562"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "MySQL",
|
"db_name": "MySQL",
|
||||||
"query": "\n SELECT\n reminders.`id` AS id,\n\n channels.`channel` AS channel_id,\n reminders.`thread_id` AS thread_id,\n channels.`webhook_id` AS webhook_id,\n channels.`webhook_token` AS webhook_token,\n\n channels.`paused` AS 'channel_paused',\n channels.`paused_until` AS 'channel_paused_until',\n reminders.`enabled` AS 'enabled',\n\n reminders.`tts` AS tts,\n reminders.`pin` AS pin,\n reminders.`content` AS content,\n reminders.`attachment` AS attachment,\n reminders.`attachment_name` AS attachment_name,\n\n reminders.`utc_time` AS 'utc_time',\n reminders.`timezone` AS timezone,\n reminders.`restartable` AS restartable,\n reminders.`expires` AS 'expires',\n reminders.`interval_seconds` AS 'interval_seconds',\n reminders.`interval_days` AS 'interval_days',\n reminders.`interval_months` AS 'interval_months',\n\n reminders.`avatar` AS avatar,\n reminders.`username` AS username\n FROM\n reminders\n INNER JOIN\n channels\n ON\n reminders.channel_id = channels.id\n WHERE\n reminders.`status` = 'pending' AND\n reminders.`id` IN (\n SELECT\n MIN(id)\n FROM\n reminders\n WHERE\n reminders.`utc_time` <= NOW() AND\n `status` = 'pending' AND\n (\n reminders.`interval_seconds` IS NOT NULL\n OR reminders.`interval_months` IS NOT NULL\n OR reminders.`interval_days` IS NOT NULL\n OR reminders.enabled\n )\n GROUP BY channel_id\n )\n ",
|
"query": "\n SELECT\n reminders.`id` AS id,\n\n channels.`channel` AS channel_id,\n reminders.`thread_id` AS thread_id,\n channels.`webhook_id` AS webhook_id,\n channels.`webhook_token` AS webhook_token,\n\n channels.`paused` AS 'channel_paused',\n channels.`paused_until` AS 'channel_paused_until',\n reminders.`enabled` AS 'enabled',\n\n reminders.`tts` AS tts,\n reminders.`pin` AS pin,\n reminders.`content` AS content,\n reminders.`message_json` AS message_json,\n reminders.`attachment` AS attachment,\n reminders.`attachment_name` AS attachment_name,\n\n reminders.`utc_time` AS 'utc_time',\n reminders.`timezone` AS timezone,\n reminders.`restartable` AS restartable,\n reminders.`expires` AS 'expires',\n reminders.`interval_seconds` AS 'interval_seconds',\n reminders.`interval_days` AS 'interval_days',\n reminders.`interval_months` AS 'interval_months',\n\n reminders.`avatar` AS avatar,\n reminders.`username` AS username\n FROM\n reminders\n INNER JOIN\n channels\n ON\n reminders.channel_id = channels.id\n WHERE\n reminders.`status` = 'pending' AND\n reminders.`id` IN (\n SELECT\n MIN(id)\n FROM\n reminders\n WHERE\n reminders.`utc_time` <= NOW() AND\n `status` = 'pending' AND\n (\n reminders.`interval_seconds` IS NOT NULL\n OR reminders.`interval_months` IS NOT NULL\n OR reminders.`interval_days` IS NOT NULL\n OR reminders.enabled\n )\n GROUP BY channel_id\n )\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -104,6 +104,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 11,
|
||||||
|
"name": "message_json",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Json",
|
||||||
|
"flags": "BLOB | BINARY",
|
||||||
|
"max_size": 4294967295
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
"name": "attachment",
|
"name": "attachment",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Blob",
|
"type": "Blob",
|
||||||
@@ -112,7 +121,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 12,
|
"ordinal": 13,
|
||||||
"name": "attachment_name",
|
"name": "attachment_name",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -121,7 +130,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 13,
|
"ordinal": 14,
|
||||||
"name": "utc_time",
|
"name": "utc_time",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Datetime",
|
"type": "Datetime",
|
||||||
@@ -130,7 +139,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 14,
|
"ordinal": 15,
|
||||||
"name": "timezone",
|
"name": "timezone",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "String",
|
"type": "String",
|
||||||
@@ -139,7 +148,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 15,
|
"ordinal": 16,
|
||||||
"name": "restartable",
|
"name": "restartable",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Tiny",
|
"type": "Tiny",
|
||||||
@@ -148,7 +157,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 16,
|
"ordinal": 17,
|
||||||
"name": "expires",
|
"name": "expires",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Datetime",
|
"type": "Datetime",
|
||||||
@@ -157,7 +166,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 17,
|
"ordinal": 18,
|
||||||
"name": "interval_seconds",
|
"name": "interval_seconds",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -166,7 +175,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 18,
|
"ordinal": 19,
|
||||||
"name": "interval_days",
|
"name": "interval_days",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -175,7 +184,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 19,
|
"ordinal": 20,
|
||||||
"name": "interval_months",
|
"name": "interval_months",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -184,7 +193,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 20,
|
"ordinal": 21,
|
||||||
"name": "avatar",
|
"name": "avatar",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -193,7 +202,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 21,
|
"ordinal": 22,
|
||||||
"name": "username",
|
"name": "username",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -219,6 +228,7 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
@@ -230,5 +240,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "a33ef7167746183766b6a9b4e31784dd3712fa2b4f72482d4b681e0a9fdbaf1d"
|
"hash": "6cc6fafaa4dab75639c6e5348d2a836bc6be510e76534c36df709883a4694859"
|
||||||
}
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "MySQL",
|
|
||||||
"query": "INSERT INTO reminders (\n uid,\n attachment,\n attachment_name,\n channel_id,\n content,\n embed_author,\n embed_author_url,\n embed_color,\n embed_description,\n embed_footer,\n embed_footer_url,\n embed_image_url,\n embed_thumbnail_url,\n embed_title,\n embed_fields,\n enabled,\n expires,\n interval_seconds,\n interval_days,\n interval_months,\n name,\n tts,\n `utc_time`\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 23
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "7f44451c441507ccd7f8d41633fb156f95a28389fcc9c489fdb228446f88342e"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "MySQL",
|
||||||
|
"query": "INSERT INTO reminders (\n uid, attachment, attachment_name, channel_id, message_json,\n enabled, expires, interval_seconds, interval_days, interval_months,\n name, restartable, `utc_time`\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 13
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "8051034e8334a31d5c8ffca9893f85b689955c1e3d144e1162d4c991a47d9852"
|
||||||
|
}
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
{
|
{
|
||||||
"db_name": "MySQL",
|
"db_name": "MySQL",
|
||||||
"query": "\n SELECT\n reminders.attachment_name,\n reminders.avatar,\n channels.channel,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.restartable,\n reminders.tts,\n reminders.uid,\n reminders.username,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?",
|
"query": "\n SELECT reminders.attachment,\n reminders.attachment_name,\n reminders.message_json,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
|
"name": "attachment",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Blob",
|
||||||
|
"flags": "BLOB | BINARY",
|
||||||
|
"max_size": 16777215
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
"name": "attachment_name",
|
"name": "attachment_name",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -12,22 +21,13 @@
|
|||||||
"max_size": 1040
|
"max_size": 1040
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "avatar",
|
|
||||||
"type_info": {
|
|
||||||
"type": "VarString",
|
|
||||||
"flags": "",
|
|
||||||
"max_size": 2048
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "channel",
|
"name": "message_json",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "LongLong",
|
"type": "Json",
|
||||||
"flags": "UNIQUE_KEY | UNSIGNED | NO_DEFAULT_VALUE",
|
"flags": "BLOB | BINARY",
|
||||||
"max_size": 20
|
"max_size": 4294967295
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -185,15 +185,6 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 20,
|
"ordinal": 20,
|
||||||
"name": "restartable",
|
|
||||||
"type_info": {
|
|
||||||
"type": "Tiny",
|
|
||||||
"flags": "NOT_NULL",
|
|
||||||
"max_size": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 21,
|
|
||||||
"name": "tts",
|
"name": "tts",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Tiny",
|
"type": "Tiny",
|
||||||
@@ -202,7 +193,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 22,
|
"ordinal": 21,
|
||||||
"name": "uid",
|
"name": "uid",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -211,16 +202,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 23,
|
"ordinal": 22,
|
||||||
"name": "username",
|
|
||||||
"type_info": {
|
|
||||||
"type": "VarString",
|
|
||||||
"flags": "",
|
|
||||||
"max_size": 128
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 24,
|
|
||||||
"name": "utc_time",
|
"name": "utc_time",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Datetime",
|
"type": "Datetime",
|
||||||
@@ -255,10 +237,8 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
|
||||||
true,
|
|
||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "fa58f079f00bec4c3f9cbb2ee0a607f63c5b586654c541bff6b99eff83ae0a44"
|
"hash": "8e5e68f6cc7549dc3c1e777def9f6fd8a6b61c6790ee9594ca20c5504ca834fa"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "MySQL",
|
"db_name": "MySQL",
|
||||||
"query": "\n SELECT\n reminders.attachment_name,\n reminders.avatar,\n channels.channel,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.restartable,\n reminders.tts,\n reminders.uid,\n reminders.username,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?",
|
"query": "\n SELECT\n reminders.attachment_name,\n reminders.avatar,\n channels.channel,\n reminders.message_json,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.restartable,\n reminders.tts,\n reminders.uid,\n reminders.username,\n reminders.utc_time\n FROM reminders\n LEFT JOIN channels ON channels.id = reminders.channel_id\n WHERE uid = ?",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -32,6 +32,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
|
"name": "message_json",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Json",
|
||||||
|
"flags": "BLOB | BINARY",
|
||||||
|
"max_size": 4294967295
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
"name": "content",
|
"name": "content",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -40,7 +49,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 5,
|
||||||
"name": "embed_author",
|
"name": "embed_author",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -49,7 +58,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 6,
|
||||||
"name": "embed_author_url",
|
"name": "embed_author_url",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -58,7 +67,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 7,
|
||||||
"name": "embed_color",
|
"name": "embed_color",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -67,7 +76,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 8,
|
||||||
"name": "embed_description",
|
"name": "embed_description",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -76,7 +85,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 8,
|
"ordinal": 9,
|
||||||
"name": "embed_footer",
|
"name": "embed_footer",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -85,7 +94,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 9,
|
"ordinal": 10,
|
||||||
"name": "embed_footer_url",
|
"name": "embed_footer_url",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -94,7 +103,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 11,
|
||||||
"name": "embed_image_url",
|
"name": "embed_image_url",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -103,7 +112,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 12,
|
||||||
"name": "embed_thumbnail_url",
|
"name": "embed_thumbnail_url",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -112,7 +121,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 12,
|
"ordinal": 13,
|
||||||
"name": "embed_title",
|
"name": "embed_title",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -121,7 +130,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 13,
|
"ordinal": 14,
|
||||||
"name": "embed_fields",
|
"name": "embed_fields",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Json",
|
"type": "Json",
|
||||||
@@ -130,7 +139,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 14,
|
"ordinal": 15,
|
||||||
"name": "enabled",
|
"name": "enabled",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Tiny",
|
"type": "Tiny",
|
||||||
@@ -139,7 +148,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 15,
|
"ordinal": 16,
|
||||||
"name": "expires",
|
"name": "expires",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Datetime",
|
"type": "Datetime",
|
||||||
@@ -148,7 +157,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 16,
|
"ordinal": 17,
|
||||||
"name": "interval_seconds",
|
"name": "interval_seconds",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -157,7 +166,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 17,
|
"ordinal": 18,
|
||||||
"name": "interval_days",
|
"name": "interval_days",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -166,7 +175,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 18,
|
"ordinal": 19,
|
||||||
"name": "interval_months",
|
"name": "interval_months",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -175,7 +184,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 19,
|
"ordinal": 20,
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -184,7 +193,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 20,
|
"ordinal": 21,
|
||||||
"name": "restartable",
|
"name": "restartable",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Tiny",
|
"type": "Tiny",
|
||||||
@@ -193,7 +202,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 21,
|
"ordinal": 22,
|
||||||
"name": "tts",
|
"name": "tts",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Tiny",
|
"type": "Tiny",
|
||||||
@@ -202,7 +211,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 22,
|
"ordinal": 23,
|
||||||
"name": "uid",
|
"name": "uid",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -211,7 +220,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 23,
|
"ordinal": 24,
|
||||||
"name": "username",
|
"name": "username",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -220,7 +229,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 24,
|
"ordinal": 25,
|
||||||
"name": "utc_time",
|
"name": "utc_time",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Datetime",
|
"type": "Datetime",
|
||||||
@@ -236,6 +245,7 @@
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
@@ -260,5 +270,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "53db321bc70b45800f696115b65da8550de208104017f8e0d0a163781488a534"
|
"hash": "8e8d6ebf8b5e69d246a73ef3b746ed3dc5dc6f8204f69aeb55dc5e2ae17319aa"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "MySQL",
|
||||||
|
"query": "INSERT INTO reminders (\n uid, attachment, attachment_name, channel_id, message_json,\n enabled, expires, interval_seconds, interval_days, interval_months,\n name, `utc_time`\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 12
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "992666a8cec803ab31a5a86266a07daa4fdf26eeb0969abd4d7987f9672dfae8"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "MySQL",
|
"db_name": "MySQL",
|
||||||
"query": "SELECT\n reminders.attachment,\n reminders.attachment_name,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n WHERE uid = ?",
|
"query": "SELECT\n reminders.attachment,\n reminders.attachment_name,\n reminders.message_json,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n reminders.embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n WHERE uid = ?",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -23,6 +23,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
|
"name": "message_json",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Json",
|
||||||
|
"flags": "BLOB | BINARY",
|
||||||
|
"max_size": 4294967295
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
"name": "content",
|
"name": "content",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -31,7 +40,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 4,
|
||||||
"name": "embed_author",
|
"name": "embed_author",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -40,7 +49,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 5,
|
||||||
"name": "embed_author_url",
|
"name": "embed_author_url",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -49,7 +58,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 6,
|
||||||
"name": "embed_color",
|
"name": "embed_color",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -58,7 +67,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 7,
|
||||||
"name": "embed_description",
|
"name": "embed_description",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -67,7 +76,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 8,
|
||||||
"name": "embed_footer",
|
"name": "embed_footer",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -76,7 +85,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 8,
|
"ordinal": 9,
|
||||||
"name": "embed_footer_url",
|
"name": "embed_footer_url",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -85,7 +94,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 9,
|
"ordinal": 10,
|
||||||
"name": "embed_image_url",
|
"name": "embed_image_url",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -94,7 +103,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 11,
|
||||||
"name": "embed_thumbnail_url",
|
"name": "embed_thumbnail_url",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -103,7 +112,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 12,
|
||||||
"name": "embed_title",
|
"name": "embed_title",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -112,7 +121,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 12,
|
"ordinal": 13,
|
||||||
"name": "embed_fields",
|
"name": "embed_fields",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Json",
|
"type": "Json",
|
||||||
@@ -121,7 +130,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 13,
|
"ordinal": 14,
|
||||||
"name": "enabled",
|
"name": "enabled",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Tiny",
|
"type": "Tiny",
|
||||||
@@ -130,7 +139,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 14,
|
"ordinal": 15,
|
||||||
"name": "expires",
|
"name": "expires",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Datetime",
|
"type": "Datetime",
|
||||||
@@ -139,7 +148,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 15,
|
"ordinal": 16,
|
||||||
"name": "interval_seconds",
|
"name": "interval_seconds",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -148,7 +157,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 16,
|
"ordinal": 17,
|
||||||
"name": "interval_days",
|
"name": "interval_days",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -157,7 +166,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 17,
|
"ordinal": 18,
|
||||||
"name": "interval_months",
|
"name": "interval_months",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -166,7 +175,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 18,
|
"ordinal": 19,
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -175,7 +184,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 19,
|
"ordinal": 20,
|
||||||
"name": "tts",
|
"name": "tts",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Tiny",
|
"type": "Tiny",
|
||||||
@@ -184,7 +193,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 20,
|
"ordinal": 21,
|
||||||
"name": "uid",
|
"name": "uid",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -193,7 +202,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 21,
|
"ordinal": 22,
|
||||||
"name": "utc_time",
|
"name": "utc_time",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Datetime",
|
"type": "Datetime",
|
||||||
@@ -206,6 +215,7 @@
|
|||||||
"Right": 1
|
"Right": 1
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
@@ -230,5 +240,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "2613618c00941649726b31f7c50b5ef50d85323a73def5c87bab2a58c80bf6cd"
|
"hash": "b708e057ab42c09c36fdc71bda77970fe04e8c066fd9c2701ac25afb059c32b6"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"db_name": "MySQL",
|
||||||
|
"query": "\n SELECT id, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused,\n paused_until\n FROM channels\n WHERE channel = ?\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Long",
|
||||||
|
"flags": "NOT_NULL | PRIMARY_KEY | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||||
|
"max_size": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "channel",
|
||||||
|
"type_info": {
|
||||||
|
"type": "LongLong",
|
||||||
|
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | NO_DEFAULT_VALUE",
|
||||||
|
"max_size": 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": {
|
||||||
|
"type": "VarString",
|
||||||
|
"flags": "",
|
||||||
|
"max_size": 400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "nudge",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Short",
|
||||||
|
"flags": "NOT_NULL",
|
||||||
|
"max_size": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "blacklisted",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Tiny",
|
||||||
|
"flags": "NOT_NULL",
|
||||||
|
"max_size": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "webhook_id",
|
||||||
|
"type_info": {
|
||||||
|
"type": "LongLong",
|
||||||
|
"flags": "UNIQUE_KEY | UNSIGNED",
|
||||||
|
"max_size": 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "webhook_token",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Blob",
|
||||||
|
"flags": "BLOB",
|
||||||
|
"max_size": 262140
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "paused",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Tiny",
|
||||||
|
"flags": "NOT_NULL",
|
||||||
|
"max_size": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "paused_until",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Timestamp",
|
||||||
|
"flags": "BINARY",
|
||||||
|
"max_size": 19
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "d3e41b7c9df5823c418181f46aa78999159afbd458dcd05ffb9e032182ec493b"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "MySQL",
|
"db_name": "MySQL",
|
||||||
"query": "\n SELECT\n reminders.attachment,\n reminders.attachment_name,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n IFNULL(reminders.embed_fields, '[]') AS embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n INNER JOIN channels ON channels.id = reminders.channel_id\n WHERE `status` = 'pending' AND channels.channel = ?\n ",
|
"query": "\n SELECT\n reminders.attachment,\n reminders.attachment_name,\n reminders.message_json,\n reminders.content,\n reminders.embed_author,\n reminders.embed_author_url,\n reminders.embed_color,\n reminders.embed_description,\n reminders.embed_footer,\n reminders.embed_footer_url,\n reminders.embed_image_url,\n reminders.embed_thumbnail_url,\n reminders.embed_title,\n IFNULL(reminders.embed_fields, '[]') AS embed_fields,\n reminders.enabled,\n reminders.expires,\n reminders.interval_seconds,\n reminders.interval_days,\n reminders.interval_months,\n reminders.name,\n reminders.tts,\n reminders.uid,\n reminders.utc_time\n FROM reminders\n INNER JOIN channels ON channels.id = reminders.channel_id\n WHERE `status` = 'pending' AND channels.channel = ?\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -23,6 +23,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
|
"name": "message_json",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Json",
|
||||||
|
"flags": "BLOB | BINARY",
|
||||||
|
"max_size": 4294967295
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
"name": "content",
|
"name": "content",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -31,7 +40,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 4,
|
||||||
"name": "embed_author",
|
"name": "embed_author",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -40,7 +49,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 5,
|
||||||
"name": "embed_author_url",
|
"name": "embed_author_url",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -49,7 +58,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 6,
|
||||||
"name": "embed_color",
|
"name": "embed_color",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -58,7 +67,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 7,
|
||||||
"name": "embed_description",
|
"name": "embed_description",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -67,7 +76,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 8,
|
||||||
"name": "embed_footer",
|
"name": "embed_footer",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -76,7 +85,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 8,
|
"ordinal": 9,
|
||||||
"name": "embed_footer_url",
|
"name": "embed_footer_url",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -85,7 +94,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 9,
|
"ordinal": 10,
|
||||||
"name": "embed_image_url",
|
"name": "embed_image_url",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -94,7 +103,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 11,
|
||||||
"name": "embed_thumbnail_url",
|
"name": "embed_thumbnail_url",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -103,7 +112,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 12,
|
||||||
"name": "embed_title",
|
"name": "embed_title",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -112,7 +121,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 12,
|
"ordinal": 13,
|
||||||
"name": "embed_fields",
|
"name": "embed_fields",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -121,7 +130,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 13,
|
"ordinal": 14,
|
||||||
"name": "enabled",
|
"name": "enabled",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Tiny",
|
"type": "Tiny",
|
||||||
@@ -130,7 +139,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 14,
|
"ordinal": 15,
|
||||||
"name": "expires",
|
"name": "expires",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Datetime",
|
"type": "Datetime",
|
||||||
@@ -139,7 +148,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 15,
|
"ordinal": 16,
|
||||||
"name": "interval_seconds",
|
"name": "interval_seconds",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -148,7 +157,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 16,
|
"ordinal": 17,
|
||||||
"name": "interval_days",
|
"name": "interval_days",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -157,7 +166,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 17,
|
"ordinal": 18,
|
||||||
"name": "interval_months",
|
"name": "interval_months",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Long",
|
"type": "Long",
|
||||||
@@ -166,7 +175,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 18,
|
"ordinal": 19,
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -175,7 +184,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 19,
|
"ordinal": 20,
|
||||||
"name": "tts",
|
"name": "tts",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Tiny",
|
"type": "Tiny",
|
||||||
@@ -184,7 +193,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 20,
|
"ordinal": 21,
|
||||||
"name": "uid",
|
"name": "uid",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -193,7 +202,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 21,
|
"ordinal": 22,
|
||||||
"name": "utc_time",
|
"name": "utc_time",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "Datetime",
|
"type": "Datetime",
|
||||||
@@ -206,6 +215,7 @@
|
|||||||
"Right": 1
|
"Right": 1
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
@@ -230,5 +240,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "48a9268e9106a3a05e86ab36551944ba855a07b0241e912c6b1adee779c6f0ea"
|
"hash": "d9e0f517df37a6c03868b6e1470169eaf06d2e6f1a9df62165656d3e97dbe929"
|
||||||
}
|
}
|
||||||
@@ -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.message_json,\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 ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -86,6 +86,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 9,
|
"ordinal": 9,
|
||||||
|
"name": "message_json",
|
||||||
|
"type_info": {
|
||||||
|
"type": "Json",
|
||||||
|
"flags": "BLOB | BINARY",
|
||||||
|
"max_size": 4294967295
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
"name": "content",
|
"name": "content",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -94,7 +103,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 11,
|
||||||
"name": "embed_description",
|
"name": "embed_description",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "VarString",
|
"type": "VarString",
|
||||||
@@ -103,7 +112,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 12,
|
||||||
"name": "set_by",
|
"name": "set_by",
|
||||||
"type_info": {
|
"type_info": {
|
||||||
"type": "LongLong",
|
"type": "LongLong",
|
||||||
@@ -125,10 +134,11 @@
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "d2921961627fef0e12892dbbcd4b891e58c0e52c20897aab3d95365774c01bda"
|
"hash": "ec12254245aa433f8d31711c2942254d91c61f6b47a5b427c43a8cac468379a3"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -31,14 +31,11 @@ futures = "0.3.30"
|
|||||||
prometheus = "0.13.3"
|
prometheus = "0.13.3"
|
||||||
rocket = { version = "0.5.0", features = ["tls", "secrets", "json"] }
|
rocket = { version = "0.5.0", features = ["tls", "secrets", "json"] }
|
||||||
rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
|
rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
|
||||||
serenity = { version = "0.12", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
serenity = { version = "0.12.5", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
||||||
oauth2 = "4"
|
oauth2 = "4"
|
||||||
csv = "1.2"
|
csv = "1.2"
|
||||||
sd-notify = "0.4.1"
|
sd-notify = "0.4.1"
|
||||||
cron-parser = "0.10"
|
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 +68,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
|
||||||
@@ -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
|
|
||||||
@@ -18,26 +18,14 @@ 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 matching the project's SQLx version: `cargo install sqlx-cli --version 0.8.2 --locked`.
|
3. Install SQLx CLI: `cargo install sqlx-cli`
|
||||||
4. Install the local build requirements (Rust, Node.js/npm, GCC, CMake, pkg-config, and OpenSSL
|
4. From the source code directory, execute `sqlx migrate run`
|
||||||
development headers), then install dashboard dependencies: `npm ci --prefix reminder-dashboard`.
|
5. Build container image: `podman build -t reminder-rs .`
|
||||||
5. Export the URL of your build/test database:
|
6. Build with podman:
|
||||||
`export DATABASE_URL="mysql://user[:password]@localhost/reminders"`.
|
`podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`
|
||||||
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
|
||||||
|
|
||||||
@@ -50,7 +38,7 @@ or `PODMAN` to use another container command, for example `make PODMAN=docker`.
|
|||||||
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
|
||||||
@@ -58,97 +46,32 @@ or `PODMAN` to use another container command, for example `make PODMAN=docker`.
|
|||||||
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`)
|
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
|
||||||
- `DISCORD_TOKEN` - your application's bot user's authorization token
|
* `DISCORD_TOKEN` - your application's bot user's authorization token
|
||||||
|
|
||||||
**Other Variables**
|
__Other Variables__
|
||||||
|
|
||||||
- `MIN_INTERVAL` - default `600`, defines the shortest interval the bot should accept
|
* `MIN_INTERVAL` - default `600`, defines the shortest interval the bot should accept
|
||||||
- `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
|
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
|
||||||
- `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to
|
* `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to
|
||||||
subscribed users
|
subscribed users
|
||||||
- `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the
|
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the
|
||||||
subscription roles belong to
|
subscription roles belong to
|
||||||
- `PYTHON_LOCATION` - default `/usr/bin/python3`. Can be changed if your Python executable is
|
* `PYTHON_LOCATION` - default `/usr/bin/python3`. Can be changed if your Python executable is
|
||||||
located somewhere else
|
located somewhere else
|
||||||
- `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message
|
* `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message
|
||||||
embeds
|
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
|
## 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.
|
A `compose.yml` file is provided to aid in running the bot agnostically using docker.
|
||||||
|
|
||||||
- Populate a `.env` file as in `conf/default.env`
|
* Populate a `.env` file as in `conf/default.env`
|
||||||
- Add the additional variable `ROCKET_SECRET_KEY` with a key generated from
|
* Add the additional variable `ROCKET_SECRET_KEY` with a key generated from
|
||||||
`head -c64 /dev/urandom | base64`
|
`head -c64 /dev/urandom | base64`
|
||||||
- Run `docker compose up`
|
* Run `docker compose up`
|
||||||
|
|
||||||
Please note that this is _not_ production-ready when run via compose. We do not offer a way for
|
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
|
backing up of your data, or a way to run the dashboard securely via HTTPS, which is required for
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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:
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
);
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE reminders ADD COLUMN message_json JSON DEFAULT NULL;
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite build --watch --mode development",
|
"dev": "vite build --watch --mode development",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"test": "node --test tests/*.test.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prettier": "prettier -w src/"
|
"prettier": "prettier -w src/"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { prepareReminder } from "./components/Reminder/message";
|
||||||
|
import type { MessagePayload } from "./components/Reminder/message";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
enum ColorScheme {
|
enum ColorScheme {
|
||||||
@@ -9,7 +11,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;
|
||||||
@@ -39,6 +40,7 @@ type EmbedField = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type Reminder = {
|
export type Reminder = {
|
||||||
|
message_json?: MessagePayload | null;
|
||||||
attachment: string | null;
|
attachment: string | null;
|
||||||
attachment_name: string | null;
|
attachment_name: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
@@ -127,13 +129,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),
|
||||||
});
|
});
|
||||||
@@ -194,12 +189,14 @@ export const fetchGuildReminders = (guild: string) => ({
|
|||||||
|
|
||||||
export const patchGuildReminder = (guild: string) => ({
|
export const patchGuildReminder = (guild: string) => ({
|
||||||
mutationFn: (reminder: Reminder) =>
|
mutationFn: (reminder: Reminder) =>
|
||||||
axios.patch(`/dashboard/api/guild/${guild}/reminders`, reminder),
|
axios.patch(`/dashboard/api/guild/${guild}/reminders`, prepareReminder(reminder)),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const postGuildReminder = (guild: string) => ({
|
export const postGuildReminder = (guild: string) => ({
|
||||||
mutationFn: (reminder: Reminder) =>
|
mutationFn: (reminder: Reminder) =>
|
||||||
axios.post(`/dashboard/api/guild/${guild}/reminders`, reminder).then((resp) => resp.data),
|
axios
|
||||||
|
.post(`/dashboard/api/guild/${guild}/reminders`, prepareReminder(reminder))
|
||||||
|
.then((resp) => resp.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteReminder = () => ({
|
export const deleteReminder = () => ({
|
||||||
@@ -244,7 +241,7 @@ export const fetchGuildTodos = (guild: string) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const patchGuildTodo = (guild: string) => ({
|
export const patchGuildTodo = (guild: string) => ({
|
||||||
mutationFn: ({ id, todo }) => axios.patch(`/dashboard/api/guild/${guild}/todos/${id}`, todo),
|
mutationFn: ({id, todo}) => axios.patch(`/dashboard/api/guild/${guild}/todos/${id}`, todo),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const postGuildTodo = (guild: string) => ({
|
export const postGuildTodo = (guild: string) => ({
|
||||||
@@ -265,9 +262,12 @@ export const fetchUserReminders = () => ({
|
|||||||
|
|
||||||
export const postUserReminder = () => ({
|
export const postUserReminder = () => ({
|
||||||
mutationFn: (reminder: Reminder) =>
|
mutationFn: (reminder: Reminder) =>
|
||||||
axios.post(`/dashboard/api/user/reminders`, reminder).then((resp) => resp.data),
|
axios
|
||||||
|
.post(`/dashboard/api/user/reminders`, prepareReminder(reminder))
|
||||||
|
.then((resp) => resp.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const patchUserReminder = () => ({
|
export const patchUserReminder = () => ({
|
||||||
mutationFn: (reminder: Reminder) => axios.patch(`/dashboard/api/user/reminders`, reminder),
|
mutationFn: (reminder: Reminder) =>
|
||||||
|
axios.patch(`/dashboard/api/user/reminders`, prepareReminder(reminder)),
|
||||||
});
|
});
|
||||||
@@ -2,22 +2,14 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Embed } from "./index";
|
||||||
|
import { ReminderContext, useReminder } from "../ReminderContext";
|
||||||
|
import { Reminder } from "../../../api";
|
||||||
|
import {
|
||||||
|
addEmbed,
|
||||||
|
embedFields,
|
||||||
|
embedTextLength,
|
||||||
|
MAX_EMBEDS,
|
||||||
|
MAX_EMBED_TEXT,
|
||||||
|
reminderEmbeds,
|
||||||
|
removeEmbed,
|
||||||
|
updateEmbed,
|
||||||
|
} from "../message";
|
||||||
|
|
||||||
|
const EmbedEditor = ({ index }: { index: number }) => {
|
||||||
|
const [reminder, setReminder] = useReminder();
|
||||||
|
const embed = reminderEmbeds(reminder)[index];
|
||||||
|
const update = (change: (r: Reminder) => Reminder) => {
|
||||||
|
setReminder((current) => updateEmbed(current, index, change));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="reminder-embed-editor" role="group" aria-label={`Embed ${index + 1}`}>
|
||||||
|
<ReminderContext.Provider value={[{ ...reminder, ...embedFields(embed) }, update]}>
|
||||||
|
<Embed />
|
||||||
|
</ReminderContext.Provider>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-small is-text remove-embed"
|
||||||
|
aria-label={`Remove embed ${index + 1}`}
|
||||||
|
onClick={() => setReminder((current) => removeEmbed(current, index))}
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Embeds = () => {
|
||||||
|
const [reminder, setReminder] = useReminder();
|
||||||
|
const embeds = reminderEmbeds(reminder);
|
||||||
|
const textLength = embedTextLength(embeds);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="reminder-embeds">
|
||||||
|
{embeds.map((_, index) => (
|
||||||
|
<EmbedEditor key={index} index={index} />
|
||||||
|
))}
|
||||||
|
<div class="add-embed-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-small is-rounded add-embed"
|
||||||
|
aria-label="Add embed"
|
||||||
|
title={embeds.length >= MAX_EMBEDS ? "Maximum of 10 embeds" : "Add embed"}
|
||||||
|
disabled={embeds.length >= MAX_EMBEDS}
|
||||||
|
onClick={() => setReminder(addEmbed)}
|
||||||
|
>
|
||||||
|
<i class="fas fa-plus" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
class={`embed-limit${textLength > MAX_EMBED_TEXT ? " has-text-danger" : ""}`}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{embeds.length}/{MAX_EMBEDS} embeds · {textLength}/{MAX_EMBED_TEXT} characters
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,7 +6,10 @@ export const Fields = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={"embed-multifield-box"}>
|
<div class={"embed-multifield-box"}>
|
||||||
{[...embed_fields, { value: "", title: "", inline: true }].map((field, index) => (
|
{[
|
||||||
|
...(embed_fields || []),
|
||||||
|
...(embed_fields?.length >= 25 ? [] : [{ value: "", title: "", inline: true }]),
|
||||||
|
].map((field, index) => (
|
||||||
<Field
|
<Field
|
||||||
{...field}
|
{...field}
|
||||||
index={index}
|
index={index}
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import { Color } from "./Color";
|
|||||||
import { Fields } from "./Fields";
|
import { Fields } from "./Fields";
|
||||||
import { Reminder } from "../../../api";
|
import { Reminder } from "../../../api";
|
||||||
import { ImagePicker } from "../ImagePicker";
|
import { ImagePicker } from "../ImagePicker";
|
||||||
|
import { DEFAULT_EMBED_COLOR } from "../message";
|
||||||
import { useReminder } from "../ReminderContext";
|
import { useReminder } from "../ReminderContext";
|
||||||
|
|
||||||
function intToColor(num: number) {
|
function intToColor(num: number) {
|
||||||
return `#${num.toString(16).padStart(6, "0")}`;
|
return `#${num.toString(16).padStart(6, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_COLOR = 9418359;
|
export const DEFAULT_COLOR = DEFAULT_EMBED_COLOR;
|
||||||
|
|
||||||
export const Embed = () => {
|
export const Embed = () => {
|
||||||
const [reminder, setReminder] = useReminder();
|
const [reminder, setReminder] = useReminder();
|
||||||
@@ -21,12 +22,12 @@ export const Embed = () => {
|
|||||||
<div
|
<div
|
||||||
class="discord-embed"
|
class="discord-embed"
|
||||||
style={{
|
style={{
|
||||||
borderLeftColor: intToColor(reminder.embed_color || DEFAULT_COLOR),
|
borderLeftColor: intToColor(reminder.embed_color ?? DEFAULT_COLOR),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="embed-body">
|
<div class="embed-body">
|
||||||
<Color
|
<Color
|
||||||
color={intToColor(reminder.embed_color || DEFAULT_COLOR)}
|
color={intToColor(reminder.embed_color ?? DEFAULT_COLOR)}
|
||||||
setReminder={setReminder}
|
setReminder={setReminder}
|
||||||
></Color>
|
></Color>
|
||||||
<div class="a">
|
<div class="a">
|
||||||
|
|||||||
@@ -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,64 +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[]) => {
|
|
||||||
setBlocked(false);
|
|
||||||
if (!uploadsEnabled || uploadInProgress.current) return;
|
|
||||||
setError("");
|
|
||||||
if (files.length !== 1) {
|
|
||||||
setError("Please upload only one image.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const file = files[0];
|
|
||||||
if (file.size > 8 * 1024 * 1024) {
|
|
||||||
setError("Images must be no larger than 8 MiB.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
uploadInProgress.current = true;
|
|
||||||
setUploading(true);
|
|
||||||
try {
|
|
||||||
setValue(await uploadImage(file));
|
|
||||||
} catch (error) {
|
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 451) {
|
|
||||||
setBlocked(true);
|
|
||||||
} else {
|
|
||||||
setError(
|
|
||||||
axios.isAxiosError(error) && typeof error.response?.data?.error === "string"
|
|
||||||
? error.response.data.error
|
|
||||||
: "Could not upload image. Please try again.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
uploadInProgress.current = false;
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const { dragging, handlers } = useImageDrop(
|
|
||||||
uploadsEnabled,
|
|
||||||
(files) => void upload(files),
|
|
||||||
uploading,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (droppedFiles) void upload(droppedFiles);
|
|
||||||
}, [droppedFiles]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -138,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
|
||||||
@@ -149,65 +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" : ""}`}
|
|
||||||
>
|
|
||||||
<p>or…</p>
|
|
||||||
<input
|
|
||||||
ref={fileInput}
|
|
||||||
type="file"
|
|
||||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
|
||||||
hidden
|
|
||||||
disabled={uploading}
|
|
||||||
aria-label="Choose an image to upload"
|
|
||||||
onChange={(event) => {
|
|
||||||
const file = event.currentTarget.files?.[0];
|
|
||||||
event.currentTarget.value = "";
|
|
||||||
if (file) void upload([file]);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={`button is-info ${uploading ? "is-loading" : ""}`}
|
|
||||||
disabled={uploading}
|
|
||||||
onClick={() => fileInput.current?.click()}
|
|
||||||
>
|
|
||||||
Upload image
|
|
||||||
</button>
|
|
||||||
<p class="help">Or drag and drop an image here.</p>
|
|
||||||
<p class="help">
|
|
||||||
Static PNG, JPEG, GIF or WebP, up to 8 MiB. All uploads are converted to
|
|
||||||
PNGs.
|
|
||||||
</p>
|
|
||||||
{blocked && (
|
|
||||||
<>
|
|
||||||
<p class="help" style="color: red;">
|
|
||||||
This image was flagged as potentially NSFW. Unfortunately, due to
|
|
||||||
the UK's Online Safety Act, we cannot handle explicit content.
|
|
||||||
Please defer to using Imgur or another image storage platform who
|
|
||||||
have the resources to deal with idiotic laws.
|
|
||||||
</p>
|
|
||||||
<p class="help" style="color: red;">
|
|
||||||
The image you attempted to upload has not been stored.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{uploading && <p role="status">Uploading image…</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<p class="help is-danger" role="alert">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useReminder } from "./ReminderContext";
|
||||||
|
import {
|
||||||
|
addLinkButton,
|
||||||
|
canAddLinkButton,
|
||||||
|
linkButtons,
|
||||||
|
MAX_LINK_BUTTONS,
|
||||||
|
updateLinkButton,
|
||||||
|
} from "./message";
|
||||||
|
|
||||||
|
export const LinkButtons = () => {
|
||||||
|
const [reminder, setReminder] = useReminder();
|
||||||
|
const buttons = linkButtons(reminder);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="reminder-link-buttons" role="group" aria-label="Link buttons">
|
||||||
|
{buttons.map(({ button, rowIndex, buttonIndex }, index) => (
|
||||||
|
<div class="link-button-editor" key={`${rowIndex}-${buttonIndex}`}>
|
||||||
|
<label class="link-button-label">
|
||||||
|
Button label
|
||||||
|
<input
|
||||||
|
class="input is-small"
|
||||||
|
aria-label={`Link button ${index + 1} label`}
|
||||||
|
maxLength={80}
|
||||||
|
value={button.label || ""}
|
||||||
|
onInput={(event) => {
|
||||||
|
const label = event.currentTarget.value;
|
||||||
|
setReminder((current) =>
|
||||||
|
updateLinkButton(current, rowIndex, buttonIndex, { label }),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="link-button-url">
|
||||||
|
URL
|
||||||
|
<input
|
||||||
|
class="input is-small"
|
||||||
|
type="url"
|
||||||
|
aria-label={`Link button ${index + 1} URL`}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
maxLength={512}
|
||||||
|
value={button.url || ""}
|
||||||
|
onInput={(event) => {
|
||||||
|
const url = event.currentTarget.value;
|
||||||
|
setReminder((current) =>
|
||||||
|
updateLinkButton(current, rowIndex, buttonIndex, { url }),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-small is-text"
|
||||||
|
aria-label={`Remove link button ${index + 1}`}
|
||||||
|
onClick={() =>
|
||||||
|
setReminder((current) =>
|
||||||
|
updateLinkButton(current, rowIndex, buttonIndex, null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div class="add-link-button-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-small is-rounded"
|
||||||
|
disabled={!canAddLinkButton(reminder)}
|
||||||
|
onClick={() => setReminder(addLinkButton)}
|
||||||
|
>
|
||||||
|
<span class="icon is-small">
|
||||||
|
<i class="fas fa-plus" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span>Add link button</span>
|
||||||
|
</button>
|
||||||
|
<span class="link-button-limit" aria-live="polite">
|
||||||
|
{buttons.length}/{MAX_LINK_BUTTONS} link buttons
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -82,6 +82,7 @@ const LoadTemplateModal = ({ setModalOpen }) => {
|
|||||||
setReminder((reminder) => ({
|
setReminder((reminder) => ({
|
||||||
...reminder,
|
...reminder,
|
||||||
...template,
|
...template,
|
||||||
|
message_json: null,
|
||||||
// drop the template's ID
|
// drop the template's ID
|
||||||
id: undefined,
|
id: undefined,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Username } from "./Username";
|
import { Username } from "./Username";
|
||||||
import { Content } from "./Content";
|
import { Content } from "./Content";
|
||||||
import { Embed } from "./Embed";
|
import { Embeds } from "./Embed/Embeds";
|
||||||
import { Avatar } from "./Avatar";
|
import { Avatar } from "./Avatar";
|
||||||
|
import { LinkButtons } from "./LinkButtons";
|
||||||
|
|
||||||
export const Message = () => (
|
export const Message = () => (
|
||||||
<div class="column discord-frame">
|
<div class="column discord-frame">
|
||||||
@@ -15,7 +16,8 @@ export const Message = () => (
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<Username />
|
<Username />
|
||||||
<Content />
|
<Content />
|
||||||
<Embed />
|
<Embeds />
|
||||||
|
<LinkButtons />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import type { Reminder } from "../../api";
|
||||||
|
|
||||||
|
export const MAX_EMBEDS = 10;
|
||||||
|
export const MAX_EMBED_TEXT = 6000;
|
||||||
|
export const DEFAULT_EMBED_COLOR = 9418359;
|
||||||
|
export const MAX_BUTTON_ROWS = 5;
|
||||||
|
export const MAX_BUTTONS_PER_ROW = 5;
|
||||||
|
export const MAX_LINK_BUTTONS = MAX_BUTTON_ROWS * MAX_BUTTONS_PER_ROW;
|
||||||
|
|
||||||
|
export type MessageComponent = {
|
||||||
|
type: number;
|
||||||
|
style?: number;
|
||||||
|
label?: string;
|
||||||
|
url?: string;
|
||||||
|
components?: MessageComponent[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiscordEmbed = {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
color?: number;
|
||||||
|
author?: { name?: string; icon_url?: string; [key: string]: unknown };
|
||||||
|
footer?: { text?: string; icon_url?: string; [key: string]: unknown };
|
||||||
|
image?: { url?: string; [key: string]: unknown };
|
||||||
|
thumbnail?: { url?: string; [key: string]: unknown };
|
||||||
|
fields?: { name: string; value: string; inline?: boolean }[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessagePayload = {
|
||||||
|
content?: string;
|
||||||
|
tts?: boolean;
|
||||||
|
username?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
embeds?: DiscordEmbed[];
|
||||||
|
attachments?: { id: number; filename: string }[];
|
||||||
|
components?: MessageComponent[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function embedFields(embed: DiscordEmbed) {
|
||||||
|
return {
|
||||||
|
embed_title: embed.title || "",
|
||||||
|
embed_description: embed.description || "",
|
||||||
|
embed_color: embed.color ?? DEFAULT_EMBED_COLOR,
|
||||||
|
embed_author: embed.author?.name || "",
|
||||||
|
embed_author_url: embed.author?.icon_url || null,
|
||||||
|
embed_footer: embed.footer?.text || "",
|
||||||
|
embed_footer_url: embed.footer?.icon_url || null,
|
||||||
|
embed_image_url: embed.image?.url || null,
|
||||||
|
embed_thumbnail_url: embed.thumbnail?.url || null,
|
||||||
|
embed_fields: (embed.fields || []).map(({ name, value, inline }) => ({
|
||||||
|
title: name,
|
||||||
|
value,
|
||||||
|
inline: inline ?? false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toEmbed(reminder: Reminder, original: DiscordEmbed = {}): DiscordEmbed {
|
||||||
|
const embed: DiscordEmbed = { ...original, color: reminder.embed_color };
|
||||||
|
for (const [key, value] of [
|
||||||
|
["title", reminder.embed_title],
|
||||||
|
["description", reminder.embed_description],
|
||||||
|
]) {
|
||||||
|
if (value) embed[key] = value;
|
||||||
|
else delete embed[key];
|
||||||
|
}
|
||||||
|
for (const [key, textKey, text, icon] of [
|
||||||
|
["author", "name", reminder.embed_author, reminder.embed_author_url],
|
||||||
|
["footer", "text", reminder.embed_footer, reminder.embed_footer_url],
|
||||||
|
]) {
|
||||||
|
const part = { ...(original[key] as object) };
|
||||||
|
if (text) part[textKey] = text;
|
||||||
|
else delete part[textKey];
|
||||||
|
if (icon) part["icon_url"] = icon;
|
||||||
|
else delete part["icon_url"];
|
||||||
|
if (Object.keys(part).length) embed[key] = part;
|
||||||
|
else delete embed[key];
|
||||||
|
}
|
||||||
|
for (const [key, url] of [
|
||||||
|
["image", reminder.embed_image_url],
|
||||||
|
["thumbnail", reminder.embed_thumbnail_url],
|
||||||
|
]) {
|
||||||
|
if (url) embed[key] = { ...(original[key] as object), url };
|
||||||
|
else delete embed[key];
|
||||||
|
}
|
||||||
|
const fields = (reminder.embed_fields || []).map(({ title, value, inline }) => ({
|
||||||
|
name: title,
|
||||||
|
value,
|
||||||
|
inline,
|
||||||
|
}));
|
||||||
|
if (fields.length) embed.fields = fields;
|
||||||
|
else delete embed.fields;
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasEmbedContent(embed: DiscordEmbed): boolean {
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
embed.title,
|
||||||
|
embed.description,
|
||||||
|
embed.author?.name,
|
||||||
|
embed.footer?.text,
|
||||||
|
embed.footer?.icon_url,
|
||||||
|
embed.image?.url,
|
||||||
|
embed.thumbnail?.url,
|
||||||
|
].some((value) => !!value?.trim()) ||
|
||||||
|
!!embed.fields?.length ||
|
||||||
|
Object.keys(embed).some(
|
||||||
|
(key) =>
|
||||||
|
![
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"author",
|
||||||
|
"footer",
|
||||||
|
"image",
|
||||||
|
"thumbnail",
|
||||||
|
"color",
|
||||||
|
"fields",
|
||||||
|
].includes(key),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reminderEmbeds(reminder: Reminder): DiscordEmbed[] {
|
||||||
|
const embeds = reminder.message_json?.embeds;
|
||||||
|
if (embeds) return embeds.length ? embeds : [{ color: DEFAULT_EMBED_COLOR }];
|
||||||
|
return [toEmbed(reminder)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateEmbed(
|
||||||
|
reminder: Reminder,
|
||||||
|
index: number,
|
||||||
|
update: (r: Reminder) => Reminder,
|
||||||
|
): Reminder {
|
||||||
|
const embeds = [...reminderEmbeds(reminder)];
|
||||||
|
const original = embeds[index];
|
||||||
|
if (!original) return reminder;
|
||||||
|
const changed = update({ ...reminder, ...embedFields(original) });
|
||||||
|
embeds[index] = toEmbed(changed, original);
|
||||||
|
return {
|
||||||
|
...reminder,
|
||||||
|
...embedFields(embeds[0]),
|
||||||
|
message_json: { ...reminder.message_json, embeds },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addEmbed(reminder: Reminder): Reminder {
|
||||||
|
const embeds = reminderEmbeds(reminder);
|
||||||
|
if (embeds.length >= MAX_EMBEDS) return reminder;
|
||||||
|
return {
|
||||||
|
...reminder,
|
||||||
|
message_json: {
|
||||||
|
...reminder.message_json,
|
||||||
|
embeds: [...embeds, { color: DEFAULT_EMBED_COLOR }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeEmbed(reminder: Reminder, index: number): Reminder {
|
||||||
|
const embeds = reminderEmbeds(reminder).filter((_, i) => i !== index);
|
||||||
|
return {
|
||||||
|
...reminder,
|
||||||
|
...embedFields(embeds[0] || {}),
|
||||||
|
message_json: { ...reminder.message_json, embeds },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function embedTextLength(embeds: DiscordEmbed[]): number {
|
||||||
|
return embeds.reduce(
|
||||||
|
(sum, embed) =>
|
||||||
|
sum +
|
||||||
|
[
|
||||||
|
embed.title,
|
||||||
|
embed.description,
|
||||||
|
embed.author?.name,
|
||||||
|
embed.footer?.text,
|
||||||
|
...(embed.fields || []).flatMap((field) => [field.name, field.value]),
|
||||||
|
].reduce((sum, value) => sum + Array.from((value || "").trim()).length, 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linkButtons(reminder: Reminder) {
|
||||||
|
return (reminder.message_json?.components || []).flatMap((row, rowIndex) =>
|
||||||
|
row.type === 1
|
||||||
|
? (row.components || []).flatMap((button, buttonIndex) =>
|
||||||
|
button.type === 2 && button.style === 5
|
||||||
|
? [{ button, rowIndex, buttonIndex }]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableButtonRow(components: MessageComponent[]): number {
|
||||||
|
return components.findIndex(
|
||||||
|
(row) =>
|
||||||
|
row.type === 1 &&
|
||||||
|
!!row.components &&
|
||||||
|
row.components.length < MAX_BUTTONS_PER_ROW &&
|
||||||
|
row.components.every((button) => button.type === 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAddLinkButton(reminder: Reminder): boolean {
|
||||||
|
const components = reminder.message_json?.components || [];
|
||||||
|
return availableButtonRow(components) >= 0 || components.length < MAX_BUTTON_ROWS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addLinkButton(reminder: Reminder): Reminder {
|
||||||
|
if (!canAddLinkButton(reminder)) return reminder;
|
||||||
|
const components = [...(reminder.message_json?.components || [])];
|
||||||
|
const rowIndex = availableButtonRow(components);
|
||||||
|
const button: MessageComponent = { type: 2, style: 5, label: "Open link", url: "" };
|
||||||
|
if (rowIndex >= 0) {
|
||||||
|
const row = components[rowIndex];
|
||||||
|
components[rowIndex] = { ...row, components: [...row.components, button] };
|
||||||
|
} else components.push({ type: 1, components: [button] });
|
||||||
|
return { ...reminder, message_json: { ...reminder.message_json, components } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLinkButton(
|
||||||
|
reminder: Reminder,
|
||||||
|
rowIndex: number,
|
||||||
|
buttonIndex: number,
|
||||||
|
change: { label?: string; url?: string } | null,
|
||||||
|
): Reminder {
|
||||||
|
const components = [...(reminder.message_json?.components || [])];
|
||||||
|
const row = components[rowIndex];
|
||||||
|
const button = row?.components?.[buttonIndex];
|
||||||
|
if (row?.type !== 1 || button?.type !== 2 || button.style !== 5) return reminder;
|
||||||
|
const buttons = [...row.components];
|
||||||
|
if (change) buttons[buttonIndex] = { ...button, ...change };
|
||||||
|
else buttons.splice(buttonIndex, 1);
|
||||||
|
if (buttons.length) components[rowIndex] = { ...row, components: buttons };
|
||||||
|
else components.splice(rowIndex, 1);
|
||||||
|
return { ...reminder, message_json: { ...reminder.message_json, components } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateComponents(components: MessageComponent[] = []) {
|
||||||
|
if (components.length > MAX_BUTTON_ROWS)
|
||||||
|
throw new Error(`A reminder can have at most ${MAX_BUTTON_ROWS} button rows.`);
|
||||||
|
for (const row of components) {
|
||||||
|
if (row.type !== 1) continue;
|
||||||
|
if (
|
||||||
|
!Array.isArray(row.components) ||
|
||||||
|
!row.components.length ||
|
||||||
|
row.components.length > MAX_BUTTONS_PER_ROW
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`A button row must have between 1 and ${MAX_BUTTONS_PER_ROW} components.`,
|
||||||
|
);
|
||||||
|
for (const button of row.components) {
|
||||||
|
if (button.type !== 2 || button.style !== 5) continue;
|
||||||
|
if (
|
||||||
|
(button.label !== undefined && typeof button.label !== "string") ||
|
||||||
|
Array.from(button.label || "").length > 80 ||
|
||||||
|
(!button.label && (!button.emoji || typeof button.emoji !== "object"))
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"Link buttons require a label of at most 80 characters or an emoji.",
|
||||||
|
);
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(button.url);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Link buttons require a valid HTTP or HTTPS URL.");
|
||||||
|
}
|
||||||
|
if (!["http:", "https:"].includes(url.protocol) || Array.from(button.url).length > 512)
|
||||||
|
throw new Error(
|
||||||
|
"Link buttons require an HTTP or HTTPS URL of at most 512 characters.",
|
||||||
|
);
|
||||||
|
if (button.custom_id !== undefined || button.sku_id !== undefined)
|
||||||
|
throw new Error("Link buttons cannot have a custom_id or sku_id.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareReminder(reminder: Reminder): Reminder {
|
||||||
|
validateComponents(reminder.message_json?.components);
|
||||||
|
const embeds = reminderEmbeds(reminder).filter(hasEmbedContent);
|
||||||
|
if (embeds.length > MAX_EMBEDS)
|
||||||
|
throw new Error(`A reminder can have at most ${MAX_EMBEDS} embeds.`);
|
||||||
|
if (embedTextLength(embeds) > MAX_EMBED_TEXT)
|
||||||
|
throw new Error(`Embed text exceeds ${MAX_EMBED_TEXT} characters across all embeds.`);
|
||||||
|
const message: MessagePayload = {
|
||||||
|
...reminder.message_json,
|
||||||
|
content: reminder.content,
|
||||||
|
tts: reminder.tts,
|
||||||
|
embeds,
|
||||||
|
};
|
||||||
|
if (reminder.username) message.username = reminder.username;
|
||||||
|
else delete message.username;
|
||||||
|
if (reminder.avatar) message.avatar_url = reminder.avatar;
|
||||||
|
else delete message.avatar_url;
|
||||||
|
if (reminder.attachment_name)
|
||||||
|
message.attachments = [{ id: 0, filename: reminder.attachment_name }];
|
||||||
|
else delete message.attachments;
|
||||||
|
return { ...reminder, ...embedFields(embeds[0] || {}), message_json: message };
|
||||||
|
}
|
||||||
@@ -35,23 +35,54 @@ div.reminderContent {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-upload-dropzone {
|
.reminder-embed-editor {
|
||||||
border: 2px dashed #b5b5b5;
|
position: relative;
|
||||||
border-radius: 4px;
|
margin-bottom: 12px;
|
||||||
padding: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-picker-dragging {
|
.discord-embed {
|
||||||
outline: 3px solid #3e8ed0;
|
padding-bottom: 36px;
|
||||||
outline-offset: 2px;
|
|
||||||
|
|
||||||
img {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.image-upload-dropzone {
|
.remove-embed {
|
||||||
background-color: #eff5fb;
|
position: absolute;
|
||||||
border-color: #3e8ed0;
|
right: 24px;
|
||||||
|
bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-embed-row,
|
||||||
|
.add-link-button-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
|
||||||
|
.embed-limit,
|
||||||
|
.link-button-limit {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #737373;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-link-buttons {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button-editor {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button-label {
|
||||||
|
flex: 1 1 140px;
|
||||||
|
}
|
||||||
|
.link-button-url {
|
||||||
|
flex: 2 1 200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
import ts from "typescript";
|
||||||
|
|
||||||
|
// Run the pure editor helpers without a browser or additional test dependencies.
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../src/components/Reminder/message.ts", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const { outputText } = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 },
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
addEmbed,
|
||||||
|
embedFields,
|
||||||
|
prepareReminder,
|
||||||
|
reminderEmbeds,
|
||||||
|
removeEmbed,
|
||||||
|
updateEmbed,
|
||||||
|
addLinkButton,
|
||||||
|
canAddLinkButton,
|
||||||
|
linkButtons,
|
||||||
|
updateLinkButton,
|
||||||
|
} = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`);
|
||||||
|
|
||||||
|
function reminder(embeds) {
|
||||||
|
return {
|
||||||
|
content: "hello",
|
||||||
|
tts: false,
|
||||||
|
username: "",
|
||||||
|
avatar: null,
|
||||||
|
attachment_name: null,
|
||||||
|
uid: "unchanged",
|
||||||
|
utc_time: "2026-10-12T12:00:00",
|
||||||
|
...embedFields(embeds?.[0] || {}),
|
||||||
|
message_json: embeds ? { embeds, allowed_mentions: { parse: [] } } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("editing one embed preserves the others and unedited payload properties", () => {
|
||||||
|
const original = reminder([
|
||||||
|
{ title: "first" },
|
||||||
|
{ description: "second", author: { name: "author", url: "https://example.com" } },
|
||||||
|
]);
|
||||||
|
const edited = updateEmbed(original, 1, (r) => ({ ...r, embed_description: "updated" }));
|
||||||
|
const payload = prepareReminder(edited).message_json;
|
||||||
|
assert.equal(payload.embeds[0].title, "first");
|
||||||
|
assert.equal(payload.embeds[1].description, "updated");
|
||||||
|
assert.equal(payload.embeds[1].author.url, "https://example.com");
|
||||||
|
assert.deepEqual(payload.allowed_mentions, { parse: [] });
|
||||||
|
assert.equal(edited.uid, original.uid);
|
||||||
|
assert.equal(original.message_json.embeds[1].description, "second");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adding stops at ten embeds and removal permits another", () => {
|
||||||
|
let value = reminder([{ title: "first" }]);
|
||||||
|
for (let i = 0; i < 12; i++) value = addEmbed(value);
|
||||||
|
assert.equal(reminderEmbeds(value).length, 10);
|
||||||
|
assert.equal(addEmbed(value), value);
|
||||||
|
value = removeEmbed(value, 0);
|
||||||
|
assert.equal(reminderEmbeds(value).length, 9);
|
||||||
|
value = updateEmbed(value, 0, (r) => ({ ...r, embed_title: "new first" }));
|
||||||
|
assert.equal(prepareReminder(value).message_json.embeds[0].title, "new first");
|
||||||
|
assert.equal(reminderEmbeds(addEmbed(value)).length, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saving and reloading multiple embeds preserves order and drops blank editors", () => {
|
||||||
|
let value = addEmbed(reminder([{ description: "first" }]));
|
||||||
|
value = updateEmbed(value, 1, (r) => ({ ...r, embed_description: "second" }));
|
||||||
|
value = addEmbed(value);
|
||||||
|
const saved = prepareReminder(value);
|
||||||
|
assert.deepEqual(
|
||||||
|
saved.message_json.embeds.map((embed) => embed.description),
|
||||||
|
["first", "second"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(reminderEmbeds(JSON.parse(JSON.stringify(saved))), saved.message_json.embeds);
|
||||||
|
assert.deepEqual(prepareReminder(reminder()).message_json.embeds, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy embeds are retained when a second embed is added", () => {
|
||||||
|
const legacy = {
|
||||||
|
...reminder(),
|
||||||
|
embed_title: "legacy",
|
||||||
|
embed_fields: [{ title: "name", value: "value", inline: true }],
|
||||||
|
};
|
||||||
|
const saved = prepareReminder(addEmbed(legacy));
|
||||||
|
assert.equal(saved.message_json.embeds[0].title, "legacy");
|
||||||
|
assert.deepEqual(saved.message_json.embeds[0].fields, [
|
||||||
|
{ name: "name", value: "value", inline: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("combined embed text accepts 6000 characters and rejects 6001", () => {
|
||||||
|
const embeds = [{ description: "x".repeat(3000) }, { description: "y".repeat(3000) }];
|
||||||
|
assert.equal(prepareReminder(reminder(embeds)).message_json.embeds.length, 2);
|
||||||
|
assert.throws(
|
||||||
|
() => prepareReminder(reminder([embeds[0], { description: "y".repeat(3001) }])),
|
||||||
|
/6000/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => prepareReminder(reminder(Array.from({ length: 11 }, () => ({ title: "embed" })))),
|
||||||
|
/10 embeds/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("link buttons can be added, edited, saved, reloaded and removed without losing embeds", () => {
|
||||||
|
const original = reminder([{ title: "first" }, { title: "second" }]);
|
||||||
|
let value = addLinkButton(original);
|
||||||
|
value = updateLinkButton(value, 0, 0, { label: "Visit", url: "https://example.com" });
|
||||||
|
const saved = prepareReminder(value);
|
||||||
|
assert.deepEqual(saved.message_json.components, [
|
||||||
|
{
|
||||||
|
type: 1,
|
||||||
|
components: [{ type: 2, style: 5, label: "Visit", url: "https://example.com" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.deepEqual(saved.message_json.embeds, original.message_json.embeds);
|
||||||
|
assert.deepEqual(saved.message_json.allowed_mentions, { parse: [] });
|
||||||
|
assert.equal(
|
||||||
|
linkButtons(JSON.parse(JSON.stringify(saved)))[0].button.url,
|
||||||
|
"https://example.com",
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
prepareReminder(updateLinkButton(saved, 0, 0, null)).message_json.components,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.equal(original.message_json.components, undefined);
|
||||||
|
const legacy = updateLinkButton(addLinkButton({ ...reminder(), embed_title: "legacy" }), 0, 0, {
|
||||||
|
url: "https://example.com",
|
||||||
|
});
|
||||||
|
assert.equal(prepareReminder(legacy).message_json.embeds[0].title, "legacy");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("link buttons fill five rows of five and preserve other components", () => {
|
||||||
|
let value = reminder();
|
||||||
|
for (let i = 0; i < 26; i++) value = addLinkButton(value);
|
||||||
|
assert.equal(linkButtons(value).length, 25);
|
||||||
|
assert.deepEqual(
|
||||||
|
value.message_json.components.map((row) => row.components.length),
|
||||||
|
[5, 5, 5, 5, 5],
|
||||||
|
);
|
||||||
|
assert.equal(canAddLinkButton(value), false);
|
||||||
|
assert.equal(addLinkButton(value), value);
|
||||||
|
value = updateLinkButton(value, 2, 2, null);
|
||||||
|
assert.equal(canAddLinkButton(value), true);
|
||||||
|
assert.equal(linkButtons(addLinkButton(value)).length, 25);
|
||||||
|
const other = { type: 1, id: 7, components: [{ type: 3, custom_id: "select", options: [] }] };
|
||||||
|
value = { ...reminder(), message_json: { components: [other] } };
|
||||||
|
value = addLinkButton(value);
|
||||||
|
assert.deepEqual(value.message_json.components[0], other);
|
||||||
|
assert.equal(linkButtons(value)[0].rowIndex, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid link button URLs, labels and row limits are rejected on save", () => {
|
||||||
|
let value = addLinkButton(reminder());
|
||||||
|
assert.throws(() => prepareReminder(value), /URL/);
|
||||||
|
value = updateLinkButton(value, 0, 0, {
|
||||||
|
url: "https://example.com/" + "x".repeat(492),
|
||||||
|
label: "x".repeat(80),
|
||||||
|
});
|
||||||
|
assert.equal(prepareReminder(value).message_json.components.length, 1);
|
||||||
|
for (const change of [
|
||||||
|
{ url: "invalid" },
|
||||||
|
{ url: "javascript:alert(1)" },
|
||||||
|
{ url: "https://example.com/" + "x".repeat(493) },
|
||||||
|
{ label: "" },
|
||||||
|
{ label: "x".repeat(81) },
|
||||||
|
])
|
||||||
|
assert.throws(() => prepareReminder(updateLinkButton(value, 0, 0, change)));
|
||||||
|
const row = value.message_json.components[0];
|
||||||
|
assert.throws(
|
||||||
|
() => prepareReminder({ ...value, message_json: { components: Array(6).fill(row) } }),
|
||||||
|
/5 button rows/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
prepareReminder({
|
||||||
|
...value,
|
||||||
|
message_json: {
|
||||||
|
components: [{ ...row, components: Array(6).fill(row.components[0]) }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/5 components/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
endpoint=http://rustfs:9000
|
|
||||||
|
|
||||||
if ! aws --endpoint-url "$endpoint" s3api head-bucket --bucket "$S3_BUCKET" >/dev/null 2>&1; then
|
|
||||||
if [ "$AWS_DEFAULT_REGION" = us-east-1 ]; then
|
|
||||||
aws --endpoint-url "$endpoint" s3api create-bucket --bucket "$S3_BUCKET"
|
|
||||||
else
|
|
||||||
aws --endpoint-url "$endpoint" s3api create-bucket --bucket "$S3_BUCKET" \
|
|
||||||
--create-bucket-configuration "LocationConstraint=$AWS_DEFAULT_REGION"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Dashboard images need public object reads. Listing and writes remain authenticated.
|
|
||||||
cat > /tmp/public-images-policy.json <<EOF
|
|
||||||
{
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [{
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Principal": "*",
|
|
||||||
"Action": "s3:GetObject",
|
|
||||||
"Resource": "arn:aws:s3:::$S3_BUCKET/uploads/*"
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
aws --endpoint-url "$endpoint" s3api put-bucket-policy --bucket "$S3_BUCKET" \
|
|
||||||
--policy file:///tmp/public-images-policy.json
|
|
||||||
echo "RustFS development bucket ready: $S3_BUCKET"
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Split an image into two lossless, random-looking PNG fixture shares."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
from io import BytesIO
|
|
||||||
from pathlib import Path
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
|
|
||||||
def split_image(input_path: Path, output_dir: Path, index: int | None = None) -> tuple[Path, Path]:
|
|
||||||
if index is None:
|
|
||||||
index = 1
|
|
||||||
while any((output_dir / f"image_{index}{part}.png").exists() for part in ("a", "b")):
|
|
||||||
index += 1
|
|
||||||
if index < 1:
|
|
||||||
raise ValueError("Image index must be a positive integer")
|
|
||||||
|
|
||||||
paths = (
|
|
||||||
output_dir / f"image_{index}a.png",
|
|
||||||
output_dir / f"image_{index}b.png",
|
|
||||||
)
|
|
||||||
for path in paths:
|
|
||||||
if path.exists():
|
|
||||||
raise FileExistsError(f"Refusing to overwrite {path}; choose another --index")
|
|
||||||
|
|
||||||
# RGBA shares preserve every decoded pixel, including transparency. For animated
|
|
||||||
# inputs, use the first frame, matching the dashboard's static image upload behavior.
|
|
||||||
with Image.open(input_path) as source:
|
|
||||||
image = source.convert("RGBA")
|
|
||||||
pixels = image.tobytes()
|
|
||||||
first = secrets.token_bytes(len(pixels))
|
|
||||||
second = bytes(pixel ^ mask for pixel, mask in zip(pixels, first))
|
|
||||||
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
for path, share in zip(paths, (first, second)):
|
|
||||||
# Exclusive creation also prevents overwriting a file created during splitting.
|
|
||||||
encoded = BytesIO()
|
|
||||||
Image.frombytes("RGBA", image.size, share).save(encoded, format="PNG")
|
|
||||||
with path.open("xb") as output:
|
|
||||||
output.write(encoded.getvalue())
|
|
||||||
return paths
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("input_image", type=Path)
|
|
||||||
parser.add_argument("--output-dir", type=Path, default=Path("."))
|
|
||||||
parser.add_argument("--index", type=int, help="Fixture number (default: first unused positive index)")
|
|
||||||
args = parser.parse_args()
|
|
||||||
try:
|
|
||||||
paths = split_image(args.input_image, args.output_dir, args.index)
|
|
||||||
except (OSError, ValueError) as error:
|
|
||||||
parser.exit(1, f"Could not split image: {error}\n")
|
|
||||||
for path in paths:
|
|
||||||
print(path)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
use std::env;
|
|
||||||
|
|
||||||
use log::{info, warn};
|
|
||||||
use sqlx::{Acquire, MySql, Pool};
|
|
||||||
|
|
||||||
use crate::{web::image_uploads::ImageUploadService, Error};
|
|
||||||
|
|
||||||
const DEFAULT_RETENTION_SECONDS: u32 = 7 * 24 * 60 * 60;
|
|
||||||
const BATCH_SIZE: u32 = 1000;
|
|
||||||
const IMAGE_COLUMNS: &str =
|
|
||||||
"avatar, embed_image_url, embed_thumbnail_url, embed_author_url, embed_footer_url";
|
|
||||||
|
|
||||||
fn binary_image_columns() -> String {
|
|
||||||
// Existing URL columns and image_uploads use different collations. S3 URLs must
|
|
||||||
// also match case-sensitively, so compare their bytes rather than text collation.
|
|
||||||
IMAGE_COLUMNS
|
|
||||||
.split(", ")
|
|
||||||
.map(|column| format!("BINARY {column}"))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn retention_seconds(value: Option<&str>) -> Result<u32, Error> {
|
|
||||||
match value.map(str::trim).filter(|value| !value.is_empty()) {
|
|
||||||
None => Ok(DEFAULT_RETENTION_SECONDS),
|
|
||||||
Some(value) => value.parse::<u32>().ok().filter(|value| *value > 0).ok_or_else(|| {
|
|
||||||
"IMAGE_UPLOAD_ORPHAN_RETENTION_SECONDS must be a positive integer of seconds".into()
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only keys generated by the upload route in the configured bucket may be deleted.
|
|
||||||
fn object_key<'a>(public_url: &str, url: &'a str) -> Option<&'a str> {
|
|
||||||
let key = url.strip_prefix(public_url)?.strip_prefix('/')?;
|
|
||||||
let name = key.strip_prefix("uploads/")?.strip_suffix(".png")?;
|
|
||||||
(name.len() == 64
|
|
||||||
&& name.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
|
|
||||||
.then_some(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn mark_orphans(pool: &Pool<MySql>) -> Result<(), Error> {
|
|
||||||
let columns = binary_image_columns();
|
|
||||||
sqlx::query(&format!(
|
|
||||||
"UPDATE image_uploads AS upload SET orphaned_at = CASE
|
|
||||||
WHEN NOT EXISTS (SELECT 1 FROM reminders
|
|
||||||
WHERE BINARY upload.url IN ({columns}))
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM reminder_template
|
|
||||||
WHERE BINARY upload.url IN ({columns}))
|
|
||||||
THEN COALESCE(upload.orphaned_at, UTC_TIMESTAMP(6)) ELSE NULL END"
|
|
||||||
))
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn run(pool: &Pool<MySql>) -> Result<(), Error> {
|
|
||||||
let retention =
|
|
||||||
retention_seconds(env::var("IMAGE_UPLOAD_ORPHAN_RETENTION_SECONDS").ok().as_deref())?;
|
|
||||||
let Some(storage) = ImageUploadService::for_cleanup()? else {
|
|
||||||
info!("Image cleanup skipped: S3 uploads are not configured");
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
cleanup(pool, &storage, retention).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup(
|
|
||||||
pool: &Pool<MySql>,
|
|
||||||
storage: &ImageUploadService,
|
|
||||||
retention: u32,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
mark_orphans(pool).await?;
|
|
||||||
let candidates: Vec<u64> = sqlx::query_scalar(
|
|
||||||
"SELECT id FROM image_uploads
|
|
||||||
WHERE orphaned_at <= UTC_TIMESTAMP(6) - INTERVAL ? SECOND
|
|
||||||
ORDER BY orphaned_at, id LIMIT ?",
|
|
||||||
)
|
|
||||||
.bind(retention)
|
|
||||||
.bind(BATCH_SIZE)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut deleted = 0;
|
|
||||||
let mut failed = 0;
|
|
||||||
for id in candidates {
|
|
||||||
match delete_orphan(pool, storage, retention, id).await {
|
|
||||||
Ok(true) => deleted += 1,
|
|
||||||
Ok(false) => {}
|
|
||||||
Err(error) => {
|
|
||||||
failed += 1;
|
|
||||||
warn!("Could not clean up image upload {}: {}", id, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info!("Image cleanup deleted {} uploads; {} failed", deleted, failed);
|
|
||||||
if failed > 0 {
|
|
||||||
return Err(format!("Image cleanup failed for {failed} uploads; retained for retry").into());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_orphan(
|
|
||||||
pool: &Pool<MySql>,
|
|
||||||
storage: &ImageUploadService,
|
|
||||||
retention: u32,
|
|
||||||
id: u64,
|
|
||||||
) -> Result<bool, Error> {
|
|
||||||
let mut connection = pool.acquire().await?;
|
|
||||||
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
|
|
||||||
.execute(&mut *connection)
|
|
||||||
.await?;
|
|
||||||
let mut transaction = connection.begin().await?;
|
|
||||||
// Serialize overlapping cleanup jobs and recheck the retention time under the row lock.
|
|
||||||
let url: Option<String> = sqlx::query_scalar(
|
|
||||||
"SELECT url FROM image_uploads
|
|
||||||
WHERE id = ? AND orphaned_at <= UTC_TIMESTAMP(6) - INTERVAL ? SECOND
|
|
||||||
FOR UPDATE",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.bind(retention)
|
|
||||||
.fetch_optional(&mut *transaction)
|
|
||||||
.await?;
|
|
||||||
let Some(url) = url else {
|
|
||||||
transaction.rollback().await?;
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
let key = object_key(&storage.public_url, &url)
|
|
||||||
.ok_or("Upload URL does not match a generated key in the configured S3 public URL")?;
|
|
||||||
|
|
||||||
for table in ["reminders", "reminder_template"] {
|
|
||||||
// Locking reads see current committed references, rather than a stale snapshot.
|
|
||||||
// InnoDB's REPEATABLE READ next-key locks also prevent a reference from
|
|
||||||
// being inserted while the object is being removed. Keep this transaction short:
|
|
||||||
// it covers one S3 request with the upload service's 30-second timeout.
|
|
||||||
let columns = binary_image_columns();
|
|
||||||
let referenced: Option<u32> = sqlx::query_scalar(&format!(
|
|
||||||
"SELECT id FROM {table} WHERE BINARY ? IN ({columns}) LIMIT 1 FOR UPDATE"
|
|
||||||
))
|
|
||||||
.bind(&url)
|
|
||||||
.fetch_optional(&mut *transaction)
|
|
||||||
.await?;
|
|
||||||
if referenced.is_some() {
|
|
||||||
sqlx::query("UPDATE image_uploads SET orphaned_at = NULL WHERE id = ?")
|
|
||||||
.bind(id)
|
|
||||||
.execute(&mut *transaction)
|
|
||||||
.await?;
|
|
||||||
transaction.commit().await?;
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = storage.bucket.delete_object(key).await?;
|
|
||||||
// A missing object is already cleaned up, including a retry after a DB commit failure.
|
|
||||||
if !(200..300).contains(&response.status_code()) && response.status_code() != 404 {
|
|
||||||
return Err(format!("S3 deletion returned HTTP {}", response.status_code()).into());
|
|
||||||
}
|
|
||||||
sqlx::query("DELETE FROM image_uploads WHERE id = ?")
|
|
||||||
.bind(id)
|
|
||||||
.execute(&mut *transaction)
|
|
||||||
.await?;
|
|
||||||
transaction.commit().await?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
use std::{sync::Arc, time::Duration};
|
|
||||||
|
|
||||||
use chrono::NaiveDateTime;
|
|
||||||
use s3::{creds::Credentials, Bucket, Region};
|
|
||||||
use tokio::{
|
|
||||||
io::{AsyncReadExt, AsyncWriteExt},
|
|
||||||
net::TcpListener,
|
|
||||||
sync::Semaphore,
|
|
||||||
task::JoinHandle,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const BASE_URL: &str = "https://images.example.com/bucket";
|
|
||||||
|
|
||||||
fn upload_url(number: u64) -> String {
|
|
||||||
format!("{BASE_URL}/uploads/{number:064x}.png")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn upload(pool: &Pool<MySql>, number: u64) -> u64 {
|
|
||||||
sqlx::query("INSERT INTO image_uploads (uploader, url) VALUES (123, ?)")
|
|
||||||
.bind(upload_url(number))
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.last_insert_id()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn orphaned_at(pool: &Pool<MySql>, id: u64) -> Option<NaiveDateTime> {
|
|
||||||
sqlx::query_scalar("SELECT orphaned_at FROM image_uploads WHERE id = ?")
|
|
||||||
.bind(id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn expire(pool: &Pool<MySql>, id: u64) {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE image_uploads SET orphaned_at = UTC_TIMESTAMP(6) - INTERVAL 2 DAY WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn reference_tables(pool: &Pool<MySql>) -> (u64, u64) {
|
|
||||||
let guild = sqlx::query("INSERT INTO guilds (guild) VALUES (123)")
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.last_insert_id();
|
|
||||||
let channel = sqlx::query("INSERT INTO channels (channel, guild_id) VALUES (123, ?)")
|
|
||||||
.bind(guild)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.last_insert_id();
|
|
||||||
(guild, channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn storage(status: u16) -> (ImageUploadService, JoinHandle<String>) {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let endpoint = format!("http://{}", listener.local_addr().unwrap());
|
|
||||||
let task = tokio::spawn(async move {
|
|
||||||
let (mut socket, _) =
|
|
||||||
tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().unwrap();
|
|
||||||
let mut received = Vec::new();
|
|
||||||
let mut buffer = [0; 4096];
|
|
||||||
while !received.windows(4).any(|window| window == b"\r\n\r\n") {
|
|
||||||
let count = socket.read(&mut buffer).await.unwrap();
|
|
||||||
assert!(count > 0);
|
|
||||||
received.extend_from_slice(&buffer[..count]);
|
|
||||||
}
|
|
||||||
socket
|
|
||||||
.write_all(
|
|
||||||
format!("HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
|
||||||
.as_bytes(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
String::from_utf8(received).unwrap()
|
|
||||||
});
|
|
||||||
let credentials = Credentials::new(Some("test"), Some("test"), None, None, None).unwrap();
|
|
||||||
let mut bucket =
|
|
||||||
Bucket::new("images", Region::Custom { region: "local".into(), endpoint }, credentials)
|
|
||||||
.unwrap()
|
|
||||||
.with_path_style();
|
|
||||||
bucket.set_request_timeout(Some(Duration::from_secs(2)));
|
|
||||||
(
|
|
||||||
ImageUploadService {
|
|
||||||
bucket,
|
|
||||||
public_url: BASE_URL.into(),
|
|
||||||
model: None,
|
|
||||||
slots: Arc::new(Semaphore::new(2)),
|
|
||||||
},
|
|
||||||
task,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn retention_defaults_and_rejects_invalid_settings() {
|
|
||||||
for value in [None, Some(""), Some(" ")] {
|
|
||||||
assert_eq!(retention_seconds(value).unwrap(), 604800);
|
|
||||||
}
|
|
||||||
assert_eq!(retention_seconds(Some(" 3600 ")).unwrap(), 3600);
|
|
||||||
for value in ["0", "-1", "1.5", "forever", "4294967296"] {
|
|
||||||
assert!(retention_seconds(Some(value)).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn restricts_deletion_to_generated_keys_in_the_configured_url() {
|
|
||||||
assert_eq!(
|
|
||||||
object_key(BASE_URL, &upload_url(1)),
|
|
||||||
Some(format!("uploads/{:064x}.png", 1).as_str())
|
|
||||||
);
|
|
||||||
for url in [
|
|
||||||
upload_url(1).replace("images.example.com", "other.example.com"),
|
|
||||||
upload_url(1).replace("/bucket/", "/bucket-other/"),
|
|
||||||
upload_url(1).replace("/uploads/", "/private/"),
|
|
||||||
format!("{BASE_URL}/uploads/../private.png"),
|
|
||||||
format!("{BASE_URL}/uploads/{}.png", "g".repeat(64)),
|
|
||||||
format!("{}?download=true", upload_url(1)),
|
|
||||||
format!("{BASE_URL}/uploads/short.png"),
|
|
||||||
] {
|
|
||||||
assert!(object_key(BASE_URL, &url).is_none(), "{url}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[sqlx::test]
|
|
||||||
async fn orphan_timestamp_survives_repeated_runs_and_resets_on_reuse(pool: Pool<MySql>) {
|
|
||||||
let id = upload(&pool, 1).await;
|
|
||||||
assert!(orphaned_at(&pool, id).await.is_none());
|
|
||||||
mark_orphans(&pool).await.unwrap();
|
|
||||||
let first = orphaned_at(&pool, id).await.unwrap();
|
|
||||||
mark_orphans(&pool).await.unwrap();
|
|
||||||
assert_eq!(orphaned_at(&pool, id).await, Some(first));
|
|
||||||
|
|
||||||
let (guild, _) = reference_tables(&pool).await;
|
|
||||||
let template =
|
|
||||||
sqlx::query("INSERT INTO reminder_template (guild_id, embed_image_url) VALUES (?, ?)")
|
|
||||||
.bind(guild)
|
|
||||||
.bind(upload_url(1))
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.last_insert_id();
|
|
||||||
mark_orphans(&pool).await.unwrap();
|
|
||||||
assert!(orphaned_at(&pool, id).await.is_none());
|
|
||||||
sqlx::query("DELETE FROM reminder_template WHERE id = ?")
|
|
||||||
.bind(template)
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
mark_orphans(&pool).await.unwrap();
|
|
||||||
assert!(orphaned_at(&pool, id).await.unwrap() > first);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[sqlx::test]
|
|
||||||
async fn protects_all_image_fields_in_reminders_and_templates(pool: Pool<MySql>) {
|
|
||||||
let (guild, channel) = reference_tables(&pool).await;
|
|
||||||
let mut ids = Vec::new();
|
|
||||||
for (index, column) in IMAGE_COLUMNS.split(", ").enumerate() {
|
|
||||||
for table in ["reminders", "reminder_template"] {
|
|
||||||
let number = ids.len() as u64 + 1;
|
|
||||||
ids.push(upload(&pool, number).await);
|
|
||||||
let query = if table == "reminders" {
|
|
||||||
// Archived/disabled reminders still retain their image URLs.
|
|
||||||
format!(
|
|
||||||
"INSERT INTO reminders (uid, channel_id, `utc_time`, status, enabled, {column})
|
|
||||||
VALUES ('field-{index}', {channel}, UTC_TIMESTAMP(), 'sent', 0, ?)"
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!("INSERT INTO reminder_template (guild_id, {column}) VALUES ({guild}, ?)")
|
|
||||||
};
|
|
||||||
sqlx::query(&query).bind(upload_url(number)).execute(&pool).await.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let unused = upload(&pool, 100).await;
|
|
||||||
// Previously orphaned files are unmarked as soon as cleanup observes a reference.
|
|
||||||
for id in &ids {
|
|
||||||
expire(&pool, *id).await;
|
|
||||||
}
|
|
||||||
mark_orphans(&pool).await.unwrap();
|
|
||||||
for id in ids {
|
|
||||||
assert!(orphaned_at(&pool, id).await.is_none());
|
|
||||||
}
|
|
||||||
assert!(orphaned_at(&pool, unused).await.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[sqlx::test]
|
|
||||||
async fn only_expired_orphans_are_deleted_and_rows_removed_after_success(pool: Pool<MySql>) {
|
|
||||||
let old = upload(&pool, 1).await;
|
|
||||||
let fresh = upload(&pool, 2).await;
|
|
||||||
expire(&pool, old).await;
|
|
||||||
let (storage, request) = storage(204).await;
|
|
||||||
cleanup(&pool, &storage, 86400).await.unwrap();
|
|
||||||
assert!(request.await.unwrap().starts_with(&format!("DELETE /images/uploads/{:064x}.png ", 1)));
|
|
||||||
let ids: Vec<u64> = sqlx::query_scalar("SELECT id FROM image_uploads ORDER BY id")
|
|
||||||
.fetch_all(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(ids, vec![fresh]);
|
|
||||||
assert!(orphaned_at(&pool, fresh).await.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[sqlx::test]
|
|
||||||
async fn failed_deletions_keep_the_original_timestamp_and_retry_missing_objects(pool: Pool<MySql>) {
|
|
||||||
let id = upload(&pool, 1).await;
|
|
||||||
expire(&pool, id).await;
|
|
||||||
let original = orphaned_at(&pool, id).await;
|
|
||||||
let (failed_storage, failed_request) = storage(403).await;
|
|
||||||
assert!(cleanup(&pool, &failed_storage, 86400).await.is_err());
|
|
||||||
failed_request.await.unwrap();
|
|
||||||
assert_eq!(orphaned_at(&pool, id).await, original);
|
|
||||||
|
|
||||||
let (retry_storage, retry_request) = storage(404).await;
|
|
||||||
cleanup(&pool, &retry_storage, 86400).await.unwrap();
|
|
||||||
retry_request.await.unwrap();
|
|
||||||
let count: i64 =
|
|
||||||
sqlx::query_scalar("SELECT COUNT(*) FROM image_uploads").fetch_one(&pool).await.unwrap();
|
|
||||||
assert_eq!(count, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[sqlx::test]
|
|
||||||
async fn rechecks_references_before_deleting_a_candidate(pool: Pool<MySql>) {
|
|
||||||
let id = upload(&pool, 1).await;
|
|
||||||
expire(&pool, id).await;
|
|
||||||
let (guild, _) = reference_tables(&pool).await;
|
|
||||||
sqlx::query("INSERT INTO reminder_template (guild_id, avatar) VALUES (?, ?)")
|
|
||||||
.bind(guild)
|
|
||||||
.bind(upload_url(1))
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let (storage, request) = storage(204).await;
|
|
||||||
assert!(!delete_orphan(&pool, &storage, 86400, id).await.unwrap());
|
|
||||||
assert!(orphaned_at(&pool, id).await.is_none());
|
|
||||||
request.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[sqlx::test]
|
|
||||||
async fn foreign_urls_are_retained_without_contacting_s3(pool: Pool<MySql>) {
|
|
||||||
let id = upload(&pool, 1).await;
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE image_uploads SET url = 'https://elsewhere.example.com/file.png' WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
expire(&pool, id).await;
|
|
||||||
let (storage, request) = storage(204).await;
|
|
||||||
assert!(cleanup(&pool, &storage, 86400).await.is_err());
|
|
||||||
assert!(orphaned_at(&pool, id).await.is_some());
|
|
||||||
request.abort();
|
|
||||||
}
|
|
||||||
@@ -9,8 +9,8 @@ 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 message;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
mod models;
|
mod models;
|
||||||
@@ -122,7 +122,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() {
|
||||||
|
|||||||
@@ -0,0 +1,604 @@
|
|||||||
|
//! Conversion between the dashboard's message fields and Discord webhook payloads.
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use sqlx::{types::Json, FromRow, MySqlConnection};
|
||||||
|
|
||||||
|
#[derive(Default, Deserialize, Serialize, FromRow)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct MessageFields {
|
||||||
|
content: String,
|
||||||
|
tts: bool,
|
||||||
|
username: Option<String>,
|
||||||
|
avatar: Option<String>,
|
||||||
|
embed_title: String,
|
||||||
|
embed_description: String,
|
||||||
|
embed_author: String,
|
||||||
|
embed_author_url: Option<String>,
|
||||||
|
embed_footer: String,
|
||||||
|
embed_footer_url: Option<String>,
|
||||||
|
embed_image_url: Option<String>,
|
||||||
|
embed_thumbnail_url: Option<String>,
|
||||||
|
embed_color: u32,
|
||||||
|
embed_fields: Option<Json<Value>>,
|
||||||
|
message_json: Option<Json<Value>>,
|
||||||
|
attachment_name: Option<String>,
|
||||||
|
has_attachment: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMBED_PATHS: &[(&str, &[&str])] = &[
|
||||||
|
("embed_title", &["title"]),
|
||||||
|
("embed_description", &["description"]),
|
||||||
|
("embed_author", &["author", "name"]),
|
||||||
|
("embed_author_url", &["author", "icon_url"]),
|
||||||
|
("embed_footer", &["footer", "text"]),
|
||||||
|
("embed_footer_url", &["footer", "icon_url"]),
|
||||||
|
("embed_image_url", &["image", "url"]),
|
||||||
|
("embed_thumbnail_url", &["thumbnail", "url"]),
|
||||||
|
("embed_color", &["color"]),
|
||||||
|
("embed_fields", &["fields"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn set_path(target: &mut Value, path: &[&str], value: Value) {
|
||||||
|
if !target.is_object() {
|
||||||
|
*target = json!({});
|
||||||
|
}
|
||||||
|
let object = target.as_object_mut().unwrap();
|
||||||
|
if path.len() == 1 {
|
||||||
|
if value.is_null() {
|
||||||
|
object.remove(path[0]);
|
||||||
|
} else {
|
||||||
|
object.insert(path[0].to_string(), value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set_path(object.entry(path[0]).or_insert_with(|| json!({})), &path[1..], value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn embed_has_content(embed: &Value) -> bool {
|
||||||
|
["/title", "/description", "/author/name", "/footer/text", "/footer/icon_url",
|
||||||
|
"/image/url", "/thumbnail/url"]
|
||||||
|
.iter()
|
||||||
|
.any(|path| embed.pointer(path).and_then(Value::as_str).is_some_and(|s| !s.trim().is_empty()))
|
||||||
|
|| embed["fields"].as_array().is_some_and(|fields| !fields.is_empty())
|
||||||
|
// Preserve payload features the dashboard does not edit.
|
||||||
|
|| embed.as_object().is_some_and(|object| object.keys().any(|key|
|
||||||
|
!["title", "description", "author", "footer", "image", "thumbnail", "color", "fields"].contains(&key.as_str())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discord_fields(value: &Value) -> Value {
|
||||||
|
match value.as_array() {
|
||||||
|
Some(fields) => Value::Array(
|
||||||
|
fields
|
||||||
|
.iter()
|
||||||
|
.map(|field| {
|
||||||
|
json!({
|
||||||
|
"name": field["title"], "value": field["value"], "inline": field["inline"]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
None => json!([]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use an explicit payload verbatim, or build one from existing dashboard fields.
|
||||||
|
pub fn payload(reminder: &impl Serialize) -> Value {
|
||||||
|
let fields = serde_json::to_value(reminder).expect("Reminder fields are serializable");
|
||||||
|
if !fields["message_json"].is_null() {
|
||||||
|
return fields["message_json"].clone();
|
||||||
|
}
|
||||||
|
let mut payload = json!({ "content": fields["content"], "tts": fields["tts"] });
|
||||||
|
if fields["username"].as_str().is_some_and(|s| !s.is_empty()) {
|
||||||
|
payload["username"] = fields["username"].clone();
|
||||||
|
}
|
||||||
|
if !fields["avatar"].is_null() {
|
||||||
|
payload["avatar_url"] = fields["avatar"].clone();
|
||||||
|
}
|
||||||
|
let mut embed = json!({});
|
||||||
|
for (field, path) in EMBED_PATHS {
|
||||||
|
let value = if *field == "embed_fields" {
|
||||||
|
discord_fields(&fields[*field])
|
||||||
|
} else {
|
||||||
|
fields[*field].clone()
|
||||||
|
};
|
||||||
|
set_path(&mut embed, path, value);
|
||||||
|
}
|
||||||
|
if embed_has_content(&embed) {
|
||||||
|
payload["embeds"] = json!([embed]);
|
||||||
|
}
|
||||||
|
if (!fields["attachment"].is_null() || fields["has_attachment"].as_bool() == Some(true))
|
||||||
|
&& fields["attachment_name"].is_string()
|
||||||
|
{
|
||||||
|
payload["attachments"] = json!([{ "id": 0, "filename": fields["attachment_name"] }]);
|
||||||
|
}
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate Discord's link buttons, embed count and text limits.
|
||||||
|
pub fn validate_payload(message: &Value) -> Result<(), String> {
|
||||||
|
if !message.is_object() {
|
||||||
|
return Err("message_json must be an object".into());
|
||||||
|
}
|
||||||
|
if let Some(content) = message.get("content") {
|
||||||
|
if content.as_str().is_none_or(|text| text.chars().count() > 2000) {
|
||||||
|
return Err("Message content exceeds 2000 characters or is not text".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validate_components(message)?;
|
||||||
|
let Some(embeds) = message.get("embeds") else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let embeds = embeds.as_array().ok_or("embeds must be an array")?;
|
||||||
|
if embeds.len() > 10 {
|
||||||
|
return Err("A reminder can have at most 10 embeds".into());
|
||||||
|
}
|
||||||
|
let mut total = 0;
|
||||||
|
for (index, embed) in embeds.iter().enumerate() {
|
||||||
|
if !embed.is_object() {
|
||||||
|
return Err(format!("Embed {} must be an object", index + 1));
|
||||||
|
}
|
||||||
|
let mut check_text = |value: Option<&Value>, limit: usize| -> Result<(), String> {
|
||||||
|
if let Some(value) = value {
|
||||||
|
let text = value
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| format!("Embed {} has a non-text field", index + 1))?;
|
||||||
|
let length = text.trim().chars().count();
|
||||||
|
if length > limit {
|
||||||
|
return Err(format!(
|
||||||
|
"Embed {} exceeds a {} character field limit",
|
||||||
|
index + 1,
|
||||||
|
limit
|
||||||
|
));
|
||||||
|
}
|
||||||
|
total += length;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
for (path, limit) in
|
||||||
|
[("/title", 256), ("/description", 4096), ("/author/name", 256), ("/footer/text", 2048)]
|
||||||
|
{
|
||||||
|
check_text(embed.pointer(path), limit)?;
|
||||||
|
}
|
||||||
|
if let Some(fields) = embed.get("fields") {
|
||||||
|
let fields = fields.as_array().ok_or("Embed fields must be an array")?;
|
||||||
|
if fields.len() > 25 {
|
||||||
|
return Err("An embed can have at most 25 fields".into());
|
||||||
|
}
|
||||||
|
for field in fields {
|
||||||
|
if !field.is_object() {
|
||||||
|
return Err("Embed fields must be objects".into());
|
||||||
|
}
|
||||||
|
for (name, limit) in [("name", 256), ("value", 1024)] {
|
||||||
|
let text = field.get(name).ok_or("Embed fields require a name and value")?;
|
||||||
|
if text.as_str().is_none_or(|text| text.trim().is_empty()) {
|
||||||
|
return Err("Embed fields require a non-empty name and value".into());
|
||||||
|
}
|
||||||
|
check_text(Some(text), limit)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total > 6000 {
|
||||||
|
return Err("Embed text exceeds 6000 characters across all embeds".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_components(message: &Value) -> Result<(), String> {
|
||||||
|
let Some(components) = message.get("components") else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let rows = components.as_array().ok_or("components must be an array")?;
|
||||||
|
// Components V2 has a different layout and count limit. Leave its validation to Discord.
|
||||||
|
if message["flags"].as_u64().is_some_and(|flags| flags & (1 << 15) != 0) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if rows.len() > 5 {
|
||||||
|
return Err("A reminder can have at most 5 button rows (25 buttons)".into());
|
||||||
|
}
|
||||||
|
for row in rows {
|
||||||
|
if row["type"].as_u64() != Some(1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let buttons = row["components"].as_array().ok_or("Button rows require components")?;
|
||||||
|
if buttons.is_empty() || buttons.len() > 5 {
|
||||||
|
return Err("A button row must have between 1 and 5 components".into());
|
||||||
|
}
|
||||||
|
for button in buttons {
|
||||||
|
if button["type"].as_u64() != Some(2) || button["style"].as_u64() != Some(5) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let label = button.get("label").and_then(Value::as_str);
|
||||||
|
if label.is_some_and(|label| label.chars().count() > 80)
|
||||||
|
|| (label.is_none_or(str::is_empty) && !button["emoji"].is_object())
|
||||||
|
|| button.get("label").is_some_and(|label| !label.is_string())
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
"Link buttons require a label of at most 80 characters or an emoji".into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let url = button["url"].as_str().ok_or("Link buttons require a URL")?;
|
||||||
|
if url.chars().count() > 512
|
||||||
|
|| !reqwest::Url::parse(url)
|
||||||
|
.is_ok_and(|url| ["http", "https"].contains(&url.scheme()))
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
"Link buttons require an HTTP or HTTPS URL of at most 512 characters".into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if button.get("custom_id").is_some() || button.get("sku_id").is_some() {
|
||||||
|
return Err("Link buttons cannot have a custom_id or sku_id".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Present the first embed through the existing dashboard controls.
|
||||||
|
pub fn response(reminder: impl Serialize) -> Value {
|
||||||
|
let mut reminder = serde_json::to_value(reminder).expect("Reminder fields are serializable");
|
||||||
|
if let Some(reminders) = reminder.as_array_mut() {
|
||||||
|
for reminder in reminders {
|
||||||
|
*reminder = response(&*reminder);
|
||||||
|
}
|
||||||
|
return reminder;
|
||||||
|
}
|
||||||
|
let payload = reminder["message_json"].clone();
|
||||||
|
if payload.is_null() {
|
||||||
|
return reminder;
|
||||||
|
}
|
||||||
|
reminder["content"] = payload["content"].as_str().unwrap_or_default().into();
|
||||||
|
reminder["tts"] = payload["tts"].as_bool().unwrap_or(false).into();
|
||||||
|
reminder["username"] = payload["username"].clone();
|
||||||
|
reminder["avatar"] = payload["avatar_url"].clone();
|
||||||
|
for (field, path) in EMBED_PATHS {
|
||||||
|
let value = payload["embeds"][0]
|
||||||
|
.pointer(&format!("/{}", path.join("/")))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
reminder[*field] = match *field {
|
||||||
|
"embed_fields" => Value::Array(value.as_array().map(|fields| fields.iter().map(|field| json!({
|
||||||
|
"title": field["name"], "value": field["value"], "inline": field["inline"].as_bool().unwrap_or(false)
|
||||||
|
})).collect()).unwrap_or_default()),
|
||||||
|
"embed_color" => value.as_u64().unwrap_or(0).into(),
|
||||||
|
"embed_title" | "embed_description" | "embed_author" | "embed_footer" => value.as_str().unwrap_or_default().into(),
|
||||||
|
_ => value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
reminder
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_message_field(field: &str) -> bool {
|
||||||
|
["content", "tts", "username", "avatar"].contains(&field)
|
||||||
|
|| EMBED_PATHS.iter().any(|(name, _)| *name == field)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch(payload: &mut Value, field: &str, mut value: Value) {
|
||||||
|
if let Some((_, path)) = EMBED_PATHS.iter().find(|(name, _)| *name == field) {
|
||||||
|
if !payload["embeds"].is_array() {
|
||||||
|
payload["embeds"] = json!([]);
|
||||||
|
}
|
||||||
|
let embeds = payload["embeds"].as_array_mut().unwrap();
|
||||||
|
if embeds.is_empty() {
|
||||||
|
embeds.push(json!({}));
|
||||||
|
}
|
||||||
|
if field == "embed_fields" {
|
||||||
|
value = discord_fields(&value);
|
||||||
|
}
|
||||||
|
set_path(&mut embeds[0], path, value);
|
||||||
|
if !embed_has_content(&embeds[0]) {
|
||||||
|
embeds.remove(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if field == "username" && value.as_str() == Some("") {
|
||||||
|
value = Value::Null;
|
||||||
|
}
|
||||||
|
set_path(payload, &[if field == "avatar" { "avatar_url" } else { field }], value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert legacy rows on first edit and preserve unedited payload keys.
|
||||||
|
pub async fn update_field(
|
||||||
|
connection: &mut MySqlConnection,
|
||||||
|
uid: &str,
|
||||||
|
field: &str,
|
||||||
|
value: &impl Serialize,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
let fields = sqlx::query_as::<_, MessageFields>(
|
||||||
|
"SELECT content, tts, username, avatar, embed_title, embed_description,
|
||||||
|
embed_author, embed_author_url, embed_footer, embed_footer_url, embed_image_url,
|
||||||
|
embed_thumbnail_url, embed_color, embed_fields, message_json, attachment_name,
|
||||||
|
attachment IS NOT NULL AS has_attachment FROM reminders WHERE uid = ? FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_one(&mut *connection)
|
||||||
|
.await?;
|
||||||
|
let mut message = payload(&fields);
|
||||||
|
let value = if field == "attachments" {
|
||||||
|
if fields.has_attachment {
|
||||||
|
fields
|
||||||
|
.attachment_name
|
||||||
|
.as_ref()
|
||||||
|
.map(|name| json!([{ "id": 0, "filename": name }]))
|
||||||
|
.unwrap_or_else(|| json!([]))
|
||||||
|
} else {
|
||||||
|
json!([])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
serde_json::to_value(value).map_err(|e| sqlx::Error::Encode(e.into()))?
|
||||||
|
};
|
||||||
|
patch(&mut message, field, value);
|
||||||
|
validate_payload(&message).map_err(sqlx::Error::Protocol)?;
|
||||||
|
sqlx::query("UPDATE reminders SET message_json = ? WHERE uid = ?")
|
||||||
|
.bind(Json(message))
|
||||||
|
.bind(uid)
|
||||||
|
.execute(connection)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_discord_payload_and_omits_empty_embed() {
|
||||||
|
assert_eq!(
|
||||||
|
payload(&json!({"content": "hello", "tts": true})),
|
||||||
|
json!({"content": "hello", "tts": true})
|
||||||
|
);
|
||||||
|
let message = payload(&json!({"content": "", "tts": false, "embed_description": "body",
|
||||||
|
"embed_fields": [{"title": "heading", "value": "value", "inline": true}],
|
||||||
|
"attachment": "bytes", "attachment_name": "file.txt"}));
|
||||||
|
assert_eq!(message["embeds"][0]["fields"][0]["name"], "heading");
|
||||||
|
assert_eq!(message["attachments"], json!([{ "id": 0, "filename": "file.txt" }]));
|
||||||
|
assert_eq!(response(json!({"message_json": message}))["embed_description"], "body");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_payload_is_exact() {
|
||||||
|
let exact = json!({"content": "<<timenow:UTC:%Y>>", "flags": 32768, "components": [], "custom": true});
|
||||||
|
assert_eq!(
|
||||||
|
payload(&json!({"message_json": exact, "content": "ignored", "tts": true})),
|
||||||
|
exact
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn null_payload_falls_back_to_legacy_fields() {
|
||||||
|
let legacy = json!({
|
||||||
|
"message_json": null, "content": "legacy", "tts": false,
|
||||||
|
"username": "Reminder", "avatar": "https://example.com/avatar.png"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
payload(&legacy),
|
||||||
|
json!({
|
||||||
|
"content": "legacy", "tts": false, "username": "Reminder",
|
||||||
|
"avatar_url": "https://example.com/avatar.png"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let expected_response = legacy.clone();
|
||||||
|
|
||||||
|
assert_eq!(response(legacy), expected_response);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dashboard_response_uses_payload_and_preserves_schedule() {
|
||||||
|
let reminder = response(json!({
|
||||||
|
"uid": "reminder-id", "utc_time": "2026-10-12T12:00:00", "enabled": true,
|
||||||
|
"content": "stale", "embed_description": "stale", "tts": false,
|
||||||
|
"message_json": {
|
||||||
|
"content": "current", "tts": true,
|
||||||
|
"embeds": [{"description": "body", "fields": [{"name": "heading", "value": "value"}]}]
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert_eq!(reminder["content"], "current");
|
||||||
|
assert_eq!(reminder["tts"], true);
|
||||||
|
assert_eq!(reminder["embed_description"], "body");
|
||||||
|
assert_eq!(
|
||||||
|
reminder["embed_fields"],
|
||||||
|
json!([{"title": "heading", "value": "value", "inline": false}])
|
||||||
|
);
|
||||||
|
assert_eq!(reminder["uid"], "reminder-id");
|
||||||
|
assert_eq!(reminder["utc_time"], "2026-10-12T12:00:00");
|
||||||
|
assert_eq!(reminder["enabled"], true);
|
||||||
|
assert_eq!(reminder["message_json"]["embeds"].as_array().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clearing_webhook_identity_preserves_other_payload_keys() {
|
||||||
|
let mut message = json!({
|
||||||
|
"content": "hello", "username": "Reminder",
|
||||||
|
"avatar_url": "https://example.com/avatar.png", "flags": 4
|
||||||
|
});
|
||||||
|
|
||||||
|
patch(&mut message, "username", json!(""));
|
||||||
|
patch(&mut message, "avatar", Value::Null);
|
||||||
|
|
||||||
|
assert_eq!(message, json!({"content": "hello", "flags": 4}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edits_preserve_extra_payload_features() {
|
||||||
|
let mut message = json!({"flags": 4, "embeds": [{"description": "before", "url": "https://example.com"}, {"title": "second"}]});
|
||||||
|
patch(&mut message, "embed_description", json!("after"));
|
||||||
|
patch(&mut message, "embed_author_url", json!("https://example.com/icon.png"));
|
||||||
|
assert_eq!(message["flags"], 4);
|
||||||
|
assert_eq!(message["embeds"][0]["description"], "after");
|
||||||
|
assert_eq!(message["embeds"][0]["url"], "https://example.com");
|
||||||
|
assert_eq!(message["embeds"][1]["title"], "second");
|
||||||
|
patch(&mut message, "embed_author_url", Value::Null);
|
||||||
|
assert!(message["embeds"][0]["author"]["icon_url"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clearing_last_embed_content_removes_embed() {
|
||||||
|
let mut message = json!({"embeds": [{"description": "body", "color": 123}]});
|
||||||
|
patch(&mut message, "embed_description", json!(""));
|
||||||
|
assert_eq!(message["embeds"], json!([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_embed_count_and_combined_text_boundaries() {
|
||||||
|
assert!(validate_payload(&json!({"embeds": vec![json!({"title": "embed"}); 10]})).is_ok());
|
||||||
|
assert!(validate_payload(&json!({"embeds": vec![json!({"title": "embed"}); 11]})).is_err());
|
||||||
|
let mut message = json!({"embeds": [{"description": "x".repeat(3000)}, {"description": "y".repeat(3000)}]});
|
||||||
|
assert!(validate_payload(&message).is_ok());
|
||||||
|
message["embeds"][1]["title"] = json!("x");
|
||||||
|
assert!(validate_payload(&message).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_each_embed_and_field() {
|
||||||
|
assert!(validate_payload(&json!({"embeds": [{"description": "x".repeat(4097)}]})).is_err());
|
||||||
|
assert!(validate_payload(
|
||||||
|
&json!({"embeds": [{"fields": vec![json!({"name": "x", "value": "y"}); 26]}]})
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
for message in [
|
||||||
|
json!([]),
|
||||||
|
json!({"embeds": {}}),
|
||||||
|
json!({"embeds": [null]}),
|
||||||
|
json!({"embeds": [{"fields": [{"name": "x"}]}]}),
|
||||||
|
json!({"embeds": [{"description": 1}]}),
|
||||||
|
] {
|
||||||
|
assert!(validate_payload(&message).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dashboard_response_retains_all_embeds_for_editing() {
|
||||||
|
let message = json!({"embeds": [{"title": "first"}, {"title": "second"}], "allowed_mentions": {"parse": []}});
|
||||||
|
let displayed =
|
||||||
|
response(json!({"message_json": message, "content": "", "uid": "unchanged"}));
|
||||||
|
assert_eq!(displayed["message_json"], message);
|
||||||
|
assert_eq!(displayed["embed_title"], "first");
|
||||||
|
assert_eq!(displayed["uid"], "unchanged");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn link_buttons_survive_payload_response_and_legacy_edits() {
|
||||||
|
let components = json!([{"type": 1, "components": [{
|
||||||
|
"type": 2, "style": 5, "label": "Open link", "url": "https://example.com"
|
||||||
|
}]}]);
|
||||||
|
let mut message = json!({"content": "hello", "components": components});
|
||||||
|
assert!(validate_payload(&message).is_ok());
|
||||||
|
assert_eq!(payload(&json!({"message_json": message})), message);
|
||||||
|
assert_eq!(response(json!({"message_json": message}))["message_json"], message);
|
||||||
|
patch(&mut message, "content", json!("updated"));
|
||||||
|
patch(&mut message, "embed_description", json!("embed"));
|
||||||
|
assert_eq!(message["components"], components);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_link_button_count_and_text_boundaries() {
|
||||||
|
let button = json!({"type": 2, "style": 5, "label": "x".repeat(80),
|
||||||
|
"url": format!("https://example.com/{}", "x".repeat(492))});
|
||||||
|
let row = json!({"type": 1, "components": vec![button.clone(); 5]});
|
||||||
|
assert!(validate_payload(&json!({"components": vec![row.clone(); 5]})).is_ok());
|
||||||
|
assert!(validate_payload(&json!({"components": vec![row; 6]})).is_err());
|
||||||
|
assert!(validate_payload(
|
||||||
|
&json!({"components": [{"type": 1, "components": vec![button.clone(); 6]}]})
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
for (field, value) in [
|
||||||
|
("label", json!("x".repeat(81))),
|
||||||
|
("url", json!(format!("https://example.com/{}", "x".repeat(493)))),
|
||||||
|
] {
|
||||||
|
let mut invalid = button.clone();
|
||||||
|
invalid[field] = value;
|
||||||
|
assert!(validate_payload(
|
||||||
|
&json!({"components": [{"type": 1, "components": [invalid]}]})
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_link_buttons_without_embeds() {
|
||||||
|
for change in [
|
||||||
|
json!({"url": ""}),
|
||||||
|
json!({"url": "javascript:alert(1)"}),
|
||||||
|
json!({"url": "not a URL"}),
|
||||||
|
json!({"label": ""}),
|
||||||
|
json!({"label": 1}),
|
||||||
|
json!({"custom_id": "interactive"}),
|
||||||
|
] {
|
||||||
|
let mut button =
|
||||||
|
json!({"type": 2, "style": 5, "label": "Open", "url": "https://example.com"});
|
||||||
|
button.as_object_mut().unwrap().extend(change.as_object().unwrap().clone());
|
||||||
|
assert!(validate_payload(
|
||||||
|
&json!({"components": [{"type": 1, "components": [button]}]})
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
assert!(validate_payload(&json!({"components": {}})).is_err());
|
||||||
|
assert!(validate_payload(&json!({"components": [{"type": 1, "components": []}]})).is_err());
|
||||||
|
assert!(validate_payload(&json!({"components": [{"type": 1, "components": [{"type": 2, "style": 5, "emoji": {"name": "🔗"}, "url": "https://example.com"}]}]})).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires MESSAGE_JSON_TEST_DATABASE_URL pointing to an isolated MySQL database"]
|
||||||
|
async fn database_edits_convert_legacy_messages_and_preserve_payload() {
|
||||||
|
let pool =
|
||||||
|
sqlx::MySqlPool::connect(&std::env::var("MESSAGE_JSON_TEST_DATABASE_URL").unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut transaction = pool.begin().await.unwrap();
|
||||||
|
let channel = sqlx::query("INSERT INTO channels (channel) VALUES (?)")
|
||||||
|
.bind(rand::random::<u64>())
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.last_insert_id();
|
||||||
|
let uid = format!("message-json-test-{}", rand::random::<u64>());
|
||||||
|
sqlx::query("INSERT INTO reminders (uid, channel_id, `utc_time`, content, embed_description, attachment, attachment_name) VALUES (?, ?, NOW(), 'legacy', 'embed', 'bytes', 'file.txt')")
|
||||||
|
.bind(&uid).bind(channel).execute(&mut *transaction).await.unwrap();
|
||||||
|
update_field(&mut transaction, &uid, "content", &"edited").await.unwrap();
|
||||||
|
let stored: (Json<Value>, String) =
|
||||||
|
sqlx::query_as("SELECT message_json, content FROM reminders WHERE uid = ?")
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_one(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored.0["content"], "edited");
|
||||||
|
assert_eq!(stored.0["embeds"][0]["description"], "embed");
|
||||||
|
assert_eq!(stored.0["attachments"][0]["filename"], "file.txt");
|
||||||
|
assert_eq!(stored.1, "legacy");
|
||||||
|
let exact = json!({"content": "exact", "custom": [1, 2], "embeds": [{"description": "first"}, {"description": "second"}]});
|
||||||
|
sqlx::query("UPDATE reminders SET message_json = ? WHERE uid = ?")
|
||||||
|
.bind(Json(exact))
|
||||||
|
.bind(&uid)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
update_field(&mut transaction, &uid, "tts", &true).await.unwrap();
|
||||||
|
update_field(&mut transaction, &uid, "embed_description", &"updated").await.unwrap();
|
||||||
|
let stored: (Json<Value>,) =
|
||||||
|
sqlx::query_as("SELECT message_json FROM reminders WHERE uid = ?")
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_one(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored.0["tts"], true);
|
||||||
|
assert_eq!(stored.0["custom"], json!([1, 2]));
|
||||||
|
assert_eq!(stored.0["embeds"][0]["description"], "updated");
|
||||||
|
assert_eq!(stored.0["embeds"][1]["description"], "second");
|
||||||
|
sqlx::query("UPDATE reminders SET attachment = NULL WHERE uid = ?")
|
||||||
|
.bind(&uid)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
update_field(&mut transaction, &uid, "attachments", &()).await.unwrap();
|
||||||
|
let stored: (Json<Value>,) =
|
||||||
|
sqlx::query_as("SELECT message_json FROM reminders WHERE uid = ?")
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_one(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored.0["attachments"], json!([]));
|
||||||
|
transaction.rollback().await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ use poise::serenity_prelude::{
|
|||||||
model::id::{ChannelId, GuildId, UserId},
|
model::id::{ChannelId, GuildId, UserId},
|
||||||
ChannelType,
|
ChannelType,
|
||||||
};
|
};
|
||||||
use sqlx::MySqlPool;
|
use sqlx::{types::Json, MySqlPool};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
consts::{DAY, MAX_TIME, MIN_INTERVAL},
|
consts::{DAY, MAX_TIME, MIN_INTERVAL},
|
||||||
@@ -76,6 +76,12 @@ impl ReminderBuilder {
|
|||||||
if utc_time < (Utc::now() - TimeDelta::try_minutes(1).unwrap()).naive_local() {
|
if utc_time < (Utc::now() - TimeDelta::try_minutes(1).unwrap()).naive_local() {
|
||||||
Err(ReminderError::PastTime)
|
Err(ReminderError::PastTime)
|
||||||
} else {
|
} else {
|
||||||
|
let mut message_json =
|
||||||
|
serde_json::json!({"content": self.content, "tts": self.tts});
|
||||||
|
if let (Some(_), Some(name)) = (&self.attachment, &self.attachment_name) {
|
||||||
|
message_json["attachments"] =
|
||||||
|
serde_json::json!([{ "id": 0, "filename": name }]);
|
||||||
|
}
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"
|
||||||
INSERT INTO reminders (
|
INSERT INTO reminders (
|
||||||
@@ -88,13 +94,12 @@ impl ReminderBuilder {
|
|||||||
`interval_days`,
|
`interval_days`,
|
||||||
`interval_months`,
|
`interval_months`,
|
||||||
`expires`,
|
`expires`,
|
||||||
`content`,
|
`message_json`,
|
||||||
`tts`,
|
|
||||||
`attachment_name`,
|
`attachment_name`,
|
||||||
`attachment`,
|
`attachment`,
|
||||||
`set_by`
|
`set_by`
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
",
|
",
|
||||||
self.uid,
|
self.uid,
|
||||||
@@ -106,8 +111,7 @@ impl ReminderBuilder {
|
|||||||
self.interval_days,
|
self.interval_days,
|
||||||
self.interval_months,
|
self.interval_months,
|
||||||
self.expires,
|
self.expires,
|
||||||
self.content,
|
Json(message_json),
|
||||||
self.tts,
|
|
||||||
self.attachment_name,
|
self.attachment_name,
|
||||||
self.attachment,
|
self.attachment,
|
||||||
self.set_by
|
self.set_by
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ pub struct Reminder {
|
|||||||
pub interval_months: Option<u32>,
|
pub interval_months: Option<u32>,
|
||||||
pub expires: Option<NaiveDateTime>,
|
pub expires: Option<NaiveDateTime>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
pub message_json: Option<sqlx::types::Json<serde_json::Value>>,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub embed_description: String,
|
pub embed_description: String,
|
||||||
pub set_by: Option<u64>,
|
pub set_by: Option<u64>,
|
||||||
@@ -83,6 +84,7 @@ impl Reminder {
|
|||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
|
reminders.message_json,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
reminders.embed_description,
|
reminders.embed_description,
|
||||||
reminders.set_by
|
reminders.set_by
|
||||||
@@ -116,6 +118,7 @@ impl Reminder {
|
|||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
|
reminders.message_json,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
reminders.embed_description,
|
reminders.embed_description,
|
||||||
reminders.set_by
|
reminders.set_by
|
||||||
@@ -156,6 +159,7 @@ impl Reminder {
|
|||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
|
reminders.message_json,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
reminders.embed_description,
|
reminders.embed_description,
|
||||||
reminders.set_by
|
reminders.set_by
|
||||||
@@ -215,6 +219,7 @@ impl Reminder {
|
|||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
|
reminders.message_json,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
reminders.embed_description,
|
reminders.embed_description,
|
||||||
reminders.set_by
|
reminders.set_by
|
||||||
@@ -251,6 +256,7 @@ impl Reminder {
|
|||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
|
reminders.message_json,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
reminders.embed_description,
|
reminders.embed_description,
|
||||||
reminders.set_by
|
reminders.set_by
|
||||||
@@ -284,6 +290,7 @@ impl Reminder {
|
|||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
|
reminders.message_json,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
reminders.embed_description,
|
reminders.embed_description,
|
||||||
reminders.set_by
|
reminders.set_by
|
||||||
@@ -321,6 +328,13 @@ impl Reminder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn display_content(&self) -> &str {
|
pub fn display_content(&self) -> &str {
|
||||||
|
if let Some(message) = &self.message_json {
|
||||||
|
let content = message["content"].as_str().unwrap_or_default();
|
||||||
|
if !content.is_empty() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return message["embeds"][0]["description"].as_str().unwrap_or_default();
|
||||||
|
}
|
||||||
if self.content.is_empty() {
|
if self.content.is_empty() {
|
||||||
&self.embed_description
|
&self.embed_description
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use poise::serenity_prelude::{
|
|||||||
Error, Result,
|
Error, Result,
|
||||||
};
|
};
|
||||||
use regex::{Captures, Regex};
|
use regex::{Captures, Regex};
|
||||||
|
use secrecy::ExposeSecret;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
types::{
|
types::{
|
||||||
@@ -341,6 +342,7 @@ pub struct Reminder {
|
|||||||
tts: bool,
|
tts: bool,
|
||||||
pin: bool,
|
pin: bool,
|
||||||
content: String,
|
content: String,
|
||||||
|
message_json: Option<Json<serde_json::Value>>,
|
||||||
attachment: Option<Vec<u8>>,
|
attachment: Option<Vec<u8>>,
|
||||||
attachment_name: Option<String>,
|
attachment_name: Option<String>,
|
||||||
|
|
||||||
@@ -376,6 +378,7 @@ impl Reminder {
|
|||||||
reminders.`tts` AS tts,
|
reminders.`tts` AS tts,
|
||||||
reminders.`pin` AS pin,
|
reminders.`pin` AS pin,
|
||||||
reminders.`content` AS content,
|
reminders.`content` AS content,
|
||||||
|
reminders.`message_json` AS message_json,
|
||||||
reminders.`attachment` AS attachment,
|
reminders.`attachment` AS attachment,
|
||||||
reminders.`attachment_name` AS attachment_name,
|
reminders.`attachment_name` AS attachment_name,
|
||||||
|
|
||||||
@@ -585,6 +588,15 @@ impl Reminder {
|
|||||||
let _ = http.as_ref().pin_message(self.channel_id.into(), message_id.into(), None).await;
|
let _ = http.as_ref().pin_message(self.channel_id.into(), message_id.into(), None).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn files(&self) -> Vec<CreateAttachment> {
|
||||||
|
match (&self.attachment, &self.attachment_name) {
|
||||||
|
(Some(attachment), Some(name)) => {
|
||||||
|
vec![CreateAttachment::bytes(attachment.clone(), name)]
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn send(
|
pub async fn send(
|
||||||
&self,
|
&self,
|
||||||
pool: impl Executor<'_, Database = Database> + Copy,
|
pool: impl Executor<'_, Database = Database> + Copy,
|
||||||
@@ -595,6 +607,22 @@ impl Reminder {
|
|||||||
reminder: &Reminder,
|
reminder: &Reminder,
|
||||||
embed: Option<CreateEmbed>,
|
embed: Option<CreateEmbed>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
if let Some(payload) = &reminder.message_json {
|
||||||
|
let files = reminder.files();
|
||||||
|
let message = cache_http
|
||||||
|
.http()
|
||||||
|
.send_message(
|
||||||
|
ChannelId::new(reminder.thread_id.unwrap_or(reminder.channel_id)),
|
||||||
|
files,
|
||||||
|
&payload.0,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if reminder.pin {
|
||||||
|
reminder.pin_message(message.id, cache_http.http()).await;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let channel = if let Some(thread_id) = reminder.thread_id {
|
let channel = if let Some(thread_id) = reminder.thread_id {
|
||||||
ChannelId::new(thread_id).to_channel(&cache_http).await
|
ChannelId::new(thread_id).to_channel(&cache_http).await
|
||||||
} else {
|
} else {
|
||||||
@@ -650,6 +678,27 @@ impl Reminder {
|
|||||||
webhook: Webhook,
|
webhook: Webhook,
|
||||||
embed: Option<CreateEmbed>,
|
embed: Option<CreateEmbed>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
if let Some(payload) = &reminder.message_json {
|
||||||
|
let token = webhook.token.as_ref().ok_or(Error::Other("Webhook token missing"))?;
|
||||||
|
let message = cache_http
|
||||||
|
.http()
|
||||||
|
.execute_webhook_with_components(
|
||||||
|
webhook.id,
|
||||||
|
reminder.thread_id.map(ChannelId::new),
|
||||||
|
token.expose_secret(),
|
||||||
|
reminder.pin || reminder.restartable,
|
||||||
|
reminder.files(),
|
||||||
|
&payload.0,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if reminder.pin {
|
||||||
|
if let Some(message) = message {
|
||||||
|
reminder.pin_message(message.id, cache_http.http()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let mut builder = if let Some(thread_id) = reminder.thread_id {
|
let mut builder = if let Some(thread_id) = reminder.thread_id {
|
||||||
ExecuteWebhook::new()
|
ExecuteWebhook::new()
|
||||||
.content(&reminder.content)
|
.content(&reminder.content)
|
||||||
@@ -712,7 +761,11 @@ impl Reminder {
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let embed = Embed::from_id(pool, self.id).await.map(|e| e.into());
|
let embed = if self.message_json.is_none() {
|
||||||
|
Embed::from_id(pool, self.id).await.map(|e| e.into())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let result = if let (Some(webhook_id), Some(webhook_token)) =
|
let result = if let (Some(webhook_id), Some(webhook_token)) =
|
||||||
(self.webhook_id, &self.webhook_token)
|
(self.webhook_id, &self.webhook_token)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::Database;
|
|||||||
pub struct Transaction<'a>(sqlx::Transaction<'a, Database>);
|
pub struct Transaction<'a>(sqlx::Transaction<'a, Database>);
|
||||||
|
|
||||||
impl Transaction<'_> {
|
impl Transaction<'_> {
|
||||||
pub fn executor(&mut self) -> impl sqlx::Executor<'_, Database = Database> {
|
pub fn executor(&mut self) -> &mut sqlx::MySqlConnection {
|
||||||
&mut *(self.0)
|
&mut *(self.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
use std::{env, io::Cursor, sync::Arc};
|
|
||||||
|
|
||||||
use image::{ImageFormat, ImageReader, Limits};
|
|
||||||
use log::{info, warn};
|
|
||||||
use nsfw::model::{Classification, Metric};
|
|
||||||
use s3::{creds::Credentials, Bucket, Region};
|
|
||||||
use tokio::sync::Semaphore;
|
|
||||||
|
|
||||||
pub const MAX_UPLOAD_BYTES: u64 = 8 * 1024 * 1024;
|
|
||||||
const MAX_IMAGE_PIXELS: u64 = 16_000_000;
|
|
||||||
const NSFW_THRESHOLD: f32 = 0.5;
|
|
||||||
const MODEL_BYTES: &[u8] =
|
|
||||||
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/nsfw/model.onnx"));
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod fixture_tests;
|
|
||||||
|
|
||||||
// An empty configuration keeps the dashboard's existing URL-only behavior.
|
|
||||||
pub struct ImageUploads(pub Option<Arc<ImageUploadService>>);
|
|
||||||
|
|
||||||
pub struct ImageUploadService {
|
|
||||||
pub bucket: Box<Bucket>,
|
|
||||||
pub public_url: String,
|
|
||||||
pub model: Option<nsfw::Model>,
|
|
||||||
pub slots: Arc<Semaphore>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImageUploads {
|
|
||||||
pub fn from_env() -> Self {
|
|
||||||
let names =
|
|
||||||
["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", "S3_PUBLIC_URL"];
|
|
||||||
let values = names.map(|name| env::var(name).unwrap_or_default());
|
|
||||||
Self::from_settings(&values)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_settings(values: &[String; 5]) -> Self {
|
|
||||||
if values.iter().all(|value| value.trim().is_empty()) {
|
|
||||||
return Self(None);
|
|
||||||
}
|
|
||||||
if values.iter().any(|value| value.trim().is_empty()) {
|
|
||||||
warn!("Image uploads disabled: all S3 upload settings must be supplied");
|
|
||||||
return Self(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let check_nsfw = nsfw_check_enabled(env::var("NSFW_CHECK_ENABLED").ok().as_deref());
|
|
||||||
match ImageUploadService::new(values, check_nsfw) {
|
|
||||||
Ok(service) => {
|
|
||||||
info!("Dashboard image uploads enabled");
|
|
||||||
Self(Some(Arc::new(service)))
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
warn!("Image uploads disabled: {}", error);
|
|
||||||
Self(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImageUploadService {
|
|
||||||
// Cleanup uses the same storage configuration without loading the moderation model.
|
|
||||||
pub(crate) fn for_cleanup() -> Result<Option<Self>, Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let values =
|
|
||||||
["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", "S3_PUBLIC_URL"]
|
|
||||||
.map(|name| env::var(name).unwrap_or_default());
|
|
||||||
if values.iter().all(|value| value.trim().is_empty()) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
if values.iter().any(|value| value.trim().is_empty()) {
|
|
||||||
return Err("Image cleanup requires all S3 upload settings".into());
|
|
||||||
}
|
|
||||||
Self::new(&values, false).map(Some).map_err(|error| error.to_string().into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn new(values: &[String; 5], check_nsfw: bool) -> Result<Self, Box<dyn std::error::Error>> {
|
|
||||||
let url = reqwest::Url::parse(&values[4])?;
|
|
||||||
let public_url = url.as_str().trim_end_matches('/').to_string();
|
|
||||||
if !matches!(url.scheme(), "https" | "http")
|
|
||||||
|| url.host_str().is_none()
|
|
||||||
|| !url.username().is_empty()
|
|
||||||
|| url.password().is_some()
|
|
||||||
|| url.query().is_some()
|
|
||||||
|| url.fragment().is_some()
|
|
||||||
|| public_url.len() > 400
|
|
||||||
{
|
|
||||||
return Err("S3_PUBLIC_URL must be an HTTP(S) base URL of at most 400 bytes".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let endpoint = env::var("S3_ENDPOINT").ok().filter(|value| !value.trim().is_empty());
|
|
||||||
let region = match &endpoint {
|
|
||||||
Some(endpoint) => {
|
|
||||||
Region::Custom { region: values[1].clone(), endpoint: endpoint.clone() }
|
|
||||||
}
|
|
||||||
None => values[1].parse()?,
|
|
||||||
};
|
|
||||||
|
|
||||||
let credentials = Credentials::new(Some(&values[2]), Some(&values[3]), None, None, None)?;
|
|
||||||
let mut bucket = Bucket::new(&values[0], region, credentials)?;
|
|
||||||
if endpoint.is_some() {
|
|
||||||
bucket = bucket.with_path_style();
|
|
||||||
}
|
|
||||||
|
|
||||||
bucket.set_request_timeout(Some(std::time::Duration::from_secs(30)));
|
|
||||||
let model = if check_nsfw {
|
|
||||||
Some(nsfw::create_model(MODEL_BYTES)?)
|
|
||||||
} else {
|
|
||||||
info!("NSFW checks disabled for dashboard image uploads");
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self { bucket, public_url, model, slots: Arc::new(Semaphore::new(2)) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nsfw_check_enabled(value: Option<&str>) -> bool {
|
|
||||||
match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
|
|
||||||
None | Some("" | "true" | "1" | "on") => true,
|
|
||||||
Some("false" | "0" | "off") => false,
|
|
||||||
Some(_) => {
|
|
||||||
warn!("Unrecognized NSFW_CHECK_ENABLED value; keeping NSFW checks enabled");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
|
||||||
pub enum ImageError {
|
|
||||||
InvalidImage,
|
|
||||||
Nsfw,
|
|
||||||
ModerationFailed,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn probable_nsfw(predictions: &[Classification]) -> Result<bool, ImageError> {
|
|
||||||
if predictions.len() != 5
|
|
||||||
|| predictions.iter().any(|prediction| {
|
|
||||||
!prediction.score.is_finite() || !(0.0..=1.0).contains(&prediction.score)
|
|
||||||
})
|
|
||||||
{
|
|
||||||
return Err(ImageError::ModerationFailed);
|
|
||||||
}
|
|
||||||
let score: f32 = predictions
|
|
||||||
.iter()
|
|
||||||
.filter(|prediction| {
|
|
||||||
matches!(prediction.metric, Metric::Porn | Metric::Hentai | Metric::Sexy)
|
|
||||||
})
|
|
||||||
.map(|prediction| prediction.score)
|
|
||||||
.sum();
|
|
||||||
Ok(score >= NSFW_THRESHOLD)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish only the decoded pixels, which are moderated when NSFW checks are enabled.
|
|
||||||
// Static PNGs strip metadata and prevent unchecked animation frames from being published.
|
|
||||||
fn decode_image(bytes: &[u8]) -> Result<image::RgbaImage, ImageError> {
|
|
||||||
let mut reader = ImageReader::new(Cursor::new(bytes))
|
|
||||||
.with_guessed_format()
|
|
||||||
.map_err(|_| ImageError::InvalidImage)?;
|
|
||||||
if !matches!(
|
|
||||||
reader.format(),
|
|
||||||
Some(ImageFormat::Jpeg | ImageFormat::Png | ImageFormat::Gif | ImageFormat::WebP)
|
|
||||||
) {
|
|
||||||
return Err(ImageError::InvalidImage);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut limits = Limits::default();
|
|
||||||
limits.max_image_width = Some(8192);
|
|
||||||
limits.max_image_height = Some(8192);
|
|
||||||
limits.max_alloc = Some(128 * 1024 * 1024);
|
|
||||||
reader.limits(limits);
|
|
||||||
|
|
||||||
let decoder = reader.into_decoder().map_err(|_| ImageError::InvalidImage)?;
|
|
||||||
|
|
||||||
use image::ImageDecoder;
|
|
||||||
let (width, height) = decoder.dimensions();
|
|
||||||
if width == 0 || height == 0 || u64::from(width) * u64::from(height) > MAX_IMAGE_PIXELS {
|
|
||||||
return Err(ImageError::InvalidImage);
|
|
||||||
}
|
|
||||||
|
|
||||||
image::DynamicImage::from_decoder(decoder)
|
|
||||||
.map(|image| image.to_rgba8())
|
|
||||||
.map_err(|_| ImageError::InvalidImage)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prepare_image(model: Option<&nsfw::Model>, bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
|
|
||||||
let image = decode_image(bytes)?;
|
|
||||||
// Flatten transparency so the classifier sees exactly the published image.
|
|
||||||
let mut visible = image::RgbImage::new(image.width(), image.height());
|
|
||||||
|
|
||||||
for (target, source) in visible.pixels_mut().zip(image.pixels()) {
|
|
||||||
for channel in 0..3 {
|
|
||||||
let alpha = u32::from(source[3]);
|
|
||||||
target[channel] =
|
|
||||||
((u32::from(source[channel]) * alpha + 255 * (255 - alpha)) / 255) as u8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let visible = image::DynamicImage::ImageRgb8(visible);
|
|
||||||
if let Some(model) = model {
|
|
||||||
let predictions = nsfw::examine(model, &visible.to_rgba8()).map_err(|error| {
|
|
||||||
warn!("Image moderation failed: {}", error);
|
|
||||||
ImageError::ModerationFailed
|
|
||||||
})?;
|
|
||||||
if probable_nsfw(&predictions)? {
|
|
||||||
return Err(ImageError::Nsfw);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut output = Cursor::new(Vec::new());
|
|
||||||
visible.write_to(&mut output, ImageFormat::Png).map_err(|_| ImageError::InvalidImage)?;
|
|
||||||
Ok(output.into_inner())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn predictions(scores: [f32; 5]) -> Vec<Classification> {
|
|
||||||
[Metric::Drawings, Metric::Hentai, Metric::Neutral, Metric::Porn, Metric::Sexy]
|
|
||||||
.into_iter()
|
|
||||||
.zip(scores)
|
|
||||||
.map(|(metric, score)| Classification { metric, score })
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn combines_unsafe_categories_and_rejects_invalid_scores() {
|
|
||||||
assert_eq!(probable_nsfw(&predictions([0.1, 0.2, 0.4, 0.2, 0.1])), Ok(true));
|
|
||||||
assert_eq!(probable_nsfw(&predictions([0.1, 0.1, 0.7, 0.05, 0.05])), Ok(false));
|
|
||||||
assert_eq!(
|
|
||||||
probable_nsfw(&predictions([0.0, 0.0, f32::NAN, 0.0, 0.0])),
|
|
||||||
Err(ImageError::ModerationFailed)
|
|
||||||
);
|
|
||||||
assert_eq!(probable_nsfw(&[]), Err(ImageError::ModerationFailed));
|
|
||||||
assert_eq!(
|
|
||||||
probable_nsfw(&predictions([0.0, -0.5, 1.0, 0.0, 0.0])),
|
|
||||||
Err(ImageError::ModerationFailed)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn absent_or_partial_configuration_disables_uploads() {
|
|
||||||
let mut settings = std::array::from_fn(|_| String::new());
|
|
||||||
assert!(ImageUploads::from_settings(&settings).0.is_none());
|
|
||||||
settings[0] = "images".into();
|
|
||||||
assert!(ImageUploads::from_settings(&settings).0.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn disabling_nsfw_checks_skips_model_initialization() {
|
|
||||||
let settings = ["images", "eu-west-2", "test", "test", "https://images.example.com"]
|
|
||||||
.map(str::to_string);
|
|
||||||
assert!(ImageUploadService::new(&settings, false).unwrap().model.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn disabled_moderation_allows_flagged_images_but_still_validates_files() {
|
|
||||||
let model = nsfw::create_model(
|
|
||||||
&include_bytes!("../../tests/fixtures/image-upload-unsafe.onnx")[..],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let mut bytes = Cursor::new(Vec::new());
|
|
||||||
image::DynamicImage::new_rgb8(2, 2).write_to(&mut bytes, ImageFormat::Png).unwrap();
|
|
||||||
assert_eq!(prepare_image(Some(&model), bytes.get_ref()), Err(ImageError::Nsfw));
|
|
||||||
let published = prepare_image(None, bytes.get_ref()).unwrap();
|
|
||||||
assert_eq!(image::load_from_memory(&published).unwrap().width(), 2);
|
|
||||||
assert_eq!(prepare_image(None, b"not an image"), Err(ImageError::InvalidImage));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bundled_model_loads_and_classifies_an_image() {
|
|
||||||
let model = nsfw::create_model(MODEL_BYTES).unwrap();
|
|
||||||
let image = image::RgbaImage::from_pixel(224, 224, image::Rgba([255, 255, 255, 255]));
|
|
||||||
let predictions = nsfw::examine(&model, &image).unwrap();
|
|
||||||
assert!(probable_nsfw(&predictions).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_non_images_and_truncated_images() {
|
|
||||||
assert_eq!(decode_image(b"<svg></svg>"), Err(ImageError::InvalidImage));
|
|
||||||
assert_eq!(decode_image(b"\x89PNG\r\n\x1a\n"), Err(ImageError::InvalidImage));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decodes_real_images_without_trusting_the_filename() {
|
|
||||||
let image = image::DynamicImage::new_rgb8(2, 2);
|
|
||||||
for format in [ImageFormat::Png, ImageFormat::Jpeg, ImageFormat::Gif, ImageFormat::WebP] {
|
|
||||||
let mut bytes = Cursor::new(Vec::new());
|
|
||||||
image.write_to(&mut bytes, format).unwrap();
|
|
||||||
assert_eq!(decode_image(bytes.get_ref()).unwrap().dimensions(), (2, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_excessive_image_dimensions() {
|
|
||||||
let image = image::DynamicImage::new_rgb8(8193, 1);
|
|
||||||
let mut bytes = Cursor::new(Vec::new());
|
|
||||||
image.write_to(&mut bytes, ImageFormat::Png).unwrap();
|
|
||||||
assert_eq!(decode_image(bytes.get_ref()), Err(ImageError::InvalidImage));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
use std::{
|
|
||||||
collections::BTreeSet,
|
|
||||||
fs,
|
|
||||||
io::Cursor,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
};
|
|
||||||
|
|
||||||
use image::{ImageFormat, RgbaImage};
|
|
||||||
|
|
||||||
use super::{prepare_image, ImageError, MODEL_BYTES};
|
|
||||||
|
|
||||||
const NSFW_IMAGES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/nsfw");
|
|
||||||
const NON_NSFW_IMAGES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/non_nsfw");
|
|
||||||
|
|
||||||
fn fixture_pairs(directory: &Path) -> Vec<(PathBuf, PathBuf)> {
|
|
||||||
let mut indices = BTreeSet::new();
|
|
||||||
for entry in fs::read_dir(directory).expect("Could not read image fixture directory") {
|
|
||||||
let entry = entry.expect("Could not read image fixture entry");
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|extension| extension.to_str()) != Some("png") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let name = path.file_name().unwrap().to_str().expect("Fixture name is not valid UTF-8");
|
|
||||||
let index = name
|
|
||||||
.strip_prefix("image_")
|
|
||||||
.and_then(|name| name.strip_suffix("a.png").or_else(|| name.strip_suffix("b.png")))
|
|
||||||
.filter(|index| index.parse::<u32>().is_ok_and(|index| index > 0))
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
panic!("Invalid fixture name {name}; expected image_1a.png/image_1b.png")
|
|
||||||
});
|
|
||||||
indices.insert(index.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
!indices.is_empty(),
|
|
||||||
"No paired fixtures in {}. Add images with scripts/split_nsfw_test_image.py.",
|
|
||||||
directory.display()
|
|
||||||
);
|
|
||||||
indices
|
|
||||||
.into_iter()
|
|
||||||
.map(|index| {
|
|
||||||
let first = directory.join(format!("image_{index}a.png"));
|
|
||||||
let second = directory.join(format!("image_{index}b.png"));
|
|
||||||
assert!(
|
|
||||||
first.is_file() && second.is_file(),
|
|
||||||
"Both fixture parts must exist: {} and {}",
|
|
||||||
first.display(),
|
|
||||||
second.display()
|
|
||||||
);
|
|
||||||
(first, second)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn recombine_parts(first: &RgbaImage, second: &RgbaImage) -> Result<RgbaImage, &'static str> {
|
|
||||||
if first.dimensions() != second.dimensions() {
|
|
||||||
return Err("Fixture parts must have identical dimensions");
|
|
||||||
}
|
|
||||||
let pixels = first.as_raw().iter().zip(second.as_raw()).map(|(a, b)| a ^ b).collect();
|
|
||||||
RgbaImage::from_raw(first.width(), first.height(), pixels).ok_or("Invalid fixture pixel data")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_directory(directory: &str, expected_nsfw: bool) {
|
|
||||||
let pairs = fixture_pairs(Path::new(directory));
|
|
||||||
let model = nsfw::create_model(MODEL_BYTES).expect("Could not load bundled NSFW model");
|
|
||||||
for (first, second) in pairs {
|
|
||||||
let a = image::open(&first).expect("Could not decode first fixture part").to_rgba8();
|
|
||||||
let b = image::open(&second).expect("Could not decode second fixture part").to_rgba8();
|
|
||||||
let reconstructed = recombine_parts(&a, &b).expect("Could not recombine fixture parts");
|
|
||||||
let mut bytes = Cursor::new(Vec::new());
|
|
||||||
reconstructed
|
|
||||||
.write_to(&mut bytes, ImageFormat::Png)
|
|
||||||
.expect("Could not encode reconstructed fixture");
|
|
||||||
|
|
||||||
// Use the production path, including flattening transparency and the same
|
|
||||||
// combined category threshold. Never write the reconstructed image to disk.
|
|
||||||
let result = prepare_image(Some(&model), bytes.get_ref());
|
|
||||||
if expected_nsfw {
|
|
||||||
assert_eq!(
|
|
||||||
result.as_ref().err(),
|
|
||||||
Some(&ImageError::Nsfw),
|
|
||||||
"Expected {} to be blocked as NSFW, got {:?}",
|
|
||||||
first.display(),
|
|
||||||
result.as_ref().err()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
assert!(
|
|
||||||
result.is_ok(),
|
|
||||||
"Expected {} to be accepted, got {:?}",
|
|
||||||
first.display(),
|
|
||||||
result.as_ref().err()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// The toggle must allow either category, while retaining image validation.
|
|
||||||
assert!(
|
|
||||||
prepare_image(None, bytes.get_ref()).is_ok(),
|
|
||||||
"Expected {} to be accepted with moderation disabled",
|
|
||||||
first.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[ignore = "Add local split fixtures to tests/fixtures/nsfw, then run with --ignored"]
|
|
||||||
fn nsfw_directory_images_are_blocked() {
|
|
||||||
check_directory(NSFW_IMAGES, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[ignore = "Add local split fixtures to tests/fixtures/non_nsfw, then run with --ignored"]
|
|
||||||
fn non_nsfw_directory_images_are_allowed() {
|
|
||||||
check_directory(NON_NSFW_IMAGES, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn recombining_parts_restores_every_pixel_and_alpha_channel() {
|
|
||||||
let original = [0, 17, 254, 255, 45, 99, 128, 0];
|
|
||||||
let random = vec![18, 201, 50, 34, 166, 76, 11, 220];
|
|
||||||
let masked = original.iter().zip(&random).map(|(pixel, mask)| pixel ^ mask).collect();
|
|
||||||
let first = RgbaImage::from_raw(2, 1, random).unwrap();
|
|
||||||
let second = RgbaImage::from_raw(2, 1, masked).unwrap();
|
|
||||||
assert_eq!(recombine_parts(&first, &second).unwrap().as_raw(), &original);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_parts_with_different_dimensions() {
|
|
||||||
assert_eq!(
|
|
||||||
recombine_parts(&RgbaImage::new(1, 1), &RgbaImage::new(2, 1)),
|
|
||||||
Err("Fixture parts must have identical dimensions")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -57,16 +57,34 @@ macro_rules! check_url_opt {
|
|||||||
macro_rules! update_field {
|
macro_rules! update_field {
|
||||||
($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => {
|
($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => {
|
||||||
if let Some(value) = &$reminder.$field {
|
if let Some(value) = &$reminder.$field {
|
||||||
match sqlx::query(concat!(
|
// The complete payload takes precedence over legacy message controls.
|
||||||
"UPDATE reminders SET `",
|
let result = if crate::message::is_message_field(stringify!($field))
|
||||||
stringify!($field),
|
&& $reminder.message_json.is_some()
|
||||||
"` = ? WHERE uid = ?"
|
|
||||||
))
|
|
||||||
.bind(value)
|
|
||||||
.bind(&$reminder.uid)
|
|
||||||
.execute($pool)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
|
Ok(())
|
||||||
|
} else if crate::message::is_message_field(stringify!($field)) {
|
||||||
|
crate::message::update_field($pool, &$reminder.uid, stringify!($field), value).await
|
||||||
|
} else {
|
||||||
|
let result = sqlx::query(concat!(
|
||||||
|
"UPDATE reminders SET `",
|
||||||
|
stringify!($field),
|
||||||
|
"` = ? WHERE uid = ?"
|
||||||
|
))
|
||||||
|
.bind(value)
|
||||||
|
.bind(&$reminder.uid)
|
||||||
|
.execute($pool)
|
||||||
|
.await
|
||||||
|
.map(|_| ());
|
||||||
|
if result.is_ok()
|
||||||
|
&& $reminder.message_json.is_none()
|
||||||
|
&& ["attachment", "attachment_name"].contains(&stringify!($field))
|
||||||
|
{
|
||||||
|
crate::message::update_field($pool, &$reminder.uid, "attachments", &()).await
|
||||||
|
} else {
|
||||||
|
result
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match result {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -125,8 +124,7 @@ pub async fn initialize(
|
|||||||
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);
|
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 +214,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
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ pub async fn get_reminders(
|
|||||||
reminders.attachment_name,
|
reminders.attachment_name,
|
||||||
reminders.avatar,
|
reminders.avatar,
|
||||||
channels.channel,
|
channels.channel,
|
||||||
|
reminders.message_json,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
reminders.embed_author,
|
reminders.embed_author,
|
||||||
reminders.embed_author_url,
|
reminders.embed_author_url,
|
||||||
@@ -121,7 +122,7 @@ pub async fn get_reminders(
|
|||||||
query
|
query
|
||||||
.fetch_all(pool.inner())
|
.fetch_all(pool.inner())
|
||||||
.await
|
.await
|
||||||
.map(|reminders| Ok(json!(reminders)))
|
.map(|reminders| Ok(crate::message::response(reminders)))
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
warn!("Failed to complete SQL query: {:?}", e);
|
warn!("Failed to complete SQL query: {:?}", e);
|
||||||
|
|
||||||
@@ -152,6 +153,11 @@ pub async fn edit_reminder(
|
|||||||
let user_id =
|
let user_id =
|
||||||
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
|
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
|
||||||
|
|
||||||
|
if let Some(message) = &reminder.message_json {
|
||||||
|
crate::message::validate_payload(message).map_err(|error| json!({"error": error}))?;
|
||||||
|
update_field!(transaction.executor(), error, reminder.[message_json]);
|
||||||
|
}
|
||||||
|
|
||||||
if reminder.message_ok() {
|
if reminder.message_ok() {
|
||||||
update_field!(transaction.executor(), error, reminder.[
|
update_field!(transaction.executor(), error, reminder.[
|
||||||
content,
|
content,
|
||||||
@@ -349,6 +355,7 @@ pub async fn edit_reminder(
|
|||||||
reminders.attachment_name,
|
reminders.attachment_name,
|
||||||
reminders.avatar,
|
reminders.avatar,
|
||||||
channels.channel,
|
channels.channel,
|
||||||
|
reminders.message_json,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
reminders.embed_author,
|
reminders.embed_author,
|
||||||
reminders.embed_author_url,
|
reminders.embed_author_url,
|
||||||
@@ -379,7 +386,9 @@ pub async fn edit_reminder(
|
|||||||
.fetch_one(pool.inner())
|
.fetch_one(pool.inner())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})),
|
Ok(reminder) => {
|
||||||
|
Ok(json!({"reminder": crate::message::response(reminder), "errors": error}))
|
||||||
|
}
|
||||||
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Error exiting `edit_reminder': {:?}", e);
|
warn!("Error exiting `edit_reminder': {:?}", e);
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
use log::warn;
|
|
||||||
use rand::{rngs::OsRng, RngCore};
|
|
||||||
use rocket::{
|
|
||||||
data::{Data, ToByteUnit},
|
|
||||||
http::{CookieJar, Status},
|
|
||||||
post,
|
|
||||||
request::{FromRequest, Outcome},
|
|
||||||
serde::json::{json, Json, Value},
|
|
||||||
Request, State,
|
|
||||||
};
|
|
||||||
use sqlx::{MySql, Pool};
|
|
||||||
|
|
||||||
use crate::web::image_uploads::{prepare_image, ImageError, ImageUploads, MAX_UPLOAD_BYTES};
|
|
||||||
|
|
||||||
pub fn mount(
|
|
||||||
rocket: rocket::Rocket<rocket::Build>,
|
|
||||||
uploads: ImageUploads,
|
|
||||||
) -> rocket::Rocket<rocket::Build> {
|
|
||||||
let enabled = uploads.0.is_some();
|
|
||||||
let rocket = rocket.manage(uploads);
|
|
||||||
if enabled {
|
|
||||||
rocket.mount("/dashboard", rocket::routes![upload_image])
|
|
||||||
} else {
|
|
||||||
rocket
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Uploader(u64);
|
|
||||||
|
|
||||||
#[rocket::async_trait]
|
|
||||||
impl<'r> FromRequest<'r> for Uploader {
|
|
||||||
type Error = ();
|
|
||||||
|
|
||||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
|
||||||
match uploader_id(request.cookies()) {
|
|
||||||
Some(id) => Outcome::Success(Self(id)),
|
|
||||||
None => Outcome::Error((Status::Unauthorized, ())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uploader_id(cookies: &CookieJar<'_>) -> Option<u64> {
|
|
||||||
cookies.get_private("userid")?.value().parse().ok().filter(|id| *id != 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
type UploadResult = Result<Json<Value>, (Status, Json<Value>)>;
|
|
||||||
|
|
||||||
fn error(status: Status, message: &str) -> (Status, Json<Value>) {
|
|
||||||
(status, Json(json!({ "error": message })))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Requiring a non-form content type prevents cross-site HTML form submissions.
|
|
||||||
// Authorization is a request guard, so it runs before the body is read.
|
|
||||||
#[post("/api/images", format = "application/octet-stream", data = "<data>")]
|
|
||||||
pub async fn upload_image(
|
|
||||||
uploader: Uploader,
|
|
||||||
uploads: &State<ImageUploads>,
|
|
||||||
pool: &State<Pool<MySql>>,
|
|
||||||
data: Data<'_>,
|
|
||||||
) -> UploadResult {
|
|
||||||
let service =
|
|
||||||
uploads.0.as_ref().ok_or_else(|| error(Status::NotFound, "Image uploads disabled"))?;
|
|
||||||
let _permit = service
|
|
||||||
.slots
|
|
||||||
.clone()
|
|
||||||
.try_acquire_owned()
|
|
||||||
.map_err(|_| error(Status::TooManyRequests, "Image uploads busy. Please try again."))?;
|
|
||||||
let bytes = data
|
|
||||||
.open(MAX_UPLOAD_BYTES.bytes())
|
|
||||||
.into_bytes()
|
|
||||||
.await
|
|
||||||
.map_err(|_| error(Status::BadRequest, "Could not read image"))?;
|
|
||||||
if !bytes.is_complete() {
|
|
||||||
return Err(error(Status::PayloadTooLarge, "Images must be no larger than 8 MiB"));
|
|
||||||
}
|
|
||||||
let service_for_moderation = service.clone();
|
|
||||||
let moderated = tokio::task::spawn_blocking(move || {
|
|
||||||
prepare_image(service_for_moderation.model.as_ref(), &bytes.into_inner())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|err| {
|
|
||||||
warn!("Image moderation task failed: {}", err);
|
|
||||||
error(Status::ServiceUnavailable, "Image moderation unavailable. Please try again.")
|
|
||||||
})?;
|
|
||||||
let image = moderated.map_err(|err| match err {
|
|
||||||
ImageError::InvalidImage => error(
|
|
||||||
Status::UnprocessableEntity,
|
|
||||||
"Please upload a valid PNG, JPEG, GIF or WebP image (at most 16 million pixels)",
|
|
||||||
),
|
|
||||||
ImageError::Nsfw => error(
|
|
||||||
Status::UnavailableForLegalReasons,
|
|
||||||
"This image was flagged as probably NSFW and cannot be uploaded.",
|
|
||||||
),
|
|
||||||
ImageError::ModerationFailed => {
|
|
||||||
error(Status::ServiceUnavailable, "Image moderation unavailable. Please try again.")
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut random = [0u8; 32];
|
|
||||||
OsRng.fill_bytes(&mut random);
|
|
||||||
let key = format!(
|
|
||||||
"uploads/{}.png",
|
|
||||||
random.iter().map(|byte| format!("{:02x}", byte)).collect::<String>()
|
|
||||||
);
|
|
||||||
let url = format!("{}/{}", service.public_url, key);
|
|
||||||
let response =
|
|
||||||
service.bucket.put_object_with_content_type(&key, &image, "image/png").await.map_err(
|
|
||||||
|err| {
|
|
||||||
warn!("Could not upload image to S3: {}", err);
|
|
||||||
error(Status::BadGateway, "Could not store image. Please try again.")
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
if !(200..300).contains(&response.status_code()) {
|
|
||||||
warn!("S3 image upload returned {}", response.status_code());
|
|
||||||
return Err(error(Status::BadGateway, "Could not store image. Please try again."));
|
|
||||||
}
|
|
||||||
if let Err(err) = sqlx::query("INSERT INTO image_uploads (uploader, url) VALUES (?, ?)")
|
|
||||||
.bind(uploader.0)
|
|
||||||
.bind(&url)
|
|
||||||
.execute(pool.inner())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!("Could not record image upload: {}", err);
|
|
||||||
match service.bucket.delete_object(&key).await {
|
|
||||||
Ok(response) if (200..300).contains(&response.status_code()) => {}
|
|
||||||
_ => warn!("Could not clean up untracked image object {}", key),
|
|
||||||
}
|
|
||||||
return Err(error(
|
|
||||||
Status::InternalServerError,
|
|
||||||
"Could not record image upload. Please try again.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(json!({ "url": url })))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::{io::Cursor, sync::Arc};
|
|
||||||
|
|
||||||
use rocket::{http::ContentType, local::asynchronous::Client};
|
|
||||||
use s3::{creds::Credentials, Bucket, Region};
|
|
||||||
use tokio::sync::Semaphore;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::web::image_uploads::ImageUploadService;
|
|
||||||
|
|
||||||
fn service(unsafe_image: bool, endpoint: &str) -> ImageUploads {
|
|
||||||
let model: &[u8] = if unsafe_image {
|
|
||||||
include_bytes!("../../../../../tests/fixtures/image-upload-unsafe.onnx")
|
|
||||||
} else {
|
|
||||||
include_bytes!("../../../../../tests/fixtures/image-upload-safe.onnx")
|
|
||||||
};
|
|
||||||
let bucket = Bucket::new(
|
|
||||||
"images",
|
|
||||||
Region::Custom { region: "eu-west-2".into(), endpoint: endpoint.into() },
|
|
||||||
Credentials::new(Some("test"), Some("test"), None, None, None).unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.with_path_style();
|
|
||||||
ImageUploads(Some(Arc::new(ImageUploadService {
|
|
||||||
bucket,
|
|
||||||
public_url: "https://images.example.com".into(),
|
|
||||||
model: Some(nsfw::create_model(model).unwrap()),
|
|
||||||
slots: Arc::new(Semaphore::new(2)),
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn image_bytes() -> Vec<u8> {
|
|
||||||
let mut bytes = Cursor::new(Vec::new());
|
|
||||||
image::DynamicImage::new_rgb8(2, 2).write_to(&mut bytes, image::ImageFormat::Png).unwrap();
|
|
||||||
bytes.into_inner()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn client(uploads: ImageUploads) -> Client {
|
|
||||||
let pool = sqlx::mysql::MySqlPoolOptions::new()
|
|
||||||
.connect_lazy("mysql://unused:unused@127.0.0.1:1/unused")
|
|
||||||
.unwrap();
|
|
||||||
Client::tracked(mount(rocket::build().manage(pool), uploads)).await.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::async_test]
|
|
||||||
async fn disabled_upload_route_is_not_available() {
|
|
||||||
let client = client(ImageUploads(None)).await;
|
|
||||||
assert_eq!(
|
|
||||||
client
|
|
||||||
.post("/dashboard/api/images")
|
|
||||||
.header(ContentType::Binary)
|
|
||||||
.body(image_bytes())
|
|
||||||
.dispatch()
|
|
||||||
.await
|
|
||||||
.status(),
|
|
||||||
Status::NotFound
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::async_test]
|
|
||||||
async fn upload_requires_an_authenticated_private_cookie() {
|
|
||||||
let client = client(service(false, "http://127.0.0.1:1")).await;
|
|
||||||
for cookie in [None, Some("not-an-id"), Some("0")] {
|
|
||||||
let mut request = client
|
|
||||||
.post("/dashboard/api/images")
|
|
||||||
.header(ContentType::Binary)
|
|
||||||
.body(image_bytes());
|
|
||||||
if let Some(cookie) = cookie {
|
|
||||||
request = request.private_cookie(("userid", cookie));
|
|
||||||
}
|
|
||||||
assert_eq!(request.dispatch().await.status(), Status::Unauthorized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::async_test]
|
|
||||||
async fn unsafe_images_return_451_without_contacting_storage_or_database() {
|
|
||||||
let client = client(service(true, "http://127.0.0.1:1")).await;
|
|
||||||
let response = client
|
|
||||||
.post("/dashboard/api/images")
|
|
||||||
.header(ContentType::Binary)
|
|
||||||
.private_cookie(("userid", "123456789012345678"))
|
|
||||||
.body(image_bytes())
|
|
||||||
.dispatch()
|
|
||||||
.await;
|
|
||||||
assert_eq!(response.status(), Status::UnavailableForLegalReasons);
|
|
||||||
assert!(response.into_json::<Value>().await.unwrap()["error"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap()
|
|
||||||
.contains("NSFW"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::async_test]
|
|
||||||
async fn rejects_invalid_oversized_and_form_uploads() {
|
|
||||||
let client = client(service(false, "http://127.0.0.1:1")).await;
|
|
||||||
for (body, status) in [
|
|
||||||
(b"this is not an image".to_vec(), Status::UnprocessableEntity),
|
|
||||||
(vec![0; MAX_UPLOAD_BYTES as usize + 1], Status::PayloadTooLarge),
|
|
||||||
] {
|
|
||||||
let response = client
|
|
||||||
.post("/dashboard/api/images")
|
|
||||||
.header(ContentType::Binary)
|
|
||||||
.private_cookie(("userid", "123456789012345678"))
|
|
||||||
.body(body)
|
|
||||||
.dispatch()
|
|
||||||
.await;
|
|
||||||
assert_eq!(response.status(), status);
|
|
||||||
}
|
|
||||||
let response = client
|
|
||||||
.post("/dashboard/api/images")
|
|
||||||
.header(ContentType::Form)
|
|
||||||
.private_cookie(("userid", "123456789012345678"))
|
|
||||||
.body("file=anything")
|
|
||||||
.dispatch()
|
|
||||||
.await;
|
|
||||||
assert_eq!(response.status(), Status::NotFound);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[sqlx::test]
|
|
||||||
async fn stores_moderated_image_and_tracks_uploader(pool: Pool<MySql>) {
|
|
||||||
use tokio::{
|
|
||||||
io::{AsyncReadExt, AsyncWriteExt},
|
|
||||||
net::TcpListener,
|
|
||||||
};
|
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let endpoint = format!("http://{}", listener.local_addr().unwrap());
|
|
||||||
let storage = tokio::spawn(async move {
|
|
||||||
let (mut socket, _) = listener.accept().await.unwrap();
|
|
||||||
let mut received = Vec::new();
|
|
||||||
let mut buffer = [0; 4096];
|
|
||||||
let (headers, body) = loop {
|
|
||||||
let count = socket.read(&mut buffer).await.unwrap();
|
|
||||||
assert!(count > 0);
|
|
||||||
received.extend_from_slice(&buffer[..count]);
|
|
||||||
if let Some(end) = received.windows(4).position(|window| window == b"\r\n\r\n") {
|
|
||||||
let headers = String::from_utf8(received[..end].to_vec()).unwrap();
|
|
||||||
let length: usize = headers
|
|
||||||
.lines()
|
|
||||||
.find_map(|line| {
|
|
||||||
line.to_lowercase()
|
|
||||||
.strip_prefix("content-length:")
|
|
||||||
.map(|length| length.trim().parse().unwrap())
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
if received.len() >= end + 4 + length {
|
|
||||||
break (headers, received[end + 4..end + 4 + length].to_vec());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket
|
|
||||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
(headers, body)
|
|
||||||
});
|
|
||||||
let client =
|
|
||||||
Client::tracked(mount(rocket::build().manage(pool.clone()), service(false, &endpoint)))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let response = client
|
|
||||||
.post("/dashboard/api/images")
|
|
||||||
.header(ContentType::Binary)
|
|
||||||
.private_cookie(("userid", "123456789012345678"))
|
|
||||||
.body(image_bytes())
|
|
||||||
.dispatch()
|
|
||||||
.await;
|
|
||||||
assert_eq!(response.status(), Status::Ok);
|
|
||||||
let response = response.into_json::<Value>().await.unwrap();
|
|
||||||
let url = response["url"].as_str().unwrap();
|
|
||||||
let row: (u64, String) = sqlx::query_as("SELECT uploader, url FROM image_uploads")
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(row, (123456789012345678, url.to_string()));
|
|
||||||
let (headers, body) = storage.await.unwrap();
|
|
||||||
assert!(headers.starts_with("PUT /images/uploads/"));
|
|
||||||
assert!(headers.to_lowercase().contains("content-type: image/png"));
|
|
||||||
assert_eq!(image::load_from_memory(&body).unwrap().width(), 2);
|
|
||||||
assert!(url.starts_with("https://images.example.com/uploads/"));
|
|
||||||
assert!(url.ends_with(".png"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
pub mod guild;
|
pub mod guild;
|
||||||
pub mod images;
|
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|
||||||
use log::warn;
|
use log::warn;
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -24,17 +24,24 @@ use crate::web::{
|
|||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct Reminder {
|
pub struct Reminder {
|
||||||
|
pub message_json: Option<Json<serde_json::Value>>,
|
||||||
pub attachment: Option<Attachment>,
|
pub attachment: Option<Attachment>,
|
||||||
pub attachment_name: Option<String>,
|
pub attachment_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
#[serde(default)]
|
||||||
pub embed_author: String,
|
pub embed_author: String,
|
||||||
pub embed_author_url: Option<String>,
|
pub embed_author_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub embed_color: u32,
|
pub embed_color: u32,
|
||||||
|
#[serde(default)]
|
||||||
pub embed_description: String,
|
pub embed_description: String,
|
||||||
|
#[serde(default)]
|
||||||
pub embed_footer: String,
|
pub embed_footer: String,
|
||||||
pub embed_footer_url: Option<String>,
|
pub embed_footer_url: Option<String>,
|
||||||
pub embed_image_url: Option<String>,
|
pub embed_image_url: Option<String>,
|
||||||
pub embed_thumbnail_url: Option<String>,
|
pub embed_thumbnail_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub embed_title: String,
|
pub embed_title: String,
|
||||||
pub embed_fields: Option<Json<Vec<EmbedField>>>,
|
pub embed_fields: Option<Json<Vec<EmbedField>>>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@@ -44,6 +51,7 @@ pub struct Reminder {
|
|||||||
pub interval_months: Option<u32>,
|
pub interval_months: Option<u32>,
|
||||||
#[serde(default = "name_default")]
|
#[serde(default = "name_default")]
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
pub tts: bool,
|
pub tts: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub uid: String,
|
pub uid: String,
|
||||||
@@ -79,35 +87,42 @@ pub async fn create_reminder(
|
|||||||
|
|
||||||
let channel = channel.unwrap();
|
let channel = channel.unwrap();
|
||||||
|
|
||||||
|
let message_json = Json(crate::message::payload(&reminder));
|
||||||
|
if let Err(error) = crate::message::validate_payload(&message_json) {
|
||||||
|
return Err(json!({"error": error}));
|
||||||
|
}
|
||||||
|
|
||||||
// validate lengths
|
// validate lengths
|
||||||
check_length!(MAX_NAME_LENGTH, reminder.name);
|
check_length!(MAX_NAME_LENGTH, reminder.name);
|
||||||
check_length!(MAX_CONTENT_LENGTH, reminder.content);
|
if reminder.message_json.is_none() {
|
||||||
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
|
check_length!(MAX_CONTENT_LENGTH, reminder.content);
|
||||||
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
|
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
|
||||||
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
|
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
|
||||||
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
|
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
|
||||||
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
|
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
|
||||||
if let Some(fields) = &reminder.embed_fields {
|
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
|
||||||
for field in &fields.0 {
|
if let Some(fields) = &reminder.embed_fields {
|
||||||
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
|
for field in &fields.0 {
|
||||||
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
|
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
|
||||||
|
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
check_length_opt!(
|
||||||
check_length_opt!(
|
MAX_URL_LENGTH,
|
||||||
MAX_URL_LENGTH,
|
reminder.embed_footer_url,
|
||||||
reminder.embed_footer_url,
|
reminder.embed_thumbnail_url,
|
||||||
reminder.embed_thumbnail_url,
|
reminder.embed_author_url,
|
||||||
reminder.embed_author_url,
|
reminder.embed_image_url
|
||||||
reminder.embed_image_url
|
);
|
||||||
);
|
|
||||||
|
|
||||||
// validate urls
|
// validate urls
|
||||||
check_url_opt!(
|
check_url_opt!(
|
||||||
reminder.embed_footer_url,
|
reminder.embed_footer_url,
|
||||||
reminder.embed_thumbnail_url,
|
reminder.embed_thumbnail_url,
|
||||||
reminder.embed_author_url,
|
reminder.embed_author_url,
|
||||||
reminder.embed_image_url
|
reminder.embed_image_url
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// validate time and interval
|
// validate time and interval
|
||||||
if reminder.utc_time < Utc::now().naive_utc() {
|
if reminder.utc_time < Utc::now().naive_utc() {
|
||||||
@@ -142,52 +157,21 @@ pub async fn create_reminder(
|
|||||||
// write to db
|
// write to db
|
||||||
match sqlx::query!(
|
match sqlx::query!(
|
||||||
"INSERT INTO reminders (
|
"INSERT INTO reminders (
|
||||||
uid,
|
uid, attachment, attachment_name, channel_id, message_json,
|
||||||
attachment,
|
enabled, expires, interval_seconds, interval_days, interval_months,
|
||||||
attachment_name,
|
name, `utc_time`
|
||||||
channel_id,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
content,
|
|
||||||
embed_author,
|
|
||||||
embed_author_url,
|
|
||||||
embed_color,
|
|
||||||
embed_description,
|
|
||||||
embed_footer,
|
|
||||||
embed_footer_url,
|
|
||||||
embed_image_url,
|
|
||||||
embed_thumbnail_url,
|
|
||||||
embed_title,
|
|
||||||
embed_fields,
|
|
||||||
enabled,
|
|
||||||
expires,
|
|
||||||
interval_seconds,
|
|
||||||
interval_days,
|
|
||||||
interval_months,
|
|
||||||
name,
|
|
||||||
tts,
|
|
||||||
`utc_time`
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
new_uid,
|
new_uid,
|
||||||
reminder.attachment,
|
reminder.attachment,
|
||||||
reminder.attachment_name,
|
reminder.attachment_name,
|
||||||
channel,
|
channel,
|
||||||
reminder.content,
|
message_json,
|
||||||
reminder.embed_author,
|
|
||||||
reminder.embed_author_url,
|
|
||||||
reminder.embed_color,
|
|
||||||
reminder.embed_description,
|
|
||||||
reminder.embed_footer,
|
|
||||||
reminder.embed_footer_url,
|
|
||||||
reminder.embed_image_url,
|
|
||||||
reminder.embed_thumbnail_url,
|
|
||||||
reminder.embed_title,
|
|
||||||
reminder.embed_fields,
|
|
||||||
reminder.enabled,
|
reminder.enabled,
|
||||||
reminder.expires,
|
reminder.expires,
|
||||||
reminder.interval_seconds,
|
reminder.interval_seconds,
|
||||||
reminder.interval_days,
|
reminder.interval_days,
|
||||||
reminder.interval_months,
|
reminder.interval_months,
|
||||||
name,
|
name,
|
||||||
reminder.tts,
|
|
||||||
reminder.utc_time,
|
reminder.utc_time,
|
||||||
)
|
)
|
||||||
.execute(transaction.executor())
|
.execute(transaction.executor())
|
||||||
@@ -198,6 +182,7 @@ pub async fn create_reminder(
|
|||||||
"SELECT
|
"SELECT
|
||||||
reminders.attachment,
|
reminders.attachment,
|
||||||
reminders.attachment_name,
|
reminders.attachment_name,
|
||||||
|
reminders.message_json,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
reminders.embed_author,
|
reminders.embed_author,
|
||||||
reminders.embed_author_url,
|
reminders.embed_author_url,
|
||||||
@@ -224,7 +209,7 @@ pub async fn create_reminder(
|
|||||||
)
|
)
|
||||||
.fetch_one(transaction.executor())
|
.fetch_one(transaction.executor())
|
||||||
.await
|
.await
|
||||||
.map(|r| Ok(json!(r)))
|
.map(|r| Ok(crate::message::response(r)))
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
warn!("Failed to complete SQL query: {:?}", e);
|
warn!("Failed to complete SQL query: {:?}", e);
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ pub async fn get_reminders(
|
|||||||
SELECT
|
SELECT
|
||||||
reminders.attachment,
|
reminders.attachment,
|
||||||
reminders.attachment_name,
|
reminders.attachment_name,
|
||||||
|
reminders.message_json,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
reminders.embed_author,
|
reminders.embed_author,
|
||||||
reminders.embed_author_url,
|
reminders.embed_author_url,
|
||||||
@@ -97,7 +98,7 @@ pub async fn get_reminders(
|
|||||||
)
|
)
|
||||||
.fetch_all(pool.inner())
|
.fetch_all(pool.inner())
|
||||||
.await
|
.await
|
||||||
.map(|r| Ok(json!(r)))
|
.map(|r| Ok(crate::message::response(r)))
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
warn!("Failed to complete SQL query: {:?}", e);
|
warn!("Failed to complete SQL query: {:?}", e);
|
||||||
|
|
||||||
@@ -130,6 +131,11 @@ pub async fn edit_reminder(
|
|||||||
let mut error = vec![];
|
let mut error = vec![];
|
||||||
let user_id = user_id_cookie.unwrap();
|
let user_id = user_id_cookie.unwrap();
|
||||||
|
|
||||||
|
if let Some(message) = &reminder.message_json {
|
||||||
|
crate::message::validate_payload(message).map_err(|error| json!({"error": error}))?;
|
||||||
|
update_field!(transaction.executor(), error, reminder.[message_json]);
|
||||||
|
}
|
||||||
|
|
||||||
if reminder.message_ok() {
|
if reminder.message_ok() {
|
||||||
update_field!(transaction.executor(), error, reminder.[
|
update_field!(transaction.executor(), error, reminder.[
|
||||||
content,
|
content,
|
||||||
@@ -246,6 +252,7 @@ pub async fn edit_reminder(
|
|||||||
"
|
"
|
||||||
SELECT reminders.attachment,
|
SELECT reminders.attachment,
|
||||||
reminders.attachment_name,
|
reminders.attachment_name,
|
||||||
|
reminders.message_json,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
reminders.embed_author,
|
reminders.embed_author,
|
||||||
reminders.embed_author_url,
|
reminders.embed_author_url,
|
||||||
@@ -275,7 +282,9 @@ pub async fn edit_reminder(
|
|||||||
.fetch_one(pool.inner())
|
.fetch_one(pool.inner())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})),
|
Ok(reminder) => {
|
||||||
|
Ok(json!({"reminder": crate::message::response(reminder), "errors": error}))
|
||||||
|
}
|
||||||
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Error exiting `edit_reminder': {:?}", e);
|
warn!("Error exiting `edit_reminder': {:?}", e);
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ pub async fn export(
|
|||||||
reminders.attachment_name,
|
reminders.attachment_name,
|
||||||
reminders.avatar,
|
reminders.avatar,
|
||||||
CONCAT('#', channels.channel) AS channel,
|
CONCAT('#', channels.channel) AS channel,
|
||||||
|
CAST(reminders.message_json AS CHAR) AS message_json,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
reminders.embed_author,
|
reminders.embed_author,
|
||||||
reminders.embed_author_url,
|
reminders.embed_author_url,
|
||||||
@@ -150,6 +151,11 @@ pub async fn import(
|
|||||||
match channel_id.parse::<u64>() {
|
match channel_id.parse::<u64>() {
|
||||||
Ok(channel_id) => {
|
Ok(channel_id) => {
|
||||||
let reminder = CreateReminder {
|
let reminder = CreateReminder {
|
||||||
|
message_json: record
|
||||||
|
.message_json
|
||||||
|
.map(|s| serde_json::from_str(&s).map(sqlx::types::Json))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|_| json!({"error": "Invalid message_json"}))?,
|
||||||
attachment: record.attachment,
|
attachment: record.attachment,
|
||||||
attachment_name: record.attachment_name,
|
attachment_name: record.attachment_name,
|
||||||
avatar: record.avatar,
|
avatar: record.avatar,
|
||||||
|
|||||||
@@ -148,22 +148,29 @@ pub struct EmbedField {
|
|||||||
inline: bool,
|
inline: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct CreateReminder {
|
pub struct CreateReminder {
|
||||||
|
message_json: Option<Json<serde_json::Value>>,
|
||||||
attachment: Option<Attachment>,
|
attachment: Option<Attachment>,
|
||||||
attachment_name: Option<String>,
|
attachment_name: Option<String>,
|
||||||
avatar: Option<String>,
|
avatar: Option<String>,
|
||||||
#[serde(with = "string")]
|
#[serde(with = "string")]
|
||||||
channel: u64,
|
channel: u64,
|
||||||
|
#[serde(default)]
|
||||||
content: String,
|
content: String,
|
||||||
|
#[serde(default)]
|
||||||
embed_author: String,
|
embed_author: String,
|
||||||
embed_author_url: Option<String>,
|
embed_author_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
embed_color: u32,
|
embed_color: u32,
|
||||||
|
#[serde(default)]
|
||||||
embed_description: String,
|
embed_description: String,
|
||||||
|
#[serde(default)]
|
||||||
embed_footer: String,
|
embed_footer: String,
|
||||||
embed_footer_url: Option<String>,
|
embed_footer_url: Option<String>,
|
||||||
embed_image_url: Option<String>,
|
embed_image_url: Option<String>,
|
||||||
embed_thumbnail_url: Option<String>,
|
embed_thumbnail_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
embed_title: String,
|
embed_title: String,
|
||||||
embed_fields: Option<Json<Vec<EmbedField>>>,
|
embed_fields: Option<Json<Vec<EmbedField>>>,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
@@ -174,6 +181,7 @@ pub struct CreateReminder {
|
|||||||
#[serde(default = "name_default")]
|
#[serde(default = "name_default")]
|
||||||
name: String,
|
name: String,
|
||||||
restartable: bool,
|
restartable: bool,
|
||||||
|
#[serde(default)]
|
||||||
tts: bool,
|
tts: bool,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
utc_time: NaiveDateTime,
|
utc_time: NaiveDateTime,
|
||||||
@@ -181,6 +189,7 @@ pub struct CreateReminder {
|
|||||||
|
|
||||||
#[derive(Serialize, FromRow)]
|
#[derive(Serialize, FromRow)]
|
||||||
pub struct GetReminder {
|
pub struct GetReminder {
|
||||||
|
message_json: Option<Json<serde_json::Value>>,
|
||||||
attachment_name: Option<String>,
|
attachment_name: Option<String>,
|
||||||
avatar: Option<String>,
|
avatar: Option<String>,
|
||||||
#[serde(with = "string")]
|
#[serde(with = "string")]
|
||||||
@@ -213,6 +222,8 @@ pub struct GetReminder {
|
|||||||
|
|
||||||
#[derive(Serialize, Deserialize, FromRow)]
|
#[derive(Serialize, Deserialize, FromRow)]
|
||||||
pub struct ReminderCsv {
|
pub struct ReminderCsv {
|
||||||
|
#[serde(default)]
|
||||||
|
message_json: Option<String>,
|
||||||
attachment: Option<Attachment>,
|
attachment: Option<Attachment>,
|
||||||
attachment_name: Option<String>,
|
attachment_name: Option<String>,
|
||||||
avatar: Option<String>,
|
avatar: Option<String>,
|
||||||
@@ -243,6 +254,8 @@ pub struct ReminderCsv {
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct PatchReminder {
|
pub struct PatchReminder {
|
||||||
|
#[serde(default)]
|
||||||
|
message_json: Option<Json<serde_json::Value>>,
|
||||||
uid: String,
|
uid: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[serde(deserialize_with = "deserialize_optional_field")]
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
@@ -311,6 +324,9 @@ pub struct PatchReminder {
|
|||||||
|
|
||||||
impl PatchReminder {
|
impl PatchReminder {
|
||||||
fn message_ok(&self) -> bool {
|
fn message_ok(&self) -> bool {
|
||||||
|
if let Some(message) = &self.message_json {
|
||||||
|
return crate::message::validate_payload(message).is_ok();
|
||||||
|
}
|
||||||
self.content.as_ref().map_or(true, |c| c.len() <= MAX_CONTENT_LENGTH)
|
self.content.as_ref().map_or(true, |c| c.len() <= MAX_CONTENT_LENGTH)
|
||||||
&& self.embed_author.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_AUTHOR_LENGTH)
|
&& self.embed_author.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_AUTHOR_LENGTH)
|
||||||
&& self
|
&& self
|
||||||
@@ -422,38 +438,45 @@ pub(crate) async fn create_reminder(
|
|||||||
|
|
||||||
let channel = channel.unwrap();
|
let channel = channel.unwrap();
|
||||||
|
|
||||||
|
let message_json = Json(crate::message::payload(&reminder));
|
||||||
|
if let Err(error) = crate::message::validate_payload(&message_json) {
|
||||||
|
return Err(json!({"error": error}));
|
||||||
|
}
|
||||||
|
|
||||||
// validate lengths
|
// validate lengths
|
||||||
check_length!(MAX_NAME_LENGTH, reminder.name);
|
check_length!(MAX_NAME_LENGTH, reminder.name);
|
||||||
check_length!(MAX_CONTENT_LENGTH, reminder.content);
|
if reminder.message_json.is_none() {
|
||||||
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
|
check_length!(MAX_CONTENT_LENGTH, reminder.content);
|
||||||
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
|
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
|
||||||
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
|
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
|
||||||
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
|
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
|
||||||
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
|
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
|
||||||
if let Some(fields) = &reminder.embed_fields {
|
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
|
||||||
for field in &fields.0 {
|
if let Some(fields) = &reminder.embed_fields {
|
||||||
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
|
for field in &fields.0 {
|
||||||
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
|
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
|
||||||
|
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
check_length_opt!(MAX_USERNAME_LENGTH, reminder.username);
|
||||||
check_length_opt!(MAX_USERNAME_LENGTH, reminder.username);
|
check_length_opt!(
|
||||||
check_length_opt!(
|
MAX_URL_LENGTH,
|
||||||
MAX_URL_LENGTH,
|
reminder.embed_footer_url,
|
||||||
reminder.embed_footer_url,
|
reminder.embed_thumbnail_url,
|
||||||
reminder.embed_thumbnail_url,
|
reminder.embed_author_url,
|
||||||
reminder.embed_author_url,
|
reminder.embed_image_url,
|
||||||
reminder.embed_image_url,
|
reminder.avatar
|
||||||
reminder.avatar
|
);
|
||||||
);
|
|
||||||
|
|
||||||
// validate urls
|
// validate urls
|
||||||
check_url_opt!(
|
check_url_opt!(
|
||||||
reminder.embed_footer_url,
|
reminder.embed_footer_url,
|
||||||
reminder.embed_thumbnail_url,
|
reminder.embed_thumbnail_url,
|
||||||
reminder.embed_author_url,
|
reminder.embed_author_url,
|
||||||
reminder.embed_image_url,
|
reminder.embed_image_url,
|
||||||
reminder.avatar
|
reminder.avatar
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// validate time and interval
|
// validate time and interval
|
||||||
if reminder.utc_time < Utc::now().naive_utc() {
|
if reminder.utc_time < Utc::now().naive_utc() {
|
||||||
@@ -483,60 +506,21 @@ pub(crate) async fn create_reminder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
|
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
|
||||||
let username = if reminder.username.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
reminder.username
|
|
||||||
};
|
|
||||||
|
|
||||||
let new_uid = generate_uid();
|
let new_uid = generate_uid();
|
||||||
|
|
||||||
// write to db
|
// write to db
|
||||||
match sqlx::query!(
|
match sqlx::query!(
|
||||||
"INSERT INTO reminders (
|
"INSERT INTO reminders (
|
||||||
uid,
|
uid, attachment, attachment_name, channel_id, message_json,
|
||||||
attachment,
|
enabled, expires, interval_seconds, interval_days, interval_months,
|
||||||
attachment_name,
|
name, restartable, `utc_time`
|
||||||
channel_id,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
avatar,
|
|
||||||
content,
|
|
||||||
embed_author,
|
|
||||||
embed_author_url,
|
|
||||||
embed_color,
|
|
||||||
embed_description,
|
|
||||||
embed_footer,
|
|
||||||
embed_footer_url,
|
|
||||||
embed_image_url,
|
|
||||||
embed_thumbnail_url,
|
|
||||||
embed_title,
|
|
||||||
embed_fields,
|
|
||||||
enabled,
|
|
||||||
expires,
|
|
||||||
interval_seconds,
|
|
||||||
interval_days,
|
|
||||||
interval_months,
|
|
||||||
name,
|
|
||||||
restartable,
|
|
||||||
tts,
|
|
||||||
username,
|
|
||||||
`utc_time`
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
new_uid,
|
new_uid,
|
||||||
reminder.attachment,
|
reminder.attachment,
|
||||||
reminder.attachment_name,
|
reminder.attachment_name,
|
||||||
channel,
|
channel,
|
||||||
reminder.avatar,
|
message_json,
|
||||||
reminder.content,
|
|
||||||
reminder.embed_author,
|
|
||||||
reminder.embed_author_url,
|
|
||||||
reminder.embed_color,
|
|
||||||
reminder.embed_description,
|
|
||||||
reminder.embed_footer,
|
|
||||||
reminder.embed_footer_url,
|
|
||||||
reminder.embed_image_url,
|
|
||||||
reminder.embed_thumbnail_url,
|
|
||||||
reminder.embed_title,
|
|
||||||
reminder.embed_fields,
|
|
||||||
reminder.enabled,
|
reminder.enabled,
|
||||||
reminder.expires,
|
reminder.expires,
|
||||||
reminder.interval_seconds,
|
reminder.interval_seconds,
|
||||||
@@ -544,8 +528,6 @@ pub(crate) async fn create_reminder(
|
|||||||
reminder.interval_months,
|
reminder.interval_months,
|
||||||
name,
|
name,
|
||||||
reminder.restartable,
|
reminder.restartable,
|
||||||
reminder.tts,
|
|
||||||
username,
|
|
||||||
reminder.utc_time,
|
reminder.utc_time,
|
||||||
)
|
)
|
||||||
.execute(transaction.executor())
|
.execute(transaction.executor())
|
||||||
@@ -558,6 +540,7 @@ pub(crate) async fn create_reminder(
|
|||||||
reminders.attachment_name,
|
reminders.attachment_name,
|
||||||
reminders.avatar,
|
reminders.avatar,
|
||||||
channels.channel,
|
channels.channel,
|
||||||
|
reminders.message_json,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
reminders.embed_author,
|
reminders.embed_author,
|
||||||
reminders.embed_author_url,
|
reminders.embed_author_url,
|
||||||
@@ -587,7 +570,7 @@ pub(crate) async fn create_reminder(
|
|||||||
)
|
)
|
||||||
.fetch_one(transaction.executor())
|
.fetch_one(transaction.executor())
|
||||||
.await
|
.await
|
||||||
.map(|r| Ok(json!(r)))
|
.map(|r| Ok(crate::message::response(r)))
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
warn!("Failed to complete SQL query: {:?}", e);
|
warn!("Failed to complete SQL query: {:?}", e);
|
||||||
|
|
||||||
@@ -869,3 +852,50 @@ async fn render_dashboard(cookies: &CookieJar<'_>) -> DashboardPage {
|
|||||||
DashboardPage::Unauthorised(Redirect::to("/login/discord"))
|
DashboardPage::Unauthorised(Redirect::to("/login/discord"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod message_json_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires MESSAGE_JSON_TEST_DATABASE_URL pointing to an isolated MySQL database"]
|
||||||
|
async fn patch_keeps_embeds_and_buttons_and_ignores_legacy_message_fields() {
|
||||||
|
let pool =
|
||||||
|
sqlx::MySqlPool::connect(&std::env::var("MESSAGE_JSON_TEST_DATABASE_URL").unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut transaction = pool.begin().await.unwrap();
|
||||||
|
let channel = sqlx::query("INSERT INTO channels (channel) VALUES (?)")
|
||||||
|
.bind(rand::random::<u64>())
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.last_insert_id();
|
||||||
|
let uid = format!("multi-embed-test-{}", rand::random::<u64>());
|
||||||
|
sqlx::query("INSERT INTO reminders (uid, channel_id, `utc_time`) VALUES (?, ?, NOW())")
|
||||||
|
.bind(&uid)
|
||||||
|
.bind(channel)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let payload = json!({"content": "current", "embeds": [{"title": "first"}, {"title": "second"}],
|
||||||
|
"components": [{"type": 1, "components": [{"type": 2, "style": 5,
|
||||||
|
"label": "Open link", "url": "https://example.com"}]}]});
|
||||||
|
let reminder: PatchReminder = serde_json::from_value(json!({
|
||||||
|
"uid": uid, "message_json": payload, "content": "stale", "embed_title": "stale"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let mut errors = vec![];
|
||||||
|
assert!(reminder.message_ok());
|
||||||
|
update_field!(&mut *transaction, errors, reminder.[message_json, content, embed_title]);
|
||||||
|
assert!(errors.is_empty());
|
||||||
|
let stored: (Json<serde_json::Value>,) =
|
||||||
|
sqlx::query_as("SELECT message_json FROM reminders WHERE uid = ?")
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_one(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored.0 .0, payload);
|
||||||
|
transaction.rollback().await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Clean orphaned image uploads from Reminder Bot
|
|
||||||
Wants=network-online.target
|
|
||||||
After=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
User=reminder
|
|
||||||
Type=oneshot
|
|
||||||
ExecStart=/usr/bin/reminder-rs clean-images
|
|
||||||
WorkingDirectory=/etc/reminder-rs
|
|
||||||
Environment="RUST_LOG=reminder_rs=info"
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Clean orphaned Reminder Bot image uploads hourly
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnCalendar=hourly
|
|
||||||
Persistent=true
|
|
||||||
Unit=reminder-rs-clean-images.service
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
The two tiny ONNX models are deterministic classifiers used by the image upload API tests.
|
|
||||||
They accept the same `[1, 224, 224, 3]` float input as the production nsfw model and return
|
|
||||||
`[Drawings, Hentai, Neutral, Porn, Sexy]` scores. One always returns Neutral = 1 and the
|
|
||||||
other always returns Porn = 1. They exercise the real nsfw inference path without requiring
|
|
||||||
image fixtures.
|
|
||||||
|
|
||||||
## Image fixtures for the bundled model
|
|
||||||
|
|
||||||
Add image pairs to these project directories:
|
|
||||||
|
|
||||||
* `tests/fixtures/nsfw/` — images expected to be rejected by moderation.
|
|
||||||
* `tests/fixtures/non_nsfw/` — images expected to be accepted by moderation.
|
|
||||||
|
|
||||||
Install the splitter's dependency with `python3 -m pip install Pillow`, then run from the
|
|
||||||
project root:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 scripts/split_nsfw_test_image.py /path/to/input.jpg --output-dir tests/fixtures/nsfw
|
|
||||||
python3 scripts/split_nsfw_test_image.py /path/to/safe.jpg --output-dir tests/fixtures/non_nsfw
|
|
||||||
```
|
|
||||||
|
|
||||||
To run the tests:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
SQLX_OFFLINE=true cargo test --locked directory_images -- --ignored
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deterministic model fixtures
|
|
||||||
|
|
||||||
To regenerate them with Python's `onnx` package:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pathlib import Path
|
|
||||||
from onnx import TensorProto, checker, helper
|
|
||||||
|
|
||||||
for name, scores in [("safe", [0., 0., 1., 0., 0.]), ("unsafe", [0., 0., 0., 1., 0.])]:
|
|
||||||
tensor = helper.make_tensor("probabilities", TensorProto.FLOAT, [1, 5], scores)
|
|
||||||
graph = helper.make_graph(
|
|
||||||
[helper.make_node("Identity", ["probabilities"], ["output"])],
|
|
||||||
"constant classifier for upload endpoint tests",
|
|
||||||
[helper.make_tensor_value_info("input", TensorProto.FLOAT, [1, 224, 224, 3])],
|
|
||||||
[helper.make_tensor_value_info("output", TensorProto.FLOAT, [1, 5])],
|
|
||||||
[tensor],
|
|
||||||
)
|
|
||||||
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)], ir_version=8)
|
|
||||||
checker.check_model(model)
|
|
||||||
Path(f"tests/fixtures/image-upload-{name}.onnx").write_bytes(model.SerializeToString())
|
|
||||||
```
|
|
||||||
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |
|
Before Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 5.3 MiB |
|
Before Width: | Height: | Size: 5.3 MiB |