Merge pull request #52 from LLukas22/user-page
Rework User Management/Account Creation and Introduce Automatic User Creation
361
Cargo.lock
generated
@@ -2,21 +2,47 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
|
||||
dependencies = [
|
||||
"gimli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
@@ -147,6 +173,16 @@ dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.27"
|
||||
@@ -358,21 +394,6 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backtrace"
|
||||
version = "0.3.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002"
|
||||
dependencies = [
|
||||
"addr2line",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"miniz_oxide",
|
||||
"object",
|
||||
"rustc-demangle",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -493,7 +514,17 @@ dependencies = [
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -671,9 +702,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
@@ -758,6 +799,24 @@ dependencies = [
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
|
||||
dependencies = [
|
||||
"deadpool-runtime",
|
||||
"lazy_static",
|
||||
"num_cpus",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool-runtime"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -974,6 +1033,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
@@ -1053,6 +1113,7 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
@@ -1102,10 +1163,14 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.31.1"
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
@@ -1170,6 +1235,12 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
@@ -1260,13 +1331,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.6.0"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80"
|
||||
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
@@ -1274,6 +1346,7 @@ dependencies = [
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
@@ -1314,7 +1387,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
@@ -1483,14 +1556,12 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-uring"
|
||||
version = "0.7.8"
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1515,9 +1586,23 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
|
||||
[[package]]
|
||||
name = "jellyfin-api"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jellyswarrm-macros"
|
||||
version = "0.1.5"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1529,8 +1614,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellyswarrm-proxy"
|
||||
version = "0.1.5"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"askama",
|
||||
"askama_axum",
|
||||
@@ -1549,6 +1635,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"indexmap 2.12.1",
|
||||
"jellyfin-api",
|
||||
"jellyswarrm-macros",
|
||||
"mime_guess",
|
||||
"mockall",
|
||||
@@ -1867,12 +1954,13 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.36.7"
|
||||
name = "num_cpus"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
|
||||
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1881,6 +1969,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "2.10.1"
|
||||
@@ -2068,6 +2162,18 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.11.1"
|
||||
@@ -2146,7 +2252,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.12",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -2183,7 +2289,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
@@ -2491,12 +2597,6 @@ dependencies = [
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@@ -2849,6 +2949,16 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
@@ -3288,29 +3398,26 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.46.1"
|
||||
version = "1.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17"
|
||||
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
"io-uring",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"socket2",
|
||||
"socket2 0.6.1",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.5.0"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
|
||||
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3695,6 +3802,16 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -3965,7 +4082,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
@@ -3998,13 +4115,19 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
@@ -4015,7 +4138,7 @@ version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4024,7 +4147,7 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4054,6 +4177,24 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
@@ -4078,13 +4219,30 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
@@ -4097,6 +4255,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
@@ -4109,6 +4273,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
@@ -4121,12 +4291,24 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
@@ -4139,6 +4321,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
@@ -4151,6 +4339,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
@@ -4163,6 +4357,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
@@ -4175,6 +4375,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.14"
|
||||
@@ -4184,6 +4390,29 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wiremock"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
|
||||
dependencies = [
|
||||
"assert-json-diff",
|
||||
"base64",
|
||||
"deadpool",
|
||||
"futures",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rt"
|
||||
version = "0.39.0"
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
members = [
|
||||
"crates/jellyswarrm-proxy",
|
||||
"crates/jellyswarrm-macros",
|
||||
"crates/jellyfin-api",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.5"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
authors = ["lukaskreussel@gmail.com"]
|
||||
repository = "https://github.com/LLukas22/Jellyswarrm"
|
||||
|
||||
11
Dockerfile
@@ -55,12 +55,14 @@ COPY .cargo .cargo
|
||||
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
|
||||
COPY crates/jellyswarrm-proxy/Cargo.toml crates/jellyswarrm-proxy/Cargo.toml
|
||||
COPY crates/jellyswarrm-macros/Cargo.toml crates/jellyswarrm-macros/Cargo.toml
|
||||
COPY crates/jellyfin-api/Cargo.toml crates/jellyfin-api/Cargo.toml
|
||||
|
||||
# Create dummy source files to build dependencies
|
||||
RUN mkdir -p crates/jellyswarrm-proxy/src crates/jellyswarrm-macros/src \
|
||||
RUN mkdir -p crates/jellyswarrm-proxy/src crates/jellyswarrm-macros/src crates/jellyfin-api/src \
|
||||
&& echo "fn main() {}" > crates/jellyswarrm-proxy/src/main.rs \
|
||||
&& echo "" > crates/jellyswarrm-proxy/src/lib.rs \
|
||||
&& echo "use proc_macro::TokenStream; #[proc_macro_attribute] pub fn multi_case_struct(_args: TokenStream, input: TokenStream) -> TokenStream { input }" > crates/jellyswarrm-macros/src/lib.rs
|
||||
&& echo "use proc_macro::TokenStream; #[proc_macro_attribute] pub fn multi_case_struct(_args: TokenStream, input: TokenStream) -> TokenStream { input }" > crates/jellyswarrm-macros/src/lib.rs \
|
||||
&& echo "" > crates/jellyfin-api/src/lib.rs
|
||||
|
||||
# Build dependencies only (will be cached)
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
@@ -68,7 +70,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/tmp/target,sharing=locked \
|
||||
CARGO_TARGET_DIR=/tmp/target cargo build --release --bin jellyswarrm-proxy \
|
||||
&& cp /tmp/target/release/jellyswarrm-proxy /app/jellyswarrm-proxy-deps \
|
||||
&& rm -rf crates/jellyswarrm-proxy/src crates/jellyswarrm-macros/src
|
||||
&& rm -rf crates/jellyswarrm-proxy/src crates/jellyswarrm-macros/src crates/jellyfin-api/src
|
||||
|
||||
#################################
|
||||
# Stage 3: Build Rust Application
|
||||
@@ -86,13 +88,16 @@ COPY crates/jellyswarrm-proxy/askama.toml crates/jellyswarrm-proxy/askama.toml
|
||||
COPY crates/jellyswarrm-proxy/src crates/jellyswarrm-proxy/src
|
||||
COPY crates/jellyswarrm-proxy/migrations crates/jellyswarrm-proxy/migrations
|
||||
COPY crates/jellyswarrm-macros/src crates/jellyswarrm-macros/src
|
||||
COPY crates/jellyfin-api/src crates/jellyfin-api/src
|
||||
|
||||
# Build only the application code (dependencies already cached)
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/tmp/target,sharing=locked \
|
||||
rm -rf /tmp/target/release/deps/libjellyswarrm_macros* /tmp/target/release/deps/jellyswarrm_macros* \
|
||||
&& rm -rf /tmp/target/release/deps/libjellyfin_api* \
|
||||
&& touch crates/jellyswarrm-macros/src/lib.rs \
|
||||
&& touch crates/jellyfin-api/src/lib.rs \
|
||||
&& CARGO_TARGET_DIR=/tmp/target cargo build --release --bin jellyswarrm-proxy \
|
||||
&& cp /tmp/target/release/jellyswarrm-proxy /app/jellyswarrm-proxy
|
||||
|
||||
|
||||
12
README.md
@@ -26,7 +26,8 @@ Jellyswarrm is a reverse proxy that lets you combine multiple Jellyfin servers i
|
||||
<p align="center">
|
||||
<!-- Side-by-side smaller views, same height -->
|
||||
<img src="./media/servers.png" alt="Server Selection" height="250px" style="margin-right:10px;">
|
||||
<img src="./media/users.png" alt="User Mappings" height="250px">
|
||||
<img src="./media/users.png" alt="User Mappings" height="250px" style="margin-right:10px;">
|
||||
<img src="./media/user_page.png" alt="Settings" height="250px">
|
||||
</p>
|
||||
|
||||
## Features
|
||||
@@ -40,17 +41,16 @@ Jellyswarrm is a reverse proxy that lets you combine multiple Jellyfin servers i
|
||||
* **Direct Playback** – Play content straight from the original server without extra overhead.
|
||||
* **User Mapping** – Link accounts across servers for a consistent user experience.
|
||||
* **API Compatibility** – Appears as a normal Jellyfin server, so existing apps and tools still work.
|
||||
* **Server Federation** – Automatically sync users across connected servers.
|
||||
* **User Page** – Personal dashboard for managing credentials and libraries.
|
||||
|
||||
### ⚠️ In Progress
|
||||
|
||||
- **QuickConnect** – This feature isn’t available yet. Please log in using your **username & password** for now.
|
||||
* **QuickConnect** – This feature isn’t available yet. Please log in using your **username & password** for now.
|
||||
* **Websocket Support** – Needed for real-time features like SyncPlay (not fully reliable yet).
|
||||
* **Audio Streaming** – May not function correctly (still untested in many cases).
|
||||
* **Automatic Bitrate Adjustment** – Stream quality based on network conditions isn’t supported yet.
|
||||
|
||||
### 🚫 Not Planned
|
||||
|
||||
* **Admin Functions** – Server administration (user management, settings, etc.) won’t be supported through Jellyswarrm.
|
||||
* **Media Management** – Features like adding or deleting media libraries through Jellyswarrm are not implemented yet.
|
||||
|
||||
---
|
||||
|
||||
|
||||
18
crates/jellyfin-api/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "jellyfin-api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
url = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
wiremock = "0.6"
|
||||
23
crates/jellyfin-api/src/error.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Network error: {0}")]
|
||||
Network(#[from] reqwest::Error),
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
#[error("URL parse error: {0}")]
|
||||
UrlParse(#[from] url::ParseError),
|
||||
#[error("Authentication failed: {0}")]
|
||||
AuthenticationFailed(String),
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("Forbidden")]
|
||||
Forbidden,
|
||||
#[error("Not found")]
|
||||
NotFound,
|
||||
#[error("Server error: {0}")]
|
||||
ServerError(String),
|
||||
#[error("Invalid response: {0}")]
|
||||
InvalidResponse(String),
|
||||
}
|
||||
305
crates/jellyfin-api/src/lib.rs
Normal file
@@ -0,0 +1,305 @@
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
|
||||
use error::Error;
|
||||
use models::{AuthResponse, MediaFoldersResponse, User};
|
||||
use reqwest::{header, Client, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientInfo {
|
||||
pub client: String,
|
||||
pub device: String,
|
||||
pub device_id: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl Default for ClientInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
client: "Jellyfin API Client".to_string(),
|
||||
device: "Unknown".to_string(),
|
||||
device_id: "unknown-device-id".to_string(),
|
||||
version: "0.0.0".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct JellyfinClient {
|
||||
base_url: Url,
|
||||
client_info: ClientInfo,
|
||||
http_client: Client,
|
||||
auth_token: Arc<RwLock<Option<String>>>,
|
||||
}
|
||||
|
||||
impl JellyfinClient {
|
||||
pub fn new(base_url: &str, client_info: ClientInfo) -> Result<Self, Error> {
|
||||
let mut url = Url::parse(base_url)?;
|
||||
// Ensure no trailing slash for consistent joining
|
||||
if url.path().ends_with('/') {
|
||||
url.path_segments_mut()
|
||||
.map_err(|_| Error::UrlParse(url::ParseError::EmptyHost))?
|
||||
.pop_if_empty();
|
||||
}
|
||||
|
||||
let http_client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
|
||||
Ok(Self {
|
||||
base_url: url,
|
||||
client_info,
|
||||
http_client,
|
||||
auth_token: Arc::new(RwLock::new(None)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_token(self, token: String) -> Self {
|
||||
*self.auth_token.write().unwrap() = Some(token);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn get_token(&self) -> Option<String> {
|
||||
self.auth_token.read().unwrap().clone()
|
||||
}
|
||||
|
||||
fn build_auth_header(&self) -> String {
|
||||
let mut header = format!(
|
||||
"MediaBrowser Client=\"{}\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\"",
|
||||
self.client_info.client,
|
||||
self.client_info.device,
|
||||
self.client_info.device_id,
|
||||
self.client_info.version
|
||||
);
|
||||
|
||||
if let Some(token) = self.auth_token.read().unwrap().as_ref() {
|
||||
header.push_str(&format!(", Token=\"{}\"", token));
|
||||
}
|
||||
|
||||
// println!("DEBUG HEADER: {}", header);
|
||||
header
|
||||
}
|
||||
|
||||
async fn request<T: DeserializeOwned>(
|
||||
&self,
|
||||
method: reqwest::Method,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> Result<T, Error> {
|
||||
let url = self.base_url.join(path)?;
|
||||
let auth_header = self.build_auth_header();
|
||||
|
||||
let mut request = self
|
||||
.http_client
|
||||
.request(method, url)
|
||||
.header(header::AUTHORIZATION, auth_header);
|
||||
|
||||
if let Some(b) = body {
|
||||
request = request.json(b);
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
let data = response.json::<T>().await?;
|
||||
Ok(data)
|
||||
} else {
|
||||
match status {
|
||||
StatusCode::UNAUTHORIZED => Err(Error::Unauthorized),
|
||||
StatusCode::FORBIDDEN => Err(Error::Forbidden),
|
||||
StatusCode::NOT_FOUND => Err(Error::NotFound),
|
||||
_ => {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
Err(Error::ServerError(format!("{} - {}", status, text)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_no_content(
|
||||
&self,
|
||||
method: reqwest::Method,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> Result<(), Error> {
|
||||
let url = self.base_url.join(path)?;
|
||||
let auth_header = self.build_auth_header();
|
||||
|
||||
let mut request = self
|
||||
.http_client
|
||||
.request(method, url)
|
||||
.header(header::AUTHORIZATION, auth_header);
|
||||
|
||||
if let Some(b) = body {
|
||||
request = request.json(b);
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
match status {
|
||||
StatusCode::UNAUTHORIZED => Err(Error::Unauthorized),
|
||||
StatusCode::FORBIDDEN => Err(Error::Forbidden),
|
||||
StatusCode::NOT_FOUND => Err(Error::NotFound),
|
||||
_ => {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
Err(Error::ServerError(format!("{} - {}", status, text)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authenticate_by_name(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<User, Error> {
|
||||
let body = json!({
|
||||
"Username": username,
|
||||
"Pw": password
|
||||
});
|
||||
|
||||
let response: AuthResponse = self
|
||||
.request(
|
||||
reqwest::Method::POST,
|
||||
"Users/AuthenticateByName",
|
||||
Some(&body),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
Error::Unauthorized => {
|
||||
Error::AuthenticationFailed("Invalid credentials".to_string())
|
||||
}
|
||||
_ => e,
|
||||
})?;
|
||||
|
||||
*self.auth_token.write().unwrap() = Some(response.access_token);
|
||||
Ok(response.user)
|
||||
}
|
||||
|
||||
pub async fn logout(&self) -> Result<(), Error> {
|
||||
self.request_no_content(reqwest::Method::POST, "Sessions/Logout", None)
|
||||
.await?;
|
||||
*self.auth_token.write().unwrap() = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_me(&self) -> Result<User, Error> {
|
||||
self.request(reqwest::Method::GET, "Users/Me", None).await
|
||||
}
|
||||
|
||||
pub async fn get_media_folders(&self) -> Result<Vec<models::MediaFolder>, Error> {
|
||||
let response: MediaFoldersResponse = self
|
||||
.request(reqwest::Method::GET, "Library/MediaFolders", None)
|
||||
.await?;
|
||||
Ok(response.items)
|
||||
}
|
||||
|
||||
pub async fn get_public_system_info(&self) -> Result<models::PublicSystemInfo, Error> {
|
||||
self.request(reqwest::Method::GET, "System/Info/Public", None)
|
||||
.await
|
||||
}
|
||||
|
||||
// Admin methods
|
||||
|
||||
pub async fn get_users(&self) -> Result<Vec<User>, Error> {
|
||||
self.request(reqwest::Method::GET, "Users", None).await
|
||||
}
|
||||
|
||||
pub async fn create_user(&self, username: &str, password: Option<&str>) -> Result<User, Error> {
|
||||
let body = json!({
|
||||
"Name": username,
|
||||
"Password": password
|
||||
});
|
||||
|
||||
let user: User = self
|
||||
.request(reqwest::Method::POST, "Users/New", Some(&body))
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn delete_user(&self, user_id: &str) -> Result<(), Error> {
|
||||
let path = format!("Users/{}", user_id);
|
||||
self.request_no_content(reqwest::Method::DELETE, &path, None)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_authenticate_success() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
let auth_response = json!({
|
||||
"AccessToken": "test_token",
|
||||
"User": {
|
||||
"Id": "user_id",
|
||||
"Name": "test_user",
|
||||
"ServerId": "server_id"
|
||||
}
|
||||
});
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/Users/AuthenticateByName"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(auth_response))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let client_info = ClientInfo::default();
|
||||
let client = JellyfinClient::new(&mock_server.uri(), client_info).unwrap();
|
||||
|
||||
let user = client
|
||||
.authenticate_by_name("test_user", "password")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(user.name, "test_user");
|
||||
assert_eq!(client.get_token().as_deref(), Some("test_token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_media_folders() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
let folders_response = json!({
|
||||
"Items": [
|
||||
{
|
||||
"Name": "Movies",
|
||||
"CollectionType": "movies",
|
||||
"Id": "folder_1"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/Library/MediaFolders"))
|
||||
//.and(header("Authorization", "MediaBrowser Client=\"Jellyfin API Client\", Device=\"Unknown\", DeviceId=\"unknown-device-id\", Version=\"0.0.0\", Token=\"test_token\""))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(folders_response))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let client_info = ClientInfo::default();
|
||||
let client = JellyfinClient::new(&mock_server.uri(), client_info)
|
||||
.unwrap()
|
||||
.with_token("test_token".to_string());
|
||||
|
||||
let folders = client.get_media_folders().await.unwrap();
|
||||
|
||||
assert_eq!(folders.len(), 1);
|
||||
assert_eq!(folders[0].name, "Movies");
|
||||
}
|
||||
}
|
||||
67
crates/jellyfin-api/src/models.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserPolicy {
|
||||
#[serde(rename = "IsAdministrator")]
|
||||
pub is_administrator: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
#[serde(rename = "Id")]
|
||||
pub id: String,
|
||||
#[serde(rename = "Name")]
|
||||
pub name: String,
|
||||
#[serde(rename = "ServerId")]
|
||||
pub server_id: Option<String>,
|
||||
#[serde(rename = "Policy")]
|
||||
pub policy: Option<UserPolicy>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthResponse {
|
||||
#[serde(rename = "AccessToken")]
|
||||
pub access_token: String,
|
||||
#[serde(rename = "User")]
|
||||
pub user: User,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MediaFolder {
|
||||
#[serde(rename = "Name")]
|
||||
pub name: String,
|
||||
#[serde(rename = "CollectionType")]
|
||||
pub collection_type: Option<String>,
|
||||
#[serde(rename = "Id")]
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MediaFoldersResponse {
|
||||
#[serde(rename = "Items")]
|
||||
pub items: Vec<MediaFolder>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NewUserRequest {
|
||||
#[serde(rename = "Name")]
|
||||
pub name: String,
|
||||
#[serde(rename = "Password")]
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PublicSystemInfo {
|
||||
#[serde(rename = "LocalAddress")]
|
||||
pub local_address: Option<String>,
|
||||
#[serde(rename = "ServerName")]
|
||||
pub server_name: Option<String>,
|
||||
#[serde(rename = "Version")]
|
||||
pub version: Option<String>,
|
||||
#[serde(rename = "ProductName")]
|
||||
pub product_name: Option<String>,
|
||||
#[serde(rename = "Id")]
|
||||
pub id: Option<String>,
|
||||
#[serde(rename = "StartupWizardCompleted")]
|
||||
pub startup_wizard_completed: Option<bool>,
|
||||
}
|
||||
@@ -59,7 +59,9 @@ serde-aux = "4.7.0"
|
||||
async-trait = "0.1.89"
|
||||
async-recursion = "1.1.1"
|
||||
moka = { version = "0.12.11", features = ["future"] }
|
||||
aes-gcm = "0.10.3"
|
||||
serde_with = "3.16.1"
|
||||
jellyfin-api = { path = "../jellyfin-api" }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4.4"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS server_admins;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Create server_admins table
|
||||
CREATE TABLE IF NOT EXISTS server_admins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (server_id) REFERENCES servers (id) ON DELETE CASCADE,
|
||||
UNIQUE(server_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_server_admins_server_id ON server_admins(server_id);
|
||||
@@ -9,12 +9,20 @@ use tower_sessions::cookie::Key;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
use jellyfin_api::ClientInfo;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use base64::prelude::*;
|
||||
|
||||
pub static MIGRATOR: Migrator = sqlx::migrate!();
|
||||
|
||||
pub static CLIENT_INFO: Lazy<ClientInfo> = Lazy::new(|| ClientInfo {
|
||||
client: "Jellyswarrm Proxy".to_string(),
|
||||
device: "Server".to_string(),
|
||||
device_id: "jellyswarrm-proxy".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
});
|
||||
|
||||
// Lazily-resolved data directory shared across the application.
|
||||
// Priority: env var JELLYSWARRM_DATA_DIR, else "./data" relative to current working dir.
|
||||
// The directory is created on first access.
|
||||
|
||||
184
crates/jellyswarrm-proxy/src/encryption.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
//! Encryption utilities for securing server mapping passwords
|
||||
//!
|
||||
//! This module provides functions to encrypt and decrypt server mapping passwords
|
||||
//! using the user's master password with AES-GCM encryption.
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit},
|
||||
Aes256Gcm, Nonce,
|
||||
};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use rand::RngCore;
|
||||
use std::string::ToString;
|
||||
|
||||
/// Custom error type for encryption/decryption operations
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EncryptionError {
|
||||
#[error("Encryption failed: {0}")]
|
||||
EncryptionFailed(String),
|
||||
#[error("Decryption failed: {0}")]
|
||||
DecryptionFailed(String),
|
||||
#[error("Base64 decoding failed: {0}")]
|
||||
Base64DecodeFailed(#[from] base64::DecodeError),
|
||||
#[error("Invalid nonce size")]
|
||||
InvalidNonceSize,
|
||||
#[error("Password decryption failed - possibly incorrect password")]
|
||||
PasswordDecryptionFailed,
|
||||
}
|
||||
|
||||
/// Encrypts a password using the provided master password
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `plaintext` - The password to encrypt
|
||||
/// * `master_password` - The master password used as encryption key
|
||||
///
|
||||
/// # Returns
|
||||
/// Base64-encoded string containing the nonce and encrypted data
|
||||
pub fn encrypt_password(plaintext: &str, master_password: &str) -> Result<String, EncryptionError> {
|
||||
tracing::debug!("Encrypting password with master password");
|
||||
|
||||
// Derive a 32-byte key from the master password using SHA-256
|
||||
let key = derive_key(master_password);
|
||||
let cipher = Aes256Gcm::new(&key.into());
|
||||
|
||||
// Generate a random 12-byte nonce
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
// Encrypt the plaintext
|
||||
let plaintext_bytes = plaintext.as_bytes();
|
||||
let ciphertext = cipher.encrypt(nonce, plaintext_bytes).map_err(|e| {
|
||||
tracing::error!("Encryption failed: {}", e);
|
||||
EncryptionError::EncryptionFailed(e.to_string())
|
||||
})?;
|
||||
|
||||
// Combine nonce and ciphertext
|
||||
let mut combined = Vec::with_capacity(nonce_bytes.len() + ciphertext.len());
|
||||
combined.extend_from_slice(&nonce_bytes);
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
|
||||
// Encode as base64 for storage
|
||||
let encoded = general_purpose::STANDARD.encode(&combined);
|
||||
tracing::debug!("Password encrypted successfully");
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
/// Decrypts a password using the provided master password
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `encrypted_data` - Base64-encoded string containing nonce and encrypted data
|
||||
/// * `master_password` - The master password used as decryption key
|
||||
///
|
||||
/// # Returns
|
||||
/// The decrypted password as a String
|
||||
pub fn decrypt_password(
|
||||
encrypted_data: &str,
|
||||
master_password: &str,
|
||||
) -> Result<String, EncryptionError> {
|
||||
tracing::debug!("Decrypting password with master password");
|
||||
|
||||
// Decode the base64 data
|
||||
let combined = general_purpose::STANDARD
|
||||
.decode(encrypted_data)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Base64 decoding failed: {}", e);
|
||||
EncryptionError::Base64DecodeFailed(e)
|
||||
})?;
|
||||
|
||||
// Extract nonce (first 12 bytes) and ciphertext (remaining bytes)
|
||||
if combined.len() < 12 {
|
||||
tracing::error!(
|
||||
"Invalid nonce size: expected at least 12 bytes, got {}",
|
||||
combined.len()
|
||||
);
|
||||
return Err(EncryptionError::InvalidNonceSize);
|
||||
}
|
||||
|
||||
let (nonce_bytes, ciphertext) = combined.split_at(12);
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
|
||||
// Derive the same key from the master password
|
||||
let key = derive_key(master_password);
|
||||
let cipher = Aes256Gcm::new(&key.into());
|
||||
|
||||
// Decrypt the ciphertext
|
||||
let plaintext_bytes = cipher.decrypt(nonce, ciphertext).map_err(|e| {
|
||||
tracing::error!("Decryption failed: {}", e);
|
||||
EncryptionError::PasswordDecryptionFailed
|
||||
})?;
|
||||
|
||||
// Convert to string
|
||||
let result = String::from_utf8(plaintext_bytes).map_err(|e| {
|
||||
tracing::error!("Invalid UTF-8 in decrypted data: {}", e);
|
||||
EncryptionError::DecryptionFailed(format!("Invalid UTF-8: {}", e))
|
||||
})?;
|
||||
|
||||
tracing::debug!("Password decrypted successfully");
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Derives a 32-byte key from a password using SHA-256
|
||||
fn derive_key(password: &str) -> [u8; 32] {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(password.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&result[..32]);
|
||||
key
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt() {
|
||||
let password = "my_secret_password";
|
||||
let master_password = "master_key_123";
|
||||
|
||||
let encrypted = encrypt_password(password, master_password).unwrap();
|
||||
let decrypted = decrypt_password(&encrypted, master_password).unwrap();
|
||||
|
||||
assert_eq!(password, decrypted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_with_wrong_key() {
|
||||
let password = "my_secret_password";
|
||||
let master_password = "master_key_123";
|
||||
let wrong_password = "wrong_key_456";
|
||||
|
||||
let encrypted = encrypt_password(password, master_password).unwrap();
|
||||
let result = decrypt_password(&encrypted, wrong_password);
|
||||
|
||||
assert!(result.is_err());
|
||||
matches!(
|
||||
result.unwrap_err(),
|
||||
EncryptionError::PasswordDecryptionFailed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_password() {
|
||||
let password = "";
|
||||
let master_password = "master_key_123";
|
||||
|
||||
let encrypted = encrypt_password(password, master_password).unwrap();
|
||||
let decrypted = decrypt_password(&encrypted, master_password).unwrap();
|
||||
|
||||
assert_eq!(password, decrypted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_special_characters() {
|
||||
let password = "p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?";
|
||||
let master_password = "m@st3r_k3y!@#$%^&*()";
|
||||
|
||||
let encrypted = encrypt_password(password, master_password).unwrap();
|
||||
let decrypted = decrypt_password(&encrypted, master_password).unwrap();
|
||||
|
||||
assert_eq!(password, decrypted);
|
||||
}
|
||||
}
|
||||
420
crates/jellyswarrm-proxy/src/federated_users.rs
Normal file
@@ -0,0 +1,420 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
encryption::decrypt_password, server_storage::ServerStorageService,
|
||||
user_authorization_service::UserAuthorizationService, AppState,
|
||||
};
|
||||
use jellyfin_api::JellyfinClient;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SyncStatus {
|
||||
Created,
|
||||
AlreadyExists,
|
||||
ExistsWithDifferentPassword,
|
||||
Failed,
|
||||
Skipped,
|
||||
Deleted,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerSyncResult {
|
||||
pub server_name: String,
|
||||
pub status: SyncStatus,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FederatedUserService {
|
||||
server_storage: Arc<ServerStorageService>,
|
||||
user_authorization: Arc<UserAuthorizationService>,
|
||||
config: Arc<tokio::sync::RwLock<crate::config::AppConfig>>,
|
||||
}
|
||||
|
||||
impl FederatedUserService {
|
||||
pub fn new(state: &AppState) -> Self {
|
||||
Self {
|
||||
server_storage: state.server_storage.clone(),
|
||||
user_authorization: state.user_authorization.clone(),
|
||||
config: state.config.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_from_components(
|
||||
server_storage: Arc<ServerStorageService>,
|
||||
user_authorization: Arc<UserAuthorizationService>,
|
||||
config: Arc<tokio::sync::RwLock<crate::config::AppConfig>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
server_storage,
|
||||
user_authorization,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Syncs a user to all configured servers where an admin account is available.
|
||||
/// If the user does not exist on a server, it is created.
|
||||
/// If the user exists, we assume it's fine (we don't update passwords for existing users here to avoid conflicts).
|
||||
pub async fn sync_user_to_all_servers(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
user_id: &str,
|
||||
) -> Vec<ServerSyncResult> {
|
||||
let mut results = Vec::new();
|
||||
let servers = match self.server_storage.list_servers().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to list servers for sync: {}", e);
|
||||
return results;
|
||||
}
|
||||
};
|
||||
|
||||
let config = self.config.read().await;
|
||||
let admin_password = config.password.clone();
|
||||
drop(config);
|
||||
|
||||
for server in servers {
|
||||
// Check if we have admin credentials for this server
|
||||
if let Some(admin) = match self.server_storage.get_server_admin(server.id).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Failed,
|
||||
message: Some(format!("Failed to get admin creds: {}", e)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
} {
|
||||
// Decrypt admin password
|
||||
let decrypted_admin_password =
|
||||
match decrypt_password(&admin.password, &admin_password) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to decrypt admin password for server {}: {}",
|
||||
server.name, e
|
||||
);
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Failed,
|
||||
message: Some("Failed to decrypt admin password".to_string()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let client_info = crate::config::CLIENT_INFO.clone();
|
||||
|
||||
let client = match JellyfinClient::new(server.url.as_str(), client_info.clone()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Failed to create jellyfin client: {}", e);
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Failed,
|
||||
message: Some(format!("Client error: {}", e)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Authenticate as admin to get token
|
||||
match client
|
||||
.authenticate_by_name(&admin.username, &decrypted_admin_password)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to authenticate as admin on server {}: {}",
|
||||
server.name, e
|
||||
);
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Failed,
|
||||
message: Some(format!("Admin auth failed: {}", e)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if user exists
|
||||
let users = match client.get_users().await {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
error!("Failed to list users on server {}: {}", server.name, e);
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Failed,
|
||||
message: Some(format!("Failed to list users: {}", e)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let existing_user = users.iter().find(|u| u.name.eq_ignore_ascii_case(username));
|
||||
|
||||
if let Some(remote_user) = existing_user {
|
||||
// User exists. Check if password matches.
|
||||
// We need a new client to check user password
|
||||
let user_client =
|
||||
match JellyfinClient::new(server.url.as_str(), client_info.clone()) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let (status, should_map) =
|
||||
match user_client.authenticate_by_name(username, password).await {
|
||||
Ok(_) => (SyncStatus::AlreadyExists, true),
|
||||
Err(_) => (SyncStatus::ExistsWithDifferentPassword, false),
|
||||
};
|
||||
|
||||
info!(
|
||||
"Synced user {} to server {} (Remote ID: {}, Status: {:?})",
|
||||
username, server.name, remote_user.id, status
|
||||
);
|
||||
|
||||
if should_map {
|
||||
if let Err(e) = self
|
||||
.user_authorization
|
||||
.add_server_mapping(
|
||||
user_id,
|
||||
server.url.as_str(),
|
||||
username,
|
||||
password,
|
||||
Some(password), // Encrypt with their own password so they can use it
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
"Failed to create local mapping for synced user on server {}: {}",
|
||||
server.name, e
|
||||
);
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Failed,
|
||||
message: Some(format!("Failed to save local mapping: {}", e)),
|
||||
});
|
||||
} else {
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status,
|
||||
message: None,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status,
|
||||
message: Some("User exists with different password".to_string()),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Create user
|
||||
match client.create_user(username, Some(password)).await {
|
||||
Ok(new_user) => {
|
||||
info!(
|
||||
"Synced user {} to server {} (Remote ID: {}, Status: Created)",
|
||||
username, server.name, new_user.id
|
||||
);
|
||||
|
||||
if let Err(e) = self
|
||||
.user_authorization
|
||||
.add_server_mapping(
|
||||
user_id,
|
||||
server.url.as_str(),
|
||||
username,
|
||||
password,
|
||||
Some(password), // Encrypt with their own password so they can use it
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
"Failed to create local mapping for synced user on server {}: {}",
|
||||
server.name, e
|
||||
);
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Failed,
|
||||
message: Some(format!("Failed to save local mapping: {}", e)),
|
||||
});
|
||||
} else {
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Created,
|
||||
message: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to sync user {} to server {}: {}",
|
||||
username, server.name, e
|
||||
);
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Failed,
|
||||
message: Some(format!("Sync failed: {}", e)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
"Skipping sync for server {}: No admin credentials configured",
|
||||
server.name
|
||||
);
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Skipped,
|
||||
message: Some("No admin credentials".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
pub async fn delete_user_from_all_servers(&self, username: &str) -> Vec<ServerSyncResult> {
|
||||
let mut results = Vec::new();
|
||||
let servers = match self.server_storage.list_servers().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to list servers for delete: {}", e);
|
||||
return results;
|
||||
}
|
||||
};
|
||||
|
||||
let config = self.config.read().await;
|
||||
let admin_password = &config.password;
|
||||
|
||||
for server in servers {
|
||||
if let Some(admin) = match self.server_storage.get_server_admin(server.id).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Failed,
|
||||
message: Some(format!("Failed to get admin creds: {}", e)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
} {
|
||||
let decrypted_admin_password =
|
||||
match decrypt_password(&admin.password, admin_password) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to decrypt admin password for server {}: {}",
|
||||
server.name, e
|
||||
);
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Failed,
|
||||
message: Some("Failed to decrypt admin password".to_string()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let client_info = crate::config::CLIENT_INFO.clone();
|
||||
|
||||
let client = match JellyfinClient::new(server.url.as_str(), client_info.clone()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Failed to create jellyfin client: {}", e);
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Failed,
|
||||
message: Some(format!("Client error: {}", e)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match client
|
||||
.authenticate_by_name(&admin.username, &decrypted_admin_password)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to authenticate as admin on server {}: {}",
|
||||
server.name, e
|
||||
);
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Failed,
|
||||
message: Some(format!("Admin auth failed: {}", e)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Find user ID
|
||||
let users = match client.get_users().await {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
error!("Failed to list users on server {}: {}", server.name, e);
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Failed,
|
||||
message: Some(format!("Failed to list users: {}", e)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let user_id = users
|
||||
.iter()
|
||||
.find(|u| u.name.eq_ignore_ascii_case(username))
|
||||
.map(|u| u.id.clone());
|
||||
|
||||
if let Some(id) = user_id {
|
||||
match client.delete_user(&id).await {
|
||||
Ok(_) => {
|
||||
info!(
|
||||
"Deleted user {} from server {} (Deleted: true)",
|
||||
username, server.name
|
||||
);
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Deleted,
|
||||
message: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to delete user {} from server {}: {}",
|
||||
username, server.name, e
|
||||
);
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Failed,
|
||||
message: Some(format!("Delete failed: {}", e)),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::NotFound,
|
||||
message: None,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
results.push(ServerSyncResult {
|
||||
server_name: server.name.clone(),
|
||||
status: SyncStatus::Skipped,
|
||||
message: Some("No admin credentials".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
}
|
||||
@@ -246,10 +246,17 @@ async fn authenticate_on_server(
|
||||
);
|
||||
|
||||
// Get user mapping for this server
|
||||
let config = state.config.read().await;
|
||||
let admin_password = &config.password;
|
||||
|
||||
let (final_username, final_password) = if let Some(mapping) = &server_mapping {
|
||||
(
|
||||
mapping.mapped_username.clone(),
|
||||
mapping.mapped_password.clone(),
|
||||
state.user_authorization.decrypt_server_mapping_password(
|
||||
mapping,
|
||||
&payload.password,
|
||||
admin_password,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(payload.username.clone(), payload.password.clone())
|
||||
@@ -325,26 +332,26 @@ async fn authenticate_on_server(
|
||||
AuthError::InternalError
|
||||
})?;
|
||||
|
||||
// if we dont have a server mapping, we need to create one
|
||||
if server_mapping.is_none() {
|
||||
info!(
|
||||
"Creating server mapping for user '{}' on server '{}'",
|
||||
payload.username, server.name
|
||||
);
|
||||
state
|
||||
.user_authorization
|
||||
.add_server_mapping(
|
||||
&user.id,
|
||||
server.url.as_str(),
|
||||
&payload.username,
|
||||
&payload.password,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error creating server mapping: {}", e);
|
||||
AuthError::InternalError
|
||||
})?;
|
||||
}
|
||||
// Update or create server mapping to ensure it's encrypted
|
||||
// This handles creating new mappings and upgrading legacy plaintext mappings
|
||||
info!(
|
||||
"Updating server mapping for user '{}' on server '{}'",
|
||||
payload.username, server.name
|
||||
);
|
||||
state
|
||||
.user_authorization
|
||||
.add_server_mapping(
|
||||
&user.id,
|
||||
server.url.as_str(),
|
||||
&final_username,
|
||||
&final_password,
|
||||
Some(&payload.password),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error updating server mapping: {}", e);
|
||||
AuthError::InternalError
|
||||
})?;
|
||||
|
||||
let auth_token = auth_response.access_token.clone();
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ use axum_login::{
|
||||
};
|
||||
|
||||
mod config;
|
||||
mod encryption;
|
||||
mod federated_users;
|
||||
mod handlers;
|
||||
mod media_storage_service;
|
||||
mod models;
|
||||
@@ -38,6 +40,7 @@ mod ui;
|
||||
mod url_helper;
|
||||
mod user_authorization_service;
|
||||
|
||||
use federated_users::FederatedUserService;
|
||||
use media_storage_service::MediaStorageService;
|
||||
use server_storage::ServerStorageService;
|
||||
use user_authorization_service::UserAuthorizationService;
|
||||
@@ -65,6 +68,7 @@ pub struct AppState {
|
||||
pub play_sessions: Arc<SessionStorage>,
|
||||
pub config: Arc<tokio::sync::RwLock<AppConfig>>,
|
||||
pub processors: Arc<JsonProcessors>,
|
||||
pub federated_users: Arc<FederatedUserService>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -73,6 +77,15 @@ impl AppState {
|
||||
data_context: DataContext,
|
||||
json_processors: JsonProcessors,
|
||||
) -> Self {
|
||||
// Create temporary state to initialize FederatedUserService
|
||||
// This is a bit circular but FederatedUserService needs parts of AppState
|
||||
// We can construct it manually here since we have all components
|
||||
let federated_users = Arc::new(FederatedUserService::new_from_components(
|
||||
data_context.server_storage.clone(),
|
||||
data_context.user_authorization.clone(),
|
||||
data_context.config.clone(),
|
||||
));
|
||||
|
||||
Self {
|
||||
reqwest_client,
|
||||
user_authorization: data_context.user_authorization,
|
||||
@@ -81,6 +94,7 @@ impl AppState {
|
||||
play_sessions: data_context.play_sessions,
|
||||
config: data_context.config,
|
||||
processors: Arc::new(json_processors),
|
||||
federated_users,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +112,11 @@ impl AppState {
|
||||
config.url_prefix.as_ref().map(|prefix| prefix.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_admin_password(&self) -> String {
|
||||
let config = self.config.read().await;
|
||||
config.password.clone()
|
||||
}
|
||||
|
||||
pub async fn remove_prefix_from_path<'a>(&self, path: &'a str) -> &'a str {
|
||||
let config = self.config.read().await;
|
||||
if let Some(prefix) = &config.url_prefix {
|
||||
@@ -264,10 +283,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
.with_secure(false)
|
||||
.with_same_site(tower_sessions::cookie::SameSite::Lax)
|
||||
.with_expiry(Expiry::OnInactivity(time::Duration::days(1))) // 24 hour
|
||||
.with_signed(key);
|
||||
|
||||
let backend = Backend::new(app_state.config.clone());
|
||||
let backend = Backend::new(
|
||||
app_state.config.clone(),
|
||||
app_state.user_authorization.clone(),
|
||||
);
|
||||
let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build();
|
||||
|
||||
let ui_route = loaded_config.ui_route.to_string();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use sqlx::{FromRow, Row, SqlitePool};
|
||||
use tracing::info;
|
||||
use url::Url;
|
||||
|
||||
@@ -13,6 +13,16 @@ pub struct Server {
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct ServerAdmin {
|
||||
pub id: i64,
|
||||
pub server_id: i64,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerStorageService {
|
||||
pool: SqlitePool,
|
||||
@@ -185,6 +195,65 @@ impl ServerStorageService {
|
||||
updated_at: row.get("updated_at"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_server_admin(
|
||||
&self,
|
||||
server_id: i64,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
INSERT OR REPLACE INTO server_admins (server_id, username, password, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(server_id)
|
||||
.bind(username)
|
||||
.bind(password)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
let admin_id = result.last_insert_rowid();
|
||||
info!("Added admin for server ID: {}", server_id);
|
||||
Ok(admin_id)
|
||||
}
|
||||
|
||||
pub async fn get_server_admin(
|
||||
&self,
|
||||
server_id: i64,
|
||||
) -> Result<Option<ServerAdmin>, sqlx::Error> {
|
||||
let row = sqlx::query_as::<_, ServerAdmin>(
|
||||
r#"
|
||||
SELECT id, server_id, username, password, created_at, updated_at
|
||||
FROM server_admins
|
||||
WHERE server_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(server_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn delete_server_admin(&self, server_id: i64) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM server_admins
|
||||
WHERE server_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(server_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
3
crates/jellyswarrm-proxy/src/ui/admin/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod servers;
|
||||
pub mod settings;
|
||||
pub mod users;
|
||||
374
crates/jellyswarrm-proxy/src/ui/admin/servers.rs
Normal file
@@ -0,0 +1,374 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
Form,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{encryption::encrypt_password, server_storage::Server, AppState};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "admin/servers.html")]
|
||||
pub struct ServersPageTemplate {
|
||||
pub ui_route: String,
|
||||
}
|
||||
|
||||
pub struct ServerWithAdmin {
|
||||
pub server: Server,
|
||||
pub has_admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "admin/server_list.html")]
|
||||
pub struct ServerListTemplate {
|
||||
pub servers: Vec<ServerWithAdmin>,
|
||||
pub ui_route: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddServerForm {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdatePriorityForm {
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddServerAdminForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
async fn render_server_list(state: &AppState) -> Result<String, String> {
|
||||
match state.server_storage.list_servers().await {
|
||||
Ok(servers) => {
|
||||
let mut servers_with_admin = Vec::new();
|
||||
for server in servers {
|
||||
let has_admin = state
|
||||
.server_storage
|
||||
.get_server_admin(server.id)
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
.is_some();
|
||||
servers_with_admin.push(ServerWithAdmin { server, has_admin });
|
||||
}
|
||||
|
||||
let template = ServerListTemplate {
|
||||
servers: servers_with_admin,
|
||||
ui_route: state.get_ui_route().await,
|
||||
};
|
||||
|
||||
template.render().map_err(|e| e.to_string())
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Main servers management page
|
||||
pub async fn servers_page(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let template = ServersPageTemplate {
|
||||
ui_route: state.get_ui_route().await,
|
||||
};
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render servers template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get server list partial (for HTMX)
|
||||
pub async fn get_server_list(State(state): State<AppState>) -> impl IntoResponse {
|
||||
match render_server_list(&state).await {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render server list: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a new server
|
||||
pub async fn add_server(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<AddServerForm>,
|
||||
) -> Response {
|
||||
// Validate the form data
|
||||
if form.name.trim().is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html("<div class=\"alert alert-error\">Server name cannot be empty</div>"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if form.url.trim().is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html("<div class=\"alert alert-error\">Server URL cannot be empty</div>"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if form.priority < 1 || form.priority > 999 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html("<div class=\"alert alert-error\">Priority must be between 1 and 999</div>"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Try to add the server
|
||||
match state
|
||||
.server_storage
|
||||
.add_server(form.name.trim(), form.url.trim(), form.priority)
|
||||
.await
|
||||
{
|
||||
Ok(server_id) => {
|
||||
info!(
|
||||
"Added new server: {} ({}) with ID: {}",
|
||||
form.name, form.url, server_id
|
||||
);
|
||||
|
||||
// Return updated server list
|
||||
get_server_list(State(state)).await.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to add server: {}", e);
|
||||
|
||||
let error_message = if e.to_string().contains("UNIQUE constraint failed") {
|
||||
"A server with that name already exists"
|
||||
} else if e.to_string().contains("Invalid URL") {
|
||||
"Invalid URL format"
|
||||
} else {
|
||||
"Failed to add server"
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html(format!(
|
||||
"<div class=\"alert alert-error\">{error_message}</div>"
|
||||
)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a server
|
||||
pub async fn delete_server(State(state): State<AppState>, Path(server_id): Path<i64>) -> Response {
|
||||
match state.server_storage.delete_server(server_id).await {
|
||||
Ok(true) => {
|
||||
info!("Deleted server with ID: {}", server_id);
|
||||
// Return updated server list
|
||||
get_server_list(State(state)).await.into_response()
|
||||
}
|
||||
Ok(false) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Html("<div class=\"alert alert-error\">Server not found</div>"),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to delete server: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("<div class=\"alert alert-error\">Failed to delete server</div>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update server priority
|
||||
pub async fn update_server_priority(
|
||||
State(state): State<AppState>,
|
||||
Path(server_id): Path<i64>,
|
||||
Form(form): Form<UpdatePriorityForm>,
|
||||
) -> Response {
|
||||
if form.priority < 1 || form.priority > 999 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html("<div class=\"alert alert-error\">Priority must be between 1 and 999</div>"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match state
|
||||
.server_storage
|
||||
.update_server_priority(server_id, form.priority)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
info!("Updated server {} priority to {}", server_id, form.priority);
|
||||
// Return updated server list
|
||||
get_server_list(State(state)).await.into_response()
|
||||
}
|
||||
Ok(false) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Html("<div class=\"alert alert-error\">Server not found</div>"),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to update server priority: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("<div class=\"alert alert-error\">Failed to update priority</div>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add server admin
|
||||
pub async fn add_server_admin(
|
||||
State(state): State<AppState>,
|
||||
Path(server_id): Path<i64>,
|
||||
Form(form): Form<AddServerAdminForm>,
|
||||
) -> Response {
|
||||
// 1. Get server details
|
||||
let server = match state.server_storage.get_server_by_id(server_id).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Server not found</div>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get server: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Database error</div>"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Verify credentials with upstream Jellyfin and check admin status
|
||||
let client_info = crate::config::CLIENT_INFO.clone();
|
||||
|
||||
let client = match jellyfin_api::JellyfinClient::new(server.url.as_str(), client_info) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Failed to create jellyfin client: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Client error</div>"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match client
|
||||
.authenticate_by_name(&form.username, &form.password)
|
||||
.await
|
||||
{
|
||||
Ok(user) => {
|
||||
// Check if user is admin
|
||||
let is_admin = user.policy.map(|p| p.is_administrator).unwrap_or(false);
|
||||
|
||||
if !is_admin {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">User is not an administrator on this server</div>"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 3. Encrypt password with admin master password
|
||||
let config = state.config.read().await;
|
||||
let encrypted_password = match encrypt_password(&form.password, &config.password) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
error!("Encryption failed: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Encryption failed</div>"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Save to database
|
||||
match state
|
||||
.server_storage
|
||||
.add_server_admin(server_id, &form.username, &encrypted_password)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
info!("Added admin for server {}", server.name);
|
||||
match render_server_list(&state).await {
|
||||
Ok(html) => Html(format!(
|
||||
r#"<div id="server-list" hx-swap-oob="innerHTML">{}</div>"#,
|
||||
html
|
||||
))
|
||||
.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to add server admin: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Database error</div>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(jellyfin_api::error::Error::AuthenticationFailed(_)) => {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Invalid credentials</div>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to authenticate with upstream: {}", e);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Html(format!(
|
||||
"<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Connection error: {}</div>",
|
||||
e
|
||||
)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete server admin
|
||||
pub async fn delete_server_admin(
|
||||
State(state): State<AppState>,
|
||||
Path(server_id): Path<i64>,
|
||||
) -> Response {
|
||||
match state.server_storage.delete_server_admin(server_id).await {
|
||||
Ok(true) => {
|
||||
info!("Deleted admin for server ID: {}", server_id);
|
||||
get_server_list(State(state)).await.into_response()
|
||||
}
|
||||
Ok(false) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Html("<div class=\"alert alert-error\">Admin not found</div>"),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to delete server admin: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("<div class=\"alert alert-error\">Failed to delete admin</div>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,13 @@ use tracing::error;
|
||||
use crate::{config::save_config, AppState};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "settings.html")]
|
||||
#[template(path = "admin/settings.html")]
|
||||
pub struct SettingsPageTemplate {
|
||||
pub ui_route: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "settings_form.html")]
|
||||
#[template(path = "admin/settings_form.html")]
|
||||
pub struct SettingsFormTemplate {
|
||||
pub server_id: String,
|
||||
pub public_address: String,
|
||||
@@ -10,13 +10,14 @@ use std::collections::HashMap;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{
|
||||
federated_users::ServerSyncResult,
|
||||
server_storage::Server,
|
||||
user_authorization_service::{ServerMapping, User},
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "users.html")]
|
||||
#[template(path = "admin/users.html")]
|
||||
pub struct UsersPageTemplate {
|
||||
pub ui_route: String,
|
||||
}
|
||||
@@ -30,14 +31,15 @@ pub struct UserWithMappings {
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user_list.html")]
|
||||
#[template(path = "admin/user_list.html")]
|
||||
pub struct UserListTemplate {
|
||||
pub users: Vec<UserWithMappings>,
|
||||
pub ui_route: String,
|
||||
pub sync_report: Option<Vec<ServerSyncResult>>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user_item.html")]
|
||||
#[template(path = "admin/user_item.html")]
|
||||
pub struct UserItememplate {
|
||||
pub uwm: UserWithMappings,
|
||||
pub ui_route: String,
|
||||
@@ -47,6 +49,8 @@ pub struct UserItememplate {
|
||||
pub struct AddUserForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub enable_federation: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -177,8 +181,10 @@ pub async fn get_user_item(
|
||||
}
|
||||
}
|
||||
|
||||
/// List users with mappings
|
||||
pub async fn get_user_list(State(state): State<AppState>) -> impl IntoResponse {
|
||||
async fn get_user_list_impl(
|
||||
state: &AppState,
|
||||
report: Option<Vec<ServerSyncResult>>,
|
||||
) -> impl IntoResponse {
|
||||
// Fetch servers once for mapping lookup
|
||||
let servers = match state.server_storage.list_servers().await {
|
||||
Ok(s) => s,
|
||||
@@ -192,12 +198,13 @@ pub async fn get_user_list(State(state): State<AppState>) -> impl IntoResponse {
|
||||
Ok(users) => {
|
||||
let mut result = Vec::new();
|
||||
for user in users {
|
||||
result.push(create_user_with_mappings(&state, user, &servers, false).await);
|
||||
result.push(create_user_with_mappings(state, user, &servers, false).await);
|
||||
}
|
||||
|
||||
let template = UserListTemplate {
|
||||
users: result,
|
||||
ui_route: state.get_ui_route().await,
|
||||
sync_report: report,
|
||||
};
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
@@ -214,6 +221,11 @@ pub async fn get_user_list(State(state): State<AppState>) -> impl IntoResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// List users with mappings
|
||||
pub async fn get_user_list(State(state): State<AppState>) -> impl IntoResponse {
|
||||
get_user_list_impl(&state, None).await
|
||||
}
|
||||
|
||||
/// Add user
|
||||
pub async fn add_user(State(state): State<AppState>, Form(form): Form<AddUserForm>) -> Response {
|
||||
if form.username.trim().is_empty() || form.password.is_empty() {
|
||||
@@ -228,9 +240,22 @@ pub async fn add_user(State(state): State<AppState>, Form(form): Form<AddUserFor
|
||||
.get_or_create_user(&form.username, &form.password)
|
||||
.await
|
||||
{
|
||||
Ok(_user) => {
|
||||
Ok(user) => {
|
||||
info!("Created user {}", form.username);
|
||||
get_user_list(State(state)).await.into_response()
|
||||
|
||||
// Sync to all servers if enabled
|
||||
let report = if form.enable_federation {
|
||||
Some(
|
||||
state
|
||||
.federated_users
|
||||
.sync_user_to_all_servers(&form.username, &form.password, &user.id)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
get_user_list_impl(&state, report).await.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to create user: {}", e);
|
||||
@@ -243,10 +268,53 @@ pub async fn add_user(State(state): State<AppState>, Form(form): Form<AddUserFor
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteUserForm {
|
||||
#[serde(default)]
|
||||
pub delete_federated: bool,
|
||||
}
|
||||
|
||||
/// Delete user
|
||||
pub async fn delete_user(State(state): State<AppState>, Path(user_id): Path<String>) -> Response {
|
||||
pub async fn delete_user(
|
||||
State(state): State<AppState>,
|
||||
Path(user_id): Path<String>,
|
||||
Form(form): Form<DeleteUserForm>,
|
||||
) -> Response {
|
||||
// 1. Get user to get username for remote deletion
|
||||
let username = match state.user_authorization.get_user_by_id(&user_id).await {
|
||||
Ok(Some(u)) => u.original_username,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Html("<div class=\"alert alert-error\">User not found</div>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to fetch user by id {}: {}", user_id, e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("<div class=\"alert alert-error\">Database error</div>"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Delete from federated servers if requested
|
||||
let report = if form.delete_federated {
|
||||
Some(
|
||||
state
|
||||
.federated_users
|
||||
.delete_user_from_all_servers(&username)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 3. Delete locally
|
||||
match state.user_authorization.delete_user(&user_id).await {
|
||||
Ok(true) => get_user_list(State(state)).await.into_response(),
|
||||
Ok(true) => get_user_list_impl(&state, report).await.into_response(),
|
||||
Ok(false) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Html("<div class=\"alert alert-error\">User not found</div>"),
|
||||
@@ -275,6 +343,10 @@ pub async fn add_mapping(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let config = state.config.read().await;
|
||||
let admin_password = &config.password;
|
||||
|
||||
match state
|
||||
.user_authorization
|
||||
.add_server_mapping(
|
||||
@@ -282,6 +354,7 @@ pub async fn add_mapping(
|
||||
&form.server_url,
|
||||
&form.mapped_username,
|
||||
&form.mapped_password,
|
||||
Some(admin_password),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1,20 +1,52 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, StatusCode},
|
||||
};
|
||||
use axum_login::{AuthUser, AuthnBackend, UserId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{sync::RwLock, task};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::{config::AppConfig, user_authorization_service::UserAuthorizationService};
|
||||
|
||||
mod routes;
|
||||
|
||||
pub use routes::router;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum UserRole {
|
||||
Admin,
|
||||
User,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
id: i64,
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
password: String,
|
||||
pub password: String,
|
||||
pub role: UserRole,
|
||||
}
|
||||
|
||||
pub struct AuthenticatedUser(pub User);
|
||||
|
||||
impl<S> FromRequestParts<S> for AuthenticatedUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = StatusCode;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let auth_session = AuthSession::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
match auth_session.user {
|
||||
Some(user) => Ok(AuthenticatedUser(user)),
|
||||
None => Err(StatusCode::UNAUTHORIZED),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Here we've implemented `Debug` manually to avoid accidentally logging the
|
||||
@@ -25,15 +57,16 @@ impl std::fmt::Debug for User {
|
||||
.field("id", &self.id)
|
||||
.field("username", &self.username)
|
||||
.field("password", &"[redacted]")
|
||||
.field("role", &self.role)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthUser for User {
|
||||
type Id = i64;
|
||||
type Id = String;
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.id
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn session_auth_hash(&self) -> &[u8] {
|
||||
@@ -56,11 +89,12 @@ pub struct Credentials {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Backend {
|
||||
config: Arc<RwLock<AppConfig>>,
|
||||
user_auth: Arc<UserAuthorizationService>,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
pub fn new(config: Arc<RwLock<AppConfig>>) -> Self {
|
||||
Self { config }
|
||||
pub fn new(config: Arc<RwLock<AppConfig>>, user_auth: Arc<UserAuthorizationService>) -> Self {
|
||||
Self { config, user_auth }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +102,8 @@ impl Backend {
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
TaskJoin(#[from] task::JoinError),
|
||||
#[error(transparent)]
|
||||
Sqlx(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
impl AuthnBackend for Backend {
|
||||
@@ -80,34 +116,60 @@ impl AuthnBackend for Backend {
|
||||
creds: Self::Credentials,
|
||||
) -> Result<Option<Self::User>, Self::Error> {
|
||||
let config = self.config.read().await;
|
||||
if creds.username != config.username {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if creds.password == config.password {
|
||||
info!("Authenticating user: {}", creds.username);
|
||||
if creds.username == config.username && creds.password == config.password {
|
||||
info!("Admin authentication successful");
|
||||
// If the password is correct, we return the default user.
|
||||
let user = User {
|
||||
id: 1,
|
||||
id: "admin".to_string(),
|
||||
username: creds.username,
|
||||
password: config.password.clone(),
|
||||
role: UserRole::Admin,
|
||||
};
|
||||
Ok(Some(user))
|
||||
} else {
|
||||
Ok(None)
|
||||
return Ok(Some(user));
|
||||
}
|
||||
|
||||
if let Some(user) = self
|
||||
.user_auth
|
||||
.get_user_by_credentials(&creds.username, &creds.password)
|
||||
.await?
|
||||
{
|
||||
info!("User authentication successful: {}", user.original_username);
|
||||
let user = User {
|
||||
id: user.id,
|
||||
username: user.original_username,
|
||||
password: user.original_password_hash,
|
||||
role: UserRole::User,
|
||||
};
|
||||
return Ok(Some(user));
|
||||
}
|
||||
|
||||
info!("Authentication failed for user: {}", creds.username);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
|
||||
let config = self.config.read().await;
|
||||
if *user_id != 1 {
|
||||
return Ok(None); // Only one user in this example.
|
||||
if user_id == "admin" {
|
||||
let config = self.config.read().await;
|
||||
return Ok(Some(User {
|
||||
id: "admin".to_string(),
|
||||
username: config.username.clone(),
|
||||
password: config.password.clone(),
|
||||
role: UserRole::Admin,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Some(User {
|
||||
id: 1,
|
||||
username: config.username.clone(),
|
||||
password: config.password.clone(),
|
||||
}))
|
||||
if let Some(user) = self.user_auth.get_user_by_id(user_id).await? {
|
||||
let user = User {
|
||||
id: user.id,
|
||||
username: user.original_username,
|
||||
password: user.original_password_hash,
|
||||
role: UserRole::User,
|
||||
};
|
||||
return Ok(Some(user));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::Path,
|
||||
middleware,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
@@ -10,19 +11,34 @@ use hyper::StatusCode;
|
||||
use rust_embed::RustEmbed;
|
||||
use tracing::error;
|
||||
|
||||
use crate::{AppState, Asset};
|
||||
use crate::{
|
||||
ui::auth::{AuthenticatedUser, UserRole},
|
||||
AppState, Asset,
|
||||
};
|
||||
|
||||
mod auth;
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod root;
|
||||
pub mod servers;
|
||||
pub mod settings;
|
||||
pub mod users;
|
||||
pub mod server_status;
|
||||
pub mod user;
|
||||
pub use auth::Backend;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "src/ui/resources/"]
|
||||
struct Resources;
|
||||
|
||||
async fn require_admin(
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> impl IntoResponse {
|
||||
if user.role == UserRole::Admin {
|
||||
next.run(req).await
|
||||
} else {
|
||||
StatusCode::FORBIDDEN.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn resource_handler(Path(path): Path<String>) -> impl IntoResponse {
|
||||
if let Some(file) = Resources::get(&path) {
|
||||
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
||||
@@ -67,40 +83,74 @@ pub static JELLYFIN_UI_VERSION: once_cell::sync::Lazy<Option<JellyfinUiVersion>>
|
||||
once_cell::sync::Lazy::new(get_jellyfin_ui_version);
|
||||
|
||||
pub fn ui_routes() -> axum::Router<AppState> {
|
||||
Router::new()
|
||||
// Root
|
||||
.route("/", get(root::index))
|
||||
let admin_routes = Router::new()
|
||||
// Users
|
||||
.route("/users", get(users::users_page))
|
||||
.route("/users", post(users::add_user))
|
||||
.route("/users/list", get(users::get_user_list))
|
||||
.route("/users/{id}", axum::routing::delete(users::delete_user))
|
||||
.route("/users/mappings", post(users::add_mapping))
|
||||
.route("/users", get(admin::users::users_page))
|
||||
.route("/users", post(admin::users::add_user))
|
||||
.route("/users/list", get(admin::users::get_user_list))
|
||||
.route("/users/{id}/delete", post(admin::users::delete_user))
|
||||
.route("/users/mappings", post(admin::users::add_mapping))
|
||||
.route(
|
||||
"/users/{user_id}/mappings/{mapping_id}",
|
||||
axum::routing::delete(users::delete_mapping),
|
||||
axum::routing::delete(admin::users::delete_mapping),
|
||||
)
|
||||
.route(
|
||||
"/users/{user_id}/sessions",
|
||||
axum::routing::delete(users::delete_sessions),
|
||||
axum::routing::delete(admin::users::delete_sessions),
|
||||
)
|
||||
.route("/servers", get(servers::servers_page))
|
||||
.route("/servers", post(servers::add_server))
|
||||
.route("/servers/list", get(servers::get_server_list))
|
||||
.route("/servers", get(admin::servers::servers_page))
|
||||
.route("/servers", post(admin::servers::add_server))
|
||||
.route("/servers/list", get(admin::servers::get_server_list))
|
||||
.route(
|
||||
"/servers/{id}",
|
||||
axum::routing::delete(servers::delete_server),
|
||||
axum::routing::delete(admin::servers::delete_server),
|
||||
)
|
||||
.route(
|
||||
"/servers/{id}/priority",
|
||||
axum::routing::patch(servers::update_server_priority),
|
||||
axum::routing::patch(admin::servers::update_server_priority),
|
||||
)
|
||||
.route(
|
||||
"/servers/{id}/admin",
|
||||
post(admin::servers::add_server_admin),
|
||||
)
|
||||
.route(
|
||||
"/servers/{id}/admin",
|
||||
axum::routing::delete(admin::servers::delete_server_admin),
|
||||
)
|
||||
.route("/servers/{id}/status", get(servers::check_server_status))
|
||||
// Settings
|
||||
.route("/settings", get(settings::settings_page))
|
||||
.route("/settings/form", get(settings::settings_form))
|
||||
.route("/settings/save", post(settings::save_settings))
|
||||
.route("/settings/reload", post(settings::reload_config))
|
||||
.route("/settings", get(admin::settings::settings_page))
|
||||
.route("/settings/form", get(admin::settings::settings_form))
|
||||
.route("/settings/save", post(admin::settings::save_settings))
|
||||
.route("/settings/reload", post(admin::settings::reload_config))
|
||||
.route_layer(middleware::from_fn(require_admin));
|
||||
|
||||
Router::new()
|
||||
// Root
|
||||
.route("/", get(root::index))
|
||||
.route("/user/servers", get(user::servers::get_user_servers))
|
||||
.route(
|
||||
"/user/servers/{id}",
|
||||
axum::routing::delete(user::servers::delete_server_mapping),
|
||||
)
|
||||
.route(
|
||||
"/user/servers/{id}/connect",
|
||||
post(user::servers::connect_server),
|
||||
)
|
||||
.route("/user/media", get(user::media::get_user_media))
|
||||
.route("/user/profile", get(user::profile::get_user_profile))
|
||||
.route(
|
||||
"/user/profile/password",
|
||||
post(user::profile::post_user_password),
|
||||
)
|
||||
.route(
|
||||
"/user/servers/{id}/status",
|
||||
get(user::servers::check_user_server_status),
|
||||
)
|
||||
.route(
|
||||
"/servers/{id}/status",
|
||||
get(server_status::check_server_status),
|
||||
)
|
||||
.merge(admin_routes)
|
||||
.route_layer(login_required!(Backend, login_url = "/ui/login"))
|
||||
.route("/resources/{*path}", get(resource_handler))
|
||||
.merge(auth::router())
|
||||
|
||||
135
crates/jellyswarrm-proxy/src/ui/resources/custom.css
Normal file
@@ -0,0 +1,135 @@
|
||||
/* Global Styles for Jellyswarrm */
|
||||
|
||||
/* --- Components --- */
|
||||
|
||||
/* Icon Buttons */
|
||||
.icon-btn {
|
||||
--_size: 1.1rem;
|
||||
font-size: var(--_size);
|
||||
line-height: 1;
|
||||
padding: .5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--pico-muted-border-color, #555);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.2s;
|
||||
color: var(--pico-color);
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--pico-card-background-color);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.icon-btn.danger {
|
||||
color: var(--pico-del-color, #e55353);
|
||||
border-color: var(--pico-del-color, #e55353);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
background: rgba(229, 83, 83, 0.1);
|
||||
}
|
||||
|
||||
/* Badges & Chips */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: .25em .55em;
|
||||
font-size: .75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
border-radius: 1rem;
|
||||
color: #fff;
|
||||
background: var(--pico-primary-background);
|
||||
}
|
||||
|
||||
.badge.muted, .status-chip {
|
||||
background: var(--pico-muted-border-color, rgba(127, 127, 127, .15));
|
||||
color: var(--pico-color);
|
||||
}
|
||||
|
||||
.badge.success { background-color: #2e7d32; color: white; }
|
||||
.badge.warning { background-color: #ffc107; color: black; }
|
||||
.badge.danger { background-color: #dc3545; color: white; }
|
||||
.badge.secondary { background-color: #6c757d; color: white; }
|
||||
|
||||
/* Status Container */
|
||||
.status-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Filter Bar */
|
||||
.filter-bar {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* User Key */
|
||||
.user-key {
|
||||
font-size: .6rem;
|
||||
opacity: .6;
|
||||
word-break: break-all;
|
||||
padding-top: .25em;
|
||||
}
|
||||
|
||||
/* Danger Text/Icon */
|
||||
.text-danger, .danger {
|
||||
color: var(--pico-del-color, #d9534f);
|
||||
}
|
||||
i.danger {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
padding: 1rem;
|
||||
border: 2px dashed var(--pico-border-color, #ccc);
|
||||
border-radius: .6rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Sync Report */
|
||||
.sync-report {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--pico-card-background-color);
|
||||
border: 1px solid var(--pico-card-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
}
|
||||
.sync-report ul {
|
||||
margin-bottom: 0;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
/* --- Animations --- */
|
||||
.htmx-added {
|
||||
animation: fadeInSoft 160ms ease-out, highlightSoft 900ms ease-out;
|
||||
}
|
||||
.htmx-swapping {
|
||||
animation: fadeOutSoft 140ms ease-in forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes fadeInSoft {
|
||||
from { opacity: 0; transform: translateY(2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeOutSoft {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
@keyframes highlightSoft {
|
||||
0% { background: var(--pico-mark-background-color, #fff8d9); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.htmx-added, .htmx-swapping { animation: none; }
|
||||
}
|
||||
@@ -4,16 +4,28 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse},
|
||||
};
|
||||
use tracing::error;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{
|
||||
ui::{JellyfinUiVersion, JELLYFIN_UI_VERSION},
|
||||
ui::{
|
||||
auth::{AuthenticatedUser, UserRole},
|
||||
JellyfinUiVersion, JELLYFIN_UI_VERSION,
|
||||
},
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "index.html")]
|
||||
pub struct IndexTemplate {
|
||||
#[template(path = "user/index.html")]
|
||||
pub struct UserIndexTemplate {
|
||||
pub version: Option<String>,
|
||||
pub ui_route: String,
|
||||
pub root: Option<String>,
|
||||
pub jellyfin_ui_version: Option<JellyfinUiVersion>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "admin/index.html")]
|
||||
pub struct AdminIndexTemplate {
|
||||
pub version: Option<String>,
|
||||
pub ui_route: String,
|
||||
pub root: Option<String>,
|
||||
@@ -21,19 +33,41 @@ pub struct IndexTemplate {
|
||||
}
|
||||
|
||||
/// Root/home page
|
||||
pub async fn index(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let template = IndexTemplate {
|
||||
version: Some(env!("CARGO_PKG_VERSION").to_string()),
|
||||
ui_route: state.get_ui_route().await,
|
||||
root: state.get_url_prefix().await,
|
||||
jellyfin_ui_version: JELLYFIN_UI_VERSION.clone(),
|
||||
};
|
||||
pub async fn index(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> impl IntoResponse {
|
||||
let response = if user.role == UserRole::User {
|
||||
let template = UserIndexTemplate {
|
||||
version: Some(env!("CARGO_PKG_VERSION").to_string()),
|
||||
ui_route: state.get_ui_route().await,
|
||||
root: state.get_url_prefix().await,
|
||||
jellyfin_ui_version: JELLYFIN_UI_VERSION.clone(),
|
||||
};
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render index template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render index template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("Rendering admin dashboard for {}", user.username);
|
||||
let template = AdminIndexTemplate {
|
||||
version: Some(env!("CARGO_PKG_VERSION").to_string()),
|
||||
ui_route: state.get_ui_route().await,
|
||||
root: state.get_url_prefix().await,
|
||||
jellyfin_ui_version: JELLYFIN_UI_VERSION.clone(),
|
||||
};
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render index template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
response
|
||||
}
|
||||
|
||||
91
crates/jellyswarrm-proxy/src/ui/server_status.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse},
|
||||
};
|
||||
use tracing::error;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "admin/server_status.html")]
|
||||
pub struct ServerStatusTemplate {
|
||||
pub error_message: Option<String>,
|
||||
pub server_version: Option<String>,
|
||||
}
|
||||
|
||||
/// Check server status
|
||||
pub async fn check_server_status(
|
||||
State(state): State<AppState>,
|
||||
Path(server_id): Path<i64>,
|
||||
) -> impl IntoResponse {
|
||||
// Get the server details first
|
||||
match state.server_storage.get_server_by_id(server_id).await {
|
||||
Ok(Some(server)) => {
|
||||
let client_info = crate::config::CLIENT_INFO.clone();
|
||||
|
||||
let client = match jellyfin_api::JellyfinClient::new(server.url.as_str(), client_info) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let template = ServerStatusTemplate {
|
||||
error_message: Some(format!("Client error: {}", e)),
|
||||
server_version: None,
|
||||
};
|
||||
|
||||
return match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render status template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
match client.get_public_system_info().await {
|
||||
Ok(info) => {
|
||||
let template = ServerStatusTemplate {
|
||||
error_message: None,
|
||||
server_version: info.version,
|
||||
};
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render status template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let template = ServerStatusTemplate {
|
||||
error_message: Some(format!("Error: {}", e)),
|
||||
server_version: None,
|
||||
};
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render status template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Html("<span style=\"color: #dc3545;\">Server not found</span>"),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to get server: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("<span style=\"color: #dc3545;\">Database error</span>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
Form,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{server_storage::Server, url_helper::join_server_url, AppState};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "servers.html")]
|
||||
pub struct ServersPageTemplate {
|
||||
pub ui_route: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "server_list.html")]
|
||||
pub struct ServerListTemplate {
|
||||
pub servers: Vec<Server>,
|
||||
pub ui_route: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "server_status.html")]
|
||||
pub struct ServerStatusTemplate {
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddServerForm {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdatePriorityForm {
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
/// Main servers management page
|
||||
pub async fn servers_page(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let template = ServersPageTemplate {
|
||||
ui_route: state.get_ui_route().await,
|
||||
};
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render servers template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get server list partial (for HTMX)
|
||||
pub async fn get_server_list(State(state): State<AppState>) -> impl IntoResponse {
|
||||
match state.server_storage.list_servers().await {
|
||||
Ok(servers) => {
|
||||
let template = ServerListTemplate {
|
||||
servers,
|
||||
ui_route: state.get_ui_route().await,
|
||||
};
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render server list template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to fetch servers: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a new server
|
||||
pub async fn add_server(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<AddServerForm>,
|
||||
) -> Response {
|
||||
// Validate the form data
|
||||
if form.name.trim().is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html("<div class=\"alert alert-error\">Server name cannot be empty</div>"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if form.url.trim().is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html("<div class=\"alert alert-error\">Server URL cannot be empty</div>"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if form.priority < 1 || form.priority > 999 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html("<div class=\"alert alert-error\">Priority must be between 1 and 999</div>"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Try to add the server
|
||||
match state
|
||||
.server_storage
|
||||
.add_server(form.name.trim(), form.url.trim(), form.priority)
|
||||
.await
|
||||
{
|
||||
Ok(server_id) => {
|
||||
info!(
|
||||
"Added new server: {} ({}) with ID: {}",
|
||||
form.name, form.url, server_id
|
||||
);
|
||||
|
||||
// Return updated server list
|
||||
get_server_list(State(state)).await.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to add server: {}", e);
|
||||
|
||||
let error_message = if e.to_string().contains("UNIQUE constraint failed") {
|
||||
"A server with that name already exists"
|
||||
} else if e.to_string().contains("Invalid URL") {
|
||||
"Invalid URL format"
|
||||
} else {
|
||||
"Failed to add server"
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html(format!(
|
||||
"<div class=\"alert alert-error\">{error_message}</div>"
|
||||
)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a server
|
||||
pub async fn delete_server(State(state): State<AppState>, Path(server_id): Path<i64>) -> Response {
|
||||
match state.server_storage.delete_server(server_id).await {
|
||||
Ok(true) => {
|
||||
info!("Deleted server with ID: {}", server_id);
|
||||
// Return updated server list
|
||||
get_server_list(State(state)).await.into_response()
|
||||
}
|
||||
Ok(false) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Html("<div class=\"alert alert-error\">Server not found</div>"),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to delete server: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("<div class=\"alert alert-error\">Failed to delete server</div>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update server priority
|
||||
pub async fn update_server_priority(
|
||||
State(state): State<AppState>,
|
||||
Path(server_id): Path<i64>,
|
||||
Form(form): Form<UpdatePriorityForm>,
|
||||
) -> Response {
|
||||
if form.priority < 1 || form.priority > 999 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html("<div class=\"alert alert-error\">Priority must be between 1 and 999</div>"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match state
|
||||
.server_storage
|
||||
.update_server_priority(server_id, form.priority)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
info!("Updated server {} priority to {}", server_id, form.priority);
|
||||
// Return updated server list
|
||||
get_server_list(State(state)).await.into_response()
|
||||
}
|
||||
Ok(false) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Html("<div class=\"alert alert-error\">Server not found</div>"),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to update server priority: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("<div class=\"alert alert-error\">Failed to update priority</div>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check server status
|
||||
pub async fn check_server_status(
|
||||
State(state): State<AppState>,
|
||||
Path(server_id): Path<i64>,
|
||||
) -> impl IntoResponse {
|
||||
// Get the server details first
|
||||
match state.server_storage.get_server_by_id(server_id).await {
|
||||
Ok(Some(server)) => {
|
||||
// Test connection to the server
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let status_url = join_server_url(&server.url, "/system/info/public");
|
||||
|
||||
match client.get(status_url.as_str()).send().await {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
let template = ServerStatusTemplate {
|
||||
error_message: None,
|
||||
};
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render status template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response) => {
|
||||
let template = ServerStatusTemplate {
|
||||
error_message: Some(format!("HTTP {}", response.status().as_u16())),
|
||||
};
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render status template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = if e.is_timeout() {
|
||||
"Connection timeout".to_string()
|
||||
} else if e.is_connect() {
|
||||
"Connection refused".to_string()
|
||||
} else {
|
||||
format!("Network error: {e}")
|
||||
};
|
||||
|
||||
let template = ServerStatusTemplate {
|
||||
error_message: Some(error_msg),
|
||||
};
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render status template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Html("<span style=\"color: #dc3545;\">Server not found</span>"),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to get server: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("<span style=\"color: #dc3545;\">Database error</span>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
37
crates/jellyswarrm-proxy/src/ui/templates/admin/index.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends "../index.html" %}
|
||||
|
||||
|
||||
{% block tabs %}
|
||||
|
||||
<li>
|
||||
<a href="#" hx-get="/{{ ui_route }}/servers" hx-target="#main-content" class="nav-link">
|
||||
<i class="fas fa-server" style="margin-right: 0.25rem;"></i>
|
||||
Servers
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" hx-get="/{{ ui_route }}/users" hx-target="#main-content" class="nav-link">
|
||||
<i class="fas fa-users" style="margin-right: 0.25rem;"></i>
|
||||
Users
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" hx-get="/{{ ui_route }}/settings" hx-target="#main-content" class="nav-link">
|
||||
<i class="fas fa-cog" style="margin-right: 0.25rem;"></i>
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block main_content %}
|
||||
<section>
|
||||
<div id="main-content" hx-get="/{{ ui_route }}/servers" hx-trigger="load">
|
||||
<div style="text-align: center; padding: 2rem;">
|
||||
<i class="fas fa-spinner fa-spin" style="font-size: 1.5rem; color: var(--pico-muted-color);"></i>
|
||||
<p style="margin-top: 1rem; color: var(--pico-muted-color);">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
114
crates/jellyswarrm-proxy/src/ui/templates/admin/server_list.html
Normal file
@@ -0,0 +1,114 @@
|
||||
{% if servers.is_empty() %}
|
||||
<article>
|
||||
<header><h4>No servers configured</h4></header>
|
||||
<p>Add your first Jellyfin server to get started.</p>
|
||||
</article>
|
||||
{% else %}
|
||||
<table aria-label="Registered servers">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>URL</th>
|
||||
<th>Priority</th>
|
||||
<th>Status</th>
|
||||
<th style="text-align: center;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in servers %}
|
||||
<tr>
|
||||
<td style="vertical-align: middle;"><strong>{{ item.server.name }}</strong></td>
|
||||
<td style="vertical-align: middle;"><a href="{{ item.server.url }}" target="_blank" rel="noopener noreferrer">{{ item.server.url }}</a></td>
|
||||
<td style="vertical-align: middle;">
|
||||
<input type="number" value="{{ item.server.priority }}" min="1" max="999"
|
||||
hx-patch="/{{ ui_route }}/servers/{{ item.server.id }}/priority"
|
||||
hx-trigger="change delay:200ms"
|
||||
hx-include="closest tr"
|
||||
name="priority"
|
||||
hx-target="#server-list" hx-swap="innerHTML"
|
||||
style="width: 130px; min-width: 130px; margin-bottom: 0;">
|
||||
</td>
|
||||
<td style="vertical-align: middle;">
|
||||
<div class="status-container">
|
||||
<span hx-get="/{{ ui_route }}/servers/{{ item.server.id }}/status"
|
||||
hx-trigger="load, every 5s"
|
||||
hx-swap="outerHTML"
|
||||
style="min-width: 80px; display: inline-block;">
|
||||
<span style="color: #666;">Checking...</span>
|
||||
</span>
|
||||
{% if item.has_admin %}
|
||||
<span class="badge success">
|
||||
<i class="fas fa-shield-alt" style="margin-right: 0.3rem; font-size: 0.7rem;"></i> Federated
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td style="text-align: center; vertical-align: middle;">
|
||||
<div style="display: flex; justify-content: center; gap: 0.5rem;">
|
||||
{% if !item.has_admin %}
|
||||
<button type="button" class="icon-btn"
|
||||
onclick="document.getElementById('add-admin-modal-{{ item.server.id }}').showModal()"
|
||||
title="Add Admin">
|
||||
<i class="fas fa-user-shield" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<dialog id="add-admin-modal-{{ item.server.id }}">
|
||||
<article>
|
||||
<header>
|
||||
<button aria-label="Close" rel="prev" onclick="this.closest('dialog').close()"></button>
|
||||
<h3>Add Admin for {{ item.server.name }}</h3>
|
||||
</header>
|
||||
|
||||
<div style="background-color: rgba(255, 193, 7, 0.1); border-left: 4px solid #ffc107; padding: 1rem; margin-bottom: 1.5rem; text-align: left;">
|
||||
<div style="display: flex; align-items: flex-start; gap: 0.75rem;">
|
||||
<i class="fas fa-exclamation-triangle" style="color: #ffc107; margin-top: 0.2rem;"></i>
|
||||
<div>
|
||||
<strong style="color: #e0a800; display: block; margin-bottom: 0.25rem;">Disclaimer</strong>
|
||||
<span style="font-size: 0.9rem; color: var(--text-color);">Adding an admin account gives Jellyswarrm <strong>full administrative control</strong> over this Jellyfin server. This allows the proxy to manage users and settings automatically.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-form-error-{{ item.server.id }}"></div>
|
||||
|
||||
<form id="add-admin-form-{{ item.server.id }}" hx-post="/{{ ui_route }}/servers/{{ item.server.id }}/admin" hx-target="#admin-form-error-{{ item.server.id }}" hx-swap="innerHTML">
|
||||
<label>
|
||||
Username
|
||||
<input type="text" name="username" required>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="password" required>
|
||||
</label>
|
||||
</form>
|
||||
<footer style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
||||
<button type="button" class="secondary" onclick="this.closest('dialog').close()" style="margin-bottom: 0;">Cancel</button>
|
||||
<button type="submit" form="add-admin-form-{{ item.server.id }}" style="margin-bottom: 0;">Add Admin</button>
|
||||
</footer>
|
||||
</article>
|
||||
</dialog>
|
||||
{% else %}
|
||||
<button type="button" class="icon-btn danger"
|
||||
hx-delete="/{{ ui_route }}/servers/{{ item.server.id }}/admin"
|
||||
hx-confirm="Remove admin credentials for '{{ item.server.name }}'?"
|
||||
hx-target="#server-list" hx-swap="innerHTML"
|
||||
title="Remove Admin">
|
||||
<i class="fas fa-user-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<button type="button" class="icon-btn danger"
|
||||
hx-delete="/{{ ui_route }}/servers/{{ item.server.id }}"
|
||||
hx-confirm="Delete server '{{ item.server.name }}'?"
|
||||
hx-target="#server-list" hx-swap="innerHTML"
|
||||
title="Delete server">
|
||||
<i class="fas fa-trash" aria-hidden="true"></i><span class="sr-only" style="position:absolute;left:-9999px;">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endif %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% if let Some(error) = error_message%}
|
||||
<span class="status-chip" style="background: rgba(220, 53, 69, 0.15); color: #dc3545; font-weight: bold; padding: 0.25rem 0.75rem; border-radius: 1rem; font-size: 0.85rem; display: inline-flex; align-items: center; gap: 0.25rem;" title="Error: {{ error }}">
|
||||
<i class="fas fa-times-circle"></i> Offline
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="status-chip" style="background: rgba(40, 167, 69, 0.15); color: #28a745; font-weight: bold; padding: 0.25rem 0.75rem; border-radius: 1rem; font-size: 0.85rem; display: inline-flex; align-items: center; gap: 0.25rem;" title="Server is responding">
|
||||
<i class="fas fa-check-circle"></i> Online
|
||||
{% if let Some(ver) = server_version %}
|
||||
<span style="font-weight: normal; opacity: 0.8; font-size: 0.75em;">({{ ver }})</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -3,9 +3,29 @@
|
||||
<div style="display:flex; align-items:center; gap:.5rem;">
|
||||
<h3 style="margin:0;">{{ uwm.user.original_username }} <span class="badge" title="Total sessions">{{
|
||||
uwm.total_sessions }}</span></h3>
|
||||
|
||||
<i class="fas fa-trash danger" style="margin-left:auto;" aria-label="Delete user"
|
||||
hx-delete="/{{ ui_route }}/users/{{ uwm.user.id }}" hx-target="#user-list" hx-swap="innerHTML"
|
||||
hx-confirm="Delete user '{{ uwm.user.original_username }}' and all mappings?"></i>
|
||||
onclick="document.getElementById('delete-user-modal-{{ uwm.user.id }}').showModal()"></i>
|
||||
|
||||
<dialog id="delete-user-modal-{{ uwm.user.id }}" onclick="if(event.target===this)this.close()">
|
||||
<article>
|
||||
<header>
|
||||
<button aria-label="Close" rel="prev" onclick="this.closest('dialog').close()"></button>
|
||||
<h3>Delete User</h3>
|
||||
</header>
|
||||
<p>Are you sure you want to delete <strong>{{ uwm.user.original_username }}</strong>?</p>
|
||||
<form id="delete-user-form-{{ uwm.user.id }}" hx-post="/{{ ui_route }}/users/{{ uwm.user.id }}/delete" hx-target="#user-list" hx-swap="innerHTML">
|
||||
<label style="margin-bottom: 1.5rem;">
|
||||
<input type="checkbox" name="delete_federated" value="true" checked>
|
||||
<span data-tooltip="If checked, this user will be removed from all upstream servers where an admin is configured.">Also delete from connected Jellyfin servers</span>
|
||||
</label>
|
||||
</form>
|
||||
<footer style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
||||
<button type="button" class="secondary" onclick="this.closest('dialog').close()" style="margin-bottom: 0;">Cancel</button>
|
||||
<button type="submit" form="delete-user-form-{{ uwm.user.id }}" style="background-color: var(--del-color); border-color: var(--del-color); margin-bottom: 0;">Delete</button>
|
||||
</footer>
|
||||
</article>
|
||||
</dialog>
|
||||
</div>
|
||||
<p class="user-key" title="Virtual key">{{ uwm.user.virtual_key }}</p>
|
||||
</header>
|
||||
@@ -0,0 +1,57 @@
|
||||
{% if let Some(report) = sync_report %}
|
||||
<div class="sync-report">
|
||||
<strong>Federated Sync Results:</strong>
|
||||
<ul>
|
||||
{% for result in report %}
|
||||
<li>
|
||||
{{ result.server_name }}:
|
||||
{% match result.status %}
|
||||
{% when crate::federated_users::SyncStatus::Created %}
|
||||
<span class="badge success">Created</span>
|
||||
{% when crate::federated_users::SyncStatus::AlreadyExists %}
|
||||
<span class="badge warning">Exists</span>
|
||||
{% when crate::federated_users::SyncStatus::ExistsWithDifferentPassword %}
|
||||
<span class="badge danger">Exists (Password Mismatch)</span>
|
||||
{% when crate::federated_users::SyncStatus::Failed %}
|
||||
<span class="badge danger">Failed</span>
|
||||
{% when crate::federated_users::SyncStatus::Skipped %}
|
||||
<span class="badge secondary">Skipped</span>
|
||||
{% when crate::federated_users::SyncStatus::Deleted %}
|
||||
<span class="badge danger">Deleted</span>
|
||||
{% when crate::federated_users::SyncStatus::NotFound %}
|
||||
<span class="badge secondary">Not Found</span>
|
||||
{% endmatch %}
|
||||
{% if let Some(msg) = result.message %}
|
||||
<small>({{ msg }})</small>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="filter-bar">
|
||||
<input id="user-filter" type="search" placeholder="Filter users" oninput="filterUsers(this.value)">
|
||||
</div>
|
||||
|
||||
{% if users.is_empty() %}
|
||||
<div class="empty-state" role="note">
|
||||
<p>No users yet. Add one above to begin.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<section class="user-list" aria-label="Users">
|
||||
{% for uwm in users %}
|
||||
{% include "user_item.html" %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function filterUsers(q) {
|
||||
q = q.toLowerCase().trim();
|
||||
for (const art of document.querySelectorAll('section.user-list > article')) {
|
||||
const name = art.dataset.username.toLowerCase();
|
||||
art.hidden = q && !name.includes(q);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -20,6 +20,10 @@
|
||||
<button type="submit">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<label style="margin-top: -0.5rem;">
|
||||
<input type="checkbox" name="enable_federation" value="true" checked>
|
||||
<span data-tooltip="Automatically creates this user on all connected Jellyfin servers where an admin is configured.">Enable Federation</span>
|
||||
</label>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="/{{ ui_route }}/resources/fontawesome/css/all.min.css">
|
||||
|
||||
<!-- Custom Styles -->
|
||||
<link rel="stylesheet" href="/{{ ui_route }}/resources/custom.css">
|
||||
|
||||
<!-- HTMX -->
|
||||
<script src="/{{ ui_route }}/resources/htmx.min.js"></script>
|
||||
|
||||
|
||||
@@ -15,24 +15,8 @@
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="#" hx-get="/{{ ui_route }}/servers" hx-target="#main-content" class="nav-link">
|
||||
<i class="fas fa-server" style="margin-right: 0.25rem;"></i>
|
||||
Servers
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" hx-get="/{{ ui_route }}/users" hx-target="#main-content" class="nav-link">
|
||||
<i class="fas fa-users" style="margin-right: 0.25rem;"></i>
|
||||
Users
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" hx-get="/{{ ui_route }}/settings" hx-target="#main-content" class="nav-link">
|
||||
<i class="fas fa-cog" style="margin-right: 0.25rem;"></i>
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% block tabs %}{% endblock %}
|
||||
<li>
|
||||
{% match root %}{% when Some with (r) %}<a href="/{{ r }}" target="_blank">{% when None %}<a href="/" target="_blank">{% endmatch %}
|
||||
<i class="fas fa-film" style="margin-right: 0.25rem;"></i>
|
||||
@@ -61,14 +45,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Main content area -->
|
||||
<section>
|
||||
<div id="main-content" hx-get="/{{ ui_route }}/servers" hx-trigger="load">
|
||||
<div style="text-align: center; padding: 2rem;">
|
||||
<i class="fas fa-spinner fa-spin" style="font-size: 1.5rem; color: var(--pico-muted-color);"></i>
|
||||
<p style="margin-top: 1rem; color: var(--pico-muted-color);">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% block main_content %} {% endblock %}
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
<header style="text-align: center; padding: 2rem 0;">
|
||||
<img src="/{{ ui_route }}/resources/icon.svg" alt="Jellyswarrm Logo" style="width: 200px; height: 200px; margin-bottom: 1rem;">
|
||||
<h1 style="margin-top: 0;">Jellyswarrm</h1>
|
||||
<h1 style="margin-top: 0; color: var(--pico-muted-color);">Admin Interface</h1>
|
||||
</header>
|
||||
|
||||
<!-- Login card -->
|
||||
@@ -35,7 +34,7 @@
|
||||
Sign In
|
||||
</h3>
|
||||
<p style="margin: 0; color: var(--pico-muted-color); font-size: 0.9rem;">
|
||||
Please enter your credentials to access the admin interface.
|
||||
Please enter your credentials to access the dashboard.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
<style>
|
||||
/* Lightweight shared styles (can be moved to global if reused) */
|
||||
.icon-btn {--_size:.7rem; font-size:var(--_size); line-height:1; padding:.3rem .45rem; display:inline-flex; align-items:center; justify-content:center; border:1px solid var(--muted-border,#555); background:transparent; cursor:pointer;}
|
||||
.icon-btn:hover {background:rgba(255,255,255,.05);}
|
||||
.icon-btn.danger {color:var(--del,#e55353); border-color:var(--del,#e55353);}
|
||||
.status-chip {font-size:.65rem; padding:.25rem .55rem; border-radius:1rem; background:var(--pill-bg,rgba(127,127,127,.15)); display:inline-block; min-width:70px; text-align:center;}
|
||||
</style>
|
||||
|
||||
{% if servers.is_empty() %}
|
||||
<article>
|
||||
<header><h4>No servers configured</h4></header>
|
||||
<p>Add your first Jellyfin server to get started.</p>
|
||||
</article>
|
||||
{% else %}
|
||||
<table aria-label="Registered servers">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>URL</th>
|
||||
<th>Priority</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for server in servers %}
|
||||
<tr>
|
||||
<td><strong>{{ server.name }}</strong></td>
|
||||
<td><a href="{{ server.url }}" target="_blank" rel="noopener noreferrer">{{ server.url }}</a></td>
|
||||
<td>
|
||||
<input type="number" value="{{ server.priority }}" min="1" max="999"
|
||||
hx-patch="/{{ ui_route }}/servers/{{ server.id }}/priority"
|
||||
hx-trigger="change delay:200ms"
|
||||
hx-include="closest tr"
|
||||
name="priority"
|
||||
hx-target="#server-list" hx-swap="innerHTML"
|
||||
style="width: 130px; min-width: 130px;">
|
||||
</td>
|
||||
<td>
|
||||
<span hx-get="/{{ ui_route }}/servers/{{ server.id }}/status"
|
||||
hx-trigger="load, every 5s"
|
||||
hx-swap="outerHTML"
|
||||
style="min-width: 80px; display: inline-block;">
|
||||
<span style="color: #666;">Checking...</span>
|
||||
</span>
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
<button type="button" class="icon-btn danger"
|
||||
hx-delete="/{{ ui_route }}/servers/{{ server.id }}"
|
||||
hx-confirm="Delete server '{{ server.name }}'?"
|
||||
hx-target="#server-list" hx-swap="innerHTML"
|
||||
title="Delete server">
|
||||
<i class="fas fa-trash" aria-hidden="true"></i><span class="sr-only" style="position:absolute;left:-9999px;">Delete</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endif %}
|
||||
@@ -1,5 +0,0 @@
|
||||
{% if let Some(error) = error_message%}
|
||||
<span style="color: #dc3545; font-weight: bold;" title="Error: {{ error }}">✗ Offline</span>
|
||||
{% else %}
|
||||
<span style="color: #28a745; font-weight: bold;" title="Server is responding">✓ Online</span>
|
||||
{% endif %}
|
||||
33
crates/jellyswarrm-proxy/src/ui/templates/user/index.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends "../index.html" %}
|
||||
|
||||
{% block tabs %}
|
||||
<li>
|
||||
<a href="#" hx-get="/{{ ui_route }}/user/servers" hx-target="#main-content" class="nav-link">
|
||||
<i class="fas fa-server" style="margin-right: 0.25rem;"></i>
|
||||
Servers
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" hx-get="/{{ ui_route }}/user/media" hx-target="#main-content" class="nav-link">
|
||||
<i class="fas fa-photo-video" style="margin-right: 0.25rem;"></i>
|
||||
Media
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" hx-get="/{{ ui_route }}/user/profile" hx-target="#main-content" class="nav-link">
|
||||
<i class="fas fa-user" style="margin-right: 0.25rem;"></i>
|
||||
Profile
|
||||
</a>
|
||||
</li>
|
||||
{% endblock %}
|
||||
|
||||
{% block main_content %}
|
||||
<section>
|
||||
<div id="main-content" hx-get="/{{ ui_route }}/user/servers" hx-trigger="load">
|
||||
<div style="text-align: center; padding: 2rem;">
|
||||
<i class="fas fa-spinner fa-spin" style="font-size: 1.5rem; color: var(--pico-muted-color);"></i>
|
||||
<p style="margin-top: 1rem; color: var(--pico-muted-color);">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% for server in servers %}
|
||||
<article>
|
||||
<header>
|
||||
<strong>{{ server.server_name }}</strong>
|
||||
{% if let Some(error) = server.error %}
|
||||
<span style="color: #dc3545; float: right;">{{ error }}</span>
|
||||
{% endif %}
|
||||
</header>
|
||||
{% if !server.libraries.is_empty() %}
|
||||
<div class="grid" style="grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1rem;">
|
||||
{% for lib in server.libraries %}
|
||||
<div style="text-align: center; padding: 1rem; border: 1px solid var(--pico-muted-border-color); border-radius: 0.5rem;">
|
||||
<i class="fas fa-folder fa-2x" style="margin-bottom: 0.5rem; color: var(--pico-primary-background);"></i>
|
||||
<div style="font-weight: bold;">{{ lib.name }}</div>
|
||||
<small style="color: var(--pico-muted-color);">
|
||||
{% match lib.collection_type %}
|
||||
{% when Some with (ctype) %}
|
||||
{{ ctype }}
|
||||
{% when None %}
|
||||
unknown
|
||||
{% endmatch %}
|
||||
</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if server.error.is_none() %}
|
||||
<p style="color: var(--pico-muted-color);">No libraries found.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{% if servers.is_empty() %}
|
||||
<div style="text-align: center; padding: 2rem;">
|
||||
<p>No servers mapped.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,53 @@
|
||||
<hgroup>
|
||||
<h1>User Profile</h1>
|
||||
<h2>Manage your account settings</h2>
|
||||
</hgroup>
|
||||
|
||||
<article>
|
||||
<header>
|
||||
<strong>Profile Information</strong>
|
||||
</header>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>
|
||||
Username
|
||||
<input type="text" value="{{ username }}" readonly>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<header>
|
||||
<strong>Change Password</strong>
|
||||
</header>
|
||||
|
||||
<form id="password_form"
|
||||
hx-post="/{{ ui_route }}/user/profile/password"
|
||||
hx-target="#password_message"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#password-loading">
|
||||
|
||||
<div id="password_message" style="margin-bottom: 1rem;"></div>
|
||||
|
||||
<label>
|
||||
Current Password
|
||||
<input type="password" name="current_password" required autocomplete="current-password">
|
||||
</label>
|
||||
<label>
|
||||
New Password
|
||||
<input type="password" name="new_password" required autocomplete="new-password">
|
||||
</label>
|
||||
<label>
|
||||
Confirm New Password
|
||||
<input type="password" name="confirm_password" required autocomplete="new-password">
|
||||
</label>
|
||||
|
||||
<button type="submit">
|
||||
Update Password
|
||||
<span id="password-loading" class="htmx-indicator" style="margin-left: 0.5rem;">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
@@ -0,0 +1,154 @@
|
||||
<hgroup>
|
||||
<h1>Welcome, {{ username }}</h1>
|
||||
<h2>Your Connected Servers</h2>
|
||||
</hgroup>
|
||||
|
||||
{% if servers.is_empty() %}
|
||||
<div style="text-align: center; padding: 2rem;">
|
||||
<i class="fas fa-server fa-3x" style="color: var(--pico-muted-color); margin-bottom: 1rem;"></i>
|
||||
<h3>No Servers Connected</h3>
|
||||
<p>You are not currently connected to any Jellyfin servers.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<table role="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">URL</th>
|
||||
<th scope="col">Priority</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for server in servers %}
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<i class="fas fa-server" style="margin-right: 0.5rem; color: var(--pico-muted-color);"></i>
|
||||
{{ server.name }}
|
||||
</th>
|
||||
<td>
|
||||
<a href="{{ server.url }}" target="_blank">
|
||||
{{ server.url }}
|
||||
<i class="fas fa-external-link-alt" style="font-size: 0.8em; margin-left: 0.25rem;"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ server.priority }}</td>
|
||||
<td>
|
||||
<span hx-get="/{{ ui_route }}/user/servers/{{ server.id }}/status"
|
||||
hx-trigger="load, every 10s"
|
||||
hx-swap="outerHTML">
|
||||
<small class="secondary"><i class="fas fa-circle-notch fa-spin"></i></small>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
hx-delete="/{{ ui_route }}/user/servers/{{ server.id }}"
|
||||
hx-confirm="Are you sure you want to disconnect from {{ server.name }}?"
|
||||
class="outline secondary"
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.8em;">
|
||||
Disconnect
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if !unmapped_servers.is_empty() %}
|
||||
<hr>
|
||||
<h2>Available Servers</h2>
|
||||
<p>Connect to these servers by providing your credentials.</p>
|
||||
|
||||
<table role="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">URL</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for server in unmapped_servers %}
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<i class="fas fa-server" style="margin-right: 0.5rem; color: var(--pico-muted-color);"></i>
|
||||
<strong>{{ server.name }}</strong>
|
||||
</th>
|
||||
<td>
|
||||
<a href="{{ server.url }}" target="_blank">
|
||||
<small>{{ server.url }}</small>
|
||||
<i class="fas fa-external-link-alt" style="font-size: 0.8em; margin-left: 0.25rem;"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span hx-get="/{{ ui_route }}/servers/{{ server.id }}/status"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML">
|
||||
<small class="secondary"><i class="fas fa-circle-notch fa-spin"></i></small>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
onclick="openConnectModal('{{ server.id }}', '{{ server.name }}')"
|
||||
class="contrast outline"
|
||||
style="padding: 0.25rem 0.5rem; font-size: 0.8em;">
|
||||
Connect
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<dialog id="connect_modal">
|
||||
<article>
|
||||
<header>
|
||||
<button aria-label="Close" rel="prev" onclick="closeConnectModal()"></button>
|
||||
<h3>Connect to <span id="modal_server_name"></span></h3>
|
||||
</header>
|
||||
<div id="connect_error"></div>
|
||||
<form id="connect_form" method="post" hx-post="" hx-target="#connect_error" hx-swap="innerHTML">
|
||||
<label>
|
||||
Username
|
||||
<input type="text" name="username" required>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="password" required>
|
||||
</label>
|
||||
<footer>
|
||||
<div class="grid">
|
||||
<button type="button" class="secondary" onclick="closeConnectModal()">Cancel</button>
|
||||
<button type="submit">Connect</button>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
</article>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const modal = document.getElementById('connect_modal');
|
||||
const form = document.getElementById('connect_form');
|
||||
const serverNameSpan = document.getElementById('modal_server_name');
|
||||
const errorDiv = document.getElementById('connect_error');
|
||||
const uiRoute = "{{ ui_route }}";
|
||||
|
||||
window.openConnectModal = function(id, name) {
|
||||
serverNameSpan.innerText = name;
|
||||
form.setAttribute('hx-post', '/' + uiRoute + '/user/servers/' + id + '/connect');
|
||||
htmx.process(form);
|
||||
errorDiv.innerHTML = '';
|
||||
form.reset();
|
||||
modal.showModal();
|
||||
}
|
||||
|
||||
window.closeConnectModal = function() {
|
||||
modal.close();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% if let Some(username) = username %}
|
||||
<span style="color: var(--pico-primary-color);">
|
||||
<i class="fas fa-check-circle" style="margin-right: 0.25rem;"></i>
|
||||
{{ username }} - ({{ server_version }})
|
||||
</span>
|
||||
{% else %}
|
||||
{% if let Some(error) = error_message %}
|
||||
<span style="color: var(--pico-del-color);">
|
||||
<i class="fas fa-exclamation-circle" style="margin-right: 0.25rem;"></i>
|
||||
{{ error }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="color: var(--pico-muted-color);">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -1,94 +0,0 @@
|
||||
<style>
|
||||
/* Minimal additions on top of Pico defaults */
|
||||
.filter-bar {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: .25em .55em;
|
||||
font-size: .65em;
|
||||
background: var(--pico-primary, #0b6efd);
|
||||
color: #fff;
|
||||
border-radius: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.user-key {
|
||||
font-size: .6rem;
|
||||
opacity: .6;
|
||||
word-break: break-all;
|
||||
padding-top: .25em;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: .25em .7em;
|
||||
background: var(--pico-muted-border-color, rgba(127, 127, 127, .15));
|
||||
border-radius: 1rem;
|
||||
font-size: .7em;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--pico-del-color, #d9534f);
|
||||
border-color: var(--pico-del-color, #d9534f);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 1rem;
|
||||
border: 2px dashed var(--pico-border-color, #ccc);
|
||||
border-radius: .6rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Softer animations */
|
||||
.htmx-added {
|
||||
animation: fadeInSoft 160ms ease-out, highlightSoft 900ms ease-out;
|
||||
}
|
||||
.htmx-swapping {
|
||||
animation: fadeOutSoft 140ms ease-in forwards;
|
||||
pointer-events:none;
|
||||
}
|
||||
@keyframes fadeInSoft {
|
||||
from { opacity:0; transform:translateY(2px); }
|
||||
to { opacity:1; transform:translateY(0); }
|
||||
}
|
||||
@keyframes fadeOutSoft {
|
||||
from { opacity:1; transform:translateY(0); }
|
||||
to { opacity:0; transform:translateY(-2px); }
|
||||
}
|
||||
@keyframes highlightSoft {
|
||||
0% { background:#fff8d9; }
|
||||
100% { background:transparent; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.htmx-added, .htmx-swapping { animation:none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="filter-bar">
|
||||
<input id="user-filter" type="search" placeholder="Filter users" oninput="filterUsers(this.value)">
|
||||
</div>
|
||||
|
||||
{% if users.is_empty() %}
|
||||
<div class="empty-state" role="note">
|
||||
<p>No users yet. Add one above to begin.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<section class="user-list" aria-label="Users">
|
||||
{% for uwm in users %}
|
||||
{% include "user_item.html" %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function filterUsers(q) {
|
||||
q = q.toLowerCase().trim();
|
||||
for (const art of document.querySelectorAll('section.user-list > article')) {
|
||||
const name = art.dataset.username.toLowerCase();
|
||||
art.hidden = q && !name.includes(q);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
63
crates/jellyswarrm-proxy/src/ui/user/common.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use jellyfin_api::JellyfinClient;
|
||||
|
||||
use crate::{server_storage::Server, AppState};
|
||||
|
||||
pub async fn authenticate_user_on_server(
|
||||
state: &AppState,
|
||||
user: &crate::ui::auth::User,
|
||||
server: &Server,
|
||||
) -> Result<
|
||||
(
|
||||
JellyfinClient,
|
||||
jellyfin_api::models::User,
|
||||
jellyfin_api::models::PublicSystemInfo,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
// Always check public system info first to get version and name
|
||||
let server_url = server.url.clone();
|
||||
let client_info = crate::config::CLIENT_INFO.clone();
|
||||
|
||||
let (public_info, client) = match JellyfinClient::new(server_url.as_str(), client_info) {
|
||||
Ok(c) => match c.get_public_system_info().await {
|
||||
Ok(info) => (info, c),
|
||||
Err(_) => return Err("Server offline or unreachable".to_string()),
|
||||
},
|
||||
Err(e) => return Err(format!("Failed to create jellyfin client: {}", e)),
|
||||
};
|
||||
|
||||
// Check for mapping and try to authenticate
|
||||
let mapping = match state
|
||||
.user_authorization
|
||||
.get_server_mapping(&user.id, server.url.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(Some(m)) => m,
|
||||
Ok(None) => return Err("No mapping found for user on this server".to_string()),
|
||||
Err(e) => return Err(format!("Database error: {}", e)),
|
||||
};
|
||||
|
||||
let admin_password = state.get_admin_password().await;
|
||||
|
||||
let password = state.user_authorization.decrypt_server_mapping_password(
|
||||
&mapping,
|
||||
&user.password,
|
||||
&admin_password,
|
||||
);
|
||||
|
||||
match client
|
||||
.authenticate_by_name(&mapping.mapped_username, &password)
|
||||
.await
|
||||
{
|
||||
Ok(jellyfin_user) => Ok((client, jellyfin_user, public_info)),
|
||||
Err(e) => {
|
||||
// Auth failed, log it but continue to check existing session
|
||||
tracing::warn!(
|
||||
"Failed to authenticate with mapped credentials for server {}: {}",
|
||||
server.id,
|
||||
e
|
||||
);
|
||||
Err("Failed to log in with provided credentials".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
83
crates/jellyswarrm-proxy/src/ui/user/media.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse},
|
||||
};
|
||||
use jellyfin_api::models::MediaFolder;
|
||||
use tracing::error;
|
||||
|
||||
use crate::{
|
||||
ui::{auth::AuthenticatedUser, user::common::authenticate_user_on_server},
|
||||
AppState,
|
||||
};
|
||||
|
||||
pub struct ServerLibraries {
|
||||
pub server_name: String,
|
||||
pub libraries: Vec<MediaFolder>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user/user_media.html")]
|
||||
pub struct UserMediaTemplate {
|
||||
pub servers: Vec<ServerLibraries>,
|
||||
}
|
||||
|
||||
pub async fn get_user_media(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> impl IntoResponse {
|
||||
let servers = match state.user_authorization.get_mapped_servers(&user.id).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to list mapped servers: {}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let mut server_libraries: Vec<ServerLibraries> = Vec::new();
|
||||
|
||||
for server in servers {
|
||||
let mut libraries = Vec::new();
|
||||
let mut error_msg = None;
|
||||
|
||||
// Authenticate user on the server
|
||||
if let Ok((client, _, _)) = authenticate_user_on_server(&state, &user, &server).await {
|
||||
match client.get_media_folders().await {
|
||||
Ok(folders) => {
|
||||
libraries = folders;
|
||||
if let Err(e) = client.logout().await {
|
||||
error!("Failed to logout from server {}: {}", server.name, e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to get media folders from server {}: {}",
|
||||
server.name, e
|
||||
);
|
||||
error_msg = Some(format!("Error fetching media folders: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error_msg = Some("Failed to authenticate on server".to_string());
|
||||
}
|
||||
|
||||
server_libraries.push(ServerLibraries {
|
||||
server_name: server.name,
|
||||
libraries,
|
||||
error: error_msg,
|
||||
});
|
||||
}
|
||||
|
||||
let template = UserMediaTemplate {
|
||||
servers: server_libraries,
|
||||
};
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render user media template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
4
crates/jellyswarrm-proxy/src/ui/user/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
mod common;
|
||||
pub mod media;
|
||||
pub mod profile;
|
||||
pub mod servers;
|
||||
138
crates/jellyswarrm-proxy/src/ui/user/profile.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse},
|
||||
Form,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tracing::error;
|
||||
|
||||
use crate::{ui::auth::AuthenticatedUser, AppState};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user/user_profile.html")]
|
||||
pub struct UserProfileTemplate {
|
||||
pub username: String,
|
||||
pub ui_route: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ChangePasswordForm {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
pub confirm_password: String,
|
||||
}
|
||||
|
||||
pub async fn get_user_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> impl IntoResponse {
|
||||
let template = UserProfileTemplate {
|
||||
username: user.username,
|
||||
ui_route: state.get_ui_route().await,
|
||||
};
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render user profile template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_user_password(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Form(form): Form<ChangePasswordForm>,
|
||||
) -> impl IntoResponse {
|
||||
if form.new_password != form.confirm_password {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Html(r#"
|
||||
<div role="alert" style="background-color: #c62828; color: white; padding: 0.75rem; border-radius: 0.25rem;">
|
||||
<i class="fas fa-exclamation-circle" style="margin-right: 0.5rem;"></i> New passwords do not match
|
||||
</div>
|
||||
"#),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match state
|
||||
.user_authorization
|
||||
.verify_user_password(&user.id, &form.current_password)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
let admin_password = {
|
||||
let config = state.config.read().await;
|
||||
config.password.clone()
|
||||
};
|
||||
|
||||
match state
|
||||
.user_authorization
|
||||
.update_user_password(
|
||||
&user.id,
|
||||
&form.current_password,
|
||||
&form.new_password,
|
||||
&admin_password,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let logout_url = format!("/{}/logout", state.get_ui_route().await);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Html(format!(r#"
|
||||
<div role="alert" style="background-color: #2e7d32; color: white; padding: 0.75rem; border-radius: 0.25rem;">
|
||||
<i class="fas fa-check-circle" style="margin-right: 0.5rem;"></i> Password updated successfully
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById("password_form").reset();
|
||||
setTimeout(function() {{
|
||||
alert("Password changed successfully. You will be logged out.");
|
||||
window.location.href = "{}";
|
||||
}}, 100);
|
||||
</script>
|
||||
"#, logout_url)),
|
||||
)
|
||||
.into_response()
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to update password: {}", e);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Html(r#"
|
||||
<div role="alert" style="background-color: #c62828; color: white; padding: 0.75rem; border-radius: 0.25rem;">
|
||||
<i class="fas fa-exclamation-circle" style="margin-right: 0.5rem;"></i> Database error
|
||||
</div>
|
||||
"#.to_string()),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => (
|
||||
StatusCode::OK,
|
||||
Html(r#"
|
||||
<div role="alert" style="background-color: #c62828; color: white; padding: 0.75rem; border-radius: 0.25rem;">
|
||||
<i class="fas fa-exclamation-circle" style="margin-right: 0.5rem;"></i> Incorrect current password
|
||||
</div>
|
||||
"#),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to verify password: {}", e);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Html(r#"
|
||||
<div role="alert" style="background-color: #c62828; color: white; padding: 0.75rem; border-radius: 0.25rem;">
|
||||
<i class="fas fa-exclamation-circle" style="margin-right: 0.5rem;"></i> Database error
|
||||
</div>
|
||||
"#),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
297
crates/jellyswarrm-proxy/src/ui/user/servers.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::{Html, IntoResponse},
|
||||
Form,
|
||||
};
|
||||
use hyper::{header::HeaderValue, StatusCode};
|
||||
use jellyfin_api::JellyfinClient;
|
||||
use serde::Deserialize;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
server_storage::Server,
|
||||
ui::{auth::AuthenticatedUser, user::common::authenticate_user_on_server},
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user/user_server_list.html")]
|
||||
pub struct UserServerListTemplate {
|
||||
pub username: String,
|
||||
pub servers: Vec<Server>,
|
||||
pub unmapped_servers: Vec<Server>,
|
||||
pub ui_route: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ConnectServerForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user/user_server_status.html")]
|
||||
pub struct UserServerStatusTemplate {
|
||||
pub username: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub server_version: String,
|
||||
}
|
||||
|
||||
pub async fn get_user_servers(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> impl IntoResponse {
|
||||
let mapped_servers = match state.user_authorization.get_mapped_servers(&user.id).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to list mapped servers: {}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let all_servers = match state.server_storage.list_servers().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to list all servers: {}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let unmapped_servers: Vec<Server> = all_servers
|
||||
.into_iter()
|
||||
.filter(|s| !mapped_servers.iter().any(|ms| ms.id == s.id))
|
||||
.collect();
|
||||
|
||||
let template = UserServerListTemplate {
|
||||
username: user.username,
|
||||
servers: mapped_servers,
|
||||
unmapped_servers,
|
||||
ui_route: state.get_ui_route().await,
|
||||
};
|
||||
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render user server list template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect_server(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(server_id): Path<i64>,
|
||||
Form(form): Form<ConnectServerForm>,
|
||||
) -> impl IntoResponse {
|
||||
// Get server details
|
||||
let server = match state.server_storage.get_server_by_id(server_id).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Html("<span style=\"color: #dc3545;\">Server not found</span>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get server: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("<span style=\"color: #dc3545;\">Database error</span>"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Verify credentials with upstream Jellyfin
|
||||
let server_url = server.url.clone();
|
||||
|
||||
let client_info = crate::config::CLIENT_INFO.clone();
|
||||
|
||||
let client = match JellyfinClient::new(server_url.as_str(), client_info) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Failed to create jellyfin client: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("<span style=\"color: #dc3545;\">Client error</span>"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match client.authenticate_by_name(&form.username, &form.password).await {
|
||||
Ok(_) => {
|
||||
// Credentials valid, create mapping
|
||||
match state
|
||||
.user_authorization
|
||||
.add_server_mapping(
|
||||
&user.id,
|
||||
server.url.as_str(),
|
||||
&form.username,
|
||||
&form.password,
|
||||
Some(&user.password),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
info!(
|
||||
"Created mapping for user {} to server {}",
|
||||
user.username, server.name
|
||||
);
|
||||
|
||||
// Return HX-Redirect header for HTMX
|
||||
let mut response = StatusCode::OK.into_response();
|
||||
response.headers_mut().insert(
|
||||
"HX-Redirect",
|
||||
HeaderValue::from_str(&format!("/{}", state.get_ui_route().await)).unwrap(),
|
||||
);
|
||||
response
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to create mapping: {}", e);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Database error</div>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(jellyfin_api::error::Error::AuthenticationFailed(_)) => {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Html("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Invalid credentials</div>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to authenticate with upstream: {}", e);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Html(format!("<div style=\"background-color: #e74c3c; color: white; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem;\">Connection error: {}</div>", e)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_server_mapping(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(server_id): Path<i64>,
|
||||
) -> impl IntoResponse {
|
||||
// Get server details to find the URL
|
||||
let server = match state.server_storage.get_server_by_id(server_id).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to get server: {}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Find the mapping
|
||||
let mappings = match state
|
||||
.user_authorization
|
||||
.list_server_mappings(&user.id)
|
||||
.await
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Failed to list mappings: {}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Normalize URLs for comparison (remove trailing slashes)
|
||||
let server_url = server.url.as_str().trim_end_matches('/');
|
||||
|
||||
if let Some(mapping) = mappings
|
||||
.iter()
|
||||
.find(|m| m.server_url.trim_end_matches('/') == server_url)
|
||||
{
|
||||
match state
|
||||
.user_authorization
|
||||
.delete_server_mapping(mapping.id)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
info!(
|
||||
"Deleted mapping for user {} to server {}",
|
||||
user.username, server.name
|
||||
);
|
||||
// Return HX-Redirect header for HTMX
|
||||
let mut response = StatusCode::OK.into_response();
|
||||
response.headers_mut().insert(
|
||||
"HX-Redirect",
|
||||
HeaderValue::from_str(&format!("/{}", state.get_ui_route().await)).unwrap(),
|
||||
);
|
||||
return response;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to delete mapping: {}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
|
||||
pub async fn check_user_server_status(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(server_id): Path<i64>,
|
||||
) -> impl IntoResponse {
|
||||
// Get server details
|
||||
let server = match state.server_storage.get_server_by_id(server_id).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Html("<span style=\"color: #dc3545;\">Server not found</span>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get server: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("<span style=\"color: #dc3545;\">Database error</span>"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match authenticate_user_on_server(&state, &user, &server).await {
|
||||
Ok((client, jellyfin_user, public_info)) => {
|
||||
let template = UserServerStatusTemplate {
|
||||
username: Some(jellyfin_user.name),
|
||||
error_message: None,
|
||||
server_version: public_info.version.unwrap_or("unknown".to_string()),
|
||||
};
|
||||
if let Err(e) = client.logout().await {
|
||||
warn!("Failed to logout from upstream server: {}", e);
|
||||
}
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render user server status template: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html("<span style=\"color: #dc3545;\">Template error</span>"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::OK,
|
||||
Html(format!("<span style=\"color: #dc3545;\">{}</span>", e)),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{FromRow, Row, SqlitePool};
|
||||
use tracing::info;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::encryption::{decrypt_password, encrypt_password};
|
||||
use crate::models::{generate_token, Authorization};
|
||||
use crate::server_storage::Server;
|
||||
|
||||
@@ -289,9 +290,22 @@ impl UserAuthorizationService {
|
||||
server_url: &str,
|
||||
mapped_username: &str,
|
||||
mapped_password: &str,
|
||||
master_password: Option<&str>,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let final_password = if let Some(master) = master_password {
|
||||
match encrypt_password(mapped_password, master) {
|
||||
Ok(encrypted) => encrypted,
|
||||
Err(e) => {
|
||||
warn!("Failed to encrypt password: {}. Storing as plaintext.", e);
|
||||
mapped_password.to_string()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mapped_password.to_string()
|
||||
};
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
INSERT OR REPLACE INTO server_mappings
|
||||
@@ -302,7 +316,7 @@ impl UserAuthorizationService {
|
||||
.bind(user_id)
|
||||
.bind(server_url)
|
||||
.bind(mapped_username)
|
||||
.bind(mapped_password)
|
||||
.bind(final_password)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&self.pool)
|
||||
@@ -316,6 +330,31 @@ impl UserAuthorizationService {
|
||||
Ok(mapping_id)
|
||||
}
|
||||
|
||||
/// Decrypt a server mapping password
|
||||
pub fn decrypt_server_mapping_password(
|
||||
&self,
|
||||
mapping: &ServerMapping,
|
||||
user_password: &str,
|
||||
admin_password: &str,
|
||||
) -> String {
|
||||
// Try user password first
|
||||
if let Ok(decrypted) = decrypt_password(&mapping.mapped_password, user_password) {
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// Try admin password
|
||||
if let Ok(decrypted) = decrypt_password(&mapping.mapped_password, admin_password) {
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// If decryption fails, assume it's plaintext (legacy or fallback)
|
||||
warn!(
|
||||
"Failed to decrypt password for mapping {}. Assuming plaintext.",
|
||||
mapping.id
|
||||
);
|
||||
mapping.mapped_password.clone()
|
||||
}
|
||||
|
||||
/// Get server mapping
|
||||
pub async fn get_server_mapping(
|
||||
&self,
|
||||
@@ -613,6 +652,99 @@ impl UserAuthorizationService {
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Update user password and re-encrypt server mappings
|
||||
pub async fn update_user_password(
|
||||
&self,
|
||||
user_id: &str,
|
||||
old_password: &str,
|
||||
new_password: &str,
|
||||
admin_password: &str,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
|
||||
// 1. Update user password hash
|
||||
let password_hash = Self::hash_password(new_password);
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let res = sqlx::query(
|
||||
r#"
|
||||
UPDATE users
|
||||
SET original_password_hash = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(password_hash)
|
||||
.bind(now)
|
||||
.bind(user_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if res.rows_affected() == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 2. Re-encrypt all server mappings
|
||||
let mappings = sqlx::query_as::<_, ServerMapping>(
|
||||
r#"
|
||||
SELECT id, user_id, server_url, mapped_username, mapped_password, created_at, updated_at
|
||||
FROM server_mappings
|
||||
WHERE user_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
for mapping in mappings {
|
||||
// Decrypt with old credentials
|
||||
let decrypted_password =
|
||||
self.decrypt_server_mapping_password(&mapping, old_password, admin_password);
|
||||
|
||||
// Encrypt with new password
|
||||
let new_encrypted_password = match encrypt_password(&decrypted_password, new_password) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
error!("Failed to encrypt password during update: {}", e);
|
||||
return Err(sqlx::Error::Protocol(format!("Encryption failed: {}", e)));
|
||||
}
|
||||
};
|
||||
|
||||
// Update mapping in DB
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE server_mappings
|
||||
SET mapped_password = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(new_encrypted_password)
|
||||
.bind(now)
|
||||
.bind(mapping.id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Verify user password
|
||||
pub async fn verify_user_password(
|
||||
&self,
|
||||
user_id: &str,
|
||||
password: &str,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let user = self.get_user_by_id(user_id).await?;
|
||||
|
||||
if let Some(user) = user {
|
||||
let password_hash = Self::hash_password(password);
|
||||
Ok(user.original_password_hash == password_hash)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get counts of authorization sessions per normalized server URL for a user
|
||||
pub async fn session_counts_by_server(
|
||||
&self,
|
||||
@@ -656,6 +788,37 @@ impl UserAuthorizationService {
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
|
||||
/// Get all servers mapped to a user, sorted by priority
|
||||
pub async fn get_mapped_servers(&self, user_id: &str) -> Result<Vec<Server>, sqlx::Error> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT s.id, s.name, s.url, s.priority, s.created_at, s.updated_at
|
||||
FROM servers s
|
||||
JOIN server_mappings sm ON RTRIM(s.url, '/') = RTRIM(sm.server_url, '/')
|
||||
WHERE sm.user_id = ?
|
||||
ORDER BY s.priority DESC, s.name ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let servers = rows
|
||||
.into_iter()
|
||||
.map(|row| Server {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
url: url::Url::parse(row.get::<String, _>("url").as_str())
|
||||
.unwrap_or_else(|_| url::Url::parse("http://invalid-url-in-db").unwrap()),
|
||||
priority: row.get("priority"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(servers)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -765,6 +928,7 @@ mod tests {
|
||||
"http://localhost:8096",
|
||||
"mappeduser",
|
||||
"mappedpass",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -907,6 +1071,7 @@ mod tests {
|
||||
"http://localhost:8096",
|
||||
"mappeduser",
|
||||
"mappedpass",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1000,6 +1165,7 @@ mod tests {
|
||||
"http://localhost:8096",
|
||||
"mappeduser",
|
||||
"mappedpass",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1114,6 +1280,7 @@ mod tests {
|
||||
"http://localhost:8096",
|
||||
"mappeduser1",
|
||||
"mappedpass1",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1124,6 +1291,7 @@ mod tests {
|
||||
"http://localhost:8097",
|
||||
"mappeduser2",
|
||||
"mappedpass2",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1238,6 +1406,7 @@ mod tests {
|
||||
"http://localhost:8096",
|
||||
"mappeduser",
|
||||
"mappedpass",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1340,7 +1509,7 @@ mod tests {
|
||||
// Add mappings for both servers
|
||||
for url in ["http://localhost:8096", "http://localhost:8097"] {
|
||||
service
|
||||
.add_server_mapping(&user.id, url, "mappeduser", "mappedpass")
|
||||
.add_server_mapping(&user.id, url, "mappeduser", "mappedpass", None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 221 KiB |
BIN
docs/images/federated.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 383 KiB |
98
docs/ui.md
@@ -1,6 +1,10 @@
|
||||
# Jellyswarrm UI Documentation
|
||||
|
||||
## Adding Servers
|
||||
## Admin Interface Overview
|
||||
|
||||
The Admin Interface is displayed when you log in with an administrator account. It provides tools to manage connected Jellyfin servers, user accounts, and global settings for your Jellyswarrm instance.
|
||||
|
||||
### Adding Servers
|
||||
|
||||
<p align="center">
|
||||
<img src="./images/servers.png" alt="Servers" width="90%">
|
||||
@@ -24,58 +28,78 @@ To connect your Jellyfin servers to Jellyswarrm, follow these steps:
|
||||
|
||||
To remove a server, simply click the **Delete** button next to the one you want to remove.
|
||||
|
||||
|
||||
## User Mappings
|
||||
|
||||
Jellyswarrm allows you to link users across multiple Jellyfin servers into a single unified account. This way, users can log in once and access media from all their connected servers seamlessly.
|
||||
|
||||
---
|
||||
|
||||
### Adding Users
|
||||
|
||||
#### Automatic Mapping
|
||||
If a user already exists on one or more connected servers, they can log in directly with their existing Jellyfin credentials. Jellyswarrm will automatically create a local user and set up the necessary server mappings.
|
||||
|
||||
If the same username and password exist on multiple servers, Jellyswarrm will link those accounts together automatically. This provides a smooth experience, giving the user unified access to all linked servers.
|
||||
|
||||
---
|
||||
|
||||
#### Manual Mapping
|
||||
#### Federarated Servers
|
||||
|
||||
<p align="center">
|
||||
<img src="./images/add_user.png" alt="Add User" width="90%">
|
||||
</p>
|
||||
<img src="./images/federated.png" alt="Add User with Federation" width="90%">
|
||||
</p>
|
||||
|
||||
To manually create a user in Jellyswarrm:
|
||||
|
||||
1. Open the Jellyswarrm Web UI in your browser:
|
||||
`http://[JELLYSWARRM_HOST]:[JELLYSWARRM_PORT]/ui`
|
||||
2. Log in with the admin credentials you set during deployment.
|
||||
3. Navigate to the **Users** section.
|
||||
4. Define a **username** and **password** for the new user.
|
||||
5. Click **Add** to create the user.
|
||||
After you add a server, you can optionally provide admin credentials. This allows Jellyswarrm to create and manage user accounts on that server automatically when using the federation features. Simply press the admin icon next to the server entry and enter the admin username and password for that Jellyfin instance.
|
||||
|
||||
|
||||
|
||||
### User Management & Federation
|
||||
|
||||
Jellyswarrm allows you to link users across multiple Jellyfin servers into a single unified account. This way, users can log in once and access media from all their connected servers seamlessly.
|
||||
|
||||
---
|
||||
|
||||
### Adding Server Mappings to a User
|
||||
### Adding Users
|
||||
|
||||
#### Federation (Recommended)
|
||||
When creating a new user in Jellyswarrm via the Admin UI, you can check the **Enable Federation** option.
|
||||
|
||||
<p align="center">
|
||||
<img src="./images/add_user.png" alt="Add User with Federation" width="90%">
|
||||
</p>
|
||||
|
||||
This will automatically:
|
||||
1. Create the user on all connected Jellyfin servers (requires Admin credentials to be configured for those servers).
|
||||
2. Set the same password for all of them.
|
||||
3. Automatically create the server mappings in Jellyswarrm.
|
||||
|
||||
This ensures that the user exists everywhere and is ready to use immediately without manual configuration.
|
||||
|
||||
#### Automatic Mapping (Login)
|
||||
If a user already exists on one or more connected servers, they can log in directly with their existing Jellyfin credentials. Jellyswarrm will automatically create a local user and set up the necessary server mappings.
|
||||
|
||||
If the same username and password exist on multiple servers, Jellyswarrm will link those accounts together automatically. This provides a smooth experience, giving the user unified access to all linked servers.
|
||||
|
||||
#### Manual Creation
|
||||
To manually create a user in Jellyswarrm without federation:
|
||||
|
||||
1. Open the Jellyswarrm Web UI in your browser:
|
||||
`http://[JELLYSWARRM_HOST]:[JELLYSWARRM_PORT]/ui`
|
||||
2. Log in with the admin credentials you set during deployment.
|
||||
3. Navigate to the **Users** section.
|
||||
4. Define a **username** and **password** for the new user.
|
||||
5. Uncheck **Enable Federation** if you only want to create the user locally in Jellyswarrm.
|
||||
6. Click **Add** to create the user.
|
||||
|
||||
---
|
||||
|
||||
### Adding Server Mappings to a User
|
||||
|
||||
<p align="center">
|
||||
<img src="./images/add_mapping.png" alt="Add Mapping" width="90%">
|
||||
</p>
|
||||
</p>
|
||||
|
||||
To link a user to additional server accounts:
|
||||
To manually link a user to additional server accounts (e.g. if they have different passwords or usernames on different servers):
|
||||
|
||||
1. In the **Users** section, find the user you want to extend with server mappings.
|
||||
2. If the user does not yet have a mapping for a server, a dropdown menu will appear under their entry.
|
||||
3. Select the target server from the dropdown.
|
||||
4. Enter the **username** and **password** for the Jellyfin account on that server.
|
||||
5. Click **Add** to save the mapping.
|
||||
1. In the **Users** section, find the user you want to extend with server mappings.
|
||||
2. If the user does not yet have a mapping for a server, a dropdown menu will appear under their entry.
|
||||
3. Select the target server from the dropdown.
|
||||
4. Enter the **username** and **password** for the Jellyfin account on that server.
|
||||
5. Click **Add** to save the mapping.
|
||||
|
||||
---
|
||||
|
||||
### Removing Users or Mappings
|
||||
### Removing Users or Mappings
|
||||
|
||||
To remove a user or unlink a specific server mapping, simply press the **Delete** button next to the entry you want to remove.
|
||||
To remove a user or unlink a specific server mapping, simply press the **Delete** button next to the entry you want to remove.
|
||||
|
||||
When deleting a user, you can optionally choose to **Delete from all servers**, which will attempt to remove the user account from all connected Jellyfin instances where the admin has access.
|
||||
|
||||
|
||||
## Global Settings
|
||||
|
||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 327 KiB |
BIN
media/user_page.png
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
media/users.png
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 237 KiB |