From dc7eb57640b3189af3ba8a8b1d0ac13dc36ea980 Mon Sep 17 00:00:00 2001 From: Butter Date: Mon, 4 May 2026 18:40:01 -0400 Subject: [PATCH] added basic admin tools i can ban users now! --- ...2454d8111c884d2133090ac76c341317f4a9a.json | 92 ----- ...b988b0b5ad8756eacf65a770a733e369d048a.json | 20 + ...e475a7769a2b6d25e0a091a92393a89e55642.json | 74 ++++ ...fb802271d2d211a04aa30eff969001869a983.json | 68 +++ ...00600131a1ba03431d07885752b41fef4cbb.json} | 48 ++- ...0d601223d16e320c54fa7e42049e970e32fb.json} | 24 +- ...bf9ceb02e4e91dfccae7b16f4365fa46db709.json | 20 + ...0742cbbcad65cc910fa4165f07d376c0b2c2.json} | 46 ++- ...c602a30efc6a3c5e40e9723a56bd65db8aad.json} | 24 +- ...f548cab89f0b656abfb24f8385675eded8132.json | 134 ++++++ ...4fa217e4133b6774a4cdb1b45347c306971c.json} | 24 +- ...1b34f6c9e307c675b920fb0ccac9bba0a797.json} | 48 ++- ...c3c896432fede4d849c10d1b02813eb84428.json} | 30 +- ...9d5ef094a447b85376822a9e8052e51372087.json | 68 +++ ...533cd8a6fc7fdc9b92c6fed993e20a3fe7d5.json} | 48 ++- ...d8c2651334145a8cc221a3ff83600c94c8c0e.json | 134 ++++++ ...f5a419eb66acfb7578fff7d019027ce565e8f.json | 20 + migrations/0006_admin.sql | 16 + src/db.rs | 53 +++ src/handlers/admin.rs | 386 ++++++++++++++++++ src/handlers/auth.rs | 23 ++ src/handlers/mod.rs | 1 + src/handlers/thread.rs | 26 +- src/main.rs | 20 + src/middleware.rs | 44 +- src/models/post.rs | 59 ++- src/models/thread.rs | 76 +++- src/models/user.rs | 230 +++++++++-- static/meeple.png | Bin 0 -> 18399 bytes templates/admin/ban_form.html | 46 +++ templates/admin/banned.html | 41 ++ templates/admin/dashboard.html | 42 ++ templates/admin/threads.html | 43 ++ templates/admin/users.html | 45 ++ templates/base.html | 3 + templates/threads/view.html | 9 + 36 files changed, 1900 insertions(+), 185 deletions(-) delete mode 100644 .sqlx/query-03e12aecfba017ed94a3dcd86e82454d8111c884d2133090ac76c341317f4a9a.json create mode 100644 .sqlx/query-2a0b3b13b130ee33ac214dcd25fb988b0b5ad8756eacf65a770a733e369d048a.json create mode 100644 .sqlx/query-378ae2f1e9de949f1cac3c0f463e475a7769a2b6d25e0a091a92393a89e55642.json create mode 100644 .sqlx/query-3811471be7d9676565e0967283ffb802271d2d211a04aa30eff969001869a983.json rename .sqlx/{query-a4c396e317e9a596629e91d1deefa19df638161c6a7a87427cb1f298aaa545ff.json => query-4a08e2a61580e024d81ac54ab8cb00600131a1ba03431d07885752b41fef4cbb.json} (54%) rename .sqlx/{query-ce4cb40fa25111c00d2f4575ce0382de37f2b57bcfccc5f05b4a59ace6a60872.json => query-5ea6f4356c6d73485f869e8190580d601223d16e320c54fa7e42049e970e32fb.json} (61%) create mode 100644 .sqlx/query-5f35fdcf88f734bf92f66012bb7bf9ceb02e4e91dfccae7b16f4365fa46db709.json rename .sqlx/{query-ebb6edb02bb7b9e2f4d7acd344790f3bdcae506ad1c604dd954b6f091e4c66f2.json => query-6dca909c5e2893eededfb9e2eac10742cbbcad65cc910fa4165f07d376c0b2c2.json} (55%) rename .sqlx/{query-c40c4444269dd206d0dc0618d8a322a76e5f77c663cdbb433d7985fb5528af67.json => query-734e50b9a4ed85367e9888ea1e4ac602a30efc6a3c5e40e9723a56bd65db8aad.json} (60%) create mode 100644 .sqlx/query-77440cead9d333038d59388bc46f548cab89f0b656abfb24f8385675eded8132.json rename .sqlx/{query-6549001e2a522da8788d77d915ca372b07872cd763a48f3a035e7e5c5a70c879.json => query-9a393c4019349783b7267a7447514fa217e4133b6774a4cdb1b45347c306971c.json} (60%) rename .sqlx/{query-1fdfa7429dc20e693f147c5184e7b2299821d822590a978bed7bdf0f0f7917e3.json => query-b0fc43ae344ae0bda14d631071271b34f6c9e307c675b920fb0ccac9bba0a797.json} (54%) rename .sqlx/{query-4a1b2d9c1c66741913ff3a9ddf50d260d8bb01fb891509bf95561d72c3376f98.json => query-db743bec93d5243e994d5a85a60ec3c896432fede4d849c10d1b02813eb84428.json} (55%) create mode 100644 .sqlx/query-e3f0046e263f5a5029bdcf1d7f99d5ef094a447b85376822a9e8052e51372087.json rename .sqlx/{query-b949d7b0995654aa1b68bb5b993e3af267b206d42f0342c0ff9cfc15028a862b.json => query-e8dc3829a39a405465c4d13b92a5533cd8a6fc7fdc9b92c6fed993e20a3fe7d5.json} (54%) create mode 100644 .sqlx/query-ea192066870fda12a9f26c27556d8c2651334145a8cc221a3ff83600c94c8c0e.json create mode 100644 .sqlx/query-ee0e90b6ddb5dcc454f47b1326bf5a419eb66acfb7578fff7d019027ce565e8f.json create mode 100644 migrations/0006_admin.sql create mode 100644 src/handlers/admin.rs create mode 100644 static/meeple.png create mode 100644 templates/admin/ban_form.html create mode 100644 templates/admin/banned.html create mode 100644 templates/admin/dashboard.html create mode 100644 templates/admin/threads.html create mode 100644 templates/admin/users.html diff --git a/.sqlx/query-03e12aecfba017ed94a3dcd86e82454d8111c884d2133090ac76c341317f4a9a.json b/.sqlx/query-03e12aecfba017ed94a3dcd86e82454d8111c884d2133090ac76c341317f4a9a.json deleted file mode 100644 index 49d3a86..0000000 --- a/.sqlx/query-03e12aecfba017ed94a3dcd86e82454d8111c884d2133090ac76c341317f4a9a.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-2a0b3b13b130ee33ac214dcd25fb988b0b5ad8756eacf65a770a733e369d048a.json b/.sqlx/query-2a0b3b13b130ee33ac214dcd25fb988b0b5ad8756eacf65a770a733e369d048a.json new file mode 100644 index 0000000..41c93eb --- /dev/null +++ b/.sqlx/query-2a0b3b13b130ee33ac214dcd25fb988b0b5ad8756eacf65a770a733e369d048a.json @@ -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" +} diff --git a/.sqlx/query-378ae2f1e9de949f1cac3c0f463e475a7769a2b6d25e0a091a92393a89e55642.json b/.sqlx/query-378ae2f1e9de949f1cac3c0f463e475a7769a2b6d25e0a091a92393a89e55642.json new file mode 100644 index 0000000..50afd39 --- /dev/null +++ b/.sqlx/query-378ae2f1e9de949f1cac3c0f463e475a7769a2b6d25e0a091a92393a89e55642.json @@ -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" +} diff --git a/.sqlx/query-3811471be7d9676565e0967283ffb802271d2d211a04aa30eff969001869a983.json b/.sqlx/query-3811471be7d9676565e0967283ffb802271d2d211a04aa30eff969001869a983.json new file mode 100644 index 0000000..b3c5cbd --- /dev/null +++ b/.sqlx/query-3811471be7d9676565e0967283ffb802271d2d211a04aa30eff969001869a983.json @@ -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" +} diff --git a/.sqlx/query-a4c396e317e9a596629e91d1deefa19df638161c6a7a87427cb1f298aaa545ff.json b/.sqlx/query-4a08e2a61580e024d81ac54ab8cb00600131a1ba03431d07885752b41fef4cbb.json similarity index 54% rename from .sqlx/query-a4c396e317e9a596629e91d1deefa19df638161c6a7a87427cb1f298aaa545ff.json rename to .sqlx/query-4a08e2a61580e024d81ac54ab8cb00600131a1ba03431d07885752b41fef4cbb.json index 7c6ceb9..736d0ab 100644 --- a/.sqlx/query-a4c396e317e9a596629e91d1deefa19df638161c6a7a87427cb1f298aaa545ff.json +++ b/.sqlx/query-4a08e2a61580e024d81ac54ab8cb00600131a1ba03431d07885752b41fef4cbb.json @@ -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" } diff --git a/.sqlx/query-ce4cb40fa25111c00d2f4575ce0382de37f2b57bcfccc5f05b4a59ace6a60872.json b/.sqlx/query-5ea6f4356c6d73485f869e8190580d601223d16e320c54fa7e42049e970e32fb.json similarity index 61% rename from .sqlx/query-ce4cb40fa25111c00d2f4575ce0382de37f2b57bcfccc5f05b4a59ace6a60872.json rename to .sqlx/query-5ea6f4356c6d73485f869e8190580d601223d16e320c54fa7e42049e970e32fb.json index 4e15a08..6847d70 100644 --- a/.sqlx/query-ce4cb40fa25111c00d2f4575ce0382de37f2b57bcfccc5f05b4a59ace6a60872.json +++ b/.sqlx/query-5ea6f4356c6d73485f869e8190580d601223d16e320c54fa7e42049e970e32fb.json @@ -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" } diff --git a/.sqlx/query-5f35fdcf88f734bf92f66012bb7bf9ceb02e4e91dfccae7b16f4365fa46db709.json b/.sqlx/query-5f35fdcf88f734bf92f66012bb7bf9ceb02e4e91dfccae7b16f4365fa46db709.json new file mode 100644 index 0000000..ff8e7be --- /dev/null +++ b/.sqlx/query-5f35fdcf88f734bf92f66012bb7bf9ceb02e4e91dfccae7b16f4365fa46db709.json @@ -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" +} diff --git a/.sqlx/query-ebb6edb02bb7b9e2f4d7acd344790f3bdcae506ad1c604dd954b6f091e4c66f2.json b/.sqlx/query-6dca909c5e2893eededfb9e2eac10742cbbcad65cc910fa4165f07d376c0b2c2.json similarity index 55% rename from .sqlx/query-ebb6edb02bb7b9e2f4d7acd344790f3bdcae506ad1c604dd954b6f091e4c66f2.json rename to .sqlx/query-6dca909c5e2893eededfb9e2eac10742cbbcad65cc910fa4165f07d376c0b2c2.json index 3e07c23..7e8b72a 100644 --- a/.sqlx/query-ebb6edb02bb7b9e2f4d7acd344790f3bdcae506ad1c604dd954b6f091e4c66f2.json +++ b/.sqlx/query-6dca909c5e2893eededfb9e2eac10742cbbcad65cc910fa4165f07d376c0b2c2.json @@ -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" } diff --git a/.sqlx/query-c40c4444269dd206d0dc0618d8a322a76e5f77c663cdbb433d7985fb5528af67.json b/.sqlx/query-734e50b9a4ed85367e9888ea1e4ac602a30efc6a3c5e40e9723a56bd65db8aad.json similarity index 60% rename from .sqlx/query-c40c4444269dd206d0dc0618d8a322a76e5f77c663cdbb433d7985fb5528af67.json rename to .sqlx/query-734e50b9a4ed85367e9888ea1e4ac602a30efc6a3c5e40e9723a56bd65db8aad.json index c294a11..88183fb 100644 --- a/.sqlx/query-c40c4444269dd206d0dc0618d8a322a76e5f77c663cdbb433d7985fb5528af67.json +++ b/.sqlx/query-734e50b9a4ed85367e9888ea1e4ac602a30efc6a3c5e40e9723a56bd65db8aad.json @@ -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" } diff --git a/.sqlx/query-77440cead9d333038d59388bc46f548cab89f0b656abfb24f8385675eded8132.json b/.sqlx/query-77440cead9d333038d59388bc46f548cab89f0b656abfb24f8385675eded8132.json new file mode 100644 index 0000000..398f41a --- /dev/null +++ b/.sqlx/query-77440cead9d333038d59388bc46f548cab89f0b656abfb24f8385675eded8132.json @@ -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" +} diff --git a/.sqlx/query-6549001e2a522da8788d77d915ca372b07872cd763a48f3a035e7e5c5a70c879.json b/.sqlx/query-9a393c4019349783b7267a7447514fa217e4133b6774a4cdb1b45347c306971c.json similarity index 60% rename from .sqlx/query-6549001e2a522da8788d77d915ca372b07872cd763a48f3a035e7e5c5a70c879.json rename to .sqlx/query-9a393c4019349783b7267a7447514fa217e4133b6774a4cdb1b45347c306971c.json index 07234d8..6a366b5 100644 --- a/.sqlx/query-6549001e2a522da8788d77d915ca372b07872cd763a48f3a035e7e5c5a70c879.json +++ b/.sqlx/query-9a393c4019349783b7267a7447514fa217e4133b6774a4cdb1b45347c306971c.json @@ -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" } diff --git a/.sqlx/query-1fdfa7429dc20e693f147c5184e7b2299821d822590a978bed7bdf0f0f7917e3.json b/.sqlx/query-b0fc43ae344ae0bda14d631071271b34f6c9e307c675b920fb0ccac9bba0a797.json similarity index 54% rename from .sqlx/query-1fdfa7429dc20e693f147c5184e7b2299821d822590a978bed7bdf0f0f7917e3.json rename to .sqlx/query-b0fc43ae344ae0bda14d631071271b34f6c9e307c675b920fb0ccac9bba0a797.json index b208197..8179acd 100644 --- a/.sqlx/query-1fdfa7429dc20e693f147c5184e7b2299821d822590a978bed7bdf0f0f7917e3.json +++ b/.sqlx/query-b0fc43ae344ae0bda14d631071271b34f6c9e307c675b920fb0ccac9bba0a797.json @@ -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" } diff --git a/.sqlx/query-4a1b2d9c1c66741913ff3a9ddf50d260d8bb01fb891509bf95561d72c3376f98.json b/.sqlx/query-db743bec93d5243e994d5a85a60ec3c896432fede4d849c10d1b02813eb84428.json similarity index 55% rename from .sqlx/query-4a1b2d9c1c66741913ff3a9ddf50d260d8bb01fb891509bf95561d72c3376f98.json rename to .sqlx/query-db743bec93d5243e994d5a85a60ec3c896432fede4d849c10d1b02813eb84428.json index 3c612aa..4e14a03 100644 --- a/.sqlx/query-4a1b2d9c1c66741913ff3a9ddf50d260d8bb01fb891509bf95561d72c3376f98.json +++ b/.sqlx/query-db743bec93d5243e994d5a85a60ec3c896432fede4d849c10d1b02813eb84428.json @@ -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" } diff --git a/.sqlx/query-e3f0046e263f5a5029bdcf1d7f99d5ef094a447b85376822a9e8052e51372087.json b/.sqlx/query-e3f0046e263f5a5029bdcf1d7f99d5ef094a447b85376822a9e8052e51372087.json new file mode 100644 index 0000000..faa0af4 --- /dev/null +++ b/.sqlx/query-e3f0046e263f5a5029bdcf1d7f99d5ef094a447b85376822a9e8052e51372087.json @@ -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" +} diff --git a/.sqlx/query-b949d7b0995654aa1b68bb5b993e3af267b206d42f0342c0ff9cfc15028a862b.json b/.sqlx/query-e8dc3829a39a405465c4d13b92a5533cd8a6fc7fdc9b92c6fed993e20a3fe7d5.json similarity index 54% rename from .sqlx/query-b949d7b0995654aa1b68bb5b993e3af267b206d42f0342c0ff9cfc15028a862b.json rename to .sqlx/query-e8dc3829a39a405465c4d13b92a5533cd8a6fc7fdc9b92c6fed993e20a3fe7d5.json index ca3b339..3145bf2 100644 --- a/.sqlx/query-b949d7b0995654aa1b68bb5b993e3af267b206d42f0342c0ff9cfc15028a862b.json +++ b/.sqlx/query-e8dc3829a39a405465c4d13b92a5533cd8a6fc7fdc9b92c6fed993e20a3fe7d5.json @@ -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" } diff --git a/.sqlx/query-ea192066870fda12a9f26c27556d8c2651334145a8cc221a3ff83600c94c8c0e.json b/.sqlx/query-ea192066870fda12a9f26c27556d8c2651334145a8cc221a3ff83600c94c8c0e.json new file mode 100644 index 0000000..e01264a --- /dev/null +++ b/.sqlx/query-ea192066870fda12a9f26c27556d8c2651334145a8cc221a3ff83600c94c8c0e.json @@ -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" +} diff --git a/.sqlx/query-ee0e90b6ddb5dcc454f47b1326bf5a419eb66acfb7578fff7d019027ce565e8f.json b/.sqlx/query-ee0e90b6ddb5dcc454f47b1326bf5a419eb66acfb7578fff7d019027ce565e8f.json new file mode 100644 index 0000000..c41c1ad --- /dev/null +++ b/.sqlx/query-ee0e90b6ddb5dcc454f47b1326bf5a419eb66acfb7578fff7d019027ce565e8f.json @@ -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" +} diff --git a/migrations/0006_admin.sql b/migrations/0006_admin.sql new file mode 100644 index 0000000..810870a --- /dev/null +++ b/migrations/0006_admin.sql @@ -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; diff --git a/src/db.rs b/src/db.rs index 7b7601a..48320dc 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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), + } + } +} diff --git a/src/handlers/admin.rs b/src/handlers/admin.rs new file mode 100644 index 0000000..9de0f76 --- /dev/null +++ b/src/handlers/admin.rs @@ -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: 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, + pub nuke: Option, +} + +// ~~ Templates + +#[derive(Template)] +#[template(path = "admin/dashboard.html")] +pub struct DashboardTemplate { + pub title: String, + pub current_user: Option, + 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, + pub csrf_token: String, + pub users: Vec, +} + +#[derive(Template)] +#[template(path = "admin/ban_form.html")] +pub struct BanFormTemplate { + pub title: String, + pub current_user: Option, + pub csrf_token: String, + pub user_id: i64, + pub username: String, + pub error: Option, +} + +#[derive(Template)] +#[template(path = "admin/threads.html")] +pub struct ThreadsTemplate { + pub title: String, + pub current_user: Option, + pub csrf_token: String, + pub threads: Vec, +} + +#[derive(Template)] +#[template(path = "admin/banned.html")] +pub struct BannedTemplate { + pub title: String, + pub current_user: Option, + pub csrf_token: String, + pub banned_at: String, + pub ban_reason: Option, + 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 { + let user_id: Option = 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, 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, 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, State(state): State, 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, + State(state): State, + session: Session, + Form(form): Form, +) -> 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, State(state): State) -> 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, State(state): State) -> 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, 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, State(state): State, 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, State(state): State) -> 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, + State(state): State, + 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() +} diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index de1344f..405c44e 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -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) diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 55126da..d963cab 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; pub mod auth; pub mod filters; pub mod thread; diff --git a/src/handlers/thread.rs b/src/handlers/thread.rs index b9be1b6..e00b74e 100644 --- a/src/handlers/thread.rs +++ b/src/handlers/thread.rs @@ -99,6 +99,7 @@ pub async fn list_threads(State(state): State, 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) -> 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) diff --git a/src/main.rs b/src/main.rs index fbcb621..e0cd1db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, 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); diff --git a/src/middleware.rs b/src/middleware.rs index 123ef50..51e7b5f 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -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() + } diff --git a/src/models/post.rs b/src/models/post.rs index 77508aa..55e038f 100644 --- a/src/models/post.rs +++ b/src/models/post.rs @@ -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, + pub hidden_by: Option, + pub ban_message: Option, } impl Post { pub async fn by_thread(pool: &SqlitePool, thread_id: i64) -> sqlx::Result> { 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> { + 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 { + let count: i32 = sqlx::query_scalar!("SELECT COUNT(*) as 'count!' FROM posts WHERE hidden = FALSE") + .fetch_one(pool) + .await?; + Ok(count as i64) + } } diff --git a/src/models/thread.rs b/src/models/thread.rs index fa7c579..a8bc0b2 100644 --- a/src/models/thread.rs +++ b/src/models/thread.rs @@ -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, + pub hidden_by: Option, } impl Thread { pub async fn list_all(pool: &SqlitePool) -> sqlx::Result> { 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> { + 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> { 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> { + 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> { 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 { + let count: i32 = sqlx::query_scalar!("SELECT COUNT(*) as 'count!' FROM threads WHERE hidden = FALSE") + .fetch_one(pool) + .await?; + Ok(count as i64) + } } diff --git a/src/models/user.rs b/src/models/user.rs index df5ccdf..a8973cd 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -18,6 +18,13 @@ pub struct User { pub verification_token_expires: Option, pub reset_token: Option, pub reset_token_expires: Option, + pub banned: bool, + pub banned_at: Option, + pub ban_reason: Option, + pub ban_length: Option, + pub unban_at: Option, + pub banned_by: Option, + pub ban_post_content: Option, } 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> { + 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 { + let count: i32 = sqlx::query_scalar!("SELECT COUNT(*) as 'count!' FROM users") + .fetch_one(pool) + .await?; + Ok(count as i64) + } } diff --git a/static/meeple.png b/static/meeple.png new file mode 100644 index 0000000000000000000000000000000000000000..036f3c2dbe96dab9c1121f2b0aede73b941c21d1 GIT binary patch literal 18399 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 O0000 + admin :: users :: ban {{ username }} + + +{% if let Some(err) = error %} +
+
{{ err }}
+
+{% endif %} + +
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + + cancel +
+
+{% endblock %} diff --git a/templates/admin/banned.html b/templates/admin/banned.html new file mode 100644 index 0000000..5278d5a --- /dev/null +++ b/templates/admin/banned.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} + +{% block content %} +
+ account banned +
+ +
+
+ your account was b& on {{ banned_at }} +
+ + {% if !ban_post_content.is_empty() %} +
+
the post that got you b&
+
{{ ban_post_content }}
+
+ {% endif %} + +
+
reason
+ {% if let Some(reason) = ban_reason %} +
{{ reason }}
+ {% else %} +
admin didn't care enough to give a reason
+ {% endif %} +
+ +
+
ban length
+
{{ ban_length }}
+
+ + {% if unban_at != "never" %} +
+
unban at
+
{{ unban_at }}
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html new file mode 100644 index 0000000..39d9741 --- /dev/null +++ b/templates/admin/dashboard.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block content %} +
+ admin dashboard +
+ +
+
+
+
{{ user_count }}
+
users
+
+
+
{{ thread_count }}
+
threads
+
+
+
{{ post_count }}
+
posts
+
+
+
+ +
+
+
+
{{ hidden_thread_count }}
+
hidden threads
+
+
+
{{ banned_user_count }}
+
banned users
+
+
+
+ + +{% endblock %} diff --git a/templates/admin/threads.html b/templates/admin/threads.html new file mode 100644 index 0000000..4c660d6 --- /dev/null +++ b/templates/admin/threads.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block content %} +
+ admin :: threads +
+ +{% for t in threads %} +
+
+
+ {% if t.hidden %} + [hidden] + {% endif %} + {{ t.title }} + by {{ t.author_username }} + {{ t.created_at }} +
+
+ {% if !t.hidden %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+ + +
+
+
+
+{% endfor %} + +{% if threads.is_empty() %} +
no threads found
+{% endif %} +{% endblock %} diff --git a/templates/admin/users.html b/templates/admin/users.html new file mode 100644 index 0000000..aa6ad54 --- /dev/null +++ b/templates/admin/users.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block content %} +
+ admin :: users +
+ +{% for u in users %} +
+
+
+ {{ u.username }} + {{ u.email }} + {% if u.role == "admin" %} + [admin] + {% endif %} + {% if u.banned %} + [banned] + {% endif %} + {% if !u.email_verified %} + [unverified] + {% endif %} +
+
+ {% if !u.banned %} + ban + {% else %} +
+ + +
+ {% endif %} +
+ + +
+
+
+
+{% endfor %} + +{% if users.is_empty() %} +
no users found
+{% endif %} +{% endblock %} diff --git a/templates/base.html b/templates/base.html index 55227d1..18a0aef 100644 --- a/templates/base.html +++ b/templates/base.html @@ -17,6 +17,9 @@ threads