added email auth and posting
Some checks failed
CI / Check (push) Failing after 20s
CI / Build & Push Docker Image (push) Has been skipped
CI / Deploy to Rocky (push) Has been skipped

really coming together now.
This commit is contained in:
Butter 2026-05-04 17:20:07 -04:00
parent e036304b78
commit 8d4042e31a
31 changed files with 2220 additions and 82 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
/target /target
/data
sarmentine.db* sarmentine.db*
.env .env
.env.* .env.*

View File

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -42,6 +42,31 @@
"name": "created_at", "name": "created_at",
"ordinal": 7, "ordinal": 7,
"type_info": "Datetime" "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": { "parameters": {
@ -55,8 +80,13 @@
true, true,
true, true,
false, false,
false false,
false,
true,
true,
true,
true
] ]
}, },
"hash": "d12d0743a4bbe18465adb69102e3f7dc70ac567c7610cc9aa6f9c6bd84be6a6b" "hash": "03e12aecfba017ed94a3dcd86e82454d8111c884d2133090ac76c341317f4a9a"
} }

View 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"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -42,6 +42,31 @@
"name": "created_at", "name": "created_at",
"ordinal": 7, "ordinal": 7,
"type_info": "Datetime" "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": { "parameters": {
@ -55,8 +80,13 @@
true, true,
true, true,
false, false,
false false,
false,
true,
true,
true,
true
] ]
}, },
"hash": "59b62edeaea0297e51ae3d1d973f46cc1a0786ad5f86bf5f539d12f728136f1d" "hash": "a4c396e317e9a596629e91d1deefa19df638161c6a7a87427cb1f298aaa545ff"
} }

View File

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -42,6 +42,31 @@
"name": "created_at", "name": "created_at",
"ordinal": 7, "ordinal": 7,
"type_info": "Datetime" "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": { "parameters": {
@ -55,8 +80,13 @@
true, true,
true, true,
false, false,
false false,
false,
true,
true,
true,
true
] ]
}, },
"hash": "9496103101618f59b785c0c8d2cee4f6053055cb06db77dc8033c7e31d503a77" "hash": "b949d7b0995654aa1b68bb5b993e3af267b206d42f0342c0ff9cfc15028a862b"
} }

View 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
View File

