diff --git a/.gitignore b/.gitignore index 4a8b4b0..be068bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/data sarmentine.db* .env .env.* diff --git a/.sqlx/query-d12d0743a4bbe18465adb69102e3f7dc70ac567c7610cc9aa6f9c6bd84be6a6b.json b/.sqlx/query-03e12aecfba017ed94a3dcd86e82454d8111c884d2133090ac76c341317f4a9a.json similarity index 51% rename from .sqlx/query-d12d0743a4bbe18465adb69102e3f7dc70ac567c7610cc9aa6f9c6bd84be6a6b.json rename to .sqlx/query-03e12aecfba017ed94a3dcd86e82454d8111c884d2133090ac76c341317f4a9a.json index 285dfb4..49d3a86 100644 --- a/.sqlx/query-d12d0743a4bbe18465adb69102e3f7dc70ac567c7610cc9aa6f9c6bd84be6a6b.json +++ b/.sqlx/query-03e12aecfba017ed94a3dcd86e82454d8111c884d2133090ac76c341317f4a9a.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!\", username, email, password, bio, avatar_url, role, created_at\n FROM users WHERE username = ?", + "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": [ { @@ -42,6 +42,31 @@ "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": { @@ -55,8 +80,13 @@ true, true, false, - false + false, + false, + true, + true, + true, + true ] }, - "hash": "d12d0743a4bbe18465adb69102e3f7dc70ac567c7610cc9aa6f9c6bd84be6a6b" + "hash": "03e12aecfba017ed94a3dcd86e82454d8111c884d2133090ac76c341317f4a9a" } diff --git a/.sqlx/query-1fdfa7429dc20e693f147c5184e7b2299821d822590a978bed7bdf0f0f7917e3.json b/.sqlx/query-1fdfa7429dc20e693f147c5184e7b2299821d822590a978bed7bdf0f0f7917e3.json new file mode 100644 index 0000000..b208197 --- /dev/null +++ b/.sqlx/query-1fdfa7429dc20e693f147c5184e7b2299821d822590a978bed7bdf0f0f7917e3.json @@ -0,0 +1,92 @@ +{ + "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 = ?", + "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": [ + false, + false, + false, + false, + true, + true, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "1fdfa7429dc20e693f147c5184e7b2299821d822590a978bed7bdf0f0f7917e3" +} diff --git a/.sqlx/query-59b62edeaea0297e51ae3d1d973f46cc1a0786ad5f86bf5f539d12f728136f1d.json b/.sqlx/query-a4c396e317e9a596629e91d1deefa19df638161c6a7a87427cb1f298aaa545ff.json similarity index 52% rename from .sqlx/query-59b62edeaea0297e51ae3d1d973f46cc1a0786ad5f86bf5f539d12f728136f1d.json rename to .sqlx/query-a4c396e317e9a596629e91d1deefa19df638161c6a7a87427cb1f298aaa545ff.json index 79a7a8c..7c6ceb9 100644 --- a/.sqlx/query-59b62edeaea0297e51ae3d1d973f46cc1a0786ad5f86bf5f539d12f728136f1d.json +++ b/.sqlx/query-a4c396e317e9a596629e91d1deefa19df638161c6a7a87427cb1f298aaa545ff.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!\", username, email, password, bio, avatar_url, role, created_at\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 FROM users WHERE email = ?", "describe": { "columns": [ { @@ -42,6 +42,31 @@ "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": { @@ -55,8 +80,13 @@ true, true, false, - false + false, + false, + true, + true, + true, + true ] }, - "hash": "59b62edeaea0297e51ae3d1d973f46cc1a0786ad5f86bf5f539d12f728136f1d" + "hash": "a4c396e317e9a596629e91d1deefa19df638161c6a7a87427cb1f298aaa545ff" } diff --git a/.sqlx/query-9496103101618f59b785c0c8d2cee4f6053055cb06db77dc8033c7e31d503a77.json b/.sqlx/query-b949d7b0995654aa1b68bb5b993e3af267b206d42f0342c0ff9cfc15028a862b.json similarity index 52% rename from .sqlx/query-9496103101618f59b785c0c8d2cee4f6053055cb06db77dc8033c7e31d503a77.json rename to .sqlx/query-b949d7b0995654aa1b68bb5b993e3af267b206d42f0342c0ff9cfc15028a862b.json index 9c7effb..ca3b339 100644 --- a/.sqlx/query-9496103101618f59b785c0c8d2cee4f6053055cb06db77dc8033c7e31d503a77.json +++ b/.sqlx/query-b949d7b0995654aa1b68bb5b993e3af267b206d42f0342c0ff9cfc15028a862b.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!\", username, email, password, bio, avatar_url, role, created_at\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 FROM users WHERE id = ?", "describe": { "columns": [ { @@ -42,6 +42,31 @@ "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": { @@ -55,8 +80,13 @@ true, true, false, - false + false, + false, + true, + true, + true, + true ] }, - "hash": "9496103101618f59b785c0c8d2cee4f6053055cb06db77dc8033c7e31d503a77" + "hash": "b949d7b0995654aa1b68bb5b993e3af267b206d42f0342c0ff9cfc15028a862b" } diff --git a/.sqlx/query-ebb6edb02bb7b9e2f4d7acd344790f3bdcae506ad1c604dd954b6f091e4c66f2.json b/.sqlx/query-ebb6edb02bb7b9e2f4d7acd344790f3bdcae506ad1c604dd954b6f091e4c66f2.json new file mode 100644 index 0000000..3e07c23 --- /dev/null +++ b/.sqlx/query-ebb6edb02bb7b9e2f4d7acd344790f3bdcae506ad1c604dd954b6f091e4c66f2.json @@ -0,0 +1,92 @@ +{ + "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 = ?", + "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": [ + false, + false, + false, + false, + true, + true, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "ebb6edb02bb7b9e2f4d7acd344790f3bdcae506ad1c604dd954b6f091e4c66f2" +} diff --git a/Cargo.lock b/Cargo.lock index 007d520..655a5d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,6 +21,19 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ammonia" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "tendril", + "url", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -100,7 +113,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -369,6 +382,42 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "der" version = "0.7.10" @@ -419,6 +468,21 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "either" version = "1.15.0" @@ -428,6 +492,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "equivalent" version = "1.0.2" @@ -499,6 +579,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.32" @@ -507,6 +597,7 @@ checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -580,12 +671,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -606,6 +704,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -642,6 +749,26 @@ dependencies = [ "wasip3", ] +[[package]] +name = "governor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b" +dependencies = [ + "cfg-if", + "dashmap", + "futures", + "futures-timer", + "no-std-compat", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand", + "smallvec", + "spinning_top", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -724,6 +851,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + [[package]] name = "http" version = "1.4.0" @@ -995,6 +1133,33 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lettre" +version = "0.11.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7" +dependencies = [ + "async-trait", + "base64 0.22.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots", +] + [[package]] name = "libc" version = "0.2.184" @@ -1058,6 +1223,40 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "matchit" version = "0.7.3" @@ -1113,6 +1312,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + [[package]] name = "nom" version = "7.1.3" @@ -1123,6 +1334,21 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1236,6 +1462,58 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1275,6 +1553,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1299,6 +1583,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "prettyplease" version = "0.2.37" @@ -1318,6 +1608,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quote" version = "1.0.45" @@ -1327,6 +1651,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -1369,6 +1699,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1387,6 +1726,20 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rmp" version = "0.8.15" @@ -1439,6 +1792,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1455,13 +1843,19 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" name = "sarmentine" version = "0.1.0" dependencies = [ + "ammonia", "anyhow", "argon2", "askama", "askama_axum", "axum", + "base64 0.22.1", "chrono", "dotenvy", + "governor", + "lettre", + "pulldown-cmark", + "rand", "rand_core", "serde", "sqlx", @@ -1597,6 +1991,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -1628,6 +2028,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -1644,7 +2053,7 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" dependencies = [ - "nom", + "nom 7.1.3", "unicode_categories", ] @@ -1856,6 +2265,31 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -1925,6 +2359,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2029,6 +2474,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -2265,6 +2720,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2277,6 +2738,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -2286,6 +2753,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -2294,6 +2762,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2421,6 +2895,37 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.6.1" @@ -2431,6 +2936,28 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -2496,7 +3023,16 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -2514,13 +3050,29 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -2529,42 +3081,90 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index e47c07e..6c09b0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,3 +19,9 @@ serde = { version = "1", features = ["derive"] } chrono = { version = "0.4", features = ["serde"] } # Error handling anyhow = "1" +governor = "0.6" +rand = "0.8" +lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] } +base64 = "0.22" +pulldown-cmark = "0.13" +ammonia = "4.1" diff --git a/migrations/0004_email_verification.sql b/migrations/0004_email_verification.sql new file mode 100644 index 0000000..8dc66f4 --- /dev/null +++ b/migrations/0004_email_verification.sql @@ -0,0 +1,3 @@ +ALTER TABLE users ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE users ADD COLUMN verification_token TEXT; +ALTER TABLE users ADD COLUMN verification_token_expires DATETIME; diff --git a/migrations/0005_password_reset.sql b/migrations/0005_password_reset.sql new file mode 100644 index 0000000..077c7fc --- /dev/null +++ b/migrations/0005_password_reset.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN reset_token TEXT; +ALTER TABLE users ADD COLUMN reset_token_expires DATETIME; diff --git a/src/db.rs b/src/db.rs index b8e6493..7b7601a 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,6 +1,14 @@ use sqlx::{SqlitePool, sqlite::SqlitePoolOptions}; pub async fn connect(database_url: &str) -> SqlitePool { + // Ensure parent directory exists for file-based SQLite URLs + if let Some(path) = database_url.strip_prefix("sqlite:") + && let Some(parent) = std::path::Path::new(path).parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent).ok(); + } + let pool = SqlitePoolOptions::new() .max_connections(5) .connect(database_url) diff --git a/src/email.rs b/src/email.rs new file mode 100644 index 0000000..c78ca68 --- /dev/null +++ b/src/email.rs @@ -0,0 +1,121 @@ +use lettre::{ + SmtpTransport, Transport, + message::{Mailbox, Message, header::ContentType}, + transport::smtp::{ + authentication::Credentials, + client::{Tls, TlsParameters}, + }, +}; + +pub struct EmailSender { + smtp_host: String, + smtp_port: u16, + credentials: Credentials, + from: Mailbox, +} + +impl EmailSender { + pub fn new( + smtp_host: String, + smtp_port: u16, + smtp_login: String, + smtp_password: String, + from_email: String, + ) -> Self { + Self { + smtp_host, + smtp_port, + credentials: Credentials::new(smtp_login, smtp_password), + from: Mailbox::new( + Some("sarmentine".to_string()), + from_email.parse().expect("invalid from email"), + ), + } + } + + pub fn send_verification(&self, to_email: &str, verify_url: &str) -> Result<(), String> { + let _body_plain = format!( + "Welcome to sarmentine!\n\n\ + Please verify your email by clicking the link below:\n\n\ + {}\n\n\ + If you did not create an account, you can ignore this email.", + verify_url + ); + + let body_html = format!( + "\ +

Welcome to sarmentine!

\ +

Please verify your email by clicking the link below:

\ +

Verify Email

\ +

If you did not create an account, you can ignore this email.

\ + ", + verify_url + ); + + let email = Message::builder() + .from(self.from.clone()) + .to(to_email + .parse() + .map_err(|e| format!("invalid to email: {}", e))?) + .subject("Verify your sarmentine account") + .header(ContentType::TEXT_HTML) + .body(body_html) + .map_err(|e| format!("failed to build email: {}", e))?; + + self.send_email(&email) + } + + pub fn send_password_reset(&self, to_email: &str, reset_url: &str) -> Result<(), String> { + let _body_plain = format!( + "You requested a password reset for your sarmentine account.\n\n\ + Click the link below to set a new password:\n\n\ + {}\n\n\ + This link expires in 4 hours.\n\n\ + If you did not request this, you can ignore this email.", + reset_url + ); + + let body_html = format!( + "\ +

Password Reset

\ +

Click the link below to set a new password:

\ +

Reset Password

\ +

This link expires in 4 hours. If you did not request this, you can ignore this email.

\ + ", + reset_url + ); + + let email = Message::builder() + .from(self.from.clone()) + .to(to_email + .parse() + .map_err(|e| format!("invalid to email: {}", e))?) + .subject("Reset your sarmentine password") + .header(ContentType::TEXT_HTML) + .body(body_html) + .map_err(|e| format!("failed to build email: {}", e))?; + + self.send_email(&email) + } + + fn send_email(&self, email: &Message) -> Result<(), String> { + let tls = Tls::Required( + TlsParameters::builder(self.smtp_host.clone()) + .build() + .map_err(|e| format!("failed to build TLS: {}", e))?, + ); + + let mailer = SmtpTransport::starttls_relay(&self.smtp_host) + .map_err(|e| format!("failed to create relay: {}", e))? + .credentials(self.credentials.clone()) + .port(self.smtp_port) + .tls(tls) + .build(); + + mailer + .send(email) + .map_err(|e| format!("failed to send email: {}", e))?; + + Ok(()) + } +} diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index ba3bbd0..de1344f 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -4,13 +4,15 @@ use argon2::{ }; use askama::Template; use axum::{ - extract::{Form, State}, + extract::{ConnectInfo, Form, Query, State}, response::{Html, IntoResponse, Redirect, Response}, }; use serde::Deserialize; -use sqlx::SqlitePool; +use std::net::SocketAddr; use tower_sessions::Session; +use crate::AppState; +use crate::middleware::generate_token; use crate::models::user::User; const SESSION_USER_ID_KEY: &str = "user_id"; @@ -23,6 +25,7 @@ pub struct RegisterTemplate { pub title: String, pub current_user: Option, pub error: Option, + pub csrf_token: String, } #[derive(Template)] @@ -31,6 +34,26 @@ pub struct LoginTemplate { pub title: String, pub current_user: Option, pub error: Option, + pub csrf_token: String, +} + +#[derive(Template)] +#[template(path = "auth/check_email.html")] +pub struct CheckEmailTemplate { + pub title: String, + pub current_user: Option, + pub email: String, + pub csrf_token: String, +} + +#[derive(Template)] +#[template(path = "auth/verify_result.html")] +pub struct VerifyResultTemplate { + pub title: String, + pub current_user: Option, + pub success: bool, + pub message: String, + pub csrf_token: String, } pub fn render(t: T) -> Response { @@ -61,25 +84,48 @@ pub struct LoginForm { // ~~ Handlers -pub async fn register_page() -> Response { +pub async fn register_page(session: Session) -> Response { + let csrf_token = session + .get("csrf_token") + .await + .ok() + .flatten() + .unwrap_or_default(); + render(RegisterTemplate { title: "register".into(), current_user: None, error: None, + csrf_token, }) } pub async fn register_submit( - State(pool): State, + State(state): State, + ConnectInfo(addr): ConnectInfo, session: Session, Form(form): Form, ) -> Response { + if !state + .rate_limiter + .check_ip(addr.ip(), &state.rate_limiter.register) + { + return (axum::http::StatusCode::TOO_MANY_REQUESTS, "rate limited").into_response(); + } + macro_rules! register_err { ($msg:expr) => { + let csrf_token: String = session + .get("csrf_token") + .await + .ok() + .flatten() + .unwrap_or_default(); return render(RegisterTemplate { title: "register".into(), current_user: None, error: Some($msg.into()), + csrf_token, }) }; } @@ -91,14 +137,14 @@ pub async fn register_submit( register_err!("password must be at least 8 characters"); } - let existing_user: Option = User::find_by_username(&pool, &form.username) + let existing_user: Option = User::find_by_username(&state.pool, &form.username) .await .unwrap_or(None); if existing_user.is_some() { register_err!("that username is already taken"); } - let existing_email: Option = User::find_by_email(&pool, &form.email) + let existing_email: Option = User::find_by_email(&state.pool, &form.email) .await .unwrap_or(None); if existing_email.is_some() { @@ -111,37 +157,86 @@ pub async fn register_submit( .expect("password hashing failed") .to_string(); - let user_id: i64 = User::insert(&pool, &form.username, &form.email, &hash) + let user_id: i64 = User::insert(&state.pool, &form.username, &form.email, &hash) .await .expect("failed to insert user"); - session.insert(SESSION_USER_ID_KEY, user_id).await.unwrap(); + let verify_token = generate_token(); + let expires = chrono::Utc::now().naive_utc() + chrono::Duration::hours(24); + User::set_verification_token(&state.pool, user_id, &verify_token, &expires) + .await + .expect("failed to set verification token"); - Redirect::to("/").into_response() + if let Some(sender) = &state.email_sender { + let verify_url = format!("{}/auth/verify?token={}", state.base_url, verify_token); + match sender.send_verification(&form.email, &verify_url) { + Ok(()) => eprintln!("verification email sent to {}", form.email), + Err(e) => eprintln!("failed to send verification email: {}", e), + } + } + + let csrf_token: String = session + .get("csrf_token") + .await + .ok() + .flatten() + .unwrap_or_default(); + + render(CheckEmailTemplate { + title: "check your email".into(), + current_user: None, + email: form.email, + csrf_token, + }) } -pub async fn login_page() -> Response { +pub async fn login_page(session: Session) -> Response { + let csrf_token = session + .get("csrf_token") + .await + .ok() + .flatten() + .unwrap_or_default(); + render(LoginTemplate { title: "login".into(), current_user: None, error: None, + csrf_token, }) } pub async fn login_submit( - State(pool): State, + State(state): State, + ConnectInfo(addr): ConnectInfo, session: Session, Form(form): Form, ) -> Response { + if !state + .rate_limiter + .check_ip(addr.ip(), &state.rate_limiter.login) + { + return (axum::http::StatusCode::TOO_MANY_REQUESTS, "rate limited").into_response(); + } + + let csrf_token: String = session + .get("csrf_token") + .await + .ok() + .flatten() + .unwrap_or_default(); + let fail = || { + let csrf_token = csrf_token.clone(); render(LoginTemplate { title: "login".into(), current_user: None, error: Some("incorrect username or password".into()), + csrf_token: csrf_token.clone(), }) }; - let user: Option = User::find_by_username(&pool, &form.username) + let user: Option = User::find_by_username(&state.pool, &form.username) .await .unwrap_or(None); @@ -170,7 +265,399 @@ pub async fn logout(session: Session) -> Response { // ~~ Session helper -pub async fn get_current_user(session: &Session, pool: &SqlitePool) -> Option { +pub async fn get_current_user(session: &Session, pool: &sqlx::SqlitePool) -> Option { let user_id: i64 = session.get(SESSION_USER_ID_KEY).await.ok()??; User::find_by_id(pool, user_id).await.unwrap_or(None) } + +#[derive(Deserialize)] +pub struct VerifyQuery { + pub token: String, +} + +pub async fn verify_email( + State(state): State, + Query(query): Query, + session: Session, +) -> Response { + let csrf_token: String = session + .get("csrf_token") + .await + .ok() + .flatten() + .unwrap_or_default(); + + let user = match User::find_by_verification_token(&state.pool, &query.token) + .await + .ok() + .flatten() + { + Some(u) => u, + None => { + return render(VerifyResultTemplate { + title: "verification failed".into(), + current_user: None, + success: false, + message: "Invalid or expired verification link.".into(), + csrf_token, + }); + } + }; + + if let Some(expires) = user.verification_token_expires + && chrono::Utc::now().naive_utc() > expires + { + return render(VerifyResultTemplate { + title: "verification expired".into(), + current_user: None, + success: false, + message: "Your verification link has expired. Please request a new one.".into(), + csrf_token, + }); + } + + User::verify_email(&state.pool, user.id) + .await + .expect("failed to verify email"); + + session.insert(SESSION_USER_ID_KEY, user.id).await.ok(); + + render(VerifyResultTemplate { + title: "email verified".into(), + current_user: None, + success: true, + message: "Your email has been verified. You can now create threads and posts.".into(), + csrf_token, + }) +} + +#[derive(Deserialize)] +pub struct ResendVerificationForm { + pub email: String, +} + +pub async fn resend_verification( + State(state): State, + ConnectInfo(addr): ConnectInfo, + session: Session, + Form(form): Form, +) -> Response { + if !state + .rate_limiter + .check_ip(addr.ip(), &state.rate_limiter.register) + { + return (axum::http::StatusCode::TOO_MANY_REQUESTS, "rate limited").into_response(); + } + + let csrf_token: String = session + .get("csrf_token") + .await + .ok() + .flatten() + .unwrap_or_default(); + + let user = match User::find_by_email(&state.pool, &form.email) + .await + .ok() + .flatten() + { + Some(u) => u, + None => { + return render(CheckEmailTemplate { + title: "check your email".into(), + current_user: None, + email: form.email, + csrf_token, + }); + } + }; + + if user.email_verified { + return render(VerifyResultTemplate { + title: "already verified".into(), + current_user: None, + success: true, + message: "Your email is already verified.".into(), + csrf_token, + }); + } + + let verify_token = generate_token(); + let expires = chrono::Utc::now().naive_utc() + chrono::Duration::hours(24); + User::set_verification_token(&state.pool, user.id, &verify_token, &expires) + .await + .expect("failed to set verification token"); + + if let Some(sender) = &state.email_sender { + let verify_url = format!("{}/auth/verify?token={}", state.base_url, verify_token); + match sender.send_verification(&user.email, &verify_url) { + Ok(()) => eprintln!("verification email sent to {}", user.email), + Err(e) => eprintln!("failed to send verification email: {}", e), + } + } + + render(CheckEmailTemplate { + title: "check your email".into(), + current_user: None, + email: form.email, + csrf_token, + }) +} + +// ~~ Password reset + +#[derive(Template)] +#[template(path = "auth/forgot_password.html")] +pub struct ForgotPasswordTemplate { + pub title: String, + pub current_user: Option, + pub error: Option, + pub csrf_token: String, +} + +#[derive(Template)] +#[template(path = "auth/forgot_password_result.html")] +pub struct ForgotPasswordResultTemplate { + pub title: String, + pub current_user: Option, + pub message: String, + pub csrf_token: String, +} + +#[derive(Template)] +#[template(path = "auth/reset_password.html")] +pub struct ResetPasswordTemplate { + pub title: String, + pub current_user: Option, + pub error: Option, + pub csrf_token: String, + pub token: String, +} + +#[derive(Template)] +#[template(path = "auth/reset_password_result.html")] +pub struct ResetPasswordResultTemplate { + pub title: String, + pub current_user: Option, + pub success: bool, + pub message: String, + pub csrf_token: String, +} + +#[derive(Deserialize)] +pub struct ForgotPasswordForm { + pub email: String, +} + +#[derive(Deserialize)] +pub struct ResetPasswordForm { + pub token: String, + pub password: String, +} + +#[derive(Deserialize)] +pub struct ResetQuery { + pub token: String, +} + +pub async fn forgot_password_page(session: Session) -> Response { + let csrf_token: String = session + .get("csrf_token") + .await + .ok() + .flatten() + .unwrap_or_default(); + + render(ForgotPasswordTemplate { + title: "forgot password".into(), + current_user: None, + error: None, + csrf_token, + }) +} + +pub async fn forgot_password_submit( + State(state): State, + ConnectInfo(addr): ConnectInfo, + session: Session, + Form(form): Form, +) -> Response { + if !state + .rate_limiter + .check_ip(addr.ip(), &state.rate_limiter.forgot_password) + { + return (axum::http::StatusCode::TOO_MANY_REQUESTS, "rate limited").into_response(); + } + + let csrf_token: String = session + .get("csrf_token") + .await + .ok() + .flatten() + .unwrap_or_default(); + + let user = match User::find_by_email(&state.pool, &form.email) + .await + .ok() + .flatten() + { + Some(u) => u, + None => { + return render(ForgotPasswordResultTemplate { + title: "password reset".into(), + current_user: None, + message: + "If an account exists with that email, a password reset link has been sent." + .into(), + csrf_token, + }); + } + }; + + let reset_token = generate_token(); + let expires = chrono::Utc::now().naive_utc() + chrono::Duration::hours(4); + User::set_reset_token(&state.pool, user.id, &reset_token, &expires) + .await + .expect("failed to set reset token"); + + if let Some(sender) = &state.email_sender { + let reset_url = format!("{}/auth/reset?token={}", state.base_url, reset_token); + match sender.send_password_reset(&user.email, &reset_url) { + Ok(()) => eprintln!("password reset email sent to {}", user.email), + Err(e) => eprintln!("failed to send password reset email: {}", e), + } + } + + render(ForgotPasswordResultTemplate { + title: "password reset".into(), + current_user: None, + message: "If an account exists with that email, a password reset link has been sent." + .into(), + csrf_token, + }) +} + +pub async fn reset_password_page( + State(state): State, + Query(query): Query, + session: Session, +) -> Response { + let csrf_token: String = session + .get("csrf_token") + .await + .ok() + .flatten() + .unwrap_or_default(); + + let user = match User::find_by_reset_token(&state.pool, &query.token) + .await + .ok() + .flatten() + { + Some(u) => u, + None => { + return render(ResetPasswordResultTemplate { + title: "reset password".into(), + current_user: None, + success: false, + message: "Invalid or expired password reset link.".into(), + csrf_token, + }); + } + }; + + if let Some(expires) = user.reset_token_expires + && chrono::Utc::now().naive_utc() > expires + { + return render(ResetPasswordResultTemplate { + title: "reset password".into(), + current_user: None, + success: false, + message: "Your password reset link has expired. Please request a new one.".into(), + csrf_token, + }); + } + + render(ResetPasswordTemplate { + title: "reset password".into(), + current_user: None, + error: None, + csrf_token, + token: query.token, + }) +} + +pub async fn reset_password_submit( + State(state): State, + session: Session, + Form(form): Form, +) -> Response { + let csrf_token: String = session + .get("csrf_token") + .await + .ok() + .flatten() + .unwrap_or_default(); + + if form.password.len() < 8 { + return render(ResetPasswordResultTemplate { + title: "reset password".into(), + current_user: None, + success: false, + message: "Password must be at least 8 characters.".into(), + csrf_token, + }); + } + + let user = match User::find_by_reset_token(&state.pool, &form.token) + .await + .ok() + .flatten() + { + Some(u) => u, + None => { + return render(ResetPasswordResultTemplate { + title: "reset password".into(), + current_user: None, + success: false, + message: "Invalid or expired password reset link.".into(), + csrf_token, + }); + } + }; + + if let Some(expires) = user.reset_token_expires + && chrono::Utc::now().naive_utc() > expires + { + return render(ResetPasswordResultTemplate { + title: "reset password".into(), + current_user: None, + success: false, + message: "Your password reset link has expired. Please request a new one.".into(), + csrf_token, + }); + } + + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default() + .hash_password(form.password.as_bytes(), &salt) + .expect("password hashing failed") + .to_string(); + + User::update_password(&state.pool, user.id, &hash) + .await + .expect("failed to update password"); + User::clear_reset_token(&state.pool, user.id) + .await + .expect("failed to clear reset token"); + + session.insert(SESSION_USER_ID_KEY, user.id).await.ok(); + + render(ResetPasswordResultTemplate { + title: "password reset".into(), + current_user: None, + success: true, + message: "Your password has been reset. You are now logged in.".into(), + csrf_token, + }) +} diff --git a/src/handlers/filters.rs b/src/handlers/filters.rs new file mode 100644 index 0000000..836736d --- /dev/null +++ b/src/handlers/filters.rs @@ -0,0 +1,15 @@ +use ammonia::clean; +use pulldown_cmark::{Options, Parser, html::push_html}; + +pub fn render_markdown(text: &str) -> askama::Result { + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_FOOTNOTES); + options.insert(Options::ENABLE_TASKLISTS); + + let mut html = String::new(); + push_html(&mut html, Parser::new_ext(text, options)); + + Ok(clean(&html)) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 545f050..55126da 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,2 +1,3 @@ pub mod auth; +pub mod filters; pub mod thread; diff --git a/src/handlers/thread.rs b/src/handlers/thread.rs index 556c6a0..b9be1b6 100644 --- a/src/handlers/thread.rs +++ b/src/handlers/thread.rs @@ -1,14 +1,16 @@ use askama::Template; use axum::{ - extract::{Form, Path, State}, + extract::{ConnectInfo, Form, Path, State}, response::{IntoResponse, Redirect, Response}, }; use serde::Deserialize; -use sqlx::SqlitePool; +use std::net::SocketAddr; use tower_sessions::Session; +use super::auth::{get_current_user, render}; +use super::filters; +use crate::AppState; use crate::CurrentUser; -use crate::handlers::auth::{get_current_user, render}; use crate::models::{post::Post, thread::Thread, user::User}; // ~~ Templates @@ -19,6 +21,7 @@ pub struct ThreadListTemplate { pub title: String, pub current_user: Option, pub threads: Vec, + pub csrf_token: String, } #[derive(Template)] @@ -27,6 +30,7 @@ pub struct NewThreadTemplate { pub title: String, pub current_user: Option, pub error: Option, + pub csrf_token: String, } #[derive(Template)] @@ -37,6 +41,7 @@ pub struct ThreadViewTemplate { pub thread: ThreadWithAuthor, pub posts: Vec, pub error: Option, + pub csrf_token: String, } #[derive(Template)] @@ -46,6 +51,7 @@ pub struct ProfileTemplate { pub current_user: Option, pub profile_user: User, pub threads: Vec, + pub csrf_token: String, } // ~~ Data wrappers @@ -75,19 +81,31 @@ pub struct NewPostForm { pub body: String, } +// ~~ Helpers + +async fn get_csrf(session: &Session) -> String { + session + .get("csrf_token") + .await + .ok() + .flatten() + .unwrap_or_default() +} + // ~~ Handlers -pub async fn list_threads(State(pool): State, session: Session) -> Response { - let user: Option = get_current_user(&session, &pool).await; +pub async fn list_threads(State(state): State, session: Session) -> Response { + let user: Option = get_current_user(&session, &state.pool).await; let current_user = user.map(|u| CurrentUser { username: u.username, + email_verified: u.email_verified, }); - let threads = Thread::list_all(&pool).await.unwrap_or_default(); + let threads = Thread::list_all(&state.pool).await.unwrap_or_default(); let mut threads_with_authors = Vec::new(); for thread in threads { - let author = User::find_by_id(&pool, thread.author_id) + let author = User::find_by_id(&state.pool, thread.author_id) .await .ok() .flatten(); @@ -100,34 +118,51 @@ pub async fn list_threads(State(pool): State, session: Session) -> R title: "threads".into(), current_user, threads: threads_with_authors, + csrf_token: get_csrf(&session).await, }) } -pub async fn new_thread_page(session: Session, State(pool): State) -> Response { - let user: Option = get_current_user(&session, &pool).await; +pub async fn new_thread_page(session: Session, State(state): State) -> Response { + let user: Option = get_current_user(&session, &state.pool).await; if user.is_none() { return Redirect::to("/auth/login").into_response(); } - let current_user = user.map(|u| CurrentUser { - username: u.username, + let user = user.unwrap(); + if !user.email_verified { + return Redirect::to("/").into_response(); + } + + let current_user = Some(CurrentUser { + username: user.username, + email_verified: user.email_verified, }); render(NewThreadTemplate { title: "new thread".into(), current_user, error: None, + csrf_token: get_csrf(&session).await, }) } pub async fn create_thread( session: Session, - State(pool): State, + State(state): State, + ConnectInfo(addr): ConnectInfo, Form(form): Form, ) -> Response { - let user: Option = get_current_user(&session, &pool).await; + if !state + .rate_limiter + .check_ip(addr.ip(), &state.rate_limiter.create_thread) + { + return (axum::http::StatusCode::TOO_MANY_REQUESTS, "rate limited").into_response(); + } + + let user: Option = get_current_user(&session, &state.pool).await; let current_user = user.clone().map(|u| CurrentUser { username: u.username, + email_verified: u.email_verified, }); let user = match user { @@ -135,15 +170,25 @@ pub async fn create_thread( None => return Redirect::to("/auth/login").into_response(), }; + if !user.email_verified { + return render(NewThreadTemplate { + title: "new thread".into(), + current_user, + error: Some("you must verify your email before creating threads".into()), + csrf_token: get_csrf(&session).await, + }); + } + if form.title.trim().is_empty() || form.body.trim().is_empty() { return render(NewThreadTemplate { title: "new thread".into(), current_user, error: Some("title and body cannot be empty".into()), + csrf_token: get_csrf(&session).await, }); } - let thread_id = Thread::insert(&pool, &form.title, &form.body, user.id) + let thread_id = Thread::insert(&state.pool, &form.title, &form.body, user.id) .await .expect("failed to create thread"); @@ -153,19 +198,20 @@ pub async fn create_thread( pub async fn view_thread( Path(id): Path, session: Session, - State(pool): State, + State(state): State, ) -> Response { - let user: Option = get_current_user(&session, &pool).await; + let user: Option = get_current_user(&session, &state.pool).await; let current_user = user.map(|u| CurrentUser { username: u.username, + email_verified: u.email_verified, }); - let thread = match Thread::find_by_id(&pool, id).await.ok().flatten() { + let thread = match Thread::find_by_id(&state.pool, id).await.ok().flatten() { Some(t) => t, None => return (axum::http::StatusCode::NOT_FOUND, "thread not found").into_response(), }; - let author = match User::find_by_id(&pool, thread.author_id) + let author = match User::find_by_id(&state.pool, thread.author_id) .await .ok() .flatten() @@ -180,11 +226,14 @@ pub async fn view_thread( } }; - let posts = Post::by_thread(&pool, id).await.unwrap_or_default(); + let posts = Post::by_thread(&state.pool, id).await.unwrap_or_default(); let mut posts_with_authors = Vec::new(); for post in posts { - let author = User::find_by_id(&pool, post.author_id).await.ok().flatten(); + let author = User::find_by_id(&state.pool, post.author_id) + .await + .ok() + .flatten(); if let Some(a) = author { posts_with_authors.push(PostWithAuthor { post, author: a }); } @@ -196,18 +245,33 @@ pub async fn view_thread( thread: ThreadWithAuthor { thread, author }, posts: posts_with_authors, error: None, + csrf_token: get_csrf(&session).await, }) } pub async fn create_post( Path(id): Path, session: Session, - State(pool): State, + State(state): State, + ConnectInfo(addr): ConnectInfo, Form(form): Form, ) -> Response { - let user: Option = get_current_user(&session, &pool).await; + if !state + .rate_limiter + .check_ip(addr.ip(), &state.rate_limiter.create_post) + { + return (axum::http::StatusCode::TOO_MANY_REQUESTS, "rate limited").into_response(); + } + + let thread = match Thread::find_by_id(&state.pool, id).await.ok().flatten() { + Some(t) => t, + None => return Redirect::to("/threads").into_response(), + }; + + let user: Option = get_current_user(&session, &state.pool).await; let current_user = user.clone().map(|u| CurrentUser { username: u.username, + email_verified: u.email_verified, }); let user = match user { @@ -215,13 +279,8 @@ pub async fn create_post( None => return Redirect::to("/auth/login").into_response(), }; - let thread = match Thread::find_by_id(&pool, id).await.ok().flatten() { - Some(t) => t, - None => return Redirect::to("/threads").into_response(), - }; - - if form.body.trim().is_empty() { - let author = User::find_by_id(&pool, thread.author_id) + if !user.email_verified { + let author = User::find_by_id(&state.pool, thread.author_id) .await .ok() .flatten() @@ -234,12 +293,63 @@ pub async fn create_post( avatar_url: None, role: "member".into(), created_at: chrono::NaiveDateTime::MIN, + email_verified: false, + verification_token: None, + verification_token_expires: None, + reset_token: None, + reset_token_expires: None, }); - let posts = Post::by_thread(&pool, id).await.unwrap_or_default(); + let posts = Post::by_thread(&state.pool, id).await.unwrap_or_default(); let mut posts_with_authors = Vec::new(); for post in posts { - let author = User::find_by_id(&pool, post.author_id).await.ok().flatten(); + let author = User::find_by_id(&state.pool, post.author_id) + .await + .ok() + .flatten(); + if let Some(a) = author { + posts_with_authors.push(PostWithAuthor { post, author: a }); + } + } + + return render(ThreadViewTemplate { + title: thread.title.clone(), + current_user, + thread: ThreadWithAuthor { thread, author }, + posts: posts_with_authors, + error: Some("you must verify your email before posting".into()), + csrf_token: get_csrf(&session).await, + }); + } + + if form.body.trim().is_empty() { + let author = User::find_by_id(&state.pool, thread.author_id) + .await + .ok() + .flatten() + .unwrap_or_else(|| User { + id: 0, + username: "unknown".into(), + email: "".into(), + password: "".into(), + bio: None, + avatar_url: None, + role: "member".into(), + created_at: chrono::NaiveDateTime::MIN, + email_verified: false, + verification_token: None, + verification_token_expires: None, + reset_token: None, + reset_token_expires: None, + }); + + let posts = Post::by_thread(&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) + .await + .ok() + .flatten(); if let Some(a) = author { posts_with_authors.push(PostWithAuthor { post, author: a }); } @@ -251,10 +361,11 @@ pub async fn create_post( thread: ThreadWithAuthor { thread, author }, posts: posts_with_authors, error: Some("reply cannot be empty".into()), + csrf_token: get_csrf(&session).await, }); } - Post::insert(&pool, &form.body, user.id, thread.id) + Post::insert(&state.pool, &form.body, user.id, thread.id) .await .expect("failed to create post"); @@ -264,14 +375,15 @@ pub async fn create_post( pub async fn view_profile( Path(username): Path, session: Session, - State(pool): State, + State(state): State, ) -> Response { - let user: Option = get_current_user(&session, &pool).await; + let user: Option = get_current_user(&session, &state.pool).await; let current_user = user.map(|u| CurrentUser { username: u.username, + email_verified: u.email_verified, }); - let profile_user = match User::find_by_username(&pool, &username) + let profile_user = match User::find_by_username(&state.pool, &username) .await .ok() .flatten() @@ -280,7 +392,7 @@ pub async fn view_profile( None => return (axum::http::StatusCode::NOT_FOUND, "user not found").into_response(), }; - let threads = Thread::by_author(&pool, profile_user.id) + let threads = Thread::by_author(&state.pool, profile_user.id) .await .unwrap_or_default(); @@ -297,5 +409,6 @@ pub async fn view_profile( current_user, profile_user, threads: threads_with_authors, + csrf_token: get_csrf(&session).await, }) } diff --git a/src/main.rs b/src/main.rs index f121a50..fbcb621 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,21 +1,27 @@ mod db; +mod email; mod handlers; +mod middleware; mod models; use askama::Template; use axum::{ Router, extract::State, + middleware::from_fn, response::{Html, IntoResponse, Response}, routing::{get, post}, }; +use middleware::{RateLimiterStore, csrf_middleware}; use sqlx::SqlitePool; use tower_http::services::ServeDir; use tower_sessions::{SessionManagerLayer, cookie::SameSite}; use tower_sessions_sqlx_store::SqliteStore; use handlers::auth::{ - get_current_user, login_page, login_submit, logout, register_page, register_submit, + forgot_password_page, forgot_password_submit, get_current_user, login_page, login_submit, + logout, register_page, register_submit, resend_verification, reset_password_page, + reset_password_submit, verify_email, }; use handlers::thread::{ create_post, create_thread, list_threads, new_thread_page, view_profile, view_thread, @@ -23,6 +29,7 @@ use handlers::thread::{ pub struct CurrentUser { pub username: String, + pub email_verified: bool, } #[derive(Template)] @@ -30,17 +37,27 @@ pub struct CurrentUser { struct IndexTemplate { title: String, current_user: Option, + csrf_token: String, } -async fn index(State(pool): State, session: tower_sessions::Session) -> Response { - let user: Option = get_current_user(&session, &pool).await; +async fn index(State(state): State, session: tower_sessions::Session) -> Response { + let user: Option = get_current_user(&session, &state.pool).await; let current_user = user.map(|u| CurrentUser { username: u.username, + email_verified: u.email_verified, }); + let csrf_token = session + .get("csrf_token") + .await + .ok() + .flatten() + .unwrap_or_default(); + let tmpl = IndexTemplate { title: "home".into(), current_user, + csrf_token, }; match tmpl.render() { @@ -81,6 +98,34 @@ async fn main() { let bind_addr = std::env::var("SARMENTINE_BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:3000".into()); + let rate_limiter = RateLimiterStore::new(); + + let email_sender = match ( + std::env::var("SARMENTINE_SMTP_HOST"), + std::env::var("SARMENTINE_SMTP_PORT"), + std::env::var("SARMENTINE_SMTP_LOGIN"), + std::env::var("SARMENTINE_SMTP_PASSWORD"), + std::env::var("SARMENTINE_SMTP_FROM"), + ) { + (Ok(host), Ok(port_str), Ok(login), Ok(password), Ok(from)) => { + let port = port_str.parse().unwrap_or(587); + Some(std::sync::Arc::new(email::EmailSender::new( + host, port, login, password, from, + ))) + } + _ => None, + }; + + let base_url = + std::env::var("SARMENTINE_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()); + + let app_state = AppState { + pool: pool.clone(), + rate_limiter, + email_sender, + base_url, + }; + let app = Router::new() .route("/", get(index)) .route("/threads", get(list_threads)) @@ -94,12 +139,32 @@ async fn main() { .route("/auth/login", get(login_page)) .route("/auth/login", post(login_submit)) .route("/auth/logout", post(logout)) + .route("/auth/verify", get(verify_email)) + .route("/auth/resend-verification", post(resend_verification)) + .route("/auth/forgot-password", get(forgot_password_page)) + .route("/auth/forgot-password", post(forgot_password_submit)) + .route("/auth/reset", get(reset_password_page)) + .route("/auth/reset", post(reset_password_submit)) .nest_service("/static", ServeDir::new(&static_dir)) + .layer(from_fn(csrf_middleware)) .layer(session_layer) - .with_state(pool); + .with_state(app_state); let listener = tokio::net::TcpListener::bind(&bind_addr).await.unwrap(); println!("listening on http://{}", bind_addr); - axum::serve(listener, app).await.unwrap(); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); +} + +#[derive(Clone)] +pub struct AppState { + pub pool: SqlitePool, + pub rate_limiter: RateLimiterStore, + pub email_sender: Option>, + pub base_url: String, } diff --git a/src/middleware.rs b/src/middleware.rs new file mode 100644 index 0000000..123ef50 --- /dev/null +++ b/src/middleware.rs @@ -0,0 +1,150 @@ +use std::num::NonZeroU32; +use std::sync::Arc; + +use axum::response::IntoResponse; +use axum::{ + body::to_bytes, + extract::Request, + http::{Method, StatusCode}, + middleware::Next, + response::Response, +}; +use governor::{Quota, RateLimiter}; +use rand::RngCore; +use tower_sessions::Session; + +type IpLimiter = RateLimiter< + std::net::IpAddr, + governor::state::keyed::DefaultKeyedStateStore, + governor::clock::DefaultClock, +>; + +#[derive(Clone)] +pub struct RateLimiterStore { + pub login: Arc, + pub register: Arc, + pub create_thread: Arc, + pub create_post: Arc, + pub forgot_password: Arc, +} + +impl RateLimiterStore { + pub fn new() -> Self { + Self { + login: Arc::new(RateLimiter::keyed( + Quota::per_second(NonZeroU32::new(1).unwrap()) + .allow_burst(NonZeroU32::new(5).unwrap()), + )), + register: Arc::new(RateLimiter::keyed(Quota::per_hour( + NonZeroU32::new(3).unwrap(), + ))), + create_thread: Arc::new(RateLimiter::keyed(Quota::per_hour( + NonZeroU32::new(2).unwrap(), + ))), + create_post: Arc::new(RateLimiter::keyed(Quota::per_hour( + NonZeroU32::new(60).unwrap(), + ))), + forgot_password: Arc::new(RateLimiter::keyed(Quota::per_hour( + NonZeroU32::new(3).unwrap(), + ))), + } + } + + pub fn check_ip(&self, ip: std::net::IpAddr, limiter: &Arc) -> bool { + limiter.check_key(&ip).is_ok() + } +} + +pub async fn csrf_middleware(session: Session, request: Request, next: Next) -> Response { + if request.method() == Method::POST { + let token_from_session: Option = session.get("csrf_token").await.ok().flatten(); + + let token_from_session = match token_from_session { + Some(t) => t, + None => return (StatusCode::FORBIDDEN, "missing csrf token").into_response(), + }; + + let (parts, body) = request.into_parts(); + let bytes = to_bytes(body, usize::MAX).await.unwrap_or_default(); + let body_str = String::from_utf8_lossy(&bytes); + + let form_token = extract_form_value(&body_str, "csrf_token"); + + if form_token.as_deref() != Some(&token_from_session) { + return (StatusCode::FORBIDDEN, "invalid csrf token").into_response(); + } + + let request = Request::from_parts(parts, axum::body::Body::from(bytes)); + return next.run(request).await; + } + + let token: Option = session.get("csrf_token").await.ok().flatten(); + + if token.is_none() { + let new_token = generate_token(); + session.insert("csrf_token", &new_token).await.ok(); + } + + next.run(request).await +} + +fn extract_form_value(body: &str, key: &str) -> Option { + let prefix = &format!("{}=", key); + if let Some(pos) = body.find(prefix) { + let start = pos + prefix.len(); + let rest = &body[start..]; + let end = rest.find('&').unwrap_or(rest.len()); + return Some(url_decode(&rest[..end])); + } + None +} + +fn url_decode(s: &str) -> String { + let mut out = String::new(); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '+' { + out.push(' '); + } else if c == '%' { + let hex: String = chars.by_ref().take(2).collect(); + if let Ok(byte) = u8::from_str_radix(&hex, 16) { + out.push(byte as char); + } else { + out.push('%'); + out.push_str(&hex); + } + } else { + out.push(c); + } + } + out +} + +pub fn generate_token() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + 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() +} diff --git a/src/models/user.rs b/src/models/user.rs index 35f873a..df5ccdf 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -13,14 +13,24 @@ pub struct User { pub avatar_url: Option, pub role: String, pub created_at: NaiveDateTime, + pub email_verified: bool, + pub verification_token: Option, + pub verification_token_expires: Option, + pub reset_token: Option, + pub reset_token_expires: Option, } impl User { pub async fn find_by_id(pool: &SqlitePool, id: i64) -> sqlx::Result> { sqlx::query_as!( User, - r#"SELECT id AS "id!", username, email, password, bio, avatar_url, role, created_at - FROM users WHERE id = ?"#, + 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 = ?"#, id ) .fetch_optional(pool) @@ -30,8 +40,13 @@ impl User { pub async fn find_by_username(pool: &SqlitePool, username: &str) -> sqlx::Result> { sqlx::query_as!( User, - r#"SELECT id AS "id!", username, email, password, bio, avatar_url, role, created_at - FROM users WHERE username = ?"#, + 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 = ?"#, username ) .fetch_optional(pool) @@ -41,14 +56,54 @@ impl User { pub async fn find_by_email(pool: &SqlitePool, email: &str) -> sqlx::Result> { sqlx::query_as!( User, - r#"SELECT id AS "id!", username, email, password, bio, avatar_url, role, created_at - FROM users WHERE email = ?"#, + 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 ) .fetch_optional(pool) .await } + pub async fn find_by_verification_token( + pool: &SqlitePool, + token: &str, + ) -> 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 + FROM users WHERE verification_token = ?"#, + token + ) + .fetch_optional(pool) + .await + } + + pub async fn find_by_reset_token(pool: &SqlitePool, token: &str) -> 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 + FROM users WHERE reset_token = ?"#, + token + ) + .fetch_optional(pool) + .await + } + pub async fn insert( pool: &SqlitePool, username: &str, @@ -64,4 +119,83 @@ impl User { Ok(result.last_insert_rowid()) } + + pub async fn set_verification_token( + pool: &SqlitePool, + user_id: i64, + token: &str, + expires: &NaiveDateTime, + ) -> sqlx::Result<()> { + sqlx::query( + "UPDATE users SET verification_token = ?, verification_token_expires = ? WHERE id = ?", + ) + .bind(token) + .bind(expires) + .bind(user_id) + .execute(pool) + .await?; + + Ok(()) + } + + pub async fn verify_email(pool: &SqlitePool, user_id: i64) -> sqlx::Result<()> { + sqlx::query( + "UPDATE users SET email_verified = TRUE, verification_token = NULL, verification_token_expires = NULL WHERE id = ?", + ) + .bind(user_id) + .execute(pool) + .await?; + + Ok(()) + } + + pub async fn clear_verification_token(pool: &SqlitePool, user_id: i64) -> sqlx::Result<()> { + sqlx::query( + "UPDATE users SET verification_token = NULL, verification_token_expires = NULL WHERE id = ?", + ) + .bind(user_id) + .execute(pool) + .await?; + + Ok(()) + } + + pub async fn set_reset_token( + pool: &SqlitePool, + user_id: i64, + 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?; + + 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?; + + Ok(()) + } + + pub async fn update_password( + pool: &SqlitePool, + user_id: i64, + new_password_hash: &str, + ) -> sqlx::Result<()> { + sqlx::query("UPDATE users SET password = ? WHERE id = ?") + .bind(new_password_hash) + .bind(user_id) + .execute(pool) + .await?; + + Ok(()) + } } diff --git a/templates/auth/check_email.html b/templates/auth/check_email.html new file mode 100644 index 0000000..21de28b --- /dev/null +++ b/templates/auth/check_email.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block content %} +
check your email
+
:: we sent a verification link to {{ email }} ::
+ +
+ +
+
+

We've sent a verification email to {{ email }}.

+

Click the link in the email to verify your account.

+
+
+ +
+
+

Didn't receive it? You can request another:

+
+ + + +
+
+
+{% endblock %} diff --git a/templates/auth/forgot_password.html b/templates/auth/forgot_password.html new file mode 100644 index 0000000..24a2ab1 --- /dev/null +++ b/templates/auth/forgot_password.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block content %} +
forgot password
+
:: no big deal ::
+ +
+ +{% if let Some(err) = error %} +
+
⚠ {{ err }}
+
+{% endif %} + +
+
+ +
+ + +
+ +
+ +
+{% endblock %} diff --git a/templates/auth/forgot_password_result.html b/templates/auth/forgot_password_result.html new file mode 100644 index 0000000..5c31f93 --- /dev/null +++ b/templates/auth/forgot_password_result.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block content %} +
forgot password
+ +
+ +
+
{{ message }}
+
+ +
+ +
+{% endblock %} diff --git a/templates/auth/login.html b/templates/auth/login.html index e4c256e..f12b99d 100644 --- a/templates/auth/login.html +++ b/templates/auth/login.html @@ -14,6 +14,7 @@
+
@@ -26,6 +27,7 @@
{% endblock %} diff --git a/templates/auth/register.html b/templates/auth/register.html index aba543f..dfa8a2a 100644 --- a/templates/auth/register.html +++ b/templates/auth/register.html @@ -14,6 +14,7 @@
+
diff --git a/templates/auth/reset_password.html b/templates/auth/reset_password.html new file mode 100644 index 0000000..b840f76 --- /dev/null +++ b/templates/auth/reset_password.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block content %} +
reset password
+
:: fresh start ::
+ +
+ +{% if let Some(err) = error %} +
+
⚠ {{ err }}
+
+{% endif %} + +
+ + + +
+ + +
+ + +
+{% endblock %} diff --git a/templates/auth/reset_password_result.html b/templates/auth/reset_password_result.html new file mode 100644 index 0000000..029bd79 --- /dev/null +++ b/templates/auth/reset_password_result.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block content %} +
{{ title }}
+ +
+ +
+
+ {{ message }} +
+
+ +
+ +
+{% endblock %} diff --git a/templates/auth/verify_result.html b/templates/auth/verify_result.html new file mode 100644 index 0000000..bf62106 --- /dev/null +++ b/templates/auth/verify_result.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block content %} +
{{ title }}
+ +
+ +
+
+ {{ message }} +
+
+ +
+ +
+{% endblock %} diff --git a/templates/base.html b/templates/base.html index e779d97..55227d1 100644 --- a/templates/base.html +++ b/templates/base.html @@ -19,6 +19,7 @@ {% if let Some(user) = current_user %} {{ user.username }}
+
{% else %} diff --git a/templates/index.html b/templates/index.html index 32f6a0f..d73ad43 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,6 +6,17 @@
+{% if let Some(user) = current_user %} +{% if !user.email_verified %} +
+
+ Your email is not verified. Check your inbox for a verification link, or + request a new one. +
+
+{% endif %} +{% endif %} +
recent threads
diff --git a/templates/threads/new.html b/templates/threads/new.html index e3397b5..6e1236a 100644 --- a/templates/threads/new.html +++ b/templates/threads/new.html @@ -10,6 +10,7 @@ {% endif %}
+
@@ -17,6 +18,7 @@
+
markdown is supported
diff --git a/templates/threads/view.html b/templates/threads/view.html index 17d0e59..06ad45a 100644 --- a/templates/threads/view.html +++ b/templates/threads/view.html @@ -2,13 +2,13 @@ {% block content %}
- threads / {{ thread.thread.title }} + threads :: {{ thread.thread.title }}
{{ thread.thread.title }}
by {{ thread.author.username }} · {{ thread.thread.created_at }}
-
{{ thread.thread.body }}
+
{{ thread.thread.body|render_markdown|safe }}
{% if let Some(error) = error %} @@ -22,7 +22,7 @@ {% for pwa in posts %}
by {{ pwa.author.username }} · {{ pwa.post.created_at }}
-
{{ pwa.post.body }}
+
{{ pwa.post.body|render_markdown|safe }}
{% endfor %} {% endif %} @@ -30,8 +30,10 @@ {% if let Some(user) = current_user %}
+
+
markdown is supported