diff --git a/.forgejo/regsync/regsync.yml b/.forgejo/regsync/regsync.yml index cd4ac094..4862c604 100644 --- a/.forgejo/regsync/regsync.yml +++ b/.forgejo/regsync/regsync.yml @@ -62,10 +62,6 @@ sync: target: registry.gitlab.com/continuwuity/continuwuity type: repository <<: *tags-main - - source: *source - target: git.nexy7574.co.uk/mirrored/continuwuity - type: repository - <<: *tags-releases - source: *source target: ghcr.io/continuwuity/continuwuity type: repository diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 015aeb09..96010012 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,4 +1,4 @@ github: [JadedBlueEyes, nexy7574, gingershaped] custom: - - https://ko-fi.com/nexy7574 - - https://ko-fi.com/JadedBlueEyes + - https://timedout.uk/donate.html + - https://jade.ellis.link/sponsors diff --git a/Cargo.lock b/Cargo.lock index b8ed8116..f46f96c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,9 +94,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anyhow" @@ -221,7 +221,7 @@ dependencies = [ "serde", "serde_derive", "unicode-ident", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -461,6 +461,7 @@ dependencies = [ "axum", "axum-core", "bytes", + "cookie", "futures-core", "futures-util", "headers", @@ -750,9 +751,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -823,9 +824,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -833,9 +834,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstyle", "clap_lex", @@ -843,9 +844,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -855,9 +856,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" @@ -905,7 +906,7 @@ dependencies = [ [[package]] name = "conduwuit" -version = "0.5.6" +version = "0.5.7-alpha.1" dependencies = [ "clap", "conduwuit_admin", @@ -937,7 +938,7 @@ dependencies = [ [[package]] name = "conduwuit_admin" -version = "0.5.6" +version = "0.5.7-alpha.1" dependencies = [ "clap", "conduwuit_api", @@ -958,7 +959,7 @@ dependencies = [ [[package]] name = "conduwuit_api" -version = "0.5.6" +version = "0.5.7-alpha.1" dependencies = [ "async-trait", "axum", @@ -990,14 +991,14 @@ dependencies = [ [[package]] name = "conduwuit_build_metadata" -version = "0.5.6" +version = "0.5.7-alpha.1" dependencies = [ "built", ] [[package]] name = "conduwuit_core" -version = "0.5.6" +version = "0.5.7-alpha.1" dependencies = [ "argon2", "arrayvec", @@ -1059,7 +1060,7 @@ dependencies = [ [[package]] name = "conduwuit_database" -version = "0.5.6" +version = "0.5.7-alpha.1" dependencies = [ "async-channel", "conduwuit_core", @@ -1077,7 +1078,7 @@ dependencies = [ [[package]] name = "conduwuit_macros" -version = "0.5.6" +version = "0.5.7-alpha.1" dependencies = [ "itertools 0.14.0", "proc-macro2", @@ -1087,7 +1088,7 @@ dependencies = [ [[package]] name = "conduwuit_router" -version = "0.5.6" +version = "0.5.7-alpha.1" dependencies = [ "axum", "axum-client-ip", @@ -1121,7 +1122,7 @@ dependencies = [ [[package]] name = "conduwuit_service" -version = "0.5.6" +version = "0.5.7-alpha.1" dependencies = [ "askama", "async-trait", @@ -1163,16 +1164,26 @@ dependencies = [ [[package]] name = "conduwuit_web" -version = "0.5.6" +version = "0.5.7-alpha.1" dependencies = [ "askama", + "async-trait", "axum", + "axum-extra", + "base64 0.22.1", "conduwuit_build_metadata", + "conduwuit_core", "conduwuit_service", "futures", + "memory-serve", "rand 0.10.0", + "ruma", + "serde", "thiserror 2.0.18", + "tower-http", + "tower-sec-fetch", "tracing", + "validator", ] [[package]] @@ -1255,6 +1266,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "coolor" version = "1.1.0" @@ -1507,6 +1529,41 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "817fa642fb0ee7fe42e95783e00e0969927b96091bdd4b9b1af082acd943913b" +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -2538,6 +2595,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -2561,9 +2624,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -2579,8 +2642,8 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.12", + "zune-core", + "zune-jpeg", ] [[package]] @@ -2866,9 +2929,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.24" +version = "1.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4735e9cbde5aac84a5ce588f6b23a90b9b0b528f6c5a8db8a4aff300463a0839" +checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1" dependencies = [ "cc", "pkg-config", @@ -3027,6 +3090,22 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memory-serve" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b5bbad2035f57b1e95f66da606832edd935b47d82312e38e1ccffbcfb8a427" +dependencies = [ + "axum", + "brotli", + "flate2", + "mime_guess", + "sha256", + "tracing", + "urlencoding", + "walkdir", +] + [[package]] name = "meowlnir-antispam" version = "0.1.0" @@ -3043,6 +3122,16 @@ 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 = "minicbor" version = "2.2.1" @@ -3115,9 +3204,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.11" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -3478,9 +3567,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" dependencies = [ "critical-section", "portable-atomic", @@ -3828,7 +3917,29 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.4+spec-1.1.0", + "toml_edit 0.25.5+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -3966,9 +4077,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -4121,9 +4232,9 @@ dependencies = [ [[package]] name = "ravif" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" dependencies = [ "avif-serialize", "imgref", @@ -4156,9 +4267,9 @@ dependencies = [ [[package]] name = "recaptcha-verify" -version = "0.1.6" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d694033c2b0abdbb8893edfb367f16270e790be4a67e618206d811dbe4efee4" +checksum = "409bf11a93fe93093f3c0254aab67576524f1e0524692615b5b63091dbc88a79" dependencies = [ "reqwest", "serde", @@ -4632,6 +4743,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "sanitize-filename" version = "0.6.0" @@ -4654,9 +4774,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -4993,6 +5113,19 @@ dependencies = [ "digest", ] +[[package]] +name = "sha256" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f880fc8562bdeb709793f00eb42a2ad0e672c4f883bbe59122b926eca935c8f6" +dependencies = [ + "async-trait", + "bytes", + "hex", + "sha2", + "tokio", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -5161,6 +5294,12 @@ dependencies = [ "quote", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subslice" version = "0.2.3" @@ -5301,16 +5440,16 @@ dependencies = [ [[package]] name = "tiff" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", "half", "quick-error", "weezl", - "zune-jpeg 0.4.21", + "zune-jpeg", ] [[package]] @@ -5384,9 +5523,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -5495,7 +5634,7 @@ dependencies = [ "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -5518,9 +5657,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.0.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" dependencies = [ "serde_core", ] @@ -5536,28 +5675,28 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" dependencies = [ "indexmap", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.0.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] @@ -5568,9 +5707,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" [[package]] name = "tonic" @@ -5661,6 +5800,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" +[[package]] +name = "tower-sec-fetch" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff1e78d241de2527d3ef67e49d65d8cb08468c644c3aafac7a988c4accd76547" +dependencies = [ + "futures", + "http", + "tower", + "tracing", +] + [[package]] name = "tower-service" version = "0.3.3" @@ -5673,6 +5824,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -5750,9 +5902,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -5894,6 +6046,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -5929,6 +6087,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "valuable" version = "0.1.1" @@ -5947,6 +6135,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -6161,6 +6359,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -6404,6 +6611,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" @@ -6538,7 +6754,7 @@ dependencies = [ [[package]] name = "xtask" -version = "0.5.6" +version = "0.5.7-alpha.1" dependencies = [ "askama", "cargo_metadata", @@ -6584,18 +6800,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.41" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96e13bc581734df6250836c59a5f44f3c57db9f9acb9dc8e3eaabdaf6170254d" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.41" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3545ea9e86d12ab9bba9fcd99b54c1556fd3199007def5a03c375623d05fac1c" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", @@ -6696,12 +6912,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - [[package]] name = "zune-core" version = "0.5.1" @@ -6719,18 +6929,9 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.4.21" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c" dependencies = [ - "zune-core 0.4.12", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" -dependencies = [ - "zune-core 0.5.1", + "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index 5c34525f..db2aa3f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ license = "Apache-2.0" # See also `rust-toolchain.toml` readme = "README.md" repository = "https://forgejo.ellis.link/continuwuation/continuwuity" -version = "0.5.6" +version = "0.5.7-alpha.1" [workspace.metadata.crane] name = "conduwuit" @@ -99,7 +99,7 @@ features = [ [workspace.dependencies.axum-extra] version = "0.12.0" default-features = false -features = ["typed-header", "tracing"] +features = ["typed-header", "tracing", "cookie"] [workspace.dependencies.axum-server] version = "0.7.2" @@ -969,3 +969,6 @@ needless_raw_string_hashes = "allow" # TODO: Enable this lint & fix all instances collapsible_if = "allow" + +# TODO: break these apart +cognitive_complexity = "allow" diff --git a/changelog.d/+6368729a.feature.md b/changelog.d/+6368729a.feature.md new file mode 100644 index 00000000..26ba608a --- /dev/null +++ b/changelog.d/+6368729a.feature.md @@ -0,0 +1 @@ +Added support for using an admin command to issue self-service password reset links. diff --git a/changelog.d/1527.feature.md b/changelog.d/1527.feature.md new file mode 100644 index 00000000..3b48a809 --- /dev/null +++ b/changelog.d/1527.feature.md @@ -0,0 +1 @@ +Add new config option to allow or disallow search engine indexing through a `` tag. Defaults to blocking indexing (`content="noindex"`). Contributed by @s1lv3r and @ginger. diff --git a/conduwuit-example.toml b/conduwuit-example.toml index f0272532..167968c7 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -25,6 +25,10 @@ # # Also see the `[global.well_known]` config section at the very bottom. # +# If `client` is not set under `[global.well_known]`, the server name will +# be used as the base domain for user-facing links (such as password +# reset links) created by Continuwuity. +# # Examples of delegation: # - https://continuwuity.org/.well-known/matrix/server # - https://continuwuity.org/.well-known/matrix/client @@ -1807,6 +1811,11 @@ # #config_reload_signal = true +# Allow search engines and crawlers to index Continuwuity's built-in +# webpages served under the `/_continuwuity/` prefix. +# +#allow_web_indexing = false + [global.tls] # Path to a valid TLS certificate file. diff --git a/flake.lock b/flake.lock index 6fe13570..7e14ef30 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "advisory-db": { "flake": false, "locked": { - "lastModified": 1772776993, - "narHash": "sha256-CpBa+UpogN0Xn1gMmgqQrzKGee+E8TCkgHar8/w6CRk=", + "lastModified": 1773786698, + "narHash": "sha256-o/J7ZculgwSs1L4H4UFlFZENOXTJzq1X0n71x6oNNvY=", "owner": "rustsec", "repo": "advisory-db", - "rev": "b3472341e37cbd4b8c27b052b2abb34792f4d3c4", + "rev": "99e9de91bb8b61f06ef234ff84e11f758ecd5384", "type": "github" }, "original": { @@ -18,11 +18,11 @@ }, "crane": { "locked": { - "lastModified": 1772560058, - "narHash": "sha256-NuVKdMBJldwUXgghYpzIWJdfeB7ccsu1CC7B+NfSoZ8=", + "lastModified": 1773189535, + "narHash": "sha256-E1G/Or6MWeP+L6mpQ0iTFLpzSzlpGrITfU2220Gq47g=", "owner": "ipetkov", "repo": "crane", - "rev": "db590d9286ed5ce22017541e36132eab4e8b3045", + "rev": "6fa2fb4cf4a89ba49fc9dd5a3eb6cde99d388269", "type": "github" }, "original": { @@ -39,11 +39,11 @@ "rust-analyzer-src": "rust-analyzer-src" }, "locked": { - "lastModified": 1772953398, - "narHash": "sha256-fTTHCaEvPLzWyZFxPud/G9HM3pNYmW/64Kj58hdH4+k=", + "lastModified": 1773732206, + "narHash": "sha256-HKibxaUXyWd4Hs+ZUnwo6XslvaFqFqJh66uL9tphU4Q=", "owner": "nix-community", "repo": "fenix", - "rev": "fc4863887d98fd879cf5f11af1d23d44d9bdd8ae", + "rev": "0aa13c1b54063a8d8679b28a5cd357ba98f4a56b", "type": "github" }, "original": { @@ -89,11 +89,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1772773019, - "narHash": "sha256-E1bxHxNKfDoQUuvriG71+f+s/NT0qWkImXsYZNFFfCs=", + "lastModified": 1773734432, + "narHash": "sha256-IF5ppUWh6gHGHYDbtVUyhwy/i7D261P7fWD1bPefOsw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "aca4d95fce4914b3892661bcb80b8087293536c6", + "rev": "cda48547b432e8d3b18b4180ba07473762ec8558", "type": "github" }, "original": { @@ -132,11 +132,11 @@ "rust-analyzer-src": { "flake": false, "locked": { - "lastModified": 1772877513, - "narHash": "sha256-RcRGv2Bng5I9y75XwFX7oK2l6mLH1dtbTTG9U8qun0c=", + "lastModified": 1773697963, + "narHash": "sha256-xdKI77It9PM6eNrCcDZsnP4SKulZwk8VkDgBRVMnCb8=", "owner": "rust-lang", "repo": "rust-analyzer", - "rev": "a1b86d600f88be98643e5dd61d6ed26eda17c09e", + "rev": "2993637174252ff60a582fd1f55b9ab52c39db6d", "type": "github" }, "original": { @@ -153,11 +153,11 @@ ] }, "locked": { - "lastModified": 1772660329, - "narHash": "sha256-IjU1FxYqm+VDe5qIOxoW+pISBlGvVApRjiw/Y/ttJzY=", + "lastModified": 1773297127, + "narHash": "sha256-6E/yhXP7Oy/NbXtf1ktzmU8SdVqJQ09HC/48ebEGBpk=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "3710e0e1218041bbad640352a0440114b1e10428", + "rev": "71b125cd05fbfd78cab3e070b73544abe24c5016", "type": "github" }, "original": { diff --git a/package-lock.json b/package-lock.json index 82a8fc54..a7983e00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,21 +16,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", "dev": true, "license": "MIT", "optional": true, @@ -39,9 +39,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "dev": true, "license": "MIT", "optional": true, @@ -144,17 +144,22 @@ } }, "node_modules/@rsbuild/plugin-react": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@rsbuild/plugin-react/-/plugin-react-1.4.5.tgz", - "integrity": "sha512-eS2sXCedgGA/7bLu8yVtn48eE/GyPbXx4Q7OcutB01IQ1D2y8WSMBys4nwfrecy19utvw4NPn4gYDy52316+vg==", + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@rsbuild/plugin-react/-/plugin-react-1.4.6.tgz", + "integrity": "sha512-LAT6xHlEyZKA0VjF/ph5d50iyG+WSmBx+7g98HNZUwb94VeeTMZFB8qVptTkbIRMss3BNKOXmHOu71Lhsh9oEw==", "dev": true, "license": "MIT", "dependencies": { - "@rspack/plugin-react-refresh": "^1.6.0", + "@rspack/plugin-react-refresh": "^1.6.1", "react-refresh": "^0.18.0" }, "peerDependencies": { "@rsbuild/core": "^1.0.0 || ^2.0.0-0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + } } }, "node_modules/@rspack/binding": { @@ -695,13 +700,13 @@ "license": "ISC" }, "node_modules/@unhead/react": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/@unhead/react/-/react-2.1.10.tgz", - "integrity": "sha512-z9IzzkaCI1GyiBwVRMt4dGc2mOvsj9drbAdXGMy6DWpu9FwTR37ZTmAi7UeCVyIkpVdIaNalz7vkbvGG8afFng==", + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@unhead/react/-/react-2.1.12.tgz", + "integrity": "sha512-1xXFrxyw29f+kScXfEb0GxjlgtnHxoYau0qpW9k8sgWhQUNnE5gNaH3u+rNhd5IqhyvbdDRJpQ25zoz0HIyGaw==", "dev": true, "license": "MIT", "dependencies": { - "unhead": "2.1.10" + "unhead": "2.1.12" }, "funding": { "url": "https://github.com/sponsors/harlan-zw" @@ -1573,9 +1578,9 @@ } }, "node_modules/hookable": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.0.1.tgz", - "integrity": "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.0.tgz", + "integrity": "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==", "dev": true, "license": "MIT" }, @@ -2978,14 +2983,14 @@ "license": "MIT" }, "node_modules/oniguruma-to-es": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", - "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", + "integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==", "dev": true, "license": "MIT", "dependencies": { "oniguruma-parser": "^0.12.1", - "regex": "^6.0.1", + "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, @@ -3720,9 +3725,9 @@ } }, "node_modules/unhead": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.10.tgz", - "integrity": "sha512-We8l9uNF8zz6U8lfQaVG70+R/QBfQx1oPIgXin4BtZnK2IQpz6yazQ0qjMNVBDw2ADgF2ea58BtvSK+XX5AS7g==", + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.12.tgz", + "integrity": "sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/admin/user/commands.rs b/src/admin/user/commands.rs index 17ea503f..9950b1ce 100644 --- a/src/admin/user/commands.rs +++ b/src/admin/user/commands.rs @@ -296,6 +296,31 @@ pub(super) async fn reset_password( Ok(()) } +#[admin_command] +pub(super) async fn issue_password_reset_link(&self, username: String) -> Result { + use conduwuit_service::password_reset::{PASSWORD_RESET_PATH, RESET_TOKEN_QUERY_PARAM}; + + self.bail_restricted()?; + + let mut reset_url = self + .services + .config + .get_client_domain() + .join(PASSWORD_RESET_PATH) + .unwrap(); + + let user_id = parse_local_user_id(self.services, &username)?; + let token = self.services.password_reset.issue_token(user_id).await?; + reset_url + .query_pairs_mut() + .append_pair(RESET_TOKEN_QUERY_PARAM, &token.token); + + self.write_str(&format!("Password reset link issued for {username}: {reset_url}")) + .await?; + + Ok(()) +} + #[admin_command] pub(super) async fn deactivate_all(&self, no_leave_rooms: bool, force: bool) -> Result { if self.body.len() < 2 diff --git a/src/admin/user/mod.rs b/src/admin/user/mod.rs index 9bdbf396..f4b26765 100644 --- a/src/admin/user/mod.rs +++ b/src/admin/user/mod.rs @@ -29,6 +29,12 @@ pub enum UserCommand { password: Option, }, + /// Issue a self-service password reset link for a user. + IssuePasswordResetLink { + /// Username of the user who may use the link + username: String, + }, + /// Deactivate a user /// /// User will be removed from all rooms by default. diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 1239b805..c2a55fbc 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -68,6 +68,10 @@ pub struct Config { /// /// Also see the `[global.well_known]` config section at the very bottom. /// + /// If `client` is not set under `[global.well_known]`, the server name will + /// be used as the base domain for user-facing links (such as password + /// reset links) created by Continuwuity. + /// /// Examples of delegation: /// - https://continuwuity.org/.well-known/matrix/server /// - https://continuwuity.org/.well-known/matrix/client @@ -2105,6 +2109,13 @@ pub struct Config { #[serde(default)] pub force_disable_first_run_mode: bool, + /// Allow search engines and crawlers to index Continuwuity's built-in + /// webpages served under the `/_continuwuity/` prefix. + /// + /// default: false + #[serde(default)] + pub allow_web_indexing: bool, + /// display: nested #[serde(default)] pub ldap: LdapConfig, diff --git a/src/core/matrix/state_res/event_auth.rs b/src/core/matrix/state_res/event_auth.rs index 24f03162..60e40d4f 100644 --- a/src/core/matrix/state_res/event_auth.rs +++ b/src/core/matrix/state_res/event_auth.rs @@ -1224,6 +1224,7 @@ fn can_send_event(event: &impl Event, ple: Option<&impl Event>, user_level: Int) } /// Confirm that the event sender has the required power levels. +#[allow(clippy::cognitive_complexity)] fn check_power_levels( room_version: &RoomVersion, power_event: &impl Event, diff --git a/src/core/matrix/state_res/mod.rs b/src/core/matrix/state_res/mod.rs index 8370bc65..bb6a274f 100644 --- a/src/core/matrix/state_res/mod.rs +++ b/src/core/matrix/state_res/mod.rs @@ -75,6 +75,7 @@ type Result = crate::Result; /// event is part of the same room. //#[tracing::instrument(level = "debug", skip(state_sets, auth_chain_sets, //#[tracing::instrument(level event_fetch))] +#[allow(clippy::cognitive_complexity)] pub async fn resolve<'a, Pdu, Sets, SetIter, Hasher, Fetch, FetchFut, Exists, ExistsFut>( room_version: &RoomVersionId, state_sets: Sets, diff --git a/src/database/maps.rs b/src/database/maps.rs index 20286b33..97222523 100644 --- a/src/database/maps.rs +++ b/src/database/maps.rs @@ -112,6 +112,10 @@ pub(super) static MAPS: &[Descriptor] = &[ name: "onetimekeyid_onetimekeys", ..descriptor::RANDOM_SMALL }, + Descriptor { + name: "passwordresettoken_info", + ..descriptor::RANDOM_SMALL + }, Descriptor { name: "pduid_pdu", cache_disp: CacheDisp::SharedWith("eventid_outlierpdu"), diff --git a/src/router/router.rs b/src/router/router.rs index fdaf9126..766a1931 100644 --- a/src/router/router.rs +++ b/src/router/router.rs @@ -18,5 +18,5 @@ pub(crate) fn build(services: &Arc) -> (Router, Guard) { } async fn not_found(_uri: Uri) -> impl IntoResponse { - Error::Request(ErrorKind::Unrecognized, "Not Found".into(), StatusCode::NOT_FOUND) + Error::Request(ErrorKind::Unrecognized, "not found :(".into(), StatusCode::NOT_FOUND) } diff --git a/src/service/Cargo.toml b/src/service/Cargo.toml index 8591496b..4d164e1b 100644 --- a/src/service/Cargo.toml +++ b/src/service/Cargo.toml @@ -121,7 +121,7 @@ webpage.workspace = true webpage.optional = true blurhash.workspace = true blurhash.optional = true -recaptcha-verify = { version = "0.1.5", default-features = false } +recaptcha-verify = { version = "0.2.0", default-features = false } yansi.workspace = true [target.'cfg(all(unix, target_os = "linux"))'.dependencies] diff --git a/src/service/config/mod.rs b/src/service/config/mod.rs index a8ae8c11..07401845 100644 --- a/src/service/config/mod.rs +++ b/src/service/config/mod.rs @@ -6,6 +6,7 @@ use conduwuit::{ config::{Config, check}, error, implement, }; +use url::Url; use crate::registration_tokens::{ValidToken, ValidTokenSource}; @@ -23,6 +24,18 @@ impl Service { .clone() .map(|token| ValidToken { token, source: ValidTokenSource::Config }) } + + /// Get the base domain to use for user-facing URLs. + #[must_use] + pub fn get_client_domain(&self) -> Url { + self.well_known.client.clone().unwrap_or_else(|| { + let host = self.server_name.host(); + format!("https://{host}") + .as_str() + .try_into() + .expect("server name should be a valid host") + }) + } } #[async_trait] diff --git a/src/service/mod.rs b/src/service/mod.rs index 6df6e539..5480b838 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -23,6 +23,7 @@ pub mod globals; pub mod key_backups; pub mod media; pub mod moderation; +pub mod password_reset; pub mod presence; pub mod pusher; pub mod registration_tokens; diff --git a/src/service/password_reset/data.rs b/src/service/password_reset/data.rs new file mode 100644 index 00000000..db29328a --- /dev/null +++ b/src/service/password_reset/data.rs @@ -0,0 +1,68 @@ +use std::{ + sync::Arc, + time::{Duration, SystemTime}, +}; + +use conduwuit::utils::{ReadyExt, stream::TryExpect}; +use database::{Database, Deserialized, Json, Map}; +use ruma::{OwnedUserId, UserId}; +use serde::{Deserialize, Serialize}; + +pub(super) struct Data { + passwordresettoken_info: Arc, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ResetTokenInfo { + pub user: OwnedUserId, + pub issued_at: SystemTime, +} + +impl ResetTokenInfo { + // one hour + const MAX_TOKEN_AGE: Duration = Duration::from_secs(60 * 60); + + pub fn is_valid(&self) -> bool { + let now = SystemTime::now(); + + now.duration_since(self.issued_at) + .is_ok_and(|duration| duration < Self::MAX_TOKEN_AGE) + } +} + +impl Data { + pub(super) fn new(db: &Arc) -> Self { + Self { + passwordresettoken_info: db["passwordresettoken_info"].clone(), + } + } + + /// Associate a reset token with its info in the database. + pub(super) fn save_token(&self, token: &str, info: &ResetTokenInfo) { + self.passwordresettoken_info.raw_put(token, Json(info)); + } + + /// Lookup the info for a reset token. + pub(super) async fn lookup_token_info(&self, token: &str) -> Option { + self.passwordresettoken_info + .get(token) + .await + .deserialized() + .ok() + } + + /// Find a user's existing reset token, if any. + pub(super) async fn find_token_for_user( + &self, + user: &UserId, + ) -> Option<(String, ResetTokenInfo)> { + self.passwordresettoken_info + .stream::<'_, String, ResetTokenInfo>() + .expect_ok() + .ready_find(|(_, info)| info.user == user) + .await + } + + /// Remove a reset token. + pub(super) fn remove_token(&self, token: &str) { self.passwordresettoken_info.remove(token); } +} diff --git a/src/service/password_reset/mod.rs b/src/service/password_reset/mod.rs new file mode 100644 index 00000000..d19826dd --- /dev/null +++ b/src/service/password_reset/mod.rs @@ -0,0 +1,120 @@ +mod data; + +use std::{sync::Arc, time::SystemTime}; + +use conduwuit::{Err, Result, utils}; +use data::{Data, ResetTokenInfo}; +use ruma::OwnedUserId; + +use crate::{Dep, globals, users}; + +pub const PASSWORD_RESET_PATH: &str = "/_continuwuity/account/reset_password"; +pub const RESET_TOKEN_QUERY_PARAM: &str = "token"; +const RESET_TOKEN_LENGTH: usize = 32; + +pub struct Service { + db: Data, + services: Services, +} + +struct Services { + users: Dep, + globals: Dep, +} + +#[derive(Debug)] +pub struct ValidResetToken { + pub token: String, + pub info: ResetTokenInfo, +} + +impl crate::Service for Service { + fn build(args: crate::Args<'_>) -> Result> { + Ok(Arc::new(Self { + db: Data::new(args.db), + services: Services { + users: args.depend::("users"), + globals: args.depend::("globals"), + }, + })) + } + + fn name(&self) -> &str { crate::service::make_name(std::module_path!()) } +} + +impl Service { + /// Generate a random string suitable to be used as a password reset token. + #[must_use] + pub fn generate_token_string() -> String { utils::random_string(RESET_TOKEN_LENGTH) } + + /// Issue a password reset token for `user`, who must be a local user with + /// the `password` origin. + pub async fn issue_token(&self, user_id: OwnedUserId) -> Result { + if !self.services.globals.user_is_local(&user_id) { + return Err!("Cannot issue a password reset token for remote user {user_id}"); + } + + if user_id == self.services.globals.server_user { + return Err!("Cannot issue a password reset token for the server user"); + } + + if self + .services + .users + .origin(&user_id) + .await + .unwrap_or_else(|_| "password".to_owned()) + != "password" + { + return Err!("Cannot issue a password reset token for non-internal user {user_id}"); + } + + if self.services.users.is_deactivated(&user_id).await? { + return Err!("Cannot issue a password reset token for deactivated user {user_id}"); + } + + if let Some((existing_token, _)) = self.db.find_token_for_user(&user_id).await { + self.db.remove_token(&existing_token); + } + + let token = Self::generate_token_string(); + let info = ResetTokenInfo { + user: user_id, + issued_at: SystemTime::now(), + }; + + self.db.save_token(&token, &info); + + Ok(ValidResetToken { token, info }) + } + + /// Check if `token` represents a valid, non-expired password reset token. + pub async fn check_token(&self, token: &str) -> Option { + self.db.lookup_token_info(token).await.and_then(|info| { + if info.is_valid() { + Some(ValidResetToken { token: token.to_owned(), info }) + } else { + self.db.remove_token(token); + None + } + }) + } + + /// Consume the supplied valid token, using it to change its user's password + /// to `new_password`. + pub async fn consume_token( + &self, + ValidResetToken { token, info }: ValidResetToken, + new_password: &str, + ) -> Result<()> { + if info.is_valid() { + self.db.remove_token(&token); + self.services + .users + .set_password(&info.user, Some(new_password)) + .await?; + } + + Ok(()) + } +} diff --git a/src/service/services.rs b/src/service/services.rs index 6356c6ea..004c3e33 100644 --- a/src/service/services.rs +++ b/src/service/services.rs @@ -11,8 +11,8 @@ use crate::{ account_data, admin, announcements, antispam, appservice, client, config, emergency, federation, firstrun, globals, key_backups, manager::Manager, - media, moderation, presence, pusher, registration_tokens, resolver, rooms, sending, - server_keys, + media, moderation, password_reset, presence, pusher, registration_tokens, resolver, rooms, + sending, server_keys, service::{self, Args, Map, Service}, sync, transactions, uiaa, users, }; @@ -27,6 +27,7 @@ pub struct Services { pub globals: Arc, pub key_backups: Arc, pub media: Arc, + pub password_reset: Arc, pub presence: Arc, pub pusher: Arc, pub registration_tokens: Arc, @@ -81,6 +82,7 @@ impl Services { globals: build!(globals::Service), key_backups: build!(key_backups::Service), media: build!(media::Service), + password_reset: build!(password_reset::Service), presence: build!(presence::Service), pusher: build!(pusher::Service), registration_tokens: build!(registration_tokens::Service), diff --git a/src/service/uiaa/mod.rs b/src/service/uiaa/mod.rs index 7c15a919..4a224675 100644 --- a/src/service/uiaa/mod.rs +++ b/src/service/uiaa/mod.rs @@ -181,20 +181,11 @@ pub async fn try_auth( uiaainfo.completed.push(AuthType::Password); }, | AuthData::ReCaptcha(r) => { - if self.services.config.recaptcha_private_site_key.is_none() { + let Some(ref private_site_key) = self.services.config.recaptcha_private_site_key + else { return Err!(Request(Forbidden("ReCaptcha is not configured."))); - } - match recaptcha_verify::verify( - self.services - .config - .recaptcha_private_site_key - .as_ref() - .unwrap(), - r.response.as_str(), - None, - ) - .await - { + }; + match recaptcha_verify::verify_v3(private_site_key, r.response.as_str(), None).await { | Ok(()) => { uiaainfo.completed.push(AuthType::ReCaptcha); }, diff --git a/src/web/Cargo.toml b/src/web/Cargo.toml index 84de46da..fa3243f4 100644 --- a/src/web/Cargo.toml +++ b/src/web/Cargo.toml @@ -20,12 +20,25 @@ crate-type = [ [dependencies] conduwuit-build-metadata.workspace = true conduwuit-service.workspace = true +conduwuit-core.workspace = true +async-trait.workspace = true askama.workspace = true axum.workspace = true +axum-extra.workspace = true +base64.workspace = true futures.workspace = true tracing.workspace = true rand.workspace = true +ruma.workspace = true thiserror.workspace = true +tower-http.workspace = true +serde.workspace = true +memory-serve = "2.1.0" +validator = { version = "0.20.0", features = ["derive"] } +tower-sec-fetch = { version = "0.1.2", features = ["tracing"] } + +[build-dependencies] +memory-serve = "2.1.0" [lints] workspace = true diff --git a/src/web/askama.toml b/src/web/askama.toml new file mode 100644 index 00000000..56cb707e --- /dev/null +++ b/src/web/askama.toml @@ -0,0 +1,2 @@ +[general] +dirs = ["pages/templates"] diff --git a/src/web/build.rs b/src/web/build.rs new file mode 100644 index 00000000..bd97b8d5 --- /dev/null +++ b/src/web/build.rs @@ -0,0 +1 @@ +fn main() { memory_serve::load_directory("./pages/resources"); } diff --git a/src/web/css/index.css b/src/web/css/index.css deleted file mode 100644 index 79ac7c2a..00000000 --- a/src/web/css/index.css +++ /dev/null @@ -1,94 +0,0 @@ -:root { - color-scheme: light; - --font-stack: sans-serif; - - --background-color: #fff; - --text-color: #000; - - --bg: oklch(0.76 0.0854 317.27); - --panel-bg: oklch(0.91 0.042 317.27); - - --name-lightness: 0.45; - - @media (prefers-color-scheme: dark) { - color-scheme: dark; - --text-color: #fff; - --bg: oklch(0.15 0.042 317.27); - --panel-bg: oklch(0.24 0.03 317.27); - - --name-lightness: 0.8; - } - - --c1: oklch(0.44 0.177 353.06); - --c2: oklch(0.59 0.158 150.88); - - --normal-font-size: 1rem; - --small-font-size: 0.8rem; -} - -body { - color: var(--text-color); - font-family: var(--font-stack); - margin: 0; - padding: 0; - display: grid; - place-items: center; - min-height: 100vh; -} - -html { - background-color: var(--bg); - background-image: linear-gradient( - 70deg, - oklch(from var(--bg) l + 0.2 c h), - oklch(from var(--bg) l - 0.2 c h) - ); - font-size: 16px; -} - -.panel { - width: min(clamp(24rem, 12rem + 40vw, 48rem), calc(100vw - 3rem)); - border-radius: 15px; - background-color: var(--panel-bg); - padding-inline: 1.5rem; - padding-block: 1rem; - box-shadow: 0 0.25em 0.375em hsla(0, 0%, 0%, 0.1); -} - -@media (max-width: 24rem) { - .panel { - padding-inline: 0.25rem; - width: calc(100vw - 0.5rem); - border-radius: 0; - margin-block-start: 0.2rem; - } - main { - height: 100%; - } -} - -footer { - padding-inline: 0.25rem; - height: max(fit-content, 2rem); -} - -.project-name { - text-decoration: none; - background: linear-gradient( - 130deg, - oklch(from var(--c1) var(--name-lightness) c h), - oklch(from var(--c2) var(--name-lightness) c h) - ); - background-clip: text; - color: transparent; - filter: brightness(1.2); -} - -b { - color: oklch(from var(--c2) var(--name-lightness) c h); -} - -.logo { - width: 100%; - height: 64px; -} diff --git a/src/web/mod.rs b/src/web/mod.rs index 25da1139..c5932a13 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,86 +1,113 @@ +use std::any::Any; + use askama::Template; use axum::{ Router, - extract::State, - http::{StatusCode, header}, + extract::rejection::{FormRejection, QueryRejection}, + http::{HeaderValue, StatusCode, header}, response::{Html, IntoResponse, Response}, - routing::get, }; -use conduwuit_build_metadata::{GIT_REMOTE_COMMIT_URL, GIT_REMOTE_WEB_URL, version_tag}; use conduwuit_service::state; +use tower_http::{catch_panic::CatchPanicLayer, set_header::SetResponseHeaderLayer}; +use tower_sec_fetch::SecFetchLayer; -pub fn build() -> Router { - Router::::new() - .route("/", get(index_handler)) - .route("/_continuwuity/logo.svg", get(logo_handler)) -} +use crate::pages::TemplateContext; -async fn index_handler( - State(services): State, -) -> Result { - #[derive(Debug, Template)] - #[template(path = "index.html.j2")] - struct Index<'a> { - nonce: &'a str, - server_name: &'a str, - first_run: bool, - } - let nonce = rand::random::().to_string(); +mod pages; - let template = Index { - nonce: &nonce, - server_name: services.config.server_name.as_str(), - first_run: services.firstrun.is_first_run(), - }; - Ok(( - [( - header::CONTENT_SECURITY_POLICY, - format!("default-src 'nonce-{nonce}'; img-src 'self';"), - )], - Html(template.render()?), - )) -} +type State = state::State; -async fn logo_handler() -> impl IntoResponse { - ( - [(header::CONTENT_TYPE, "image/svg+xml")], - include_str!("templates/logo.svg").to_owned(), - ) -} +const CATASTROPHIC_FAILURE: &str = "cat-astrophic failure! we couldn't even render the error template. \ +please contact the team @ https://continuwuity.org"; #[derive(Debug, thiserror::Error)] enum WebError { + #[error("Failed to validate form body: {0}")] + ValidationError(#[from] validator::ValidationErrors), + #[error("{0}")] + QueryRejection(#[from] QueryRejection), + #[error("{0}")] + FormRejection(#[from] FormRejection), + #[error("{0}")] + BadRequest(String), + + #[error("This page does not exist.")] + NotFound, + #[error("Failed to render template: {0}")] Render(#[from] askama::Error), + #[error("{0}")] + InternalError(#[from] conduwuit_core::Error), + #[error("Request handler panicked! {0}")] + Panic(String), } impl IntoResponse for WebError { fn into_response(self) -> Response { #[derive(Debug, Template)] #[template(path = "error.html.j2")] - struct Error<'a> { - nonce: &'a str, - err: WebError, + struct Error { + error: WebError, + status: StatusCode, + context: TemplateContext, } - let nonce = rand::random::().to_string(); - let status = match &self { - | Self::Render(_) => StatusCode::INTERNAL_SERVER_ERROR, + | Self::ValidationError(_) + | Self::BadRequest(_) + | Self::QueryRejection(_) + | Self::FormRejection(_) => StatusCode::BAD_REQUEST, + | Self::NotFound => StatusCode::NOT_FOUND, + | _ => StatusCode::INTERNAL_SERVER_ERROR, }; - let tmpl = Error { nonce: &nonce, err: self }; - if let Ok(body) = tmpl.render() { - ( - status, - [( - header::CONTENT_SECURITY_POLICY, - format!("default-src 'none' 'nonce-{nonce}';"), - )], - Html(body), - ) - .into_response() + + let template = Error { + error: self, + status, + context: TemplateContext { + // Statically set false to prevent error pages from being indexed. + allow_indexing: false, + }, + }; + + if let Ok(body) = template.render() { + (status, Html(body)).into_response() } else { - (status, "Something went wrong").into_response() + (status, CATASTROPHIC_FAILURE).into_response() } } } + +pub fn build() -> Router { + #[allow(clippy::wildcard_imports)] + use pages::*; + + Router::new() + .merge(index::build()) + .nest( + "/_continuwuity/", + Router::new() + .merge(resources::build()) + .merge(password_reset::build()) + .merge(debug::build()) + .fallback(async || WebError::NotFound), + ) + .layer(CatchPanicLayer::custom(|panic: Box| { + let details = if let Some(s) = panic.downcast_ref::() { + s.clone() + } else if let Some(s) = panic.downcast_ref::<&str>() { + (*s).to_owned() + } else { + "(opaque panic payload)".to_owned() + }; + + WebError::Panic(details).into_response() + })) + .layer(SetResponseHeaderLayer::if_not_present( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static("default-src 'self'; img-src 'self' data:;"), + )) + .layer(SecFetchLayer::new(|policy| { + policy.allow_safe_methods().reject_missing_metadata(); + })) +} diff --git a/src/web/pages/components/form.rs b/src/web/pages/components/form.rs new file mode 100644 index 00000000..625c763e --- /dev/null +++ b/src/web/pages/components/form.rs @@ -0,0 +1,98 @@ +use askama::{Template, filters::HtmlSafe}; +use validator::ValidationErrors; + +/// A reusable form component with field validation. +#[derive(Debug, Template)] +#[template(path = "_components/form.html.j2", print = "code")] +pub(crate) struct Form<'a> { + pub inputs: Vec>, + pub validation_errors: Option, + pub submit_label: &'a str, +} + +impl HtmlSafe for Form<'_> {} + +/// An input element in a form component. +#[derive(Debug, Clone, Copy)] +pub(crate) struct FormInput<'a> { + /// The field name of the input. + pub id: &'static str, + /// The `type` property of the input. + pub input_type: &'a str, + /// The contents of the input's label. + pub label: &'a str, + /// Whether the input is required. Defaults to `true`. + pub required: bool, + /// The autocomplete mode for the input. Defaults to `on`. + pub autocomplete: &'a str, + + // This is a hack to make the form! macro's support for client-only fields + // work properly. Client-only fields are specified in the macro without a type and aren't + // included in the POST body or as a field in the generated struct. + // To keep the field from being included in the POST body, its `name` property needs not to + // be set in the template. Because of limitations of macro_rules!'s repetition feature, this + // field needs to exist to allow the template to check if the field is client-only. + #[doc(hidden)] + pub type_name: Option<&'static str>, +} + +impl Default for FormInput<'_> { + fn default() -> Self { + Self { + id: "", + input_type: "text", + label: "", + required: true, + autocomplete: "", + + type_name: None, + } + } +} + +/// Generate a deserializable struct which may be turned into a [`Form`] +/// for inclusion in another template. +#[macro_export] +macro_rules! form { + ( + $(#[$struct_meta:meta])* + struct $struct_name:ident { + $( + $(#[$field_meta:meta])* + $name:ident$(: $type:ty)? where { $($prop:ident: $value:expr),* } + ),* + + submit: $submit_label:expr + } + ) => { + #[derive(Debug, serde::Deserialize, validator::Validate)] + $(#[$struct_meta])* + struct $struct_name { + $( + $(#[$field_meta])* + $(pub $name: $type,)? + )* + } + + impl $struct_name { + /// Generate a [`Form`] which matches the shape of this struct. + #[allow(clippy::needless_update)] + fn build(validation_errors: Option) -> $crate::pages::components::form::Form<'static> { + $crate::pages::components::form::Form { + inputs: vec![ + $( + $crate::pages::components::form::FormInput { + id: stringify!($name), + $(type_name: Some(stringify!($type)),)? + $($prop: $value),*, + ..Default::default() + }, + )* + ], + validation_errors, + submit_label: $submit_label, + } + } + } + }; +} diff --git a/src/web/pages/components/mod.rs b/src/web/pages/components/mod.rs new file mode 100644 index 00000000..880de538 --- /dev/null +++ b/src/web/pages/components/mod.rs @@ -0,0 +1,71 @@ +use askama::{Template, filters::HtmlSafe}; +use base64::Engine; +use conduwuit_core::result::FlatOk; +use conduwuit_service::Services; +use ruma::UserId; + +pub(super) mod form; + +#[derive(Debug)] +pub(super) enum AvatarType<'a> { + Initial(char), + Image(&'a str), +} + +#[derive(Debug, Template)] +#[template(path = "_components/avatar.html.j2")] +pub(super) struct Avatar<'a> { + pub(super) avatar_type: AvatarType<'a>, +} + +impl HtmlSafe for Avatar<'_> {} + +#[derive(Debug, Template)] +#[template(path = "_components/user_card.html.j2")] +pub(super) struct UserCard<'a> { + pub user_id: &'a UserId, + pub display_name: Option, + pub avatar_src: Option, +} + +impl HtmlSafe for UserCard<'_> {} + +impl<'a> UserCard<'a> { + pub(super) async fn for_local_user(services: &Services, user_id: &'a UserId) -> Self { + let display_name = services.users.displayname(user_id).await.ok(); + + let avatar_src = async { + let avatar_url = services.users.avatar_url(user_id).await.ok()?; + let avatar_mxc = avatar_url.parts().ok()?; + let file = services.media.get(&avatar_mxc).await.flat_ok()?; + + Some(format!( + "data:{};base64,{}", + file.content_type + .unwrap_or_else(|| "application/octet-stream".to_owned()), + file.content + .map(|content| base64::prelude::BASE64_STANDARD.encode(content)) + .unwrap_or_default(), + )) + } + .await; + + Self { user_id, display_name, avatar_src } + } + + fn avatar(&'a self) -> Avatar<'a> { + let avatar_type = if let Some(ref avatar_src) = self.avatar_src { + AvatarType::Image(avatar_src) + } else if let Some(initial) = self + .display_name + .as_ref() + .and_then(|display_name| display_name.chars().next()) + { + AvatarType::Initial(initial) + } else { + AvatarType::Initial(self.user_id.localpart().chars().next().unwrap()) + }; + + Avatar { avatar_type } + } +} diff --git a/src/web/pages/debug.rs b/src/web/pages/debug.rs new file mode 100644 index 00000000..9522480a --- /dev/null +++ b/src/web/pages/debug.rs @@ -0,0 +1,17 @@ +use std::convert::Infallible; + +use axum::{Router, routing::get}; +use conduwuit_core::Error; + +use crate::WebError; + +pub(crate) fn build() -> Router { + Router::new() + .route("/_debug/panic", get(async || -> Infallible { panic!("Guru meditation error") })) + .route( + "/_debug/error", + get(async || -> WebError { + Error::Err(std::borrow::Cow::Borrowed("Guru meditation error")).into() + }), + ) +} diff --git a/src/web/pages/index.rs b/src/web/pages/index.rs new file mode 100644 index 00000000..6070dac2 --- /dev/null +++ b/src/web/pages/index.rs @@ -0,0 +1,28 @@ +use askama::Template; +use axum::{Router, extract::State, response::IntoResponse, routing::get}; + +use crate::{WebError, template}; + +pub(crate) fn build() -> Router { + Router::new() + .route("/", get(index_handler)) + .route("/_continuwuity/", get(index_handler)) +} + +async fn index_handler( + State(services): State, +) -> Result { + template! { + struct Index<'a> use "index.html.j2" { + server_name: &'a str, + first_run: bool + } + } + + Ok(Index::new( + &services, + services.globals.server_name().as_str(), + services.firstrun.is_first_run(), + ) + .into_response()) +} diff --git a/src/web/pages/mod.rs b/src/web/pages/mod.rs new file mode 100644 index 00000000..58977fd0 --- /dev/null +++ b/src/web/pages/mod.rs @@ -0,0 +1,53 @@ +mod components; +pub(super) mod debug; +pub(super) mod index; +pub(super) mod password_reset; +pub(super) mod resources; + +#[derive(Debug)] +pub(crate) struct TemplateContext { + pub allow_indexing: bool, +} + +impl From<&crate::State> for TemplateContext { + fn from(state: &crate::State) -> Self { + Self { + allow_indexing: state.config.allow_web_indexing, + } + } +} + +#[macro_export] +macro_rules! template { + ( + struct $name:ident $(<$lifetime:lifetime>)? use $path:literal { + $($field_name:ident: $field_type:ty),* + } + ) => { + #[derive(Debug, askama::Template)] + #[template(path = $path)] + struct $name$(<$lifetime>)? { + context: $crate::pages::TemplateContext, + $($field_name: $field_type,)* + } + + impl$(<$lifetime>)? $name$(<$lifetime>)? { + fn new(state: &$crate::State, $($field_name: $field_type,)*) -> Self { + Self { + context: state.into(), + $($field_name,)* + } + } + } + + #[allow(single_use_lifetimes)] + impl$(<$lifetime>)? axum::response::IntoResponse for $name$(<$lifetime>)? { + fn into_response(self) -> axum::response::Response { + match self.render() { + Ok(rendered) => axum::response::Html(rendered).into_response(), + Err(err) => $crate::WebError::from(err).into_response() + } + } + } + }; +} diff --git a/src/web/pages/password_reset.rs b/src/web/pages/password_reset.rs new file mode 100644 index 00000000..1c33f0f8 --- /dev/null +++ b/src/web/pages/password_reset.rs @@ -0,0 +1,120 @@ +use askama::Template; +use axum::{ + Router, + extract::{ + Query, State, + rejection::{FormRejection, QueryRejection}, + }, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, +}; +use serde::Deserialize; +use validator::Validate; + +use crate::{ + WebError, form, + pages::components::{UserCard, form::Form}, + template, +}; + +const INVALID_TOKEN_ERROR: &str = "Invalid reset token. Your reset link may have expired."; + +#[derive(Deserialize)] +struct PasswordResetQuery { + token: String, +} + +template! { + struct PasswordReset<'a> use "password_reset.html.j2" { + user_card: UserCard<'a>, + body: PasswordResetBody + } +} + +#[derive(Debug)] +enum PasswordResetBody { + Form(Form<'static>), + Success, +} + +form! { + struct PasswordResetForm { + #[validate(length(min = 1, message = "Password cannot be empty"))] + new_password: String where { + input_type: "password", + label: "New password", + autocomplete: "new-password" + }, + + #[validate(must_match(other = "new_password", message = "Passwords must match"))] + confirm_new_password: String where { + input_type: "password", + label: "Confirm new password", + autocomplete: "new-password" + } + + submit: "Reset Password" + } +} + +pub(crate) fn build() -> Router { + Router::new() + .route("/account/reset_password", get(get_password_reset).post(post_password_reset)) +} + +async fn password_reset_form( + services: crate::State, + query: PasswordResetQuery, + reset_form: Form<'static>, +) -> Result { + let Some(token) = services.password_reset.check_token(&query.token).await else { + return Err(WebError::BadRequest(INVALID_TOKEN_ERROR.to_owned())); + }; + + let user_card = UserCard::for_local_user(&services, &token.info.user).await; + + Ok(PasswordReset::new(&services, user_card, PasswordResetBody::Form(reset_form)) + .into_response()) +} + +async fn get_password_reset( + State(services): State, + query: Result, QueryRejection>, +) -> Result { + let Query(query) = query?; + + password_reset_form(services, query, PasswordResetForm::build(None)).await +} + +async fn post_password_reset( + State(services): State, + query: Result, QueryRejection>, + form: Result, FormRejection>, +) -> Result { + let Query(query) = query?; + let axum::Form(form) = form?; + + match form.validate() { + | Ok(()) => { + let Some(token) = services.password_reset.check_token(&query.token).await else { + return Err(WebError::BadRequest(INVALID_TOKEN_ERROR.to_owned())); + }; + let user_id = token.info.user.clone(); + + services + .password_reset + .consume_token(token, &form.new_password) + .await?; + + let user_card = UserCard::for_local_user(&services, &user_id).await; + Ok(PasswordReset::new(&services, user_card, PasswordResetBody::Success) + .into_response()) + }, + | Err(err) => Ok(( + StatusCode::BAD_REQUEST, + password_reset_form(services, query, PasswordResetForm::build(Some(err))).await, + ) + .into_response()), + } +} diff --git a/src/web/pages/resources.rs b/src/web/pages/resources.rs new file mode 100644 index 00000000..e959dcfe --- /dev/null +++ b/src/web/pages/resources.rs @@ -0,0 +1,9 @@ +use axum::Router; + +pub(crate) fn build() -> Router { + Router::new().nest( + "/resources/", + #[allow(unused_qualifications)] + memory_serve::load!().index_file(None).into_router(), + ) +} diff --git a/src/web/pages/resources/common.css b/src/web/pages/resources/common.css new file mode 100644 index 00000000..a2e9989d --- /dev/null +++ b/src/web/pages/resources/common.css @@ -0,0 +1,185 @@ +:root { + color-scheme: light; + --font-stack: sans-serif; + + --background-color: #fff; + --text-color: #000; + --secondary: #666; + --bg: oklch(0.76 0.0854 317.27); + --panel-bg: oklch(0.91 0.042 317.27); + --c1: oklch(0.44 0.177 353.06); + --c2: oklch(0.59 0.158 150.88); + + --name-lightness: 0.45; + --background-lightness: 0.9; + + --background-gradient: + radial-gradient(42.12% 56.13% at 100% 0%, oklch(from var(--c2) var(--background-lightness) c h) 0%, #fff0 100%), + radial-gradient(42.01% 79.63% at 52.86% 0%, #d9ff5333 0%, #fff0 100%), + radial-gradient(79.67% 58.09% at 0% 0%, oklch(from var(--c1) var(--background-lightness) c h) 0%, #fff0 100%); + + --normal-font-size: 1rem; + --small-font-size: 0.8rem; + + --border-radius-sm: 5px; + --border-radius-lg: 15px; + + @media (prefers-color-scheme: dark) { + color-scheme: dark; + --text-color: #fff; + --secondary: #888; + --bg: oklch(0.15 0.042 317.27); + --panel-bg: oklch(0.24 0.03 317.27); + + --name-lightness: 0.8; + --background-lightness: 0.2; + + --background-gradient: + radial-gradient( + 42.12% 56.13% at 100% 0%, + oklch(from var(--c2) var(--background-lightness) c h) 0%, + #12121200 100% + ), + radial-gradient(55.81% 87.78% at 48.37% 0%, #000 0%, #12121200 89.55%), + radial-gradient( + 122.65% 88.24% at 0% 0%, + oklch(from var(--c1) var(--background-lightness) c h) 0%, + #12121200 100% + ); + } +} + +* { + box-sizing: border-box; +} + +body { + display: grid; + margin: 0; + padding: 0; + place-items: center; + min-height: 100vh; + + color: var(--text-color); + font-family: var(--font-stack); + line-height: 1.5; +} + +html { + background-color: var(--bg); + background-image: var(--background-gradient); + font-size: var(--normal-font-size); +} + +footer { + padding-inline: 0.25rem; + height: max(fit-content, 2rem); + + .logo { + width: 100%; + height: 64px; + } +} + +p { + margin: 1rem 0; +} + +em { + color: oklch(from var(--c2) var(--name-lightness) c h); + font-weight: bold; + font-style: normal; +} + +small { + color: var(--secondary); +} + +small.error { + display: block; + color: red; + font-size: small; + font-style: italic; + margin-bottom: 0.5rem; +} + +.panel { + --preferred-width: 12rem + 40dvw; + --maximum-width: 48rem; + + width: min(clamp(24rem, var(--preferred-width), var(--maximum-width)), calc(100dvw - 3rem)); + border-radius: var(--border-radius-lg); + background-color: var(--panel-bg); + padding-inline: 1.5rem; + padding-block: 1rem; + box-shadow: 0 0.25em 0.375em hsla(0, 0%, 0%, 0.1); + + &.narrow { + --preferred-width: 12rem + 20dvw; + --maximum-width: 36rem; + + input, button { + width: 100%; + } + } +} + +label { + display: block; +} + +input, button { + display: inline-block; + padding: 0.5em; + margin-bottom: 0.5em; + + font-size: inherit; + font-family: inherit; + color: white; + background-color: transparent; + + border: none; + border-radius: var(--border-radius-sm); +} + +input { + border: 2px solid var(--secondary); + + &:focus-visible { + outline: 2px solid var(--c1); + border-color: transparent; + } +} + +button { + background-color: var(--c1); + transition: opacity .2s; + + &:enabled:hover { + opacity: 0.8; + cursor: pointer; + } +} + +h1 { + margin-top: 0; + margin-bottom: 0.67em; +} + +@media (max-width: 425px) { + main { + padding-block-start: 2rem; + width: 100%; + } + + .panel { + border-radius: 0; + width: 100%; + } +} + +@media (max-width: 799px) { + input, button { + width: 100%; + } +} diff --git a/src/web/pages/resources/components.css b/src/web/pages/resources/components.css new file mode 100644 index 00000000..11c68b03 --- /dev/null +++ b/src/web/pages/resources/components.css @@ -0,0 +1,44 @@ +.avatar { + --avatar-size: 56px; + + display: inline-block; + aspect-ratio: 1 / 1; + inline-size: var(--avatar-size); + border-radius: 50%; + + text-align: center; + text-transform: uppercase; + font-size: calc(var(--avatar-size) * 0.5); + font-weight: 700; + line-height: calc(var(--avatar-size) - 2px); + + color: oklch(from var(--c1) calc(l + 0.2) c h); + background-color: var(--c1); +} + +.user-card { + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; + + background-color: oklch(from var(--panel-bg) calc(l - 0.05) c h); + border-radius: var(--border-radius-lg); + padding: 16px; + + .info { + flex: 1 1; + + p { + margin: 0; + + &.display-name { + font-weight: 700; + } + + &:nth-of-type(2) { + color: var(--secondary); + } + } + } +} diff --git a/src/web/pages/resources/error.css b/src/web/pages/resources/error.css new file mode 100644 index 00000000..55449795 --- /dev/null +++ b/src/web/pages/resources/error.css @@ -0,0 +1,13 @@ +.k10y { + font-family: monospace; + font-size: x-small; + font-weight: 700; + transform: translate(1rem, 1.6rem); + color: var(--secondary); + user-select: none; +} + +h1 { + display: flex; + align-items: center; +} diff --git a/src/web/pages/resources/index.css b/src/web/pages/resources/index.css new file mode 100644 index 00000000..235a20ee --- /dev/null +++ b/src/web/pages/resources/index.css @@ -0,0 +1,11 @@ +.project-name { + text-decoration: none; + background: linear-gradient( + 130deg, + oklch(from var(--c1) var(--name-lightness) c h), + oklch(from var(--c2) var(--name-lightness) c h) + ); + background-clip: text; + color: transparent; + filter: brightness(1.2); +} diff --git a/src/web/pages/resources/logo.svg b/src/web/pages/resources/logo.svg new file mode 100644 index 00000000..aca988a3 --- /dev/null +++ b/src/web/pages/resources/logo.svg @@ -0,0 +1,43 @@ + + + + diff --git a/src/web/pages/templates/_components/avatar.html.j2 b/src/web/pages/templates/_components/avatar.html.j2 new file mode 100644 index 00000000..4ed17089 --- /dev/null +++ b/src/web/pages/templates/_components/avatar.html.j2 @@ -0,0 +1,6 @@ +{% match avatar_type %} + {% when AvatarType::Initial with (initial) %} + {{ initial }} + {% when AvatarType::Image with (src) %} + +{% endmatch %} diff --git a/src/web/pages/templates/_components/form.html.j2 b/src/web/pages/templates/_components/form.html.j2 new file mode 100644 index 00000000..8aa82ba4 --- /dev/null +++ b/src/web/pages/templates/_components/form.html.j2 @@ -0,0 +1,30 @@ +
+ {% let validation_errors = validation_errors.clone().unwrap_or_default() %} + {% let field_errors = validation_errors.field_errors() %} + {% for input in inputs %} +