@ -21,6 +21,19 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 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]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.5" version = "0.1.5"
@ -100,7 +113,7 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
dependencies = [ dependencies = [
"nom", "nom 7.1.3",
] ]
[[package]] [[package]]
@ -369,6 +382,42 @@ dependencies = [
"typenum", "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]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@ -419,6 +468,21 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" 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]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@ -428,6 +492,22 @@ dependencies = [
"serde", "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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@ -499,6 +579,16 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.32"
@ -507,6 +597,7 @@ checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-executor",
"futures-io", "futures-io",
"futures-sink", "futures-sink",
"futures-task", "futures-task",
@ -580,12 +671,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.32" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-macro", "futures-macro",
@ -606,6 +704,15 @@ dependencies = [
"version_check", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@ -642,6 +749,26 @@ dependencies = [
"wasip3", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@ -724,6 +851,17 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@ -995,6 +1133,33 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" 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]] [[package]]
name = "libc" name = "libc"
version = "0.2.184" version = "0.2.184"
@ -1058,6 +1223,40 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 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]] [[package]]
name = "matchit" name = "matchit"
version = "0.7.3" version = "0.7.3"
@ -1113,6 +1312,18 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@ -1123,6 +1334,21 @@ dependencies = [
"minimal-lexical", "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]] [[package]]
name = "num-bigint-dig" name = "num-bigint-dig"
version = "0.8.6" version = "0.8.6"
@ -1236,6 +1462,58 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 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]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@ -1275,6 +1553,12 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@ -1299,6 +1583,12 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@ -1318,6 +1608,40 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@ -1327,6 +1651,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "quoted_printable"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "5.3.0" version = "5.3.0"
@ -1369,6 +1699,15 @@ dependencies = [
"getrandom 0.2.17", "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]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@ -1387,6 +1726,20 @@ dependencies = [
"bitflags", "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]] [[package]]
name = "rmp" name = "rmp"
version = "0.8.15" version = "0.8.15"
@ -1439,6 +1792,41 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@ -1455,13 +1843,19 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
name = "sarmentine" name = "sarmentine"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ammonia",
"anyhow", "anyhow",
"argon2", "argon2",
"askama", "askama",
"askama_axum", "askama_axum",
"axum", "axum",
"base64 0.22.1",
"chrono", "chrono",
"dotenvy", "dotenvy",
"governor",
"lettre",
"pulldown-cmark",
"rand",
"rand_core", "rand_core",
"serde", "serde",
"sqlx", "sqlx",
@ -1597,6 +1991,12 @@ dependencies = [
"rand_core", "rand_core",
] ]
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@ -1628,6 +2028,15 @@ dependencies = [
"lock_api", "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]] [[package]]
name = "spki" name = "spki"
version = "0.7.3" version = "0.7.3"
@ -1644,7 +2053,7 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790"
dependencies = [ dependencies = [
"nom", "nom 7.1.3",
"unicode_categories", "unicode_categories",
] ]
@ -1856,6 +2265,31 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 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]] [[package]]
name = "stringprep" name = "stringprep"
version = "0.1.5" version = "0.1.5"
@ -1925,6 +2359,17 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@ -2029,6 +2474,16 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "tokio-stream" name = "tokio-stream"
version = "0.1.18" version = "0.1.18"
@ -2265,6 +2720,12 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.6" version = "0.2.6"
@ -2277,6 +2738,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@ -2286,6 +2753,7 @@ dependencies = [
"form_urlencoded", "form_urlencoded",
"idna", "idna",
"percent-encoding", "percent-encoding",
"serde",
] ]
[[package]] [[package]]
@ -2294,6 +2762,12 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@ -2421,6 +2895,37 @@ dependencies = [
"semver", "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]] [[package]]
name = "whoami" name = "whoami"
version = "1.6.1" version = "1.6.1"
@ -2431,6 +2936,28 @@ dependencies = [
"wasite", "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]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@ -2496,7 +3023,16 @@ version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [ 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]] [[package]]
@ -2514,13 +3050,29 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm", "windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc", "windows_aarch64_msvc 0.48.5",
"windows_i686_gnu", "windows_i686_gnu 0.48.5",
"windows_i686_msvc", "windows_i686_msvc 0.48.5",
"windows_x86_64_gnu", "windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm", "windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc", "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]] [[package]]
@ -2529,42 +3081,90 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.48.5" version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.48.5" version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 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]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.48.5" version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.48.5" version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.48.5" version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.48.5" version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 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]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"

View File

@ -19,3 +19,9 @@ serde = { version = "1", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
# Error handling # Error handling
anyhow = "1" 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"

View 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;

View File

@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN reset_token TEXT;
ALTER TABLE users ADD COLUMN reset_token_expires DATETIME;

View File

@ -1,6 +1,14 @@
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions}; use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
pub async fn connect(database_url: &str) -> SqlitePool { 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() let pool = SqlitePoolOptions::new()
.max_connections(5) .max_connections(5)
.connect(database_url) .connect(database_url)

121
src/email.rs Normal file
View 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(())
}
}

View File

