added basic admin tools
Some checks failed
CI / Check (push) Failing after 20s
CI / Build & Push Docker Image (push) Has been skipped
CI / Deploy to Rocky (push) Has been skipped

i can ban users now!
This commit is contained in:
Butter 2026-05-04 18:40:01 -04:00
parent 8d4042e31a
commit dc7eb57640
36 changed files with 1900 additions and 185 deletions

View File

@ -1,92 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!\", username, email, password, bio, avatar_url, role, created_at,\n email_verified AS \"email_verified!\",\n verification_token,\n verification_token_expires,\n reset_token,\n reset_token_expires\n FROM users WHERE username = ?",
"describe": {
"columns": [
{
"name": "id!",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "username",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "password",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "bio",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "avatar_url",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "role",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "email_verified!",
"ordinal": 8,
"type_info": "Bool"
},
{
"name": "verification_token",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "verification_token_expires",
"ordinal": 10,
"type_info": "Datetime"
},
{
"name": "reset_token",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "reset_token_expires",
"ordinal": 12,
"type_info": "Datetime"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
true,
true,
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "03e12aecfba017ed94a3dcd86e82454d8111c884d2133090ac76c341317f4a9a"
}

View File

@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) as 'count!' FROM threads WHERE hidden = FALSE",
"describe": {
"columns": [
{
"name": "count!",
"ordinal": 0,
"type_info": "Int"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "2a0b3b13b130ee33ac214dcd25fb988b0b5ad8756eacf65a770a733e369d048a"
}

View File

@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "SELECT id, body, author_id, thread_id, created_at, updated_at,\n hidden AS \"hidden!\", hidden_at, hidden_by, ban_message\n FROM posts WHERE thread_id = ? AND hidden = FALSE ORDER BY created_at ASC",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "body",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "author_id",
"ordinal": 2,
"type_info": "Int64"
},
{
"name": "thread_id",
"ordinal": 3,
"type_info": "Int64"
},
{
"name": "created_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "hidden!",
"ordinal": 6,
"type_info": "Bool"
},
{
"name": "hidden_at",
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "hidden_by",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "ban_message",
"ordinal": 9,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
true,
true
]
},
"hash": "378ae2f1e9de949f1cac3c0f463e475a7769a2b6d25e0a091a92393a89e55642"
}

View File

@ -0,0 +1,68 @@
{
"db_name": "SQLite",
"query": "SELECT id, title, body, author_id, created_at, updated_at,\n hidden AS \"hidden!\", hidden_at, hidden_by\n FROM threads WHERE author_id = ? AND hidden = FALSE ORDER BY updated_at DESC",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "title",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "author_id",
"ordinal": 3,
"type_info": "Int64"
},
{
"name": "created_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "hidden!",
"ordinal": 6,
"type_info": "Bool"
},
{
"name": "hidden_at",
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "hidden_by",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
true
]
},
"hash": "3811471be7d9676565e0967283ffb802271d2d211a04aa30eff969001869a983"
}

View File

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!\", username, email, password, bio, avatar_url, role, created_at,\n email_verified AS \"email_verified!\",\n verification_token,\n verification_token_expires,\n reset_token,\n reset_token_expires\n FROM users WHERE email = ?",
"query": "SELECT id AS \"id!\", username, email, password, bio, avatar_url, role, created_at,\n email_verified AS \"email_verified!\",\n verification_token,\n verification_token_expires,\n reset_token,\n reset_token_expires,\n banned AS \"banned!\",\n banned_at,\n ban_reason,\n ban_length,\n unban_at,\n banned_by,\n ban_post_content\n FROM users WHERE reset_token = ?",
"describe": {
"columns": [
{
@ -67,13 +67,48 @@
"name": "reset_token_expires",
"ordinal": 12,
"type_info": "Datetime"
},
{
"name": "banned!",
"ordinal": 13,
"type_info": "Bool"
},
{
"name": "banned_at",
"ordinal": 14,
"type_info": "Datetime"
},
{
"name": "ban_reason",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "ban_length",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "unban_at",
"ordinal": 17,
"type_info": "Datetime"
},
{
"name": "banned_by",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "ban_post_content",
"ordinal": 19,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false,
@ -85,8 +120,15 @@
true,
true,
true,
true,
false,
true,
true,
true,
true,
true,
true
]
},
"hash": "a4c396e317e9a596629e91d1deefa19df638161c6a7a87427cb1f298aaa545ff"
"hash": "4a08e2a61580e024d81ac54ab8cb00600131a1ba03431d07885752b41fef4cbb"
}

View File

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id, title, body, author_id, created_at, updated_at\n FROM threads WHERE author_id = ? ORDER BY updated_at DESC",
"query": "SELECT id, title, body, author_id, created_at, updated_at,\n hidden AS \"hidden!\", hidden_at, hidden_by\n FROM threads WHERE id = ?",
"describe": {
"columns": [
{
@ -32,6 +32,21 @@
"name": "updated_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "hidden!",
"ordinal": 6,
"type_info": "Bool"
},
{
"name": "hidden_at",
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "hidden_by",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
@ -43,8 +58,11 @@
false,
false,
false,
false
false,
false,
true,
true
]
},
"hash": "ce4cb40fa25111c00d2f4575ce0382de37f2b57bcfccc5f05b4a59ace6a60872"
"hash": "5ea6f4356c6d73485f869e8190580d601223d16e320c54fa7e42049e970e32fb"
}

View File

