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 %}
+
+{% 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
+
+
+
+
+
+
+{% 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 @@