@ -4,13 +4,15 @@ use argon2::{
}; };
use askama::Template; use askama::Template;
use axum::{ use axum::{
extract::{Form, State}, extract::{ConnectInfo, Form, Query, State},
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
use serde::Deserialize; use serde::Deserialize;
use sqlx::SqlitePool; use std::net::SocketAddr;
use tower_sessions::Session; use tower_sessions::Session;
use crate::AppState;
use crate::middleware::generate_token;
use crate::models::user::User; use crate::models::user::User;
const SESSION_USER_ID_KEY: &str = "user_id"; const SESSION_USER_ID_KEY: &str = "user_id";
@ -23,6 +25,7 @@ pub struct RegisterTemplate {
pub title: String, pub title: String,
pub current_user: Option<crate::CurrentUser>, pub current_user: Option<crate::CurrentUser>,
pub error: Option<String>, pub error: Option<String>,
pub csrf_token: String,
} }
#[derive(Template)] #[derive(Template)]
@ -31,6 +34,26 @@ pub struct LoginTemplate {
pub title: String, pub title: String,
pub current_user: Option<crate::CurrentUser>, pub current_user: Option<crate::CurrentUser>,
pub error: Option<String>, 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 { pub fn render<T: Template>(t: T) -> Response {
@ -61,25 +84,48 @@ pub struct LoginForm {
// ~~ Handlers // ~~ 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 { render(RegisterTemplate {
title: "register".into(), title: "register".into(),
current_user: None, current_user: None,
error: None, error: None,
csrf_token,
}) })
} }
pub async fn register_submit( pub async fn register_submit(
State(pool): State<SqlitePool>, State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
session: Session, session: Session,
Form(form): Form<RegisterForm>, Form(form): Form<RegisterForm>,
) -> Response { ) -> 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 { macro_rules! register_err {
($msg:expr) => { ($msg:expr) => {
let csrf_token: String = session
.get("csrf_token")
.await
.ok()
.flatten()
.unwrap_or_default();
return render(RegisterTemplate { return render(RegisterTemplate {
title: "register".into(), title: "register".into(),
current_user: None, current_user: None,
error: Some($msg.into()), error: Some($msg.into()),
csrf_token,
}) })
}; };
} }
@ -91,14 +137,14 @@ pub async fn register_submit(
register_err!("password must be at least 8 characters"); 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 .await
.unwrap_or(None); .unwrap_or(None);
if existing_user.is_some() { if existing_user.is_some() {
register_err!("that username is already taken"); 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 .await
.unwrap_or(None); .unwrap_or(None);
if existing_email.is_some() { if existing_email.is_some() {
@ -111,37 +157,86 @@ pub async fn register_submit(
.expect("password hashing failed") .expect("password hashing failed")
.to_string(); .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 .await
.expect("failed to insert user"); .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 { render(LoginTemplate {
title: "login".into(), title: "login".into(),
current_user: None, current_user: None,
error: None, error: None,
csrf_token,
}) })
} }
pub async fn login_submit( pub async fn login_submit(
State(pool): State<SqlitePool>, State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
session: Session, session: Session,
Form(form): Form<LoginForm>, Form(form): Form<LoginForm>,
) -> Response { ) -> 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 fail = || {
let csrf_token = csrf_token.clone();
render(LoginTemplate { render(LoginTemplate {
title: "login".into(), title: "login".into(),
current_user: None, current_user: None,
error: Some("incorrect username or password".into()), 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 .await
.unwrap_or(None); .unwrap_or(None);
@ -170,7 +265,399 @@ pub async fn logout(session: Session) -> Response {
// ~~ Session helper // ~~ 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()??; let user_id: i64 = session.get(SESSION_USER_ID_KEY).await.ok()??;
User::find_by_id(pool, user_id).await.unwrap_or(None) 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
View 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))
}

View File

@ -1,2 +1,3 @@
pub mod auth; pub mod auth;
pub mod filters;
pub mod thread; pub mod thread;

View File

@ -1,14 +1,16 @@
use askama::Template; use askama::Template;
use axum::{ use axum::{
extract::{Form, Path, State}, extract::{ConnectInfo, Form, Path, State},
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
use serde::Deserialize; use serde::Deserialize;
use sqlx::SqlitePool; use std::net::SocketAddr;
use tower_sessions::Session; use tower_sessions::Session;
use super::auth::{get_current_user, render};
use super::filters;
use crate::AppState;
use crate::CurrentUser; use crate::CurrentUser;
use crate::handlers::auth::{get_current_user, render};
use crate::models::{post::Post, thread::Thread, user::User}; use crate::models::{post::Post, thread::Thread, user::User};
// ~~ Templates // ~~ Templates
@ -19,6 +21,7 @@ pub struct ThreadListTemplate {
pub title: String, pub title: String,
pub current_user: Option<CurrentUser>, pub current_user: Option<CurrentUser>,
pub threads: Vec<ThreadWithAuthor>, pub threads: Vec<ThreadWithAuthor>,
pub csrf_token: String,
} }
#[derive(Template)] #[derive(Template)]
@ -27,6 +30,7 @@ pub struct NewThreadTemplate {
pub title: String, pub title: String,
pub current_user: Option<CurrentUser>, pub current_user: Option<CurrentUser>,
pub error: Option<String>, pub error: Option<String>,
pub csrf_token: String,
} }
#[derive(Template)] #[derive(Template)]
@ -37,6 +41,7 @@ pub struct ThreadViewTemplate {
pub thread: ThreadWithAuthor, pub thread: ThreadWithAuthor,
pub posts: Vec<PostWithAuthor>, pub posts: Vec<PostWithAuthor>,
pub error: Option<String>, pub error: Option<String>,
pub csrf_token: String,
} }
#[derive(Template)] #[derive(Template)]
@ -46,6 +51,7 @@ pub struct ProfileTemplate {
pub current_user: Option<CurrentUser>, pub current_user: Option<CurrentUser>,
pub profile_user: User, pub profile_user: User,
pub threads: Vec<ThreadWithAuthor>, pub threads: Vec<ThreadWithAuthor>,
pub csrf_token: String,
} }
// ~~ Data wrappers // ~~ Data wrappers
@ -75,19 +81,31 @@ pub struct NewPostForm {
pub body: String, pub body: String,
} }
// ~~ Helpers
async fn get_csrf(session: &Session) -> String {
session
.get("csrf_token")
.await
.ok()
.flatten()
.unwrap_or_default()
}
// ~~ Handlers // ~~ Handlers
pub async fn list_threads(State(pool): State<SqlitePool>, session: Session) -> Response { pub async fn list_threads(State(state): State<AppState>, session: Session) -> 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 { let current_user = user.map(|u| CurrentUser {
username: u.username, 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(); let mut threads_with_authors = Vec::new();
for thread in threads { 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 .await
.ok() .ok()
.flatten(); .flatten();
@ -100,34 +118,51 @@ pub async fn list_threads(State(pool): State<SqlitePool>, session: Session) -> R
title: "threads".into(), title: "threads".into(),
current_user, current_user,
threads: threads_with_authors, threads: threads_with_authors,
csrf_token: get_csrf(&session).await,
}) })
} }
pub async fn new_thread_page(session: Session, State(pool): State<SqlitePool>) -> Response { pub async fn new_thread_page(session: Session, 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;
if user.is_none() { if user.is_none() {
return Redirect::to("/auth/login").into_response(); return Redirect::to("/auth/login").into_response();
} }
let current_user = user.map(|u| CurrentUser { let user = user.unwrap();
username: u.username, if !user.email_verified {
return Redirect::to("/").into_response();
}
let current_user = Some(CurrentUser {
username: user.username,
email_verified: user.email_verified,
}); });
render(NewThreadTemplate { render(NewThreadTemplate {
title: "new thread".into(), title: "new thread".into(),
current_user, current_user,
error: None, error: None,
csrf_token: get_csrf(&session).await,
}) })
} }
pub async fn create_thread( pub async fn create_thread(
session: Session, session: Session,
State(pool): State<SqlitePool>, State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Form(form): Form<NewThreadForm>, Form(form): Form<NewThreadForm>,
) -> Response { ) -> 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 { let current_user = user.clone().map(|u| CurrentUser {
username: u.username, username: u.username,
email_verified: u.email_verified,
}); });
let user = match user { let user = match user {
@ -135,15 +170,25 @@ pub async fn create_thread(
None => return Redirect::to("/auth/login").into_response(), 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() { if form.title.trim().is_empty() || form.body.trim().is_empty() {
return render(NewThreadTemplate { return render(NewThreadTemplate {
title: "new thread".into(), title: "new thread".into(),
current_user, current_user,
error: Some("title and body cannot be empty".into()), 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 .await
.expect("failed to create thread"); .expect("failed to create thread");
@ -153,19 +198,20 @@ pub async fn create_thread(
pub async fn view_thread( pub async fn view_thread(
Path(id): Path<i64>, Path(id): Path<i64>,
session: Session, session: Session,
State(pool): State<SqlitePool>, State(state): State<AppState>,
) -> Response { ) -> 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 { let current_user = user.map(|u| CurrentUser {
username: u.username, 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, Some(t) => t,
None => return (axum::http::StatusCode::NOT_FOUND, "thread not found").into_response(), 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 .await
.ok() .ok()
.flatten() .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(); let mut posts_with_authors = Vec::new();
for post in posts { 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 { if let Some(a) = author {
posts_with_authors.push(PostWithAuthor { post, author: a }); posts_with_authors.push(PostWithAuthor { post, author: a });
} }
@ -196,18 +245,33 @@ pub async fn view_thread(
thread: ThreadWithAuthor { thread, author }, thread: ThreadWithAuthor { thread, author },
posts: posts_with_authors, posts: posts_with_authors,
error: None, error: None,
csrf_token: get_csrf(&session).await,
}) })
} }
pub async fn create_post( pub async fn create_post(
Path(id): Path<i64>, Path(id): Path<i64>,
session: Session, session: Session,
State(pool): State<SqlitePool>, State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Form(form): Form<NewPostForm>, Form(form): Form<NewPostForm>,
) -> Response { ) -> 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 { let current_user = user.clone().map(|u| CurrentUser {
username: u.username, username: u.username,
email_verified: u.email_verified,
}); });
let user = match user { let user = match user {
@ -215,13 +279,8 @@ pub async fn create_post(
None => return Redirect::to("/auth/login").into_response(), None => return Redirect::to("/auth/login").into_response(),
}; };
let thread = match Thread::find_by_id(&pool, id).await.ok().flatten() { if !user.email_verified {
Some(t) => t, let author = User::find_by_id(&state.pool, thread.author_id)
None => return Redirect::to("/threads").into_response(),
};
if form.body.trim().is_empty() {
let author = User::find_by_id(&pool, thread.author_id)
.await .await
.ok() .ok()
.flatten() .flatten()
@ -234,12 +293,63 @@ pub async fn create_post(
avatar_url: None, avatar_url: None,
role: "member".into(), role: "member".into(),
created_at: chrono::NaiveDateTime::MIN, 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(); let mut posts_with_authors = Vec::new();
for post in posts { 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 { if let Some(a) = author {
posts_with_authors.push(PostWithAuthor { post, author: a }); posts_with_authors.push(PostWithAuthor { post, author: a });
} }
@ -251,10 +361,11 @@ pub async fn create_post(
thread: ThreadWithAuthor { thread, author }, thread: ThreadWithAuthor { thread, author },
posts: posts_with_authors, posts: posts_with_authors,
error: Some("reply cannot be empty".into()), 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 .await
.expect("failed to create post"); .expect("failed to create post");
@ -264,14 +375,15 @@ pub async fn create_post(
pub async fn view_profile( pub async fn view_profile(
Path(username): Path<String>, Path(username): Path<String>,
session: Session, session: Session,
State(pool): State<SqlitePool>, State(state): State<AppState>,
) -> Response { ) -> 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 { let current_user = user.map(|u| CurrentUser {
username: u.username, 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 .await
.ok() .ok()
.flatten() .flatten()
@ -280,7 +392,7 @@ pub async fn view_profile(
None => return (axum::http::StatusCode::NOT_FOUND, "user not found").into_response(), 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 .await
.unwrap_or_default(); .unwrap_or_default();
@ -297,5 +409,6 @@ pub async fn view_profile(
current_user, current_user,
profile_user, profile_user,
threads: threads_with_authors, threads: threads_with_authors,
csrf_token: get_csrf(&session).await,
}) })
} }

View File

@ -1,21 +1,27 @@
mod db; mod db;
mod email;
mod handlers; mod handlers;
mod middleware;
mod models; mod models;
use askama::Template; use askama::Template;
use axum::{ use axum::{
Router, Router,
extract::State, extract::State,
middleware::from_fn,
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
routing::{get, post}, routing::{get, post},
}; };
use middleware::{RateLimiterStore, csrf_middleware};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tower_sessions::{SessionManagerLayer, cookie::SameSite}; use tower_sessions::{SessionManagerLayer, cookie::SameSite};
use tower_sessions_sqlx_store::SqliteStore; use tower_sessions_sqlx_store::SqliteStore;
use handlers::auth::{ 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::{ use handlers::thread::{
create_post, create_thread, list_threads, new_thread_page, view_profile, view_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 struct CurrentUser {
pub username: String, pub username: String,
pub email_verified: bool,
} }
#[derive(Template)] #[derive(Template)]
@ -30,17 +37,27 @@ pub struct CurrentUser {
struct IndexTemplate { struct IndexTemplate {
title: String, title: String,
current_user: Option<CurrentUser>, current_user: Option<CurrentUser>,
csrf_token: String,
} }
async fn index(State(pool): State<SqlitePool>, session: tower_sessions::Session) -> Response { async fn index(State(state): State<AppState>, session: tower_sessions::Session) -> Response {
let user: Option<models::user::User> = get_current_user(&session, &pool).await; let user: Option<models::user::User> = get_current_user(&session, &state.pool).await;
let current_user = user.map(|u| CurrentUser { let current_user = user.map(|u| CurrentUser {
username: u.username, username: u.username,
email_verified: u.email_verified,
}); });
let csrf_token = session
.get("csrf_token")
.await
.ok()
.flatten()
.unwrap_or_default();
let tmpl = IndexTemplate { let tmpl = IndexTemplate {
title: "home".into(), title: "home".into(),
current_user, current_user,
csrf_token,
}; };
match tmpl.render() { match tmpl.render() {
@ -81,6 +98,34 @@ async fn main() {
let bind_addr = let bind_addr =
std::env::var("SARMENTINE_BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:3000".into()); 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() let app = Router::new()
.route("/", get(index)) .route("/", get(index))
.route("/threads", get(list_threads)) .route("/threads", get(list_threads))
@ -94,12 +139,32 @@ async fn main() {
.route("/auth/login", get(login_page)) .route("/auth/login", get(login_page))
.route("/auth/login", post(login_submit)) .route("/auth/login", post(login_submit))
.route("/auth/logout", post(logout)) .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)) .nest_service("/static", ServeDir::new(&static_dir))
.layer(from_fn(csrf_middleware))
.layer(session_layer) .layer(session_layer)
.with_state(pool); .with_state(app_state);
let listener = tokio::net::TcpListener::bind(&bind_addr).await.unwrap(); let listener = tokio::net::TcpListener::bind(&bind_addr).await.unwrap();
println!("listening on http://{}", bind_addr); 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
View 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()
}

View File

@ -13,13 +13,23 @@ pub struct User {
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
pub role: String, pub role: String,
pub created_at: NaiveDateTime, 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 { impl User {
pub async fn find_by_id(pool: &SqlitePool, id: i64) -> sqlx::Result<Option<User>> { pub async fn find_by_id(pool: &SqlitePool, id: i64) -> sqlx::Result<Option<User>> {
sqlx::query_as!( sqlx::query_as!(
User, 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 = ?"#, FROM users WHERE id = ?"#,
id id
) )
@ -30,7 +40,12 @@ impl User {
pub async fn find_by_username(pool: &SqlitePool, username: &str) -> sqlx::Result<Option<User>> { pub async fn find_by_username(pool: &SqlitePool, username: &str) -> sqlx::Result<Option<User>> {
sqlx::query_as!( sqlx::query_as!(
User, 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 = ?"#, FROM users WHERE username = ?"#,
username username
) )
@ -41,7 +56,12 @@ impl User {
pub async fn find_by_email(pool: &SqlitePool, email: &str) -> sqlx::Result<Option<User>> { pub async fn find_by_email(pool: &SqlitePool, email: &str) -> sqlx::Result<Option<User>> {
sqlx::query_as!( sqlx::query_as!(
User, 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 = ?"#, FROM users WHERE email = ?"#,
email email
) )
@ -49,6 +69,41 @@ impl User {
.await .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( pub async fn insert(
pool: &SqlitePool, pool: &SqlitePool,
username: &str, username: &str,
@ -64,4 +119,83 @@ impl User {
Ok(result.last_insert_rowid()) 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(())
}
} }

View 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 %}

View 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 %}

View 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>
&middot; <a href="/" style="color:#d4879c;">go home</a>
</div>
</div>
{% endblock %}

View File

@ -14,6 +14,7 @@
<div class="post"> <div class="post">
<form method="post" action="/auth/login"> <form method="post" action="/auth/login">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-field"> <div class="form-field">
<label class="form-label">username</label> <label class="form-label">username</label>
<input class="form-input" type="text" name="username" required autocomplete="username"> <input class="form-input" type="text" name="username" required autocomplete="username">
@ -26,6 +27,7 @@
</form> </form>
<div class="post-meta" style="margin-top:10px;"> <div class="post-meta" style="margin-top:10px;">
new here? <a href="/auth/register" style="color:#d4879c;">register here</a> new here? <a href="/auth/register" style="color:#d4879c;">register here</a>
&middot; <a href="/auth/forgot-password" style="color:#888;">forgot password?</a>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -14,6 +14,7 @@
<div class="post"> <div class="post">
<form method="post" action="/auth/register"> <form method="post" action="/auth/register">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-field"> <div class="form-field">
<label class="form-label">username</label> <label class="form-label">username</label>
<input class="form-input" type="text" name="username" required minlength="2" maxlength="32" autocomplete="username"> <input class="form-input" type="text" name="username" required minlength="2" maxlength="32" autocomplete="username">

View 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 %}

View 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 %}

View 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 %}
&middot; <a href="/auth/login" style="color:#d4879c;">login</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -19,6 +19,7 @@
{% if let Some(user) = current_user %} {% if let Some(user) = current_user %}
<a href="/profile/{{ user.username }}">{{ user.username }}</a> <a href="/profile/{{ user.username }}">{{ user.username }}</a>
<form method="post" action="/auth/logout" style="display:inline;"> <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> <button type="submit" class="nav-logout">logout</button>
</form> </form>
{% else %} {% else %}

View File

@ -6,6 +6,17 @@
<hr class="divider"> <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 style="font-family:'Silkscreen',monospace; font-size:11px; color:#d4879c; margin-bottom:8px; text-transform:uppercase;">recent threads</div>
<div class="post"> <div class="post">

View File

@ -10,6 +10,7 @@
{% endif %} {% endif %}
<form method="post" action="/threads"> <form method="post" action="/threads">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="margin-bottom:8px;"> <div style="margin-bottom:8px;">
<label style="display:block; font-size:11px; color:#888; margin-bottom:4px;">TITLE</label> <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;"> <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;"> <div style="margin-bottom:8px;">
<label style="display:block; font-size:11px; color:#888; margin-bottom:4px;">BODY</label> <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> <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> </div>
<button type="submit" class="btn" style="margin-top:8px;">create thread</button> <button type="submit" class="btn" style="margin-top:8px;">create thread</button>
</form> </form>

View File

@ -2,13 +2,13 @@
{% block content %} {% block content %}
<div style="font-family:'Silkscreen',monospace; font-size:14px; color:#d4879c; margin-bottom:12px;"> <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>
<div class="post"> <div class="post">
<div class="post-title">{{ thread.thread.title }}</div> <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> &middot; {{ thread.thread.created_at }}</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> &middot; {{ 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> </div>
{% if let Some(error) = error %} {% if let Some(error) = error %}
@ -22,7 +22,7 @@
{% for pwa in posts %} {% for pwa in posts %}
<div class="post"> <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> &middot; {{ pwa.post.created_at }}</div> <div class="post-body" style="color:#888; font-size:11px; margin-bottom:8px;">by <a href="/profile/{{ pwa.author.username }}">{{ pwa.author.username }}</a> &middot; {{ 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> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
@ -30,8 +30,10 @@
{% if let Some(user) = current_user %} {% if let Some(user) = current_user %}
<div style="margin-top:16px;"> <div style="margin-top:16px;">
<form method="post" action="/threads/{{ thread.thread.id }}/posts"> <form method="post" action="/threads/{{ thread.thread.id }}/posts">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="margin-bottom:8px;"> <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> <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> </div>
<button type="submit" class="btn">reply</button> <button type="submit" class="btn">reply</button>
</form> </form>