@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) as 'count!' FROM users",
"describe": {
"columns": [
{
"name": "count!",
"ordinal": 0,
"type_info": "Int"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "5f35fdcf88f734bf92f66012bb7bf9ceb02e4e91dfccae7b16f4365fa46db709"
}

View File

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!\", username, email, password, bio, avatar_url, role, created_at,\n email_verified AS \"email_verified!\",\n verification_token,\n verification_token_expires,\n reset_token,\n reset_token_expires\n FROM users WHERE verification_token = ?",
"query": "SELECT id AS \"id!\", username, email, password, bio, avatar_url, role, created_at,\n email_verified AS \"email_verified!\",\n verification_token,\n verification_token_expires,\n reset_token,\n reset_token_expires,\n banned AS \"banned!\",\n banned_at,\n ban_reason,\n ban_length,\n unban_at,\n banned_by,\n ban_post_content\n FROM users WHERE id = ?",
"describe": {
"columns": [
{
@ -67,6 +67,41 @@
"name": "reset_token_expires",
"ordinal": 12,
"type_info": "Datetime"
},
{
"name": "banned!",
"ordinal": 13,
"type_info": "Bool"
},
{
"name": "banned_at",
"ordinal": 14,
"type_info": "Datetime"
},
{
"name": "ban_reason",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "ban_length",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "unban_at",
"ordinal": 17,
"type_info": "Datetime"
},
{
"name": "banned_by",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "ban_post_content",
"ordinal": 19,
"type_info": "Text"
}
],
"parameters": {
@ -85,8 +120,15 @@
true,
true,
true,
true,
false,
true,
true,
true,
true,
true,
true
]
},
"hash": "ebb6edb02bb7b9e2f4d7acd344790f3bdcae506ad1c604dd954b6f091e4c66f2"
"hash": "6dca909c5e2893eededfb9e2eac10742cbbcad65cc910fa4165f07d376c0b2c2"
}

View File

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id, title, body, author_id, created_at, updated_at\n FROM threads ORDER BY updated_at DESC",
"query": "SELECT id, title, body, author_id, created_at, updated_at,\n hidden AS \"hidden!\", hidden_at, hidden_by\n FROM threads ORDER BY updated_at DESC",
"describe": {
"columns": [
{
@ -32,6 +32,21 @@
"name": "updated_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "hidden!",
"ordinal": 6,
"type_info": "Bool"
},
{
"name": "hidden_at",
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "hidden_by",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
@ -43,8 +58,11 @@
false,
false,
false,
false
false,
false,
true,
true
]
},
"hash": "c40c4444269dd206d0dc0618d8a322a76e5f77c663cdbb433d7985fb5528af67"
"hash": "734e50b9a4ed85367e9888ea1e4ac602a30efc6a3c5e40e9723a56bd65db8aad"
}

View File

@ -0,0 +1,134 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!\", username, email, password, bio, avatar_url, role, created_at,\n email_verified AS \"email_verified!\",\n verification_token,\n verification_token_expires,\n reset_token,\n reset_token_expires,\n banned AS \"banned!\",\n banned_at,\n ban_reason,\n ban_length,\n unban_at,\n banned_by,\n ban_post_content\n FROM users ORDER BY created_at DESC",
"describe": {
"columns": [
{
"name": "id!",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "username",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "password",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "bio",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "avatar_url",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "role",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "email_verified!",
"ordinal": 8,
"type_info": "Bool"
},
{
"name": "verification_token",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "verification_token_expires",
"ordinal": 10,
"type_info": "Datetime"
},
{
"name": "reset_token",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "reset_token_expires",
"ordinal": 12,
"type_info": "Datetime"
},
{
"name": "banned!",
"ordinal": 13,
"type_info": "Bool"
},
{
"name": "banned_at",
"ordinal": 14,
"type_info": "Datetime"
},
{
"name": "ban_reason",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "ban_length",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "unban_at",
"ordinal": 17,
"type_info": "Datetime"
},
{
"name": "banned_by",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "ban_post_content",
"ordinal": 19,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
true,
true,
false,
false,
false,
true,
true,
true,
true,
false,
true,
true,
true,
true,
true,
true
]
},
"hash": "77440cead9d333038d59388bc46f548cab89f0b656abfb24f8385675eded8132"
}

View File

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id, title, body, author_id, created_at, updated_at\n FROM threads WHERE id = ?",
"query": "SELECT id, title, body, author_id, created_at, updated_at,\n hidden AS \"hidden!\", hidden_at, hidden_by\n FROM threads WHERE id = ? AND hidden = FALSE",
"describe": {
"columns": [
{
@ -32,6 +32,21 @@
"name": "updated_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "hidden!",
"ordinal": 6,
"type_info": "Bool"
},
{
"name": "hidden_at",
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "hidden_by",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
@ -43,8 +58,11 @@
false,
false,
false,
false
false,
false,
true,
true
]
},
"hash": "6549001e2a522da8788d77d915ca372b07872cd763a48f3a035e7e5c5a70c879"
"hash": "9a393c4019349783b7267a7447514fa217e4133b6774a4cdb1b45347c306971c"
}

View File

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!\", username, email, password, bio, avatar_url, role, created_at,\n email_verified AS \"email_verified!\",\n verification_token,\n verification_token_expires,\n reset_token,\n reset_token_expires\n FROM users WHERE reset_token = ?",
"query": "SELECT id AS \"id!\", username, email, password, bio, avatar_url, role, created_at,\n email_verified AS \"email_verified!\",\n verification_token,\n verification_token_expires,\n reset_token,\n reset_token_expires,\n banned AS \"banned!\",\n banned_at,\n ban_reason,\n ban_length,\n unban_at,\n banned_by,\n ban_post_content\n FROM users WHERE email = ?",
"describe": {
"columns": [
{
@ -67,13 +67,48 @@
"name": "reset_token_expires",
"ordinal": 12,
"type_info": "Datetime"
},
{
"name": "banned!",
"ordinal": 13,
"type_info": "Bool"
},
{
"name": "banned_at",
"ordinal": 14,
"type_info": "Datetime"
},
{
"name": "ban_reason",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "ban_length",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "unban_at",
"ordinal": 17,
"type_info": "Datetime"
},
{
"name": "banned_by",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "ban_post_content",
"ordinal": 19,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
false,
false,
@ -85,8 +120,15 @@
true,
true,
true,
true,
false,
true,
true,
true,
true,
true,
true
]
},
"hash": "1fdfa7429dc20e693f147c5184e7b2299821d822590a978bed7bdf0f0f7917e3"
"hash": "b0fc43ae344ae0bda14d631071271b34f6c9e307c675b920fb0ccac9bba0a797"
}

View File

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id, body, author_id, thread_id, created_at, updated_at\n FROM posts WHERE thread_id = ? ORDER BY created_at ASC",
"query": "SELECT id, body, author_id, thread_id, created_at, updated_at,\n hidden AS \"hidden!\", hidden_at, hidden_by, ban_message\n FROM posts WHERE thread_id = ? ORDER BY created_at ASC",
"describe": {
"columns": [
{
@ -32,6 +32,26 @@
"name": "updated_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "hidden!",
"ordinal": 6,
"type_info": "Bool"
},
{
"name": "hidden_at",
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "hidden_by",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "ban_message",
"ordinal": 9,
"type_info": "Text"
}
],
"parameters": {
@ -43,8 +63,12 @@
false,
false,
false,
false
false,
false,
true,
true,
true
]
},
"hash": "4a1b2d9c1c66741913ff3a9ddf50d260d8bb01fb891509bf95561d72c3376f98"
"hash": "db743bec93d5243e994d5a85a60ec3c896432fede4d849c10d1b02813eb84428"
}

View File

@ -0,0 +1,68 @@
{
"db_name": "SQLite",
"query": "SELECT id, title, body, author_id, created_at, updated_at,\n hidden AS \"hidden!\", hidden_at, hidden_by\n FROM threads WHERE hidden = FALSE ORDER BY updated_at DESC",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "title",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "author_id",
"ordinal": 3,
"type_info": "Int64"
},
{
"name": "created_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "hidden!",
"ordinal": 6,
"type_info": "Bool"
},
{
"name": "hidden_at",
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "hidden_by",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
true
]
},
"hash": "e3f0046e263f5a5029bdcf1d7f99d5ef094a447b85376822a9e8052e51372087"
}

View File

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!\", username, email, password, bio, avatar_url, role, created_at,\n email_verified AS \"email_verified!\",\n verification_token,\n verification_token_expires,\n reset_token,\n reset_token_expires\n FROM users WHERE id = ?",
"query": "SELECT id AS \"id!\", username, email, password, bio, avatar_url, role, created_at,\n email_verified AS \"email_verified!\",\n verification_token,\n verification_token_expires,\n reset_token,\n reset_token_expires,\n banned AS \"banned!\",\n banned_at,\n ban_reason,\n ban_length,\n unban_at,\n banned_by,\n ban_post_content\n FROM users WHERE username = ?",
"describe": {
"columns": [
{
@ -67,13 +67,48 @@
"name": "reset_token_expires",
"ordinal": 12,
"type_info": "Datetime"
},
{
"name": "banned!",
"ordinal": 13,
"type_info": "Bool"
},
{
"name": "banned_at",
"ordinal": 14,
"type_info": "Datetime"
},
{
"name": "ban_reason",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "ban_length",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "unban_at",
"ordinal": 17,
"type_info": "Datetime"
},
{
"name": "banned_by",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "ban_post_content",
"ordinal": 19,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
false,
false,
@ -85,8 +120,15 @@
true,
true,
true,
true,
false,
true,
true,
true,
true,
true,
true
]
},
"hash": "b949d7b0995654aa1b68bb5b993e3af267b206d42f0342c0ff9cfc15028a862b"
"hash": "e8dc3829a39a405465c4d13b92a5533cd8a6fc7fdc9b92c6fed993e20a3fe7d5"
}

