use actix_web::HttpRequest; use md5::{Digest, Md5}; use sea_orm::DatabaseConnection; use shanty_db::entities::user::Model as User; use shanty_db::queries; /// Subsonic authentication method. pub enum AuthMethod { /// Modern: token = md5(password + salt) Token { token: String, salt: String }, /// Legacy: plaintext password Password(String), /// Legacy: hex-encoded password (p=enc:hexstring) HexPassword(String), } /// Common Subsonic API parameters extracted from the query string. pub struct SubsonicParams { /// Username pub username: String, /// Authentication method + credentials pub auth: AuthMethod, /// API version requested #[allow(dead_code)] pub version: String, /// Client name #[allow(dead_code)] pub client: String, /// Response format: "xml" or "json" pub format: String, } pub enum SubsonicAuthError { MissingParam(String), AuthFailed, } impl SubsonicParams { /// Extract Subsonic params from the query string. pub fn from_request(req: &HttpRequest) -> Result { let qs = req.query_string(); let params: Vec<(String, String)> = serde_urlencoded::from_str(qs).unwrap_or_default(); let get = |name: &str| -> Option { params .iter() .find(|(k, _)| k == name) .map(|(_, v)| v.clone()) }; let username = get("u").ok_or_else(|| SubsonicAuthError::MissingParam("u".into()))?; let version = get("v").unwrap_or_else(|| "1.16.1".into()); let client = get("c").unwrap_or_else(|| "unknown".into()); let format = get("f").unwrap_or_else(|| "xml".into()); // Try token auth first (modern), then legacy password let auth = if let (Some(token), Some(salt)) = (get("t"), get("s")) { AuthMethod::Token { token, salt } } else if let Some(p) = get("p") { if let Some(hex_str) = p.strip_prefix("enc:") { AuthMethod::HexPassword(hex_str.to_string()) } else { AuthMethod::Password(p) } } else { return Err(SubsonicAuthError::MissingParam( "authentication required (t+s or p)".into(), )); }; Ok(Self { username, auth, version, client, format, }) } } /// Verify Subsonic authentication against the stored subsonic_password. pub async fn verify_auth( db: &DatabaseConnection, params: &SubsonicParams, ) -> Result { let user = queries::users::find_by_username(db, ¶ms.username) .await .map_err(|_| SubsonicAuthError::AuthFailed)? .ok_or(SubsonicAuthError::AuthFailed)?; let subsonic_password = user .subsonic_password .as_deref() .ok_or(SubsonicAuthError::AuthFailed)?; match ¶ms.auth { AuthMethod::Token { token, salt } => { // Compute md5(password + salt) and compare let mut hasher = Md5::new(); hasher.update(subsonic_password.as_bytes()); hasher.update(salt.as_bytes()); let result = hasher.finalize(); let expected = hex::encode(result); if expected != *token { return Err(SubsonicAuthError::AuthFailed); } } AuthMethod::Password(password) => { // Direct plaintext comparison if password != subsonic_password { return Err(SubsonicAuthError::AuthFailed); } } AuthMethod::HexPassword(hex_str) => { // Decode hex to string, compare let decoded = hex::decode(hex_str).map_err(|_| SubsonicAuthError::AuthFailed)?; let password = String::from_utf8(decoded).map_err(|_| SubsonicAuthError::AuthFailed)?; if password != subsonic_password { return Err(SubsonicAuthError::AuthFailed); } } } Ok(user) }