Compare commits

..

No commits in common. "17f1b299512a7aaba39266de51db51ffaea057bf" and "c75bd0cb31ed130f728f05744392ed77690809a7" have entirely different histories.

25 changed files with 136 additions and 1169 deletions

375
Cargo.lock generated
View file

@ -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",
]

View file

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

View file

@ -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>"

View file

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

View file

@ -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)
);

View file

@ -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 {

View file

@ -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);

View file

@ -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()

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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,
}

View file

@ -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

View file

@ -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 })
}
}

View file

@ -1,7 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<title>Admin</title>
<ul>
<li><a href="/admin/users">Users</a></li>
</ul>
</html>

View file

@ -1,5 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<title>Error</title>
<p class="error">{{error}}</p>
</html>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -1,5 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<title>Signup</title>
<p>A confirmation email has been sent to: {{email}}</p>
</html>

View file

@ -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>

View file

@ -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>

View file

@ -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
}

View file

@ -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";

View file

@ -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
}