mirror of
https://github.com/LLukas22/Jellyswarrm.git
synced 2025-12-23 22:47:47 -05:00
Merge pull request #61 from LLukas22/user-media
Jellyfin API, Typesave Passwords and Improved UserMediaView
This commit is contained in:
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -199,9 +199,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.0"
|
||||
version = "3.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18"
|
||||
checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
@@ -930,7 +930,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1590,6 +1590,7 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
name = "jellyfin-api"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"moka",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -22,6 +22,7 @@ hyper = { version = "1.0", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["full"] }
|
||||
http-body-util = "0.1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "gzip", "brotli", "deflate", "rustls-tls"] }
|
||||
moka = { version = "0.12.11", features = ["future"] }
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono"] }
|
||||
|
||||
@@ -6,12 +6,14 @@ authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
url = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
moka = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
|
||||
396
crates/jellyfin-api/src/client.rs
Normal file
396
crates/jellyfin-api/src/client.rs
Normal file
@@ -0,0 +1,396 @@
|
||||
use crate::error::Error;
|
||||
use crate::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, PartialEq, Eq, Hash)]
|
||||
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 PartialEq for JellyfinClient {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.base_url == other.base_url && self.client_info == other.client_info
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for JellyfinClient {}
|
||||
|
||||
impl std::hash::Hash for JellyfinClient {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.base_url.hash(state);
|
||||
self.client_info.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<Vec<crate::models::MediaFolder>, Error> {
|
||||
let path = if let Some(uid) = user_id {
|
||||
format!("Users/{}/Views", uid)
|
||||
} else {
|
||||
"Library/MediaFolders".to_string()
|
||||
};
|
||||
|
||||
let response: MediaFoldersResponse =
|
||||
self.request(reqwest::Method::GET, &path, None).await?;
|
||||
Ok(response.items)
|
||||
}
|
||||
|
||||
pub async fn get_public_system_info(&self) -> Result<crate::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
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn get_items(
|
||||
&self,
|
||||
user_id: &str,
|
||||
parent_id: Option<&str>,
|
||||
recursive: bool,
|
||||
include_item_types: Option<Vec<String>>,
|
||||
limit: Option<i32>,
|
||||
start_index: Option<i32>,
|
||||
sort_by: Option<String>,
|
||||
sort_order: Option<String>,
|
||||
) -> Result<crate::models::ItemsResponse, Error> {
|
||||
let mut query = vec![
|
||||
("Recursive", recursive.to_string()),
|
||||
("Fields", "PrimaryImageAspectRatio,CanDelete,BasicSyncInfo,ProductionYear,RunTimeTicks,CommunityRating".to_string()),
|
||||
];
|
||||
|
||||
if let Some(pid) = parent_id {
|
||||
query.push(("ParentId", pid.to_string()));
|
||||
}
|
||||
|
||||
if let Some(types) = include_item_types {
|
||||
query.push(("IncludeItemTypes", types.join(",")));
|
||||
}
|
||||
|
||||
if let Some(l) = limit {
|
||||
query.push(("Limit", l.to_string()));
|
||||
}
|
||||
|
||||
if let Some(si) = start_index {
|
||||
query.push(("StartIndex", si.to_string()));
|
||||
}
|
||||
|
||||
if let Some(s) = sort_by {
|
||||
query.push(("SortBy", s));
|
||||
}
|
||||
|
||||
if let Some(o) = sort_order {
|
||||
query.push(("SortOrder", o));
|
||||
}
|
||||
|
||||
let path = format!("Users/{}/Items", user_id);
|
||||
let url = self.base_url.join(&path)?;
|
||||
|
||||
let auth_header = self.build_auth_header();
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(url)
|
||||
.header(header::AUTHORIZATION, auth_header)
|
||||
.query(&query)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
let data = response.json::<crate::models::ItemsResponse>().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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
let client = client.with_token("test_token".to_string());
|
||||
|
||||
let folders = client.get_media_folders(None).await.unwrap();
|
||||
|
||||
assert_eq!(folders.len(), 1);
|
||||
assert_eq!(folders[0].name, "Movies");
|
||||
}
|
||||
}
|
||||
@@ -1,305 +1,6 @@
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
pub mod storage;
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
pub use client::{ClientInfo, JellyfinClient};
|
||||
|
||||
@@ -65,3 +65,29 @@ pub struct PublicSystemInfo {
|
||||
#[serde(rename = "StartupWizardCompleted")]
|
||||
pub startup_wizard_completed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BaseItem {
|
||||
#[serde(rename = "Name")]
|
||||
pub name: String,
|
||||
#[serde(rename = "Id")]
|
||||
pub id: String,
|
||||
#[serde(rename = "Type")]
|
||||
pub type_: String,
|
||||
#[serde(rename = "ImageTags")]
|
||||
pub image_tags: Option<std::collections::HashMap<String, String>>,
|
||||
#[serde(rename = "ProductionYear")]
|
||||
pub production_year: Option<i32>,
|
||||
#[serde(rename = "RunTimeTicks")]
|
||||
pub run_time_ticks: Option<i64>,
|
||||
#[serde(rename = "CommunityRating")]
|
||||
pub community_rating: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ItemsResponse {
|
||||
#[serde(rename = "Items")]
|
||||
pub items: Vec<BaseItem>,
|
||||
#[serde(rename = "TotalRecordCount")]
|
||||
pub total_record_count: i32,
|
||||
}
|
||||
|
||||
141
crates/jellyfin-api/src/storage.rs
Normal file
141
crates/jellyfin-api/src/storage.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{error::Error, ClientInfo, JellyfinClient};
|
||||
use moka::future::Cache;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct JellyfinClientStorage {
|
||||
cache: Cache<(String, ClientInfo, String), Arc<JellyfinClient>>,
|
||||
}
|
||||
|
||||
impl JellyfinClientStorage {
|
||||
pub fn new(capacity: u64, ttl: std::time::Duration) -> Self {
|
||||
let cache = Cache::builder()
|
||||
.max_capacity(capacity)
|
||||
.time_to_idle(ttl)
|
||||
.eviction_listener(|_key, value: Arc<JellyfinClient>, _cause| {
|
||||
if value.get_token().is_some() {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = value.logout().await {
|
||||
tracing::error!("Failed to logout evicted client: {:?}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
Self { cache }
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
&self,
|
||||
base_url: &str,
|
||||
client_info: ClientInfo,
|
||||
id: Option<&str>,
|
||||
) -> Result<Arc<JellyfinClient>, Error> {
|
||||
let mut url = Url::parse(base_url)?;
|
||||
if url.path().ends_with('/') {
|
||||
url.path_segments_mut()
|
||||
.map_err(|_| Error::UrlParse(url::ParseError::EmptyHost))?
|
||||
.pop_if_empty();
|
||||
}
|
||||
let normalized_url = url.to_string();
|
||||
let id = id.unwrap_or_default().to_string();
|
||||
let key = (normalized_url.clone(), client_info.clone(), id);
|
||||
|
||||
if let Some(client) = self.cache.get(&key).await {
|
||||
return Ok(client);
|
||||
}
|
||||
|
||||
let client = Arc::new(JellyfinClient::new(&normalized_url, client_info)?);
|
||||
self.cache.insert(key, client.clone()).await;
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_client_eviction_logout() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
// Mock the logout endpoint
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/Sessions/Logout"))
|
||||
.respond_with(ResponseTemplate::new(204))
|
||||
.expect(1) // Expect exactly one call
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// Create storage with capacity 1
|
||||
let storage = JellyfinClientStorage::new(1, Duration::from_secs(60));
|
||||
let client_info = ClientInfo::default();
|
||||
|
||||
// 1. Get first client
|
||||
let client1 = storage
|
||||
.get(&mock_server.uri(), client_info.clone(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Simulate authentication (this updates the shared Arc<RwLock>)
|
||||
let _ = client1.with_token("test_token".to_string());
|
||||
|
||||
// 2. Manually invalidate the client to force eviction
|
||||
// We need to reconstruct the key used in storage
|
||||
let mut url = Url::parse(&mock_server.uri()).unwrap();
|
||||
if url.path().ends_with('/') {
|
||||
url.path_segments_mut().unwrap().pop_if_empty();
|
||||
}
|
||||
let normalized_url = url.to_string();
|
||||
let key = (normalized_url, client_info, "".to_string());
|
||||
|
||||
storage.cache.invalidate(&key).await;
|
||||
|
||||
// Force maintenance to ensure eviction happens (invalidate might be lazy or listener might be async)
|
||||
storage.cache.run_pending_tasks().await;
|
||||
|
||||
// We need to wait for the background task to complete.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// The expectation on the Mock will verify that the request was received.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_client_ttl_eviction() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/Sessions/Logout"))
|
||||
.respond_with(ResponseTemplate::new(204))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// Create storage with short TTL
|
||||
let storage = JellyfinClientStorage::new(10, Duration::from_millis(100));
|
||||
let client_info = ClientInfo::default();
|
||||
|
||||
let client = storage
|
||||
.get(&mock_server.uri(), client_info, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _ = client.with_token("test_token".to_string());
|
||||
|
||||
// Wait for TTL to expire + some buffer
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Trigger maintenance/eviction check
|
||||
storage.cache.run_pending_tasks().await;
|
||||
|
||||
// Wait for the eviction listener to run
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ sha2.workspace = true
|
||||
hex.workspace = true
|
||||
chrono.workspace = true
|
||||
rand.workspace = true
|
||||
moka.workspace = true
|
||||
dashmap = "6.1.0"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
anyhow = "1.0.98"
|
||||
@@ -58,7 +59,7 @@ base64 = "0.22.1"
|
||||
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" }
|
||||
|
||||
@@ -14,6 +14,8 @@ use once_cell::sync::Lazy;
|
||||
|
||||
use base64::prelude::*;
|
||||
|
||||
use crate::encryption::Password;
|
||||
|
||||
pub static MIGRATOR: Migrator = sqlx::migrate!();
|
||||
|
||||
pub static CLIENT_INFO: Lazy<ClientInfo> = Lazy::new(|| ClientInfo {
|
||||
@@ -23,6 +25,11 @@ pub static CLIENT_INFO: Lazy<ClientInfo> = Lazy::new(|| ClientInfo {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
});
|
||||
|
||||
pub static CLIENT_STORAGE: Lazy<jellyfin_api::storage::JellyfinClientStorage> = Lazy::new(|| {
|
||||
jellyfin_api::storage::JellyfinClientStorage::new(300, std::time::Duration::from_secs(60 * 15))
|
||||
// 15 minutes
|
||||
});
|
||||
|
||||
// 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.
|
||||
@@ -64,8 +71,8 @@ fn default_username() -> String {
|
||||
"admin".to_string()
|
||||
}
|
||||
|
||||
fn default_password() -> String {
|
||||
"jellyswarrm".to_string()
|
||||
fn default_password() -> Password {
|
||||
"jellyswarrm".to_string().into()
|
||||
}
|
||||
|
||||
fn default_session_key() -> Vec<u8> {
|
||||
@@ -186,7 +193,7 @@ pub struct AppConfig {
|
||||
#[serde(default = "default_username")]
|
||||
pub username: String,
|
||||
#[serde(default = "default_password")]
|
||||
pub password: String,
|
||||
pub password: Password,
|
||||
|
||||
#[serde(default)]
|
||||
pub preconfigured_servers: Vec<PreconfiguredServer>,
|
||||
|
||||
@@ -9,8 +9,115 @@ use aes_gcm::{
|
||||
};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::string::ToString;
|
||||
|
||||
/// A wrapper type for plaintext passwords.
|
||||
#[derive(Clone, PartialEq, Eq, Hash, sqlx::Type, Serialize, Deserialize)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct Password(String);
|
||||
|
||||
impl std::fmt::Debug for Password {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("Password").field(&"***").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Password {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "***")
|
||||
}
|
||||
}
|
||||
|
||||
impl Password {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Password {
|
||||
fn from(password: String) -> Self {
|
||||
Self(password)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Password {
|
||||
fn from(password: &str) -> Self {
|
||||
Self(password.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash a password using SHA-256
|
||||
pub fn hash_password(password: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(password.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// A wrapper type for hashed passwords.
|
||||
/// This does not contain the plaintext password, only the hashed version.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Hash, sqlx::Type, Serialize, Deserialize)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct HashedPassword(String);
|
||||
|
||||
impl HashedPassword {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn from_password(password: &str) -> Self {
|
||||
Self(hash_password(password))
|
||||
}
|
||||
|
||||
pub fn verify(&self, password: &str) -> bool {
|
||||
self.0 == hash_password(password)
|
||||
}
|
||||
|
||||
pub fn from_hashed(hashed: String) -> Self {
|
||||
Self(hashed)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Password> for HashedPassword {
|
||||
fn from(password: Password) -> Self {
|
||||
Self::from_password(password.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Password> for HashedPassword {
|
||||
fn from(password: &Password) -> Self {
|
||||
Self::from_password(password.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapper type for encrypted passwords.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Hash, sqlx::Type, Serialize, Deserialize)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct EncryptedPassword(String);
|
||||
|
||||
impl EncryptedPassword {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn from_raw(raw: String) -> Self {
|
||||
Self(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom error type for encryption/decryption operations
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EncryptionError {
|
||||
@@ -30,15 +137,18 @@ pub enum EncryptionError {
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `plaintext` - The password to encrypt
|
||||
/// * `master_password` - The master password used as encryption key
|
||||
/// * `master_password` - The hashed 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> {
|
||||
pub fn encrypt_password(
|
||||
plaintext: &Password,
|
||||
master_password: &HashedPassword,
|
||||
) -> Result<EncryptedPassword, 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 key = derive_key(master_password.as_str());
|
||||
let cipher = Aes256Gcm::new(&key.into());
|
||||
|
||||
// Generate a random 12-byte nonce
|
||||
@@ -47,7 +157,7 @@ pub fn encrypt_password(plaintext: &str, master_password: &str) -> Result<String
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
// Encrypt the plaintext
|
||||
let plaintext_bytes = plaintext.as_bytes();
|
||||
let plaintext_bytes = plaintext.as_str().as_bytes();
|
||||
let ciphertext = cipher.encrypt(nonce, plaintext_bytes).map_err(|e| {
|
||||
tracing::error!("Encryption failed: {}", e);
|
||||
EncryptionError::EncryptionFailed(e.to_string())
|
||||
@@ -59,7 +169,7 @@ pub fn encrypt_password(plaintext: &str, master_password: &str) -> Result<String
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
|
||||
// Encode as base64 for storage
|
||||
let encoded = general_purpose::STANDARD.encode(&combined);
|
||||
let encoded = EncryptedPassword(general_purpose::STANDARD.encode(&combined));
|
||||
tracing::debug!("Password encrypted successfully");
|
||||
Ok(encoded)
|
||||
}
|
||||
@@ -68,19 +178,19 @@ pub fn encrypt_password(plaintext: &str, master_password: &str) -> Result<String
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `encrypted_data` - Base64-encoded string containing nonce and encrypted data
|
||||
/// * `master_password` - The master password used as decryption key
|
||||
/// * `master_password` - The hashed 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> {
|
||||
encrypted_data: &EncryptedPassword,
|
||||
master_password: &HashedPassword,
|
||||
) -> Result<Password, EncryptionError> {
|
||||
tracing::debug!("Decrypting password with master password");
|
||||
|
||||
// Decode the base64 data
|
||||
let combined = general_purpose::STANDARD
|
||||
.decode(encrypted_data)
|
||||
.decode(encrypted_data.as_str())
|
||||
.map_err(|e| {
|
||||
tracing::error!("Base64 decoding failed: {}", e);
|
||||
EncryptionError::Base64DecodeFailed(e)
|
||||
@@ -99,7 +209,7 @@ pub fn decrypt_password(
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
|
||||
// Derive the same key from the master password
|
||||
let key = derive_key(master_password);
|
||||
let key = derive_key(master_password.as_str());
|
||||
let cipher = Aes256Gcm::new(&key.into());
|
||||
|
||||
// Decrypt the ciphertext
|
||||
@@ -109,10 +219,10 @@ pub fn decrypt_password(
|
||||
})?;
|
||||
|
||||
// Convert to string
|
||||
let result = String::from_utf8(plaintext_bytes).map_err(|e| {
|
||||
let result = Password(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)
|
||||
@@ -120,7 +230,6 @@ pub fn decrypt_password(
|
||||
|
||||
/// 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();
|
||||
@@ -135,23 +244,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt() {
|
||||
let password = "my_secret_password";
|
||||
let master_password = "master_key_123";
|
||||
let password = Password("my_secret_password".into());
|
||||
let master_password = HashedPassword::from_password("master_key_123");
|
||||
|
||||
let encrypted = encrypt_password(password, master_password).unwrap();
|
||||
let decrypted = decrypt_password(&encrypted, master_password).unwrap();
|
||||
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 password = Password("my_secret_password".into());
|
||||
let master_password = HashedPassword::from_password("master_key_123");
|
||||
let wrong_password = HashedPassword::from_password("wrong_key_456");
|
||||
|
||||
let encrypted = encrypt_password(password, master_password).unwrap();
|
||||
let result = decrypt_password(&encrypted, wrong_password);
|
||||
let encrypted = encrypt_password(&password, &master_password).unwrap();
|
||||
let result = decrypt_password(&encrypted, &wrong_password);
|
||||
|
||||
assert!(result.is_err());
|
||||
matches!(
|
||||
@@ -162,22 +271,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_empty_password() {
|
||||
let password = "";
|
||||
let master_password = "master_key_123";
|
||||
let password = Password("".into());
|
||||
let master_password = HashedPassword::from_password("master_key_123");
|
||||
|
||||
let encrypted = encrypt_password(password, master_password).unwrap();
|
||||
let decrypted = decrypt_password(&encrypted, master_password).unwrap();
|
||||
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 password = Password("p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?".into());
|
||||
let master_password = HashedPassword::from_password("m@st3r_k3y!@#$%^&*()");
|
||||
|
||||
let encrypted = encrypt_password(password, master_password).unwrap();
|
||||
let decrypted = decrypt_password(&encrypted, master_password).unwrap();
|
||||
let encrypted = encrypt_password(&password, &master_password).unwrap();
|
||||
let decrypted = decrypt_password(&encrypted, &master_password).unwrap();
|
||||
|
||||
assert_eq!(password, decrypted);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
encryption::decrypt_password, server_storage::ServerStorageService,
|
||||
user_authorization_service::UserAuthorizationService, AppState,
|
||||
encryption::{decrypt_password, HashedPassword, Password},
|
||||
server_storage::ServerStorageService,
|
||||
user_authorization_service::UserAuthorizationService,
|
||||
AppState,
|
||||
};
|
||||
use jellyfin_api::JellyfinClient;
|
||||
|
||||
@@ -60,7 +62,7 @@ impl FederatedUserService {
|
||||
pub async fn sync_user_to_all_servers(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
password: &Password,
|
||||
user_id: &str,
|
||||
) -> Vec<ServerSyncResult> {
|
||||
let mut results = Vec::new();
|
||||
@@ -73,7 +75,8 @@ impl FederatedUserService {
|
||||
};
|
||||
|
||||
let config = self.config.read().await;
|
||||
let admin_password = config.password.clone();
|
||||
let admin_password: HashedPassword = config.password.clone().into();
|
||||
|
||||
drop(config);
|
||||
|
||||
for server in servers {
|
||||
@@ -124,7 +127,7 @@ impl FederatedUserService {
|
||||
|
||||
// Authenticate as admin to get token
|
||||
match client
|
||||
.authenticate_by_name(&admin.username, &decrypted_admin_password)
|
||||
.authenticate_by_name(&admin.username, decrypted_admin_password.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
@@ -167,11 +170,13 @@ impl FederatedUserService {
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let (status, should_map) =
|
||||
match user_client.authenticate_by_name(username, password).await {
|
||||
Ok(_) => (SyncStatus::AlreadyExists, true),
|
||||
Err(_) => (SyncStatus::ExistsWithDifferentPassword, false),
|
||||
};
|
||||
let (status, should_map) = match user_client
|
||||
.authenticate_by_name(username, password.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(_) => (SyncStatus::AlreadyExists, true),
|
||||
Err(_) => (SyncStatus::ExistsWithDifferentPassword, false),
|
||||
};
|
||||
|
||||
info!(
|
||||
"Synced user {} to server {} (Remote ID: {}, Status: {:?})",
|
||||
@@ -186,7 +191,7 @@ impl FederatedUserService {
|
||||
server.url.as_str(),
|
||||
username,
|
||||
password,
|
||||
Some(password), // Encrypt with their own password so they can use it
|
||||
Some(&password.into()), // Encrypt with their own password so they can use it
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -215,7 +220,7 @@ impl FederatedUserService {
|
||||
}
|
||||
} else {
|
||||
// Create user
|
||||
match client.create_user(username, Some(password)).await {
|
||||
match client.create_user(username, Some(password.as_str())).await {
|
||||
Ok(new_user) => {
|
||||
info!(
|
||||
"Synced user {} to server {} (Remote ID: {}, Status: Created)",
|
||||
@@ -229,7 +234,7 @@ impl FederatedUserService {
|
||||
server.url.as_str(),
|
||||
username,
|
||||
password,
|
||||
Some(password), // Encrypt with their own password so they can use it
|
||||
Some(&password.into()), // Encrypt with their own password so they can use it
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -305,7 +310,7 @@ impl FederatedUserService {
|
||||
}
|
||||
} {
|
||||
let decrypted_admin_password =
|
||||
match decrypt_password(&admin.password, admin_password) {
|
||||
match decrypt_password(&admin.password, &admin_password.into()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
error!(
|
||||
@@ -337,7 +342,7 @@ impl FederatedUserService {
|
||||
};
|
||||
|
||||
match client
|
||||
.authenticate_by_name(&admin.username, &decrypted_admin_password)
|
||||
.authenticate_by_name(&admin.username, decrypted_admin_password.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
|
||||
@@ -249,13 +249,15 @@ async fn authenticate_on_server(
|
||||
let config = state.config.read().await;
|
||||
let admin_password = &config.password;
|
||||
|
||||
let given_password = payload.password.clone();
|
||||
|
||||
let (final_username, final_password) = if let Some(mapping) = &server_mapping {
|
||||
(
|
||||
mapping.mapped_username.clone(),
|
||||
state.user_authorization.decrypt_server_mapping_password(
|
||||
mapping,
|
||||
&payload.password,
|
||||
admin_password,
|
||||
&given_password.clone().into(),
|
||||
&admin_password.into(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
@@ -325,7 +327,7 @@ async fn authenticate_on_server(
|
||||
// We authenticated sucessfully, now we need to get the user or create it
|
||||
let user = state
|
||||
.user_authorization
|
||||
.get_or_create_user(&payload.username, &payload.password)
|
||||
.get_or_create_user(&payload.username, &given_password)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error getting user: {}", e);
|
||||
@@ -345,7 +347,7 @@ async fn authenticate_on_server(
|
||||
server.url.as_str(),
|
||||
&final_username,
|
||||
&final_password,
|
||||
Some(&payload.password),
|
||||
Some(&given_password.into()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
|
||||
@@ -46,8 +46,8 @@ use server_storage::ServerStorageService;
|
||||
use user_authorization_service::UserAuthorizationService;
|
||||
|
||||
use crate::{
|
||||
config::DATA_DIR, request_preprocessing::preprocess_request, session_storage::SessionStorage,
|
||||
ui::ui_routes,
|
||||
config::DATA_DIR, encryption::Password, request_preprocessing::preprocess_request,
|
||||
session_storage::SessionStorage, ui::ui_routes,
|
||||
};
|
||||
use crate::{
|
||||
config::{AppConfig, MIGRATOR},
|
||||
@@ -112,7 +112,7 @@ impl AppState {
|
||||
config.url_prefix.as_ref().map(|prefix| prefix.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_admin_password(&self) -> String {
|
||||
pub async fn get_admin_password(&self) -> Password {
|
||||
let config = self.config.read().await;
|
||||
config.password.clone()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ use jellyswarrm_macros::multi_case_struct;
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
use serde_with::skip_serializing_none;
|
||||
|
||||
use crate::models::{enums::CollectionType, jellyfin::enums::BaseItemKind};
|
||||
use crate::{
|
||||
encryption::Password,
|
||||
models::{enums::CollectionType, jellyfin::enums::BaseItemKind},
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
@@ -110,7 +113,7 @@ pub struct NowPlayingQueueItem {
|
||||
pub struct AuthenticateRequest {
|
||||
pub username: String,
|
||||
#[serde(rename = "Pw")]
|
||||
pub password: String,
|
||||
pub password: Password,
|
||||
}
|
||||
|
||||
#[multi_case_struct(pascal, camel)]
|
||||
|
||||
@@ -3,6 +3,8 @@ use sqlx::{FromRow, Row, SqlitePool};
|
||||
use tracing::info;
|
||||
use url::Url;
|
||||
|
||||
use crate::encryption::EncryptedPassword;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
||||
pub struct Server {
|
||||
pub id: i64,
|
||||
@@ -18,7 +20,7 @@ pub struct ServerAdmin {
|
||||
pub id: i64,
|
||||
pub server_id: i64,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub password: EncryptedPassword,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
@@ -200,7 +202,7 @@ impl ServerStorageService {
|
||||
&self,
|
||||
server_id: i64,
|
||||
username: &str,
|
||||
password: &str,
|
||||
password: &EncryptedPassword,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
|
||||
@@ -8,7 +8,11 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{encryption::encrypt_password, server_storage::Server, AppState};
|
||||
use crate::{
|
||||
encryption::{encrypt_password, Password},
|
||||
server_storage::Server,
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "admin/servers.html")]
|
||||
@@ -43,7 +47,7 @@ pub struct UpdatePriorityForm {
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddServerAdminForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub password: Password,
|
||||
}
|
||||
|
||||
async fn render_server_list(state: &AppState) -> Result<String, String> {
|
||||
@@ -270,7 +274,7 @@ pub async fn add_server_admin(
|
||||
};
|
||||
|
||||
match client
|
||||
.authenticate_by_name(&form.username, &form.password)
|
||||
.authenticate_by_name(&form.username, form.password.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(user) => {
|
||||
@@ -287,7 +291,7 @@ pub async fn add_server_admin(
|
||||
|
||||
// 3. Encrypt password with admin master password
|
||||
let config = state.config.read().await;
|
||||
let encrypted_password = match encrypt_password(&form.password, &config.password) {
|
||||
let encrypted_password = match encrypt_password(&form.password, &config.password.clone().into()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
error!("Encryption failed: {}", e);
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::collections::HashMap;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{
|
||||
encryption::Password,
|
||||
federated_users::ServerSyncResult,
|
||||
server_storage::Server,
|
||||
user_authorization_service::{ServerMapping, User},
|
||||
@@ -48,7 +49,7 @@ pub struct UserItememplate {
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddUserForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub password: Password,
|
||||
#[serde(default)]
|
||||
pub enable_federation: bool,
|
||||
}
|
||||
@@ -58,7 +59,7 @@ pub struct AddMappingForm {
|
||||
pub user_id: String,
|
||||
pub server_url: String,
|
||||
pub mapped_username: String,
|
||||
pub mapped_password: String,
|
||||
pub mapped_password: Password,
|
||||
}
|
||||
|
||||
pub async fn create_user_with_mappings(
|
||||
@@ -228,7 +229,7 @@ pub async fn get_user_list(State(state): State<AppState>) -> impl IntoResponse {
|
||||
|
||||
/// 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() {
|
||||
if form.username.trim().is_empty() || form.password.as_str().is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html("<div class=\"alert alert-error\">Username and password required</div>"),
|
||||
@@ -336,7 +337,7 @@ pub async fn add_mapping(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<AddMappingForm>,
|
||||
) -> Response {
|
||||
if form.mapped_username.trim().is_empty() || form.mapped_password.is_empty() {
|
||||
if form.mapped_username.trim().is_empty() || form.mapped_password.as_str().is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html("<div class=\"alert alert-error\">Mapping credentials required</div>"),
|
||||
@@ -354,7 +355,7 @@ pub async fn add_mapping(
|
||||
&form.server_url,
|
||||
&form.mapped_username,
|
||||
&form.mapped_password,
|
||||
Some(admin_password),
|
||||
Some(&admin_password.into()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -9,7 +9,10 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::{sync::RwLock, task};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{config::AppConfig, user_authorization_service::UserAuthorizationService};
|
||||
use crate::{
|
||||
config::AppConfig, encryption::HashedPassword,
|
||||
user_authorization_service::UserAuthorizationService,
|
||||
};
|
||||
|
||||
mod routes;
|
||||
|
||||
@@ -25,7 +28,7 @@ pub enum UserRole {
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub password_hash: HashedPassword,
|
||||
pub role: UserRole,
|
||||
}
|
||||
|
||||
@@ -70,10 +73,10 @@ impl AuthUser for User {
|
||||
}
|
||||
|
||||
fn session_auth_hash(&self) -> &[u8] {
|
||||
self.password.as_bytes() // We use the password hash as the auth
|
||||
// hash--what this means
|
||||
// is when the user changes their password the
|
||||
// auth session becomes invalid.
|
||||
self.password_hash.as_str().as_bytes() // We use the password hash as the auth
|
||||
// hash--what this means
|
||||
// is when the user changes their password the
|
||||
// auth session becomes invalid.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,15 +118,16 @@ impl AuthnBackend for Backend {
|
||||
&self,
|
||||
creds: Self::Credentials,
|
||||
) -> Result<Option<Self::User>, Self::Error> {
|
||||
let password = creds.password.into();
|
||||
let config = self.config.read().await;
|
||||
info!("Authenticating user: {}", creds.username);
|
||||
if creds.username == config.username && creds.password == config.password {
|
||||
if creds.username == config.username && password == config.password {
|
||||
info!("Admin authentication successful");
|
||||
// If the password is correct, we return the default user.
|
||||
let user = User {
|
||||
id: "admin".to_string(),
|
||||
username: creds.username,
|
||||
password: config.password.clone(),
|
||||
password_hash: config.password.clone().into(),
|
||||
role: UserRole::Admin,
|
||||
};
|
||||
return Ok(Some(user));
|
||||
@@ -131,14 +135,14 @@ impl AuthnBackend for Backend {
|
||||
|
||||
if let Some(user) = self
|
||||
.user_auth
|
||||
.get_user_by_credentials(&creds.username, &creds.password)
|
||||
.get_user_by_credentials(&creds.username, &password)
|
||||
.await?
|
||||
{
|
||||
info!("User authentication successful: {}", user.original_username);
|
||||
let user = User {
|
||||
id: user.id,
|
||||
username: user.original_username,
|
||||
password: user.original_password_hash,
|
||||
password_hash: user.original_password_hash,
|
||||
role: UserRole::User,
|
||||
};
|
||||
return Ok(Some(user));
|
||||
@@ -154,7 +158,7 @@ impl AuthnBackend for Backend {
|
||||
return Ok(Some(User {
|
||||
id: "admin".to_string(),
|
||||
username: config.username.clone(),
|
||||
password: config.password.clone(),
|
||||
password_hash: config.password.clone().into(),
|
||||
role: UserRole::Admin,
|
||||
}));
|
||||
}
|
||||
@@ -163,7 +167,7 @@ impl AuthnBackend for Backend {
|
||||
let user = User {
|
||||
id: user.id,
|
||||
username: user.original_username,
|
||||
password: user.original_password_hash,
|
||||
password_hash: user.original_password_hash,
|
||||
role: UserRole::User,
|
||||
};
|
||||
return Ok(Some(user));
|
||||
|
||||
@@ -137,6 +137,18 @@ pub fn ui_routes() -> axum::Router<AppState> {
|
||||
post(user::servers::connect_server),
|
||||
)
|
||||
.route("/user/media", get(user::media::get_user_media))
|
||||
.route(
|
||||
"/user/media/server/{server_id}/libraries",
|
||||
get(user::media::get_server_libraries),
|
||||
)
|
||||
.route(
|
||||
"/user/media/server/{server_id}/library/{library_id}/items",
|
||||
get(user::media::get_library_items),
|
||||
)
|
||||
.route(
|
||||
"/user/media/image/{server_id}/{item_id}",
|
||||
get(user::media::proxy_media_image),
|
||||
)
|
||||
.route("/user/profile", get(user::profile::get_user_profile))
|
||||
.route(
|
||||
"/user/profile/password",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{% for item in items %}
|
||||
<article class="media-card">
|
||||
{% if let Some(tags) = item.image_tags %}
|
||||
{% if tags.contains_key("Primary") %}
|
||||
<img src="/{{ ui_route }}/user/media/image/{{ server_id }}/{{ item.id }}" loading="lazy" alt="{{ item.name }}">
|
||||
{% else %}
|
||||
<div class="placeholder-image"><i class="fas fa-film"></i></div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="placeholder-image"><i class="fas fa-film"></i></div>
|
||||
{% endif %}
|
||||
<footer title="{{ item.name }}">
|
||||
{{ item.name }}
|
||||
</footer>
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{% if let Some(page) = next_page %}
|
||||
<div class="load-more-sentinel"
|
||||
hx-get="/{{ ui_route }}/user/media/server/{{ server_id }}/library/{{ library_id }}/items?page={{ page }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML">
|
||||
<div aria-busy="true" style="min-width: 100px; height: 100%; display: flex; align-items: center; justify-content: center;">Loading...</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,14 @@
|
||||
<div class="libraries-grid">
|
||||
{% for library in libraries %}
|
||||
<details>
|
||||
<summary><strong>{{ library.name }} ({{ library.count }})</strong></summary>
|
||||
<div class="library-items-container horizontal-scroll-container"
|
||||
id="library-{{ server_id }}-{{ library.id }}"
|
||||
hx-get="/{{ ui_route }}/user/media/server/{{ server_id }}/library/{{ library.id }}/items"
|
||||
hx-trigger="intersect once"
|
||||
hx-swap="innerHTML">
|
||||
<div aria-busy="true">Loading items...</div>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -1,38 +1,104 @@
|
||||
{% 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 class="container">
|
||||
<h1>My Media</h1>
|
||||
|
||||
<div id="servers-container">
|
||||
{% for server in servers %}
|
||||
<article class="server-section" id="server-{{ server.id }}">
|
||||
<header>
|
||||
<h2><i class="fas fa-server"></i> {{ server.name }}</h2>
|
||||
</header>
|
||||
|
||||
<div class="libraries-container"
|
||||
hx-get="/{{ ui_route }}/user/media/server/{{ server.id }}/libraries"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div aria-busy="true">Loading libraries...</div>
|
||||
</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>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.server-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.libraries-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.horizontal-scroll-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0.5rem 1rem 0.5rem;
|
||||
align-items: start;
|
||||
scrollbar-width: thin; /* Firefox */
|
||||
}
|
||||
|
||||
/* Webkit scrollbar styling */
|
||||
.horizontal-scroll-container::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.horizontal-scroll-container::-webkit-scrollbar-track {
|
||||
background: var(--card-background-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.horizontal-scroll-container::-webkit-scrollbar-thumb {
|
||||
background-color: var(--secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.media-card {
|
||||
flex: 0 0 140px;
|
||||
width: 140px;
|
||||
background: var(--card-background-color);
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.media-card:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.media-card img {
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.media-card footer {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.placeholder-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
background-color: var(--secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
color: var(--contrast);
|
||||
}
|
||||
|
||||
.load-more-sentinel {
|
||||
flex: 0 0 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use jellyfin_api::JellyfinClient;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{server_storage::Server, AppState};
|
||||
use jellyfin_api::JellyfinClient;
|
||||
use tracing::info;
|
||||
|
||||
use crate::{config::CLIENT_STORAGE, server_storage::Server, AppState};
|
||||
|
||||
pub async fn authenticate_user_on_server(
|
||||
state: &AppState,
|
||||
@@ -8,22 +11,29 @@ pub async fn authenticate_user_on_server(
|
||||
server: &Server,
|
||||
) -> Result<
|
||||
(
|
||||
JellyfinClient,
|
||||
Arc<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 server_url = server.url.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 cache first
|
||||
let client = CLIENT_STORAGE
|
||||
.get(
|
||||
server_url.as_ref(),
|
||||
client_info,
|
||||
Some(user.username.as_str()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get client from storage: {}", e))?;
|
||||
|
||||
// Always check public system info first to get version and name
|
||||
let public_info = match client.get_public_system_info().await {
|
||||
Ok(info) => info,
|
||||
Err(_) => return Err("Server offline or unreachable".to_string()),
|
||||
};
|
||||
|
||||
// Check for mapping and try to authenticate
|
||||
@@ -39,14 +49,32 @@ pub async fn authenticate_user_on_server(
|
||||
|
||||
let admin_password = state.get_admin_password().await;
|
||||
|
||||
let password = state.user_authorization.decrypt_server_mapping_password(
|
||||
&mapping,
|
||||
&user.password,
|
||||
&admin_password,
|
||||
info!(
|
||||
"Authenticating user '{}' on server '{}'.",
|
||||
user.username, server.id
|
||||
);
|
||||
|
||||
let password = state.user_authorization.decrypt_server_mapping_password(
|
||||
&mapping,
|
||||
&user.password_hash,
|
||||
&admin_password.into(),
|
||||
);
|
||||
|
||||
if client.get_token().is_some() {
|
||||
// Try to validate existing session
|
||||
match client.get_me().await {
|
||||
Ok(jellyfin_user) => {
|
||||
return Ok((client, jellyfin_user, public_info));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Existing session invalid for server {}: {}", server.id, e);
|
||||
// Fall through to re-authenticate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match client
|
||||
.authenticate_by_name(&mapping.mapped_username, &password)
|
||||
.authenticate_by_name(&mapping.mapped_username, password.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(jellyfin_user) => Ok((client, jellyfin_user, public_info)),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse},
|
||||
body::Body,
|
||||
extract::{Path, Query, State},
|
||||
http::{header, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use jellyfin_api::models::MediaFolder;
|
||||
use jellyfin_api::models::BaseItem;
|
||||
use tracing::error;
|
||||
|
||||
use crate::{
|
||||
@@ -12,16 +13,45 @@ use crate::{
|
||||
AppState,
|
||||
};
|
||||
|
||||
pub struct ServerLibraries {
|
||||
pub server_name: String,
|
||||
pub libraries: Vec<MediaFolder>,
|
||||
pub error: Option<String>,
|
||||
pub struct ServerInfo {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub struct LibraryWithCount {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub count: i32,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user/user_media.html")]
|
||||
pub struct UserMediaTemplate {
|
||||
pub servers: Vec<ServerLibraries>,
|
||||
pub servers: Vec<ServerInfo>,
|
||||
pub ui_route: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user/server_libraries.html")]
|
||||
pub struct ServerLibrariesTemplate {
|
||||
pub server_id: i64,
|
||||
pub libraries: Vec<LibraryWithCount>,
|
||||
pub ui_route: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user/library_items.html")]
|
||||
pub struct LibraryItemsTemplate {
|
||||
pub server_id: i64,
|
||||
pub library_id: String,
|
||||
pub items: Vec<BaseItem>,
|
||||
pub next_page: Option<i32>,
|
||||
pub ui_route: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct Pagination {
|
||||
pub page: Option<i32>,
|
||||
}
|
||||
|
||||
pub async fn get_user_media(
|
||||
@@ -36,42 +66,17 @@ pub async fn get_user_media(
|
||||
}
|
||||
};
|
||||
|
||||
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 server_infos = servers
|
||||
.into_iter()
|
||||
.map(|s| ServerInfo {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let template = UserMediaTemplate {
|
||||
servers: server_libraries,
|
||||
servers: server_infos,
|
||||
ui_route: state.get_ui_route().await,
|
||||
};
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
@@ -81,3 +86,206 @@ pub async fn get_user_media(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_server_libraries(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(server_id): Path<i64>,
|
||||
) -> impl IntoResponse {
|
||||
let server = match state.server_storage.get_server_by_id(server_id).await {
|
||||
Ok(Some(s)) => s,
|
||||
_ => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
if let Ok((client, jellyfin_user, _)) =
|
||||
authenticate_user_on_server(&state, &user, &server).await
|
||||
{
|
||||
match client.get_media_folders(Some(&jellyfin_user.id)).await {
|
||||
Ok(folders) => {
|
||||
let mut libraries = Vec::new();
|
||||
for folder in folders {
|
||||
if folder.collection_type.as_deref() == Some("playlists")
|
||||
|| folder.name.to_lowercase() == "playlists"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let count = match client
|
||||
.get_items(
|
||||
&jellyfin_user.id,
|
||||
Some(&folder.id),
|
||||
true,
|
||||
Some(vec!["Movie".to_string(), "Series".to_string()]),
|
||||
Some(0),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.total_record_count,
|
||||
Err(_) => 0,
|
||||
};
|
||||
|
||||
libraries.push(LibraryWithCount {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
let template = ServerLibrariesTemplate {
|
||||
server_id,
|
||||
libraries,
|
||||
ui_route: state.get_ui_route().await,
|
||||
};
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render server libraries template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get media folders: {}", e);
|
||||
(StatusCode::BAD_GATEWAY, "Failed to fetch libraries").into_response()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(StatusCode::UNAUTHORIZED, "Failed to authenticate").into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_library_items(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path((server_id, library_id)): Path<(i64, String)>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> impl IntoResponse {
|
||||
let server = match state.server_storage.get_server_by_id(server_id).await {
|
||||
Ok(Some(s)) => s,
|
||||
_ => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let page = pagination.page.unwrap_or(0);
|
||||
let limit = 20;
|
||||
let start_index = page * limit;
|
||||
|
||||
if let Ok((client, jellyfin_user, _)) =
|
||||
authenticate_user_on_server(&state, &user, &server).await
|
||||
{
|
||||
match client
|
||||
.get_items(
|
||||
&jellyfin_user.id,
|
||||
Some(&library_id),
|
||||
true,
|
||||
Some(vec!["Movie".to_string(), "Series".to_string()]),
|
||||
Some(limit),
|
||||
Some(start_index),
|
||||
Some("DateCreated".to_string()),
|
||||
Some("Descending".to_string()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let total_items = response.total_record_count;
|
||||
let total_pages = (total_items as f64 / limit as f64).ceil() as i32;
|
||||
|
||||
let next_page = if (page + 1) < total_pages {
|
||||
Some(page + 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let template = LibraryItemsTemplate {
|
||||
server_id,
|
||||
library_id,
|
||||
items: response.items,
|
||||
next_page,
|
||||
ui_route: state.get_ui_route().await,
|
||||
};
|
||||
match template.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
error!("Failed to render library items template: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Template error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get items: {}", e);
|
||||
(StatusCode::BAD_GATEWAY, "Failed to fetch items").into_response()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(StatusCode::UNAUTHORIZED, "Failed to authenticate").into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn proxy_media_image(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path((server_id, item_id)): Path<(i64, String)>,
|
||||
) -> impl IntoResponse {
|
||||
// Get server
|
||||
let server = match state.server_storage.get_server_by_id(server_id).await {
|
||||
Ok(Some(s)) => s,
|
||||
_ => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
// Authenticate
|
||||
let (client, _, _) = match authenticate_user_on_server(&state, &user, &server).await {
|
||||
Ok(res) => res,
|
||||
Err(_) => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
|
||||
// Construct image URL
|
||||
// We need to access the base_url from client, but it's private.
|
||||
// However, we have server.url.
|
||||
let image_url = format!(
|
||||
"{}/Items/{}/Images/Primary",
|
||||
server.url.as_str().trim_end_matches('/'),
|
||||
item_id
|
||||
);
|
||||
|
||||
// Fetch image using the client's internal http client would be best, but we can't access it.
|
||||
// We can use state.reqwest_client but we need the token.
|
||||
let token = client.get_token().unwrap_or_default();
|
||||
|
||||
// Build auth header manually since we are using a raw request
|
||||
// Or we can add a method to JellyfinClient to fetch raw resource.
|
||||
// For now, let's use state.reqwest_client
|
||||
|
||||
let auth_header = format!(
|
||||
"MediaBrowser Client=\"Jellyswarrm Proxy\", Device=\"Server\", DeviceId=\"jellyswarrm-proxy\", Version=\"{}\", Token=\"{}\"",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
token
|
||||
);
|
||||
|
||||
match state
|
||||
.reqwest_client
|
||||
.get(&image_url)
|
||||
.header(header::AUTHORIZATION, auth_header)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let headers = resp.headers().clone();
|
||||
let body = resp.bytes().await.unwrap_or_default();
|
||||
|
||||
let mut response = Response::builder().status(status);
|
||||
if let Some(ct) = headers.get(header::CONTENT_TYPE) {
|
||||
response = response.header(header::CONTENT_TYPE, ct);
|
||||
}
|
||||
// Cache control
|
||||
response = response.header(header::CACHE_CONTROL, "public, max-age=3600");
|
||||
|
||||
response
|
||||
.body(Body::from(body))
|
||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||
}
|
||||
Err(_) => StatusCode::BAD_GATEWAY.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
mod common;
|
||||
pub mod common;
|
||||
pub mod media;
|
||||
pub mod profile;
|
||||
pub mod servers;
|
||||
|
||||
@@ -8,7 +8,7 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use tracing::error;
|
||||
|
||||
use crate::{ui::auth::AuthenticatedUser, AppState};
|
||||
use crate::{encryption::Password, ui::auth::AuthenticatedUser, AppState};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user/user_profile.html")]
|
||||
@@ -19,9 +19,9 @@ pub struct UserProfileTemplate {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ChangePasswordForm {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
pub confirm_password: String,
|
||||
pub current_password: Password,
|
||||
pub new_password: Password,
|
||||
pub confirm_password: Password,
|
||||
}
|
||||
|
||||
pub async fn get_user_profile(
|
||||
|
||||
@@ -10,6 +10,7 @@ use serde::Deserialize;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
encryption::Password,
|
||||
server_storage::Server,
|
||||
ui::{auth::AuthenticatedUser, user::common::authenticate_user_on_server},
|
||||
AppState,
|
||||
@@ -27,7 +28,7 @@ pub struct UserServerListTemplate {
|
||||
#[derive(Deserialize)]
|
||||
pub struct ConnectServerForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub password: Password,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
@@ -122,7 +123,7 @@ pub async fn connect_server(
|
||||
}
|
||||
};
|
||||
|
||||
match client.authenticate_by_name(&form.username, &form.password).await {
|
||||
match client.authenticate_by_name(&form.username, form.password.as_str()).await {
|
||||
Ok(_) => {
|
||||
// Credentials valid, create mapping
|
||||
match state
|
||||
@@ -132,7 +133,7 @@ pub async fn connect_server(
|
||||
server.url.as_str(),
|
||||
&form.username,
|
||||
&form.password,
|
||||
Some(&user.password),
|
||||
Some(&user.password_hash),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{FromRow, Row, SqlitePool};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::encryption::{decrypt_password, encrypt_password};
|
||||
use crate::encryption::{
|
||||
decrypt_password, encrypt_password, EncryptedPassword, HashedPassword, Password,
|
||||
};
|
||||
use crate::models::{generate_token, Authorization};
|
||||
use crate::server_storage::Server;
|
||||
|
||||
@@ -11,7 +12,7 @@ pub struct User {
|
||||
pub id: String,
|
||||
pub virtual_key: String,
|
||||
pub original_username: String,
|
||||
pub original_password_hash: String,
|
||||
pub original_password_hash: HashedPassword,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
@@ -22,7 +23,7 @@ pub struct ServerMapping {
|
||||
pub user_id: String,
|
||||
pub server_url: String,
|
||||
pub mapped_username: String,
|
||||
pub mapped_password: String,
|
||||
pub mapped_password: EncryptedPassword,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
@@ -184,9 +185,9 @@ impl UserAuthorizationService {
|
||||
pub async fn get_or_create_user(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
password: &Password,
|
||||
) -> Result<User, sqlx::Error> {
|
||||
let password_hash = Self::hash_password(password);
|
||||
let password_hash: HashedPassword = password.into();
|
||||
|
||||
// Try to find existing user
|
||||
if let Some(user) = self.get_user_by_credentials(username, password).await? {
|
||||
@@ -264,9 +265,9 @@ impl UserAuthorizationService {
|
||||
pub async fn get_user_by_credentials(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
password: &Password,
|
||||
) -> Result<Option<User>, sqlx::Error> {
|
||||
let password_hash = Self::hash_password(password);
|
||||
let password_hash: HashedPassword = password.into();
|
||||
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
@@ -289,8 +290,8 @@ impl UserAuthorizationService {
|
||||
user_id: &str,
|
||||
server_url: &str,
|
||||
mapped_username: &str,
|
||||
mapped_password: &str,
|
||||
master_password: Option<&str>,
|
||||
mapped_password: &Password,
|
||||
master_password: Option<&HashedPassword>,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
@@ -299,11 +300,12 @@ impl UserAuthorizationService {
|
||||
Ok(encrypted) => encrypted,
|
||||
Err(e) => {
|
||||
warn!("Failed to encrypt password: {}. Storing as plaintext.", e);
|
||||
mapped_password.to_string()
|
||||
EncryptedPassword::from_raw(mapped_password.as_str().into())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mapped_password.to_string()
|
||||
warn!("No encryption password provided. Storing as plaintext!");
|
||||
EncryptedPassword::from_raw(mapped_password.as_str().into())
|
||||
};
|
||||
|
||||
let result = sqlx::query(
|
||||
@@ -334,9 +336,9 @@ impl UserAuthorizationService {
|
||||
pub fn decrypt_server_mapping_password(
|
||||
&self,
|
||||
mapping: &ServerMapping,
|
||||
user_password: &str,
|
||||
admin_password: &str,
|
||||
) -> String {
|
||||
user_password: &HashedPassword,
|
||||
admin_password: &HashedPassword,
|
||||
) -> Password {
|
||||
// Try user password first
|
||||
if let Ok(decrypted) = decrypt_password(&mapping.mapped_password, user_password) {
|
||||
return decrypted;
|
||||
@@ -352,7 +354,7 @@ impl UserAuthorizationService {
|
||||
"Failed to decrypt password for mapping {}. Assuming plaintext.",
|
||||
mapping.id
|
||||
);
|
||||
mapping.mapped_password.clone()
|
||||
mapping.mapped_password.clone().into_inner().into()
|
||||
}
|
||||
|
||||
/// Get server mapping
|
||||
@@ -613,13 +615,6 @@ impl UserAuthorizationService {
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
/// Hash a password using SHA-256
|
||||
fn hash_password(password: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(password.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// List all users
|
||||
pub async fn list_users(&self) -> Result<Vec<User>, sqlx::Error> {
|
||||
let users = sqlx::query_as::<_, User>(
|
||||
@@ -656,14 +651,14 @@ impl UserAuthorizationService {
|
||||
pub async fn update_user_password(
|
||||
&self,
|
||||
user_id: &str,
|
||||
old_password: &str,
|
||||
new_password: &str,
|
||||
admin_password: &str,
|
||||
old_password: &Password,
|
||||
new_password: &Password,
|
||||
admin_password: &Password,
|
||||
) -> 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 password_hash: HashedPassword = new_password.into();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let res = sqlx::query(
|
||||
@@ -695,19 +690,23 @@ impl UserAuthorizationService {
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let old_password = old_password.into();
|
||||
let admin_password = admin_password.into();
|
||||
|
||||
for mapping in mappings {
|
||||
// Decrypt with old credentials
|
||||
let decrypted_password =
|
||||
self.decrypt_server_mapping_password(&mapping, old_password, admin_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)));
|
||||
}
|
||||
};
|
||||
let new_encrypted_password =
|
||||
match encrypt_password(&decrypted_password, &new_password.into()) {
|
||||
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(
|
||||
@@ -733,13 +732,12 @@ impl UserAuthorizationService {
|
||||
pub async fn verify_user_password(
|
||||
&self,
|
||||
user_id: &str,
|
||||
password: &str,
|
||||
password: &Password,
|
||||
) -> 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)
|
||||
Ok(user.original_password_hash.verify(password.as_str()))
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
@@ -917,7 +915,7 @@ mod tests {
|
||||
|
||||
// Create user
|
||||
let user = service
|
||||
.get_or_create_user("testuser", "testpass")
|
||||
.get_or_create_user("testuser", &"testpass".into())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -927,7 +925,7 @@ mod tests {
|
||||
&user.id,
|
||||
"http://localhost:8096",
|
||||
"mappeduser",
|
||||
"mappedpass",
|
||||
&"mappedpass".into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -1060,7 +1058,7 @@ mod tests {
|
||||
|
||||
// Create user
|
||||
let user = service
|
||||
.get_or_create_user("testuser", "testpass")
|
||||
.get_or_create_user("testuser", &"testpass".into())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1070,7 +1068,7 @@ mod tests {
|
||||
&user.id,
|
||||
"http://localhost:8096",
|
||||
"mappeduser",
|
||||
"mappedpass",
|
||||
&"mappedpass".into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -1154,7 +1152,7 @@ mod tests {
|
||||
|
||||
// Create user
|
||||
let user = service
|
||||
.get_or_create_user("testuser", "testpass")
|
||||
.get_or_create_user("testuser", &"testpass".into())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1164,7 +1162,7 @@ mod tests {
|
||||
&user.id,
|
||||
"http://localhost:8096",
|
||||
"mappeduser",
|
||||
"mappedpass",
|
||||
&"mappedpass".into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -1269,7 +1267,7 @@ mod tests {
|
||||
|
||||
// Create user
|
||||
let user = service
|
||||
.get_or_create_user("testuser", "testpass")
|
||||
.get_or_create_user("testuser", &"testpass".into())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1279,7 +1277,7 @@ mod tests {
|
||||
&user.id,
|
||||
"http://localhost:8096",
|
||||
"mappeduser1",
|
||||
"mappedpass1",
|
||||
&"mappedpass1".into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -1290,7 +1288,7 @@ mod tests {
|
||||
&user.id,
|
||||
"http://localhost:8097",
|
||||
"mappeduser2",
|
||||
"mappedpass2",
|
||||
&"mappedpass2".into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -1395,7 +1393,7 @@ mod tests {
|
||||
|
||||
// Create user
|
||||
let user = service
|
||||
.get_or_create_user("testuser", "testpass")
|
||||
.get_or_create_user("testuser", &"testpass".into())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1405,7 +1403,7 @@ mod tests {
|
||||
&user.id,
|
||||
"http://localhost:8096",
|
||||
"mappeduser",
|
||||
"mappedpass",
|
||||
&"mappedpass".into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -1502,14 +1500,14 @@ mod tests {
|
||||
|
||||
// Create user
|
||||
let user = service
|
||||
.get_or_create_user("testuser", "testpass")
|
||||
.get_or_create_user("testuser", &"testpass".into())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Add mappings for both servers
|
||||
for url in ["http://localhost:8096", "http://localhost:8097"] {
|
||||
service
|
||||
.add_server_mapping(&user.id, url, "mappeduser", "mappedpass", None)
|
||||
.add_server_mapping(&user.id, url, "mappeduser", &"mappedpass".into(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user