+ + {% let name = std::borrow::Cow::from(*input.id) %} + {% if let Some(errors) = field_errors.get(name) %} + {% for error in errors %} + + {% if let Some(message) = error.message %} + {{ message }} + {% else %} + Mysterious validation error {{ error.code }}! + {% endif %} + + {% endfor %} + {% endif %} + +

+ {% endfor %} + + +
diff --git a/src/web/pages/templates/_components/user_card.html.j2 b/src/web/pages/templates/_components/user_card.html.j2 new file mode 100644 index 00000000..ba612bdb --- /dev/null +++ b/src/web/pages/templates/_components/user_card.html.j2 @@ -0,0 +1,9 @@ +
+ {{ avatar() }} +
+ {% if let Some(display_name) = display_name %} +

{{ display_name }}

+ {% endif %} +

{{ user_id }}

+
+
diff --git a/src/web/templates/_layout.html.j2 b/src/web/pages/templates/_layout.html.j2 similarity index 51% rename from src/web/templates/_layout.html.j2 rename to src/web/pages/templates/_layout.html.j2 index fbae50b5..c79f8247 100644 --- a/src/web/templates/_layout.html.j2 +++ b/src/web/pages/templates/_layout.html.j2 @@ -5,23 +5,24 @@ {% block title %}Continuwuity{% endblock %} + {%- if !context.allow_indexing %} + + {%- endif %} - - + + + + {% block head %}{% endblock %}
{%~ block content %}{% endblock ~%}
{%~ block footer ~%}