Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e32b4bfb82 | |||
| 586faccc7c | |||
| ec36ea9116 | |||
| ace9515f6e | |||
| 89995a0386 | |||
| 63e55f814f | |||
| c28b25d8d9 | |||
| 1e0b9ac166 | |||
| c35bfaeaf8 | |||
| ccbd97b836 | |||
| 9933990fd3 | |||
| 44c083a8ef | |||
| e28c584b26 | |||
| 78df25f150 | |||
| ae5d466e99 | |||
| 38700ee4cb | |||
| 8a7e644960 | |||
| 58a8c375f0 | |||
| f1a2721cbe | |||
|
|
bd12632a7e | ||
| 1bbdb04dc9 | |||
| dba07912d2 | |||
| 10548295cd | |||
| 8c29f1d31f | |||
| b37c0665d3 | |||
| 452da73413 |
40 changed files with 2335 additions and 360 deletions
711
Cargo.lock
generated
711
Cargo.lock
generated
|
|
@ -2,6 +2,12 @@
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "argh"
|
name = "argh"
|
||||||
version = "0.1.19"
|
version = "0.1.19"
|
||||||
|
|
@ -33,12 +39,136 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-trait"
|
||||||
|
version = "0.1.92"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atomic-waker"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||||
|
dependencies = [
|
||||||
|
"axum-core",
|
||||||
|
"axum-macros",
|
||||||
|
"bytes",
|
||||||
|
"form_urlencoded",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"itoa",
|
||||||
|
"matchit",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"serde_core",
|
||||||
|
"serde_json",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-core"
|
||||||
|
version = "0.5.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-extra"
|
||||||
|
version = "0.12.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"cookie",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-macros"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.23.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
|
|
@ -56,6 +186,21 @@ name = "camino"
|
||||||
version = "1.2.5"
|
version = "1.2.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
|
checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
|
|
@ -63,10 +208,112 @@ version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie"
|
||||||
|
version = "0.18.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
"time",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"const-oid",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "display_full_error"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bc7c47e9a2fd28a8edd1446f10dabe8ac5d26bb77ed2b1077bfcd8308904e8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dn_escape"
|
name = "dn_escape"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
"zlib-rs",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "form_urlencoded"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-channel"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
|
|
@ -121,6 +368,123 @@ dependencies = [
|
||||||
"r-efi",
|
"r-efi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glob"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body-util"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range-header"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httparse"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper"
|
||||||
|
version = "1.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
|
"itoa",
|
||||||
|
"pin-project-lite",
|
||||||
|
"smallvec",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-util"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"hyper",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jobserver"
|
||||||
|
version = "0.1.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.104"
|
version = "0.3.104"
|
||||||
|
|
@ -154,7 +518,7 @@ version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "105256b138a7ed84ac1ae375870eae23be5f8fed16ca172ae5f1c1aa37bd4242"
|
checksum = "105256b138a7ed84ac1ae375870eae23be5f8fed16ca172ae5f1c1aa37bd4242"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"ldap3_lber",
|
"ldap3_lber",
|
||||||
"nom",
|
"nom",
|
||||||
|
|
@ -176,13 +540,24 @@ name = "llldap"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argh",
|
"argh",
|
||||||
|
"async-trait",
|
||||||
|
"axum",
|
||||||
|
"axum-extra",
|
||||||
|
"base64 0.23.1",
|
||||||
"camino",
|
"camino",
|
||||||
"dn_escape",
|
"dn_escape",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"http",
|
||||||
"ldap3_proto",
|
"ldap3_proto",
|
||||||
|
"minijinja",
|
||||||
|
"minijinja-embed",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"static-serve",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|
@ -194,18 +569,72 @@ version = "0.4.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.3"
|
version = "2.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memo-map"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5449c8c750f1a07ea702bbd212bd999fceece9b3d1508b17023b3e174583124b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime"
|
||||||
|
version = "0.3.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime_guess"
|
||||||
|
version = "2.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||||
|
dependencies = [
|
||||||
|
"mime",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minijinja"
|
||||||
|
version = "2.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "86886cf6dbf4e614b19c9a1eec9775f021869d7eadde0fc73921a81b90c9b4c9"
|
||||||
|
dependencies = [
|
||||||
|
"memo-map",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minijinja-embed"
|
||||||
|
version = "2.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2795289c499a1331f73166ff8b8e0e68fdc81ef0b385eecb0e4de96f1638b66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
|
|
@ -236,6 +665,12 @@ dependencies = [
|
||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
|
|
@ -269,12 +704,30 @@ version = "0.8.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7011d97b484a5ebdc4b1fdb3b12d5e4bbbea56e9d22b688f2e79e04b65a7d8a6"
|
checksum = "7011d97b484a5ebdc4b1fdb3b12d5e4bbbea56e9d22b688f2e79e04b65a7d8a6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkg-config"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.107"
|
version = "1.0.107"
|
||||||
|
|
@ -299,12 +752,30 @@ version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "range-requests"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0e30cea6780132baa8257e81ab3f6e11526a9271e9032963eab8d830972e68b6"
|
||||||
|
dependencies = [
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.229"
|
version = "1.0.229"
|
||||||
|
|
@ -335,6 +806,53 @@ dependencies = [
|
||||||
"syn 3.0.4",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.151"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_path_to_error"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
|
|
@ -344,6 +862,18 @@ dependencies = [
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
|
|
@ -366,6 +896,36 @@ dependencies = [
|
||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "static-serve"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49c7a4b3dbe44375f7e30895c04d3ca4fde020eb7336c0f6a71ed495c5302e1a"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"bytes",
|
||||||
|
"range-requests",
|
||||||
|
"static-serve-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "static-serve-macro"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5cb887d817cf39e80fd7fdd21c4f138c8277d7dc2623fee4d07e141f4ce32a0a"
|
||||||
|
dependencies = [
|
||||||
|
"display_full_error",
|
||||||
|
"flate2",
|
||||||
|
"glob",
|
||||||
|
"mime_guess",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"sha2",
|
||||||
|
"syn 3.0.4",
|
||||||
|
"thiserror",
|
||||||
|
"zstd",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.119"
|
version = "2.0.119"
|
||||||
|
|
@ -388,6 +948,12 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sync_wrapper"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.20"
|
version = "2.0.20"
|
||||||
|
|
@ -417,12 +983,43 @@ dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.55"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||||
|
dependencies = [
|
||||||
|
"deranged",
|
||||||
|
"num-conv",
|
||||||
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-macros"
|
||||||
|
version = "0.2.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||||
|
dependencies = [
|
||||||
|
"num-conv",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.53.1"
|
version = "1.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
|
@ -456,12 +1053,66 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-http"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"http-range-header",
|
||||||
|
"httpdate",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-layer"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-service"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
|
|
@ -513,6 +1164,18 @@ dependencies = [
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
|
|
@ -536,6 +1199,12 @@ version = "0.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
|
@ -601,3 +1270,43 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zlib-rs"
|
||||||
|
version = "0.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-safe",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-safe"
|
||||||
|
version = "7.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-sys"
|
||||||
|
version = "2.0.16+zstd.1.5.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
|
||||||
24
Cargo.toml
24
Cargo.toml
|
|
@ -5,13 +5,24 @@ edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
argh = "0.1.19"
|
argh = "0.1.19"
|
||||||
camino = "1.2.5"
|
async-trait = "0.1.92"
|
||||||
|
axum = { version = "0.8.9", optional = true, features = ["macros"] }
|
||||||
|
axum-extra = { version = "0.12.6", features = ["cookie"], optional = true }
|
||||||
|
base64 = "0.23.1"
|
||||||
|
camino = { version = "1.2.5", features = ["serde1"] }
|
||||||
dn_escape = { path = "vendor/dn_escape" }
|
dn_escape = { path = "vendor/dn_escape" }
|
||||||
futures-util = { version = "0.3.34", features = ["sink"] }
|
futures-util = { version = "0.3.34", features = ["sink"] }
|
||||||
|
http = { version = "1.5.0", optional = true }
|
||||||
ldap3_proto = "0.8.1"
|
ldap3_proto = "0.8.1"
|
||||||
|
minijinja = { version = "2.24.0", optional = true }
|
||||||
|
minijinja-embed = { version = "2.24.0", optional = true }
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
|
serde_json = "1.0.151"
|
||||||
|
static-serve = { version = "0.6.3", optional = true }
|
||||||
tokio = { version = "1.53.1", features = ["macros", "net", "rt", "time", "sync"] }
|
tokio = { version = "1.53.1", features = ["macros", "net", "rt", "time", "sync"] }
|
||||||
tokio-util = { version = "0.7.19", features = ["codec"] }
|
tokio-util = { version = "0.7.19", features = ["codec"] }
|
||||||
|
tower = "0.5.3"
|
||||||
|
tower-http = { version = "0.7.1", features = ["fs"], optional = true }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = "0.3.23"
|
tracing-subscriber = "0.3.23"
|
||||||
uuid = { version = "1.26.0", features = ["v4"] }
|
uuid = { version = "1.26.0", features = ["v4"] }
|
||||||
|
|
@ -22,3 +33,14 @@ complexity = "deny"
|
||||||
pedantic = "deny"
|
pedantic = "deny"
|
||||||
perf = "deny"
|
perf = "deny"
|
||||||
style = "deny"
|
style = "deny"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = [ "http", "noembed" ]
|
||||||
|
# Embed assets and templates in binary
|
||||||
|
embed = [ "dep:minijinja-embed", "dep:static-serve" ]
|
||||||
|
# Don't embed assets and templates in binary
|
||||||
|
noembed = [ "dep:tower-http" ]
|
||||||
|
http = ["dep:axum", "dep:axum-extra", "dep:http", "dep:minijinja"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
minijinja-embed = { version = "2.24.0", optional = true }
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ Compared to lldap, llldap:
|
||||||
- does not use GraphQL to build an API, and therefore does not require custom logic in the client (JS/WASM)
|
- does not use GraphQL to build an API, and therefore does not require custom logic in the client (JS/WASM)
|
||||||
- supports virtualhosts out-of-the-box
|
- supports virtualhosts out-of-the-box
|
||||||
- supports mailalias with multiple values in search queries
|
- supports mailalias with multiple values in search queries
|
||||||
|
- support listening on unix domain sockets (UDS) out-of-the-box
|
||||||
|
|
||||||
## Potential future features
|
## Potential future features
|
||||||
|
|
||||||
|
|
|
||||||
81
assets/css/main.css
Normal file
81
assets/css/main.css
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logo {
|
||||||
|
display: block;
|
||||||
|
max-height: 5rem;
|
||||||
|
max-width: 5rem;
|
||||||
|
height: auto;
|
||||||
|
width: auto;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: space-around;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 250px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-form {
|
||||||
|
max-width: 200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-line {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
height: 2.5rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-line > img {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-line > input {
|
||||||
|
border-color: rgba(31, 41, 55, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#submit-login {
|
||||||
|
width: 100%;
|
||||||
|
background-color: rgb(87, 13, 248);
|
||||||
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: none;
|
||||||
|
color: rgb(240, 240, 240);
|
||||||
|
font-weight: bolder;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
margin: 1rem auto;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner > p {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-success {
|
||||||
|
background-color: #28cb28;
|
||||||
|
color: #efefef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-error {
|
||||||
|
background-color: #d42929;
|
||||||
|
color: #efefef;
|
||||||
|
}
|
||||||
BIN
assets/img/logo.png
Normal file
BIN
assets/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
1
assets/img/password.svg
Normal file
1
assets/img/password.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg data-v-c3ad5561="" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" viewBox="0 0 24 24"><path fill="currentColor" d="M12 17a2 2 0 0 0 2-2a2 2 0 0 0-2-2a2 2 0 0 0-2 2a2 2 0 0 0 2 2m6-9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2h1V6a5 5 0 0 1 5-5a5 5 0 0 1 5 5v2h1m-6-5a3 3 0 0 0-3 3v2h6V6a3 3 0 0 0-3-3Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 403 B |
1
assets/img/user.svg
Normal file
1
assets/img/user.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg data-v-c3ad5561="" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" viewBox="0 0 24 24"><path fill="currentColor" d="M12 19.2c-2.5 0-4.71-1.28-6-3.2c.03-2 4-3.1 6-3.1s5.97 1.1 6 3.1a7.232 7.232 0 0 1-6 3.2M12 5a3 3 0 0 1 3 3a3 3 0 0 1-3 3a3 3 0 0 1-3-3a3 3 0 0 1 3-3m0-3A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10c0-5.53-4.5-10-10-10Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 424 B |
4
build.rs
Normal file
4
build.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
fn main() {
|
||||||
|
#[cfg(feature = "embed")]
|
||||||
|
minijinja_embed::embed_templates!("templates");
|
||||||
|
}
|
||||||
15
src/cli.rs
15
src/cli.rs
|
|
@ -1,9 +1,18 @@
|
||||||
use argh::FromArgs;
|
use argh::FromArgs;
|
||||||
|
use camino::Utf8PathBuf;
|
||||||
|
|
||||||
/// Run the llldap server
|
/// Run the llldap server
|
||||||
#[derive(FromArgs)]
|
#[derive(FromArgs)]
|
||||||
pub struct CliArgs {
|
pub struct CliArgs {
|
||||||
/// address or socket to listen on
|
/// path to the JSON file for the database
|
||||||
#[argh(positional, default = "String::from(\"127.0.0.1:3389\")")]
|
#[argh(option)]
|
||||||
pub listen: String,
|
pub db: Option<Utf8PathBuf>,
|
||||||
|
|
||||||
|
/// address or socket to listen on for LDAP connections
|
||||||
|
#[argh(option, default = "String::from(\"127.0.0.1:3389\")")]
|
||||||
|
pub listen_ldap: String,
|
||||||
|
|
||||||
|
/// address or socket to listen on for HTTP connections
|
||||||
|
#[argh(option, default = "String::from(\"127.0.0.1:3390\")")]
|
||||||
|
pub listen_http: String,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,22 @@ use tokio::sync::RwLock;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::db::error::BoxedError;
|
use crate::db::error::BoxedError;
|
||||||
use crate::db::{DatabaseInterface, UserRef};
|
use crate::db::{DatabaseInterface, Domain, User, UserRef};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Database<D: DatabaseInterface> {
|
pub struct Database {
|
||||||
pub inner: Arc<RwLock<D>>,
|
pub inner: Arc<RwLock<Box<dyn DatabaseInterface>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<D: DatabaseInterface> Database<D> {
|
impl Database {
|
||||||
pub fn new(db: D) -> Self {
|
pub fn new(db: impl DatabaseInterface) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(RwLock::new(db)),
|
inner: Arc::new(RwLock::new(Box::new(db))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<D: DatabaseInterface> Database<D> {
|
impl Database {
|
||||||
/// Return false if the user doesn't exist, or the password is wrong.
|
/// Return false if the user doesn't exist, or the password is wrong.
|
||||||
///
|
///
|
||||||
/// TODO: should we return something else when the account doesn't exist?
|
/// TODO: should we return something else when the account doesn't exist?
|
||||||
|
|
@ -36,4 +36,13 @@ impl<D: DatabaseInterface> Database<D> {
|
||||||
tracing::debug!("Comparing {} and {}", user.password, password);
|
tracing::debug!("Comparing {} and {}", user.password, password);
|
||||||
Ok(user.password == password)
|
Ok(user.password == password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn domains_user_can_see(&self, user: &User) -> Result<Vec<Domain>, BoxedError> {
|
||||||
|
Ok(self
|
||||||
|
.list_all_domains()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|d| user.can_see_domain(&d.name))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
src/db/domain.rs
Normal file
6
src/db/domain.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
pub struct Domain {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
@ -5,12 +5,42 @@ use crate::db::UserRef;
|
||||||
pub type BoxedError = Box<dyn std::error::Error + Send + Sync + 'static>;
|
pub type BoxedError = Box<dyn std::error::Error + Send + Sync + 'static>;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct UserAlreadyExists(pub UserRef);
|
pub enum UserCreationError {
|
||||||
|
DomainNotFound(String),
|
||||||
|
UserAlreadyExists(UserRef),
|
||||||
|
Permissions,
|
||||||
|
}
|
||||||
|
|
||||||
impl fmt::Display for UserAlreadyExists {
|
impl fmt::Display for UserCreationError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "User already exists: {}", self.0)
|
match self {
|
||||||
|
Self::DomainNotFound(domain) => write!(f, "No domain {domain} to create user in"),
|
||||||
|
Self::UserAlreadyExists(user) => write!(f, "User already exists: {user}"),
|
||||||
|
Self::Permissions => write!(f, "You do not have permissions to create this user"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for UserAlreadyExists {}
|
impl std::error::Error for UserCreationError {}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum DomainCreationError {
|
||||||
|
DomainAlreadyExists(String),
|
||||||
|
InvalidDomain(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for DomainCreationError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::DomainAlreadyExists(domain) => {
|
||||||
|
write!(f, "Cannot create domain {domain} because it already exists")
|
||||||
|
}
|
||||||
|
Self::InvalidDomain(domain) => write!(
|
||||||
|
f,
|
||||||
|
"Cannot create domain `{domain}` because it's not considered a valid domain"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for DomainCreationError {}
|
||||||
|
|
|
||||||
169
src/db/filesystem.rs
Normal file
169
src/db/filesystem.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
use camino::{Utf8Path, Utf8PathBuf};
|
||||||
|
use serde_json::Error as JsonError;
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::io::Error as IOError;
|
||||||
|
|
||||||
|
use crate::db::error::{BoxedError, DomainCreationError, UserCreationError};
|
||||||
|
use crate::db::{Database, DatabaseInterface, Domain, MemoryDatabase, User, UserRef};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum FilesystemDatabaseError {
|
||||||
|
ReadFileIO(Utf8PathBuf, IOError),
|
||||||
|
ReadFileJson(Utf8PathBuf, JsonError),
|
||||||
|
WriteFileIO(Utf8PathBuf, IOError),
|
||||||
|
WriteFileJson(Utf8PathBuf, JsonError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for FilesystemDatabaseError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}",
|
||||||
|
match self {
|
||||||
|
Self::ReadFileIO(path, e) => format!("Failed to read database file {path}: {e}"),
|
||||||
|
Self::ReadFileJson(path, e) =>
|
||||||
|
format!("Failed to parse database JSON file {path}: {e}"),
|
||||||
|
Self::WriteFileIO(path, e) => format!("Failed to write database file {path}: {e}"),
|
||||||
|
Self::WriteFileJson(path, e) =>
|
||||||
|
format!("Failed to convert database to JSON file {path}: {e}"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for FilesystemDatabaseError {}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct FilesystemDatabase {
|
||||||
|
// TODO: Once we have common validation steps in place across DB backends, we can reduce cloning.
|
||||||
|
// For now, we clone the DB on every write operation, and update it when saving to disk
|
||||||
|
// succeeds.
|
||||||
|
pub inner: MemoryDatabase,
|
||||||
|
pub path: Utf8PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FilesystemDatabase {
|
||||||
|
pub async fn from_path(path: impl AsRef<Utf8Path>) -> Result<Database, BoxedError> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
|
||||||
|
if !tokio::fs::try_exists(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Box::new(FilesystemDatabaseError::ReadFileIO(path.to_path_buf(), e)))?
|
||||||
|
{
|
||||||
|
// The database doesn't exist yet, we create an empty one and try to write it
|
||||||
|
// to make sure the permissions are correct and the destination folder exists.
|
||||||
|
let mut db = Self {
|
||||||
|
inner: MemoryDatabase::default(),
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("Initializing empty database in file {path}. Checking permissions...");
|
||||||
|
db.save_self().await?;
|
||||||
|
tracing::info!("Database successfully created");
|
||||||
|
|
||||||
|
return Ok(Database::new(db));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Loading database from file {path}");
|
||||||
|
let s = tokio::fs::read(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Box::new(FilesystemDatabaseError::ReadFileIO(path.to_path_buf(), e)))?;
|
||||||
|
let inner: MemoryDatabase = serde_json::from_slice(&s)
|
||||||
|
.map_err(|e| Box::new(FilesystemDatabaseError::ReadFileJson(path.to_path_buf(), e)))?;
|
||||||
|
Ok(Database::new(Self {
|
||||||
|
inner,
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to save a new state of the DB, effectively switching to the new state
|
||||||
|
/// only if the save is successful.
|
||||||
|
pub async fn save(&mut self, new_db: MemoryDatabase) -> Result<(), BoxedError> {
|
||||||
|
self.save_inner(&new_db).await?;
|
||||||
|
self.inner = new_db;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves an in-memory DB to disk. Cannot be used directly to observe exclusive access.
|
||||||
|
async fn save_inner(&self, inner: &MemoryDatabase) -> Result<(), BoxedError> {
|
||||||
|
let s = serde_json::to_string(inner)
|
||||||
|
.map_err(|e| Box::new(FilesystemDatabaseError::WriteFileJson(self.path.clone(), e)))?;
|
||||||
|
Ok(tokio::fs::write(&self.path, &s)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Box::new(FilesystemDatabaseError::WriteFileIO(self.path.clone(), e)))?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the database without switching to a new state.
|
||||||
|
///
|
||||||
|
/// Used when initializing the DB, to check for permissions.
|
||||||
|
pub async fn save_self(&mut self) -> Result<(), BoxedError> {
|
||||||
|
self.save_inner(&self.inner).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl DatabaseInterface for FilesystemDatabase {
|
||||||
|
async fn create_domain(
|
||||||
|
&mut self,
|
||||||
|
domain: &str,
|
||||||
|
) -> Result<Result<(), DomainCreationError>, BoxedError> {
|
||||||
|
let mut new_db = self.inner.clone();
|
||||||
|
|
||||||
|
if let Err(e) = new_db.create_domain(domain).await? {
|
||||||
|
return Ok(Err(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.save(new_db).await?;
|
||||||
|
Ok(Ok(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_domain(&self, domain: &str) -> Result<Option<Domain>, BoxedError> {
|
||||||
|
self.inner.get_domain(domain).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user(&self, req_user: &UserRef) -> Result<Option<User>, BoxedError> {
|
||||||
|
self.inner.get_user(req_user).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_user(
|
||||||
|
&mut self,
|
||||||
|
user: User,
|
||||||
|
) -> Result<Result<(), UserCreationError>, BoxedError> {
|
||||||
|
let mut new_db = self.inner.clone();
|
||||||
|
|
||||||
|
if let Err(e) = new_db.create_user(user).await? {
|
||||||
|
return Ok(Err(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.save(new_db).await?;
|
||||||
|
Ok(Ok(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn try_create_user(
|
||||||
|
&mut self,
|
||||||
|
new_user: User,
|
||||||
|
current_user: &User,
|
||||||
|
) -> Result<Result<(), UserCreationError>, BoxedError> {
|
||||||
|
let mut new_db = self.inner.clone();
|
||||||
|
|
||||||
|
if let Err(e) = new_db.try_create_user(new_user, current_user).await? {
|
||||||
|
return Ok(Err(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.save(new_db).await?;
|
||||||
|
Ok(Ok(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_all_domains(&self) -> Result<Vec<Domain>, BoxedError> {
|
||||||
|
self.inner.list_all_domains().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_all_users(&self) -> Result<Vec<User>, BoxedError> {
|
||||||
|
self.inner.list_all_users().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_domain_users(&self, domain: Option<String>) -> Result<Vec<User>, BoxedError> {
|
||||||
|
self.inner.list_domain_users(domain).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
#[derive(Clone, Debug)]
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
pub struct Group {
|
pub struct Group {
|
||||||
name: String,
|
name: String,
|
||||||
domain: String,
|
domain: String,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,19 @@
|
||||||
use crate::db::error::{BoxedError, UserAlreadyExists};
|
use crate::db::error::{BoxedError, DomainCreationError, UserCreationError};
|
||||||
use crate::db::{Database, User, UserRef};
|
use crate::db::{Database, Domain, User, UserRef};
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl DatabaseInterface for Database {
|
||||||
|
async fn create_domain(
|
||||||
|
&mut self,
|
||||||
|
domain: &str,
|
||||||
|
) -> Result<Result<(), DomainCreationError>, BoxedError> {
|
||||||
|
self.inner.write().await.create_domain(domain).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_domain(&self, domain: &str) -> Result<Option<Domain>, BoxedError> {
|
||||||
|
self.inner.read().await.get_domain(domain).await
|
||||||
|
}
|
||||||
|
|
||||||
impl<D: DatabaseInterface> DatabaseInterface for Database<D> {
|
|
||||||
async fn get_user(&self, user: &UserRef) -> Result<Option<User>, BoxedError> {
|
async fn get_user(&self, user: &UserRef) -> Result<Option<User>, BoxedError> {
|
||||||
self.inner.read().await.get_user(user).await
|
self.inner.read().await.get_user(user).await
|
||||||
}
|
}
|
||||||
|
|
@ -9,15 +21,59 @@ impl<D: DatabaseInterface> DatabaseInterface for Database<D> {
|
||||||
async fn create_user(
|
async fn create_user(
|
||||||
&mut self,
|
&mut self,
|
||||||
user: User,
|
user: User,
|
||||||
) -> Result<Result<(), UserAlreadyExists>, BoxedError> {
|
) -> Result<Result<(), UserCreationError>, BoxedError> {
|
||||||
self.inner.write().await.create_user(user).await
|
self.inner.write().await.create_user(user).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn try_create_user(
|
||||||
|
&mut self,
|
||||||
|
new_user: User,
|
||||||
|
current_user: &User,
|
||||||
|
) -> Result<Result<(), UserCreationError>, BoxedError> {
|
||||||
|
self.inner
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.try_create_user(new_user, current_user)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_all_domains(&self) -> Result<Vec<Domain>, BoxedError> {
|
||||||
|
self.inner.read().await.list_all_domains().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_all_users(&self) -> Result<Vec<User>, BoxedError> {
|
||||||
|
self.inner.read().await.list_all_users().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_domain_users(&self, domain: Option<String>) -> Result<Vec<User>, BoxedError> {
|
||||||
|
self.inner.read().await.list_domain_users(domain).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait DatabaseInterface {
|
#[async_trait::async_trait]
|
||||||
|
pub trait DatabaseInterface: std::fmt::Debug + Send + Sync + 'static {
|
||||||
|
async fn create_domain(
|
||||||
|
&mut self,
|
||||||
|
domain: &str,
|
||||||
|
) -> Result<Result<(), DomainCreationError>, BoxedError>;
|
||||||
|
async fn get_domain(&self, domain: &str) -> Result<Option<Domain>, BoxedError>;
|
||||||
|
|
||||||
async fn get_user(&self, user: &UserRef) -> Result<Option<User>, BoxedError>;
|
async fn get_user(&self, user: &UserRef) -> Result<Option<User>, BoxedError>;
|
||||||
async fn create_user(
|
async fn create_user(
|
||||||
&mut self,
|
&mut self,
|
||||||
user: User,
|
user: User,
|
||||||
) -> Result<Result<(), UserAlreadyExists>, BoxedError>;
|
) -> Result<Result<(), UserCreationError>, BoxedError>;
|
||||||
|
async fn try_create_user(
|
||||||
|
&mut self,
|
||||||
|
new_user: User,
|
||||||
|
current_user: &User,
|
||||||
|
) -> Result<Result<(), UserCreationError>, BoxedError>;
|
||||||
|
|
||||||
|
async fn list_all_domains(&self) -> Result<Vec<Domain>, BoxedError>;
|
||||||
|
async fn list_all_users(&self) -> Result<Vec<User>, BoxedError>;
|
||||||
|
|
||||||
|
/// List users on a specific domain.
|
||||||
|
///
|
||||||
|
/// A `None` domain requested lists global service users.
|
||||||
|
async fn list_domain_users(&self, domain: Option<String>) -> Result<Vec<User>, BoxedError>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
109
src/db/memory.rs
109
src/db/memory.rs
|
|
@ -1,44 +1,119 @@
|
||||||
use std::future::{Future, ready};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::db::error::{BoxedError, UserAlreadyExists};
|
use crate::db::error::{BoxedError, DomainCreationError, UserCreationError};
|
||||||
use crate::db::{Database, DatabaseInterface, Group, User, UserRef};
|
use crate::db::{Database, DatabaseInterface, Domain, Group, Operation, User, UserRef};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
pub struct MemoryDatabase {
|
pub struct MemoryDatabase {
|
||||||
pub users: Vec<User>,
|
// We store data in tables like in SQL
|
||||||
|
pub domains: Vec<Domain>,
|
||||||
pub groups: Vec<Group>,
|
pub groups: Vec<Group>,
|
||||||
|
pub users: Vec<User>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryDatabase {
|
impl MemoryDatabase {
|
||||||
pub fn new() -> Database<Self> {
|
#[allow(clippy::new_ret_no_self)]
|
||||||
|
pub fn new() -> Database {
|
||||||
|
tracing::warn!("Using in-memory database. Data will not be saved across restarts!");
|
||||||
Database::new(Self::default())
|
Database::new(Self::default())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
impl DatabaseInterface for MemoryDatabase {
|
impl DatabaseInterface for MemoryDatabase {
|
||||||
fn get_user(
|
async fn create_domain(
|
||||||
&self,
|
&mut self,
|
||||||
req_user: &UserRef,
|
domain: &str,
|
||||||
) -> impl Future<Output = Result<Option<User>, BoxedError>> {
|
) -> Result<Result<(), DomainCreationError>, BoxedError> {
|
||||||
for user in &self.users {
|
if domain.is_empty() {
|
||||||
if user.username == req_user.username && user.domain == req_user.domain {
|
return Ok(Err(DomainCreationError::InvalidDomain(domain.to_string())));
|
||||||
return ready(Ok(Some(user.clone())));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ready(Ok(None))
|
if self.domains.iter().find(|d| d.name == domain).is_some() {
|
||||||
|
return Ok(Err(DomainCreationError::DomainAlreadyExists(
|
||||||
|
domain.to_string(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.domains.push(Domain {
|
||||||
|
name: domain.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Ok(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_domain(&self, domain: &str) -> Result<Option<Domain>, BoxedError> {
|
||||||
|
let Some(domain) = self.domains.iter().find(|d| d.name == domain) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(domain.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user(&self, req_user: &UserRef) -> Result<Option<User>, BoxedError> {
|
||||||
|
Ok(self
|
||||||
|
.users
|
||||||
|
.iter()
|
||||||
|
.find(|u| u.username == req_user.username && u.domain == req_user.domain)
|
||||||
|
.cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_user(
|
async fn create_user(
|
||||||
&mut self,
|
&mut self,
|
||||||
user: User,
|
user: User,
|
||||||
) -> Result<Result<(), UserAlreadyExists>, BoxedError> {
|
) -> Result<Result<(), UserCreationError>, BoxedError> {
|
||||||
let user_ref = user.user_ref();
|
let user_ref = user.user_ref();
|
||||||
|
|
||||||
if self.get_user(&user_ref).await?.is_some() {
|
if self.get_user(&user_ref).await?.is_some() {
|
||||||
return Ok(Err(UserAlreadyExists(user_ref)));
|
return Ok(Err(UserCreationError::UserAlreadyExists(user_ref)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a domain is requested (i.e. not a global user), make sure the domain exists
|
||||||
|
if let Some(req_domain) = &user.domain
|
||||||
|
&& self.get_domain(req_domain).await?.is_none()
|
||||||
|
{
|
||||||
|
return Ok(Err(UserCreationError::DomainNotFound(req_domain.clone())));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.users.push(user);
|
self.users.push(user);
|
||||||
Ok(Ok(()))
|
Ok(Ok(()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn try_create_user(
|
||||||
|
&mut self,
|
||||||
|
new_user: User,
|
||||||
|
current_user: &User,
|
||||||
|
) -> Result<Result<(), UserCreationError>, BoxedError> {
|
||||||
|
// First check permissions, then apply the operation
|
||||||
|
//
|
||||||
|
// TODO: for now we don't allow creating service users manually
|
||||||
|
// so we assume there's a domain provided
|
||||||
|
let Some(new_user_domain) = &new_user.domain else {
|
||||||
|
return Ok(Err(UserCreationError::Permissions));
|
||||||
|
};
|
||||||
|
|
||||||
|
let op = Operation::CreateUser(new_user_domain.clone());
|
||||||
|
if !current_user.can_perform(&op) {
|
||||||
|
return Ok(Err(UserCreationError::Permissions));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.create_user(new_user).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_all_domains(&self) -> Result<Vec<Domain>, BoxedError> {
|
||||||
|
Ok(self.domains.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_all_users(&self) -> Result<Vec<User>, BoxedError> {
|
||||||
|
Ok(self.users.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_domain_users(&self, domain: Option<String>) -> Result<Vec<User>, BoxedError> {
|
||||||
|
Ok(self
|
||||||
|
.users
|
||||||
|
.iter()
|
||||||
|
.filter(|u| u.domain == domain)
|
||||||
|
.cloned()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
mod common;
|
mod common;
|
||||||
pub use common::Database;
|
pub use common::Database;
|
||||||
|
mod domain;
|
||||||
|
pub use domain::Domain;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
mod filesystem;
|
||||||
|
pub use filesystem::FilesystemDatabase;
|
||||||
mod group;
|
mod group;
|
||||||
pub use group::Group;
|
pub use group::Group;
|
||||||
mod interface;
|
mod interface;
|
||||||
pub use interface::DatabaseInterface;
|
pub use interface::DatabaseInterface;
|
||||||
mod memory;
|
mod memory;
|
||||||
pub use memory::MemoryDatabase;
|
pub use memory::MemoryDatabase;
|
||||||
|
mod role;
|
||||||
|
pub use role::{Operation, Role};
|
||||||
mod user;
|
mod user;
|
||||||
pub use user::{User, UserRef};
|
pub use user::{User, UserRef};
|
||||||
|
|
|
||||||
55
src/db/role.rs
Normal file
55
src/db/role.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum Operation {
|
||||||
|
CreateDomain,
|
||||||
|
CreateUser(String),
|
||||||
|
ListUsers(Option<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||||
|
pub enum Role {
|
||||||
|
/// Can do anything
|
||||||
|
Admin,
|
||||||
|
/// Can only read data across all vhosts
|
||||||
|
ReadonlyAdmin,
|
||||||
|
/// Can do anything on a domain, except removing
|
||||||
|
/// oneself as a domain admin.
|
||||||
|
///
|
||||||
|
/// Can not give away roles other than DomainModerator/User
|
||||||
|
DomainAdmin(String),
|
||||||
|
/// Can create users and reset passwords on a domain
|
||||||
|
DomainModerator(String),
|
||||||
|
/// Can only edit own profile
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Role {
|
||||||
|
pub fn can_perform(&self, operation: &Operation) -> bool {
|
||||||
|
match operation {
|
||||||
|
Operation::CreateDomain => self == &Self::Admin,
|
||||||
|
Operation::CreateUser(op_domain) => match self {
|
||||||
|
Self::Admin => true,
|
||||||
|
Self::DomainAdmin(usr_domain) | Self::DomainModerator(usr_domain) => {
|
||||||
|
op_domain == usr_domain
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
},
|
||||||
|
Operation::ListUsers(op_domain) => match self {
|
||||||
|
Self::Admin => true,
|
||||||
|
Self::DomainAdmin(usr_domain) | Self::DomainModerator(usr_domain) => {
|
||||||
|
op_domain.as_ref() == Some(usr_domain)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_see_domain(&self, domain: &str) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Admin | Self::ReadonlyAdmin => true,
|
||||||
|
Self::DomainAdmin(d) | Self::DomainModerator(d) => domain == d,
|
||||||
|
Self::User => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,71 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
|
use crate::db::{Operation, Role};
|
||||||
|
|
||||||
|
/// A requested user/domain combo for login, lowercased.
|
||||||
|
///
|
||||||
|
/// Domain may be empty, but a value with more than one
|
||||||
|
/// `@` is considered invalid.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct UserRef {
|
pub struct UserRef {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub domain: String,
|
pub domain: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct InvalidUserRef(pub String);
|
||||||
|
|
||||||
|
impl std::error::Error for InvalidUserRef {}
|
||||||
|
|
||||||
|
impl fmt::Display for InvalidUserRef {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "Invalid username: {}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRef {
|
||||||
|
pub fn from_user_maybe_domain(value: &str) -> Result<Self, InvalidUserRef> {
|
||||||
|
let value = value.to_lowercase();
|
||||||
|
|
||||||
|
let mut parts = value.split('@');
|
||||||
|
let username = parts.next().unwrap();
|
||||||
|
let domain = parts.next();
|
||||||
|
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return Err(InvalidUserRef(value.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
username: username.to_string(),
|
||||||
|
domain: domain.map(Into::into),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for UserRef {
|
impl fmt::Display for UserRef {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{}@{}", self.username, self.domain)
|
if let Some(domain) = &self.domain {
|
||||||
|
write!(f, "{}@{}", self.username, domain)
|
||||||
|
} else {
|
||||||
|
write!(f, "{}", self.username)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
|
/// Username, without the domain part. Once set, cannot be edited.
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub domain: String,
|
/// Domain of the user. Once set, cannot be edited.
|
||||||
|
///
|
||||||
|
/// Service accounts may exist with an empty domain.
|
||||||
|
pub domain: Option<String>,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
/// Mail for the user. Computed from username/domain, cannot be edited.
|
||||||
pub mail: String,
|
pub mail: String,
|
||||||
|
pub role: Role,
|
||||||
// recovery_mail: String,
|
// recovery_mail: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,10 +76,28 @@ impl User {
|
||||||
domain: self.domain.clone(),
|
domain: self.domain.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn can_perform(&self, operation: &Operation) -> bool {
|
||||||
|
self.role.can_perform(operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_create_domain(&self) -> bool {
|
||||||
|
self.role.can_perform(&Operation::CreateDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_see_domain(&self, domain: &str) -> bool {
|
||||||
|
let allowed = self.role.can_see_domain(domain);
|
||||||
|
tracing::debug!("{} can see domain {}: {}", self.mail, domain, allowed);
|
||||||
|
allowed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for User {
|
impl fmt::Display for User {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{}@{}", self.username, self.domain)
|
if let Some(domain) = &self.domain {
|
||||||
|
write!(f, "{}@{}", self.username, domain)
|
||||||
|
} else {
|
||||||
|
write!(f, "{}", self.username)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
src/http/domain.rs
Normal file
71
src/http/domain.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
use axum::extract::{Form, Path, State};
|
||||||
|
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||||
|
use http::StatusCode;
|
||||||
|
use minijinja::context;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::db::{DatabaseInterface, Operation};
|
||||||
|
use crate::http::{HttpSession, HttpState};
|
||||||
|
|
||||||
|
pub async fn get_domain(
|
||||||
|
State(state): State<HttpState>,
|
||||||
|
session: HttpSession,
|
||||||
|
Path(domain): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
let domain = match state.db.get_domain(&domain).await {
|
||||||
|
Ok(Some(domain)) => domain,
|
||||||
|
Ok(None) => return format!("Domain not found: {domain}").into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
return format!("Database error: {e}").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let domain_users = match state.db.list_domain_users(Some(domain.name.clone())).await {
|
||||||
|
Ok(users) => users,
|
||||||
|
Err(e) => {
|
||||||
|
return format!("Database error: {e}").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Redundant because someone who can see the domain admin page for the moment
|
||||||
|
// always can create accounts.
|
||||||
|
let op = Operation::CreateUser(domain.name.clone());
|
||||||
|
let can_create_user = session.user.can_perform(&op);
|
||||||
|
|
||||||
|
let ctx = context! {
|
||||||
|
can_create_user,
|
||||||
|
domain,
|
||||||
|
user => session.user,
|
||||||
|
users => domain_users,
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = state
|
||||||
|
.templates
|
||||||
|
.get_template("domain.html")
|
||||||
|
.unwrap()
|
||||||
|
.render(ctx)
|
||||||
|
.unwrap();
|
||||||
|
(StatusCode::OK, Html(page)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct DomainCreationForm {
|
||||||
|
domainname: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_domain(
|
||||||
|
State(mut state): State<HttpState>,
|
||||||
|
session: HttpSession,
|
||||||
|
Form(form): Form<DomainCreationForm>,
|
||||||
|
) -> Response {
|
||||||
|
let op = Operation::CreateDomain;
|
||||||
|
if !session.user.can_perform(&op) {
|
||||||
|
return "Not authorized to create a new domain".into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match state.db.create_domain(&form.domainname).await {
|
||||||
|
Ok(Ok(())) => Redirect::to(&format!("/domain/{}", form.domainname)).into_response(),
|
||||||
|
Ok(Err(e)) => format!("Failed to create domain {}: {}", form.domainname, e).into_response(),
|
||||||
|
Err(e) => format!("Database error: {e}").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/http/home.rs
Normal file
58
src/http/home.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
|
use http::StatusCode;
|
||||||
|
use minijinja::context;
|
||||||
|
|
||||||
|
use crate::db::{DatabaseInterface, Operation};
|
||||||
|
use crate::http::{HttpSession, HttpState};
|
||||||
|
|
||||||
|
pub async fn home(
|
||||||
|
State(state): State<HttpState>,
|
||||||
|
// Only logged in users are allowed here
|
||||||
|
session: HttpSession,
|
||||||
|
) -> Response {
|
||||||
|
// When the user has no domain (service admin) list all domains
|
||||||
|
let op = Operation::ListUsers(session.user.domain.clone());
|
||||||
|
let other_users = if session.user.can_perform(&op) {
|
||||||
|
match state
|
||||||
|
.db
|
||||||
|
.list_domain_users(session.user.domain.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(other_users) => other_users,
|
||||||
|
Err(e) => {
|
||||||
|
return format!("Database error: {e}").into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Found {} users on domain {:?}",
|
||||||
|
other_users.len(),
|
||||||
|
session.user.domain
|
||||||
|
);
|
||||||
|
|
||||||
|
let domains = match state.db.domains_user_can_see(&session.user).await {
|
||||||
|
Ok(domains) => domains,
|
||||||
|
Err(e) => {
|
||||||
|
return format!("Database error: {e}").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ctx = context! {
|
||||||
|
domains,
|
||||||
|
user => session.user,
|
||||||
|
can_create_domain => session.user.can_create_domain(),
|
||||||
|
other_users,
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = state
|
||||||
|
.templates
|
||||||
|
.get_template("home.html")
|
||||||
|
.unwrap()
|
||||||
|
.render(ctx)
|
||||||
|
.unwrap();
|
||||||
|
(StatusCode::OK, Html(page)).into_response()
|
||||||
|
}
|
||||||
95
src/http/login.rs
Normal file
95
src/http/login.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
use axum::extract::{Form, State};
|
||||||
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use http::StatusCode;
|
||||||
|
use minijinja::context;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::db::{DatabaseInterface, UserRef};
|
||||||
|
use crate::http::{HttpState, InternalRedirect, OptionalHttpSession};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginForm {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub enum LoginError {
|
||||||
|
InvalidCredentials,
|
||||||
|
SessionInvalidated,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login_page(
|
||||||
|
State(state): State<HttpState>,
|
||||||
|
login_error: Option<LoginError>,
|
||||||
|
redirect: InternalRedirect,
|
||||||
|
) -> Response {
|
||||||
|
let page = state
|
||||||
|
.templates
|
||||||
|
.get_template("login.html")
|
||||||
|
.unwrap()
|
||||||
|
.render(context! {login_error => login_error, redirect => format!("/login/?redirect={}", redirect.to_base64url())})
|
||||||
|
.unwrap();
|
||||||
|
(StatusCode::OK, Html(page)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_login(
|
||||||
|
State(state): State<HttpState>,
|
||||||
|
maybe_session: Option<OptionalHttpSession>,
|
||||||
|
redirect: InternalRedirect,
|
||||||
|
) -> Response {
|
||||||
|
if maybe_session.is_some() {
|
||||||
|
return redirect.to_redirect().into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
login_page(State(state), None, redirect).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_login(
|
||||||
|
State(state): State<HttpState>,
|
||||||
|
session: Option<OptionalHttpSession>,
|
||||||
|
cookies: CookieJar,
|
||||||
|
redirect: InternalRedirect,
|
||||||
|
Form(form): Form<LoginForm>,
|
||||||
|
) -> Response {
|
||||||
|
if session.is_some() {
|
||||||
|
// Already logged in
|
||||||
|
return (cookies, redirect.to_redirect()).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let req_user = match UserRef::from_user_maybe_domain(&form.username) {
|
||||||
|
Ok(req_user) => req_user,
|
||||||
|
Err(e) => {
|
||||||
|
return e.to_string().into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let success = match state.db.check_password(&req_user, &form.password).await {
|
||||||
|
Ok(success) => success,
|
||||||
|
Err(e) => {
|
||||||
|
return format!("Database error: {e}").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if success {
|
||||||
|
let maybe_user = match state.db.get_user(&req_user).await {
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(e) => {
|
||||||
|
return format!("Database error: {e}").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(user) = maybe_user else {
|
||||||
|
return "Woops, user has been deleted while you were logging in. What are the chances?!".to_string()
|
||||||
|
.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let cookies = state.sessions.add_session(user, cookies);
|
||||||
|
(cookies, redirect.to_redirect()).into_response()
|
||||||
|
} else {
|
||||||
|
login_page(State(state), Some(LoginError::InvalidCredentials), redirect)
|
||||||
|
.await
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/http/logout.rs
Normal file
28
src/http/logout.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::response::{IntoResponse, Redirect, Response};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
|
||||||
|
use crate::http::login::{LoginError, login_page};
|
||||||
|
use crate::http::{HttpState, InternalRedirect, OptionalHttpSession};
|
||||||
|
|
||||||
|
pub async fn logout(
|
||||||
|
State(state): State<HttpState>,
|
||||||
|
session: Option<OptionalHttpSession>,
|
||||||
|
cookies: CookieJar,
|
||||||
|
) -> Response {
|
||||||
|
let Some(session) = session else {
|
||||||
|
return (cookies, Redirect::to("/")).into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let cookies = state.sessions.remove_session(&session, cookies);
|
||||||
|
(
|
||||||
|
cookies,
|
||||||
|
login_page(
|
||||||
|
State(state),
|
||||||
|
Some(LoginError::SessionInvalidated),
|
||||||
|
InternalRedirect::new(),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
112
src/http/mod.rs
Normal file
112
src/http/mod.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
use axum::Router;
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::serve::Listener as AxumListener;
|
||||||
|
use minijinja::Environment;
|
||||||
|
#[cfg(not(feature = "embed"))]
|
||||||
|
use minijinja::path_loader;
|
||||||
|
#[cfg(feature = "embed")]
|
||||||
|
use static_serve::embed_assets;
|
||||||
|
#[cfg(not(feature = "embed"))]
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
|
use crate::db::Database;
|
||||||
|
use crate::listener::{Listener, ListenerKind};
|
||||||
|
use crate::stream::{AbstractSocketAddr, AbstractStreamKind};
|
||||||
|
|
||||||
|
mod domain;
|
||||||
|
mod home;
|
||||||
|
mod login;
|
||||||
|
mod logout;
|
||||||
|
mod redirect;
|
||||||
|
use redirect::InternalRedirect;
|
||||||
|
mod session;
|
||||||
|
use session::{HttpSession, HttpSessionManager, OptionalHttpSession};
|
||||||
|
mod user;
|
||||||
|
|
||||||
|
impl AxumListener for Listener {
|
||||||
|
type Io = AbstractStreamKind;
|
||||||
|
type Addr = AbstractSocketAddr;
|
||||||
|
|
||||||
|
async fn accept(&mut self) -> (Self::Io, Self::Addr) {
|
||||||
|
loop {
|
||||||
|
let res = match &self.kind {
|
||||||
|
ListenerKind::Tcp(l) => l
|
||||||
|
.accept()
|
||||||
|
.await
|
||||||
|
.map(|(stream, remote_addr)| (stream.into(), remote_addr.into())),
|
||||||
|
ListenerKind::Uds(l) => l
|
||||||
|
.accept()
|
||||||
|
.await
|
||||||
|
.map(|(stream, remote_addr)| (stream.into(), remote_addr.into())),
|
||||||
|
};
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok((stream, remote_addr)) => return (stream, remote_addr),
|
||||||
|
Err(e) => {
|
||||||
|
// Here the error could be fatal, or could simply be that a client aborted the connected,
|
||||||
|
// in which case we don't want to crash the server, simply skip this client connection.
|
||||||
|
// https://doc.rust-lang.org/stable/std/net/struct.TcpListener.html#errors
|
||||||
|
match e.kind() {
|
||||||
|
std::io::ErrorKind::ConnectionAborted => {}
|
||||||
|
_ => panic!("Unrecoverable HTTP client connection error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_addr(&self) -> std::io::Result<Self::Addr> {
|
||||||
|
match &self.kind {
|
||||||
|
ListenerKind::Tcp(l) => l.local_addr().map(Into::into),
|
||||||
|
ListenerKind::Uds(l) => l.local_addr().map(Into::into),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HttpState {
|
||||||
|
pub db: Database,
|
||||||
|
pub sessions: HttpSessionManager,
|
||||||
|
pub templates: Environment<'static>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpState {
|
||||||
|
pub fn new(db: Database) -> Self {
|
||||||
|
let mut templates = Environment::new();
|
||||||
|
#[cfg(feature = "embed")]
|
||||||
|
minijinja_embed::load_templates!(&mut templates);
|
||||||
|
#[cfg(not(feature = "embed"))]
|
||||||
|
templates.set_loader(path_loader("templates"));
|
||||||
|
Self {
|
||||||
|
db,
|
||||||
|
sessions: HttpSessionManager::new(),
|
||||||
|
templates,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn http_listen(listener: Listener, db: Database) {
|
||||||
|
#[cfg(all(feature = "embed", feature = "noembed"))]
|
||||||
|
compile_error!("You cannot have `embed` and `noembed` features enabled at the same time.");
|
||||||
|
#[cfg(not(any(feature = "embed", feature = "noembed")))]
|
||||||
|
compile_error!("You must have `embed` or `noembed` feature enabled.");
|
||||||
|
|
||||||
|
#[cfg(feature = "embed")]
|
||||||
|
let app = {
|
||||||
|
embed_assets!("assets");
|
||||||
|
Router::new().nest("/assets", static_router())
|
||||||
|
};
|
||||||
|
#[cfg(not(feature = "embed"))]
|
||||||
|
let app = { Router::new().nest_service("/assets", ServeDir::new("assets")) };
|
||||||
|
let app = app
|
||||||
|
.route("/", get(home::home))
|
||||||
|
.route("/login", get(login::get_login))
|
||||||
|
.route("/login", post(login::post_login))
|
||||||
|
.route("/logout", get(logout::logout))
|
||||||
|
.route("/domain/{domain}", get(domain::get_domain))
|
||||||
|
.route("/domain", post(domain::create_domain))
|
||||||
|
.route("/user", post(user::create_user))
|
||||||
|
.with_state(HttpState::new(db));
|
||||||
|
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
96
src/http/redirect.rs
Normal file
96
src/http/redirect.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
use axum::extract::{FromRequestParts, Query};
|
||||||
|
use axum::response::Redirect;
|
||||||
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE};
|
||||||
|
use http::request::Parts;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::http::HttpState;
|
||||||
|
|
||||||
|
/// Internal redirect to a different page.
|
||||||
|
///
|
||||||
|
/// Is usually constructed from a base64url-encoded `redirect`
|
||||||
|
/// query string, for example in the login page.
|
||||||
|
pub struct InternalRedirect(Option<String>);
|
||||||
|
|
||||||
|
impl InternalRedirect {
|
||||||
|
/// Creates a new redirection from a base64url-encoded string.
|
||||||
|
///
|
||||||
|
/// An invalid redirect is silently discarded and treated as no redirect.
|
||||||
|
pub fn from_base64url(s: &str) -> Self {
|
||||||
|
let s = match URL_SAFE.decode(s) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("Malformed login redirect URL, treating as empty: {e}");
|
||||||
|
return Self::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let s = match String::from_utf8(s) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("Malformed login redirect URL bytes, treating as empty: {e}");
|
||||||
|
return Self::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Self::from_string(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns into a base64url string that can be added to a URL.
|
||||||
|
pub fn to_base64url(&self) -> String {
|
||||||
|
let s = self
|
||||||
|
.0
|
||||||
|
.as_ref()
|
||||||
|
.expect("Cannot call InternalRedirect::to_base64url on an empty redirect");
|
||||||
|
URL_SAFE.encode(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new redirection from a raw string.
|
||||||
|
///
|
||||||
|
/// If the string is empty or seems to redirect outside of our website (absolute URL),
|
||||||
|
/// the redirect is silently discarded and treated as no redirect.
|
||||||
|
pub fn from_string(s: String) -> Self {
|
||||||
|
if s.trim().is_empty() {
|
||||||
|
return Self(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.starts_with("http://") || s.starts_with("https://") {
|
||||||
|
tracing::debug!("Invalid login redirect URL, treating as empty: {s}");
|
||||||
|
return Self(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Self(Some(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_redirect(&self) -> Redirect {
|
||||||
|
if let Some(url) = &self.0 {
|
||||||
|
Redirect::to(url)
|
||||||
|
} else {
|
||||||
|
Redirect::to("/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequestParts<HttpState> for InternalRedirect {
|
||||||
|
type Rejection = !;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &HttpState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let Ok(f) = Query::<RedirectForm>::from_request_parts(parts, state).await else {
|
||||||
|
return Ok(Self::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self::from_base64url(&f.redirect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct RedirectForm {
|
||||||
|
redirect: String,
|
||||||
|
}
|
||||||
137
src/http/session.rs
Normal file
137
src/http/session.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
use axum::extract::{FromRequestParts, OptionalFromRequestParts};
|
||||||
|
use axum::response::Redirect;
|
||||||
|
use axum_extra::extract::cookie::{Cookie, CookieJar};
|
||||||
|
use http::request::Parts;
|
||||||
|
use http::uri::Uri;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
|
use crate::db::User;
|
||||||
|
use crate::http::{HttpState, InternalRedirect};
|
||||||
|
|
||||||
|
pub const COOKIE_NAME: &str = "lldap_session";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct HttpSessionManager {
|
||||||
|
inner: Arc<RwLock<Vec<HttpSession>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpSessionManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(RwLock::new(vec![])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_session(&self, user: User, cookies: CookieJar) -> CookieJar {
|
||||||
|
let uuid = Uuid::new_v4();
|
||||||
|
|
||||||
|
let mut cookie = Cookie::new(COOKIE_NAME, uuid.to_string());
|
||||||
|
cookie.set_path("/");
|
||||||
|
|
||||||
|
let session = HttpSession { user, uuid };
|
||||||
|
|
||||||
|
{
|
||||||
|
self.inner.write().unwrap().push(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies.add(cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_session(&self, cookies: &CookieJar) -> Option<HttpSession> {
|
||||||
|
let cookie = cookies.get(COOKIE_NAME)?;
|
||||||
|
let previous_uuid = Uuid::parse_str(cookie.value()).ok()?;
|
||||||
|
|
||||||
|
self.inner
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|session| session.uuid == previous_uuid)
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_session(&self, session: &HttpSession, cookies: CookieJar) -> CookieJar {
|
||||||
|
let mut cookie = Cookie::new(COOKIE_NAME, String::new());
|
||||||
|
cookie.set_path("/");
|
||||||
|
|
||||||
|
let idx = {
|
||||||
|
let Some(idx) = self.inner.read().unwrap().iter().position(|s| s == session) else {
|
||||||
|
return cookies;
|
||||||
|
};
|
||||||
|
idx
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
self.inner.write().unwrap().remove(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies.remove(cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct HttpSession {
|
||||||
|
// TODO: by storing the session here, it means
|
||||||
|
// it needs to be updated every time we change the user.
|
||||||
|
pub user: User,
|
||||||
|
pub uuid: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq<HttpSession> for HttpSession {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.uuid == other.uuid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequestParts<HttpState> for HttpSession {
|
||||||
|
type Rejection = Redirect;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &HttpState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let cookies = CookieJar::from_request_parts(parts, state).await.unwrap();
|
||||||
|
if let Some(session) = state.sessions.get_session(&cookies) {
|
||||||
|
return Ok(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the requested URL, turn it into base64, to let the login page
|
||||||
|
// know where to redirect us.
|
||||||
|
// But first, remove the scheme/host/port from URL.
|
||||||
|
let s = Uri::builder()
|
||||||
|
.path_and_query(parts.uri.path_and_query().unwrap().clone())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let r = InternalRedirect::from_string(s.to_string());
|
||||||
|
Err(Redirect::to(&format!(
|
||||||
|
"/login?redirect={}",
|
||||||
|
r.to_base64url()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OptionalHttpSession(pub HttpSession);
|
||||||
|
|
||||||
|
impl std::ops::Deref for OptionalHttpSession {
|
||||||
|
type Target = HttpSession;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OptionalFromRequestParts<HttpState> for OptionalHttpSession {
|
||||||
|
type Rejection = Redirect;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &HttpState,
|
||||||
|
) -> Result<Option<Self>, Self::Rejection> {
|
||||||
|
let maybe_session = HttpSession::from_request_parts(parts, state)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(Self);
|
||||||
|
Ok(maybe_session)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/http/user.rs
Normal file
37
src/http/user.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
use axum::extract::{Form, State};
|
||||||
|
use axum::response::{IntoResponse, Redirect, Response};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::db::{DatabaseInterface, Role, User};
|
||||||
|
use crate::http::{HttpSession, HttpState};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct UserCreationForm {
|
||||||
|
pub username: String,
|
||||||
|
pub domain: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_user(
|
||||||
|
State(mut state): State<HttpState>,
|
||||||
|
session: HttpSession,
|
||||||
|
Form(form): Form<UserCreationForm>,
|
||||||
|
) -> Response {
|
||||||
|
let new_user = User {
|
||||||
|
mail: format!("{}@{}", form.username, form.domain),
|
||||||
|
username: form.username.clone(),
|
||||||
|
domain: Some(form.domain.clone()),
|
||||||
|
password: form.password,
|
||||||
|
role: Role::User,
|
||||||
|
};
|
||||||
|
|
||||||
|
match state.db.try_create_user(new_user, &session.user).await {
|
||||||
|
Ok(Ok(())) => Redirect::to(&format!("/domain/{}", form.domain)).into_response(),
|
||||||
|
Ok(Err(e)) => format!(
|
||||||
|
"Failed to create user {} on domain {}: {}",
|
||||||
|
form.username, form.domain, e
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => format!("Database error: {e}").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/ldap/attr.rs
Normal file
66
src/ldap/attr.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct UnknownLdapAttribute(String);
|
||||||
|
|
||||||
|
impl fmt::Display for UnknownLdapAttribute {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "Unknown LDAP attribute: {}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for UnknownLdapAttribute {}
|
||||||
|
|
||||||
|
/// An LDAP attribute that is requested, or requested to be matched against an entry.
|
||||||
|
///
|
||||||
|
/// Attributes are case-insensitive when parsing from a string.
|
||||||
|
///
|
||||||
|
/// In the future, we may want to support custom attributes, but that is not
|
||||||
|
/// implemented for now.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum LdapAttribute {
|
||||||
|
Uid,
|
||||||
|
CommonName,
|
||||||
|
MemberOf,
|
||||||
|
ObjectClass,
|
||||||
|
Mail,
|
||||||
|
MailAlias,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for LdapAttribute {
|
||||||
|
type Err = UnknownLdapAttribute;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"uid" => Ok(Self::Uid),
|
||||||
|
"cn" => Ok(Self::CommonName),
|
||||||
|
"memberof" => Ok(Self::MemberOf),
|
||||||
|
"objectclass" => Ok(Self::ObjectClass),
|
||||||
|
"mail" => Ok(Self::Mail),
|
||||||
|
"mailalias" => Ok(Self::MailAlias),
|
||||||
|
_ => Err(UnknownLdapAttribute(s.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uppercased_attribute() {
|
||||||
|
let s = "MemberOF";
|
||||||
|
let attr = LdapAttribute::from_str(s);
|
||||||
|
println!("{attr:?}");
|
||||||
|
assert_eq!(attr.unwrap(), LdapAttribute::MemberOf);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_attribute() {
|
||||||
|
let s = "foobar";
|
||||||
|
let attr = LdapAttribute::from_str(s);
|
||||||
|
println!("{attr:?}");
|
||||||
|
assert_eq!(attr.unwrap_err(), UnknownLdapAttribute(s.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -137,6 +137,7 @@ impl Dn {
|
||||||
/// Overrides the DN hostname (`dc` fields) with the provided host.
|
/// Overrides the DN hostname (`dc` fields) with the provided host.
|
||||||
///
|
///
|
||||||
/// When no `dc` fields are present, they are only added when `force` is true.
|
/// When no `dc` fields are present, they are only added when `force` is true.
|
||||||
|
#[expect(unused)]
|
||||||
pub fn set_hostname(&mut self, host: &str, force: bool) {
|
pub fn set_hostname(&mut self, host: &str, force: bool) {
|
||||||
let domain_components: Vec<String> =
|
let domain_components: Vec<String> =
|
||||||
host.split('.').map(|x| dn_escape(x).to_string()).collect();
|
host.split('.').map(|x| dn_escape(x).to_string()).collect();
|
||||||
|
|
@ -148,9 +149,11 @@ impl Dn {
|
||||||
let mut keys = VecMap::new();
|
let mut keys = VecMap::new();
|
||||||
keys.insert_or_append("uid", &user.username);
|
keys.insert_or_append("uid", &user.username);
|
||||||
keys.insert_or_append("ou", "people");
|
keys.insert_or_append("ou", "people");
|
||||||
for domain_component in user.domain.split('.') {
|
if let Some(domain_components) = &user.domain {
|
||||||
|
for domain_component in domain_components.split('.') {
|
||||||
keys.insert_or_append("dc", domain_component);
|
keys.insert_or_append("dc", domain_component);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Self { keys }
|
Self { keys }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
use ldap3_proto::{LdapFilter, LdapResultCode};
|
|
||||||
|
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
use crate::db::UserRef;
|
|
||||||
use crate::ldap::LdapReturnError;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct MailFilter {
|
|
||||||
pub complete: String,
|
|
||||||
pub username: String,
|
|
||||||
pub domain: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for MailFilter {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.complete)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MailFilter {
|
|
||||||
/// Tries to parse a string into an email address.
|
|
||||||
///
|
|
||||||
/// This method is not really RFC-compliant because it merely splits by `@`,
|
|
||||||
/// but is good enough for what we do.
|
|
||||||
pub fn new(value: &str) -> Result<Self, MailDomainError> {
|
|
||||||
let original = value;
|
|
||||||
// Here we normalize the mail attribute to lowercase to prevent
|
|
||||||
// useless mismatches.
|
|
||||||
// TODO: investigate if that's supposed to be a problem for anyone?
|
|
||||||
let value = value.to_lowercase();
|
|
||||||
if value != original {
|
|
||||||
tracing::debug!("Normalized search email from {original} to {value}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut parts = value.split('@');
|
|
||||||
let username = parts.next().unwrap();
|
|
||||||
let Some(domain) = parts.next() else {
|
|
||||||
tracing::debug!("Not a valid username@domain email: {value}");
|
|
||||||
return Err(MailDomainError::InvalidMail);
|
|
||||||
};
|
|
||||||
|
|
||||||
if parts.next().is_some() {
|
|
||||||
tracing::debug!("Too many parts in mail address");
|
|
||||||
return Err(MailDomainError::InvalidMail);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
complete: value.clone(),
|
|
||||||
username: username.to_string(),
|
|
||||||
domain: domain.to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract any mail filter from an LDAP search filter
|
|
||||||
/// which may contain other criteria, which we overall don't care about
|
|
||||||
/// at the moment.
|
|
||||||
pub fn from_search_filter(filter: &LdapFilter) -> Result<Self, MailDomainError> {
|
|
||||||
let res = match filter {
|
|
||||||
LdapFilter::And(filters) | LdapFilter::Or(filters) => {
|
|
||||||
Self::from_multiple_filters(filters)
|
|
||||||
}
|
|
||||||
LdapFilter::Equality(attr, value) => {
|
|
||||||
if let Some(domain) = Self::from_equality_filter(attr, value)? {
|
|
||||||
Ok(domain)
|
|
||||||
} else {
|
|
||||||
Err(MailDomainError::NoMailFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Err(MailDomainError::InvalidFilter),
|
|
||||||
};
|
|
||||||
|
|
||||||
match &res {
|
|
||||||
Ok(mail) => tracing::debug!("Found email in search filter: {mail}"),
|
|
||||||
Err(e) => tracing::debug!("Not found email in search filter: {}", e.message()),
|
|
||||||
}
|
|
||||||
|
|
||||||
res
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract any mail filter from a bunch of LDAP filters.
|
|
||||||
///
|
|
||||||
/// Any filter that is not an equality check is discarded.
|
|
||||||
fn from_multiple_filters(filters: &[LdapFilter]) -> Result<Self, MailDomainError> {
|
|
||||||
for filter in filters {
|
|
||||||
if let LdapFilter::Equality(attr, value) = filter {
|
|
||||||
// Here if we receive None, it means the filter was not checking for the
|
|
||||||
// `mail` attr so we continue iterating.
|
|
||||||
if let Some(found) = Self::from_equality_filter(attr, value)? {
|
|
||||||
return Ok(found);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(MailDomainError::NoMailFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract any mail filter from an LDAP search equality filter extracted
|
|
||||||
/// from a global search filter.
|
|
||||||
fn from_equality_filter(attr: &str, value: &str) -> Result<Option<Self>, MailDomainError> {
|
|
||||||
if attr != "mail" {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mail = Self::new(value)?;
|
|
||||||
Ok(Some(mail))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_user_ref(&self) -> UserRef {
|
|
||||||
UserRef {
|
|
||||||
username: self.username.clone(),
|
|
||||||
domain: self.domain.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
pub enum MailDomainError {
|
|
||||||
/// mail value not user@domain format
|
|
||||||
InvalidMail,
|
|
||||||
/// No mail filter found in the search query
|
|
||||||
NoMailFilter,
|
|
||||||
/// Filter is not And/Or/Equality for which we can find a mail filter
|
|
||||||
InvalidFilter,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for MailDomainError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
let msg = match self {
|
|
||||||
Self::InvalidMail => {
|
|
||||||
"No valid email address requested in mail filter in search request"
|
|
||||||
}
|
|
||||||
Self::NoMailFilter => "No mail filter found in search request",
|
|
||||||
Self::InvalidFilter => "No AND/OR/EQUALITY filter found in search request",
|
|
||||||
};
|
|
||||||
write!(f, "{msg}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LdapReturnError for MailDomainError {
|
|
||||||
fn code(&self) -> LdapResultCode {
|
|
||||||
match self {
|
|
||||||
Self::InvalidMail => LdapResultCode::InvalidAttributeSyntax,
|
|
||||||
Self::NoMailFilter => LdapResultCode::InappropriateMatching,
|
|
||||||
Self::InvalidFilter => LdapResultCode::UnwillingToPerform,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn message(&self) -> String {
|
|
||||||
self.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::filter::{search_filter_and, search_filter_eq};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_mail_filter() {
|
|
||||||
let filter = search_filter_and(&[
|
|
||||||
search_filter_eq("uid", "a"),
|
|
||||||
search_filter_eq("objectClass", "inetOrgPerson"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let mail_filter = MailFilter::from_search_filter(&filter);
|
|
||||||
println!("{:?}", mail_filter);
|
|
||||||
let e = mail_filter.unwrap_err();
|
|
||||||
assert_eq!(e, MailDomainError::NoMailFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_filter() {
|
|
||||||
let filter = LdapFilter::Approx("mail".to_string(), "a@a.localhost".to_string());
|
|
||||||
let mail_filter = MailFilter::from_search_filter(&filter);
|
|
||||||
println!("{:?}", mail_filter);
|
|
||||||
let e = mail_filter.unwrap_err();
|
|
||||||
assert_eq!(e, MailDomainError::InvalidFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_mail_no_domain() {
|
|
||||||
let filter = search_filter_eq("mail", "a");
|
|
||||||
let mail_filter = MailFilter::from_search_filter(&filter);
|
|
||||||
println!("{:?}", mail_filter);
|
|
||||||
let e = mail_filter.unwrap_err();
|
|
||||||
assert_eq!(e, MailDomainError::InvalidMail);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_mail_too_many_parts() {
|
|
||||||
let filter = search_filter_eq("mail", "a@a.localhost@a.localhost");
|
|
||||||
let mail_filter = MailFilter::from_search_filter(&filter);
|
|
||||||
println!("{:?}", mail_filter);
|
|
||||||
let e = mail_filter.unwrap_err();
|
|
||||||
assert_eq!(e, MailDomainError::InvalidMail);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn valid_basic() {
|
|
||||||
let filter = search_filter_eq("mail", "a@a.localhost");
|
|
||||||
let mail_filter = MailFilter::from_search_filter(&filter);
|
|
||||||
println!("{:?}", mail_filter);
|
|
||||||
let mail = mail_filter.unwrap();
|
|
||||||
assert_eq!(mail.complete, "a@a.localhost");
|
|
||||||
assert_eq!(mail.username, "a");
|
|
||||||
assert_eq!(mail.domain, "a.localhost");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn valid_basic_normalization() {
|
|
||||||
let filter = search_filter_eq("mail", "A@A.localhost");
|
|
||||||
let mail_filter = MailFilter::from_search_filter(&filter);
|
|
||||||
println!("{:?}", mail_filter);
|
|
||||||
let mail = mail_filter.unwrap();
|
|
||||||
assert_eq!(mail.complete, "a@a.localhost");
|
|
||||||
assert_eq!(mail.username, "a");
|
|
||||||
assert_eq!(mail.domain, "a.localhost");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stalwart_default() {
|
|
||||||
// (&(objectClass=inetOrgPerson)(mail=?))
|
|
||||||
let filter = search_filter_and(&[
|
|
||||||
search_filter_eq("mail", "a@a.localhost"),
|
|
||||||
search_filter_eq("objectClass", "inetOrgPerson"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let mail_filter = MailFilter::from_search_filter(&filter);
|
|
||||||
println!("{:?}", mail_filter);
|
|
||||||
let mail = mail_filter.unwrap();
|
|
||||||
assert_eq!(mail.complete, "a@a.localhost");
|
|
||||||
assert_eq!(mail.username, "a");
|
|
||||||
assert_eq!(mail.domain, "a.localhost");
|
|
||||||
}
|
|
||||||
|
|
||||||
// fn stalwart_lldap_example() {
|
|
||||||
// // &(|(objectClass=person)(member=cn=mail,ou=groups,dc=example,dc=org))(uid=?))
|
|
||||||
// let filter = search_filter_and(&[
|
|
||||||
// search_filter_eq("uid", "a"),
|
|
||||||
// search_filter_or(&[
|
|
||||||
// search_filter_eq("objectClass", "person"),
|
|
||||||
// search_filter_eq("member", "cn=mail,ou=groups,dc=a,dc=localhost"),
|
|
||||||
// ]),
|
|
||||||
// ]);
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
pub mod mail;
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
use ldap3_proto::LdapMsg;
|
use ldap3_proto::LdapMsg;
|
||||||
use ldap3_proto::proto::LdapOp;
|
use ldap3_proto::proto::LdapOp;
|
||||||
|
|
||||||
use crate::db::{Database, DatabaseInterface};
|
use crate::db::Database;
|
||||||
use crate::ldap::{
|
use crate::ldap::{
|
||||||
LdapClientState, LdapStream, LdapStreamError, op_bind, op_ext, search_by_mail_filter,
|
LdapClientState, LdapStream, LdapStreamError, op_bind, op_ext, search_by_everything,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tracing::instrument(name = "ldap", skip(stream, db), fields(session = %stream.session))]
|
#[tracing::instrument(name = "ldap", skip(stream, db), fields(session = %stream.session))]
|
||||||
pub async fn ldap_handler<D: DatabaseInterface>(mut stream: LdapStream, mut db: Database<D>) {
|
pub async fn ldap_handler(mut stream: LdapStream, mut db: Database) {
|
||||||
tracing::info! {
|
tracing::info! {
|
||||||
remote_addr = ?stream.remote_addr,
|
remote_addr = ?stream.remote_addr,
|
||||||
"New client connection"
|
"New client connection"
|
||||||
|
|
@ -44,12 +44,12 @@ pub async fn ldap_handler<D: DatabaseInterface>(mut stream: LdapStream, mut db:
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return true to keep the connection going, false to close it.
|
/// Return true to keep the connection going, false to close it.
|
||||||
#[tracing::instrument(name = "ldap-handler", skip(client_state, db, stream))]
|
#[tracing::instrument(name = "ldap-handler", skip(client_state, db, stream, msg))]
|
||||||
pub async fn ldap_handler_inner<D: DatabaseInterface>(
|
pub async fn ldap_handler_inner(
|
||||||
stream: &mut LdapStream,
|
stream: &mut LdapStream,
|
||||||
msg: LdapMsg,
|
msg: LdapMsg,
|
||||||
client_state: &mut LdapClientState,
|
client_state: &mut LdapClientState,
|
||||||
db: &mut Database<D>,
|
db: &mut Database,
|
||||||
) -> Result<bool, LdapStreamError> {
|
) -> Result<bool, LdapStreamError> {
|
||||||
tracing::debug!(msg = ?msg, "Received LDAP message");
|
tracing::debug!(msg = ?msg, "Received LDAP message");
|
||||||
match msg {
|
match msg {
|
||||||
|
|
@ -93,7 +93,7 @@ pub async fn ldap_handler_inner<D: DatabaseInterface>(
|
||||||
// TODO: ctrl for pagination
|
// TODO: ctrl for pagination
|
||||||
ctrl: _,
|
ctrl: _,
|
||||||
} => {
|
} => {
|
||||||
search_by_mail_filter(stream, db, sr, msgid).await?;
|
search_by_everything(stream, db, sr, msgid).await?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
// Unsupported message
|
// Unsupported message
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
|
mod attr;
|
||||||
|
pub use attr::LdapAttribute;
|
||||||
mod client_state;
|
mod client_state;
|
||||||
pub use client_state::LdapClientState;
|
pub use client_state::LdapClientState;
|
||||||
mod dn;
|
mod dn;
|
||||||
pub use dn::{Dn, MalformedDn};
|
pub use dn::{Dn, MalformedDn};
|
||||||
mod filter;
|
|
||||||
mod handler;
|
mod handler;
|
||||||
mod op;
|
mod op;
|
||||||
pub use handler::ldap_handler;
|
pub use handler::ldap_handler;
|
||||||
pub use op::bind::{BindDn, op_bind};
|
pub use op::bind::{BindDn, op_bind};
|
||||||
pub use op::ext::op_ext;
|
pub use op::ext::op_ext;
|
||||||
pub use op::search::search_by_mail_filter;
|
pub use op::search::search_by_everything;
|
||||||
mod return_error;
|
mod return_error;
|
||||||
pub use return_error::LdapReturnError;
|
pub use return_error::LdapReturnError;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use ldap3_proto::proto::{LdapBindCred, LdapBindRequest, LdapBindResponse, LdapOp
|
||||||
use ldap3_proto::{LdapMsg, LdapResultCode};
|
use ldap3_proto::{LdapMsg, LdapResultCode};
|
||||||
|
|
||||||
use crate::db::error::BoxedError;
|
use crate::db::error::BoxedError;
|
||||||
use crate::db::{Database, DatabaseInterface, UserRef};
|
use crate::db::{Database, UserRef};
|
||||||
use crate::ldap::{Dn, LdapReturnError, LdapStream, LdapStreamError, MalformedDn};
|
use crate::ldap::{Dn, LdapReturnError, LdapStream, LdapStreamError, MalformedDn};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -76,9 +76,15 @@ impl BindDn {
|
||||||
|
|
||||||
pub fn to_user_ref(&self) -> UserRef {
|
pub fn to_user_ref(&self) -> UserRef {
|
||||||
let username = self.0.keys.get("uid").unwrap()[0].clone();
|
let username = self.0.keys.get("uid").unwrap()[0].clone();
|
||||||
|
// TODO: if we want service domains to connect over LDAP
|
||||||
|
// we need to remove this unwrap and allow a BindDn to
|
||||||
|
// not have a domain part.
|
||||||
let domain = self.0.keys.get("dc").unwrap().join(".");
|
let domain = self.0.keys.get("dc").unwrap().join(".");
|
||||||
|
|
||||||
UserRef { username, domain }
|
UserRef {
|
||||||
|
username,
|
||||||
|
domain: Some(domain),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,9 +165,9 @@ pub async fn bind_success(stream: &mut LdapStream, msgid: i32) -> Result<(), Lda
|
||||||
///
|
///
|
||||||
/// On success, returns `Ok(Some(bound_dn))`. `Ok(None)` means credentials failed,
|
/// On success, returns `Ok(Some(bound_dn))`. `Ok(None)` means credentials failed,
|
||||||
/// either because the account does not exist, or the password is wrong.
|
/// either because the account does not exist, or the password is wrong.
|
||||||
pub async fn op_bind<D: DatabaseInterface>(
|
pub async fn op_bind(
|
||||||
stream: &mut LdapStream,
|
stream: &mut LdapStream,
|
||||||
db: &Database<D>,
|
db: &Database,
|
||||||
req: LdapBindRequest,
|
req: LdapBindRequest,
|
||||||
msgid: i32,
|
msgid: i32,
|
||||||
) -> Result<Option<BindDn>, LdapStreamError> {
|
) -> Result<Option<BindDn>, LdapStreamError> {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
use ldap3_proto::control::LdapControl;
|
use ldap3_proto::control::LdapControl;
|
||||||
use ldap3_proto::proto::{
|
use ldap3_proto::proto::{
|
||||||
LdapOp, LdapPartialAttribute, LdapResult, LdapSearchRequest, LdapSearchResultEntry,
|
LdapFilter, LdapOp, LdapPartialAttribute, LdapResult, LdapSearchRequest, LdapSearchResultEntry,
|
||||||
};
|
};
|
||||||
use ldap3_proto::{LdapMsg, LdapResultCode};
|
use ldap3_proto::{LdapMsg, LdapResultCode};
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use crate::db::error::BoxedError;
|
use crate::db::error::BoxedError;
|
||||||
use crate::db::{Database, DatabaseInterface, User};
|
use crate::db::{Database, DatabaseInterface, User};
|
||||||
use crate::ldap::filter::mail::{MailDomainError, MailFilter};
|
use crate::ldap::{Dn, LdapAttribute, LdapReturnError, LdapStream, LdapStreamError, MalformedDn};
|
||||||
use crate::ldap::{Dn, LdapReturnError, LdapStream, LdapStreamError, MalformedDn};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct InvalidSearchDn {
|
pub struct InvalidSearchDn {
|
||||||
|
|
@ -39,7 +40,7 @@ impl LdapReturnError for InvalidSearchDn {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct SearchDn(Dn);
|
pub struct SearchDn(#[expect(unused)] Dn);
|
||||||
|
|
||||||
impl SearchDn {
|
impl SearchDn {
|
||||||
pub fn from_dn_str(input: &str) -> Result<Self, InvalidSearchDn> {
|
pub fn from_dn_str(input: &str) -> Result<Self, InvalidSearchDn> {
|
||||||
|
|
@ -70,7 +71,6 @@ impl SearchDn {
|
||||||
pub enum SearchError {
|
pub enum SearchError {
|
||||||
Db(BoxedError),
|
Db(BoxedError),
|
||||||
InvalidDn(InvalidSearchDn),
|
InvalidDn(InvalidSearchDn),
|
||||||
MailDomain(MailDomainError),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SearchError {
|
impl SearchError {
|
||||||
|
|
@ -100,7 +100,6 @@ impl LdapReturnError for SearchError {
|
||||||
match self {
|
match self {
|
||||||
Self::Db(_e) => LdapResultCode::Unavailable,
|
Self::Db(_e) => LdapResultCode::Unavailable,
|
||||||
Self::InvalidDn(e) => e.code(),
|
Self::InvalidDn(e) => e.code(),
|
||||||
Self::MailDomain(e) => e.code(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,7 +107,6 @@ impl LdapReturnError for SearchError {
|
||||||
match self {
|
match self {
|
||||||
Self::Db(e) => format!("Database error: {e}"),
|
Self::Db(e) => format!("Database error: {e}"),
|
||||||
Self::InvalidDn(e) => e.message(),
|
Self::InvalidDn(e) => e.message(),
|
||||||
Self::MailDomain(e) => e.message(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -150,23 +148,43 @@ pub async fn search_success(
|
||||||
|
|
||||||
fn search_entry_from_user(user: &User, req_attrs: &[String]) -> LdapSearchResultEntry {
|
fn search_entry_from_user(user: &User, req_attrs: &[String]) -> LdapSearchResultEntry {
|
||||||
let mut res: Vec<LdapPartialAttribute> = vec![];
|
let mut res: Vec<LdapPartialAttribute> = vec![];
|
||||||
for attr in req_attrs {
|
for attr_str in req_attrs {
|
||||||
if let Some(attr_value) = match attr.as_str() {
|
// We keep a copy of the requested attribute so that we can answer as it was requested,
|
||||||
"uid" => Some(user.username.clone()),
|
// eg. `CN` => `CN` (instead of normalizing to `cn`).
|
||||||
"cn"|"mail" => Some(user.mail.clone()),
|
let Some(attr) = LdapAttribute::from_str(attr_str)
|
||||||
_ => {
|
.inspect_err(|e| tracing::debug!("Unknown attribute in request: {e}"))
|
||||||
tracing::warn!("Ignoring unknown attr in search query: {attr}");
|
.ok()
|
||||||
None
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let str_values = match attr {
|
||||||
|
// TODO: should we normalize the value some more here?
|
||||||
|
LdapAttribute::Uid => vec![user.username.clone()],
|
||||||
|
// TODO: should CN be different than the mail?
|
||||||
|
// TODO: should we normalize values some more here?
|
||||||
|
LdapAttribute::CommonName | LdapAttribute::Mail | LdapAttribute::MailAlias => {
|
||||||
|
vec![user.mail.clone()]
|
||||||
}
|
}
|
||||||
} {
|
// TODO: group membership
|
||||||
|
LdapAttribute::MemberOf => vec![],
|
||||||
|
// TODO: if we introduce mail permission, we need to remove mailaccount from here
|
||||||
|
LdapAttribute::ObjectClass => vec![
|
||||||
|
"inetOrgPerson".to_string(),
|
||||||
|
"posixAccount".to_string(),
|
||||||
|
"mailAccount".to_string(),
|
||||||
|
"person".to_string(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
res.push(LdapPartialAttribute {
|
res.push(LdapPartialAttribute {
|
||||||
atype: attr.clone(),
|
// Reuse the requested attribute name, not the normalized form
|
||||||
// TODO: there may be multiple values here in the future,
|
// we would otherwise produce.
|
||||||
// eg. mailaliases
|
atype: attr_str.clone(),
|
||||||
vals: vec![Vec::from(attr_value)],
|
// LDAP response expects raw byte vec for each value
|
||||||
|
vals: str_values.into_iter().map(Vec::from).collect(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
LdapSearchResultEntry {
|
LdapSearchResultEntry {
|
||||||
dn: Dn::from_user(user).to_dn_string(),
|
dn: Dn::from_user(user).to_dn_string(),
|
||||||
|
|
@ -174,9 +192,9 @@ fn search_entry_from_user(user: &User, req_attrs: &[String]) -> LdapSearchResult
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn search_by_mail_filter<D: DatabaseInterface>(
|
pub async fn search_by_everything(
|
||||||
stream: &mut LdapStream,
|
stream: &mut LdapStream,
|
||||||
db: &Database<D>,
|
db: &Database,
|
||||||
sr: LdapSearchRequest,
|
sr: LdapSearchRequest,
|
||||||
msgid: i32,
|
msgid: i32,
|
||||||
) -> Result<(), LdapStreamError> {
|
) -> Result<(), LdapStreamError> {
|
||||||
|
|
@ -188,30 +206,96 @@ pub async fn search_by_mail_filter<D: DatabaseInterface>(
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mail_filter = match MailFilter::from_search_filter(&sr.filter) {
|
let users = match search_everything(db, &sr.filter).await {
|
||||||
Ok(mail_filter) => mail_filter,
|
Ok(users) => users,
|
||||||
Err(e) => {
|
|
||||||
SearchError::MailDomain(e)
|
|
||||||
.error_message(stream, msgid)
|
|
||||||
.await?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let maybe_user = match db.get_user(&mail_filter.to_user_ref()).await {
|
|
||||||
Ok(maybe_user) => maybe_user,
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
SearchError::Db(e).error_message(stream, msgid).await?;
|
SearchError::Db(e).error_message(stream, msgid).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(user) = maybe_user {
|
// TODO: pagination ctrl
|
||||||
let entry_ctrl = (search_entry_from_user(&user, &sr.attrs), vec![]);
|
let entries = users
|
||||||
search_success(stream, msgid, vec![entry_ctrl]).await?;
|
.into_iter()
|
||||||
} else {
|
.map(|user| (search_entry_from_user(&user, &sr.attrs), vec![]))
|
||||||
search_success(stream, msgid, vec![]).await?;
|
.collect();
|
||||||
}
|
search_success(stream, msgid, entries).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: pagination
|
||||||
|
// TODO: support groups (objectclass=groupOfNames) and membership
|
||||||
|
/// A very unoptimized search algorithm for the LDAP database.
|
||||||
|
///
|
||||||
|
/// Key information:
|
||||||
|
/// - ignores all filters that are not AND/OR/NOT/EQ (others may be added
|
||||||
|
/// if we find a valid usecase)
|
||||||
|
/// - iterates over every single entry in the database!!!!!
|
||||||
|
pub async fn search_everything(db: &Database, sr: &LdapFilter) -> Result<Vec<User>, BoxedError> {
|
||||||
|
Ok(db
|
||||||
|
.list_all_users()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|user| user_matches_filter(user, sr))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a user matches a single `LdapFilter`.
|
||||||
|
///
|
||||||
|
/// Used recursively to see if a user matches a filter overall.
|
||||||
|
pub fn user_matches_filter(user: &User, filter: &LdapFilter) -> bool {
|
||||||
|
// TODO: we should also receive the group memberships as argument here
|
||||||
|
// so we can match that in the future
|
||||||
|
// For now, we only match mail/mailalias/uid attributes
|
||||||
|
// TODO: implement mailalias, for now it's simply mapped to the mail attribute
|
||||||
|
match filter {
|
||||||
|
LdapFilter::And(filters) => {
|
||||||
|
for sub_filter in filters {
|
||||||
|
if !user_matches_filter(user, sub_filter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
LdapFilter::Or(filters) => {
|
||||||
|
for sub_filter in filters {
|
||||||
|
if user_matches_filter(user, sub_filter) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
LdapFilter::Not(sub_filter) => !user_matches_filter(user, sub_filter),
|
||||||
|
LdapFilter::Equality(attr, value) => LdapAttribute::from_str(attr).map_or_else(
|
||||||
|
|e| {
|
||||||
|
tracing::warn!("Unrecognized attribute, considering no match: {e}");
|
||||||
|
false
|
||||||
|
},
|
||||||
|
|attr| user_matches_attribute(user, &attr, value),
|
||||||
|
),
|
||||||
|
_ => {
|
||||||
|
tracing::warn!("Unimplemented search filter, considering no match: {filter:?}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: we want to support group relations here as argument soon
|
||||||
|
pub fn user_matches_attribute(user: &User, attribute: &LdapAttribute, value: &str) -> bool {
|
||||||
|
match attribute {
|
||||||
|
// TODO: should we lowercase the value here?
|
||||||
|
LdapAttribute::Uid => user.username == *value,
|
||||||
|
// TODO: should CN be different than the mail?
|
||||||
|
// TODO: should we lowercase the value here?
|
||||||
|
LdapAttribute::CommonName | LdapAttribute::Mail | LdapAttribute::MailAlias => {
|
||||||
|
user.mail == *value
|
||||||
|
}
|
||||||
|
// TODO: group membership
|
||||||
|
LdapAttribute::MemberOf => false,
|
||||||
|
LdapAttribute::ObjectClass => matches!(
|
||||||
|
// We lowercase the value here because there's no ambiguity
|
||||||
|
value.to_lowercase().as_ref(),
|
||||||
|
"inetorgperson" | "posixaccount" | "mailaccount" | "person"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
38
src/main.rs
38
src/main.rs
|
|
@ -3,23 +3,31 @@
|
||||||
mod cli;
|
mod cli;
|
||||||
mod db;
|
mod db;
|
||||||
mod error;
|
mod error;
|
||||||
|
#[cfg(feature = "http")]
|
||||||
|
mod http;
|
||||||
mod ldap;
|
mod ldap;
|
||||||
mod listener;
|
mod listener;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
|
||||||
|
#[cfg(feature = "http")]
|
||||||
|
use crate::http::http_listen;
|
||||||
use cli::CliArgs;
|
use cli::CliArgs;
|
||||||
use db::{Database, DatabaseInterface, MemoryDatabase, User};
|
use db::{Database, DatabaseInterface, FilesystemDatabase, MemoryDatabase, Role, User};
|
||||||
use error::GlobalError;
|
use error::GlobalError;
|
||||||
use ldap::ldap_handler;
|
use ldap::ldap_handler;
|
||||||
use listener::ListenerPath;
|
use listener::ListenerPath;
|
||||||
|
|
||||||
async fn create_dummy_users<D: DatabaseInterface>(db: &mut Database<D>) {
|
async fn create_dummy_users(db: &mut Database) {
|
||||||
for domain in &["a", "b", "c"] {
|
// TODO: customize admin password
|
||||||
|
if db.list_all_users().await.unwrap().is_empty() {
|
||||||
|
tracing::info!("Creating admin account with default `adminadmin` password");
|
||||||
db.create_user(User {
|
db.create_user(User {
|
||||||
username: domain.to_string(),
|
username: "admin".to_string(),
|
||||||
domain: format!("{domain}.localhost"),
|
domain: None,
|
||||||
password: "adminadmin".to_string(),
|
password: "adminadmin".to_string(),
|
||||||
mail: format!("{domain}@{domain}.localhost"),
|
// TODO: what should we put here?
|
||||||
|
mail: "admin".to_string(),
|
||||||
|
role: Role::Admin,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
@ -39,14 +47,26 @@ async fn main() -> Result<(), GlobalError> {
|
||||||
// .init();
|
// .init();
|
||||||
// tracing::subscriber::set_global_default(subscriber)?;
|
// tracing::subscriber::set_global_default(subscriber)?;
|
||||||
|
|
||||||
let listener = ListenerPath::new(&cli.listen)?.listener().await?;
|
let ldap_listener = ListenerPath::new(&cli.listen_ldap)?.listener().await?;
|
||||||
|
|
||||||
let mut db = MemoryDatabase::new();
|
let mut db = if let Some(db_path) = &cli.db {
|
||||||
|
FilesystemDatabase::from_path(db_path).await.unwrap()
|
||||||
|
} else {
|
||||||
|
MemoryDatabase::new()
|
||||||
|
};
|
||||||
|
tracing::info!("Database loaded successfully");
|
||||||
create_dummy_users(&mut db).await;
|
create_dummy_users(&mut db).await;
|
||||||
|
|
||||||
|
#[cfg(feature = "http")]
|
||||||
|
{
|
||||||
|
let http_listener = ListenerPath::new(&cli.listen_http)?.listener().await?;
|
||||||
|
let http_db = db.clone();
|
||||||
|
tokio::spawn(http_listen(http_listener, http_db));
|
||||||
|
}
|
||||||
|
|
||||||
// If the connection is None, it's because the client aborted early
|
// If the connection is None, it's because the client aborted early
|
||||||
// so there's nothing to do about it.
|
// so there's nothing to do about it.
|
||||||
while let Some(stream) = listener.accept_ldap().await? {
|
while let Some(stream) = ldap_listener.accept_ldap().await? {
|
||||||
let db = db.clone();
|
let db = db.clone();
|
||||||
tokio::spawn(ldap_handler(stream, db));
|
tokio::spawn(ldap_handler(stream, db));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
templates/base.html
Normal file
11
templates/base.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>LLLDAP</title>
|
||||||
|
<link rel="stylesheet" href="/assets/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% block main %}
|
||||||
|
{% endblock %}
|
||||||
|
</html>
|
||||||
|
|
||||||
30
templates/domain.html
Normal file
30
templates/domain.html
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block main %}
|
||||||
|
<main id="center">
|
||||||
|
<img src="/assets/img/logo.png" id="logo">
|
||||||
|
<p style="text-align: center;">You are logged in as {{ user.username }}</p>
|
||||||
|
<div id="login-line">
|
||||||
|
<a href="/logout" id="submit-login" value="Logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
{% if can_create_user %}
|
||||||
|
<div>
|
||||||
|
<h2>Create user</h2>
|
||||||
|
<form action="/user" method="POST">
|
||||||
|
<input type="hidden" name="domain" value="{{ domain.name }}">
|
||||||
|
<input type="text" name="username" placeholder="username">
|
||||||
|
<input type="password" name="password" placeholder="password">
|
||||||
|
<button type="submit" class="button is-info">Create</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<h2>Users on {{ domain.name }}</h2>
|
||||||
|
<ul>
|
||||||
|
{% for user in users %}
|
||||||
|
<li>{{ user.mail }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
38
templates/home.html
Normal file
38
templates/home.html
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block main %}
|
||||||
|
<main id="center">
|
||||||
|
<img src="/assets/img/logo.png" id="logo">
|
||||||
|
<p style="text-align: center;">You are logged in as {{ user.username }}</p>
|
||||||
|
<div id="login-line">
|
||||||
|
<a href="/logout" id="submit-login" value="Logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
{% if can_create_domain %}
|
||||||
|
<div>
|
||||||
|
<h2>Create domain</h2>
|
||||||
|
<form action="/domain" method="POST">
|
||||||
|
<input type="text" name="domainname" placeholder="example.com">
|
||||||
|
<button type="submit" class="button is-info">Create</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<h2>Active domains you can see</h2>
|
||||||
|
<ul>
|
||||||
|
{% for domain in domains %}
|
||||||
|
<li><a href="/domain/{{ domain.name }}">{{ domain.name }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% if other_users %}
|
||||||
|
<div>
|
||||||
|
<h2>Other users you have permission to see on your own domain</h2>
|
||||||
|
<ul>
|
||||||
|
{% for user in other_users %}
|
||||||
|
<li>{{ user.mail }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
29
templates/login.html
Normal file
29
templates/login.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block main %}
|
||||||
|
<main id="center">
|
||||||
|
<img src="/assets/img/logo.png" id="logo">
|
||||||
|
{% if login_error == "InvalidCredentials" %}
|
||||||
|
<div class="banner is-error">
|
||||||
|
<p>Bad login/password</p>
|
||||||
|
</div>
|
||||||
|
{% elif login_error == "SessionInvalidated" %}
|
||||||
|
<div class="banner is-success">
|
||||||
|
<p>Logout successful</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST" action="{{ login_url }}">
|
||||||
|
<div id="login-line">
|
||||||
|
<img src="/assets/img/user.svg">
|
||||||
|
<input type="text" name="username" id="username" placeholder="Username">
|
||||||
|
</div>
|
||||||
|
<div id="login-line">
|
||||||
|
<img src="/assets/img/password.svg">
|
||||||
|
<input type="password" name="password" id="password" placeholder="Password">
|
||||||
|
</div>
|
||||||
|
<div id="login-line">
|
||||||
|
<input type="submit" id="submit-login" value="LOG IN">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
Loading…
Reference in a new issue