View File

@ -0,0 +1,134 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!\", username, email, password, bio, avatar_url, role, created_at,\n email_verified AS \"email_verified!\",\n verification_token,\n verification_token_expires,\n reset_token,\n reset_token_expires,\n banned AS \"banned!\",\n banned_at,\n ban_reason,\n ban_length,\n unban_at,\n banned_by,\n ban_post_content\n FROM users WHERE verification_token = ?",
"describe": {
"columns": [
{
"name": "id!",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "username",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "password",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "bio",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "avatar_url",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "role",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "email_verified!",
"ordinal": 8,
"type_info": "Bool"
},
{
"name": "verification_token",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "verification_token_expires",
"ordinal": 10,
"type_info": "Datetime"
},
{
"name": "reset_token",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "reset_token_expires",
"ordinal": 12,
"type_info": "Datetime"
},
{
"name": "banned!",
"ordinal": 13,
"type_info": "Bool"
},
{
"name": "banned_at",
"ordinal": 14,
"type_info": "Datetime"
},
{
"name": "ban_reason",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "ban_length",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "unban_at",
"ordinal": 17,
"type_info": "Datetime"
},
{
"name": "banned_by",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "ban_post_content",
"ordinal": 19,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
true,
false,
false,
false,
true,
true,
true,
true,
false,
true,
true,
true,
true,
true,
true
]
},
"hash": "ea192066870fda12a9f26c27556d8c2651334145a8cc221a3ff83600c94c8c0e"
}

View File

