Compare commits

..

1 commit

Author SHA1 Message Date
Renovate Bot
4d45f06a9d chore(deps): update rust crate serde_html_form to 0.4.0 2026-03-09 13:01:37 +00:00
81 changed files with 422 additions and 5726 deletions

View file

@ -62,6 +62,10 @@ 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

4
.github/FUNDING.yml vendored
View file

@ -1,4 +1,4 @@
github: [JadedBlueEyes, nexy7574, gingershaped]
custom:
- https://timedout.uk/donate.html
- https://jade.ellis.link/sponsors
- https://ko-fi.com/nexy7574
- https://ko-fi.com/JadedBlueEyes

377
Cargo.lock generated
View file

@ -94,9 +94,9 @@ dependencies = [
[[package]]
name = "anstyle"
version = "1.0.14"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anyhow"
@ -221,7 +221,7 @@ dependencies = [
"serde",
"serde_derive",
"unicode-ident",
"winnow 0.7.15",
"winnow",
]
[[package]]
@ -461,7 +461,6 @@ dependencies = [
"axum",
"axum-core",
"bytes",
"cookie",
"futures-core",
"futures-util",
"headers",
@ -751,9 +750,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.57"
version = "1.2.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
dependencies = [
"find-msvc-tools",
"jobserver",
@ -824,9 +823,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.6.0"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [
"clap_builder",
"clap_derive",
@ -834,9 +833,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.6.0"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [
"anstyle",
"clap_lex",
@ -844,9 +843,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.6.0"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
@ -856,9 +855,9 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "1.1.0"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "cmake"
@ -906,7 +905,7 @@ dependencies = [
[[package]]
name = "conduwuit"
version = "0.5.7-alpha.1"
version = "0.5.6"
dependencies = [
"clap",
"conduwuit_admin",
@ -938,7 +937,7 @@ dependencies = [
[[package]]
name = "conduwuit_admin"
version = "0.5.7-alpha.1"
version = "0.5.6"
dependencies = [
"clap",
"conduwuit_api",
@ -959,7 +958,7 @@ dependencies = [
[[package]]
name = "conduwuit_api"
version = "0.5.7-alpha.1"
version = "0.5.6"
dependencies = [
"async-trait",
"axum",
@ -991,14 +990,14 @@ dependencies = [
[[package]]
name = "conduwuit_build_metadata"
version = "0.5.7-alpha.1"
version = "0.5.6"
dependencies = [
"built",
]
[[package]]
name = "conduwuit_core"
version = "0.5.7-alpha.1"
version = "0.5.6"
dependencies = [
"argon2",
"arrayvec",
@ -1060,7 +1059,7 @@ dependencies = [
[[package]]
name = "conduwuit_database"
version = "0.5.7-alpha.1"
version = "0.5.6"
dependencies = [
"async-channel",
"conduwuit_core",
@ -1078,7 +1077,7 @@ dependencies = [
[[package]]
name = "conduwuit_macros"
version = "0.5.7-alpha.1"
version = "0.5.6"
dependencies = [
"itertools 0.14.0",
"proc-macro2",
@ -1088,7 +1087,7 @@ dependencies = [
[[package]]
name = "conduwuit_router"
version = "0.5.7-alpha.1"
version = "0.5.6"
dependencies = [
"axum",
"axum-client-ip",
@ -1122,7 +1121,7 @@ dependencies = [
[[package]]
name = "conduwuit_service"
version = "0.5.7-alpha.1"
version = "0.5.6"
dependencies = [
"askama",
"async-trait",
@ -1164,26 +1163,16 @@ dependencies = [
[[package]]
name = "conduwuit_web"
version = "0.5.7-alpha.1"
version = "0.5.6"
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]]
@ -1266,17 +1255,6 @@ 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"
@ -1529,41 +1507,6 @@ 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"
@ -2595,12 +2538,6 @@ 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"
@ -2624,9 +2561,9 @@ dependencies = [
[[package]]
name = "image"
version = "0.25.10"
version = "0.25.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [
"bytemuck",
"byteorder-lite",
@ -2642,8 +2579,8 @@ dependencies = [
"rayon",
"rgb",
"tiff",
"zune-core",
"zune-jpeg",
"zune-core 0.5.1",
"zune-jpeg 0.5.12",
]
[[package]]
@ -2929,9 +2866,9 @@ dependencies = [
[[package]]
name = "libz-sys"
version = "1.1.25"
version = "1.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1"
checksum = "4735e9cbde5aac84a5ce588f6b23a90b9b0b528f6c5a8db8a4aff300463a0839"
dependencies = [
"cc",
"pkg-config",
@ -3090,22 +3027,6 @@ 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"
@ -3122,16 +3043,6 @@ 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"
@ -3204,9 +3115,9 @@ dependencies = [
[[package]]
name = "moxcms"
version = "0.8.1"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
dependencies = [
"num-traits",
"pxfm",
@ -3567,9 +3478,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.21.4"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
dependencies = [
"critical-section",
"portable-atomic",
@ -3917,29 +3828,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"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",
"toml_edit 0.25.4+spec-1.1.0",
]
[[package]]
@ -4077,9 +3966,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.14"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.4",
@ -4232,9 +4121,9 @@ dependencies = [
[[package]]
name = "ravif"
version = "0.13.0"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45"
checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285"
dependencies = [
"avif-serialize",
"imgref",
@ -4267,9 +4156,9 @@ dependencies = [
[[package]]
name = "recaptcha-verify"
version = "0.2.0"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "409bf11a93fe93093f3c0254aab67576524f1e0524692615b5b63091dbc88a79"
checksum = "0d694033c2b0abdbb8893edfb367f16270e790be4a67e618206d811dbe4efee4"
dependencies = [
"reqwest",
"serde",
@ -4743,15 +4632,6 @@ 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"
@ -4774,9 +4654,9 @@ dependencies = [
[[package]]
name = "schannel"
version = "0.1.29"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.2",
]
@ -4976,9 +4856,9 @@ dependencies = [
[[package]]
name = "serde-saphyr"
version = "0.0.21"
version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6fc4aa0da972ba0f51cf5c1bb16e9dba35334adc6831b09b3ffb0ec20bb264"
checksum = "29fb2d57f074f415e3ea6905994be8aca2bd7a17f8a0344cc43ed15bf240a547"
dependencies = [
"ahash",
"annotate-snippets",
@ -4990,6 +4870,7 @@ dependencies = [
"regex",
"saphyr-parser-bw",
"serde",
"serde_json",
"smallvec",
"zmij",
]
@ -5033,6 +4914,7 @@ version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"indexmap",
"itoa",
"memchr",
"serde",
@ -5113,19 +4995,6 @@ 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"
@ -5294,12 +5163,6 @@ 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"
@ -5440,16 +5303,16 @@ dependencies = [
[[package]]
name = "tiff"
version = "0.11.3"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
"zune-jpeg 0.4.21",
]
[[package]]
@ -5523,9 +5386,9 @@ dependencies = [
[[package]]
name = "tinyvec"
version = "1.11.0"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
dependencies = [
"tinyvec_macros",
]
@ -5634,7 +5497,7 @@ dependencies = [
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
"winnow",
]
[[package]]
@ -5657,9 +5520,9 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "1.0.1+spec-1.1.0"
version = "1.0.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9"
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
dependencies = [
"serde_core",
]
@ -5675,28 +5538,28 @@ dependencies = [
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow 0.7.15",
"winnow",
]
[[package]]
name = "toml_edit"
version = "0.25.5+spec-1.1.0"
version = "0.25.4+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1"
checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
dependencies = [
"indexmap",
"toml_datetime 1.0.1+spec-1.1.0",
"toml_datetime 1.0.0+spec-1.1.0",
"toml_parser",
"winnow 1.0.0",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.0.10+spec-1.1.0"
version = "1.0.9+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420"
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
dependencies = [
"winnow 1.0.0",
"winnow",
]
[[package]]
@ -5707,9 +5570,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.0.7+spec-1.1.0"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
[[package]]
name = "tonic"
@ -5800,18 +5663,6 @@ 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"
@ -5824,7 +5675,6 @@ 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",
@ -5902,9 +5752,9 @@ dependencies = [
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
"nu-ansi-term",
@ -6046,12 +5896,6 @@ 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"
@ -6087,36 +5931,6 @@ 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"
@ -6135,16 +5949,6 @@ 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"
@ -6359,15 +6163,6 @@ 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"
@ -6611,15 +6406,6 @@ 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"
@ -6754,7 +6540,7 @@ dependencies = [
[[package]]
name = "xtask"
version = "0.5.7-alpha.1"
version = "0.5.6"
dependencies = [
"askama",
"cargo_metadata",
@ -6800,18 +6586,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.42"
version = "0.8.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
checksum = "96e13bc581734df6250836c59a5f44f3c57db9f9acb9dc8e3eaabdaf6170254d"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.42"
version = "0.8.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
checksum = "3545ea9e86d12ab9bba9fcd99b54c1556fd3199007def5a03c375623d05fac1c"
dependencies = [
"proc-macro2",
"quote",
@ -6912,6 +6698,12 @@ 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"
@ -6929,9 +6721,18 @@ dependencies = [
[[package]]
name = "zune-jpeg"
version = "0.5.13"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core",
"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",
]

View file

@ -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.7-alpha.1"
version = "0.5.6"
[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", "cookie"]
features = ["typed-header", "tracing"]
[workspace.dependencies.axum-server]
version = "0.7.2"
@ -159,7 +159,7 @@ features = ["raw_value"]
# Used for appservice registration files
[workspace.dependencies.serde-saphyr]
version = "0.0.21"
version = "0.0.19"
# Used to load forbidden room/user regex from config
[workspace.dependencies.serde_regex]
@ -167,7 +167,7 @@ version = "1.1.0"
# Used for ruma wrapper
[workspace.dependencies.serde_html_form]
version = "0.2.6"
version = "0.4.0"
# Used for password hashing
[workspace.dependencies.argon2]
@ -969,6 +969,3 @@ needless_raw_string_hashes = "allow"
# TODO: Enable this lint & fix all instances
collapsible_if = "allow"
# TODO: break these apart
cognitive_complexity = "allow"

View file

@ -1 +0,0 @@
Added support for using an admin command to issue self-service password reset links.

View file

@ -1 +0,0 @@
Add Space permission cascading: power levels cascade from Spaces to child rooms, role-based room access with custom roles, continuous enforcement (auto-join/kick), and admin commands for role management. Server-wide default controlled by `space_permission_cascading` config flag (off by default), with per-Space overrides via `!admin space roles enable/disable <space>`.

View file

@ -1 +0,0 @@
Add new config option to allow or disallow search engine indexing through a `<meta ../>` tag. Defaults to blocking indexing (`content="noindex"`). Contributed by @s1lv3r and @ginger.

View file

@ -25,10 +25,6 @@
#
# 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
@ -474,18 +470,6 @@
#
#suspend_on_register = false
# Server-wide default for space permission cascading (power levels and
# role-based access). Individual Spaces can override this via the
# `com.continuwuity.space.cascading` state event or the admin command
# `!admin space roles enable/disable <space>`.
#
#space_permission_cascading = false
# Maximum number of spaces to cache role data for. When exceeded the
# cache is cleared and repopulated on demand.
#
#space_roles_cache_flush_threshold = 1000
# Enabling this setting opens registration to anyone without restrictions.
# This makes your server vulnerable to abuse
#
@ -1521,11 +1505,6 @@
#
#url_preview_user_agent = "continuwuity/<version> (bot; +https://continuwuity.org)"
# Determines whether audio and video files will be downloaded for URL
# previews.
#
#url_preview_allow_audio_video = false
# List of forbidden room aliases and room IDs as strings of regex
# patterns.
#
@ -1811,11 +1790,6 @@
#
#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.

View file

@ -109,6 +109,9 @@ Restart Continuwuity and your reverse proxy. Once that's done, visit these route
{
"m.homeserver": {
"base_url": "https://matrix.example.com/"
},
"org.matrix.msc3575.proxy": {
"url": "https://matrix.example.com/"
}
}
```

View file

@ -6,7 +6,6 @@ services:
### then you are ready to go.
image: forgejo.ellis.link/continuwuation/continuwuity:latest
restart: unless-stopped
command: /sbin/conduwuit
volumes:
- db:/var/lib/continuwuity
#- ./continuwuity.toml:/etc/continuwuity.toml

View file

@ -23,7 +23,6 @@ services:
### then you are ready to go.
image: forgejo.ellis.link/continuwuation/continuwuity:latest
restart: unless-stopped
command: /sbin/conduwuit
volumes:
- db:/var/lib/continuwuity
- /etc/resolv.conf:/etc/resolv.conf:ro # Use the host's DNS resolver rather than Docker's.

View file

@ -6,7 +6,6 @@ services:
### then you are ready to go.
image: forgejo.ellis.link/continuwuation/continuwuity:latest
restart: unless-stopped
command: /sbin/conduwuit
volumes:
- db:/var/lib/continuwuity
- /etc/resolv.conf:/etc/resolv.conf:ro # Use the host's DNS resolver rather than Docker's.

View file

@ -6,7 +6,6 @@ services:
### then you are ready to go.
image: forgejo.ellis.link/continuwuation/continuwuity:latest
restart: unless-stopped
command: /sbin/conduwuit
ports:
- 8448:6167
volumes:

View file

@ -78,7 +78,7 @@ docker run -d \
-e CONTINUWUITY_ALLOW_REGISTRATION="false" \
--name continuwuity \
forgejo.ellis.link/continuwuation/continuwuity:latest \
/sbin/conduwuit --execute "users create-user admin"
--execute "users create-user admin"
```
Replace `matrix.example.com` with your actual server name and `admin` with
@ -141,7 +141,7 @@ compose file, add under the `continuwuity` service:
services:
continuwuity:
image: forgejo.ellis.link/continuwuation/continuwuity:latest
command: /sbin/conduwuit --execute "users create-user admin"
command: --execute "users create-user admin"
# ... rest of configuration
```

View file

@ -1,7 +1,7 @@
# Continuwuity for FreeBSD
Continuwuity doesn't provide official FreeBSD packages; however, a community-maintained set of packages is available on [Forgejo](https://forgejo.ellis.link/katie/continuwuity-bsd). Note that these are provided as standalone packages and are not part of a FreeBSD package repository (yet), so updates need to be downloaded and installed manually.
Continuwuity currently does not provide FreeBSD builds or FreeBSD packaging. However, Continuwuity does build and work on FreeBSD using the system-provided RocksDB.
Please see the installation instructions in that repository. Direct any questions to its issue tracker or to [@katie:kat5.dev](https://matrix.to/#/@katie:kat5.dev).
Contributions to get Continuwuity packaged for FreeBSD are welcome.
For general BSD support, please join our [Continuwuity BSD](https://matrix.to/#/%23bsd:continuwuity.org) community room.
Please join our [Continuwuity BSD](https://matrix.to/#/%23bsd:continuwuity.org) community room.

View file

@ -39,7 +39,6 @@ spec:
- name: continuwuity
# use a sha hash <3
image: forgejo.ellis.link/continuwuation/continuwuity:latest
command: ["/sbin/conduwuit"]
imagePullPolicy: IfNotPresent
ports:
- name: http

View file

@ -1,226 +0,0 @@
# Space Permission Cascading — Design Document
**Date:** 2026-03-17
**Status:** Approved
## Overview
Server-side feature that allows user rights in a Space to cascade down to its
direct child rooms. Includes power level cascading and role-based room access
control. Enabled via a server-wide configuration flag, disabled by default.
## Requirements
1. Power levels defined in a Space cascade to all direct child rooms (Space
always wins over per-room overrides).
2. Admins can define custom roles in a Space and assign them to users.
3. Child rooms can require one or more roles for access.
4. Enforcement is continuous — role revocation auto-kicks users from rooms they
no longer qualify for.
5. Users are auto-joined to all qualifying child rooms when they join a Space or
receive a new role.
6. Cascading applies to direct parent Space only; no nested cascade through
sub-spaces.
7. Feature is toggled by a single server-wide config flag
(`space_permission_cascading`), off by default.
## Configuration
```toml
# conduwuit-example.toml
# Enable space permission cascading (power levels and role-based access).
# When enabled, power levels cascade from Spaces to child rooms and rooms
# can require roles for access. Applies to all Spaces on this server.
# Default: false
space_permission_cascading = false
```
## Custom State Events
All events live in the Space room.
### `m.space.roles` (state key: `""`)
Defines the available roles for the Space. Two default roles (`admin` and `mod`)
are created automatically when a Space is first encountered with the feature
enabled.
```json
{
"roles": {
"admin": {
"description": "Space administrator",
"power_level": 100
},
"mod": {
"description": "Space moderator",
"power_level": 50
},
"nsfw": {
"description": "Access to NSFW content"
},
"vip": {
"description": "VIP member"
}
}
}
```
- `description` (string, required): Human-readable description.
- `power_level` (integer, optional): If present, users with this role receive
this power level in all child rooms. When a user holds multiple roles with
power levels, the highest value wins.
### `m.space.role.member` (state key: user ID)
Assigns roles to a user within the Space.
```json
{
"roles": ["nsfw", "vip"]
}
```
### `m.space.role.room` (state key: room ID)
Declares which roles a child room requires. A user must hold **all** listed
roles to access the room.
```json
{
"required_roles": ["nsfw"]
}
```
## Enforcement Rules
All enforcement is skipped when `space_permission_cascading = false`.
### 1. Join gating
When a user attempts to join a room that is a direct child of a Space:
- Look up the room's `m.space.role.room` event in the parent Space.
- If the room has `required_roles`, check the user's `m.space.role.member`.
- Reject the join if the user is missing any required role.
### 2. Power level override
For every user in a child room of a Space:
- Look up their roles via `m.space.role.member` in the parent Space.
- For each role that has a `power_level`, take the highest value.
- Override the user's power level in the child room's `m.room.power_levels`.
- Reject attempts to manually set per-room power levels that conflict with
Space-granted levels.
### 3. Role revocation
When an `m.space.role.member` event is updated and a role is removed:
- Identify all child rooms that require the removed role.
- Auto-kick the user from rooms they no longer qualify for.
- Recalculate and update the user's power level in all child rooms.
### 4. Room requirement change
When an `m.space.role.room` event is updated with new requirements:
- Check all current members of the room.
- Auto-kick members who do not hold all newly required roles.
### 5. Auto-join on role grant
When an `m.space.role.member` event is updated and a role is added:
- Find all child rooms where the user now meets all required roles.
- Auto-join the user to qualifying rooms they are not already in.
This also applies when a user first joins the Space — they are auto-joined to
all child rooms they qualify for. Rooms with no role requirements auto-join all
Space members.
### 6. New child room
When a new `m.space.child` event is added to a Space:
- Auto-join all qualifying Space members to the new child room.
## Caching & Indexing
The source of truth is always the state events. The server maintains an
in-memory index for fast enforcement lookups, following the same patterns as the
existing `roomid_spacehierarchy_cache`.
### Index structures
| Index | Source event |
|------------------------------|------------------------|
| Space → roles defined | `m.space.roles` |
| Space → user → roles | `m.space.role.member` |
| Space → room → required roles| `m.space.role.room` |
| Room → parent Space | `m.space.child` (reverse lookup) |
The Space → child rooms mapping already exists.
### Cache invalidation triggers
| Event changed | Action |
|----------------------------|-----------------------------------------------------|
| `m.space.roles` | Refresh role definitions, revalidate all members |
| `m.space.role.member` | Refresh user's roles, trigger auto-join/kick |
| `m.space.role.room` | Refresh room requirements, trigger auto-join/kick |
| `m.space.child` added | Index new child, auto-join qualifying members |
| `m.space.child` removed | Remove from index (no auto-kick) |
| Server startup | Full rebuild from state events |
## Admin Room Commands
Roles are managed via the existing admin room interface, which sends the
appropriate state events under the hood and triggers enforcement.
```
!admin space roles list <space>
!admin space roles add <space> <role_name> [description] [power_level]
!admin space roles remove <space> <role_name>
!admin space roles assign <space> <user_id> <role_name>
!admin space roles revoke <space> <user_id> <role_name>
!admin space roles require <space> <room_id> <role_name>
!admin space roles unrequire <space> <room_id> <role_name>
!admin space roles user <space> <user_id>
!admin space roles room <space> <room_id>
```
## Architecture
**Approach:** Hybrid — state events for definition, database cache for
enforcement.
- State events are the source of truth and federate normally.
- The server maintains an in-memory cache/index for fast enforcement.
- Cache is invalidated on relevant state event changes and fully rebuilt on
startup.
- All enforcement hooks (join gating, PL override, auto-join, auto-kick) check
the feature flag first and no-op when disabled.
- Existing clients can manage roles via Developer Tools (custom state events).
The admin room commands provide a user-friendly interface.
## Scope
### In scope
- Server-wide feature flag
- Custom state events for role definition, assignment, and room requirements
- Power level cascading (Space always wins)
- Continuous enforcement (auto-join, auto-kick)
- Admin room commands
- In-memory caching with invalidation
- Default `admin` (PL 100) and `mod` (PL 50) roles
### Out of scope
- Client-side UI for role management
- Nested cascade through sub-spaces
- Per-space opt-in/opt-out (it is server-wide)
- Federation-specific logic beyond normal state event replication

File diff suppressed because it is too large Load diff

View file

@ -1 +1 @@
{"m.homeserver":{"base_url": "https://matrix.continuwuity.org"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://livekit.ellis.link"}]}
{"m.homeserver":{"base_url": "https://matrix.continuwuity.org"},"org.matrix.msc3575.proxy":{"url": "https://matrix.continuwuity.org"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://livekit.ellis.link"}]}

36
flake.lock generated
View file

@ -3,11 +3,11 @@
"advisory-db": {
"flake": false,
"locked": {
"lastModified": 1773786698,
"narHash": "sha256-o/J7ZculgwSs1L4H4UFlFZENOXTJzq1X0n71x6oNNvY=",
"lastModified": 1772776993,
"narHash": "sha256-CpBa+UpogN0Xn1gMmgqQrzKGee+E8TCkgHar8/w6CRk=",
"owner": "rustsec",
"repo": "advisory-db",
"rev": "99e9de91bb8b61f06ef234ff84e11f758ecd5384",
"rev": "b3472341e37cbd4b8c27b052b2abb34792f4d3c4",
"type": "github"
},
"original": {
@ -18,11 +18,11 @@
},
"crane": {
"locked": {
"lastModified": 1773189535,
"narHash": "sha256-E1G/Or6MWeP+L6mpQ0iTFLpzSzlpGrITfU2220Gq47g=",
"lastModified": 1772560058,
"narHash": "sha256-NuVKdMBJldwUXgghYpzIWJdfeB7ccsu1CC7B+NfSoZ8=",
"owner": "ipetkov",
"repo": "crane",
"rev": "6fa2fb4cf4a89ba49fc9dd5a3eb6cde99d388269",
"rev": "db590d9286ed5ce22017541e36132eab4e8b3045",
"type": "github"
},
"original": {
@ -39,11 +39,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1773732206,
"narHash": "sha256-HKibxaUXyWd4Hs+ZUnwo6XslvaFqFqJh66uL9tphU4Q=",
"lastModified": 1772953398,
"narHash": "sha256-fTTHCaEvPLzWyZFxPud/G9HM3pNYmW/64Kj58hdH4+k=",
"owner": "nix-community",
"repo": "fenix",
"rev": "0aa13c1b54063a8d8679b28a5cd357ba98f4a56b",
"rev": "fc4863887d98fd879cf5f11af1d23d44d9bdd8ae",
"type": "github"
},
"original": {
@ -89,11 +89,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1773734432,
"narHash": "sha256-IF5ppUWh6gHGHYDbtVUyhwy/i7D261P7fWD1bPefOsw=",
"lastModified": 1772773019,
"narHash": "sha256-E1bxHxNKfDoQUuvriG71+f+s/NT0qWkImXsYZNFFfCs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "cda48547b432e8d3b18b4180ba07473762ec8558",
"rev": "aca4d95fce4914b3892661bcb80b8087293536c6",
"type": "github"
},
"original": {
@ -132,11 +132,11 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1773697963,
"narHash": "sha256-xdKI77It9PM6eNrCcDZsnP4SKulZwk8VkDgBRVMnCb8=",
"lastModified": 1772877513,
"narHash": "sha256-RcRGv2Bng5I9y75XwFX7oK2l6mLH1dtbTTG9U8qun0c=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "2993637174252ff60a582fd1f55b9ab52c39db6d",
"rev": "a1b86d600f88be98643e5dd61d6ed26eda17c09e",
"type": "github"
},
"original": {
@ -153,11 +153,11 @@
]
},
"locked": {
"lastModified": 1773297127,
"narHash": "sha256-6E/yhXP7Oy/NbXtf1ktzmU8SdVqJQ09HC/48ebEGBpk=",
"lastModified": 1772660329,
"narHash": "sha256-IjU1FxYqm+VDe5qIOxoW+pISBlGvVApRjiw/Y/ttJzY=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "71b125cd05fbfd78cab3e070b73544abe24c5016",
"rev": "3710e0e1218041bbad640352a0440114b1e10428",
"type": "github"
},
"original": {

224
package-lock.json generated
View file

@ -16,21 +16,21 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
"integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.0",
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"dev": true,
"license": "MIT",
"optional": true,
@ -39,9 +39,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
"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==",
"dev": true,
"license": "MIT",
"optional": true,
@ -144,22 +144,17 @@
}
},
"node_modules/@rsbuild/plugin-react": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/@rsbuild/plugin-react/-/plugin-react-1.4.6.tgz",
"integrity": "sha512-LAT6xHlEyZKA0VjF/ph5d50iyG+WSmBx+7g98HNZUwb94VeeTMZFB8qVptTkbIRMss3BNKOXmHOu71Lhsh9oEw==",
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@rsbuild/plugin-react/-/plugin-react-1.4.5.tgz",
"integrity": "sha512-eS2sXCedgGA/7bLu8yVtn48eE/GyPbXx4Q7OcutB01IQ1D2y8WSMBys4nwfrecy19utvw4NPn4gYDy52316+vg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rspack/plugin-react-refresh": "^1.6.1",
"@rspack/plugin-react-refresh": "^1.6.0",
"react-refresh": "^0.18.0"
},
"peerDependencies": {
"@rsbuild/core": "^1.0.0 || ^2.0.0-0"
},
"peerDependenciesMeta": {
"@rsbuild/core": {
"optional": true
}
}
},
"node_modules/@rspack/binding": {
@ -367,9 +362,9 @@
}
},
"node_modules/@rspress/core": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@rspress/core/-/core-2.0.5.tgz",
"integrity": "sha512-2ezGmANmIrWmhsUrvlRb9Df4xsun1BDgEertDc890aQqtKcNrbu+TBRsOoO+E/N6ioavun7JGGe1wWjvxubCHw==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@rspress/core/-/core-2.0.4.tgz",
"integrity": "sha512-OdeGMY75OFzyRZvXuBEMre3q8Y4/OjYJa4vVBDp4Z2E65LSt8+hYkzzkarEl6sFWqbp8c1o9qfSUf4xMctmKvw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -377,7 +372,7 @@
"@mdx-js/react": "^3.1.1",
"@rsbuild/core": "2.0.0-beta.6",
"@rsbuild/plugin-react": "~1.4.5",
"@rspress/shared": "2.0.5",
"@rspress/shared": "2.0.4",
"@shikijs/rehype": "^4.0.1",
"@types/unist": "^3.0.3",
"@unhead/react": "^2.1.9",
@ -404,8 +399,6 @@
"react-router-dom": "^7.13.1",
"rehype-external-links": "^3.0.0",
"rehype-raw": "^7.0.0",
"remark-cjk-friendly": "^2.0.1",
"remark-cjk-friendly-gfm-strikethrough": "^2.0.1",
"remark-gfm": "^4.0.1",
"remark-mdx": "^3.1.1",
"remark-parse": "^11.0.0",
@ -427,35 +420,35 @@
}
},
"node_modules/@rspress/plugin-client-redirects": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@rspress/plugin-client-redirects/-/plugin-client-redirects-2.0.5.tgz",
"integrity": "sha512-sxwWzwHPefSPWUyV6/AA/hBlQUeNFntL8dBQi/vZCQiZHM6ShvKbqa3s5Xu2yI7DeFKHH3jb0VGbjufu8M3Ypw==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@rspress/plugin-client-redirects/-/plugin-client-redirects-2.0.4.tgz",
"integrity": "sha512-cm7VNfisVCHe+YHNjd9YrWt6/WtJ5I/oNRyjt+tqCeOcC1IJSX2LhNXpNN5h9az3wxYn37kVctBUjzqkj2FQ+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rspress/core": "^2.0.5"
"@rspress/core": "^2.0.4"
}
},
"node_modules/@rspress/plugin-sitemap": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@rspress/plugin-sitemap/-/plugin-sitemap-2.0.5.tgz",
"integrity": "sha512-wBxKL8sNd3bkKFxlFtB1xJ7jCtSRDL6pfVvWsmTIbTNDPCtefd1nmiMBIDMLOR8EflwuStIz3bMQXdWpbC7ahA==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@rspress/plugin-sitemap/-/plugin-sitemap-2.0.4.tgz",
"integrity": "sha512-TKaj3/8+P1fP3sD5NOaWVMXvRvJFQmuJQlUBxhRM0oiUHhzNNkVy/2YXkjYJuXuMhFPLnOWCjrYjTG3xcZE7Wg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rspress/core": "^2.0.5"
"@rspress/core": "^2.0.4"
}
},
"node_modules/@rspress/shared": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@rspress/shared/-/shared-2.0.5.tgz",
"integrity": "sha512-Wdhh+VjU8zJWoVLhv9KJTRAZQ4X2V/Z81Lo2D0hQsa0Kj5F3EaxlMt5/dhX7DoflqNuZPZk/e7CSUB+gO/Umlg==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@rspress/shared/-/shared-2.0.4.tgz",
"integrity": "sha512-os2nzsPgHKVFXjDoW7N53rmhLChCw/y2O2TGilT4w2A4HNJa2oJwRk0UryXbxxWD5C85HErTjovs2uBdhdOTtA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -700,13 +693,13 @@
"license": "ISC"
},
"node_modules/@unhead/react": {
"version": "2.1.12",
"resolved": "https://registry.npmjs.org/@unhead/react/-/react-2.1.12.tgz",
"integrity": "sha512-1xXFrxyw29f+kScXfEb0GxjlgtnHxoYau0qpW9k8sgWhQUNnE5gNaH3u+rNhd5IqhyvbdDRJpQ25zoz0HIyGaw==",
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@unhead/react/-/react-2.1.10.tgz",
"integrity": "sha512-z9IzzkaCI1GyiBwVRMt4dGc2mOvsj9drbAdXGMy6DWpu9FwTR37ZTmAi7UeCVyIkpVdIaNalz7vkbvGG8afFng==",
"dev": true,
"license": "MIT",
"dependencies": {
"unhead": "2.1.12"
"unhead": "2.1.10"
},
"funding": {
"url": "https://github.com/sponsors/harlan-zw"
@ -1292,19 +1285,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-east-asian-width": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
"integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/github-slugger": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
@ -1578,9 +1558,9 @@
}
},
"node_modules/hookable": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.0.tgz",
"integrity": "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-6.0.1.tgz",
"integrity": "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==",
"dev": true,
"license": "MIT"
},
@ -2196,80 +2176,6 @@
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-extension-cjk-friendly": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-extension-cjk-friendly/-/micromark-extension-cjk-friendly-2.0.1.tgz",
"integrity": "sha512-OkzoYVTL1ChbvQ8Cc1ayTIz7paFQz8iS9oIYmewncweUSwmWR+hkJF9spJ1lxB90XldJl26A1F4IkPOKS3bDXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"devlop": "^1.1.0",
"micromark-extension-cjk-friendly-util": "3.0.1",
"micromark-util-chunked": "^2.0.1",
"micromark-util-resolve-all": "^2.0.1",
"micromark-util-symbol": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"micromark": "^4.0.0",
"micromark-util-types": "^2.0.0"
},
"peerDependenciesMeta": {
"micromark-util-types": {
"optional": true
}
}
},
"node_modules/micromark-extension-cjk-friendly-gfm-strikethrough": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-extension-cjk-friendly-gfm-strikethrough/-/micromark-extension-cjk-friendly-gfm-strikethrough-2.0.1.tgz",
"integrity": "sha512-wVC0zwjJNqQeX+bb07YTPu/CvSAyCTafyYb7sMhX1r62/Lw5M/df3JyYaANyp8g15c1ypJRFSsookTqA1IDsUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"devlop": "^1.1.0",
"get-east-asian-width": "^1.4.0",
"micromark-extension-cjk-friendly-util": "3.0.1",
"micromark-util-character": "^2.1.1",
"micromark-util-chunked": "^2.0.1",
"micromark-util-resolve-all": "^2.0.1",
"micromark-util-symbol": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"micromark": "^4.0.0",
"micromark-util-types": "^2.0.0"
},
"peerDependenciesMeta": {
"micromark-util-types": {
"optional": true
}
}
},
"node_modules/micromark-extension-cjk-friendly-util": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/micromark-extension-cjk-friendly-util/-/micromark-extension-cjk-friendly-util-3.0.1.tgz",
"integrity": "sha512-GcbXqTTHOsiZHyF753oIddP/J2eH8j9zpyQPhkof6B2JNxfEJabnQqxbCgzJNuNes0Y2jTNJ3LiYPSXr6eJA8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.4.0",
"micromark-util-character": "^2.1.1",
"micromark-util-symbol": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"peerDependenciesMeta": {
"micromark-util-types": {
"optional": true
}
}
},
"node_modules/micromark-extension-gfm": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
@ -2983,14 +2889,14 @@
"license": "MIT"
},
"node_modules/oniguruma-to-es": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz",
"integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==",
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"oniguruma-parser": "^0.12.1",
"regex": "^6.1.0",
"regex": "^6.0.1",
"regex-recursion": "^6.0.2"
}
},
@ -3336,50 +3242,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-cjk-friendly": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/remark-cjk-friendly/-/remark-cjk-friendly-2.0.1.tgz",
"integrity": "sha512-6WwkoQyZf/4j5k53zdFYrR8Ca+UVn992jXdLUSBDZR4eBpFhKyVxmA4gUHra/5fesjGIxrDhHesNr/sVoiiysA==",
"dev": true,
"license": "MIT",
"dependencies": {
"micromark-extension-cjk-friendly": "2.0.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/mdast": "^4.0.0",
"unified": "^11.0.0"
},
"peerDependenciesMeta": {
"@types/mdast": {
"optional": true
}
}
},
"node_modules/remark-cjk-friendly-gfm-strikethrough": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/remark-cjk-friendly-gfm-strikethrough/-/remark-cjk-friendly-gfm-strikethrough-2.0.1.tgz",
"integrity": "sha512-pWKj25O2eLXIL1aBupayl1fKhco+Brw8qWUWJPVB9EBzbQNd7nGLj0nLmJpggWsGLR5j5y40PIdjxby9IEYTuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"micromark-extension-cjk-friendly-gfm-strikethrough": "2.0.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/mdast": "^4.0.0",
"unified": "^11.0.0"
},
"peerDependenciesMeta": {
"@types/mdast": {
"optional": true
}
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@ -3725,9 +3587,9 @@
}
},
"node_modules/unhead": {
"version": "2.1.12",
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.12.tgz",
"integrity": "sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA==",
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.10.tgz",
"integrity": "sha512-We8l9uNF8zz6U8lfQaVG70+R/QBfQx1oPIgXin4BtZnK2IQpz6yazQ0qjMNVBDw2ADgF2ea58BtvSK+XX5AS7g==",
"dev": true,
"license": "MIT",
"dependencies": {

View file

@ -18,7 +18,6 @@ Environment="CONTINUWUITY_DATABASE_PATH=%S/conduwuit"
Environment="CONTINUWUITY_CONFIG_RELOAD_SIGNAL=true"
LoadCredential=conduwuit.toml:/etc/conduwuit/conduwuit.toml
RefreshOnReload=yes
ExecStart=/usr/bin/conduwuit --config ${CREDENTIALS_DIRECTORY}/conduwuit.toml

View file

@ -1,6 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", "replacements:all", ":semanticCommitTypeAll(chore)"],
"extends": ["config:recommended", "replacements:all"],
"dependencyDashboard": true,
"osvVulnerabilityAlerts": true,
"lockFileMaintenance": {
@ -36,18 +36,10 @@
},
"packageRules": [
{
"description": "Batch minor and patch Rust dependency updates",
"matchManagers": ["cargo"],
"matchUpdateTypes": ["minor", "patch"],
"matchCurrentVersion": ">=1.0.0",
"groupName": "rust-non-major"
},
{
"description": "Batch patch-level zerover Rust dependency updates",
"description": "Batch patch-level Rust dependency updates",
"matchManagers": ["cargo"],
"matchUpdateTypes": ["patch"],
"matchCurrentVersion": ">=0.1.0,<1.0.0",
"groupName": "rust-zerover-patch-updates"
"groupName": "rust-patch-updates"
},
{
"description": "Limit concurrent Cargo PRs",

View file

@ -11,7 +11,6 @@ use crate::{
query::{self, QueryCommand},
room::{self, RoomCommand},
server::{self, ServerCommand},
space::{self, SpaceCommand},
token::{self, TokenCommand},
user::{self, UserCommand},
};
@ -35,10 +34,6 @@ pub enum AdminCommand {
/// Commands for managing rooms
Rooms(RoomCommand),
#[command(subcommand)]
/// Commands for managing space permissions
Spaces(SpaceCommand),
#[command(subcommand)]
/// Commands for managing federation
Federation(FederationCommand),
@ -86,10 +81,6 @@ pub(super) async fn process(command: AdminCommand, context: &Context<'_>) -> Res
token::process(command, context).await
},
| Rooms(command) => room::process(command, context).await,
| Spaces(command) => {
context.bail_restricted()?;
space::process(command, context).await
},
| Federation(command) => federation::process(command, context).await,
| Server(command) => server::process(command, context).await,
| Debug(command) => debug::process(command, context).await,

View file

@ -17,7 +17,6 @@ pub(crate) mod media;
pub(crate) mod query;
pub(crate) mod room;
pub(crate) mod server;
pub(crate) mod space;
pub(crate) mod token;
pub(crate) mod user;

View file

@ -1,15 +0,0 @@
pub(super) mod roles;
use clap::Subcommand;
use conduwuit::Result;
use self::roles::SpaceRolesCommand;
use crate::admin_command_dispatch;
#[admin_command_dispatch]
#[derive(Debug, Subcommand)]
pub enum SpaceCommand {
#[command(subcommand)]
/// Manage space roles and permissions
Roles(SpaceRolesCommand),
}

View file

@ -1,632 +0,0 @@
use std::fmt::Write;
use clap::Subcommand;
use conduwuit::{Err, Event, Result, matrix::pdu::PduBuilder};
use conduwuit_core::matrix::space_roles::{
RoleDefinition, SPACE_CASCADING_EVENT_TYPE, SPACE_ROLE_MEMBER_EVENT_TYPE,
SPACE_ROLE_ROOM_EVENT_TYPE, SPACE_ROLES_EVENT_TYPE, SpaceCascadingEventContent,
SpaceRoleMemberEventContent, SpaceRoleRoomEventContent, SpaceRolesEventContent,
};
use futures::StreamExt;
use ruma::{OwnedRoomId, OwnedRoomOrAliasId, OwnedUserId, events::StateEventType};
use serde_json::value::to_raw_value;
use crate::{admin_command, admin_command_dispatch};
fn roles_event_type() -> StateEventType {
StateEventType::from(SPACE_ROLES_EVENT_TYPE.to_owned())
}
fn member_event_type() -> StateEventType {
StateEventType::from(SPACE_ROLE_MEMBER_EVENT_TYPE.to_owned())
}
fn room_event_type() -> StateEventType {
StateEventType::from(SPACE_ROLE_ROOM_EVENT_TYPE.to_owned())
}
fn cascading_event_type() -> StateEventType {
StateEventType::from(SPACE_CASCADING_EVENT_TYPE.to_owned())
}
macro_rules! resolve_room_as_space {
($self:expr, $space:expr) => {{
let space_id = $self.services.rooms.alias.resolve(&$space).await?;
if !matches!(
$self
.services
.rooms
.state_accessor
.get_room_type(&space_id)
.await,
Ok(ruma::room::RoomType::Space)
) {
return Err!("The specified room is not a Space.");
}
space_id
}};
}
macro_rules! resolve_space {
($self:expr, $space:expr) => {{
let space_id = resolve_room_as_space!($self, $space);
if !$self
.services
.rooms
.roles
.is_enabled_for_space(&space_id)
.await
{
return $self
.write_str(
"Space permission cascading is disabled for this Space. Enable it \
server-wide with `space_permission_cascading = true` in your config, or \
per-Space with `!admin space roles enable <space>`.",
)
.await;
}
space_id
}};
}
macro_rules! custom_state_pdu {
($event_type:expr, $state_key:expr, $content:expr) => {
PduBuilder {
event_type: $event_type.to_owned().into(),
content: to_raw_value($content)
.map_err(|e| conduwuit::err!("Failed to serialize state event content: {e}"))?,
state_key: Some($state_key.to_owned().into()),
..PduBuilder::default()
}
};
}
/// Cascade-remove a role name from all state events of a given type. For each
/// event that contains the role, the `$field` is filtered and the updated
/// content is sent back as a new state event.
macro_rules! cascade_remove_role {
(
$self:expr,
$shortstatehash:expr,
$event_type_fn:expr,
$event_type_const:expr,
$content_ty:ty,
$field:ident,
$role_name:expr,
$space_id:expr,
$state_lock:expr,
$server_user:expr
) => {{
let ev_type = $event_type_fn;
let entries: Vec<(_, ruma::OwnedEventId)> = $self
.services
.rooms
.state_accessor
.state_keys_with_ids($shortstatehash, &ev_type)
.collect()
.await;
for (state_key, event_id) in entries {
if let Ok(pdu) = $self.services.rooms.timeline.get_pdu(&event_id).await {
if let Ok(mut content) = pdu.get_content::<$content_ty>() {
if content.$field.contains($role_name) {
content.$field.retain(|r| r != $role_name);
$self
.services
.rooms
.timeline
.build_and_append_pdu(
custom_state_pdu!($event_type_const, &state_key, &content),
$server_user,
Some(&$space_id),
&$state_lock,
)
.await?;
}
}
}
}
}};
}
macro_rules! send_space_state {
($self:expr, $space_id:expr, $event_type:expr, $state_key:expr, $content:expr) => {{
let state_lock = $self.services.rooms.state.mutex.lock(&$space_id).await;
let server_user = &$self.services.globals.server_user;
$self
.services
.rooms
.timeline
.build_and_append_pdu(
custom_state_pdu!($event_type, $state_key, $content),
server_user,
Some(&$space_id),
&state_lock,
)
.await?
}};
}
#[admin_command_dispatch]
#[derive(Debug, Subcommand)]
pub enum SpaceRolesCommand {
/// List all roles defined in a space
List {
space: OwnedRoomOrAliasId,
},
/// Add a new role to a space
Add {
space: OwnedRoomOrAliasId,
role_name: String,
#[arg(long)]
description: Option<String>,
#[arg(long)]
power_level: Option<i64>,
},
/// Remove a role from a space
Remove {
space: OwnedRoomOrAliasId,
role_name: String,
},
/// Assign a role to a user
Assign {
space: OwnedRoomOrAliasId,
user_id: OwnedUserId,
role_name: String,
},
/// Revoke a role from a user
Revoke {
space: OwnedRoomOrAliasId,
user_id: OwnedUserId,
role_name: String,
},
/// Require a role for a room
Require {
space: OwnedRoomOrAliasId,
room_id: OwnedRoomId,
role_name: String,
},
/// Remove a role requirement from a room
Unrequire {
space: OwnedRoomOrAliasId,
room_id: OwnedRoomId,
role_name: String,
},
/// Show a user's roles in a space
User {
space: OwnedRoomOrAliasId,
user_id: OwnedUserId,
},
/// Show a room's role requirements in a space
Room {
space: OwnedRoomOrAliasId,
room_id: OwnedRoomId,
},
/// Enable space permission cascading for a specific space (overrides
/// server config)
Enable {
space: OwnedRoomOrAliasId,
},
/// Disable space permission cascading for a specific space (overrides
/// server config)
Disable {
space: OwnedRoomOrAliasId,
},
/// Show whether cascading is enabled for a space and the source (server
/// default or per-space override)
Status {
space: OwnedRoomOrAliasId,
},
}
#[admin_command]
async fn list(&self, space: OwnedRoomOrAliasId) -> Result {
let space_id = resolve_space!(self, space);
let roles_event_type = roles_event_type();
let content: SpaceRolesEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &roles_event_type, "")
.await
.unwrap_or_default();
if content.roles.is_empty() {
return self.write_str("No roles defined in this space.").await;
}
let mut msg = format!("Roles in {space_id}:\n```\n");
for (name, def) in &content.roles {
let pl = def
.power_level
.map(|p| format!(" (power_level: {p})"))
.unwrap_or_default();
let _ = writeln!(msg, "- {name}: {}{pl}", def.description);
}
msg.push_str("```");
self.write_str(&msg).await
}
#[admin_command]
async fn add(
&self,
space: OwnedRoomOrAliasId,
role_name: String,
description: Option<String>,
power_level: Option<i64>,
) -> Result {
let space_id = resolve_space!(self, space);
if let Some(pl) = power_level {
if pl > i64::from(ruma::Int::MAX) || pl < i64::from(ruma::Int::MIN) {
return Err!(
"Power level must be between {} and {}.",
ruma::Int::MIN,
ruma::Int::MAX
);
}
}
let roles_event_type = roles_event_type();
let mut content: SpaceRolesEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &roles_event_type, "")
.await
.unwrap_or_default();
if content.roles.contains_key(&role_name) {
return Err!("Role '{role_name}' already exists in this space.");
}
content.roles.insert(role_name.clone(), RoleDefinition {
description: description.unwrap_or_else(|| role_name.clone()),
power_level,
});
send_space_state!(self, space_id, SPACE_ROLES_EVENT_TYPE, "", &content);
self.write_str(&format!("Added role '{role_name}' to space {space_id}."))
.await
}
#[admin_command]
async fn remove(&self, space: OwnedRoomOrAliasId, role_name: String) -> Result {
let space_id = resolve_space!(self, space);
let roles_event_type = roles_event_type();
let mut content: SpaceRolesEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &roles_event_type, "")
.await
.unwrap_or_default();
if content.roles.remove(&role_name).is_none() {
return Err!("Role '{role_name}' does not exist in this space.");
}
send_space_state!(self, space_id, SPACE_ROLES_EVENT_TYPE, "", &content);
// Cascade: remove the deleted role from all member and room events
let server_user = &self.services.globals.server_user;
if let Ok(shortstatehash) = self
.services
.rooms
.state
.get_room_shortstatehash(&space_id)
.await
{
let state_lock = self.services.rooms.state.mutex.lock(&space_id).await;
cascade_remove_role!(
self,
shortstatehash,
member_event_type(),
SPACE_ROLE_MEMBER_EVENT_TYPE,
SpaceRoleMemberEventContent,
roles,
&role_name,
space_id,
state_lock,
server_user
);
cascade_remove_role!(
self,
shortstatehash,
room_event_type(),
SPACE_ROLE_ROOM_EVENT_TYPE,
SpaceRoleRoomEventContent,
required_roles,
&role_name,
space_id,
state_lock,
server_user
);
}
self.write_str(&format!("Removed role '{role_name}' from space {space_id}."))
.await
}
#[admin_command]
async fn assign(
&self,
space: OwnedRoomOrAliasId,
user_id: OwnedUserId,
role_name: String,
) -> Result {
let space_id = resolve_space!(self, space);
let roles_event_type = roles_event_type();
let role_defs: SpaceRolesEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &roles_event_type, "")
.await
.unwrap_or_default();
if !role_defs.roles.contains_key(&role_name) {
return Err!("Role '{role_name}' does not exist in this space.");
}
let member_event_type = member_event_type();
let mut content: SpaceRoleMemberEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &member_event_type, user_id.as_str())
.await
.unwrap_or_default();
if content.roles.contains(&role_name) {
return Err!("User {user_id} already has role '{role_name}' in this space.");
}
content.roles.push(role_name.clone());
send_space_state!(self, space_id, SPACE_ROLE_MEMBER_EVENT_TYPE, user_id.as_str(), &content);
self.write_str(&format!("Assigned role '{role_name}' to {user_id} in space {space_id}."))
.await
}
#[admin_command]
async fn revoke(
&self,
space: OwnedRoomOrAliasId,
user_id: OwnedUserId,
role_name: String,
) -> Result {
let space_id = resolve_space!(self, space);
let member_event_type = member_event_type();
let mut content: SpaceRoleMemberEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &member_event_type, user_id.as_str())
.await
.unwrap_or_default();
let original_len = content.roles.len();
content.roles.retain(|r| r != &role_name);
if content.roles.len() == original_len {
return Err!("User {user_id} does not have role '{role_name}' in this space.");
}
send_space_state!(self, space_id, SPACE_ROLE_MEMBER_EVENT_TYPE, user_id.as_str(), &content);
self.write_str(&format!("Revoked role '{role_name}' from {user_id} in space {space_id}."))
.await
}
#[admin_command]
async fn require(
&self,
space: OwnedRoomOrAliasId,
room_id: OwnedRoomId,
role_name: String,
) -> Result {
let space_id = resolve_space!(self, space);
let child_rooms = self.services.rooms.roles.get_child_rooms(&space_id).await;
if !child_rooms.contains(&room_id) {
return Err!("Room {room_id} is not a child of space {space_id}.");
}
let roles_event_type = roles_event_type();
let role_defs: SpaceRolesEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &roles_event_type, "")
.await
.unwrap_or_default();
if !role_defs.roles.contains_key(&role_name) {
return Err!("Role '{role_name}' does not exist in this space.");
}
let room_event_type = room_event_type();
let mut content: SpaceRoleRoomEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &room_event_type, room_id.as_str())
.await
.unwrap_or_default();
if content.required_roles.contains(&role_name) {
return Err!("Room {room_id} already requires role '{role_name}' in this space.");
}
content.required_roles.push(role_name.clone());
send_space_state!(self, space_id, SPACE_ROLE_ROOM_EVENT_TYPE, room_id.as_str(), &content);
self.write_str(&format!(
"Room {room_id} now requires role '{role_name}' in space {space_id}."
))
.await
}
#[admin_command]
async fn unrequire(
&self,
space: OwnedRoomOrAliasId,
room_id: OwnedRoomId,
role_name: String,
) -> Result {
let space_id = resolve_space!(self, space);
let room_event_type = room_event_type();
let mut content: SpaceRoleRoomEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &room_event_type, room_id.as_str())
.await
.unwrap_or_default();
let original_len = content.required_roles.len();
content.required_roles.retain(|r| r != &role_name);
if content.required_roles.len() == original_len {
return Err!("Room {room_id} does not require role '{role_name}' in this space.");
}
send_space_state!(self, space_id, SPACE_ROLE_ROOM_EVENT_TYPE, room_id.as_str(), &content);
self.write_str(&format!(
"Removed role requirement '{role_name}' from room {room_id} in space {space_id}."
))
.await
}
#[admin_command]
async fn user(&self, space: OwnedRoomOrAliasId, user_id: OwnedUserId) -> Result {
let space_id = resolve_space!(self, space);
let roles = self
.services
.rooms
.roles
.get_user_roles_in_space(&space_id, &user_id)
.await;
match roles {
| Some(roles) if !roles.is_empty() => {
let list: String = roles
.iter()
.map(|r| format!("- {r}"))
.collect::<Vec<_>>()
.join("\n");
self.write_str(&format!("Roles for {user_id} in space {space_id}:\n```\n{list}\n```"))
.await
},
| _ =>
self.write_str(&format!("User {user_id} has no roles in space {space_id}."))
.await,
}
}
#[admin_command]
async fn room(&self, space: OwnedRoomOrAliasId, room_id: OwnedRoomId) -> Result {
let space_id = resolve_space!(self, space);
let reqs = self
.services
.rooms
.roles
.get_room_requirements_in_space(&space_id, &room_id)
.await;
match reqs {
| Some(reqs) if !reqs.is_empty() => {
let list: String = reqs
.iter()
.map(|r| format!("- {r}"))
.collect::<Vec<_>>()
.join("\n");
self.write_str(&format!(
"Required roles for room {room_id} in space {space_id}:\n```\n{list}\n```"
))
.await
},
| _ =>
self.write_str(&format!(
"Room {room_id} has no role requirements in space {space_id}."
))
.await,
}
}
#[admin_command]
async fn enable(&self, space: OwnedRoomOrAliasId) -> Result {
let space_id = resolve_room_as_space!(self, space);
self.services
.rooms
.roles
.ensure_default_roles(&space_id)
.await?;
let content = SpaceCascadingEventContent { enabled: true };
send_space_state!(self, space_id, SPACE_CASCADING_EVENT_TYPE, "", &content);
self.write_str(&format!("Space permission cascading enabled for {space_id}."))
.await
}
#[admin_command]
async fn disable(&self, space: OwnedRoomOrAliasId) -> Result {
let space_id = resolve_room_as_space!(self, space);
let content = SpaceCascadingEventContent { enabled: false };
send_space_state!(self, space_id, SPACE_CASCADING_EVENT_TYPE, "", &content);
self.write_str(&format!("Space permission cascading disabled for {space_id}."))
.await
}
#[admin_command]
async fn status(&self, space: OwnedRoomOrAliasId) -> Result {
let space_id = resolve_room_as_space!(self, space);
let global_default = self.services.rooms.roles.is_enabled();
let cascading_event_type = cascading_event_type();
let per_space_override: Option<bool> = self
.services
.rooms
.state_accessor
.room_state_get_content::<SpaceCascadingEventContent>(
&space_id,
&cascading_event_type,
"",
)
.await
.ok()
.map(|c| c.enabled);
let effective = per_space_override.unwrap_or(global_default);
let source = match per_space_override {
| Some(v) => format!("per-Space override (enabled: {v})"),
| None => format!("server default (space_permission_cascading: {global_default})"),
};
self.write_str(&format!(
"Cascading status for {space_id}:\n- Effective: **{effective}**\n- Source: {source}"
))
.await
}

View file

@ -296,31 +296,6 @@ 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

View file

@ -29,12 +29,6 @@ pub enum UserCommand {
password: Option<String>,
},
/// 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.

View file

@ -347,12 +347,6 @@ pub async fn join_room_by_id_helper(
}
}
services
.rooms
.roles
.check_join_allowed(room_id, sender_user)
.await?;
if server_in_room {
join_room_by_id_helper_local(services, sender_user, room_id, reason, servers, state_lock)
.boxed()

View file

@ -68,10 +68,6 @@ 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
@ -607,22 +603,6 @@ pub struct Config {
#[serde(default)]
pub suspend_on_register: bool,
/// Server-wide default for space permission cascading (power levels and
/// role-based access). Individual Spaces can override this via the
/// `com.continuwuity.space.cascading` state event or the admin command
/// `!admin space roles enable/disable <space>`.
///
/// default: false
#[serde(default)]
pub space_permission_cascading: bool,
/// Maximum number of spaces to cache role data for. When exceeded the
/// cache is cleared and repopulated on demand.
///
/// default: 1000
#[serde(default = "default_space_roles_cache_flush_threshold")]
pub space_roles_cache_flush_threshold: u32,
/// Enabling this setting opens registration to anyone without restrictions.
/// This makes your server vulnerable to abuse
#[serde(default)]
@ -1755,11 +1735,6 @@ pub struct Config {
/// default: "continuwuity/<version> (bot; +https://continuwuity.org)"
pub url_preview_user_agent: Option<String>,
/// Determines whether audio and video files will be downloaded for URL
/// previews.
#[serde(default)]
pub url_preview_allow_audio_video: bool,
/// List of forbidden room aliases and room IDs as strings of regex
/// patterns.
///
@ -2109,13 +2084,6 @@ 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,
@ -2853,5 +2821,3 @@ fn default_ldap_search_filter() -> String { "(objectClass=*)".to_owned() }
fn default_ldap_uid_attribute() -> String { String::from("uid") }
fn default_ldap_name_attribute() -> String { String::from("givenName") }
fn default_space_roles_cache_flush_threshold() -> u32 { 1000 }

View file

@ -2,7 +2,6 @@
pub mod event;
pub mod pdu;
pub mod space_roles;
pub mod state_key;
pub mod state_res;

View file

@ -1,81 +0,0 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const SPACE_ROLES_EVENT_TYPE: &str = "com.continuwuity.space.roles";
pub const SPACE_ROLE_MEMBER_EVENT_TYPE: &str = "com.continuwuity.space.role.member";
pub const SPACE_ROLE_ROOM_EVENT_TYPE: &str = "com.continuwuity.space.role.room";
pub const SPACE_CASCADING_EVENT_TYPE: &str = "com.continuwuity.space.cascading";
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct SpaceRolesEventContent {
pub roles: BTreeMap<String, RoleDefinition>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct RoleDefinition {
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub power_level: Option<i64>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct SpaceRoleMemberEventContent {
pub roles: Vec<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct SpaceRoleRoomEventContent {
pub required_roles: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct SpaceCascadingEventContent {
pub enabled: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn space_roles_roundtrip() {
let mut roles = BTreeMap::new();
roles.insert("admin".to_owned(), RoleDefinition {
description: "Space administrator".to_owned(),
power_level: Some(100),
});
roles.insert("nsfw".to_owned(), RoleDefinition {
description: "NSFW access".to_owned(),
power_level: None,
});
let content = SpaceRolesEventContent { roles };
let json = serde_json::to_string(&content).unwrap();
let deserialized: SpaceRolesEventContent = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.roles["admin"].power_level, Some(100));
assert!(deserialized.roles["nsfw"].power_level.is_none());
}
#[test]
fn power_level_omitted_in_serialization_when_none() {
let role = RoleDefinition {
description: "Test".to_owned(),
power_level: None,
};
let json = serde_json::to_string(&role).unwrap();
assert!(!json.contains("power_level"));
}
#[test]
fn negative_power_level() {
let json = r#"{"description":"Restricted","power_level":-10}"#;
let role: RoleDefinition = serde_json::from_str(json).unwrap();
assert_eq!(role.power_level, Some(-10));
}
#[test]
fn missing_description_fails() {
let json = r#"{"power_level":100}"#;
serde_json::from_str::<RoleDefinition>(json).unwrap_err();
}
}

View file

@ -1224,7 +1224,6 @@ 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,

View file

@ -75,7 +75,6 @@ type Result<T, E = Error> = crate::Result<T, E>;
/// 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,

View file

@ -112,10 +112,6 @@ 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"),

View file

@ -18,5 +18,5 @@ pub(crate) fn build(services: &Arc<Services>) -> (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)
}

View file

@ -121,7 +121,7 @@ webpage.workspace = true
webpage.optional = true
blurhash.workspace = true
blurhash.optional = true
recaptcha-verify = { version = "0.2.0", default-features = false }
recaptcha-verify = { version = "0.1.5", default-features = false }
yansi.workspace = true
[target.'cfg(all(unix, target_os = "linux"))'.dependencies]

View file

@ -6,7 +6,6 @@ use conduwuit::{
config::{Config, check},
error, implement,
};
use url::Url;
use crate::registration_tokens::{ValidToken, ValidTokenSource};
@ -24,18 +23,6 @@ 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]

View file

@ -142,10 +142,6 @@ impl Service {
self.server.config.url_preview_check_root_domain
}
pub fn url_preview_allow_audio_video(&self) -> bool {
self.server.config.url_preview_allow_audio_video
}
pub fn forbidden_alias_names(&self) -> &RegexSet { &self.server.config.forbidden_alias_names }
pub fn forbidden_usernames(&self) -> &RegexSet { &self.server.config.forbidden_usernames }

View file

@ -207,28 +207,6 @@ impl Data {
value.extend_from_slice(&data.image_width.unwrap_or(0).to_be_bytes());
value.push(0xFF);
value.extend_from_slice(&data.image_height.unwrap_or(0).to_be_bytes());
value.push(0xFF);
value.extend_from_slice(
data.video
.as_ref()
.map(String::as_bytes)
.unwrap_or_default(),
);
value.push(0xFF);
value.extend_from_slice(&data.video_size.unwrap_or(0).to_be_bytes());
value.push(0xFF);
value.extend_from_slice(&data.video_width.unwrap_or(0).to_be_bytes());
value.push(0xFF);
value.extend_from_slice(&data.video_height.unwrap_or(0).to_be_bytes());
value.push(0xFF);
value.extend_from_slice(
data.audio
.as_ref()
.map(String::as_bytes)
.unwrap_or_default(),
);
value.push(0xFF);
value.extend_from_slice(&data.audio_size.unwrap_or(0).to_be_bytes());
self.url_previews.insert(url.as_bytes(), &value);
@ -289,48 +267,6 @@ impl Data {
| Some(0) => None,
| x => x,
};
let video = match values
.next()
.and_then(|b| String::from_utf8(b.to_vec()).ok())
{
| Some(s) if s.is_empty() => None,
| x => x,
};
let video_size = match values
.next()
.map(|b| usize::from_be_bytes(b.try_into().unwrap_or_default()))
{
| Some(0) => None,
| x => x,
};
let video_width = match values
.next()
.map(|b| u32::from_be_bytes(b.try_into().unwrap_or_default()))
{
| Some(0) => None,
| x => x,
};
let video_height = match values
.next()
.map(|b| u32::from_be_bytes(b.try_into().unwrap_or_default()))
{
| Some(0) => None,
| x => x,
};
let audio = match values
.next()
.and_then(|b| String::from_utf8(b.to_vec()).ok())
{
| Some(s) if s.is_empty() => None,
| x => x,
};
let audio_size = match values
.next()
.map(|b| usize::from_be_bytes(b.try_into().unwrap_or_default()))
{
| Some(0) => None,
| x => x,
};
Ok(UrlPreviewData {
title,
@ -339,12 +275,6 @@ impl Data {
image_size,
image_width,
image_height,
video,
video_size,
video_width,
video_height,
audio,
audio_size,
})
}
}

View file

@ -10,8 +10,6 @@ use std::time::SystemTime;
use conduwuit::{Err, Result, debug, err, utils::response::LimitReadExt};
use conduwuit_core::implement;
use ipaddress::IPAddress;
#[cfg(feature = "url_preview")]
use ruma::OwnedMxcUri;
use serde::Serialize;
use url::Url;
@ -31,18 +29,6 @@ pub struct UrlPreviewData {
pub image_width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none", rename(serialize = "og:image:height"))]
pub image_height: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none", rename(serialize = "og:video"))]
pub video: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename(serialize = "matrix:video:size"))]
pub video_size: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none", rename(serialize = "og:video:width"))]
pub video_width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none", rename(serialize = "og:video:height"))]
pub video_height: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none", rename(serialize = "og:audio"))]
pub audio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename(serialize = "matrix:audio:size"))]
pub audio_size: Option<usize>,
}
#[implement(Service)]
@ -110,9 +96,7 @@ async fn request_url_preview(&self, url: &Url) -> Result<UrlPreviewData> {
let data = match content_type {
| html if html.starts_with("text/html") => self.download_html(url.as_str()).await?,
| img if img.starts_with("image/") => self.download_image(url.as_str(), None).await?,
| video if video.starts_with("video/") => self.download_video(url.as_str(), None).await?,
| audio if audio.starts_with("audio/") => self.download_audio(url.as_str(), None).await?,
| img if img.starts_with("image/") => self.download_image(url.as_str()).await?,
| _ => return Err!(Request(Unknown("Unsupported Content-Type"))),
};
@ -123,17 +107,11 @@ async fn request_url_preview(&self, url: &Url) -> Result<UrlPreviewData> {
#[cfg(feature = "url_preview")]
#[implement(Service)]
pub async fn download_image(
&self,
url: &str,
preview_data: Option<UrlPreviewData>,
) -> Result<UrlPreviewData> {
pub async fn download_image(&self, url: &str) -> Result<UrlPreviewData> {
use conduwuit::utils::random_string;
use image::ImageReader;
use ruma::Mxc;
let mut preview_data = preview_data.unwrap_or_default();
let image = self
.services
.client
@ -150,7 +128,6 @@ pub async fn download_image(
.expect("u64 should fit in usize"),
)
.await?;
let mxc = Mxc {
server_name: self.services.globals.server_name(),
media_id: &random_string(super::MXC_LENGTH),
@ -158,125 +135,27 @@ pub async fn download_image(
self.create(&mxc, None, None, None, &image).await?;
preview_data.image = Some(mxc.to_string());
if preview_data.image_height.is_none() || preview_data.image_width.is_none() {
let cursor = std::io::Cursor::new(&image);
let (width, height) = match ImageReader::new(cursor).with_guessed_format() {
let cursor = std::io::Cursor::new(&image);
let (width, height) = match ImageReader::new(cursor).with_guessed_format() {
| Err(_) => (None, None),
| Ok(reader) => match reader.into_dimensions() {
| Err(_) => (None, None),
| Ok(reader) => match reader.into_dimensions() {
| Err(_) => (None, None),
| Ok((width, height)) => (Some(width), Some(height)),
},
};
preview_data.image_width = width;
preview_data.image_height = height;
}
Ok(preview_data)
}
#[cfg(feature = "url_preview")]
#[implement(Service)]
pub async fn download_video(
&self,
url: &str,
preview_data: Option<UrlPreviewData>,
) -> Result<UrlPreviewData> {
let mut preview_data = preview_data.unwrap_or_default();
if self.services.globals.url_preview_allow_audio_video() {
let (url, size) = self.download_media(url).await?;
preview_data.video = Some(url.to_string());
preview_data.video_size = Some(size);
}
Ok(preview_data)
}
#[cfg(feature = "url_preview")]
#[implement(Service)]
pub async fn download_audio(
&self,
url: &str,
preview_data: Option<UrlPreviewData>,
) -> Result<UrlPreviewData> {
let mut preview_data = preview_data.unwrap_or_default();
if self.services.globals.url_preview_allow_audio_video() {
let (url, size) = self.download_media(url).await?;
preview_data.audio = Some(url.to_string());
preview_data.audio_size = Some(size);
}
Ok(preview_data)
}
#[cfg(feature = "url_preview")]
#[implement(Service)]
pub async fn download_media(&self, url: &str) -> Result<(OwnedMxcUri, usize)> {
use conduwuit::utils::random_string;
use http::header::CONTENT_TYPE;
use ruma::Mxc;
let response = self.services.client.url_preview.get(url).send().await?;
let content_type = response.headers().get(CONTENT_TYPE).cloned();
let media = response
.limit_read(
self.services
.server
.config
.max_request_size
.try_into()
.expect("u64 should fit in usize"),
)
.await?;
let mxc = Mxc {
server_name: self.services.globals.server_name(),
media_id: &random_string(super::MXC_LENGTH),
| Ok((width, height)) => (Some(width), Some(height)),
},
};
let content_type = content_type.and_then(|v| v.to_str().map(ToOwned::to_owned).ok());
self.create(&mxc, None, None, content_type.as_deref(), &media)
.await?;
Ok((OwnedMxcUri::from(mxc.to_string()), media.len()))
Ok(UrlPreviewData {
image: Some(mxc.to_string()),
image_size: Some(image.len()),
image_width: width,
image_height: height,
..Default::default()
})
}
#[cfg(not(feature = "url_preview"))]
#[implement(Service)]
pub async fn download_image(
&self,
_url: &str,
_preview_data: Option<UrlPreviewData>,
) -> Result<UrlPreviewData> {
Err!(FeatureDisabled("url_preview"))
}
#[cfg(not(feature = "url_preview"))]
#[implement(Service)]
pub async fn download_video(
&self,
_url: &str,
_preview_data: Option<UrlPreviewData>,
) -> Result<UrlPreviewData> {
Err!(FeatureDisabled("url_preview"))
}
#[cfg(not(feature = "url_preview"))]
#[implement(Service)]
pub async fn download_audio(
&self,
_url: &str,
_preview_data: Option<UrlPreviewData>,
) -> Result<UrlPreviewData> {
Err!(FeatureDisabled("url_preview"))
}
#[cfg(not(feature = "url_preview"))]
#[implement(Service)]
pub async fn download_media(&self, _url: &str) -> Result<UrlPreviewData> {
pub async fn download_image(&self, _url: &str) -> Result<UrlPreviewData> {
Err!(FeatureDisabled("url_preview"))
}
@ -303,29 +182,18 @@ async fn download_html(&self, url: &str) -> Result<UrlPreviewData> {
return Err!(Request(Unknown("Failed to parse HTML")));
};
let mut preview_data = UrlPreviewData::default();
if let Some(obj) = html.opengraph.images.first() {
preview_data = self.download_image(&obj.url, Some(preview_data)).await?;
}
if let Some(obj) = html.opengraph.videos.first() {
preview_data = self.download_video(&obj.url, Some(preview_data)).await?;
preview_data.video_width = obj.properties.get("width").and_then(|v| v.parse().ok());
preview_data.video_height = obj.properties.get("height").and_then(|v| v.parse().ok());
}
if let Some(obj) = html.opengraph.audios.first() {
preview_data = self.download_audio(&obj.url, Some(preview_data)).await?;
}
let mut data = match html.opengraph.images.first() {
| None => UrlPreviewData::default(),
| Some(obj) => self.download_image(&obj.url).await?,
};
let props = html.opengraph.properties;
/* use OpenGraph title/description, but fall back to HTML if not available */
preview_data.title = props.get("title").cloned().or(html.title);
preview_data.description = props.get("description").cloned().or(html.description);
data.title = props.get("title").cloned().or(html.title);
data.description = props.get("description").cloned().or(html.description);
Ok(preview_data)
Ok(data)
}
#[cfg(not(feature = "url_preview"))]

View file

@ -1,9 +1,8 @@
use std::{cmp, collections::HashMap, future::ready};
use conduwuit::{
Err, Event, Pdu, Result, debug, debug_info, debug_warn, err, error, info,
Err, Event, Pdu, Result, debug, debug_info, debug_warn, error, info,
result::NotFound,
trace,
utils::{
IterStream, ReadyExt,
stream::{TryExpect, TryIgnore},
@ -58,7 +57,6 @@ pub(crate) async fn migrations(services: &Services) -> Result<()> {
}
async fn fresh(services: &Services) -> Result<()> {
info!("Creating new fresh database");
let db = &services.db;
services.globals.db.bump_database_version(DATABASE_VERSION);
@ -68,18 +66,11 @@ async fn fresh(services: &Services) -> Result<()> {
db["global"].insert(b"retroactively_fix_bad_data_from_roomuserid_joined", []);
db["global"].insert(b"fix_referencedevents_missing_sep", []);
db["global"].insert(b"fix_readreceiptid_readreceipt_duplicates", []);
db["global"].insert(b"fix_corrupt_msc4133_fields", []);
db["global"].insert(b"populate_userroomid_leftstate_table", []);
db["global"].insert(b"fix_local_invite_state", []);
// Create the admin room and server user on first run
info!("Creating admin room and server user");
crate::admin::create_admin_room(services)
.boxed()
.await
.inspect_err(|e| error!("Failed to create admin room during db init: {e}"))?;
crate::admin::create_admin_room(services).boxed().await?;
info!("Created new database with version {DATABASE_VERSION}");
warn!("Created new RocksDB database with version {DATABASE_VERSION}");
Ok(())
}
@ -97,33 +88,19 @@ async fn migrate(services: &Services) -> Result<()> {
}
if services.globals.db.database_version().await < 12 {
db_lt_12(services)
.await
.map_err(|e| err!("Failed to run v12 migrations: {e}"))?;
db_lt_12(services).await?;
}
// This migration can be reused as-is anytime the server-default rules are
// updated.
if services.globals.db.database_version().await < 13 {
db_lt_13(services)
.await
.map_err(|e| err!("Failed to run v13 migrations: {e}"))?;
db_lt_13(services).await?;
}
if db["global"].get(b"feat_sha256_media").await.is_not_found() {
media::migrations::migrate_sha256_media(services)
.await
.map_err(|e| err!("Failed to run SHA256 media migration: {e}"))?;
media::migrations::migrate_sha256_media(services).await?;
} else if config.media_startup_check {
info!("Starting media startup integrity check.");
let now = std::time::Instant::now();
media::migrations::checkup_sha256_media(services)
.await
.map_err(|e| err!("Failed to verify media integrity: {e}"))?;
info!(
"Finished media startup integrity check in {} seconds.",
now.elapsed().as_secs_f32()
);
media::migrations::checkup_sha256_media(services).await?;
}
if db["global"]
@ -131,12 +108,7 @@ async fn migrate(services: &Services) -> Result<()> {
.await
.is_not_found()
{
info!("Running migration 'fix_bad_double_separator_in_state_cache'");
fix_bad_double_separator_in_state_cache(services)
.await
.map_err(|e| {
err!("Failed to run 'fix_bad_double_separator_in_state_cache' migration: {e}")
})?;
fix_bad_double_separator_in_state_cache(services).await?;
}
if db["global"]
@ -144,15 +116,7 @@ async fn migrate(services: &Services) -> Result<()> {
.await
.is_not_found()
{
info!("Running migration 'retroactively_fix_bad_data_from_roomuserid_joined'");
retroactively_fix_bad_data_from_roomuserid_joined(services)
.await
.map_err(|e| {
err!(
"Failed to run 'retroactively_fix_bad_data_from_roomuserid_joined' \
migration: {e}"
)
})?;
retroactively_fix_bad_data_from_roomuserid_joined(services).await?;
}
if db["global"]
@ -161,12 +125,7 @@ async fn migrate(services: &Services) -> Result<()> {
.is_not_found()
|| services.globals.db.database_version().await < 17
{
info!("Running migration 'fix_referencedevents_missing_sep'");
fix_referencedevents_missing_sep(services)
.await
.map_err(|e| {
err!("Failed to run 'fix_referencedevents_missing_sep' migration': {e}")
})?;
fix_referencedevents_missing_sep(services).await?;
}
if db["global"]
@ -175,12 +134,7 @@ async fn migrate(services: &Services) -> Result<()> {
.is_not_found()
|| services.globals.db.database_version().await < 17
{
info!("Running migration 'fix_readreceiptid_readreceipt_duplicates'");
fix_readreceiptid_readreceipt_duplicates(services)
.await
.map_err(|e| {
err!("Failed to run 'fix_readreceiptid_readreceipt_duplicates' migration': {e}")
})?;
fix_readreceiptid_readreceipt_duplicates(services).await?;
}
if services.globals.db.database_version().await < 17 {
@ -193,10 +147,7 @@ async fn migrate(services: &Services) -> Result<()> {
.await
.is_not_found()
{
info!("Running migration 'fix_corrupt_msc4133_fields'");
fix_corrupt_msc4133_fields(services)
.await
.map_err(|e| err!("Failed to run 'fix_corrupt_msc4133_fields' migration': {e}"))?;
fix_corrupt_msc4133_fields(services).await?;
}
if services.globals.db.database_version().await < 18 {
@ -209,12 +160,7 @@ async fn migrate(services: &Services) -> Result<()> {
.await
.is_not_found()
{
info!("Running migration 'populate_userroomid_leftstate_table'");
populate_userroomid_leftstate_table(services)
.await
.map_err(|e| {
err!("Failed to run 'populate_userroomid_leftstate_table' migration': {e}")
})?;
populate_userroomid_leftstate_table(services).await?;
}
if db["global"]
@ -222,17 +168,14 @@ async fn migrate(services: &Services) -> Result<()> {
.await
.is_not_found()
{
info!("Running migration 'fix_local_invite_state'");
fix_local_invite_state(services)
.await
.map_err(|e| err!("Failed to run 'fix_local_invite_state' migration': {e}"))?;
fix_local_invite_state(services).await?;
}
assert_eq!(
services.globals.db.database_version().await,
DATABASE_VERSION,
"Failed asserting local database version {} is equal to known latest continuwuity \
database version {}",
"Failed asserting local database version {} is equal to known latest conduwuit database \
version {}",
services.globals.db.database_version().await,
DATABASE_VERSION,
);
@ -427,7 +370,7 @@ async fn db_lt_13(services: &Services) -> Result<()> {
}
async fn fix_bad_double_separator_in_state_cache(services: &Services) -> Result<()> {
info!("Fixing bad double separator in state_cache roomuserid_joined");
warn!("Fixing bad double separator in state_cache roomuserid_joined");
let db = &services.db;
let roomuserid_joined = &db["roomuserid_joined"];
@ -471,7 +414,7 @@ async fn fix_bad_double_separator_in_state_cache(services: &Services) -> Result<
}
async fn retroactively_fix_bad_data_from_roomuserid_joined(services: &Services) -> Result<()> {
info!("Retroactively fixing bad data from broken roomuserid_joined");
warn!("Retroactively fixing bad data from broken roomuserid_joined");
let db = &services.db;
let _cork = db.cork_and_sync();
@ -561,7 +504,7 @@ async fn retroactively_fix_bad_data_from_roomuserid_joined(services: &Services)
}
async fn fix_referencedevents_missing_sep(services: &Services) -> Result {
info!("Fixing missing record separator between room_id and event_id in referencedevents");
warn!("Fixing missing record separator between room_id and event_id in referencedevents");
let db = &services.db;
let cork = db.cork_and_sync();
@ -609,7 +552,7 @@ async fn fix_readreceiptid_readreceipt_duplicates(services: &Services) -> Result
type ArrayId = ArrayString<MAX_BYTES>;
type Key<'a> = (&'a RoomId, u64, &'a UserId);
info!("Fixing undeleted entries in readreceiptid_readreceipt...");
warn!("Fixing undeleted entries in readreceiptid_readreceipt...");
let db = &services.db;
let cork = db.cork_and_sync();
@ -663,7 +606,7 @@ async fn fix_corrupt_msc4133_fields(services: &Services) -> Result {
use serde_json::{Value, from_slice};
type KeyVal<'a> = ((OwnedUserId, String), &'a [u8]);
info!("Fixing corrupted `us.cloke.msc4175.tz` fields...");
warn!("Fixing corrupted `us.cloke.msc4175.tz` fields...");
let db = &services.db;
let cork = db.cork_and_sync();
@ -803,18 +746,7 @@ async fn fix_local_invite_state(services: &Services) -> Result {
let fixed = userroomid_invitestate.stream()
// if they're a local user on this homeserver
.try_filter(|((user_id, _), _): &KeyVal<'_>| ready(services.globals.user_is_local(user_id)))
.and_then(async |((user_id, room_id), stripped_state): KeyVal<'_>| Ok::<_,
conduwuit::Error>((user_id.to_owned(), room_id.to_owned(), stripped_state.deserialize
().unwrap_or_else(|e| {
trace!("Failed to deserialize: {:?}", stripped_state.json());
warn!(
%user_id,
%room_id,
"Failed to deserialize stripped state for invite, removing from db: {e}"
);
userroomid_invitestate.del((user_id, room_id));
vec![]
}))))
.and_then(async |((user_id, room_id), stripped_state): KeyVal<'_>| Ok::<_, conduwuit::Error>((user_id.to_owned(), room_id.to_owned(), stripped_state.deserialize()?)))
.try_fold(0_usize, async |mut fixed, (user_id, room_id, stripped_state)| {
// and their invite state is None
if stripped_state.is_empty()

View file

@ -23,7 +23,6 @@ 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;

View file

@ -1,68 +0,0 @@
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<Map>,
}
#[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<Database>) -> 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<ResetTokenInfo> {
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); }
}

View file

@ -1,120 +0,0 @@
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<users::Service>,
globals: Dep<globals::Service>,
}
#[derive(Debug)]
pub struct ValidResetToken {
pub token: String,
pub info: ResetTokenInfo,
}
impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
db: Data::new(args.db),
services: Services {
users: args.depend::<users::Service>("users"),
globals: args.depend::<globals::Service>("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<ValidResetToken> {
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<ValidResetToken> {
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(())
}
}

View file

@ -7,7 +7,6 @@ pub mod metadata;
pub mod outlier;
pub mod pdu_metadata;
pub mod read_receipt;
pub mod roles;
pub mod search;
pub mod short;
pub mod spaces;
@ -32,7 +31,6 @@ pub struct Service {
pub outlier: Arc<outlier::Service>,
pub pdu_metadata: Arc<pdu_metadata::Service>,
pub read_receipt: Arc<read_receipt::Service>,
pub roles: Arc<roles::Service>,
pub search: Arc<search::Service>,
pub short: Arc<short::Service>,
pub spaces: Arc<spaces::Service>,

File diff suppressed because it is too large Load diff

View file

@ -1,204 +0,0 @@
use std::collections::{BTreeMap, HashSet};
use conduwuit_core::matrix::space_roles::RoleDefinition;
use super::{compute_user_power_level, roles_satisfy_requirements};
pub(super) fn make_roles(entries: &[(&str, Option<i64>)]) -> BTreeMap<String, RoleDefinition> {
entries
.iter()
.map(|(name, pl)| {
((*name).to_owned(), RoleDefinition {
description: format!("{name} role"),
power_level: *pl,
})
})
.collect()
}
pub(super) fn make_set(items: &[&str]) -> HashSet<String> {
items.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn power_level_single_role() {
let roles = make_roles(&[("admin", Some(100)), ("mod", Some(50))]);
assert_eq!(compute_user_power_level(&roles, &make_set(&["admin"])), Some(100));
}
#[test]
fn power_level_multiple_roles_takes_highest() {
let roles = make_roles(&[("admin", Some(100)), ("mod", Some(50)), ("helper", Some(25))]);
assert_eq!(compute_user_power_level(&roles, &make_set(&["mod", "helper"])), Some(50));
}
#[test]
fn power_level_no_power_roles() {
let roles = make_roles(&[("nsfw", None), ("vip", None)]);
assert_eq!(compute_user_power_level(&roles, &make_set(&["nsfw", "vip"])), None);
}
#[test]
fn power_level_mixed_roles() {
let roles = make_roles(&[("mod", Some(50)), ("nsfw", None)]);
assert_eq!(compute_user_power_level(&roles, &make_set(&["mod", "nsfw"])), Some(50));
}
#[test]
fn power_level_no_roles_assigned() {
let roles = make_roles(&[("admin", Some(100))]);
assert_eq!(compute_user_power_level(&roles, &HashSet::new()), None);
}
#[test]
fn power_level_unknown_role_ignored() {
let roles = make_roles(&[("admin", Some(100))]);
assert_eq!(compute_user_power_level(&roles, &make_set(&["nonexistent"])), None);
}
#[test]
fn qualifies_with_all_required_roles() {
assert!(roles_satisfy_requirements(
&make_set(&["nsfw", "vip"]),
&make_set(&["nsfw", "vip", "extra"]),
));
}
#[test]
fn does_not_qualify_missing_one_role() {
assert!(!roles_satisfy_requirements(&make_set(&["nsfw", "vip"]), &make_set(&["nsfw"]),));
}
#[test]
fn qualifies_with_no_requirements() {
assert!(roles_satisfy_requirements(&HashSet::new(), &make_set(&["nsfw"])));
}
#[test]
fn does_not_qualify_with_no_roles() {
assert!(!roles_satisfy_requirements(&make_set(&["nsfw"]), &HashSet::new()));
}
// Multi-space scenarios
#[test]
fn multi_space_highest_pl_wins() {
let space_a_roles = make_roles(&[("mod", Some(50))]);
let space_b_roles = make_roles(&[("admin", Some(100))]);
let user_roles_a = make_set(&["mod"]);
let user_roles_b = make_set(&["admin"]);
let pl_a = compute_user_power_level(&space_a_roles, &user_roles_a);
let pl_b = compute_user_power_level(&space_b_roles, &user_roles_b);
let effective = [pl_a, pl_b].into_iter().flatten().max();
assert_eq!(effective, Some(100));
}
#[test]
fn multi_space_one_space_has_no_pl() {
let space_a_roles = make_roles(&[("nsfw", None)]);
let space_b_roles = make_roles(&[("mod", Some(50))]);
let user_roles_a = make_set(&["nsfw"]);
let user_roles_b = make_set(&["mod"]);
let pl_a = compute_user_power_level(&space_a_roles, &user_roles_a);
let pl_b = compute_user_power_level(&space_b_roles, &user_roles_b);
let effective = [pl_a, pl_b].into_iter().flatten().max();
assert_eq!(effective, Some(50));
}
#[test]
fn multi_space_neither_has_pl() {
let space_a_roles = make_roles(&[("nsfw", None)]);
let space_b_roles = make_roles(&[("vip", None)]);
let user_roles_a = make_set(&["nsfw"]);
let user_roles_b = make_set(&["vip"]);
let pl_a = compute_user_power_level(&space_a_roles, &user_roles_a);
let pl_b = compute_user_power_level(&space_b_roles, &user_roles_b);
let effective = [pl_a, pl_b].into_iter().flatten().max();
assert_eq!(effective, None);
}
#[test]
fn multi_space_user_only_in_one_space() {
let space_a_roles = make_roles(&[("admin", Some(100))]);
let space_b_roles = make_roles(&[("mod", Some(50))]);
let user_roles_a = make_set(&["admin"]);
let user_roles_b: HashSet<String> = HashSet::new();
let pl_a = compute_user_power_level(&space_a_roles, &user_roles_a);
let pl_b = compute_user_power_level(&space_b_roles, &user_roles_b);
let effective = [pl_a, pl_b].into_iter().flatten().max();
assert_eq!(effective, Some(100));
}
#[test]
fn multi_space_qualifies_in_one_not_other() {
let space_a_reqs = make_set(&["staff"]);
let space_b_reqs = make_set(&["nsfw"]);
let user_roles = make_set(&["nsfw"]);
assert!(!roles_satisfy_requirements(&space_a_reqs, &user_roles));
assert!(roles_satisfy_requirements(&space_b_reqs, &user_roles));
}
#[test]
fn multi_space_qualifies_after_role_revoke_via_other_space() {
let space_a_reqs = make_set(&["nsfw"]);
let space_b_reqs = make_set(&["vip"]);
let user_roles_after_revoke = make_set(&["vip"]);
assert!(!roles_satisfy_requirements(&space_a_reqs, &user_roles_after_revoke));
assert!(roles_satisfy_requirements(&space_b_reqs, &user_roles_after_revoke));
}
#[test]
fn multi_space_room_has_reqs_in_one_space_only() {
let space_a_reqs = make_set(&["admin"]);
let space_b_reqs: HashSet<String> = HashSet::new();
let user_roles = make_set(&["nsfw"]);
assert!(!roles_satisfy_requirements(&space_a_reqs, &user_roles));
assert!(roles_satisfy_requirements(&space_b_reqs, &user_roles));
}
#[test]
fn multi_space_no_qualification_anywhere() {
let space_a_reqs = make_set(&["staff"]);
let space_b_reqs = make_set(&["admin"]);
let user_roles = make_set(&["nsfw"]);
let qualifies_a = roles_satisfy_requirements(&space_a_reqs, &user_roles);
let qualifies_b = roles_satisfy_requirements(&space_b_reqs, &user_roles);
assert!(!qualifies_a);
assert!(!qualifies_b);
assert!(!(qualifies_a || qualifies_b));
}
#[test]
fn multi_space_same_role_different_pl() {
let space_a_roles = make_roles(&[("mod", Some(50))]);
let space_b_roles = make_roles(&[("mod", Some(75))]);
let user_roles = make_set(&["mod"]);
let pl_a = compute_user_power_level(&space_a_roles, &user_roles);
let pl_b = compute_user_power_level(&space_b_roles, &user_roles);
let effective = [pl_a, pl_b].into_iter().flatten().max();
assert_eq!(effective, Some(75));
}

View file

@ -327,7 +327,7 @@ where
}
},
| TimelineEventType::SpaceChild =>
if pdu.state_key().is_some() {
if let Some(_state_key) = pdu.state_key() {
self.services
.spaces
.roomid_spacehierarchy_cache
@ -359,8 +359,6 @@ where
| _ => {},
}
self.services.roles.on_pdu_appended(room_id, &pdu);
// CONCERN: If we receive events with a relation out-of-order, we never write
// their relation / thread. We need some kind of way to trigger when we receive
// this event, and potentially a way to rebuild the table entirely.

View file

@ -97,17 +97,6 @@ pub async fn build_and_append_pdu(
)));
}
}
if *pdu.kind() == TimelineEventType::RoomPowerLevels {
if let Ok(proposed) =
pdu.get_content::<ruma::events::room::power_levels::RoomPowerLevelsEventContent>()
{
self.services
.roles
.validate_pl_change(&room_id, pdu.sender(), &proposed)
.await?;
}
}
if *pdu.kind() == TimelineEventType::RoomCreate {
trace!("Creating shortroomid for {room_id}");
self.services

View file

@ -80,7 +80,6 @@ struct Services {
threads: Dep<rooms::threads::Service>,
search: Dep<rooms::search::Service>,
spaces: Dep<rooms::spaces::Service>,
roles: Dep<rooms::roles::Service>,
event_handler: Dep<rooms::event_handler::Service>,
}
@ -113,7 +112,6 @@ impl crate::Service for Service {
threads: args.depend::<rooms::threads::Service>("rooms::threads"),
search: args.depend::<rooms::search::Service>("rooms::search"),
spaces: args.depend::<rooms::spaces::Service>("rooms::spaces"),
roles: args.depend::<rooms::roles::Service>("rooms::roles"),
event_handler: args
.depend::<rooms::event_handler::Service>("rooms::event_handler"),
},

View file

@ -228,7 +228,7 @@ async fn acquire_notary_result(&self, missing: &mut Batch, server_keys: ServerSi
self.add_signing_keys(server_keys.clone()).await;
if let Some(key_ids) = missing.get_mut(server) {
key_ids.retain(|key_id| !key_exists(&server_keys, key_id));
key_ids.retain(|key_id| key_exists(&server_keys, key_id));
if key_ids.is_empty() {
missing.remove(server);
}

View file

@ -1,7 +1,7 @@
use std::{any::Any, collections::BTreeMap, sync::Arc};
use conduwuit::{
Result, Server, SyncRwLock, debug, debug_info, error, info, trace, utils::stream::IterStream,
Result, Server, SyncRwLock, debug, debug_info, info, trace, utils::stream::IterStream,
};
use database::Database;
use futures::{Stream, StreamExt, TryStreamExt};
@ -11,8 +11,8 @@ use crate::{
account_data, admin, announcements, antispam, appservice, client, config, emergency,
federation, firstrun, globals, key_backups,
manager::Manager,
media, moderation, password_reset, presence, pusher, registration_tokens, resolver, rooms,
sending, server_keys,
media, moderation, presence, pusher, registration_tokens, resolver, rooms, sending,
server_keys,
service::{self, Args, Map, Service},
sync, transactions, uiaa, users,
};
@ -27,7 +27,6 @@ pub struct Services {
pub globals: Arc<globals::Service>,
pub key_backups: Arc<key_backups::Service>,
pub media: Arc<media::Service>,
pub password_reset: Arc<password_reset::Service>,
pub presence: Arc<presence::Service>,
pub pusher: Arc<pusher::Service>,
pub registration_tokens: Arc<registration_tokens::Service>,
@ -82,7 +81,6 @@ 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),
@ -96,7 +94,6 @@ impl Services {
outlier: build!(rooms::outlier::Service),
pdu_metadata: build!(rooms::pdu_metadata::Service),
read_receipt: build!(rooms::read_receipt::Service),
roles: build!(rooms::roles::Service),
search: build!(rooms::search::Service),
short: build!(rooms::short::Service),
spaces: build!(rooms::spaces::Service),
@ -128,12 +125,10 @@ impl Services {
}
pub async fn start(self: &Arc<Self>) -> Result<Arc<Self>> {
info!("Starting services...");
debug_info!("Starting services...");
self.admin.set_services(Some(Arc::clone(self)).as_ref());
super::migrations::migrations(self)
.await
.inspect_err(|e| error!("Migrations failed: {e}"))?;
super::migrations::migrations(self).await?;
self.manager
.lock()
.await
@ -152,7 +147,7 @@ impl Services {
.await;
}
info!("Services startup complete.");
debug_info!("Services startup complete.");
Ok(Arc::clone(self))
}

View file

@ -181,11 +181,20 @@ pub async fn try_auth(
uiaainfo.completed.push(AuthType::Password);
},
| AuthData::ReCaptcha(r) => {
let Some(ref private_site_key) = self.services.config.recaptcha_private_site_key
else {
if self.services.config.recaptcha_private_site_key.is_none() {
return Err!(Request(Forbidden("ReCaptcha is not configured.")));
};
match recaptcha_verify::verify_v3(private_site_key, r.response.as_str(), None).await {
}
match recaptcha_verify::verify(
self.services
.config
.recaptcha_private_site_key
.as_ref()
.unwrap(),
r.response.as_str(),
None,
)
.await
{
| Ok(()) => {
uiaainfo.completed.push(AuthType::ReCaptcha);
},

View file

@ -20,25 +20,12 @@ 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

View file

@ -1,2 +0,0 @@
[general]
dirs = ["pages/templates"]

View file

@ -1 +0,0 @@
fn main() { memory_serve::load_directory("./pages/resources"); }

94
src/web/css/index.css Normal file
View file

@ -0,0 +1,94 @@
: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;
}

View file

@ -1,113 +1,86 @@
use std::any::Any;
use askama::Template;
use axum::{
Router,
extract::rejection::{FormRejection, QueryRejection},
http::{HeaderValue, StatusCode, header},
extract::State,
http::{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;
use crate::pages::TemplateContext;
pub fn build() -> Router<state::State> {
Router::<state::State>::new()
.route("/", get(index_handler))
.route("/_continuwuity/logo.svg", get(logo_handler))
}
mod pages;
async fn index_handler(
State(services): State<state::State>,
) -> Result<impl IntoResponse, WebError> {
#[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::<u64>().to_string();
type State = state::State;
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()?),
))
}
const CATASTROPHIC_FAILURE: &str = "cat-astrophic failure! we couldn't even render the error template. \
please contact the team @ https://continuwuity.org";
async fn logo_handler() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/svg+xml")],
include_str!("templates/logo.svg").to_owned(),
)
}
#[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 {
error: WebError,
status: StatusCode,
context: TemplateContext,
struct Error<'a> {
nonce: &'a str,
err: WebError,
}
let nonce = rand::random::<u64>().to_string();
let status = match &self {
| Self::ValidationError(_)
| Self::BadRequest(_)
| Self::QueryRejection(_)
| Self::FormRejection(_) => StatusCode::BAD_REQUEST,
| Self::NotFound => StatusCode::NOT_FOUND,
| _ => StatusCode::INTERNAL_SERVER_ERROR,
| Self::Render(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
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()
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()
} else {
(status, CATASTROPHIC_FAILURE).into_response()
(status, "Something went wrong").into_response()
}
}
}
pub fn build() -> Router<state::State> {
#[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<dyn Any + Send + 'static>| {
let details = if let Some(s) = panic.downcast_ref::<String>() {
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();
}))
}

View file

@ -1,98 +0,0 @@
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<FormInput<'a>>,
pub validation_errors: Option<ValidationErrors>,
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<validator::ValidationErrors>) -> $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,
}
}
}
};
}

View file

@ -1,71 +0,0 @@
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<String>,
pub avatar_src: Option<String>,
}
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 }
}
}

View file

@ -1,17 +0,0 @@
use std::convert::Infallible;
use axum::{Router, routing::get};
use conduwuit_core::Error;
use crate::WebError;
pub(crate) fn build() -> Router<crate::State> {
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()
}),
)
}

View file

@ -1,28 +0,0 @@
use askama::Template;
use axum::{Router, extract::State, response::IntoResponse, routing::get};
use crate::{WebError, template};
pub(crate) fn build() -> Router<crate::State> {
Router::new()
.route("/", get(index_handler))
.route("/_continuwuity/", get(index_handler))
}
async fn index_handler(
State(services): State<crate::State>,
) -> Result<impl IntoResponse, WebError> {
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())
}

View file

@ -1,53 +0,0 @@
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()
}
}
}
};
}

View file

@ -1,120 +0,0 @@
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<crate::State> {
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<impl IntoResponse, WebError> {
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<crate::State>,
query: Result<Query<PasswordResetQuery>, QueryRejection>,
) -> Result<impl IntoResponse, WebError> {
let Query(query) = query?;
password_reset_form(services, query, PasswordResetForm::build(None)).await
}
async fn post_password_reset(
State(services): State<crate::State>,
query: Result<Query<PasswordResetQuery>, QueryRejection>,
form: Result<axum::Form<PasswordResetForm>, FormRejection>,
) -> Result<Response, WebError> {
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()),
}
}

View file

@ -1,9 +0,0 @@
use axum::Router;
pub(crate) fn build() -> Router<crate::State> {
Router::new().nest(
"/resources/",
#[allow(unused_qualifications)]
memory_serve::load!().index_file(None).into_router(),
)
}

View file

@ -1,185 +0,0 @@
: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%;
}
}

View file

@ -1,44 +0,0 @@
.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);
}
}
}
}

View file

@ -1,13 +0,0 @@
.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;
}

View file

@ -1,11 +0,0 @@
.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);
}

View file

@ -1,43 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="447.99823"
height="447.99823"
viewBox="0 0 447.99823 447.99823"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
id="layer1"
transform="translate(-32.000893,-32.000893)"><circle
style="fill:#9b4bd4;fill-opacity:1;stroke:#000000;stroke-width:10;stroke-dasharray:none;stroke-opacity:1"
id="path1"
cy="256"
cx="256"
r="176" /><path
style="fill:#de6cd3;fill-opacity:1;stroke:#000000;stroke-width:10;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 41,174 69,36 C 135,126 175,102 226,94 l -12,31 62,-44 -69,-44 15,30 C 128,69 84,109 41,172 Z"
id="path7" /><path
style="fill:#de6cd3;fill-opacity:1;stroke:#000000;stroke-width:10;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 338,41 -36,69 c 84,25 108,65 116,116 l -31,-12 44,62 44,-69 -30,15 C 443,128 403,84 340,41 Z"
id="path6" /><path
style="fill:#de6cd3;fill-opacity:1;stroke:#000000;stroke-width:10;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 471,338 -69,-36 c -25,84 -65,108 -116,116 l 12,-31 -62,44 69,44 -15,-30 c 94,-2 138,-42 181,-105 z"
id="path8" /><path
style="fill:#de6cd3;fill-opacity:1;stroke:#000000;stroke-width:10;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 174,471 36,-69 C 126,377 102,337 94,286 l 31,12 -44,-62 -44,69 30,-15 c 2,94 42,138 105,181 z"
id="path9" /><g
id="g15"
transform="translate(-5.4157688e-4)"><path
style="fill:none;stroke:#000000;stroke-width:10;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
d="m 155.45977,224.65379 c -7.25909,13.49567 -7.25909,26.09161 -6.35171,39.58729 0.90737,11.69626 12.7034,24.29222 24.49943,26.09164 21.77727,3.59884 28.12898,-20.69338 28.12898,-20.69338 0,0 4.53693,-15.29508 5.4443,-40.48699"
id="path11" /><path
style="fill:none;stroke:#000000;stroke-width:10;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
d="m 218.96706,278.05399 c 3.00446,17.12023 7.52704,24.88918 19.22704,28.48918 9,2.7 22.5,-4.5 22.5,-16.2 0.9,21.6 17.1,17.1 19.8,17.1 11.7,-1.8 18.9,-14.4 16.2,-30.6"
id="path12" /><path
style="fill:none;stroke:#000000;stroke-width:10;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
d="m 305.6941,230.94317 c 1.8,27 6.3,40.5 6.3,40.5 8.1,27 28.8,19.8 28.8,19.8 18.9,-7.2 22.5,-24.3 22.5,-30.6 0,-25.2 -6.3,-35.1 -6.3,-35.1"
id="path13" /></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -1,6 +0,0 @@
{% match avatar_type %}
{% when AvatarType::Initial with (initial) %}
<span class="avatar" role="img">{{ initial }}</span>
{% when AvatarType::Image with (src) %}
<img class="avatar" src="{{ src }}">
{% endmatch %}

View file

@ -1,30 +0,0 @@
<form method="post">
{% let validation_errors = validation_errors.clone().unwrap_or_default() %}
{% let field_errors = validation_errors.field_errors() %}
{% for input in inputs %}
<p>
<label for="{{ input.id }}">{{ input.label }}</label>
{% let name = std::borrow::Cow::from(*input.id) %}
{% if let Some(errors) = field_errors.get(name) %}
{% for error in errors %}
<small class="error">
{% if let Some(message) = error.message %}
{{ message }}
{% else %}
Mysterious validation error <code>{{ error.code }}</code>!
{% endif %}
</small>
{% endfor %}
{% endif %}
<input
type="{{ input.input_type }}"
id="{{ input.id }}"
autocomplete="{{ input.autocomplete }}"
{% if input.type_name.is_some() %}name="{{ input.id }}"{% endif %}
{% if input.required %}required{% endif %}
>
</p>
{% endfor %}
<button type="submit">{{ submit_label }}</button>
</form>

View file

@ -1,9 +0,0 @@
<div class="user-card">
{{ avatar() }}
<div class="info">
{% if let Some(display_name) = display_name %}
<p class="display-name">{{ display_name }}</p>
{% endif %}
<p class="user_id">{{ user_id }}</p>
</div>
</div>

View file

@ -1,41 +0,0 @@
{% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="/_continuwuity/resources/error.css">
{%- endblock -%}
{%- block title -%}
🐈 Request Error
{%- endblock -%}
{%- block content -%}
<pre class="k10y" aria-hidden>
        
      |  _  _|
     ` ミ_x
     /      |
    /  ヽ   ノ
    │  | | |
 / ̄|   | | |
 | ( ̄ヽ__ヽ_)__)
 \二つ
</pre>
<div class="panel">
<h1>
{% if status == StatusCode::NOT_FOUND %}
Not found
{% else if status == StatusCode::INTERNAL_SERVER_ERROR %}
Internal server error
{% else %}
Bad request
{% endif %}
</h1>
{% if status == StatusCode::INTERNAL_SERVER_ERROR %}
<p>Please <a href="https://forgejo.ellis.link/continuwuation/continuwuity/issues/new">submit a bug report</a> 🥺</p>
{% endif %}
<pre><code>{{ error }}</code></pre>
</div>
{%- endblock -%}

View file

@ -1,18 +0,0 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Reset Password
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
<h1>Reset Password</h1>
{{ user_card }}
{% match body %}
{% when PasswordResetBody::Form(reset_form) %}
{{ reset_form }}
{% when PasswordResetBody::Success %}
<p>Your password has been reset successfully.</p>
{% endmatch %}
</div>
{%- endblock -%}

View file

@ -5,24 +5,23 @@
<meta charset="UTF-8" />
<title>{% block title %}Continuwuity{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
{%- if !context.allow_indexing %}
<meta name="robots" content="noindex" />
{%- endif %}
<link rel="icon" href="/_continuwuity/resources/logo.svg">
<link rel="stylesheet" href="/_continuwuity/resources/common.css">
<link rel="stylesheet" href="/_continuwuity/resources/components.css">
{% block head %}{% endblock %}
<link rel="icon" href="/_continuwuity/logo.svg">
<style type="text/css" nonce="{{ nonce }}">
/*<![CDATA[*/
{{ include_str !("css/index.css") | safe }}
/*]]>*/
</style>
</head>
<body>
<main>{%~ block content %}{% endblock ~%}</main>
{%~ block footer ~%}
<footer>
<img class="logo" src="/_continuwuity/resources/logo.svg">
<img class="logo" src="/_continuwuity/logo.svg">
<p>Powered by <a href="https://continuwuity.org">Continuwuity</a> {{ env!("CARGO_PKG_VERSION") }}
{%~ if let Some(version_info) = conduwuit_build_metadata::version_tag() ~%}
{%~ if let Some(url) = conduwuit_build_metadata::GIT_REMOTE_COMMIT_URL.or(conduwuit_build_metadata::GIT_REMOTE_WEB_URL) ~%}
{%~ if let Some(version_info) = self::version_tag() ~%}
{%~ if let Some(url) = GIT_REMOTE_COMMIT_URL.or(GIT_REMOTE_WEB_URL) ~%}
(<a href="{{ url }}">{{ version_info }}</a>)
{%~ else ~%}
({{ version_info }})

View file

@ -0,0 +1,20 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Server Error
{%- endblock -%}
{%- block content -%}
<h1>
{%- match err -%}
{% else -%} 500: Internal Server Error
{%- endmatch -%}
</h1>
{%- match err -%}
{% when WebError::Render(err) -%}
<pre>{{ err }}</pre>
{% else -%} <p>An error occurred</p>
{%- endmatch -%}
{%- endblock -%}

View file

@ -1,9 +1,4 @@
{% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="/_continuwuity/resources/index.css">
{%- endblock -%}
{%- block content -%}
<div class="panel">
<h1>
@ -11,7 +6,7 @@
</h1>
<p>Continuwuity is successfully installed and working.</p>
{%- if first_run %}
<p>To get started, <em>check the server logs</em> for instructions on how to create the first account.</p>
<p>To get started, <b>check the server logs</b> for instructions on how to create the first account.</p>
<p>For support, take a look at the <a href="https://continuwuity.org/introduction">documentation</a> or join the <a href="https://matrix.to/#/#continuwuity:continuwuity.org?via=continuwuity.org&via=ellis.link&via=explodie.org&via=matrix.org">Continuwuity Matrix room</a>.</p>
{%- else %}
<p>To get started, <a href="https://matrix.org/ecosystem/clients">choose a client</a> and connect to <code>{{ server_name }}</code>.</p>

1
src/web/templates/logo.svg Symbolic link
View file

@ -0,0 +1 @@
../../../docs/public/assets/logo.svg