added email auth and posting
really coming together now.
This commit is contained in:
parent
e036304b78
commit
8d4042e31a
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
/target
|
||||
/data
|
||||
sarmentine.db*
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@ -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"
|
||||
}
|
||||
92
.sqlx/query-1fdfa7429dc20e693f147c5184e7b2299821d822590a978bed7bdf0f0f7917e3.json
generated
Normal file
92
.sqlx/query-1fdfa7429dc20e693f147c5184e7b2299821d822590a978bed7bdf0f0f7917e3.json
generated
Normal file
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
92
.sqlx/query-ebb6edb02bb7b9e2f4d7acd344790f3bdcae506ad1c604dd954b6f091e4c66f2.json
generated
Normal file
92
.sqlx/query-ebb6edb02bb7b9e2f4d7acd344790f3bdcae506ad1c604dd954b6f091e4c66f2.json
generated
Normal file
@ -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"
|
||||
}
|
||||
620
Cargo.lock
generated
620
Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
3
migrations/0004_email_verification.sql
Normal file
3
migrations/0004_email_verification.sql
Normal file
@ -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;
|
||||
2
migrations/0005_password_reset.sql
Normal file
2
migrations/0005_password_reset.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE users ADD COLUMN reset_token TEXT;
|
||||
ALTER TABLE users ADD COLUMN reset_token_expires DATETIME;
|
||||
@ -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)
|
||||
|
||||
121
src/email.rs
Normal file
121
src/email.rs
Normal file
@ -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!(
|
||||
"<html><body style=\"font-family:monospace;color:#e0e0e0;background:#1a1a2e;padding:20px;\">\
|
||||
<h2 style=\"color:#d4879c;\">Welcome to sarmentine!</h2>\
|
||||
<p>Please verify your email by clicking the link below:</p>\
|
||||
<p><a href=\"{}\" style=\"color:#d4879c;\">Verify Email</a></p>\
|
||||
<p style=\"color:#888;font-size:12px;\">If you did not create an account, you can ignore this email.</p>\
|
||||
</body></html>",
|
||||
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!(
|
||||
"<html><body style=\"font-family:monospace;color:#e0e0e0;background:#1a1a2e;padding:20px;\">\
|
||||
<h2 style=\"color:#d4879c;\">Password Reset</h2>\
|
||||
<p>Click the link below to set a new password:</p>\
|
||||
<p><a href=\"{}\" style=\"color:#d4879c;\">Reset Password</a></p>\
|
||||
<p style=\"color:#888;font-size:12px;\">This link expires in 4 hours. If you did not request this, you can ignore this email.</p>\
|
||||
</body></html>",
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@ -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<crate::CurrentUser>,
|
||||
pub error: Option<String>,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
@ -31,6 +34,26 @@ pub struct LoginTemplate {
|
||||
pub title: String,
|
||||
pub current_user: Option<crate::CurrentUser>,
|
||||
pub error: Option<String>,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "auth/check_email.html")]
|
||||
pub struct CheckEmailTemplate {
|
||||
pub title: String,
|
||||
pub current_user: Option<crate::CurrentUser>,
|
||||
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<crate::CurrentUser>,
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
pub fn render<T: Template>(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<SqlitePool>,
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
session: Session,
|
||||
Form(form): Form<RegisterForm>,
|
||||
) -> 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> = User::find_by_username(&pool, &form.username)
|
||||
let existing_user: Option<User> = 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> = User::find_by_email(&pool, &form.email)
|
||||
let existing_email: Option<User> = 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),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login_page() -> Response {
|
||||
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(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<SqlitePool>,
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
session: Session,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> 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> = User::find_by_username(&pool, &form.username)
|
||||
let user: Option<User> = 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<User> {
|
||||
pub async fn get_current_user(session: &Session, pool: &sqlx::SqlitePool) -> Option<User> {
|
||||
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<AppState>,
|
||||
Query(query): Query<VerifyQuery>,
|
||||
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<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
session: Session,
|
||||
Form(form): Form<ResendVerificationForm>,
|
||||
) -> 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<crate::CurrentUser>,
|
||||
pub error: Option<String>,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "auth/forgot_password_result.html")]
|
||||
pub struct ForgotPasswordResultTemplate {
|
||||
pub title: String,
|
||||
pub current_user: Option<crate::CurrentUser>,
|
||||
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<crate::CurrentUser>,
|
||||
pub error: Option<String>,
|
||||
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<crate::CurrentUser>,
|
||||
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<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
session: Session,
|
||||
Form(form): Form<ForgotPasswordForm>,
|
||||
) -> 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<AppState>,
|
||||
Query(query): Query<ResetQuery>,
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
Form(form): Form<ResetPasswordForm>,
|
||||
) -> 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,
|
||||
})
|
||||
}
|
||||
|
||||
15
src/handlers/filters.rs
Normal file
15
src/handlers/filters.rs
Normal file
@ -0,0 +1,15 @@
|
||||
use ammonia::clean;
|
||||
use pulldown_cmark::{Options, Parser, html::push_html};
|
||||
|
||||
pub fn render_markdown(text: &str) -> askama::Result<String> {
|
||||
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))
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
pub mod auth;
|
||||
pub mod filters;
|
||||
pub mod thread;
|
||||
|
||||
@ -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<CurrentUser>,
|
||||
pub threads: Vec<ThreadWithAuthor>,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
@ -27,6 +30,7 @@ pub struct NewThreadTemplate {
|
||||
pub title: String,
|
||||
pub current_user: Option<CurrentUser>,
|
||||
pub error: Option<String>,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
@ -37,6 +41,7 @@ pub struct ThreadViewTemplate {
|
||||
pub thread: ThreadWithAuthor,
|
||||
pub posts: Vec<PostWithAuthor>,
|
||||
pub error: Option<String>,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
@ -46,6 +51,7 @@ pub struct ProfileTemplate {
|
||||
pub current_user: Option<CurrentUser>,
|
||||
pub profile_user: User,
|
||||
pub threads: Vec<ThreadWithAuthor>,
|
||||
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<SqlitePool>, session: Session) -> Response {
|
||||
let user: Option<User> = get_current_user(&session, &pool).await;
|
||||
pub async fn list_threads(State(state): State<AppState>, session: Session) -> Response {
|
||||
let user: Option<User> = 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<SqlitePool>, 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<SqlitePool>) -> Response {
|
||||
let user: Option<User> = get_current_user(&session, &pool).await;
|
||||
pub async fn new_thread_page(session: Session, State(state): State<AppState>) -> Response {
|
||||
let user: Option<User> = 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<SqlitePool>,
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
Form(form): Form<NewThreadForm>,
|
||||
) -> Response {
|
||||
let user: Option<User> = 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<User> = 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<i64>,
|
||||
session: Session,
|
||||
State(pool): State<SqlitePool>,
|
||||
State(state): State<AppState>,
|
||||
) -> Response {
|
||||
let user: Option<User> = get_current_user(&session, &pool).await;
|
||||
let user: Option<User> = 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<i64>,
|
||||
session: Session,
|
||||
State(pool): State<SqlitePool>,
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
Form(form): Form<NewPostForm>,
|
||||
) -> Response {
|
||||
let user: Option<User> = 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<User> = 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<String>,
|
||||
session: Session,
|
||||
State(pool): State<SqlitePool>,
|
||||
State(state): State<AppState>,
|
||||
) -> Response {
|
||||
let user: Option<User> = get_current_user(&session, &pool).await;
|
||||
let user: Option<User> = 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,
|
||||
})
|
||||
}
|
||||
|
||||
75
src/main.rs
75
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<CurrentUser>,
|
||||
csrf_token: String,
|
||||
}
|
||||
|
||||
async fn index(State(pool): State<SqlitePool>, session: tower_sessions::Session) -> Response {
|
||||
let user: Option<models::user::User> = get_current_user(&session, &pool).await;
|
||||
async fn index(State(state): State<AppState>, session: tower_sessions::Session) -> Response {
|
||||
let user: Option<models::user::User> = 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::<std::net::SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: SqlitePool,
|
||||
pub rate_limiter: RateLimiterStore,
|
||||
pub email_sender: Option<std::sync::Arc<email::EmailSender>>,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
150
src/middleware.rs
Normal file
150
src/middleware.rs
Normal file
@ -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<std::net::IpAddr>,
|
||||
governor::clock::DefaultClock,
|
||||
>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RateLimiterStore {
|
||||
pub login: Arc<IpLimiter>,
|
||||
pub register: Arc<IpLimiter>,
|
||||
pub create_thread: Arc<IpLimiter>,
|
||||
pub create_post: Arc<IpLimiter>,
|
||||
pub forgot_password: Arc<IpLimiter>,
|
||||
}
|
||||
|
||||
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<IpLimiter>) -> 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<String> = 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<String> = 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<String> {
|
||||
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()
|
||||
}
|
||||
@ -13,13 +13,23 @@ pub struct User {
|
||||
pub avatar_url: Option<String>,
|
||||
pub role: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub email_verified: bool,
|
||||
pub verification_token: Option<String>,
|
||||
pub verification_token_expires: Option<NaiveDateTime>,
|
||||
pub reset_token: Option<String>,
|
||||
pub reset_token_expires: Option<NaiveDateTime>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub async fn find_by_id(pool: &SqlitePool, id: i64) -> sqlx::Result<Option<User>> {
|
||||
sqlx::query_as!(
|
||||
User,
|
||||
r#"SELECT id AS "id!", username, email, password, bio, avatar_url, role, created_at
|
||||
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
|
||||
)
|
||||
@ -30,7 +40,12 @@ impl User {
|
||||
pub async fn find_by_username(pool: &SqlitePool, username: &str) -> sqlx::Result<Option<User>> {
|
||||
sqlx::query_as!(
|
||||
User,
|
||||
r#"SELECT id AS "id!", username, email, password, bio, avatar_url, role, created_at
|
||||
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
|
||||
)
|
||||
@ -41,7 +56,12 @@ impl User {
|
||||
pub async fn find_by_email(pool: &SqlitePool, email: &str) -> sqlx::Result<Option<User>> {
|
||||
sqlx::query_as!(
|
||||
User,
|
||||
r#"SELECT id AS "id!", username, email, password, bio, avatar_url, role, created_at
|
||||
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
|
||||
)
|
||||
@ -49,6 +69,41 @@ impl User {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_verification_token(
|
||||
pool: &SqlitePool,
|
||||
token: &str,
|
||||
) -> sqlx::Result<Option<User>> {
|
||||
sqlx::query_as!(
|
||||
User,
|
||||
r#"SELECT id AS "id!", username, email, password, bio, avatar_url, role, created_at,
|
||||
email_verified AS "email_verified!",
|
||||
verification_token,
|
||||
verification_token_expires,
|
||||
reset_token,
|
||||
reset_token_expires
|
||||
FROM users WHERE verification_token = ?"#,
|
||||
token
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_reset_token(pool: &SqlitePool, token: &str) -> sqlx::Result<Option<User>> {
|
||||
sqlx::query_as!(
|
||||
User,
|
||||
r#"SELECT id AS "id!", username, email, password, bio, avatar_url, role, created_at,
|
||||
email_verified AS "email_verified!",
|
||||
verification_token,
|
||||
verification_token_expires,
|
||||
reset_token,
|
||||
reset_token_expires
|
||||
FROM users WHERE reset_token = ?"#,
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
26
templates/auth/check_email.html
Normal file
26
templates/auth/check_email.html
Normal file
@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="site-title">check your email</div>
|
||||
<div class="site-subtitle">:: we sent a verification link to {{ email }} ::</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="post">
|
||||
<div class="post-body">
|
||||
<p>We've sent a verification email to <strong>{{ email }}</strong>.</p>
|
||||
<p>Click the link in the email to verify your account.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post" style="margin-top:12px;">
|
||||
<div class="post-body">
|
||||
<p style="color:#888;font-size:12px;">Didn't receive it? You can request another:</p>
|
||||
<form method="post" action="/auth/resend-verification">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="email" value="{{ email }}">
|
||||
<button class="form-submit" type="submit">resend verification →</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
28
templates/auth/forgot_password.html
Normal file
28
templates/auth/forgot_password.html
Normal file
@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="site-title">forgot password</div>
|
||||
<div class="site-subtitle">:: no big deal ::</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
{% if let Some(err) = error %}
|
||||
<div class="post" style="border-color:#d4879c; margin-bottom:12px;">
|
||||
<div class="post-body" style="color:#d4879c;">⚠ {{ err }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="post">
|
||||
<form method="post" action="/auth/forgot-password">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-field">
|
||||
<label class="form-label">email</label>
|
||||
<input class="form-input" type="email" name="email" required autocomplete="email">
|
||||
</div>
|
||||
<button class="form-submit" type="submit">send reset link →</button>
|
||||
</form>
|
||||
<div class="post-meta" style="margin-top:10px;">
|
||||
remember your password? <a href="/auth/login" style="color:#d4879c;">login here</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
18
templates/auth/forgot_password_result.html
Normal file
18
templates/auth/forgot_password_result.html
Normal file
@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="site-title">forgot password</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="post">
|
||||
<div class="post-body">{{ message }}</div>
|
||||
</div>
|
||||
|
||||
<div class="post" style="margin-top:12px;">
|
||||
<div class="post-meta">
|
||||
<a href="/auth/login" style="color:#d4879c;">login</a>
|
||||
· <a href="/" style="color:#d4879c;">go home</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -14,6 +14,7 @@
|
||||
|
||||
<div class="post">
|
||||
<form method="post" action="/auth/login">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-field">
|
||||
<label class="form-label">username</label>
|
||||
<input class="form-input" type="text" name="username" required autocomplete="username">
|
||||
@ -26,6 +27,7 @@
|
||||
</form>
|
||||
<div class="post-meta" style="margin-top:10px;">
|
||||
new here? <a href="/auth/register" style="color:#d4879c;">register here</a>
|
||||
· <a href="/auth/forgot-password" style="color:#888;">forgot password?</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
|
||||
<div class="post">
|
||||
<form method="post" action="/auth/register">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-field">
|
||||
<label class="form-label">username</label>
|
||||
<input class="form-input" type="text" name="username" required minlength="2" maxlength="32" autocomplete="username">
|
||||
|
||||
26
templates/auth/reset_password.html
Normal file
26
templates/auth/reset_password.html
Normal file
@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="site-title">reset password</div>
|
||||
<div class="site-subtitle">:: fresh start ::</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
{% if let Some(err) = error %}
|
||||
<div class="post" style="border-color:#d4879c; margin-bottom:12px;">
|
||||
<div class="post-body" style="color:#d4879c;">⚠ {{ err }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="post">
|
||||
<form method="post" action="/auth/reset">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="token" value="{{ token }}">
|
||||
<div class="form-field">
|
||||
<label class="form-label">new password</label>
|
||||
<input class="form-input" type="password" name="password" required minlength="8" autocomplete="new-password">
|
||||
</div>
|
||||
<button class="form-submit" type="submit">reset password →</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
19
templates/auth/reset_password_result.html
Normal file
19
templates/auth/reset_password_result.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="site-title">{{ title }}</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="post" style="border-color:{% if success %}#7ab648{% else %}#d4879c{% endif %};">
|
||||
<div class="post-body" style="color:{% if success %}#7ab648{% else %}#d4879c{% endif %};">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post" style="margin-top:12px;">
|
||||
<div class="post-meta">
|
||||
<a href="/" style="color:#d4879c;">go home</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
22
templates/auth/verify_result.html
Normal file
22
templates/auth/verify_result.html
Normal file
@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="site-title">{{ title }}</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="post" style="border-color:{% if success %}#7ab648{% else %}#d4879c{% endif %};">
|
||||
<div class="post-body" style="color:{% if success %}#7ab648{% else %}#d4879c{% endif %};">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post" style="margin-top:12px;">
|
||||
<div class="post-meta">
|
||||
<a href="/" style="color:#d4879c;">go home</a>
|
||||
{% if !success %}
|
||||
· <a href="/auth/login" style="color:#d4879c;">login</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -19,6 +19,7 @@
|
||||
{% if let Some(user) = current_user %}
|
||||
<a href="/profile/{{ user.username }}">{{ user.username }}</a>
|
||||
<form method="post" action="/auth/logout" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="nav-logout">logout</button>
|
||||
</form>
|
||||
{% else %}
|
||||
|
||||
@ -6,6 +6,17 @@
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
{% if let Some(user) = current_user %}
|
||||
{% if !user.email_verified %}
|
||||
<div class="post" style="border-color:#d4879c; margin-bottom:12px;">
|
||||
<div class="post-body" style="color:#d4879c;">
|
||||
Your email is not verified. Check your inbox for a verification link, or
|
||||
<a href="/auth/login" style="color:#d4879c;">request a new one</a>.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div style="font-family:'Silkscreen',monospace; font-size:11px; color:#d4879c; margin-bottom:8px; text-transform:uppercase;">recent threads</div>
|
||||
|
||||
<div class="post">
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/threads">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div style="margin-bottom:8px;">
|
||||
<label style="display:block; font-size:11px; color:#888; margin-bottom:4px;">TITLE</label>
|
||||
<input type="text" name="title" required maxlength="200" style="width:100%; padding:6px; background:#1a1a2e; border:1px solid #333; color:#e0e0e0; font-family:'VT323',monospace; font-size:14px;">
|
||||
@ -17,6 +18,7 @@
|
||||
<div style="margin-bottom:8px;">
|
||||
<label style="display:block; font-size:11px; color:#888; margin-bottom:4px;">BODY</label>
|
||||
<textarea name="body" required rows="10" style="width:100%; padding:6px; background:#1a1a2e; border:1px solid #333; color:#e0e0e0; font-family:'VT323',monospace; font-size:14px; resize:vertical;"></textarea>
|
||||
<div style="color:#666; font-size:10px; margin-top:4px;">markdown is supported</div>
|
||||
</div>
|
||||
<button type="submit" class="btn" style="margin-top:8px;">create thread</button>
|
||||
</form>
|
||||
|
||||
@ -2,13 +2,13 @@
|
||||
|
||||
{% block content %}
|
||||
<div style="font-family:'Silkscreen',monospace; font-size:14px; color:#d4879c; margin-bottom:12px;">
|
||||
<a href="/threads" style="color:#888;">threads</a> / {{ thread.thread.title }}
|
||||
<a href="/threads" style="color:#888;">threads</a> :: {{ thread.thread.title }}
|
||||
</div>
|
||||
|
||||
<div class="post">
|
||||
<div class="post-title">{{ thread.thread.title }}</div>
|
||||
<div class="post-body" style="color:#888; font-size:11px; margin-bottom:8px;">by <a href="/profile/{{ thread.author.username }}">{{ thread.author.username }}</a> · {{ thread.thread.created_at }}</div>
|
||||
<div class="post-body" style="white-space:pre-wrap;">{{ thread.thread.body }}</div>
|
||||
<div class="post-body markdown-content">{{ thread.thread.body|render_markdown|safe }}</div>
|
||||
</div>
|
||||
|
||||
{% if let Some(error) = error %}
|
||||
@ -22,7 +22,7 @@
|
||||
{% for pwa in posts %}
|
||||
<div class="post">
|
||||
<div class="post-body" style="color:#888; font-size:11px; margin-bottom:8px;">by <a href="/profile/{{ pwa.author.username }}">{{ pwa.author.username }}</a> · {{ pwa.post.created_at }}</div>
|
||||
<div class="post-body" style="white-space:pre-wrap;">{{ pwa.post.body }}</div>
|
||||
<div class="post-body markdown-content">{{ pwa.post.body|render_markdown|safe }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@ -30,8 +30,10 @@
|
||||
{% if let Some(user) = current_user %}
|
||||
<div style="margin-top:16px;">
|
||||
<form method="post" action="/threads/{{ thread.thread.id }}/posts">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div style="margin-bottom:8px;">
|
||||
<textarea name="body" required rows="4" style="width:100%; padding:6px; background:#1a1a2e; border:1px solid #333; color:#e0e0e0; font-family:'VT323',monospace; font-size:14px; resize:vertical;"></textarea>
|
||||
<div style="color:#666; font-size:10px; margin-top:4px;">markdown is supported</div>
|
||||
</div>
|
||||
<button type="submit" class="btn">reply</button>
|
||||
</form>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user