@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) as 'count!' FROM posts WHERE hidden = FALSE",
"describe": {
"columns": [
{
"name": "count!",
"ordinal": 0,
"type_info": "Int"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "ee0e90b6ddb5dcc454f47b1326bf5a419eb66acfb7578fff7d019027ce565e8f"
}

16
migrations/0006_admin.sql Normal file
View File

@ -0,0 +1,16 @@
ALTER TABLE users ADD COLUMN banned BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users ADD COLUMN banned_at DATETIME;
ALTER TABLE users ADD COLUMN ban_reason TEXT;
ALTER TABLE users ADD COLUMN ban_length TEXT;
ALTER TABLE users ADD COLUMN unban_at DATETIME;
ALTER TABLE users ADD COLUMN banned_by TEXT;
ALTER TABLE users ADD COLUMN ban_post_content TEXT;
ALTER TABLE threads ADD COLUMN hidden BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE threads ADD COLUMN hidden_at DATETIME;
ALTER TABLE threads ADD COLUMN hidden_by TEXT;
ALTER TABLE posts ADD COLUMN hidden BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE posts ADD COLUMN hidden_at DATETIME;
ALTER TABLE posts ADD COLUMN hidden_by TEXT;
ALTER TABLE posts ADD COLUMN ban_message TEXT;

View File

@ -1,3 +1,7 @@
use argon2::{
Argon2,
password_hash::{PasswordHasher, SaltString, rand_core::OsRng},
};
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
pub async fn connect(database_url: &str) -> SqlitePool {
@ -32,6 +36,55 @@ pub async fn connect(database_url: &str) -> SqlitePool {
.await
.expect("failed to enable foreign keys");
seed_admin(&pool).await;
println!("database ready");
pool
}
async fn seed_admin(pool: &SqlitePool) {
let admin_username = std::env::var("SARMENTINE_ADMIN_USERNAME")
.unwrap_or_else(|_| "admin".into());
let admin_email = std::env::var("SARMENTINE_ADMIN_EMAIL")
.unwrap_or_else(|_| "admin@localhost".into());
let admin_password = std::env::var("SARMENTINE_ADMIN_PASSWORD")
.unwrap_or_else(|_| "butterbutter".into());
let existing: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM users WHERE username = ?",
)
.bind(&admin_username)
.fetch_optional(pool)
.await
.ok()
.flatten();
if let Some((id,)) = existing {
if let Err(e) = sqlx::query("UPDATE users SET role = 'admin' WHERE id = ?")
.bind(id)
.execute(pool)
.await
{
eprintln!("failed to ensure admin role for {}: {}", admin_username, e);
}
} else {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(admin_password.as_bytes(), &salt)
.expect("password hashing failed")
.to_string();
match sqlx::query(
"INSERT INTO users (username, email, password, email_verified, role) VALUES (?, ?, ?, TRUE, 'admin')",
)
.bind(&admin_username)
.bind(&admin_email)
.bind(&hash)
.execute(pool)
.await
{
Ok(_) => eprintln!("seeded admin account: {}", admin_username),
Err(e) => eprintln!("failed to seed admin account: {}", e),
}
}
}

386
src/handlers/admin.rs Normal file
View File

@ -0,0 +1,386 @@
use axum::{
extract::{Form, Path, State},
response::{Html, IntoResponse, Redirect, Response},
};
use askama::Template;
use chrono::NaiveDateTime;
use serde::Deserialize;
use tower_sessions::Session;
use crate::AppState;
use crate::models::{post::Post, thread::Thread, user::User};
fn render<T: Template>(t: T) -> Response {
match t.render() {
Ok(html) => Html(html).into_response(),
Err(_) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"render error",
)
.into_response(),
}
}
// ~~ Data wrappers
pub struct AdminUserRow {
pub id: i64,
pub username: String,
pub email: String,
pub role: String,
pub email_verified: bool,
pub banned: bool,
}
pub struct AdminThreadRow {
pub id: i64,
pub title: String,
pub author_username: String,
pub created_at: NaiveDateTime,
pub hidden: bool,
}
// ~~ Form types
#[derive(Deserialize)]
pub struct BanForm {
pub reason: String,
pub ban_length: String,
pub unban_at: Option<String>,
pub nuke: Option<String>,
}
// ~~ Templates
#[derive(Template)]
#[template(path = "admin/dashboard.html")]
pub struct DashboardTemplate {
pub title: String,
pub current_user: Option<crate::CurrentUser>,
pub csrf_token: String,
pub user_count: i64,
pub thread_count: i64,
pub post_count: i64,
pub hidden_thread_count: i64,
pub banned_user_count: i64,
}
#[derive(Template)]
#[template(path = "admin/users.html")]
pub struct UsersTemplate {
pub title: String,
pub current_user: Option<crate::CurrentUser>,
pub csrf_token: String,
pub users: Vec<AdminUserRow>,
}
#[derive(Template)]
#[template(path = "admin/ban_form.html")]
pub struct BanFormTemplate {
pub title: String,
pub current_user: Option<crate::CurrentUser>,
pub csrf_token: String,
pub user_id: i64,
pub username: String,
pub error: Option<String>,
}
#[derive(Template)]
#[template(path = "admin/threads.html")]
pub struct ThreadsTemplate {
pub title: String,
pub current_user: Option<crate::CurrentUser>,
pub csrf_token: String,
pub threads: Vec<AdminThreadRow>,
}
#[derive(Template)]
#[template(path = "admin/banned.html")]
pub struct BannedTemplate {
pub title: String,
pub current_user: Option<crate::CurrentUser>,
pub csrf_token: String,
pub banned_at: String,
pub ban_reason: Option<String>,
pub ban_length: String,
pub unban_at: String,
pub ban_post_content: String,
}
// ~~ Helpers
async fn get_csrf(session: &Session) -> String {
session
.get("csrf_token")
.await
.ok()
.flatten()
.unwrap_or_default()
}
async fn require_admin(session: &Session, pool: &sqlx::SqlitePool) -> Option<User> {
let user_id: Option<i64> = session.get("user_id").await.ok().flatten();
let id = user_id?;
let user = User::find_by_id(pool, id).await.ok()??;
if user.role == "admin" && !user.banned {
Some(user)
} else {
None
}
}
fn admin_redirect() -> Redirect {
Redirect::to("/")
}
// ~~ Handlers
pub async fn dashboard(State(state): State<AppState>, session: Session) -> Response {
let admin = match require_admin(&session, &state.pool).await {
Some(u) => u,
None => return admin_redirect().into_response(),
};
let current_user = Some(crate::CurrentUser {
username: admin.username,
email_verified: admin.email_verified,
role: admin.role,
});
let csrf_token = get_csrf(&session).await;
let user_count = User::count_all(&state.pool).await.unwrap_or(0);
let banned_user_count = User::list_all(&state.pool)
.await
.map(|users| users.iter().filter(|u| u.banned).count() as i64)
.unwrap_or(0);
let thread_count = Thread::count_all(&state.pool).await.unwrap_or(0);
let hidden_thread_count = Thread::list_all_admin(&state.pool)
.await
.map(|threads| threads.iter().filter(|t| t.hidden).count() as i64)
.unwrap_or(0);
let post_count = Post::count_all(&state.pool).await.unwrap_or(0);
render(DashboardTemplate {
title: "admin dashboard".into(),
current_user,
csrf_token,
user_count,
thread_count,
post_count,
hidden_thread_count,
banned_user_count,
})
}
pub async fn list_users(State(state): State<AppState>, session: Session) -> Response {
let admin = match require_admin(&session, &state.pool).await {
Some(u) => u,
None => return admin_redirect().into_response(),
};
let current_user = Some(crate::CurrentUser {
username: admin.username,
email_verified: admin.email_verified,
role: admin.role,
});
let csrf_token = get_csrf(&session).await;
let users = User::list_all(&state.pool)
.await
.unwrap_or_default()
.into_iter()
.map(|u| AdminUserRow {
id: u.id,
username: u.username,
email: u.email,
role: u.role,
email_verified: u.email_verified,
banned: u.banned,
})
.collect();
render(UsersTemplate {
title: "admin :: users".into(),
current_user,
csrf_token,
users,
})
}
pub async fn ban_form(Path(id): Path<i64>, State(state): State<AppState>, session: Session) -> Response {
let admin = match require_admin(&session, &state.pool).await {
Some(u) => u,
None => return admin_redirect().into_response(),
};
let current_user = Some(crate::CurrentUser {
username: admin.username,
email_verified: admin.email_verified,
role: admin.role,
});
let csrf_token = get_csrf(&session).await;
let target = match User::find_by_id(&state.pool, id).await.ok().flatten() {
Some(u) => u,
None => return Redirect::to("/admin/users").into_response(),
};
render(BanFormTemplate {
title: format!("ban {}", target.username),
current_user,
csrf_token,
user_id: id,
username: target.username,
error: None,
})
}
pub async fn ban_submit(
Path(id): Path<i64>,
State(state): State<AppState>,
session: Session,
Form(form): Form<BanForm>,
) -> Response {
let admin = match require_admin(&session, &state.pool).await {
Some(u) => u,
None => return Redirect::to("/auth/login").into_response(),
};
let unban_at = if let Some(ref s) = form.unban_at {
if !s.is_empty() {
NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S").ok()
} else {
None
}
} else {
None
};
let reason = if form.reason.is_empty() {
None
} else {
Some(form.reason.as_str())
};
let length = if form.ban_length.is_empty() {
None
} else {
Some(form.ban_length.as_str())
};
if let Err(e) = User::ban(
&state.pool,
id,
&admin.username,
reason,
length,
unban_at.as_ref(),
None,
).await {
eprintln!("failed to ban user {}: {}", id, e);
}
if form.nuke.as_deref() == Some("on") {
let ban_msg = form.reason.clone();
if let Err(e) = User::nuke_content(&state.pool, id, &admin.username, &ban_msg).await {
eprintln!("failed to nuke content for user {}: {}", id, e);
}
}
Redirect::to("/admin/users").into_response()
}
pub async fn unban_user(Path(id): Path<i64>, State(state): State<AppState>) -> Response {
if let Err(e) = User::unban(&state.pool, id).await {
eprintln!("failed to unban user {}: {}", id, e);
}
Redirect::to("/admin/users").into_response()
}
pub async fn toggle_role(Path(id): Path<i64>, State(state): State<AppState>) -> Response {
let user = match User::find_by_id(&state.pool, id).await.ok().flatten() {
Some(u) => u,
None => return Redirect::to("/admin/users").into_response(),
};
let new_role = if user.role == "admin" { "member" } else { "admin" };
if let Err(e) = User::set_role(&state.pool, id, new_role).await {
eprintln!("failed to set role for user {}: {}", id, e);
}
Redirect::to("/admin/users").into_response()
}
pub async fn list_threads_admin(State(state): State<AppState>, session: Session) -> Response {
let admin = match require_admin(&session, &state.pool).await {
Some(u) => u,
None => return admin_redirect().into_response(),
};
let current_user = Some(crate::CurrentUser {
username: admin.username,
email_verified: admin.email_verified,
role: admin.role,
});
let csrf_token = get_csrf(&session).await;
let threads = Thread::list_all_admin(&state.pool).await.unwrap_or_default();
let mut rows = Vec::new();
for t in threads {
let author = User::find_by_id(&state.pool, t.author_id)
.await
.ok()
.flatten()
.map(|u| u.username)
.unwrap_or_else(|| "unknown".into());
rows.push(AdminThreadRow {
id: t.id,
title: t.title,
author_username: author,
created_at: t.created_at,
hidden: t.hidden,
});
}
render(ThreadsTemplate {
title: "admin :: threads".into(),
current_user,
csrf_token,
threads: rows,
})
}
pub async fn hide_thread(Path(id): Path<i64>, State(state): State<AppState>, session: Session) -> Response {
let admin = match require_admin(&session, &state.pool).await {
Some(u) => u,
None => return Redirect::to("/auth/login").into_response(),
};
if let Err(e) = Thread::hide(&state.pool, id, &admin.username).await {
eprintln!("failed to hide thread {}: {}", id, e);
}
Redirect::to("/admin/threads").into_response()
}
pub async fn unhide_thread(Path(id): Path<i64>, State(state): State<AppState>) -> Response {
if let Err(e) = Thread::unhide(&state.pool, id).await {
eprintln!("failed to unhide thread {}: {}", id, e);
}
Redirect::to("/admin/threads").into_response()
}
pub async fn perma_delete_thread(
Path(id): Path<i64>,
State(state): State<AppState>,
session: Session,
) -> Response {
let admin = match require_admin(&session, &state.pool).await {
Some(u) => u,
None => return Redirect::to("/auth/login").into_response(),
};
if admin.username != "butter" {
return Redirect::to("/admin/threads").into_response();
}
if let Err(e) = Thread::permanent_delete(&state.pool, id).await {
eprintln!("failed to perma-delete thread {}: {}", id, e);
}
Redirect::to("/admin/threads").into_response()
}

View File

@ -12,6 +12,7 @@ use std::net::SocketAddr;
use tower_sessions::Session;
use crate::AppState;
use crate::handlers::admin::BannedTemplate;
use crate::middleware::generate_token;
use crate::models::user::User;
@ -245,6 +246,28 @@ pub async fn login_submit(
None => return fail(),
};
if user.banned {
let now = chrono::Utc::now().naive_utc();
if let Some(unban) = user.unban_at && now >= unban {
let _ = User::unban(&state.pool, user.id).await;
} else {
let banned_at = user.banned_at.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()).unwrap_or_else(|| "unknown".into());
let ban_length = user.ban_length.unwrap_or_else(|| "permanent".into());
let unban_at = user.unban_at.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()).unwrap_or_else(|| "never".into());
let ban_post_content = user.ban_post_content.unwrap_or_default();
return render(BannedTemplate {
title: "account banned".into(),
current_user: None,
csrf_token: csrf_token.clone(),
banned_at,
ban_reason: user.ban_reason,
ban_length,
unban_at,
ban_post_content,
});
}
}
let parsed_hash = PasswordHash::new(&user.password).expect("invalid stored hash");
if Argon2::default()
.verify_password(form.password.as_bytes(), &parsed_hash)

