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 0000000..036f3c2 Binary files /dev/null and b/static/meeple.png differ diff --git a/templates/admin/ban_form.html b/templates/admin/ban_form.html new file mode 100644 index 0000000..15f6baa --- /dev/null +++ b/templates/admin/ban_form.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block content %} +
+ 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