Compare commits

..
Author SHA1 Message Date
claude Bot 439575acd0 Extend message features 2026-10-11 13:16:02 +01:00
claude Bot 2665c61d33 Move message content to message_json 2026-10-11 12:43:43 +01:00
67 changed files with 2157 additions and 2819 deletions

No files matched your search

-1
View File
@@ -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"
}
Generated
+61 -1127
View File
File diff suppressed because it is too large. Load diff
+2 -11
View File
@@ -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
-22
View File
@@ -1,22 +0,0 @@
.DEFAULT_GOAL := build
CARGO ?= cargo
PODMAN ?= podman
IMAGE ?= reminder-rs
.PHONY: build prepare test image
# Keep the stages ordered even when make is run with -j.
build: image
$(PODMAN) run --rm -v "$(CURDIR):/mnt" -w /mnt \
-e SQLX_OFFLINE=true "$(IMAGE)" cargo deb
image: test
$(PODMAN) build -t "$(IMAGE)" .
test: prepare
SQLX_OFFLINE=true $(CARGO) test --locked
# Include test-only queries in the metadata used by offline compilation.
prepare:
SQLX_OFFLINE=false $(CARGO) sqlx prepare -- --all-targets --locked
+6 -27
View File
@@ -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
@@ -76,15 +64,6 @@ __Other Variables__
* `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
Images can be uploaded directly when all of these settings are supplied:
* `S3_BUCKET` - bucket
* `S3_REGION` - bucket region
* `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` - credentials
* `S3_PUBLIC_URL` - public base URL. Objects must be publicly readable through this URL.
* `NSFW_CHECK_ENABLED` - enables the use of a small model to block uploading
of NSFW images. Set it to `false` to skip NSFW moderation and model initialization.
## Running with Docker ## Running with Docker
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.
-7
View File
@@ -1,7 +0,0 @@
# Bundled NSFW model
`model.onnx` is the pretrained model distributed with
[Fyko/nsfw v0.2.0](https://github.com/fyko/nsfw/releases/tag/v0.2.0), based on
[GantMan/nsfw_model](https://github.com/GantMan/nsfw_model).
Source: https://github.com/fyko/nsfw/releases/download/v0.2.0/model.onnx
Binary file not shown.
-15
View File
@@ -20,18 +20,3 @@ REPORT_EMAIL=
SENT_CLEAN_AGE= SENT_CLEAN_AGE=
# Delete all reminders after this many months # Delete all reminders after this many months
TOTAL_CLEAN_AGE= TOTAL_CLEAN_AGE=
# Optional dashboard image uploads. Leave these blank to use image URLs only.
# All five S3 settings are required. The bucket/CDN must serve objects publicly.
S3_BUCKET=
S3_REGION=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_PUBLIC_URL=
# Optional S3-compatible endpoint (uses path-style addressing).
S3_ENDPOINT=
# NSFW moderation is enabled by default. Set false (or 0/off) to disable it.
NSFW_CHECK_ENABLED=true
# Seconds to retain an upload after cleanup first detects that it is orphaned.
# Must be positive. Defaults to seven days when absent or blank.
IMAGE_UPLOAD_ORPHAN_RETENTION_SECONDS=604800
@@ -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;
+1
View File
@@ -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/"
}, },
+13 -13
View File
@@ -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;
} }
} }
+186
View File
@@ -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/,
);
});
-165
View File
@@ -1,165 +0,0 @@
use std::env;
use log::{info, warn};
use sqlx::{Acquire, MySql, Pool};
use crate::{web::image_uploads::ImageUploadService, Error};
const DEFAULT_RETENTION_SECONDS: u32 = 7 * 24 * 60 * 60;
const BATCH_SIZE: u32 = 1000;
const IMAGE_COLUMNS: &str =
"avatar, embed_image_url, embed_thumbnail_url, embed_author_url, embed_footer_url";
fn binary_image_columns() -> String {
// Existing URL columns and image_uploads use different collations. S3 URLs must
// also match case-sensitively, so compare their bytes rather than text collation.
IMAGE_COLUMNS
.split(", ")
.map(|column| format!("BINARY {column}"))
.collect::<Vec<_>>()
.join(", ")
}
fn retention_seconds(value: Option<&str>) -> Result<u32, Error> {
match value.map(str::trim).filter(|value| !value.is_empty()) {
None => Ok(DEFAULT_RETENTION_SECONDS),
Some(value) => value.parse::<u32>().ok().filter(|value| *value > 0).ok_or_else(|| {
"IMAGE_UPLOAD_ORPHAN_RETENTION_SECONDS must be a positive integer of seconds".into()
}),
}
}
// Only keys generated by the upload route in the configured bucket may be deleted.
fn object_key<'a>(public_url: &str, url: &'a str) -> Option<&'a str> {
let key = url.strip_prefix(public_url)?.strip_prefix('/')?;
let name = key.strip_prefix("uploads/")?.strip_suffix(".png")?;
(name.len() == 64
&& name.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
.then_some(key)
}
async fn mark_orphans(pool: &Pool<MySql>) -> Result<(), Error> {
let columns = binary_image_columns();
sqlx::query(&format!(
"UPDATE image_uploads AS upload SET orphaned_at = CASE
WHEN NOT EXISTS (SELECT 1 FROM reminders
WHERE BINARY upload.url IN ({columns}))
AND NOT EXISTS (SELECT 1 FROM reminder_template
WHERE BINARY upload.url IN ({columns}))
THEN COALESCE(upload.orphaned_at, UTC_TIMESTAMP(6)) ELSE NULL END"
))
.execute(pool)
.await?;
Ok(())
}
pub(crate) async fn run(pool: &Pool<MySql>) -> Result<(), Error> {
let retention =
retention_seconds(env::var("IMAGE_UPLOAD_ORPHAN_RETENTION_SECONDS").ok().as_deref())?;
let Some(storage) = ImageUploadService::for_cleanup()? else {
info!("Image cleanup skipped: S3 uploads are not configured");
return Ok(());
};
cleanup(pool, &storage, retention).await
}
async fn cleanup(
pool: &Pool<MySql>,
storage: &ImageUploadService,
retention: u32,
) -> Result<(), Error> {
mark_orphans(pool).await?;
let candidates: Vec<u64> = sqlx::query_scalar(
"SELECT id FROM image_uploads
WHERE orphaned_at <= UTC_TIMESTAMP(6) - INTERVAL ? SECOND
ORDER BY orphaned_at, id LIMIT ?",
)
.bind(retention)
.bind(BATCH_SIZE)
.fetch_all(pool)
.await?;
let mut deleted = 0;
let mut failed = 0;
for id in candidates {
match delete_orphan(pool, storage, retention, id).await {
Ok(true) => deleted += 1,
Ok(false) => {}
Err(error) => {
failed += 1;
warn!("Could not clean up image upload {}: {}", id, error);
}
}
}
info!("Image cleanup deleted {} uploads; {} failed", deleted, failed);
if failed > 0 {
return Err(format!("Image cleanup failed for {failed} uploads; retained for retry").into());
}
Ok(())
}
async fn delete_orphan(
pool: &Pool<MySql>,
storage: &ImageUploadService,
retention: u32,
id: u64,
) -> Result<bool, Error> {
let mut connection = pool.acquire().await?;
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
.execute(&mut *connection)
.await?;
let mut transaction = connection.begin().await?;
// Serialize overlapping cleanup jobs and recheck the retention time under the row lock.
let url: Option<String> = sqlx::query_scalar(
"SELECT url FROM image_uploads
WHERE id = ? AND orphaned_at <= UTC_TIMESTAMP(6) - INTERVAL ? SECOND
FOR UPDATE",
)
.bind(id)
.bind(retention)
.fetch_optional(&mut *transaction)
.await?;
let Some(url) = url else {
transaction.rollback().await?;
return Ok(false);
};
let key = object_key(&storage.public_url, &url)
.ok_or("Upload URL does not match a generated key in the configured S3 public URL")?;
for table in ["reminders", "reminder_template"] {
// Locking reads see current committed references, rather than a stale snapshot.
// InnoDB's REPEATABLE READ next-key locks also prevent a reference from
// being inserted while the object is being removed. Keep this transaction short:
// it covers one S3 request with the upload service's 30-second timeout.
let columns = binary_image_columns();
let referenced: Option<u32> = sqlx::query_scalar(&format!(
"SELECT id FROM {table} WHERE BINARY ? IN ({columns}) LIMIT 1 FOR UPDATE"
))
.bind(&url)
.fetch_optional(&mut *transaction)
.await?;
if referenced.is_some() {
sqlx::query("UPDATE image_uploads SET orphaned_at = NULL WHERE id = ?")
.bind(id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
return Ok(false);
}
}
let response = storage.bucket.delete_object(key).await?;
// A missing object is already cleaned up, including a retry after a DB commit failure.
if !(200..300).contains(&response.status_code()) && response.status_code() != 404 {
return Err(format!("S3 deletion returned HTTP {}", response.status_code()).into());
}
sqlx::query("DELETE FROM image_uploads WHERE id = ?")
.bind(id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(true)
}
#[cfg(test)]
mod tests;
-258
View File
@@ -1,258 +0,0 @@
use std::{sync::Arc, time::Duration};
use chrono::NaiveDateTime;
use s3::{creds::Credentials, Bucket, Region};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
sync::Semaphore,
task::JoinHandle,
};
use super::*;
const BASE_URL: &str = "https://images.example.com/bucket";
fn upload_url(number: u64) -> String {
format!("{BASE_URL}/uploads/{number:064x}.png")
}
async fn upload(pool: &Pool<MySql>, number: u64) -> u64 {
sqlx::query("INSERT INTO image_uploads (uploader, url) VALUES (123, ?)")
.bind(upload_url(number))
.execute(pool)
.await
.unwrap()
.last_insert_id()
}
async fn orphaned_at(pool: &Pool<MySql>, id: u64) -> Option<NaiveDateTime> {
sqlx::query_scalar("SELECT orphaned_at FROM image_uploads WHERE id = ?")
.bind(id)
.fetch_one(pool)
.await
.unwrap()
}
async fn expire(pool: &Pool<MySql>, id: u64) {
sqlx::query(
"UPDATE image_uploads SET orphaned_at = UTC_TIMESTAMP(6) - INTERVAL 2 DAY WHERE id = ?",
)
.bind(id)
.execute(pool)
.await
.unwrap();
}
async fn reference_tables(pool: &Pool<MySql>) -> (u64, u64) {
let guild = sqlx::query("INSERT INTO guilds (guild) VALUES (123)")
.execute(pool)
.await
.unwrap()
.last_insert_id();
let channel = sqlx::query("INSERT INTO channels (channel, guild_id) VALUES (123, ?)")
.bind(guild)
.execute(pool)
.await
.unwrap()
.last_insert_id();
(guild, channel)
}
async fn storage(status: u16) -> (ImageUploadService, JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (mut socket, _) =
tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().unwrap();
let mut received = Vec::new();
let mut buffer = [0; 4096];
while !received.windows(4).any(|window| window == b"\r\n\r\n") {
let count = socket.read(&mut buffer).await.unwrap();
assert!(count > 0);
received.extend_from_slice(&buffer[..count]);
}
socket
.write_all(
format!("HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
.as_bytes(),
)
.await
.unwrap();
String::from_utf8(received).unwrap()
});
let credentials = Credentials::new(Some("test"), Some("test"), None, None, None).unwrap();
let mut bucket =
Bucket::new("images", Region::Custom { region: "local".into(), endpoint }, credentials)
.unwrap()
.with_path_style();
bucket.set_request_timeout(Some(Duration::from_secs(2)));
(
ImageUploadService {
bucket,
public_url: BASE_URL.into(),
model: None,
slots: Arc::new(Semaphore::new(2)),
},
task,
)
}
#[test]
fn retention_defaults_and_rejects_invalid_settings() {
for value in [None, Some(""), Some(" ")] {
assert_eq!(retention_seconds(value).unwrap(), 604800);
}
assert_eq!(retention_seconds(Some(" 3600 ")).unwrap(), 3600);
for value in ["0", "-1", "1.5", "forever", "4294967296"] {
assert!(retention_seconds(Some(value)).is_err());
}
}
#[test]
fn restricts_deletion_to_generated_keys_in_the_configured_url() {
assert_eq!(
object_key(BASE_URL, &upload_url(1)),
Some(format!("uploads/{:064x}.png", 1).as_str())
);
for url in [
upload_url(1).replace("images.example.com", "other.example.com"),
upload_url(1).replace("/bucket/", "/bucket-other/"),
upload_url(1).replace("/uploads/", "/private/"),
format!("{BASE_URL}/uploads/../private.png"),
format!("{BASE_URL}/uploads/{}.png", "g".repeat(64)),
format!("{}?download=true", upload_url(1)),
format!("{BASE_URL}/uploads/short.png"),
] {
assert!(object_key(BASE_URL, &url).is_none(), "{url}");
}
}
#[sqlx::test]
async fn orphan_timestamp_survives_repeated_runs_and_resets_on_reuse(pool: Pool<MySql>) {
let id = upload(&pool, 1).await;
assert!(orphaned_at(&pool, id).await.is_none());
mark_orphans(&pool).await.unwrap();
let first = orphaned_at(&pool, id).await.unwrap();
mark_orphans(&pool).await.unwrap();
assert_eq!(orphaned_at(&pool, id).await, Some(first));
let (guild, _) = reference_tables(&pool).await;
let template =
sqlx::query("INSERT INTO reminder_template (guild_id, embed_image_url) VALUES (?, ?)")
.bind(guild)
.bind(upload_url(1))
.execute(&pool)
.await
.unwrap()
.last_insert_id();
mark_orphans(&pool).await.unwrap();
assert!(orphaned_at(&pool, id).await.is_none());
sqlx::query("DELETE FROM reminder_template WHERE id = ?")
.bind(template)
.execute(&pool)
.await
.unwrap();
mark_orphans(&pool).await.unwrap();
assert!(orphaned_at(&pool, id).await.unwrap() > first);
}
#[sqlx::test]
async fn protects_all_image_fields_in_reminders_and_templates(pool: Pool<MySql>) {
let (guild, channel) = reference_tables(&pool).await;
let mut ids = Vec::new();
for (index, column) in IMAGE_COLUMNS.split(", ").enumerate() {
for table in ["reminders", "reminder_template"] {
let number = ids.len() as u64 + 1;
ids.push(upload(&pool, number).await);
let query = if table == "reminders" {
// Archived/disabled reminders still retain their image URLs.
format!(
"INSERT INTO reminders (uid, channel_id, `utc_time`, status, enabled, {column})
VALUES ('field-{index}', {channel}, UTC_TIMESTAMP(), 'sent', 0, ?)"
)
} else {
format!("INSERT INTO reminder_template (guild_id, {column}) VALUES ({guild}, ?)")
};
sqlx::query(&query).bind(upload_url(number)).execute(&pool).await.unwrap();
}
}
let unused = upload(&pool, 100).await;
// Previously orphaned files are unmarked as soon as cleanup observes a reference.
for id in &ids {
expire(&pool, *id).await;
}
mark_orphans(&pool).await.unwrap();
for id in ids {
assert!(orphaned_at(&pool, id).await.is_none());
}
assert!(orphaned_at(&pool, unused).await.is_some());
}
#[sqlx::test]
async fn only_expired_orphans_are_deleted_and_rows_removed_after_success(pool: Pool<MySql>) {
let old = upload(&pool, 1).await;
let fresh = upload(&pool, 2).await;
expire(&pool, old).await;
let (storage, request) = storage(204).await;
cleanup(&pool, &storage, 86400).await.unwrap();
assert!(request.await.unwrap().starts_with(&format!("DELETE /images/uploads/{:064x}.png ", 1)));
let ids: Vec<u64> = sqlx::query_scalar("SELECT id FROM image_uploads ORDER BY id")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(ids, vec![fresh]);
assert!(orphaned_at(&pool, fresh).await.is_some());
}
#[sqlx::test]
async fn failed_deletions_keep_the_original_timestamp_and_retry_missing_objects(pool: Pool<MySql>) {
let id = upload(&pool, 1).await;
expire(&pool, id).await;
let original = orphaned_at(&pool, id).await;
let (failed_storage, failed_request) = storage(403).await;
assert!(cleanup(&pool, &failed_storage, 86400).await.is_err());
failed_request.await.unwrap();
assert_eq!(orphaned_at(&pool, id).await, original);
let (retry_storage, retry_request) = storage(404).await;
cleanup(&pool, &retry_storage, 86400).await.unwrap();
retry_request.await.unwrap();
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM image_uploads").fetch_one(&pool).await.unwrap();
assert_eq!(count, 0);
}
#[sqlx::test]
async fn rechecks_references_before_deleting_a_candidate(pool: Pool<MySql>) {
let id = upload(&pool, 1).await;
expire(&pool, id).await;
let (guild, _) = reference_tables(&pool).await;
sqlx::query("INSERT INTO reminder_template (guild_id, avatar) VALUES (?, ?)")
.bind(guild)
.bind(upload_url(1))
.execute(&pool)
.await
.unwrap();
let (storage, request) = storage(204).await;
assert!(!delete_orphan(&pool, &storage, 86400, id).await.unwrap());
assert!(orphaned_at(&pool, id).await.is_none());
request.abort();
}
#[sqlx::test]
async fn foreign_urls_are_retained_without_contacting_s3(pool: Pool<MySql>) {
let id = upload(&pool, 1).await;
sqlx::query(
"UPDATE image_uploads SET url = 'https://elsewhere.example.com/file.png' WHERE id = ?",
)
.bind(id)
.execute(&pool)
.await
.unwrap();
expire(&pool, id).await;
let (storage, request) = storage(204).await;
assert!(cleanup(&pool, &storage, 86400).await.is_err());
assert!(orphaned_at(&pool, id).await.is_some());
request.abort();
}
+1 -2
View File
@@ -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() {
+604
View File
@@ -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();
}
}
+10 -6
View File
@@ -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
+14
View File
@@ -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 {
+54 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
} }
-295
View File
@@ -1,295 +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"));
// 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));
}
}
+19 -1
View File
@@ -57,7 +57,15 @@ 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.
let result = if crate::message::is_message_field(stringify!($field))
&& $reminder.message_json.is_some()
{
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 `", "UPDATE reminders SET `",
stringify!($field), stringify!($field),
"` = ? WHERE uid = ?" "` = ? WHERE uid = ?"
@@ -66,7 +74,17 @@ macro_rules! update_field {
.bind(&$reminder.uid) .bind(&$reminder.uid)
.execute($pool) .execute($pool)
.await .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
View File
@@ -4,7 +4,6 @@ mod macros;
mod catchers; mod catchers;
mod fairings; mod fairings;
mod guards; mod guards;
pub(crate) mod image_uploads;
mod ip_blocking; mod ip_blocking;
mod routes; mod routes;
@@ -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);
-319
View File
@@ -1,319 +0,0 @@
use log::warn;
use rand::{rngs::OsRng, RngCore};
use rocket::{
data::{Data, ToByteUnit},
http::{CookieJar, Status},
post,
request::{FromRequest, Outcome},
serde::json::{json, Json, Value},
Request, State,
};
use sqlx::{MySql, Pool};
use crate::web::image_uploads::{prepare_image, ImageError, ImageUploads, MAX_UPLOAD_BYTES};
pub fn mount(
rocket: rocket::Rocket<rocket::Build>,
uploads: ImageUploads,
) -> rocket::Rocket<rocket::Build> {
let enabled = uploads.0.is_some();
let rocket = rocket.manage(uploads);
if enabled {
rocket.mount("/dashboard", rocket::routes![upload_image])
} else {
rocket
}
}
pub struct Uploader(u64);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Uploader {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
match uploader_id(request.cookies()) {
Some(id) => Outcome::Success(Self(id)),
None => Outcome::Error((Status::Unauthorized, ())),
}
}
}
fn uploader_id(cookies: &CookieJar<'_>) -> Option<u64> {
cookies.get_private("userid")?.value().parse().ok().filter(|id| *id != 0)
}
type UploadResult = Result<Json<Value>, (Status, Json<Value>)>;
fn error(status: Status, message: &str) -> (Status, Json<Value>) {
(status, Json(json!({ "error": message })))
}
// Requiring a non-form content type prevents cross-site HTML form submissions.
// Authorization is a request guard, so it runs before the body is read.
#[post("/api/images", format = "application/octet-stream", data = "<data>")]
pub async fn upload_image(
uploader: Uploader,
uploads: &State<ImageUploads>,
pool: &State<Pool<MySql>>,
data: Data<'_>,
) -> UploadResult {
let service =
uploads.0.as_ref().ok_or_else(|| error(Status::NotFound, "Image uploads disabled"))?;
let _permit = service
.slots
.clone()
.try_acquire_owned()
.map_err(|_| error(Status::TooManyRequests, "Image uploads busy. Please try again."))?;
let bytes = data
.open(MAX_UPLOAD_BYTES.bytes())
.into_bytes()
.await
.map_err(|_| error(Status::BadRequest, "Could not read image"))?;
if !bytes.is_complete() {
return Err(error(Status::PayloadTooLarge, "Images must be no larger than 8 MiB"));
}
let service_for_moderation = service.clone();
let moderated = tokio::task::spawn_blocking(move || {
prepare_image(service_for_moderation.model.as_ref(), &bytes.into_inner())
})
.await
.map_err(|err| {
warn!("Image moderation task failed: {}", err);
error(Status::ServiceUnavailable, "Image moderation unavailable. Please try again.")
})?;
let image = moderated.map_err(|err| match err {
ImageError::InvalidImage => error(
Status::UnprocessableEntity,
"Please upload a valid PNG, JPEG, GIF or WebP image (at most 16 million pixels)",
),
ImageError::Nsfw => error(
Status::UnavailableForLegalReasons,
"This image was flagged as probably NSFW and cannot be uploaded.",
),
ImageError::ModerationFailed => {
error(Status::ServiceUnavailable, "Image moderation unavailable. Please try again.")
}
})?;
let mut random = [0u8; 32];
OsRng.fill_bytes(&mut random);
let key = format!(
"uploads/{}.png",
random.iter().map(|byte| format!("{:02x}", byte)).collect::<String>()
);
let url = format!("{}/{}", service.public_url, key);
let response =
service.bucket.put_object_with_content_type(&key, &image, "image/png").await.map_err(
|err| {
warn!("Could not upload image to S3: {}", err);
error(Status::BadGateway, "Could not store image. Please try again.")
},
)?;
if !(200..300).contains(&response.status_code()) {
warn!("S3 image upload returned {}", response.status_code());
return Err(error(Status::BadGateway, "Could not store image. Please try again."));
}
if let Err(err) = sqlx::query("INSERT INTO image_uploads (uploader, url) VALUES (?, ?)")
.bind(uploader.0)
.bind(&url)
.execute(pool.inner())
.await
{
warn!("Could not record image upload: {}", err);
match service.bucket.delete_object(&key).await {
Ok(response) if (200..300).contains(&response.status_code()) => {}
_ => warn!("Could not clean up untracked image object {}", key),
}
return Err(error(
Status::InternalServerError,
"Could not record image upload. Please try again.",
));
}
Ok(Json(json!({ "url": url })))
}
#[cfg(test)]
mod tests {
use std::{io::Cursor, sync::Arc};
use rocket::{http::ContentType, local::asynchronous::Client};
use s3::{creds::Credentials, Bucket, Region};
use tokio::sync::Semaphore;
use super::*;
use crate::web::image_uploads::ImageUploadService;
fn service(unsafe_image: bool, endpoint: &str) -> ImageUploads {
let model: &[u8] = if unsafe_image {
include_bytes!("../../../../../tests/fixtures/image-upload-unsafe.onnx")
} else {
include_bytes!("../../../../../tests/fixtures/image-upload-safe.onnx")
};
let bucket = Bucket::new(
"images",
Region::Custom { region: "eu-west-2".into(), endpoint: endpoint.into() },
Credentials::new(Some("test"), Some("test"), None, None, None).unwrap(),
)
.unwrap()
.with_path_style();
ImageUploads(Some(Arc::new(ImageUploadService {
bucket,
public_url: "https://images.example.com".into(),
model: Some(nsfw::create_model(model).unwrap()),
slots: Arc::new(Semaphore::new(2)),
})))
}
fn image_bytes() -> Vec<u8> {
let mut bytes = Cursor::new(Vec::new());
image::DynamicImage::new_rgb8(2, 2).write_to(&mut bytes, image::ImageFormat::Png).unwrap();
bytes.into_inner()
}
async fn client(uploads: ImageUploads) -> Client {
let pool = sqlx::mysql::MySqlPoolOptions::new()
.connect_lazy("mysql://unused:unused@127.0.0.1:1/unused")
.unwrap();
Client::tracked(mount(rocket::build().manage(pool), uploads)).await.unwrap()
}
#[rocket::async_test]
async fn disabled_upload_route_is_not_available() {
let client = client(ImageUploads(None)).await;
assert_eq!(
client
.post("/dashboard/api/images")
.header(ContentType::Binary)
.body(image_bytes())
.dispatch()
.await
.status(),
Status::NotFound
);
}
#[rocket::async_test]
async fn upload_requires_an_authenticated_private_cookie() {
let client = client(service(false, "http://127.0.0.1:1")).await;
for cookie in [None, Some("not-an-id"), Some("0")] {
let mut request = client
.post("/dashboard/api/images")
.header(ContentType::Binary)
.body(image_bytes());
if let Some(cookie) = cookie {
request = request.private_cookie(("userid", cookie));
}
assert_eq!(request.dispatch().await.status(), Status::Unauthorized);
}
}
#[rocket::async_test]
async fn unsafe_images_return_451_without_contacting_storage_or_database() {
let client = client(service(true, "http://127.0.0.1:1")).await;
let response = client
.post("/dashboard/api/images")
.header(ContentType::Binary)
.private_cookie(("userid", "123456789012345678"))
.body(image_bytes())
.dispatch()
.await;
assert_eq!(response.status(), Status::UnavailableForLegalReasons);
assert!(response.into_json::<Value>().await.unwrap()["error"]
.as_str()
.unwrap()
.contains("NSFW"));
}
#[rocket::async_test]
async fn rejects_invalid_oversized_and_form_uploads() {
let client = client(service(false, "http://127.0.0.1:1")).await;
for (body, status) in [
(b"this is not an image".to_vec(), Status::UnprocessableEntity),
(vec![0; MAX_UPLOAD_BYTES as usize + 1], Status::PayloadTooLarge),
] {
let response = client
.post("/dashboard/api/images")
.header(ContentType::Binary)
.private_cookie(("userid", "123456789012345678"))
.body(body)
.dispatch()
.await;
assert_eq!(response.status(), status);
}
let response = client
.post("/dashboard/api/images")
.header(ContentType::Form)
.private_cookie(("userid", "123456789012345678"))
.body("file=anything")
.dispatch()
.await;
assert_eq!(response.status(), Status::NotFound);
}
#[sqlx::test]
async fn stores_moderated_image_and_tracks_uploader(pool: Pool<MySql>) {
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
let storage = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut received = Vec::new();
let mut buffer = [0; 4096];
let (headers, body) = loop {
let count = socket.read(&mut buffer).await.unwrap();
assert!(count > 0);
received.extend_from_slice(&buffer[..count]);
if let Some(end) = received.windows(4).position(|window| window == b"\r\n\r\n") {
let headers = String::from_utf8(received[..end].to_vec()).unwrap();
let length: usize = headers
.lines()
.find_map(|line| {
line.to_lowercase()
.strip_prefix("content-length:")
.map(|length| length.trim().parse().unwrap())
})
.unwrap();
if received.len() >= end + 4 + length {
break (headers, received[end + 4..end + 4 + length].to_vec());
}
}
};
socket
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
.await
.unwrap();
(headers, body)
});
let client =
Client::tracked(mount(rocket::build().manage(pool.clone()), service(false, &endpoint)))
.await
.unwrap();
let response = client
.post("/dashboard/api/images")
.header(ContentType::Binary)
.private_cookie(("userid", "123456789012345678"))
.body(image_bytes())
.dispatch()
.await;
assert_eq!(response.status(), Status::Ok);
let response = response.into_json::<Value>().await.unwrap();
let url = response["url"].as_str().unwrap();
let row: (u64, String) = sqlx::query_as("SELECT uploader, url FROM image_uploads")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row, (123456789012345678, url.to_string()));
let (headers, body) = storage.await.unwrap();
assert!(headers.starts_with("PUT /images/uploads/"));
assert!(headers.to_lowercase().contains("content-type: image/png"));
assert_eq!(image::load_from_memory(&body).unwrap().width(), 2);
assert!(url.starts_with("https://images.example.com/uploads/"));
assert!(url.ends_with(".png"));
}
}
-1
View File
@@ -1,5 +1,4 @@
pub mod guild; pub mod guild;
pub mod images;
pub mod user; pub mod user;
use log::warn; use log::warn;
-5
View File
@@ -23,7 +23,6 @@ use serenity::{
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use crate::web::guards::transaction::Transaction; use crate::web::guards::transaction::Transaction;
use crate::web::image_uploads::ImageUploads;
#[derive(Serialize)] #[derive(Serialize)]
struct UserPreferences { struct UserPreferences {
@@ -37,7 +36,6 @@ struct UserPreferences {
struct UserInfo { struct UserInfo {
name: String, name: String,
patreon: bool, patreon: bool,
image_uploads: bool,
preferences: UserPreferences, preferences: UserPreferences,
} }
@@ -54,12 +52,10 @@ pub async fn get_user_info(
cookies: &CookieJar<'_>, cookies: &CookieJar<'_>,
ctx: &State<Context>, ctx: &State<Context>,
pool: &State<Pool<MySql>>, pool: &State<Pool<MySql>>,
uploads: &State<ImageUploads>,
) -> JsonValue { ) -> JsonValue {
offline!(json!(UserInfo { offline!(json!(UserInfo {
name: "Discord".to_string(), name: "Discord".to_string(),
patreon: true, patreon: true,
image_uploads: uploads.0.is_some(),
preferences: UserPreferences { preferences: UserPreferences {
timezone: "UTC".to_string(), timezone: "UTC".to_string(),
use_browser_timezone: true, use_browser_timezone: true,
@@ -105,7 +101,6 @@ pub async fn get_user_info(
); );
let user_info = UserInfo { let user_info = UserInfo {
image_uploads: uploads.0.is_some(),
name: cookies name: cookies
.get_private("username") .get_private("username")
.map_or("Discord User".to_string(), |c| c.value().to_string()), .map_or("Discord User".to_string(), |c| c.value().to_string()),
+22 -37
View File
@@ -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,8 +87,14 @@ 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);
if reminder.message_json.is_none() {
check_length!(MAX_CONTENT_LENGTH, reminder.content); check_length!(MAX_CONTENT_LENGTH, reminder.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description); check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title); check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
@@ -108,6 +122,7 @@ pub async fn create_reminder(
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);
+11 -2
View File
@@ -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,
+78 -48
View File
@@ -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,8 +438,14 @@ 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);
if reminder.message_json.is_none() {
check_length!(MAX_CONTENT_LENGTH, reminder.content); check_length!(MAX_CONTENT_LENGTH, reminder.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description); check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title); check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
@@ -454,6 +476,7 @@ pub(crate) async fn create_reminder(
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();
}
}
-11
View File
@@ -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"
-10
View File
@@ -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
-27
View File
@@ -1,27 +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.
## 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())
```
Binary file not shown.
Binary file not shown.