Compare commits
10 commits
c75bd0cb31
...
17f1b29951
Author | SHA1 | Date | |
---|---|---|---|
![]() |
17f1b29951 | ||
![]() |
ef3cc5a11b | ||
![]() |
c7500ae6a3 | ||
![]() |
b1bf7cb581 | ||
![]() |
a365e3cd3e | ||
![]() |
3e15b7b0c9 | ||
![]() |
233968fa97 | ||
![]() |
b6111fb8fa | ||
![]() |
8f18d71743 | ||
![]() |
30f8d9ecf1 |
25 changed files with 1171 additions and 138 deletions
375
Cargo.lock
generated
375
Cargo.lock
generated
|
@ -148,7 +148,7 @@ dependencies = [
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -184,7 +184,7 @@ checksum = "34921de3d57974069bad483fdfe0ec65d88c4ff892edd1ab4d8b03be0dda1b9b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -195,7 +195,7 @@ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -306,7 +306,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -351,6 +351,21 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit-set"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
|
||||||
|
dependencies = [
|
||||||
|
"bit-vec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit-vec"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.9.0"
|
version = "2.9.0"
|
||||||
|
@ -402,7 +417,7 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"thiserror",
|
"thiserror 2.0.12",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
@ -560,8 +575,10 @@ checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"hkdf",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"sha2",
|
||||||
"subtle",
|
"subtle",
|
||||||
"time",
|
"time",
|
||||||
"version_check",
|
"version_check",
|
||||||
|
@ -707,7 +724,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -873,6 +890,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.31"
|
version = "0.3.31"
|
||||||
|
@ -939,7 +966,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1149,6 +1176,20 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "html5ever"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"mac",
|
||||||
|
"markup5ever",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 1.0.109",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
|
@ -1601,11 +1642,15 @@ version = "0.4.27"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mac"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maik"
|
name = "maik"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ba8f0b47dc7cc74760332828e060ec705339b5e863894f575d81691546bbc836"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
|
@ -1615,6 +1660,32 @@ dependencies = [
|
||||||
"wg",
|
"wg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markup5ever"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"phf",
|
||||||
|
"phf_codegen",
|
||||||
|
"string_cache",
|
||||||
|
"string_cache_codegen",
|
||||||
|
"tendril",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markup5ever_rcdom"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9521dd6750f8e80ee6c53d65e2e4656d7de37064f3a7a5d2d11d05df93839c2"
|
||||||
|
dependencies = [
|
||||||
|
"html5ever",
|
||||||
|
"markup5ever",
|
||||||
|
"tendril",
|
||||||
|
"xml5ever",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
@ -1689,6 +1760,12 @@ dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "new_debug_unreachable"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "8.0.0"
|
version = "8.0.0"
|
||||||
|
@ -1805,7 +1882,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1882,6 +1959,12 @@ dependencies = [
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paste"
|
||||||
|
version = "1.0.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pathdiff"
|
name = "pathdiff"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
@ -1920,7 +2003,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6"
|
checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
"thiserror",
|
"thiserror 2.0.12",
|
||||||
"ucd-trie",
|
"ucd-trie",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -1944,7 +2027,7 @@ dependencies = [
|
||||||
"pest_meta",
|
"pest_meta",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1958,6 +2041,63 @@ dependencies = [
|
||||||
"sha2",
|
"sha2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared 0.10.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_codegen"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator 0.10.0",
|
||||||
|
"phf_shared 0.10.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_generator"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared 0.10.0",
|
||||||
|
"rand 0.8.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_generator"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared 0.11.3",
|
||||||
|
"rand 0.8.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher 0.3.11",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher 1.0.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project"
|
name = "pin-project"
|
||||||
version = "1.1.10"
|
version = "1.1.10"
|
||||||
|
@ -1975,7 +2115,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -2068,6 +2208,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 = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.95"
|
version = "1.0.95"
|
||||||
|
@ -2308,6 +2454,28 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rmp"
|
||||||
|
version = "0.8.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"num-traits",
|
||||||
|
"paste",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rmp-serde"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"rmp",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ron"
|
name = "ron"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
|
@ -2468,6 +2636,17 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "select"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5910c1d91bd7e6e178c0f8eb9e4ad01f814064b4a1c0ae3c906224a3cbf12879"
|
||||||
|
dependencies = [
|
||||||
|
"bit-set",
|
||||||
|
"html5ever",
|
||||||
|
"markup5ever_rcdom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.219"
|
version = "1.0.219"
|
||||||
|
@ -2485,7 +2664,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -2518,7 +2697,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -2615,6 +2794,18 @@ dependencies = [
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "0.3.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.9"
|
version = "0.4.9"
|
||||||
|
@ -2683,7 +2874,6 @@ checksum = "f743f2a3cea30a58cd479013f75550e879009e3a02f616f18ca699335aa248c3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
|
||||||
"crc",
|
"crc",
|
||||||
"crossbeam-queue",
|
"crossbeam-queue",
|
||||||
"either",
|
"either",
|
||||||
|
@ -2703,7 +2893,8 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror",
|
"thiserror 2.0.12",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
@ -2721,7 +2912,7 @@ dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"sqlx-macros-core",
|
"sqlx-macros-core",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -2744,7 +2935,7 @@ dependencies = [
|
||||||
"sqlx-mysql",
|
"sqlx-mysql",
|
||||||
"sqlx-postgres",
|
"sqlx-postgres",
|
||||||
"sqlx-sqlite",
|
"sqlx-sqlite",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
|
@ -2761,7 +2952,6 @@ dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
|
||||||
"crc",
|
"crc",
|
||||||
"digest",
|
"digest",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
@ -2788,7 +2978,8 @@ dependencies = [
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror",
|
"thiserror 2.0.12",
|
||||||
|
"time",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
"whoami",
|
"whoami",
|
||||||
|
@ -2804,7 +2995,6 @@ dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"chrono",
|
|
||||||
"crc",
|
"crc",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"etcetera",
|
"etcetera",
|
||||||
|
@ -2827,7 +3017,8 @@ dependencies = [
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror",
|
"thiserror 2.0.12",
|
||||||
|
"time",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
"whoami",
|
"whoami",
|
||||||
|
@ -2840,7 +3031,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c26083e9a520e8eb87a06b12347679b142dc2ea29e6e409f805644a7a979a5bc"
|
checksum = "c26083e9a520e8eb87a06b12347679b142dc2ea29e6e409f805644a7a979a5bc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"chrono",
|
|
||||||
"flume",
|
"flume",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
@ -2853,7 +3043,8 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"thiserror",
|
"thiserror 2.0.12",
|
||||||
|
"time",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
@ -2878,6 +3069,31 @@ dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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 0.11.3",
|
||||||
|
"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 0.11.3",
|
||||||
|
"phf_shared 0.11.3",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stringprep"
|
name = "stringprep"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
@ -2895,6 +3111,17 @@ version = "2.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "1.0.109"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.101"
|
version = "2.0.101"
|
||||||
|
@ -2923,7 +3150,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -2971,6 +3198,17 @@ dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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 = "test-log"
|
name = "test-log"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
@ -2989,7 +3227,16 @@ checksum = "888d0c3c6db53c0fdab160d2ed5e12ba745383d3e85813f2ea0f2b1475ab553f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -2998,7 +3245,18 @@ version = "2.0.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
|
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl 2.0.12",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -3009,7 +3267,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -3113,7 +3371,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -3287,7 +3545,7 @@ dependencies = [
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror 2.0.12",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
@ -3305,6 +3563,20 @@ dependencies = [
|
||||||
"tower-sessions-core",
|
"tower-sessions-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-sessions-sqlx-store"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e054622079f57fc1a7d6a6089c9334f963d62028fe21dc9eddd58af9a78480b3"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"rmp-serde",
|
||||||
|
"sqlx",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"time",
|
||||||
|
"tower-sessions-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.41"
|
version = "0.1.41"
|
||||||
|
@ -3325,7 +3597,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -3461,6 +3733,12 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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"
|
||||||
|
@ -3547,7 +3825,7 @@ dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -3582,7 +3860,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
"wasm-bindgen-backend",
|
"wasm-bindgen-backend",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
@ -3681,7 +3959,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -3692,7 +3970,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -3985,6 +4263,17 @@ dependencies = [
|
||||||
"rustix",
|
"rustix",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xml5ever"
|
||||||
|
version = "0.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4034e1d05af98b51ad7214527730626f019682d797ba38b51689212118d8e650"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"mac",
|
||||||
|
"markup5ever",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yaml-rust2"
|
name = "yaml-rust2"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
|
@ -4025,7 +4314,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -4041,6 +4330,7 @@ dependencies = [
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
"bollard",
|
"bollard",
|
||||||
"config",
|
"config",
|
||||||
|
"cookie",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
@ -4051,18 +4341,21 @@ dependencies = [
|
||||||
"rand 0.9.1",
|
"rand 0.9.1",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"select",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tar",
|
"tar",
|
||||||
"test-log",
|
"test-log",
|
||||||
"thiserror",
|
"thiserror 2.0.12",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-sessions",
|
"tower-sessions",
|
||||||
|
"tower-sessions-sqlx-store",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
@ -4085,7 +4378,7 @@ checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -4105,7 +4398,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -4145,5 +4438,5 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.101",
|
||||||
]
|
]
|
||||||
|
|
|
@ -15,6 +15,7 @@ askama_web = { version = "0.14.4", features = ["axum-0.8"] }
|
||||||
axum = { version = "0.8", features = ["tokio", "http1", "http2", "macros"] }
|
axum = { version = "0.8", features = ["tokio", "http1", "http2", "macros"] }
|
||||||
axum-extra = { version = "0.10", features = ["cookie-private", "typed-header"] }
|
axum-extra = { version = "0.10", features = ["cookie-private", "typed-header"] }
|
||||||
config = { version = "0.15", features = ["toml"] }
|
config = { version = "0.15", features = ["toml"] }
|
||||||
|
cookie = { version = "0.18.1", features = ["key-expansion"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
hyper = "1.1"
|
hyper = "1.1"
|
||||||
lettre = { version = "0.11.17", features = ["tokio1", "tokio1-native-tls", "tracing", "web"] }
|
lettre = { version = "0.11.17", features = ["tokio1", "tokio1-native-tls", "tracing", "web"] }
|
||||||
|
@ -23,7 +24,7 @@ pin-project = "1.1.0"
|
||||||
rand = "0.9.1"
|
rand = "0.9.1"
|
||||||
serde = { version = "1.0.164", features = ["derive"] }
|
serde = { version = "1.0.164", features = ["derive"] }
|
||||||
serde_json = "1.0.99"
|
serde_json = "1.0.99"
|
||||||
sqlx = { version = "0.8.5", features = ["runtime-tokio", "macros", "postgres", "uuid", "chrono", "migrate"] }
|
sqlx = { version = "0.8.5", features = ["runtime-tokio", "macros", "postgres", "uuid", "migrate"] }
|
||||||
tar = "0.4.44"
|
tar = "0.4.44"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
tokio = { version = "1.28.2", features = ["full"] }
|
tokio = { version = "1.28.2", features = ["full"] }
|
||||||
|
@ -32,6 +33,7 @@ tokio-util = "0.7.15"
|
||||||
tower = "0.5.2"
|
tower = "0.5.2"
|
||||||
tower-http = { version = "0.6.6", features = ["trace"] }
|
tower-http = { version = "0.6.6", features = ["trace"] }
|
||||||
tower-sessions = "0.14.0"
|
tower-sessions = "0.14.0"
|
||||||
|
tower-sessions-sqlx-store = { version = "0.15.0", features = ["postgres"] }
|
||||||
tracing = "0.1.37"
|
tracing = "0.1.37"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
uuid = { version = "1.16.0", features = ["serde", "v4"] }
|
uuid = { version = "1.16.0", features = ["serde", "v4"] }
|
||||||
|
@ -42,7 +44,12 @@ http-body-util = "0.1.3"
|
||||||
maik = "0.2.0"
|
maik = "0.2.0"
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
reqwest = { version = "0.12", features = ["cookies"] }
|
reqwest = { version = "0.12", features = ["cookies"] }
|
||||||
|
select = "0.6.1"
|
||||||
|
serde_urlencoded = "0.7.1"
|
||||||
test-log = { version = "0.2.12", default-features = false, features = ["trace"] }
|
test-log = { version = "0.2.12", default-features = false, features = ["trace"] }
|
||||||
|
|
||||||
[profile.dev.package.sqlx-macros]
|
[profile.dev.package.sqlx-macros]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
maik = { path = "../downloads/maik" }
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
[app]
|
[app]
|
||||||
listen = "[::]:3742"
|
listen = "[::]:3742"
|
||||||
|
key = "Q^,zH6M}*JY-W[oWCn6T7W!G=TvN,a5[~%cfRKZ7jse1EMDgG7GdTFy)ez*E(9I"
|
||||||
|
|
||||||
[email]
|
[email]
|
||||||
server = "smtp.fastmail.com"
|
server = "smtp.postmarkapp.com"
|
||||||
username = "patrick@psbarrett.com"
|
port = 587
|
||||||
sender = "Z2A Bot <bot@azdle.net>"
|
tls = "StartTls"
|
||||||
|
username = "PM-T-outbound-Nh7Fw1vnQ3dwi-57cweG5e"
|
||||||
|
sender = "Z2A Bot <bot@psbarrett.com>"
|
||||||
|
|
16
migrations/20250724181953_add-signup-confirmations.sql
Normal file
16
migrations/20250724181953_add-signup-confirmations.sql
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
-- Not yet deployed, do all in one migration.
|
||||||
|
|
||||||
|
CREATE TABLE signup_tokens(
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
user_id uuid NOT NULL
|
||||||
|
REFERENCES users (id),
|
||||||
|
PRIMARY KEY (token)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN status TEXT NULL;
|
||||||
|
|
||||||
|
UPDATE users
|
||||||
|
SET status = 'confirmed'
|
||||||
|
where status IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE users ALTER COLUMN status SET NOT NULL;
|
6
migrations/20250725194521_add-user-roles.sql
Normal file
6
migrations/20250725194521_add-user-roles.sql
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
CREATE TABLE user_roles(
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
user_id uuid NOT NULL
|
||||||
|
REFERENCES users (id),
|
||||||
|
PRIMARY KEY (user_id, role)
|
||||||
|
);
|
|
@ -12,6 +12,7 @@ pub struct Database {
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub listen: SocketAddr,
|
pub listen: SocketAddr,
|
||||||
pub public_url: String,
|
pub public_url: String,
|
||||||
|
pub key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
@ -21,9 +22,16 @@ pub struct Email {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
pub sender: String,
|
pub sender: String,
|
||||||
|
pub tls: Option<TlsMode>,
|
||||||
pub cert: Option<String>,
|
pub cert: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub enum TlsMode {
|
||||||
|
Tls,
|
||||||
|
StartTls,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub struct Conf {
|
pub struct Conf {
|
||||||
|
|
|
@ -11,7 +11,7 @@ use lettre::{
|
||||||
};
|
};
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
use crate::conf;
|
use crate::conf::{self, TlsMode};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum EmailClient {
|
pub enum EmailClient {
|
||||||
|
@ -28,7 +28,12 @@ impl EmailClient {
|
||||||
return Ok(EmailClient::Disabled);
|
return Ok(EmailClient::Disabled);
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut inner = AsyncSmtpTransport::<Tokio1Executor>::relay(&conf.server).unwrap();
|
let mut inner = match conf.tls {
|
||||||
|
Some(TlsMode::Tls) | None => AsyncSmtpTransport::<Tokio1Executor>::relay(&conf.server)?,
|
||||||
|
Some(TlsMode::StartTls) => {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&conf.server)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(port) = conf.port {
|
if let Some(port) = conf.port {
|
||||||
inner = inner.port(port);
|
inner = inner.port(port);
|
||||||
|
|
|
@ -4,7 +4,9 @@ pub mod session;
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use axum::extract::FromRef;
|
use axum::extract::FromRef;
|
||||||
use axum_extra::extract::cookie::Key;
|
use axum_extra::extract::cookie::Key;
|
||||||
|
use futures_util::FutureExt as _;
|
||||||
use pin_project::pin_project;
|
use pin_project::pin_project;
|
||||||
|
use session::GetPgPool;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::future::{Future, IntoFuture};
|
use std::future::{Future, IntoFuture};
|
||||||
|
@ -15,7 +17,8 @@ use std::sync::Arc;
|
||||||
use tokio::signal;
|
use tokio::signal;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tower_sessions::cookie::time::Duration;
|
use tower_sessions::cookie::time::Duration;
|
||||||
use tower_sessions::{Expiry, MemoryStore, SessionManagerLayer};
|
use tower_sessions::{session_store::ExpiredDeletion, Expiry, SessionManagerLayer};
|
||||||
|
use tower_sessions_sqlx_store::PostgresStore;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::email_client::EmailClient;
|
use crate::email_client::EmailClient;
|
||||||
|
@ -53,20 +56,24 @@ impl ZeroToAxum {
|
||||||
let email_client =
|
let email_client =
|
||||||
email_client::EmailClient::new(conf.email.clone()).context("build email client")?;
|
email_client::EmailClient::new(conf.email.clone()).context("build email client")?;
|
||||||
|
|
||||||
let app_state = AppState {
|
let session_store = PostgresStore::new(db.clone());
|
||||||
conf: Arc::new(conf.clone()),
|
session_store.migrate().await?;
|
||||||
// TODO: pull from config
|
let session_cleanup_task = tokio::task::spawn(
|
||||||
key: Key::generate(),
|
session_store
|
||||||
db,
|
.clone()
|
||||||
email_client,
|
.continuously_delete_expired(std::time::Duration::from_secs(60)),
|
||||||
};
|
);
|
||||||
|
|
||||||
// Just store locally for now. Supports database connections.
|
|
||||||
let session_store = MemoryStore::default();
|
|
||||||
let session_layer = SessionManagerLayer::new(session_store)
|
let session_layer = SessionManagerLayer::new(session_store)
|
||||||
.with_secure(false)
|
.with_secure(false)
|
||||||
.with_expiry(Expiry::OnInactivity(Duration::weeks(1)));
|
.with_expiry(Expiry::OnInactivity(Duration::weeks(1)));
|
||||||
|
|
||||||
|
let app_state = AppState {
|
||||||
|
conf: Arc::new(conf.clone()),
|
||||||
|
key: Key::derive_from(conf.app.key.as_bytes()),
|
||||||
|
db,
|
||||||
|
email_client,
|
||||||
|
};
|
||||||
|
|
||||||
let app = routes::build()
|
let app = routes::build()
|
||||||
.with_state(app_state)
|
.with_state(app_state)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
|
@ -76,7 +83,9 @@ impl ZeroToAxum {
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let bound_addr = listener.local_addr().unwrap();
|
let bound_addr = listener.local_addr().unwrap();
|
||||||
let server = axum::serve(listener, app).with_graceful_shutdown(shutdown_signal());
|
let server = axum::serve(listener, app).with_graceful_shutdown(
|
||||||
|
shutdown_signal().then(async move |()| session_cleanup_task.abort_handle().abort()),
|
||||||
|
);
|
||||||
|
|
||||||
info!("server started, listening on {bound_addr:?}");
|
info!("server started, listening on {bound_addr:?}");
|
||||||
|
|
||||||
|
@ -103,6 +112,12 @@ impl FromRef<AppState> for Key {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl GetPgPool for AppState {
|
||||||
|
fn get_pg_pool(&self) -> PgPool {
|
||||||
|
self.db.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn shutdown_signal() {
|
async fn shutdown_signal() {
|
||||||
let ctrl_c = async {
|
let ctrl_c = async {
|
||||||
signal::ctrl_c()
|
signal::ctrl_c()
|
||||||
|
|
95
src/server/routes/admin/mod.rs
Normal file
95
src/server/routes/admin/mod.rs
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
use anyhow::Context;
|
||||||
|
use askama::Template;
|
||||||
|
use askama_web::WebTemplate;
|
||||||
|
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Router};
|
||||||
|
use tracing::info;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::server::{session::Auth, AppState};
|
||||||
|
|
||||||
|
use super::ErrorPage;
|
||||||
|
|
||||||
|
pub fn build() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(index_page))
|
||||||
|
.route("/users", get(users_page))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate, Default)]
|
||||||
|
#[template(path = "admin.html")]
|
||||||
|
struct IndexPage;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(auth))]
|
||||||
|
pub async fn index_page(auth: Auth) -> impl IntoResponse {
|
||||||
|
info!("get admin index page");
|
||||||
|
|
||||||
|
if !auth.has_role(crate::server::session::Role::Admin) {
|
||||||
|
return (StatusCode::FORBIDDEN).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
IndexPage.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate, Default)]
|
||||||
|
#[template(path = "users.html")]
|
||||||
|
struct UsersPage {
|
||||||
|
users: Vec<UserRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserRecord {
|
||||||
|
id: Uuid,
|
||||||
|
email: String,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(auth))]
|
||||||
|
pub async fn users_page(
|
||||||
|
State(AppState { db, .. }): State<AppState>,
|
||||||
|
auth: Auth,
|
||||||
|
) -> Result<impl IntoResponse, AdminError> {
|
||||||
|
info!("get login page");
|
||||||
|
|
||||||
|
if !auth.has_role(crate::server::session::Role::Admin) {
|
||||||
|
return Ok((StatusCode::FORBIDDEN).into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let users: Vec<UserRecord> = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT id, email, status FROM users;
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_all(&db)
|
||||||
|
.await
|
||||||
|
.context("fetch users from db")?
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| UserRecord {
|
||||||
|
id: r.id,
|
||||||
|
email: r.email,
|
||||||
|
status: r.status,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(UsersPage { users }.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum AdminError {
|
||||||
|
#[error("Unknown Error: {0}")]
|
||||||
|
Unknown(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AdminError {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
let (status, message) = match self {
|
||||||
|
AdminError::Unknown(e) => (StatusCode::INTERNAL_SERVER_ERROR, e),
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
ErrorPage {
|
||||||
|
error: message.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,33 +5,61 @@ use argon2::Argon2;
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use askama_web::WebTemplate;
|
use askama_web::WebTemplate;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::{Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Redirect},
|
response::{IntoResponse, Redirect},
|
||||||
routing::{get, post},
|
routing::get,
|
||||||
Form, Router,
|
Form, Router,
|
||||||
};
|
};
|
||||||
use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||||
|
use rand::{distr::Alphanumeric, rng, Rng as _};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{debug, error, info};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::server::{
|
use crate::server::{
|
||||||
session::{Auth, User},
|
session::{Auth, CsrfCookie, User},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use super::ErrorPage;
|
||||||
|
|
||||||
pub fn build() -> Router<AppState> {
|
pub fn build() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/signup", post(signup))
|
.route("/signup", get(signup_page).post(signup))
|
||||||
.route("/login", get(login_page).post(login))
|
.route("/login", get(login_page).post(login))
|
||||||
.route("/logout", get(logout_page).post(logout))
|
.route("/logout", get(logout_page).post(logout))
|
||||||
|
.route("/confirm", get(signup_confirm_page).post(signup_confirm))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate, Default)]
|
||||||
|
#[template(path = "signup.html")]
|
||||||
|
struct SignupPage {
|
||||||
|
error: Option<String>,
|
||||||
|
csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument]
|
||||||
|
pub async fn signup_page(csrf: CsrfCookie) -> impl IntoResponse {
|
||||||
|
info!("get signup page");
|
||||||
|
|
||||||
|
let csrf_token = csrf.token().to_string();
|
||||||
|
|
||||||
|
(
|
||||||
|
csrf,
|
||||||
|
SignupPage {
|
||||||
|
error: None,
|
||||||
|
csrf_token,
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub struct SignupForm {
|
pub struct SignupForm {
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
password: String,
|
||||||
|
csrf_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for SignupForm {
|
impl fmt::Debug for SignupForm {
|
||||||
|
@ -39,18 +67,34 @@ impl fmt::Debug for SignupForm {
|
||||||
f.debug_struct("SignupForm")
|
f.debug_struct("SignupForm")
|
||||||
.field("email", &self.email)
|
.field("email", &self.email)
|
||||||
.field("password", &"REDACTED")
|
.field("password", &"REDACTED")
|
||||||
|
.field("csrf-token", &self.csrf_token)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(db, user))]
|
#[derive(Template, WebTemplate, Default)]
|
||||||
|
#[template(path = "signup-confirmation.html")]
|
||||||
|
struct SignupConfirmation {
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(db, email_client))]
|
||||||
pub async fn signup(
|
pub async fn signup(
|
||||||
State(AppState { db, .. }): State<AppState>,
|
State(AppState {
|
||||||
mut user: Auth,
|
db,
|
||||||
|
email_client,
|
||||||
|
conf,
|
||||||
|
..
|
||||||
|
}): State<AppState>,
|
||||||
|
csrf: CsrfCookie,
|
||||||
Form(form): Form<SignupForm>,
|
Form(form): Form<SignupForm>,
|
||||||
) -> Result<impl IntoResponse, SignupError> {
|
) -> Result<impl IntoResponse, SignupError> {
|
||||||
info!("signup attempt");
|
info!("signup attempt");
|
||||||
|
|
||||||
|
if form.csrf_token != csrf.token() {
|
||||||
|
return Err(SignupError::CsrfValidationFailed);
|
||||||
|
}
|
||||||
|
|
||||||
info!("hash password: {}", &form.password);
|
info!("hash password: {}", &form.password);
|
||||||
let password_hash: String = tokio::task::spawn_blocking(async move || {
|
let password_hash: String = tokio::task::spawn_blocking(async move || {
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
@ -66,42 +110,89 @@ pub async fn signup(
|
||||||
.await?;
|
.await?;
|
||||||
info!("hashed password: {password_hash}");
|
info!("hashed password: {password_hash}");
|
||||||
|
|
||||||
warn!("db: {db:?}");
|
let mut txn = db.begin().await.context("start database transaction")?;
|
||||||
|
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO users (id, email, password)
|
INSERT INTO users (id, email, password, status)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, 'pending_confirmation')
|
||||||
"#,
|
"#,
|
||||||
user_id,
|
user_id,
|
||||||
form.email,
|
form.email,
|
||||||
password_hash,
|
password_hash,
|
||||||
)
|
)
|
||||||
.execute(&db)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.context("insert new user into database")?;
|
.context("insert new user into database")?;
|
||||||
|
|
||||||
user.user = Some(User::new(user_id));
|
let token: String = {
|
||||||
user.session.insert("user", &user.user).await.unwrap();
|
let mut rng = rng();
|
||||||
|
std::iter::repeat_with(|| rng.sample(Alphanumeric))
|
||||||
|
.map(char::from)
|
||||||
|
.take(25)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Redirect::to("/"))
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO signup_tokens (user_id, token)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
"#,
|
||||||
|
user_id,
|
||||||
|
token
|
||||||
|
)
|
||||||
|
.execute(&mut *txn)
|
||||||
|
.await
|
||||||
|
.context("insert subscription token into database")?;
|
||||||
|
|
||||||
|
email_client
|
||||||
|
.send_email(
|
||||||
|
form.email.parse().map_err(|_| SignupError::InvalidEmail)?,
|
||||||
|
"Test".to_owned(),
|
||||||
|
format!(
|
||||||
|
"Please confirm your subscription: {}auth/confirm?token={token}",
|
||||||
|
conf.app.public_url
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
debug!("email sent");
|
||||||
|
|
||||||
|
txn.commit().await.context("commit transaction")?;
|
||||||
|
|
||||||
|
Ok(SignupConfirmation { email: form.email })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
pub enum SignupError {
|
pub enum SignupError {
|
||||||
|
#[error("Invalid Email")]
|
||||||
|
InvalidEmail,
|
||||||
|
#[error("CSRF Validation Failed")]
|
||||||
|
CsrfValidationFailed,
|
||||||
#[error("Unknown Error: {0}")]
|
#[error("Unknown Error: {0}")]
|
||||||
Unknown(#[from] anyhow::Error),
|
Unknown(#[from] anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for SignupError {
|
impl IntoResponse for SignupError {
|
||||||
fn into_response(self) -> axum::response::Response {
|
fn into_response(self) -> axum::response::Response {
|
||||||
match self {
|
let (status, message) = match self {
|
||||||
|
SignupError::InvalidEmail => (StatusCode::BAD_REQUEST, "Invalid Email"),
|
||||||
|
SignupError::CsrfValidationFailed => {
|
||||||
|
(StatusCode::BAD_REQUEST, "CSRF Validation Failed, Try Again")
|
||||||
|
}
|
||||||
SignupError::Unknown(e) => {
|
SignupError::Unknown(e) => {
|
||||||
error!(?e, "returning INTERNAL SERVER ERROR");
|
error!(?e, "returning INTERNAL SERVER ERROR");
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error")
|
(StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error")
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
ErrorPage {
|
||||||
|
error: message.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -110,19 +201,30 @@ impl IntoResponse for SignupError {
|
||||||
#[template(path = "login.html")]
|
#[template(path = "login.html")]
|
||||||
struct LoginPage {
|
struct LoginPage {
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
csrf_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
pub async fn login_page() -> LoginPage {
|
pub async fn login_page(csrf: CsrfCookie) -> impl IntoResponse {
|
||||||
info!("get login page");
|
info!("get login page");
|
||||||
|
|
||||||
Default::default()
|
let csrf_token = csrf.token().to_string();
|
||||||
|
|
||||||
|
(
|
||||||
|
csrf,
|
||||||
|
LoginPage {
|
||||||
|
error: None,
|
||||||
|
csrf_token,
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub struct LoginForm {
|
pub struct LoginForm {
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
password: String,
|
||||||
|
csrf_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for LoginForm {
|
impl fmt::Debug for LoginForm {
|
||||||
|
@ -138,13 +240,18 @@ impl fmt::Debug for LoginForm {
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
State(AppState { db, .. }): State<AppState>,
|
State(AppState { db, .. }): State<AppState>,
|
||||||
mut auth: Auth,
|
mut auth: Auth,
|
||||||
|
csrf: CsrfCookie,
|
||||||
Form(form): Form<LoginForm>,
|
Form(form): Form<LoginForm>,
|
||||||
) -> Result<impl IntoResponse, LoginError> {
|
) -> Result<impl IntoResponse, LoginError> {
|
||||||
info!("login attempt");
|
info!("login attempt");
|
||||||
|
|
||||||
|
if form.csrf_token != csrf.token() {
|
||||||
|
return Err(LoginError::CsrfValidationFailed);
|
||||||
|
}
|
||||||
|
|
||||||
let user = sqlx::query!(
|
let user = sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, password FROM users WHERE email = $1 LIMIT 1;
|
SELECT id, password FROM users WHERE status = 'confirmed' AND email = $1 LIMIT 1;
|
||||||
"#,
|
"#,
|
||||||
form.email
|
form.email
|
||||||
)
|
)
|
||||||
|
@ -190,6 +297,8 @@ pub enum LoginError {
|
||||||
InvalidPassword,
|
InvalidPassword,
|
||||||
#[error("Unknown User")]
|
#[error("Unknown User")]
|
||||||
UnknownUser,
|
UnknownUser,
|
||||||
|
#[error("CSRF Validation Failed")]
|
||||||
|
CsrfValidationFailed,
|
||||||
#[error("Unknown Error: {0}")]
|
#[error("Unknown Error: {0}")]
|
||||||
Unknown(#[from] anyhow::Error),
|
Unknown(#[from] anyhow::Error),
|
||||||
}
|
}
|
||||||
|
@ -199,6 +308,9 @@ impl IntoResponse for LoginError {
|
||||||
let (status, message) = match self {
|
let (status, message) = match self {
|
||||||
LoginError::InvalidPassword => (StatusCode::UNAUTHORIZED, "Invalid Password"),
|
LoginError::InvalidPassword => (StatusCode::UNAUTHORIZED, "Invalid Password"),
|
||||||
LoginError::UnknownUser => (StatusCode::UNAUTHORIZED, "Unknown User"),
|
LoginError::UnknownUser => (StatusCode::UNAUTHORIZED, "Unknown User"),
|
||||||
|
LoginError::CsrfValidationFailed => {
|
||||||
|
(StatusCode::BAD_REQUEST, "CSRF Validation Failed, Try Again")
|
||||||
|
}
|
||||||
LoginError::Unknown(e) => {
|
LoginError::Unknown(e) => {
|
||||||
error!(?e, "returning INTERNAL SERVER ERROR");
|
error!(?e, "returning INTERNAL SERVER ERROR");
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error")
|
(StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error")
|
||||||
|
@ -207,8 +319,8 @@ impl IntoResponse for LoginError {
|
||||||
|
|
||||||
(
|
(
|
||||||
status,
|
status,
|
||||||
LoginPage {
|
ErrorPage {
|
||||||
error: Some(message.to_string()),
|
error: message.to_string(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
|
@ -217,18 +329,36 @@ impl IntoResponse for LoginError {
|
||||||
|
|
||||||
#[derive(Template, WebTemplate)]
|
#[derive(Template, WebTemplate)]
|
||||||
#[template(path = "logout.html")]
|
#[template(path = "logout.html")]
|
||||||
struct LogoutPage;
|
struct LogoutPage {
|
||||||
|
csrf_token: String,
|
||||||
#[tracing::instrument]
|
|
||||||
pub async fn logout_page() -> LogoutPage {
|
|
||||||
info!("get logout page");
|
|
||||||
|
|
||||||
LogoutPage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn logout(mut user: Auth) -> Result<impl IntoResponse, LogoutError> {
|
#[tracing::instrument]
|
||||||
|
pub async fn logout_page(csrf: CsrfCookie) -> impl IntoResponse {
|
||||||
|
info!("get logout page");
|
||||||
|
|
||||||
|
let csrf_token = csrf.token().to_string();
|
||||||
|
|
||||||
|
(csrf, LogoutPage { csrf_token })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub struct LogoutForm {
|
||||||
|
csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(
|
||||||
|
mut user: Auth,
|
||||||
|
csrf: CsrfCookie,
|
||||||
|
Form(form): Form<LogoutForm>,
|
||||||
|
) -> Result<impl IntoResponse, LogoutError> {
|
||||||
info!("logout attempt");
|
info!("logout attempt");
|
||||||
|
|
||||||
|
if form.csrf_token != csrf.token() {
|
||||||
|
return Err(LogoutError::CsrfValidationFailed);
|
||||||
|
}
|
||||||
|
|
||||||
if user.user.is_none() {
|
if user.user.is_none() {
|
||||||
return Err(LogoutError::NotLoggedIn);
|
return Err(LogoutError::NotLoggedIn);
|
||||||
}
|
}
|
||||||
|
@ -243,19 +373,120 @@ pub async fn logout(mut user: Auth) -> Result<impl IntoResponse, LogoutError> {
|
||||||
pub enum LogoutError {
|
pub enum LogoutError {
|
||||||
#[error("Not Logged In")]
|
#[error("Not Logged In")]
|
||||||
NotLoggedIn,
|
NotLoggedIn,
|
||||||
|
#[error("CSRF Validation Failed")]
|
||||||
|
CsrfValidationFailed,
|
||||||
#[error("Unknown Error: {0}")]
|
#[error("Unknown Error: {0}")]
|
||||||
Unknown(#[from] anyhow::Error),
|
Unknown(#[from] anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for LogoutError {
|
impl IntoResponse for LogoutError {
|
||||||
fn into_response(self) -> axum::response::Response {
|
fn into_response(self) -> axum::response::Response {
|
||||||
match self {
|
let (status, message) = match self {
|
||||||
LogoutError::NotLoggedIn => (StatusCode::UNAUTHORIZED, "Unknown User"),
|
LogoutError::NotLoggedIn => (StatusCode::UNAUTHORIZED, "Unknown User"),
|
||||||
|
LogoutError::CsrfValidationFailed => {
|
||||||
|
(StatusCode::BAD_REQUEST, "CSRF Validation Failed, Try Again")
|
||||||
|
}
|
||||||
LogoutError::Unknown(e) => {
|
LogoutError::Unknown(e) => {
|
||||||
error!(?e, "returning INTERNAL SERVER ERROR");
|
error!(?e, "returning INTERNAL SERVER ERROR");
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error")
|
(StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error")
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
ErrorPage {
|
||||||
|
error: message.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate)]
|
||||||
|
#[template(path = "signup-confirm.html")]
|
||||||
|
struct SignupConfirmPage {
|
||||||
|
csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument]
|
||||||
|
pub async fn signup_confirm_page(csrf: CsrfCookie) -> impl IntoResponse {
|
||||||
|
info!("get signup confirm page");
|
||||||
|
|
||||||
|
let csrf_token = csrf.token().to_string();
|
||||||
|
|
||||||
|
(csrf, SignupConfirmPage { csrf_token })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub struct SignupConfirmForm {
|
||||||
|
csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SignupConfirmQuery {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn signup_confirm(
|
||||||
|
State(AppState { db, .. }): State<AppState>,
|
||||||
|
csrf: CsrfCookie,
|
||||||
|
Query(query): Query<SignupConfirmQuery>,
|
||||||
|
Form(form): Form<SignupConfirmForm>,
|
||||||
|
) -> Result<impl IntoResponse, SignupConfirmError> {
|
||||||
|
info!("signup confirm attempt");
|
||||||
|
|
||||||
|
if form.csrf_token != csrf.token() {
|
||||||
|
return Err(SignupConfirmError::CsrfValidationFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
UPDATE users SET status = 'confirmed' FROM signup_tokens
|
||||||
|
WHERE status = 'pending_confirmation' AND signup_tokens.user_id = id AND signup_tokens.token = $1;
|
||||||
|
"#,
|
||||||
|
query.token
|
||||||
|
)
|
||||||
|
.execute(&db)
|
||||||
|
.await
|
||||||
|
.context("set user to confirmed")?.rows_affected();
|
||||||
|
|
||||||
|
if rows < 1 {
|
||||||
|
return Err(SignupConfirmError::InvalidToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Redirect::to("/auth/login"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum SignupConfirmError {
|
||||||
|
#[error("Invalid Token")]
|
||||||
|
InvalidToken,
|
||||||
|
#[error("CSRF Validation Failed")]
|
||||||
|
CsrfValidationFailed,
|
||||||
|
#[error("Unknown Error: {0}")]
|
||||||
|
Unknown(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for SignupConfirmError {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
let (status, message) = match self {
|
||||||
|
SignupConfirmError::InvalidToken => (StatusCode::UNAUTHORIZED, "Invalid Token"),
|
||||||
|
SignupConfirmError::CsrfValidationFailed => {
|
||||||
|
(StatusCode::BAD_REQUEST, "CSRF Validation Failed, Try Again")
|
||||||
|
}
|
||||||
|
SignupConfirmError::Unknown(e) => {
|
||||||
|
error!(?e, "returning INTERNAL SERVER ERROR");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
ErrorPage {
|
||||||
|
error: message.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
mod admin;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod subscriptions;
|
mod subscriptions;
|
||||||
|
|
||||||
|
@ -12,6 +13,7 @@ pub fn build() -> Router<AppState> {
|
||||||
.route("/", get(homepage))
|
.route("/", get(homepage))
|
||||||
.route("/health", get(health_check))
|
.route("/health", get(health_check))
|
||||||
.nest("/auth", auth::build())
|
.nest("/auth", auth::build())
|
||||||
|
.nest("/admin", admin::build())
|
||||||
.nest("/subscriptions", subscriptions::build())
|
.nest("/subscriptions", subscriptions::build())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,10 +24,18 @@ async fn health_check() {}
|
||||||
#[template(path = "homepage.html")]
|
#[template(path = "homepage.html")]
|
||||||
struct Homepage {
|
struct Homepage {
|
||||||
is_logged_in: bool,
|
is_logged_in: bool,
|
||||||
|
is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn homepage(user: Auth) -> Homepage {
|
async fn homepage(user: Auth) -> Homepage {
|
||||||
Homepage {
|
Homepage {
|
||||||
is_logged_in: user.user.is_some(),
|
is_logged_in: user.user.is_some(),
|
||||||
|
is_admin: user.has_role(super::session::Role::Admin),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate)]
|
||||||
|
#[template(path = "error.html")]
|
||||||
|
struct ErrorPage {
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
|
|
@ -6,9 +6,9 @@ use axum::{
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Form, Router,
|
Form, Router,
|
||||||
};
|
};
|
||||||
|
use cookie::time::OffsetDateTime;
|
||||||
use rand::{distr::Alphanumeric, rng, Rng as _};
|
use rand::{distr::Alphanumeric, rng, Rng as _};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::types::chrono::Utc;
|
|
||||||
use tracing::{debug, error, info};
|
use tracing::{debug, error, info};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
@ -53,7 +53,7 @@ pub async fn subscribe(
|
||||||
subscriber_id,
|
subscriber_id,
|
||||||
form.email,
|
form.email,
|
||||||
form.name,
|
form.name,
|
||||||
Utc::now()
|
OffsetDateTime::now_utc()
|
||||||
)
|
)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
|
|
|
@ -1,42 +1,174 @@
|
||||||
|
use std::{convert::Infallible, fmt};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::FromRequestParts,
|
extract::FromRequestParts,
|
||||||
http::{request::Parts, StatusCode},
|
http::{request::Parts, StatusCode},
|
||||||
|
response::IntoResponseParts,
|
||||||
};
|
};
|
||||||
|
use axum_extra::extract::{cookie::Cookie, CookieJar};
|
||||||
|
use rand::{distr::Alphanumeric, rng, Rng as _};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
// roles: Vec<String>,
|
roles: Vec<Role>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
pub fn new(id: Uuid) -> User {
|
pub fn new(id: Uuid) -> User {
|
||||||
User { id }
|
User {
|
||||||
|
id,
|
||||||
|
roles: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn fetch(db: &PgPool, id: Uuid) -> Option<User> {
|
||||||
|
let roles = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT role FROM user_roles WHERE user_id = $1;
|
||||||
|
"#,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(roles) = roles {
|
||||||
|
Some(User {
|
||||||
|
id,
|
||||||
|
roles: roles
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|r| match r.role.as_str() {
|
||||||
|
"user" => Some(Role::User),
|
||||||
|
"admin" => Some(Role::Admin),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn roles(&self) -> &[Role] {
|
||||||
|
&self.roles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum Role {
|
||||||
|
User,
|
||||||
|
Admin,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Auth {
|
pub struct Auth {
|
||||||
// id: Uuid,
|
// id: Uuid,
|
||||||
pub session: Session,
|
pub session: Session,
|
||||||
pub user: Option<User>,
|
pub user: Option<User>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Auth {
|
||||||
|
pub fn has_role(&self, role: Role) -> bool {
|
||||||
|
if let Some(user) = &self.user {
|
||||||
|
user.roles().contains(&role)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<S> FromRequestParts<S> for Auth
|
impl<S> FromRequestParts<S> for Auth
|
||||||
where
|
where
|
||||||
S: Send + Sync,
|
S: Send + Sync + GetPgPool,
|
||||||
{
|
{
|
||||||
type Rejection = (StatusCode, &'static str);
|
type Rejection = (StatusCode, &'static str);
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
let session = Session::from_request_parts(parts, state).await?;
|
let session = Session::from_request_parts(parts, state).await?;
|
||||||
|
let db = state.get_pg_pool();
|
||||||
|
|
||||||
let user: Option<User> = session.get("user").await.unwrap_or_default();
|
let user: Option<User> = session.get("user").await.unwrap_or_default();
|
||||||
|
|
||||||
|
// pull roles from db
|
||||||
|
let user = if let Some(user) = user {
|
||||||
|
User::fetch(&db, user.id).await
|
||||||
|
} else {
|
||||||
|
user
|
||||||
|
};
|
||||||
|
|
||||||
// session.insert("user", &data).await.unwrap();
|
// session.insert("user", &data).await.unwrap();
|
||||||
|
|
||||||
Ok(Self { session, user })
|
Ok(Self { session, user })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait GetPgPool {
|
||||||
|
fn get_pg_pool(&self) -> PgPool;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CsrfCookie {
|
||||||
|
jar: CookieJar,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CsrfCookie {
|
||||||
|
pub fn token(&self) -> &str {
|
||||||
|
&self.token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for CsrfCookie
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = (StatusCode, &'static str);
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let mut jar = CookieJar::from_request_parts(parts, state).await.unwrap(); // infalible result
|
||||||
|
|
||||||
|
let token = if let Some(token) = jar.get("csrf-token") {
|
||||||
|
token.value().to_string()
|
||||||
|
} else {
|
||||||
|
let token = generate_csrf_token();
|
||||||
|
jar = jar.add(
|
||||||
|
Cookie::build(("csrf-token", token.clone()))
|
||||||
|
.http_only(true)
|
||||||
|
.path("/")
|
||||||
|
.same_site(tower_sessions::cookie::SameSite::Strict),
|
||||||
|
);
|
||||||
|
token
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(CsrfCookie { jar, token })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponseParts for CsrfCookie {
|
||||||
|
type Error = Infallible;
|
||||||
|
|
||||||
|
fn into_response_parts(
|
||||||
|
self,
|
||||||
|
res: axum::response::ResponseParts,
|
||||||
|
) -> Result<axum::response::ResponseParts, Self::Error> {
|
||||||
|
self.jar.into_response_parts(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSRF_TOKEN_LENGTH: usize = 64;
|
||||||
|
fn generate_csrf_token() -> String {
|
||||||
|
rng()
|
||||||
|
.sample_iter(&Alphanumeric)
|
||||||
|
.take(CSRF_TOKEN_LENGTH)
|
||||||
|
.map(char::from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for CsrfCookie {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_tuple("CsrfCookie").field(&self.token).finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
7
templates/admin.html
Normal file
7
templates/admin.html
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<title>Admin</title>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/admin/users">Users</a></li>
|
||||||
|
</ul>
|
||||||
|
</html>
|
5
templates/error.html
Normal file
5
templates/error.html
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<title>Error</title>
|
||||||
|
<p class="error">{{error}}</p>
|
||||||
|
</html>
|
|
@ -8,9 +8,15 @@
|
||||||
<li><a href="/auth/signup">Signup</a></li>
|
<li><a href="/auth/signup">Signup</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
{% if is_logged_in %}
|
{% if is_logged_in %}
|
||||||
<h1>Super Secret Pages</h1>
|
<h1>Secret Pages</h1>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/auth/logout">Logout</a></li>
|
<li><a href="/auth/logout">Logout</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if is_admin %}
|
||||||
|
<h1>Super Secret Pages</h1>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/admin">Admin</a></li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
Password
|
Password
|
||||||
<input type=password name=password>
|
<input type=password name=password>
|
||||||
</label>
|
</label>
|
||||||
|
<input type=hidden name=csrf-token value="{{csrf_token}}" />
|
||||||
|
|
||||||
<button type="submit">Login</button>
|
<button type="submit">Login</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<title>Logout</title>
|
<title>Logout</title>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
<input type=hidden name=csrf-token value="{{csrf_token}}" />
|
||||||
<button type="submit">Logout</button>
|
<button type="submit">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
</html>
|
</html>
|
||||||
|
|
8
templates/signup-confirm.html
Normal file
8
templates/signup-confirm.html
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<title>Confirm Account</title>
|
||||||
|
<form method="post">
|
||||||
|
<input type=hidden name=csrf-token value="{{csrf_token}}" />
|
||||||
|
<button type="submit">Confirm My Account</button>
|
||||||
|
</form>
|
||||||
|
</html>
|
5
templates/signup-confirmation.html
Normal file
5
templates/signup-confirmation.html
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<title>Signup</title>
|
||||||
|
<p>A confirmation email has been sent to: {{email}}</p>
|
||||||
|
</html>
|
20
templates/signup.html
Normal file
20
templates/signup.html
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<title>Signup</title>
|
||||||
|
{%if let Some(msg) = error %}
|
||||||
|
<p class="error">{{msg}}</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method=post>
|
||||||
|
<label>
|
||||||
|
Email Address
|
||||||
|
<input type=text name=email>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type=password name=password>
|
||||||
|
</label>
|
||||||
|
<input type=hidden name=csrf-token value="{{csrf_token}}" />
|
||||||
|
|
||||||
|
<button type="submit">Signup</button>
|
||||||
|
</form>
|
||||||
|
</html>
|
21
templates/users.html
Normal file
21
templates/users.html
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<title>Users</title>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<th><a href="/admin/users/{{user.id}}">{{user.id}}</a></th>
|
||||||
|
<td>{{user.email}}</td>
|
||||||
|
<td>{{user.status}}</td>
|
||||||
|
{% endfor %}
|
||||||
|
<tbody>
|
||||||
|
</table>
|
||||||
|
</html>
|
|
@ -2,35 +2,57 @@ pub mod fixture;
|
||||||
use fixture::TestServer;
|
use fixture::TestServer;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use regex::Regex;
|
||||||
use test_log::test as traced;
|
use test_log::test as traced;
|
||||||
|
|
||||||
#[traced(tokio::test)]
|
#[traced(tokio::test)]
|
||||||
async fn login_succeeds_with_valid_credentials() -> Result<()> {
|
async fn login_succeeds_with_valid_credentials() -> Result<()> {
|
||||||
let server = TestServer::spawn().await;
|
let server = TestServer::spawn().await;
|
||||||
let client = reqwest::Client::builder().cookie_store(true).build()?;
|
let mut client = server.browser_client();
|
||||||
|
client.get_csrf_token().await?;
|
||||||
|
|
||||||
// Signup
|
// Signup
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(server.url("/auth/signup"))
|
.post("/auth/signup")
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
.csrf_form(&[("email", "admin@example.com"), ("password", "hunter2")])
|
||||||
.body("email=admin&password=hunter2")
|
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
assert_eq!(resp.status(), 200, "signup succeeds");
|
assert_eq!(resp.status(), 200, "signup succeeds");
|
||||||
|
|
||||||
|
let mail = server.mock_smtp_server.receive_mail().expect("recv mail");
|
||||||
|
let link_regex = Regex::new(r"/auth/confirm\?token\=([a-zA-Z0-9]+)")?;
|
||||||
|
let confirm_link = link_regex
|
||||||
|
.find(&mail.body)
|
||||||
|
.expect("find link in body")
|
||||||
|
.as_str();
|
||||||
|
|
||||||
|
println!("link: {confirm_link}");
|
||||||
|
|
||||||
|
// Confirm Account
|
||||||
|
let resp = client
|
||||||
|
.post(confirm_link)
|
||||||
|
.csrf_form::<[(&str, &str); 0]>(&[])
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), 200, "confirm succeeds");
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(server.url("/auth/login"))
|
.post("/auth/login")
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
.csrf_form(&[("email", "admin@example.com"), ("password", "hunter2")])
|
||||||
.body("email=admin&password=hunter2")
|
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
assert_eq!(resp.status(), 200, "login succeeds");
|
assert_eq!(resp.status(), 200, "login succeeds");
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
let resp = client.post(server.url("/auth/logout")).send().await?;
|
let resp = client
|
||||||
|
.post("/auth/logout")
|
||||||
|
.csrf_form::<[(&str, &str)]>(&[])
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
assert_eq!(resp.status(), 200, "logout succeeds");
|
assert_eq!(resp.status(), 200, "logout succeeds");
|
||||||
|
|
||||||
|
@ -40,13 +62,13 @@ async fn login_succeeds_with_valid_credentials() -> Result<()> {
|
||||||
#[traced(tokio::test)]
|
#[traced(tokio::test)]
|
||||||
async fn login_fails_with_invalid_credentials() -> Result<()> {
|
async fn login_fails_with_invalid_credentials() -> Result<()> {
|
||||||
let server = TestServer::spawn().await;
|
let server = TestServer::spawn().await;
|
||||||
let client = reqwest::Client::new();
|
let mut client = server.browser_client();
|
||||||
|
client.get_csrf_token().await?;
|
||||||
|
|
||||||
// Signup
|
// Signup
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(server.url("/auth/signup"))
|
.post("/auth/signup")
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
.csrf_form(&[("email", "admin@example.com"), ("password", "hunter2")])
|
||||||
.body("email=admin&password=hunter2")
|
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
@ -54,20 +76,15 @@ async fn login_fails_with_invalid_credentials() -> Result<()> {
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(server.url("/auth/login"))
|
.post("/auth/login")
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
.csrf_form(&[("email", "admin@example.com"), ("password", "hunter3")])
|
||||||
.body("email=admin&password=hunter3")
|
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
assert_ne!(
|
assert_eq!(
|
||||||
resp.status(),
|
resp.status(),
|
||||||
200,
|
401,
|
||||||
"login suceeded with invalid credentials"
|
"login didn't reject invalid credentials"
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
resp.headers().get("Set-Cookie").is_none(),
|
|
||||||
"auth cookie was set for invalid crednetials"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
server.shutdown().await
|
server.shutdown().await
|
||||||
|
@ -76,11 +93,12 @@ async fn login_fails_with_invalid_credentials() -> Result<()> {
|
||||||
#[traced(tokio::test)]
|
#[traced(tokio::test)]
|
||||||
async fn login_rejects_missing_credentials() -> Result<()> {
|
async fn login_rejects_missing_credentials() -> Result<()> {
|
||||||
let server = TestServer::spawn().await;
|
let server = TestServer::spawn().await;
|
||||||
let client = reqwest::Client::new();
|
let mut client = server.browser_client();
|
||||||
|
client.get_csrf_token().await?;
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(server.url("/auth/login"))
|
.post("/auth/login")
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
.csrf_form(&[("email", ""), ("password", "")])
|
||||||
.body("email=&password=")
|
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
@ -89,10 +107,6 @@ async fn login_rejects_missing_credentials() -> Result<()> {
|
||||||
401,
|
401,
|
||||||
"login didn't reject missing credentials"
|
"login didn't reject missing credentials"
|
||||||
);
|
);
|
||||||
assert!(
|
|
||||||
resp.headers().get("Set-Cookie").is_none(),
|
|
||||||
"auth cookie was set for missing crednetials"
|
|
||||||
);
|
|
||||||
|
|
||||||
server.shutdown().await
|
server.shutdown().await
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
use anyhow::Result;
|
use anyhow::{Context as _, Result};
|
||||||
|
use axum::http;
|
||||||
use bollard::query_parameters::{CreateImageOptionsBuilder, ListContainersOptionsBuilder};
|
use bollard::query_parameters::{CreateImageOptionsBuilder, ListContainersOptionsBuilder};
|
||||||
use bollard::secret::{
|
use bollard::secret::{
|
||||||
ContainerCreateBody, ContainerInspectResponse, ContainerState, CreateImageInfo, Health,
|
ContainerCreateBody, ContainerInspectResponse, ContainerState, CreateImageInfo, Health,
|
||||||
|
@ -8,6 +9,11 @@ use bollard::Docker;
|
||||||
use futures_util::{FutureExt, StreamExt as _};
|
use futures_util::{FutureExt, StreamExt as _};
|
||||||
use rand::distr::slice::Choose;
|
use rand::distr::slice::Choose;
|
||||||
use rand::{rng, Rng};
|
use rand::{rng, Rng};
|
||||||
|
use reqwest::header::{HeaderName, HeaderValue};
|
||||||
|
use reqwest::Body;
|
||||||
|
use select::document::Document;
|
||||||
|
use select::predicate::Attr;
|
||||||
|
use serde::Serialize;
|
||||||
use sqlx::migrate::MigrateDatabase;
|
use sqlx::migrate::MigrateDatabase;
|
||||||
use sqlx::{Connection, PgConnection};
|
use sqlx::{Connection, PgConnection};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
@ -43,6 +49,7 @@ impl TestServer {
|
||||||
listen: "[::]:0".parse().unwrap(),
|
listen: "[::]:0".parse().unwrap(),
|
||||||
// TODO: how do I both configure this and use a random port?
|
// TODO: how do I both configure this and use a random port?
|
||||||
public_url: "http://localhost/".to_string(),
|
public_url: "http://localhost/".to_string(),
|
||||||
|
key: "Q^,zH6M}*JY-W[oWCn6T7W!G=TvN,a5[~%cfRKZ7jse1EMDgG7GdTFy)ez*E(9I".to_string(),
|
||||||
},
|
},
|
||||||
database: conf::Database { url },
|
database: conf::Database { url },
|
||||||
debug: true,
|
debug: true,
|
||||||
|
@ -52,6 +59,7 @@ impl TestServer {
|
||||||
username: "bot@example.com".to_owned(),
|
username: "bot@example.com".to_owned(),
|
||||||
password: "1234".to_owned(),
|
password: "1234".to_owned(),
|
||||||
sender: "bot@example.com".to_owned(),
|
sender: "bot@example.com".to_owned(),
|
||||||
|
tls: None,
|
||||||
cert: Some(String::from_utf8_lossy(mock_smtp_server.cert_pem()).to_string()),
|
cert: Some(String::from_utf8_lossy(mock_smtp_server.cert_pem()).to_string()),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
@ -74,6 +82,11 @@ impl TestServer {
|
||||||
format!("http://{}{path}", self.addr)
|
format!("http://{}{path}", self.addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Construct a browser-like client.
|
||||||
|
pub fn browser_client(&self) -> BrowserClient {
|
||||||
|
BrowserClient::new(self.addr)
|
||||||
|
}
|
||||||
|
|
||||||
/// Request a graceful shutdown and return imideately.
|
/// Request a graceful shutdown and return imideately.
|
||||||
pub async fn start_shutdown(&self) -> Result<()> {
|
pub async fn start_shutdown(&self) -> Result<()> {
|
||||||
self.server_task_handle.abort();
|
self.server_task_handle.abort();
|
||||||
|
@ -90,6 +103,115 @@ impl TestServer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct BrowserClient {
|
||||||
|
inner: reqwest::Client,
|
||||||
|
server: SocketAddr,
|
||||||
|
csrf_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserClient {
|
||||||
|
fn new(server: SocketAddr) -> BrowserClient {
|
||||||
|
let inner = reqwest::Client::builder()
|
||||||
|
.cookie_store(true)
|
||||||
|
.build()
|
||||||
|
.expect("build reqwest client");
|
||||||
|
BrowserClient {
|
||||||
|
inner,
|
||||||
|
server,
|
||||||
|
csrf_token: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// format a URL for the given path
|
||||||
|
fn url(&self, path: &str) -> String {
|
||||||
|
println!("url: http://{}{path}", self.server);
|
||||||
|
format!("http://{}{path}", self.server)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn post<U: AsRef<str>>(&self, url: U) -> RequestBuilder {
|
||||||
|
RequestBuilder::new(
|
||||||
|
self.inner.post(self.url(url.as_ref())),
|
||||||
|
self.csrf_token.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_csrf_token(&mut self) -> Result<String> {
|
||||||
|
// Any page with a CSRF-cookie will do, tokens are valid for a session.
|
||||||
|
let resp = self.inner.get(&self.url("/auth/signup")).send().await?;
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), 200, "get CSRF page");
|
||||||
|
|
||||||
|
let signup_page = resp.text().await.context("recv signup page body")?;
|
||||||
|
|
||||||
|
let document = Document::from(signup_page.as_str());
|
||||||
|
let csrf_node = document
|
||||||
|
.find(Attr("name", "csrf-token"))
|
||||||
|
.next()
|
||||||
|
.context("find csrf node")?;
|
||||||
|
let csrf_token = csrf_node
|
||||||
|
.attr("value")
|
||||||
|
.context("get csrf token from node")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
self.csrf_token = Some(csrf_token.clone());
|
||||||
|
|
||||||
|
Ok(csrf_token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RequestBuilder {
|
||||||
|
inner: reqwest::RequestBuilder,
|
||||||
|
csrf_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestBuilder {
|
||||||
|
fn new(request: reqwest::RequestBuilder, csrf_token: Option<String>) -> RequestBuilder {
|
||||||
|
RequestBuilder {
|
||||||
|
inner: request,
|
||||||
|
csrf_token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn header<K, V>(mut self, key: K, value: V) -> Self
|
||||||
|
where
|
||||||
|
HeaderName: TryFrom<K>,
|
||||||
|
<HeaderName as TryFrom<K>>::Error: Into<http::Error>,
|
||||||
|
HeaderValue: TryFrom<V>,
|
||||||
|
<HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
|
||||||
|
{
|
||||||
|
self.inner = self.inner.header(key, value);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn form<T: Serialize + ?Sized>(mut self, form: &T) -> RequestBuilder {
|
||||||
|
self.inner = self.inner.form(form);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn csrf_form<T: Serialize + ?Sized>(mut self, form: &T) -> RequestBuilder {
|
||||||
|
let body_str = serde_urlencoded::to_string(form).unwrap();
|
||||||
|
let full_body_str = format!(
|
||||||
|
"{body_str}&csrf-token={}",
|
||||||
|
self.csrf_token.as_ref().unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
self.inner = self
|
||||||
|
.inner
|
||||||
|
.body(full_body_str)
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded");
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn body<T: Into<Body>>(mut self, body: T) -> Self {
|
||||||
|
self.inner = self.inner.body(body);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send(self) -> Result<reqwest::Response, reqwest::Error> {
|
||||||
|
self.inner.send().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const TEST_DB_IMAGE_NAME: &str = "postgres";
|
const TEST_DB_IMAGE_NAME: &str = "postgres";
|
||||||
const TEST_DB_SUPERUSER: &str = "postgres";
|
const TEST_DB_SUPERUSER: &str = "postgres";
|
||||||
const TEST_DB_SUPERUSER_PASS: &str = "password";
|
const TEST_DB_SUPERUSER_PASS: &str = "password";
|
||||||
|
|
|
@ -2,8 +2,7 @@ pub mod fixture;
|
||||||
use fixture::TestServer;
|
use fixture::TestServer;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use maik::MailAssertion;
|
use regex::Regex;
|
||||||
use regex::bytes::Regex;
|
|
||||||
use test_log::test as traced;
|
use test_log::test as traced;
|
||||||
|
|
||||||
#[traced(tokio::test)]
|
#[traced(tokio::test)]
|
||||||
|
@ -21,12 +20,15 @@ async fn subscribe_succeeds_with_valid_input() -> Result<()> {
|
||||||
|
|
||||||
assert_eq!(resp.status(), 200, "subscribe succeeds");
|
assert_eq!(resp.status(), 200, "subscribe succeeds");
|
||||||
|
|
||||||
assert!(
|
let mail = server.mock_smtp_server.receive_mail().expect("recv mail");
|
||||||
server
|
let link_regex =
|
||||||
.mock_smtp_server
|
Regex::new(r"http\:\/\/localhost\/subscriptions/confirm/\?token\=[a-zA-Z0-9]+")?;
|
||||||
.assert(MailAssertion::new().body_matches(Regex::new(r"http\:\/\/localhost\/")?)),
|
let link = link_regex
|
||||||
"email sent has link"
|
.find(&mail.body)
|
||||||
);
|
.expect("find link in body")
|
||||||
|
.as_str();
|
||||||
|
|
||||||
|
println!("link: {link}");
|
||||||
|
|
||||||
server.shutdown().await
|
server.shutdown().await
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue