Merge pull request #52 from LLukas22/user-page

Rework User Management/Account Creation and Introduce Automatic User Creation
This commit is contained in:
Lukas Kreussel
2025-12-01 18:14:36 +01:00
committed by GitHub
60 changed files with 3741 additions and 701 deletions

361
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@@ -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 isnt available yet. Please log in using your **username & password** for now.
* **QuickConnect** This feature isnt 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 isnt supported yet.
### 🚫 Not Planned
* **Admin Functions** Server administration (user management, settings, etc.) wont be supported through Jellyswarrm.
* **Media Management** Features like adding or deleting media libraries through Jellyswarrm are not implemented yet.
---

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

View 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),
}

View 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");
}
}

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

View File

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

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS server_admins;

View File

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

View File

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

View 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);
}
}

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
pub mod servers;
pub mod settings;
pub mod users;

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

View File

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

View File

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

View File

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

View File

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

View 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; }
}

View File

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

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

View File

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

View 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 %}

View 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 %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 %}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,4 @@
mod common;
pub mod media;
pub mod profile;
pub mod servers;

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

View 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(),
}
}

View File

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

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 221 KiB

BIN
docs/images/federated.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 383 KiB

View File

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

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 327 KiB

BIN
media/user_page.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 237 KiB