View File

@ -1,3 +1,4 @@
pub mod admin;
pub mod auth;
pub mod filters;
pub mod thread;

View File

@ -99,6 +99,7 @@ pub async fn list_threads(State(state): State<AppState>, session: Session) -> Re
let current_user = user.map(|u| CurrentUser {
username: u.username,
email_verified: u.email_verified,
role: u.role,
});
let threads = Thread::list_all(&state.pool).await.unwrap_or_default();
@ -136,6 +137,7 @@ pub async fn new_thread_page(session: Session, State(state): State<AppState>) ->
let current_user = Some(CurrentUser {
username: user.username,
email_verified: user.email_verified,
role: user.role,
});
render(NewThreadTemplate {
@ -163,6 +165,7 @@ pub async fn create_thread(
let current_user = user.clone().map(|u| CurrentUser {
username: u.username,
email_verified: u.email_verified,
role: u.role,
});
let user = match user {
@ -204,6 +207,7 @@ pub async fn view_thread(
let current_user = user.map(|u| CurrentUser {
username: u.username,
email_verified: u.email_verified,
role: u.role,
});
let thread = match Thread::find_by_id(&state.pool, id).await.ok().flatten() {
@ -226,7 +230,7 @@ pub async fn view_thread(
}
};
let posts = Post::by_thread(&state.pool, id).await.unwrap_or_default();
let posts = Post::by_thread_all(&state.pool, id).await.unwrap_or_default();
let mut posts_with_authors = Vec::new();
for post in posts {
@ -272,6 +276,7 @@ pub async fn create_post(
let current_user = user.clone().map(|u| CurrentUser {
username: u.username,
email_verified: u.email_verified,
role: u.role,
});
let user = match user {
@ -298,9 +303,16 @@ pub async fn create_post(
verification_token_expires: None,
reset_token: None,
reset_token_expires: None,
banned: false,
banned_at: None,
ban_reason: None,
ban_length: None,
unban_at: None,
banned_by: None,
ban_post_content: None,
});
let posts = Post::by_thread(&state.pool, id).await.unwrap_or_default();
let posts = Post::by_thread_all(&state.pool, id).await.unwrap_or_default();
let mut posts_with_authors = Vec::new();
for post in posts {
let author = User::find_by_id(&state.pool, post.author_id)
@ -341,9 +353,16 @@ pub async fn create_post(
verification_token_expires: None,
reset_token: None,
reset_token_expires: None,
banned: false,
banned_at: None,
ban_reason: None,
ban_length: None,
unban_at: None,
banned_by: None,
ban_post_content: None,
});
let posts = Post::by_thread(&state.pool, id).await.unwrap_or_default();
let posts = Post::by_thread_all(&state.pool, id).await.unwrap_or_default();
let mut posts_with_authors = Vec::new();
for post in posts {
let author = User::find_by_id(&state.pool, post.author_id)
@ -381,6 +400,7 @@ pub async fn view_profile(
let current_user = user.map(|u| CurrentUser {
username: u.username,
email_verified: u.email_verified,
role: u.role,
});
let profile_user = match User::find_by_username(&state.pool, &username)

View File

@ -18,6 +18,7 @@ use tower_http::services::ServeDir;
use tower_sessions::{SessionManagerLayer, cookie::SameSite};
use tower_sessions_sqlx_store::SqliteStore;
use handlers::admin::{ban_form, ban_submit, dashboard, hide_thread, list_threads_admin, list_users, perma_delete_thread, toggle_role, unban_user, unhide_thread};
use handlers::auth::{
forgot_password_page, forgot_password_submit, get_current_user, login_page, login_submit,
logout, register_page, register_submit, resend_verification, reset_password_page,
@ -30,6 +31,7 @@ use handlers::thread::{
pub struct CurrentUser {
pub username: String,
pub email_verified: bool,
pub role: String,
}
#[derive(Template)]
@ -45,6 +47,7 @@ async fn index(State(state): State<AppState>, session: tower_sessions::Session)
let current_user = user.map(|u| CurrentUser {
username: u.username,
email_verified: u.email_verified,
role: u.role,
});
let csrf_token = session
@ -147,9 +150,26 @@ async fn main() {
.route("/auth/reset", post(reset_password_submit))
.nest_service("/static", ServeDir::new(&static_dir))
.layer(from_fn(csrf_middleware))
.layer(session_layer.clone())
.with_state(app_state.clone());
let admin_routes = Router::new()
.route("/admin", get(dashboard))
.route("/admin/users", get(list_users))
.route("/admin/users/:id/ban", get(ban_form))
.route("/admin/users/:id/ban", post(ban_submit))
.route("/admin/users/:id/unban", post(unban_user))
.route("/admin/users/:id/toggle-role", post(toggle_role))
.route("/admin/threads", get(list_threads_admin))
.route("/admin/threads/:id/hide", post(hide_thread))
.route("/admin/threads/:id/unhide", post(unhide_thread))
.route("/admin/threads/:id/delete", post(perma_delete_thread))
.layer(from_fn(csrf_middleware))
.layer(session_layer)
.with_state(app_state);
let app = app.merge(admin_routes);
let listener = tokio::net::TcpListener::bind(&bind_addr).await.unwrap();
println!("listening on http://{}", bind_addr);

View File

@ -126,25 +126,25 @@ pub fn generate_token() -> String {
base64_url_encode(&bytes)
}
fn base64_url_encode(input: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
input
.chunks(3)
.map(|chunk| {
let mut out = String::new();
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
let combined = (b0 << 16) | (b1 << 8) | b2;
out.push(CHARS[((combined >> 18) & 0x3F) as usize] as char);
out.push(CHARS[((combined >> 12) & 0x3F) as usize] as char);
if chunk.len() > 1 {
out.push(CHARS[((combined >> 6) & 0x3F) as usize] as char);
}
if chunk.len() > 2 {
out.push(CHARS[(combined & 0x3F) as usize] as char);
}
out
})
.collect()
}
fn base64_url_encode(input: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
input
.chunks(3)
.map(|chunk| {
let mut out = String::new();
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
let combined = (b0 << 16) | (b1 << 8) | b2;
out.push(CHARS[((combined >> 18) & 0x3F) as usize] as char);
out.push(CHARS[((combined >> 12) & 0x3F) as usize] as char);
if chunk.len() > 1 {
out.push(CHARS[((combined >> 6) & 0x3F) as usize] as char);
}
if chunk.len() > 2 {
out.push(CHARS[(combined & 0x3F) as usize] as char);
}
out
})
.collect()
}

View File

@ -10,13 +10,30 @@ pub struct Post {
pub thread_id: i64,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub hidden: bool,
pub hidden_at: Option<NaiveDateTime>,
pub hidden_by: Option<String>,
pub ban_message: Option<String>,
}
impl Post {
pub async fn by_thread(pool: &SqlitePool, thread_id: i64) -> sqlx::Result<Vec<Post>> {
sqlx::query_as!(
Post,
r#"SELECT id, body, author_id, thread_id, created_at, updated_at
r#"SELECT id, body, author_id, thread_id, created_at, updated_at,
hidden AS "hidden!", hidden_at, hidden_by, ban_message
FROM posts WHERE thread_id = ? AND hidden = FALSE ORDER BY created_at ASC"#,
thread_id
)
.fetch_all(pool)
.await
}
pub async fn by_thread_all(pool: &SqlitePool, thread_id: i64) -> sqlx::Result<Vec<Post>> {
sqlx::query_as!(
Post,
r#"SELECT id, body, author_id, thread_id, created_at, updated_at,
hidden AS "hidden!", hidden_at, hidden_by, ban_message
FROM posts WHERE thread_id = ? ORDER BY created_at ASC"#,
thread_id
)
@ -39,4 +56,44 @@ impl Post {
Ok(result.last_insert_rowid())
}
pub async fn hide(pool: &SqlitePool, id: i64, hidden_by: &str, ban_message: Option<&str>) -> sqlx::Result<()> {
sqlx::query(
"UPDATE posts SET hidden = TRUE, hidden_at = CURRENT_TIMESTAMP, hidden_by = ?, ban_message = ? WHERE id = ?",
)
.bind(hidden_by)
.bind(ban_message)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn unhide(pool: &SqlitePool, id: i64) -> sqlx::Result<()> {
sqlx::query(
"UPDATE posts SET hidden = FALSE, hidden_at = NULL, hidden_by = NULL, ban_message = NULL WHERE id = ?",
)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn permanent_delete(pool: &SqlitePool, id: i64) -> sqlx::Result<()> {
sqlx::query("DELETE FROM posts WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn count_all(pool: &SqlitePool) -> sqlx::Result<i64> {
let count: i32 = sqlx::query_scalar!("SELECT COUNT(*) as 'count!' FROM posts WHERE hidden = FALSE")
.fetch_one(pool)
.await?;
Ok(count as i64)
}
}

View File

@ -10,13 +10,28 @@ pub struct Thread {
pub author_id: i64,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub hidden: bool,
pub hidden_at: Option<NaiveDateTime>,
pub hidden_by: Option<String>,
}
impl Thread {
pub async fn list_all(pool: &SqlitePool) -> sqlx::Result<Vec<Thread>> {
sqlx::query_as!(
Thread,
r#"SELECT id, title, body, author_id, created_at, updated_at
r#"SELECT id, title, body, author_id, created_at, updated_at,
hidden AS "hidden!", hidden_at, hidden_by
FROM threads WHERE hidden = FALSE ORDER BY updated_at DESC"#
)
.fetch_all(pool)
.await
}
pub async fn list_all_admin(pool: &SqlitePool) -> sqlx::Result<Vec<Thread>> {
sqlx::query_as!(
Thread,
r#"SELECT id, title, body, author_id, created_at, updated_at,
hidden AS "hidden!", hidden_at, hidden_by
FROM threads ORDER BY updated_at DESC"#
)
.fetch_all(pool)
@ -26,7 +41,20 @@ impl Thread {
pub async fn find_by_id(pool: &SqlitePool, id: i64) -> sqlx::Result<Option<Thread>> {
sqlx::query_as!(
Thread,
r#"SELECT id, title, body, author_id, created_at, updated_at
r#"SELECT id, title, body, author_id, created_at, updated_at,
hidden AS "hidden!", hidden_at, hidden_by
FROM threads WHERE id = ? AND hidden = FALSE"#,
id
)
.fetch_optional(pool)
.await
}
pub async fn find_by_id_admin(pool: &SqlitePool, id: i64) -> sqlx::Result<Option<Thread>> {
sqlx::query_as!(
Thread,
r#"SELECT id, title, body, author_id, created_at, updated_at,
hidden AS "hidden!", hidden_at, hidden_by
FROM threads WHERE id = ?"#,
id
)
@ -37,8 +65,9 @@ impl Thread {
pub async fn by_author(pool: &SqlitePool, author_id: i64) -> sqlx::Result<Vec<Thread>> {
sqlx::query_as!(
Thread,
r#"SELECT id, title, body, author_id, created_at, updated_at
FROM threads WHERE author_id = ? ORDER BY updated_at DESC"#,
r#"SELECT id, title, body, author_id, created_at, updated_at,
hidden AS "hidden!", hidden_at, hidden_by
FROM threads WHERE author_id = ? AND hidden = FALSE ORDER BY updated_at DESC"#,
author_id
)
.fetch_all(pool)
@ -60,4 +89,43 @@ impl Thread {
Ok(result.last_insert_rowid())
}
pub async fn hide(pool: &SqlitePool, id: i64, hidden_by: &str) -> sqlx::Result<()> {
sqlx::query(
"UPDATE threads SET hidden = TRUE, hidden_at = CURRENT_TIMESTAMP, hidden_by = ? WHERE id = ?",
)
.bind(hidden_by)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn unhide(pool: &SqlitePool, id: i64) -> sqlx::Result<()> {
sqlx::query(
"UPDATE threads SET hidden = FALSE, hidden_at = NULL, hidden_by = NULL WHERE id = ?",
)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn permanent_delete(pool: &SqlitePool, id: i64) -> sqlx::Result<()> {
sqlx::query("DELETE FROM threads WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn count_all(pool: &SqlitePool) -> sqlx::Result<i64> {
let count: i32 = sqlx::query_scalar!("SELECT COUNT(*) as 'count!' FROM threads WHERE hidden = FALSE")
.fetch_one(pool)
.await?;
Ok(count as i64)
}
}

View File

@ -18,6 +18,13 @@ pub struct User {
pub verification_token_expires: Option<NaiveDateTime>,
pub reset_token: Option<String>,
pub reset_token_expires: Option<NaiveDateTime>,
pub banned: bool,
pub banned_at: Option<NaiveDateTime>,
pub ban_reason: Option<String>,
pub ban_length: Option<String>,
pub unban_at: Option<NaiveDateTime>,
pub banned_by: Option<String>,
pub ban_post_content: Option<String>,
}
impl User {
@ -25,12 +32,19 @@ impl User {
sqlx::query_as!(
User,
r#"SELECT id AS "id!", username, email, password, bio, avatar_url, role, created_at,
email_verified AS "email_verified!",
verification_token,
verification_token_expires,
reset_token,
reset_token_expires
FROM users WHERE id = ?"#,
email_verified AS "email_verified!",
verification_token,
verification_token_expires,
reset_token,
reset_token_expires,
banned AS "banned!",
banned_at,
ban_reason,
ban_length,
unban_at,
banned_by,
ban_post_content
FROM users WHERE id = ?"#,
id
)
.fetch_optional(pool)
@ -41,12 +55,19 @@ impl User {
sqlx::query_as!(
User,
r#"SELECT id AS "id!", username, email, password, bio, avatar_url, role, created_at,
email_verified AS "email_verified!",
verification_token,
verification_token_expires,
reset_token,
reset_token_expires
FROM users WHERE username = ?"#,
email_verified AS "email_verified!",
verification_token,
verification_token_expires,
reset_token,
reset_token_expires,
banned AS "banned!",
banned_at,
ban_reason,
ban_length,
unban_at,
banned_by,
ban_post_content
FROM users WHERE username = ?"#,
username
)
.fetch_optional(pool)
@ -57,12 +78,19 @@ impl User {
sqlx::query_as!(
User,
r#"SELECT id AS "id!", username, email, password, bio, avatar_url, role, created_at,
email_verified AS "email_verified!",
verification_token,
verification_token_expires,
reset_token,
reset_token_expires
FROM users WHERE email = ?"#,
email_verified AS "email_verified!",
verification_token,
verification_token_expires,
reset_token,
reset_token_expires,
banned AS "banned!",
banned_at,
ban_reason,
ban_length,
unban_at,
banned_by,
ban_post_content
FROM users WHERE email = ?"#,
email
)
.fetch_optional(pool)
@ -76,12 +104,19 @@ impl User {
sqlx::query_as!(
User,
r#"SELECT id AS "id!", username, email, password, bio, avatar_url, role, created_at,
email_verified AS "email_verified!",
verification_token,
verification_token_expires,
reset_token,
reset_token_expires
FROM users WHERE verification_token = ?"#,
email_verified AS "email_verified!",
verification_token,
verification_token_expires,
reset_token,
reset_token_expires,
banned AS "banned!",
banned_at,
ban_reason,
ban_length,
unban_at,
banned_by,
ban_post_content
FROM users WHERE verification_token = ?"#,
token
)
.fetch_optional(pool)
@ -92,18 +127,47 @@ impl User {
sqlx::query_as!(
User,
r#"SELECT id AS "id!", username, email, password, bio, avatar_url, role, created_at,
email_verified AS "email_verified!",
verification_token,
verification_token_expires,
reset_token,
reset_token_expires
FROM users WHERE reset_token = ?"#,
email_verified AS "email_verified!",
verification_token,
verification_token_expires,
reset_token,
reset_token_expires,
banned AS "banned!",
banned_at,
ban_reason,
ban_length,
unban_at,
banned_by,
ban_post_content
FROM users WHERE reset_token = ?"#,
token
)
.fetch_optional(pool)
.await
}
pub async fn list_all(pool: &SqlitePool) -> sqlx::Result<Vec<User>> {
sqlx::query_as!(
User,
r#"SELECT id AS "id!", username, email, password, bio, avatar_url, role, created_at,
email_verified AS "email_verified!",
verification_token,
verification_token_expires,
reset_token,
reset_token_expires,
banned AS "banned!",
banned_at,
ban_reason,
ban_length,
unban_at,
banned_by,
ban_post_content
FROM users ORDER BY created_at DESC"#,
)
.fetch_all(pool)
.await
}
pub async fn insert(
pool: &SqlitePool,
username: &str,
@ -166,21 +230,25 @@ impl User {
token: &str,
expires: &NaiveDateTime,
) -> sqlx::Result<()> {
sqlx::query("UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?")
.bind(token)
.bind(expires)
.bind(user_id)
.execute(pool)
.await?;
sqlx::query(
"UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?",
)
.bind(token)
.bind(expires)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn clear_reset_token(pool: &SqlitePool, user_id: i64) -> sqlx::Result<()> {
sqlx::query("UPDATE users SET reset_token = NULL, reset_token_expires = NULL WHERE id = ?")
.bind(user_id)
.execute(pool)
.await?;
sqlx::query(
"UPDATE users SET reset_token = NULL, reset_token_expires = NULL WHERE id = ?",
)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
@ -198,4 +266,86 @@ impl User {
Ok(())
}
pub async fn set_role(pool: &SqlitePool, user_id: i64, role: &str) -> sqlx::Result<()> {
sqlx::query("UPDATE users SET role = ? WHERE id = ?")
.bind(role)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn set_role_to_admin(pool: &SqlitePool, username: &str) -> sqlx::Result<()> {
sqlx::query("UPDATE users SET role = 'admin' WHERE username = ?")
.bind(username)
.execute(pool)
.await?;
Ok(())
}
pub async fn ban(
pool: &SqlitePool,
user_id: i64,
banned_by: &str,
reason: Option<&str>,
length: Option<&str>,
unban_at: Option<&NaiveDateTime>,
post_content: Option<&str>,
) -> sqlx::Result<()> {
sqlx::query(
"UPDATE users SET banned = TRUE, banned_at = CURRENT_TIMESTAMP, banned_by = ?, ban_reason = ?, ban_length = ?, unban_at = ?, ban_post_content = ? WHERE id = ?",
)
.bind(banned_by)
.bind(reason)
.bind(length)
.bind(unban_at)
.bind(post_content)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn unban(pool: &SqlitePool, user_id: i64) -> sqlx::Result<()> {
sqlx::query(
"UPDATE users SET banned = FALSE, banned_at = NULL, ban_reason = NULL, ban_length = NULL, unban_at = NULL, banned_by = NULL, ban_post_content = NULL WHERE id = ?",
)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn nuke_content(pool: &SqlitePool, user_id: i64, hidden_by: &str, ban_message: &str) -> sqlx::Result<()> {
sqlx::query(
"UPDATE threads SET hidden = TRUE, hidden_at = CURRENT_TIMESTAMP, hidden_by = ? WHERE author_id = ? AND hidden = FALSE",
)
.bind(hidden_by)
.bind(user_id)
.execute(pool)
.await?;
sqlx::query(
"UPDATE posts SET hidden = TRUE, hidden_at = CURRENT_TIMESTAMP, hidden_by = ?, ban_message = ? WHERE author_id = ? AND hidden = FALSE",
)
.bind(hidden_by)
.bind(ban_message)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn count_all(pool: &SqlitePool) -> sqlx::Result<i64> {
let count: i32 = sqlx::query_scalar!("SELECT COUNT(*) as 'count!' FROM users")
.fetch_one(pool)
.await?;
Ok(count as i64)
}
}

BIN
static/meeple.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block content %}
<div style="font-family:'Silkscreen',monospace; font-size:14px; color:#d4879c; margin-bottom:16px; text-transform:uppercase;">
<a href="/admin" style="color:#888;">admin</a> :: <a href="/admin/users" style="color:#888;">users</a> :: ban {{ username }}
</div>
{% if let Some(err) = error %}
<div class="post" style="border-color:#ff6b6b;">
<div class="post-body" style="color:#ff6b6b;">{{ err }}</div>
</div>
{% endif %}
<div class="post">
<form method="post" action="/admin/users/{{ user_id }}/ban">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="margin-bottom:12px;">
<label style="display:block; color:#888; font-size:11px; margin-bottom:4px; text-transform:uppercase;">reason</label>
<input type="text" name="reason" style="width:100%; padding:6px; background:#1a1a2e; border:1px solid #333; color:#e0e0e0; font-family:'VT323',monospace; font-size:14px;" placeholder="leave empty for no reason">
</div>
<div style="margin-bottom:12px;">
<label style="display:block; color:#888; font-size:11px; margin-bottom:4px; text-transform:uppercase;">ban length</label>
<select name="ban_length" style="padding:6px; background:#1a1a2e; border:1px solid #333; color:#e0e0e0; font-family:'VT323',monospace; font-size:14px;">
<option value="permanent">permanent</option>
<option value="temporary">temporary</option>
</select>
</div>
<div style="margin-bottom:12px;">
<label style="display:block; color:#888; font-size:11px; margin-bottom:4px; text-transform:uppercase;">unban at (optional, for temporary bans)</label>
<input type="datetime-local" name="unban_at" style="padding:6px; background:#1a1a2e; border:1px solid #333; color:#e0e0e0; font-family:'VT323',monospace; font-size:14px;">
</div>
<div style="margin-bottom:16px;">
<label style="color:#ff6b6b; font-size:11px; text-transform:uppercase;">
<input type="checkbox" name="nuke" value="on"> nuke all user content (hide threads and posts)
</label>
</div>
<button type="submit" class="btn" style="background:#ff6b6b;">ban user</button>
<a href="/admin/users" class="btn" style="margin-left:8px;">cancel</a>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block content %}
<div style="font-family:'Silkscreen',monospace; font-size:14px; color:#ff6b6b; margin-bottom:16px; text-transform:uppercase;">
account banned
</div>
<div class="post" style="border-color:#ff6b6b;">
<div style="color:#ff6b6b; font-size:16px; margin-bottom:12px;">
your account was b& on {{ banned_at }}
</div>
{% if !ban_post_content.is_empty() %}
<div style="margin-bottom:12px;">
<div style="color:#888; font-size:10px; text-transform:uppercase; margin-bottom:4px;">the post that got you b&</div>
<div style="color:#d4879c; font-size:12px; padding:8px; background:#1a1a2e; border:1px solid #333;">{{ ban_post_content }}</div>
</div>
{% endif %}
<div style="margin-bottom:12px;">
<div style="color:#888; font-size:10px; text-transform:uppercase; margin-bottom:4px;">reason</div>
{% if let Some(reason) = ban_reason %}
<div style="color:#e0e0e0; font-size:12px;">{{ reason }}</div>
{% else %}
<div style="color:#888; font-size:12px;">admin didn't care enough to give a reason</div>
{% endif %}
</div>
<div style="margin-bottom:12px;">
<div style="color:#888; font-size:10px; text-transform:uppercase; margin-bottom:4px;">ban length</div>
<div style="color:#e0e0e0; font-size:12px;">{{ ban_length }}</div>
</div>
{% if unban_at != "never" %}
<div style="margin-bottom:12px;">
<div style="color:#888; font-size:10px; text-transform:uppercase; margin-bottom:4px;">unban at</div>
<div style="color:#4ade80; font-size:12px;">{{ unban_at }}</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block content %}
<div style="font-family:'Silkscreen',monospace; font-size:14px; color:#d4879c; margin-bottom:16px; text-transform:uppercase;">
admin dashboard
</div>
<div class="post">
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:16px;">
<div style="text-align:center;">
<div style="font-size:28px; color:#e0e0e0;">{{ user_count }}</div>
<div style="color:#888; font-size:10px; text-transform:uppercase;">users</div>
</div>
<div style="text-align:center;">
<div style="font-size:28px; color:#e0e0e0;">{{ thread_count }}</div>
<div style="color:#888; font-size:10px; text-transform:uppercase;">threads</div>
</div>
<div style="text-align:center;">
<div style="font-size:28px; color:#e0e0e0;">{{ post_count }}</div>
<div style="color:#888; font-size:10px; text-transform:uppercase;">posts</div>
</div>
</div>
</div>
<div class="post" style="margin-top:16px;">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
<div style="text-align:center;">
<div style="font-size:28px; color:#ff6b6b;">{{ hidden_thread_count }}</div>
<div style="color:#888; font-size:10px; text-transform:uppercase;">hidden threads</div>
</div>
<div style="text-align:center;">
<div style="font-size:28px; color:#ff6b6b;">{{ banned_user_count }}</div>
<div style="color:#888; font-size:10px; text-transform:uppercase;">banned users</div>
</div>
</div>
</div>
<div style="margin-top:20px; display:flex; gap:12px;">
<a href="/admin/users" class="btn">manage users</a>
<a href="/admin/threads" class="btn">manage threads</a>
</div>
{% endblock %}

View File

@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block content %}
<div style="font-family:'Silkscreen',monospace; font-size:14px; color:#d4879c; margin-bottom:16px; text-transform:uppercase;">
<a href="/admin" style="color:#888;">admin</a> :: threads
</div>
{% for t in threads %}
<div class="post" {% if t.hidden %}style="border-color:#ff6b6b;"{% endif %}>
<div style="display:flex; justify-content:space-between; align-items:center;">
<div>
{% if t.hidden %}
<span style="color:#ff6b6b; font-size:10px;">[hidden]</span>
{% endif %}
<span style="color:#e0e0e0;">{{ t.title }}</span>
<span style="color:#888; font-size:10px; margin-left:8px;">by {{ t.author_username }}</span>
<span style="color:#888; font-size:10px; margin-left:8px;">{{ t.created_at }}</span>
</div>
<div style="display:flex; gap:8px;">
{% if !t.hidden %}
<form method="post" action="/admin/threads/{{ t.id }}/hide" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-sm">hide</button>
</form>
{% else %}
<form method="post" action="/admin/threads/{{ t.id }}/unhide" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-sm">unhide</button>
</form>
{% endif %}
<form method="post" action="/admin/threads/{{ t.id }}/delete" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-sm" style="background:#ff0000;">delete</button>
</form>
</div>
</div>
</div>
{% endfor %}
{% if threads.is_empty() %}
<div style="color:#888;">no threads found</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block content %}
<div style="font-family:'Silkscreen',monospace; font-size:14px; color:#d4879c; margin-bottom:16px; text-transform:uppercase;">
<a href="/admin" style="color:#888;">admin</a> :: users
</div>
{% for u in users %}
<div class="post" {% if u.banned %}style="border-color:#ff6b6b;"{% endif %}>
<div style="display:flex; justify-content:space-between; align-items:center;">
<div>
<a href="/profile/{{ u.username }}" style="color:#e0e0e0;">{{ u.username }}</a>
<span style="color:#888; font-size:10px; margin-left:8px;">{{ u.email }}</span>
{% if u.role == "admin" %}
<span style="color:#ffd700; font-size:10px; margin-left:8px;">[admin]</span>
{% endif %}
{% if u.banned %}
<span style="color:#ff6b6b; font-size:10px; margin-left:8px;">[banned]</span>
{% endif %}
{% if !u.email_verified %}
<span style="color:#888; font-size:10px; margin-left:8px;">[unverified]</span>
{% endif %}
</div>
<div style="display:flex; gap:8px;">
{% if !u.banned %}
<a href="/admin/users/{{ u.id }}/ban" class="btn btn-sm">ban</a>
{% else %}
<form method="post" action="/admin/users/{{ u.id }}/unban" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-sm">unban</button>
</form>
{% endif %}
<form method="post" action="/admin/users/{{ u.id }}/toggle-role" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-sm">{% if u.role == "admin" %}demote{% else %}promote{% endif %}</button>
</form>
</div>
</div>
</div>
{% endfor %}
{% if users.is_empty() %}
<div style="color:#888;">no users found</div>
{% endif %}
{% endblock %}

View File

@ -17,6 +17,9 @@
<a href="/threads">threads</a>
<div class="nav-right">
{% if let Some(user) = current_user %}
{% if user.role == "admin" %}
<a href="/admin" style="color:#ffd700;">admin</a>
{% endif %}
<a href="/profile/{{ user.username }}">{{ user.username }}</a>
<form method="post" action="/auth/logout" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">

View File

@ -21,8 +21,17 @@
<div style="font-family:'Silkscreen',monospace; font-size:11px; color:#888; margin:16px 0 8px; text-transform:uppercase;">replies</div>
{% for pwa in posts %}
<div class="post">
{% if pwa.post.hidden %}
<div style="color:#ff6b6b; font-size:12px;">[hidden]</div>
{% if let Some(msg) = pwa.post.ban_message %}
<div style="color:#ff6b6b; font-size:10px;">REASON: {{ msg }}</div>
{% else %}
<div style="color:#ff6b6b; font-size:10px;">REASON: admin didn't care enough to give a reason</div>
{% endif %}
{% else %}
<div class="post-body" style="color:#888; font-size:11px; margin-bottom:8px;">by <a href="/profile/{{ pwa.author.username }}">{{ pwa.author.username }}</a> &middot; {{ pwa.post.created_at }}</div>
<div class="post-body markdown-content">{{ pwa.post.body|render_markdown|safe }}</div>
{% endif %}
</div>
{% endfor %}
{% endif %}