Compare commits
No commits in common. "17f1b299512a7aaba39266de51db51ffaea057bf" and "c75bd0cb31ed130f728f05744392ed77690809a7" have entirely different histories.
17f1b29951
...
c75bd0cb31
25 changed files with 136 additions and 1169 deletions
375
Cargo.lock
generated
375
Cargo.lock
generated
|
@ -148,7 +148,7 @@ dependencies = [
|
|||
"rustc-hash",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -184,7 +184,7 @@ checksum = "34921de3d57974069bad483fdfe0ec65d88c4ff892edd1ab4d8b03be0dda1b9b"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -195,7 +195,7 @@ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -306,7 +306,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -351,21 +351,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "bitflags"
|
||||
version = "2.9.0"
|
||||
|
@ -417,7 +402,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"serde_repr",
|
||||
"serde_urlencoded",
|
||||
"thiserror 2.0.12",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
|
@ -575,10 +560,8 @@ checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
|||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64 0.22.1",
|
||||
"hkdf",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"time",
|
||||
"version_check",
|
||||
|
@ -724,7 +707,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -890,16 +873,6 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
||||
dependencies = [
|
||||
"mac",
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
|
@ -966,7 +939,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1176,20 +1149,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "http"
|
||||
version = "1.3.1"
|
||||
|
@ -1642,15 +1601,11 @@ version = "0.4.27"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "maik"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba8f0b47dc7cc74760332828e060ec705339b5e863894f575d81691546bbc836"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"lazy_static",
|
||||
|
@ -1660,32 +1615,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "matchers"
|
||||
version = "0.1.0"
|
||||
|
@ -1760,12 +1689,6 @@ dependencies = [
|
|||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
|
@ -1882,7 +1805,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1959,12 +1882,6 @@ dependencies = [
|
|||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
|
@ -2003,7 +1920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"thiserror 2.0.12",
|
||||
"thiserror",
|
||||
"ucd-trie",
|
||||
]
|
||||
|
||||
|
@ -2027,7 +1944,7 @@ dependencies = [
|
|||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2041,63 +1958,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "pin-project"
|
||||
version = "1.1.10"
|
||||
|
@ -2115,7 +1975,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2208,12 +2068,6 @@ dependencies = [
|
|||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.95"
|
||||
|
@ -2454,28 +2308,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "ron"
|
||||
version = "0.8.1"
|
||||
|
@ -2636,17 +2468,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "serde"
|
||||
version = "1.0.219"
|
||||
|
@ -2664,7 +2485,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2697,7 +2518,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2794,18 +2615,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "slab"
|
||||
version = "0.4.9"
|
||||
|
@ -2874,6 +2683,7 @@ checksum = "f743f2a3cea30a58cd479013f75550e879009e3a02f616f18ca699335aa248c3"
|
|||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"crossbeam-queue",
|
||||
"either",
|
||||
|
@ -2893,8 +2703,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"thiserror 2.0.12",
|
||||
"time",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
|
@ -2912,7 +2721,7 @@ dependencies = [
|
|||
"quote",
|
||||
"sqlx-core",
|
||||
"sqlx-macros-core",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2935,7 +2744,7 @@ dependencies = [
|
|||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
"sqlx-sqlite",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"url",
|
||||
|
@ -2952,6 +2761,7 @@ dependencies = [
|
|||
"bitflags",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"digest",
|
||||
"dotenvy",
|
||||
|
@ -2978,8 +2788,7 @@ dependencies = [
|
|||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror 2.0.12",
|
||||
"time",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami",
|
||||
|
@ -2995,6 +2804,7 @@ dependencies = [
|
|||
"base64 0.22.1",
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"chrono",
|
||||
"crc",
|
||||
"dotenvy",
|
||||
"etcetera",
|
||||
|
@ -3017,8 +2827,7 @@ dependencies = [
|
|||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror 2.0.12",
|
||||
"time",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami",
|
||||
|
@ -3031,6 +2840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "c26083e9a520e8eb87a06b12347679b142dc2ea29e6e409f805644a7a979a5bc"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"chrono",
|
||||
"flume",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
|
@ -3043,8 +2853,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_urlencoded",
|
||||
"sqlx-core",
|
||||
"thiserror 2.0.12",
|
||||
"time",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
|
@ -3069,31 +2878,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
|
@ -3111,17 +2895,6 @@ version = "2.6.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "syn"
|
||||
version = "2.0.101"
|
||||
|
@ -3150,7 +2923,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -3198,17 +2971,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "test-log"
|
||||
version = "0.2.17"
|
||||
|
@ -3227,16 +2989,7 @@ checksum = "888d0c3c6db53c0fdab160d2ed5e12ba745383d3e85813f2ea0f2b1475ab553f"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"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",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -3245,18 +2998,7 @@ version = "2.0.12"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
|
||||
dependencies = [
|
||||
"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",
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -3267,7 +3009,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -3371,7 +3113,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -3545,7 +3287,7 @@ dependencies = [
|
|||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.12",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
@ -3563,20 +3305,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "tracing"
|
||||
version = "0.1.41"
|
||||
|
@ -3597,7 +3325,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -3733,12 +3461,6 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
|
@ -3825,7 +3547,7 @@ dependencies = [
|
|||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
|
@ -3860,7 +3582,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
@ -3959,7 +3681,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -3970,7 +3692,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -4263,17 +3985,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "yaml-rust2"
|
||||
version = "0.10.1"
|
||||
|
@ -4314,7 +4025,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
|
@ -4330,7 +4041,6 @@ dependencies = [
|
|||
"axum-extra",
|
||||
"bollard",
|
||||
"config",
|
||||
"cookie",
|
||||
"futures-util",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
|
@ -4341,21 +4051,18 @@ dependencies = [
|
|||
"rand 0.9.1",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"select",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sqlx",
|
||||
"tar",
|
||||
"test-log",
|
||||
"thiserror 2.0.12",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-sessions",
|
||||
"tower-sessions-sqlx-store",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
|
@ -4378,7 +4085,7 @@ checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -4398,7 +4105,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
|
@ -4438,5 +4145,5 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
"syn",
|
||||
]
|
||||
|
|
|
@ -15,7 +15,6 @@ askama_web = { version = "0.14.4", features = ["axum-0.8"] }
|
|||
axum = { version = "0.8", features = ["tokio", "http1", "http2", "macros"] }
|
||||
axum-extra = { version = "0.10", features = ["cookie-private", "typed-header"] }
|
||||
config = { version = "0.15", features = ["toml"] }
|
||||
cookie = { version = "0.18.1", features = ["key-expansion"] }
|
||||
futures-util = "0.3"
|
||||
hyper = "1.1"
|
||||
lettre = { version = "0.11.17", features = ["tokio1", "tokio1-native-tls", "tracing", "web"] }
|
||||
|
@ -24,7 +23,7 @@ pin-project = "1.1.0"
|
|||
rand = "0.9.1"
|
||||
serde = { version = "1.0.164", features = ["derive"] }
|
||||
serde_json = "1.0.99"
|
||||
sqlx = { version = "0.8.5", features = ["runtime-tokio", "macros", "postgres", "uuid", "migrate"] }
|
||||
sqlx = { version = "0.8.5", features = ["runtime-tokio", "macros", "postgres", "uuid", "chrono", "migrate"] }
|
||||
tar = "0.4.44"
|
||||
thiserror = "2.0"
|
||||
tokio = { version = "1.28.2", features = ["full"] }
|
||||
|
@ -33,7 +32,6 @@ tokio-util = "0.7.15"
|
|||
tower = "0.5.2"
|
||||
tower-http = { version = "0.6.6", features = ["trace"] }
|
||||
tower-sessions = "0.14.0"
|
||||
tower-sessions-sqlx-store = { version = "0.15.0", features = ["postgres"] }
|
||||
tracing = "0.1.37"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
uuid = { version = "1.16.0", features = ["serde", "v4"] }
|
||||
|
@ -44,12 +42,7 @@ http-body-util = "0.1.3"
|
|||
maik = "0.2.0"
|
||||
regex = "1.11.1"
|
||||
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"] }
|
||||
|
||||
[profile.dev.package.sqlx-macros]
|
||||
opt-level = 3
|
||||
|
||||
[patch.crates-io]
|
||||
maik = { path = "../downloads/maik" }
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
[app]
|
||||
listen = "[::]:3742"
|
||||
key = "Q^,zH6M}*JY-W[oWCn6T7W!G=TvN,a5[~%cfRKZ7jse1EMDgG7GdTFy)ez*E(9I"
|
||||
|
||||
[email]
|
||||
server = "smtp.postmarkapp.com"
|
||||
port = 587
|
||||
tls = "StartTls"
|
||||
username = "PM-T-outbound-Nh7Fw1vnQ3dwi-57cweG5e"
|
||||
sender = "Z2A Bot <bot@psbarrett.com>"
|
||||
server = "smtp.fastmail.com"
|
||||
username = "patrick@psbarrett.com"
|
||||
sender = "Z2A Bot <bot@azdle.net>"
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
-- 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;
|
|
@ -1,6 +0,0 @@
|
|||
CREATE TABLE user_roles(
|
||||
role TEXT NOT NULL,
|
||||
user_id uuid NOT NULL
|
||||
REFERENCES users (id),
|
||||
PRIMARY KEY (user_id, role)
|
||||
);
|
|
@ -12,7 +12,6 @@ pub struct Database {
|
|||
pub struct App {
|
||||
pub listen: SocketAddr,
|
||||
pub public_url: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
|
@ -22,16 +21,9 @@ pub struct Email {
|
|||
pub username: String,
|
||||
pub password: String,
|
||||
pub sender: String,
|
||||
pub tls: Option<TlsMode>,
|
||||
pub cert: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub enum TlsMode {
|
||||
Tls,
|
||||
StartTls,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[allow(unused)]
|
||||
pub struct Conf {
|
||||
|
|
|
@ -11,7 +11,7 @@ use lettre::{
|
|||
};
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::conf::{self, TlsMode};
|
||||
use crate::conf;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum EmailClient {
|
||||
|
@ -28,12 +28,7 @@ impl EmailClient {
|
|||
return Ok(EmailClient::Disabled);
|
||||
};
|
||||
|
||||
let mut inner = match conf.tls {
|
||||
Some(TlsMode::Tls) | None => AsyncSmtpTransport::<Tokio1Executor>::relay(&conf.server)?,
|
||||
Some(TlsMode::StartTls) => {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&conf.server)?
|
||||
}
|
||||
};
|
||||
let mut inner = AsyncSmtpTransport::<Tokio1Executor>::relay(&conf.server).unwrap();
|
||||
|
||||
if let Some(port) = conf.port {
|
||||
inner = inner.port(port);
|
||||
|
|
|
@ -4,9 +4,7 @@ pub mod session;
|
|||
use anyhow::{Context as _, Result};
|
||||
use axum::extract::FromRef;
|
||||
use axum_extra::extract::cookie::Key;
|
||||
use futures_util::FutureExt as _;
|
||||
use pin_project::pin_project;
|
||||
use session::GetPgPool;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use std::future::{Future, IntoFuture};
|
||||
|
@ -17,8 +15,7 @@ use std::sync::Arc;
|
|||
use tokio::signal;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tower_sessions::cookie::time::Duration;
|
||||
use tower_sessions::{session_store::ExpiredDeletion, Expiry, SessionManagerLayer};
|
||||
use tower_sessions_sqlx_store::PostgresStore;
|
||||
use tower_sessions::{Expiry, MemoryStore, SessionManagerLayer};
|
||||
use tracing::info;
|
||||
|
||||
use crate::email_client::EmailClient;
|
||||
|
@ -56,24 +53,20 @@ impl ZeroToAxum {
|
|||
let email_client =
|
||||
email_client::EmailClient::new(conf.email.clone()).context("build email client")?;
|
||||
|
||||
let session_store = PostgresStore::new(db.clone());
|
||||
session_store.migrate().await?;
|
||||
let session_cleanup_task = tokio::task::spawn(
|
||||
session_store
|
||||
.clone()
|
||||
.continuously_delete_expired(std::time::Duration::from_secs(60)),
|
||||
);
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
.with_secure(false)
|
||||
.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()),
|
||||
// TODO: pull from config
|
||||
key: Key::generate(),
|
||||
db,
|
||||
email_client,
|
||||
};
|
||||
|
||||
// Just store locally for now. Supports database connections.
|
||||
let session_store = MemoryStore::default();
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
.with_secure(false)
|
||||
.with_expiry(Expiry::OnInactivity(Duration::weeks(1)));
|
||||
|
||||
let app = routes::build()
|
||||
.with_state(app_state)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
|
@ -83,9 +76,7 @@ impl ZeroToAxum {
|
|||
.await
|
||||
.unwrap();
|
||||
let bound_addr = listener.local_addr().unwrap();
|
||||
let server = axum::serve(listener, app).with_graceful_shutdown(
|
||||
shutdown_signal().then(async move |()| session_cleanup_task.abort_handle().abort()),
|
||||
);
|
||||
let server = axum::serve(listener, app).with_graceful_shutdown(shutdown_signal());
|
||||
|
||||
info!("server started, listening on {bound_addr:?}");
|
||||
|
||||
|
@ -112,12 +103,6 @@ impl FromRef<AppState> for Key {
|
|||
}
|
||||
}
|
||||
|
||||
impl GetPgPool for AppState {
|
||||
fn get_pg_pool(&self) -> PgPool {
|
||||
self.db.clone()
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
|
|
|
@ -1,95 +0,0 @@
|
|||
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,61 +5,33 @@ use argon2::Argon2;
|
|||
use askama::Template;
|
||||
use askama_web::WebTemplate;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Redirect},
|
||||
routing::get,
|
||||
routing::{get, post},
|
||||
Form, Router,
|
||||
};
|
||||
use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use rand::{distr::Alphanumeric, rng, Rng as _};
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, error, info};
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::server::{
|
||||
session::{Auth, CsrfCookie, User},
|
||||
session::{Auth, User},
|
||||
AppState,
|
||||
};
|
||||
|
||||
use super::ErrorPage;
|
||||
|
||||
pub fn build() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/signup", get(signup_page).post(signup))
|
||||
.route("/signup", post(signup))
|
||||
.route("/login", get(login_page).post(login))
|
||||
.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)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct SignupForm {
|
||||
email: String,
|
||||
password: String,
|
||||
csrf_token: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for SignupForm {
|
||||
|
@ -67,34 +39,18 @@ impl fmt::Debug for SignupForm {
|
|||
f.debug_struct("SignupForm")
|
||||
.field("email", &self.email)
|
||||
.field("password", &"REDACTED")
|
||||
.field("csrf-token", &self.csrf_token)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template, WebTemplate, Default)]
|
||||
#[template(path = "signup-confirmation.html")]
|
||||
struct SignupConfirmation {
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(db, email_client))]
|
||||
#[tracing::instrument(skip(db, user))]
|
||||
pub async fn signup(
|
||||
State(AppState {
|
||||
db,
|
||||
email_client,
|
||||
conf,
|
||||
..
|
||||
}): State<AppState>,
|
||||
csrf: CsrfCookie,
|
||||
State(AppState { db, .. }): State<AppState>,
|
||||
mut user: Auth,
|
||||
Form(form): Form<SignupForm>,
|
||||
) -> Result<impl IntoResponse, SignupError> {
|
||||
info!("signup attempt");
|
||||
|
||||
if form.csrf_token != csrf.token() {
|
||||
return Err(SignupError::CsrfValidationFailed);
|
||||
}
|
||||
|
||||
info!("hash password: {}", &form.password);
|
||||
let password_hash: String = tokio::task::spawn_blocking(async move || {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
|
@ -110,90 +66,43 @@ pub async fn signup(
|
|||
.await?;
|
||||
info!("hashed password: {password_hash}");
|
||||
|
||||
let mut txn = db.begin().await.context("start database transaction")?;
|
||||
warn!("db: {db:?}");
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO users (id, email, password, status)
|
||||
VALUES ($1, $2, $3, 'pending_confirmation')
|
||||
INSERT INTO users (id, email, password)
|
||||
VALUES ($1, $2, $3)
|
||||
"#,
|
||||
user_id,
|
||||
form.email,
|
||||
password_hash,
|
||||
)
|
||||
.execute(&mut *txn)
|
||||
.execute(&db)
|
||||
.await
|
||||
.context("insert new user into database")?;
|
||||
|
||||
let token: String = {
|
||||
let mut rng = rng();
|
||||
std::iter::repeat_with(|| rng.sample(Alphanumeric))
|
||||
.map(char::from)
|
||||
.take(25)
|
||||
.collect()
|
||||
};
|
||||
user.user = Some(User::new(user_id));
|
||||
user.session.insert("user", &user.user).await.unwrap();
|
||||
|
||||
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 })
|
||||
Ok(Redirect::to("/"))
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum SignupError {
|
||||
#[error("Invalid Email")]
|
||||
InvalidEmail,
|
||||
#[error("CSRF Validation Failed")]
|
||||
CsrfValidationFailed,
|
||||
#[error("Unknown Error: {0}")]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl IntoResponse for SignupError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let (status, message) = match self {
|
||||
SignupError::InvalidEmail => (StatusCode::BAD_REQUEST, "Invalid Email"),
|
||||
SignupError::CsrfValidationFailed => {
|
||||
(StatusCode::BAD_REQUEST, "CSRF Validation Failed, Try Again")
|
||||
}
|
||||
match self {
|
||||
SignupError::Unknown(e) => {
|
||||
error!(?e, "returning INTERNAL SERVER ERROR");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error")
|
||||
}
|
||||
};
|
||||
|
||||
(
|
||||
status,
|
||||
ErrorPage {
|
||||
error: message.to_string(),
|
||||
},
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -201,30 +110,19 @@ impl IntoResponse for SignupError {
|
|||
#[template(path = "login.html")]
|
||||
struct LoginPage {
|
||||
error: Option<String>,
|
||||
csrf_token: String,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn login_page(csrf: CsrfCookie) -> impl IntoResponse {
|
||||
pub async fn login_page() -> LoginPage {
|
||||
info!("get login page");
|
||||
|
||||
let csrf_token = csrf.token().to_string();
|
||||
|
||||
(
|
||||
csrf,
|
||||
LoginPage {
|
||||
error: None,
|
||||
csrf_token,
|
||||
},
|
||||
)
|
||||
Default::default()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct LoginForm {
|
||||
email: String,
|
||||
password: String,
|
||||
csrf_token: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for LoginForm {
|
||||
|
@ -240,18 +138,13 @@ impl fmt::Debug for LoginForm {
|
|||
pub async fn login(
|
||||
State(AppState { db, .. }): State<AppState>,
|
||||
mut auth: Auth,
|
||||
csrf: CsrfCookie,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Result<impl IntoResponse, LoginError> {
|
||||
info!("login attempt");
|
||||
|
||||
if form.csrf_token != csrf.token() {
|
||||
return Err(LoginError::CsrfValidationFailed);
|
||||
}
|
||||
|
||||
let user = sqlx::query!(
|
||||
r#"
|
||||
SELECT id, password FROM users WHERE status = 'confirmed' AND email = $1 LIMIT 1;
|
||||
SELECT id, password FROM users WHERE email = $1 LIMIT 1;
|
||||
"#,
|
||||
form.email
|
||||
)
|
||||
|
@ -297,8 +190,6 @@ pub enum LoginError {
|
|||
InvalidPassword,
|
||||
#[error("Unknown User")]
|
||||
UnknownUser,
|
||||
#[error("CSRF Validation Failed")]
|
||||
CsrfValidationFailed,
|
||||
#[error("Unknown Error: {0}")]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
}
|
||||
|
@ -308,9 +199,6 @@ impl IntoResponse for LoginError {
|
|||
let (status, message) = match self {
|
||||
LoginError::InvalidPassword => (StatusCode::UNAUTHORIZED, "Invalid Password"),
|
||||
LoginError::UnknownUser => (StatusCode::UNAUTHORIZED, "Unknown User"),
|
||||
LoginError::CsrfValidationFailed => {
|
||||
(StatusCode::BAD_REQUEST, "CSRF Validation Failed, Try Again")
|
||||
}
|
||||
LoginError::Unknown(e) => {
|
||||
error!(?e, "returning INTERNAL SERVER ERROR");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error")
|
||||
|
@ -319,8 +207,8 @@ impl IntoResponse for LoginError {
|
|||
|
||||
(
|
||||
status,
|
||||
ErrorPage {
|
||||
error: message.to_string(),
|
||||
LoginPage {
|
||||
error: Some(message.to_string()),
|
||||
},
|
||||
)
|
||||
.into_response()
|
||||
|
@ -329,36 +217,18 @@ impl IntoResponse for LoginError {
|
|||
|
||||
#[derive(Template, WebTemplate)]
|
||||
#[template(path = "logout.html")]
|
||||
struct LogoutPage {
|
||||
csrf_token: String,
|
||||
}
|
||||
struct LogoutPage;
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn logout_page(csrf: CsrfCookie) -> impl IntoResponse {
|
||||
pub async fn logout_page() -> LogoutPage {
|
||||
info!("get logout page");
|
||||
|
||||
let csrf_token = csrf.token().to_string();
|
||||
|
||||
(csrf, LogoutPage { csrf_token })
|
||||
LogoutPage
|
||||
}
|
||||
|
||||
#[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> {
|
||||
pub async fn logout(mut user: Auth) -> Result<impl IntoResponse, LogoutError> {
|
||||
info!("logout attempt");
|
||||
|
||||
if form.csrf_token != csrf.token() {
|
||||
return Err(LogoutError::CsrfValidationFailed);
|
||||
}
|
||||
|
||||
if user.user.is_none() {
|
||||
return Err(LogoutError::NotLoggedIn);
|
||||
}
|
||||
|
@ -373,120 +243,19 @@ pub async fn logout(
|
|||
pub enum LogoutError {
|
||||
#[error("Not Logged In")]
|
||||
NotLoggedIn,
|
||||
#[error("CSRF Validation Failed")]
|
||||
CsrfValidationFailed,
|
||||
#[error("Unknown Error: {0}")]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl IntoResponse for LogoutError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let (status, message) = match self {
|
||||
match self {
|
||||
LogoutError::NotLoggedIn => (StatusCode::UNAUTHORIZED, "Unknown User"),
|
||||
LogoutError::CsrfValidationFailed => {
|
||||
(StatusCode::BAD_REQUEST, "CSRF Validation Failed, Try Again")
|
||||
}
|
||||
LogoutError::Unknown(e) => {
|
||||
error!(?e, "returning INTERNAL SERVER 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,4 +1,3 @@
|
|||
mod admin;
|
||||
mod auth;
|
||||
mod subscriptions;
|
||||
|
||||
|
@ -13,7 +12,6 @@ pub fn build() -> Router<AppState> {
|
|||
.route("/", get(homepage))
|
||||
.route("/health", get(health_check))
|
||||
.nest("/auth", auth::build())
|
||||
.nest("/admin", admin::build())
|
||||
.nest("/subscriptions", subscriptions::build())
|
||||
}
|
||||
|
||||
|
@ -24,18 +22,10 @@ async fn health_check() {}
|
|||
#[template(path = "homepage.html")]
|
||||
struct Homepage {
|
||||
is_logged_in: bool,
|
||||
is_admin: bool,
|
||||
}
|
||||
|
||||
async fn homepage(user: Auth) -> Homepage {
|
||||
Homepage {
|
||||
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},
|
||||
Form, Router,
|
||||
};
|
||||
use cookie::time::OffsetDateTime;
|
||||
use rand::{distr::Alphanumeric, rng, Rng as _};
|
||||
use serde::Deserialize;
|
||||
use sqlx::types::chrono::Utc;
|
||||
use tracing::{debug, error, info};
|
||||
use uuid::Uuid;
|
||||
|
||||
|
@ -53,7 +53,7 @@ pub async fn subscribe(
|
|||
subscriber_id,
|
||||
form.email,
|
||||
form.name,
|
||||
OffsetDateTime::now_utc()
|
||||
Utc::now()
|
||||
)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
|
|
|
@ -1,68 +1,21 @@
|
|||
use std::{convert::Infallible, fmt};
|
||||
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
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 sqlx::PgPool;
|
||||
use tower_sessions::Session;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
roles: Vec<Role>,
|
||||
// roles: Vec<String>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn new(id: Uuid) -> User {
|
||||
User {
|
||||
id,
|
||||
roles: Vec::new(),
|
||||
}
|
||||
User { id }
|
||||
}
|
||||
|
||||
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 {
|
||||
|
@ -71,104 +24,19 @@ pub struct Auth {
|
|||
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
|
||||
where
|
||||
S: Send + Sync + GetPgPool,
|
||||
{
|
||||
type Rejection = (StatusCode, &'static str);
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
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();
|
||||
|
||||
// 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();
|
||||
|
||||
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 session = Session::from_request_parts(parts, state).await?;
|
||||
|
||||
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
|
||||
};
|
||||
let user: Option<User> = session.get("user").await.unwrap_or_default();
|
||||
|
||||
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()
|
||||
// session.insert("user", &data).await.unwrap();
|
||||
|
||||
Ok(Self { session, user })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<title>Admin</title>
|
||||
<ul>
|
||||
<li><a href="/admin/users">Users</a></li>
|
||||
</ul>
|
||||
</html>
|
|
@ -1,5 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<title>Error</title>
|
||||
<p class="error">{{error}}</p>
|
||||
</html>
|
|
@ -8,15 +8,9 @@
|
|||
<li><a href="/auth/signup">Signup</a></li>
|
||||
</ul>
|
||||
{% if is_logged_in %}
|
||||
<h1>Secret Pages</h1>
|
||||
<h1>Super Secret Pages</h1>
|
||||
<ul>
|
||||
<li><a href="/auth/logout">Logout</a></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if is_admin %}
|
||||
<h1>Super Secret Pages</h1>
|
||||
<ul>
|
||||
<li><a href="/admin">Admin</a></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</html>
|
||||
|
|
|
@ -13,7 +13,6 @@
|
|||
Password
|
||||
<input type=password name=password>
|
||||
</label>
|
||||
<input type=hidden name=csrf-token value="{{csrf_token}}" />
|
||||
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
<html lang="en">
|
||||
<title>Logout</title>
|
||||
<form method="post">
|
||||
<input type=hidden name=csrf-token value="{{csrf_token}}" />
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
</html>
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
<!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>
|
|
@ -1,5 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<title>Signup</title>
|
||||
<p>A confirmation email has been sent to: {{email}}</p>
|
||||
</html>
|
|
@ -1,20 +0,0 @@
|
|||
<!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>
|
|
@ -1,21 +0,0 @@
|
|||
<!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,57 +2,35 @@ pub mod fixture;
|
|||
use fixture::TestServer;
|
||||
|
||||
use anyhow::Result;
|
||||
use regex::Regex;
|
||||
use test_log::test as traced;
|
||||
|
||||
#[traced(tokio::test)]
|
||||
async fn login_succeeds_with_valid_credentials() -> Result<()> {
|
||||
let server = TestServer::spawn().await;
|
||||
let mut client = server.browser_client();
|
||||
client.get_csrf_token().await?;
|
||||
let client = reqwest::Client::builder().cookie_store(true).build()?;
|
||||
|
||||
// Signup
|
||||
let resp = client
|
||||
.post("/auth/signup")
|
||||
.csrf_form(&[("email", "admin@example.com"), ("password", "hunter2")])
|
||||
.post(server.url("/auth/signup"))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body("email=admin&password=hunter2")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
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
|
||||
let resp = client
|
||||
.post("/auth/login")
|
||||
.csrf_form(&[("email", "admin@example.com"), ("password", "hunter2")])
|
||||
.post(server.url("/auth/login"))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body("email=admin&password=hunter2")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert_eq!(resp.status(), 200, "login succeeds");
|
||||
|
||||
// Logout
|
||||
let resp = client
|
||||
.post("/auth/logout")
|
||||
.csrf_form::<[(&str, &str)]>(&[])
|
||||
.send()
|
||||
.await?;
|
||||
let resp = client.post(server.url("/auth/logout")).send().await?;
|
||||
|
||||
assert_eq!(resp.status(), 200, "logout succeeds");
|
||||
|
||||
|
@ -62,13 +40,13 @@ async fn login_succeeds_with_valid_credentials() -> Result<()> {
|
|||
#[traced(tokio::test)]
|
||||
async fn login_fails_with_invalid_credentials() -> Result<()> {
|
||||
let server = TestServer::spawn().await;
|
||||
let mut client = server.browser_client();
|
||||
client.get_csrf_token().await?;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Signup
|
||||
let resp = client
|
||||
.post("/auth/signup")
|
||||
.csrf_form(&[("email", "admin@example.com"), ("password", "hunter2")])
|
||||
.post(server.url("/auth/signup"))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body("email=admin&password=hunter2")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
|
@ -76,15 +54,20 @@ async fn login_fails_with_invalid_credentials() -> Result<()> {
|
|||
|
||||
// Login
|
||||
let resp = client
|
||||
.post("/auth/login")
|
||||
.csrf_form(&[("email", "admin@example.com"), ("password", "hunter3")])
|
||||
.post(server.url("/auth/login"))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body("email=admin&password=hunter3")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
401,
|
||||
"login didn't reject invalid credentials"
|
||||
200,
|
||||
"login suceeded with invalid credentials"
|
||||
);
|
||||
assert!(
|
||||
resp.headers().get("Set-Cookie").is_none(),
|
||||
"auth cookie was set for invalid crednetials"
|
||||
);
|
||||
|
||||
server.shutdown().await
|
||||
|
@ -93,12 +76,11 @@ async fn login_fails_with_invalid_credentials() -> Result<()> {
|
|||
#[traced(tokio::test)]
|
||||
async fn login_rejects_missing_credentials() -> Result<()> {
|
||||
let server = TestServer::spawn().await;
|
||||
let mut client = server.browser_client();
|
||||
client.get_csrf_token().await?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post("/auth/login")
|
||||
.csrf_form(&[("email", ""), ("password", "")])
|
||||
.post(server.url("/auth/login"))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body("email=&password=")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
|
@ -107,6 +89,10 @@ async fn login_rejects_missing_credentials() -> Result<()> {
|
|||
401,
|
||||
"login didn't reject missing credentials"
|
||||
);
|
||||
assert!(
|
||||
resp.headers().get("Set-Cookie").is_none(),
|
||||
"auth cookie was set for missing crednetials"
|
||||
);
|
||||
|
||||
server.shutdown().await
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
use anyhow::{Context as _, Result};
|
||||
use axum::http;
|
||||
use anyhow::Result;
|
||||
use bollard::query_parameters::{CreateImageOptionsBuilder, ListContainersOptionsBuilder};
|
||||
use bollard::secret::{
|
||||
ContainerCreateBody, ContainerInspectResponse, ContainerState, CreateImageInfo, Health,
|
||||
|
@ -9,11 +8,6 @@ use bollard::Docker;
|
|||
use futures_util::{FutureExt, StreamExt as _};
|
||||
use rand::distr::slice::Choose;
|
||||
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::{Connection, PgConnection};
|
||||
use std::net::SocketAddr;
|
||||
|
@ -49,7 +43,6 @@ impl TestServer {
|
|||
listen: "[::]:0".parse().unwrap(),
|
||||
// TODO: how do I both configure this and use a random port?
|
||||
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 },
|
||||
debug: true,
|
||||
|
@ -59,7 +52,6 @@ impl TestServer {
|
|||
username: "bot@example.com".to_owned(),
|
||||
password: "1234".to_owned(),
|
||||
sender: "bot@example.com".to_owned(),
|
||||
tls: None,
|
||||
cert: Some(String::from_utf8_lossy(mock_smtp_server.cert_pem()).to_string()),
|
||||
}),
|
||||
})
|
||||
|
@ -82,11 +74,6 @@ impl TestServer {
|
|||
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.
|
||||
pub async fn start_shutdown(&self) -> Result<()> {
|
||||
self.server_task_handle.abort();
|
||||
|
@ -103,115 +90,6 @@ 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_SUPERUSER: &str = "postgres";
|
||||
const TEST_DB_SUPERUSER_PASS: &str = "password";
|
||||
|
|
|
@ -2,7 +2,8 @@ pub mod fixture;
|
|||
use fixture::TestServer;
|
||||
|
||||
use anyhow::Result;
|
||||
use regex::Regex;
|
||||
use maik::MailAssertion;
|
||||
use regex::bytes::Regex;
|
||||
use test_log::test as traced;
|
||||
|
||||
#[traced(tokio::test)]
|
||||
|
@ -20,15 +21,12 @@ async fn subscribe_succeeds_with_valid_input() -> Result<()> {
|
|||
|
||||
assert_eq!(resp.status(), 200, "subscribe succeeds");
|
||||
|
||||
let mail = server.mock_smtp_server.receive_mail().expect("recv mail");
|
||||
let link_regex =
|
||||
Regex::new(r"http\:\/\/localhost\/subscriptions/confirm/\?token\=[a-zA-Z0-9]+")?;
|
||||
let link = link_regex
|
||||
.find(&mail.body)
|
||||
.expect("find link in body")
|
||||
.as_str();
|
||||
|
||||
println!("link: {link}");
|
||||
assert!(
|
||||
server
|
||||
.mock_smtp_server
|
||||
.assert(MailAssertion::new().body_matches(Regex::new(r"http\:\/\/localhost\/")?)),
|
||||
"email sent has link"
|
||||
);
|
||||
|
||||
server.shutdown().await
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue