use std::sync::{Arc, RwLock};
use reqwest::cookie::{CookieStore, Jar};
use serde::Serialize;
use serde::de::DeserializeOwned;
use tracing::{debug, trace};
use url::Url;
use crate::auth::ControllerPlatform;
use crate::error::Error;
use crate::legacy::models::LegacyResponse;
use crate::transport::TransportConfig;
#[derive(serde::Deserialize)]
struct UnifiOsError {
error: Option<UnifiOsErrorInner>,
}
#[derive(serde::Deserialize)]
struct UnifiOsErrorInner {
code: u16,
message: Option<String>,
}
pub struct LegacyClient {
http: reqwest::Client,
base_url: Url,
site: String,
platform: ControllerPlatform,
csrf_token: RwLock<Option<String>>,
cookie_jar: Option<Arc<Jar>>,
}
impl LegacyClient {
pub fn new(
base_url: Url,
site: String,
platform: ControllerPlatform,
transport: &TransportConfig,
) -> Result<Self, Error> {
let config = if transport.cookie_jar.is_some() {
transport.clone()
} else {
transport.clone().with_cookie_jar()
};
let cookie_jar = config.cookie_jar.clone();
let http = config.build_client()?;
Ok(Self {
http,
base_url,
site,
platform,
csrf_token: RwLock::new(None),
cookie_jar,
})
}
pub fn with_client(
http: reqwest::Client,
base_url: Url,
site: String,
platform: ControllerPlatform,
) -> Self {
Self {
http,
base_url,
site,
platform,
csrf_token: RwLock::new(None),
cookie_jar: None,
}
}
pub fn site(&self) -> &str {
&self.site
}
pub fn http(&self) -> &reqwest::Client {
&self.http
}
pub fn base_url(&self) -> &Url {
&self.base_url
}
pub fn platform(&self) -> ControllerPlatform {
self.platform
}
pub fn cookie_header(&self) -> Option<String> {
let jar = self.cookie_jar.as_ref()?;
let cookies = jar.cookies(&self.base_url)?;
cookies.to_str().ok().map(String::from)
}
pub(crate) fn add_cookie(&self, set_cookie_value: &str, url: &Url) -> Result<(), Error> {
let jar = self
.cookie_jar
.as_ref()
.ok_or_else(|| Error::Authentication {
message: "no cookie jar available for MFA flow".into(),
})?;
let header_value: reqwest::header::HeaderValue =
set_cookie_value
.parse()
.map_err(|_| Error::Authentication {
message: "failed to parse MFA cookie value".into(),
})?;
jar.set_cookies(&mut std::iter::once(&header_value), url);
Ok(())
}
pub(crate) fn csrf_token_value(&self) -> Option<String> {
self.csrf_token.read().expect("CSRF lock poisoned").clone()
}
pub(crate) fn set_csrf_token(&self, token: String) {
debug!("storing CSRF token");
*self.csrf_token.write().expect("CSRF lock poisoned") = Some(token);
}
fn update_csrf_from_response(&self, headers: &reqwest::header::HeaderMap) {
let new_token = headers
.get("X-Updated-CSRF-Token")
.or_else(|| headers.get("x-csrf-token"))
.and_then(|v| v.to_str().ok())
.map(String::from);
if let Some(token) = new_token {
trace!("CSRF token rotated");
*self.csrf_token.write().expect("CSRF lock poisoned") = Some(token);
}
}
fn apply_csrf(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
let guard = self.csrf_token.read().expect("CSRF lock poisoned");
match guard.as_deref() {
Some(token) => builder.header("X-CSRF-Token", token),
None => builder,
}
}
pub(crate) fn api_url(&self, path: &str) -> Url {
let prefix = self.platform.legacy_prefix().unwrap_or("");
let base = self.base_url.as_str().trim_end_matches('/');
let prefix = prefix.trim_end_matches('/');
let full = format!("{base}{prefix}/api/{path}");
Url::parse(&full).expect("invalid API URL")
}
pub(crate) fn site_url(&self, path: &str) -> Url {
let prefix = self.platform.legacy_prefix().unwrap_or("");
let base = self.base_url.as_str().trim_end_matches('/');
let prefix = prefix.trim_end_matches('/');
let full = format!("{base}{prefix}/api/s/{}/{path}", self.site);
Url::parse(&full).expect("invalid site URL")
}
pub(crate) fn site_url_v2(&self, path: &str) -> Url {
let prefix = self.platform.legacy_prefix().unwrap_or("");
let base = self.base_url.as_str().trim_end_matches('/');
let prefix = prefix.trim_end_matches('/');
let full = format!("{base}{prefix}/v2/api/site/{}/{path}", self.site);
Url::parse(&full).expect("invalid v2 site URL")
}
pub(crate) async fn get<T: DeserializeOwned>(&self, url: Url) -> Result<Vec<T>, Error> {
debug!("GET {}", url);
let resp = self.http.get(url).send().await.map_err(Error::Transport)?;
self.parse_envelope(resp).await
}
pub(crate) async fn get_raw(&self, url: Url) -> Result<serde_json::Value, Error> {
debug!("GET (raw) {}", url);
let resp = self.http.get(url).send().await.map_err(Error::Transport)?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(Error::Authentication {
message: "session expired or invalid credentials".into(),
});
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::LegacyApi {
message: format!("HTTP {status}: {}", &body[..body.len().min(200)]),
});
}
let body = resp.text().await.map_err(Error::Transport)?;
serde_json::from_str(&body).map_err(|e| Error::Deserialization {
message: format!("{e}"),
body,
})
}
pub(crate) async fn post<T: DeserializeOwned>(
&self,
url: Url,
body: &(impl Serialize + Sync),
) -> Result<Vec<T>, Error> {
debug!("POST {}", url);
let builder = self.apply_csrf(self.http.post(url).json(body));
let resp = builder.send().await.map_err(Error::Transport)?;
self.parse_envelope(resp).await
}
#[allow(dead_code)]
pub(crate) async fn put<T: DeserializeOwned>(
&self,
url: Url,
body: &(impl Serialize + Sync),
) -> Result<Vec<T>, Error> {
debug!("PUT {}", url);
let builder = self.apply_csrf(self.http.put(url).json(body));
let resp = builder.send().await.map_err(Error::Transport)?;
self.parse_envelope(resp).await
}
#[allow(dead_code)]
pub(crate) async fn delete<T: DeserializeOwned>(&self, url: Url) -> Result<Vec<T>, Error> {
debug!("DELETE {}", url);
let builder = self.apply_csrf(self.http.delete(url));
let resp = builder.send().await.map_err(Error::Transport)?;
self.parse_envelope(resp).await
}
pub async fn raw_get(&self, path: &str) -> Result<serde_json::Value, Error> {
let prefix = self.platform.legacy_prefix().unwrap_or("");
let base = self.base_url.as_str().trim_end_matches('/');
let prefix = prefix.trim_end_matches('/');
let url = Url::parse(&format!("{base}{prefix}/{path}")).expect("invalid raw URL");
self.get_raw(url).await
}
pub async fn raw_post(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value, Error> {
let prefix = self.platform.legacy_prefix().unwrap_or("");
let base = self.base_url.as_str().trim_end_matches('/');
let prefix = prefix.trim_end_matches('/');
let url = Url::parse(&format!("{base}{prefix}/{path}")).expect("invalid raw URL");
debug!("POST (raw) {}", url);
let builder = self.apply_csrf(self.http.post(url).json(body));
let resp = builder.send().await.map_err(Error::Transport)?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(Error::Authentication {
message: "session expired or invalid credentials".into(),
});
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::LegacyApi {
message: format!("HTTP {status}: {}", &body[..body.len().min(200)]),
});
}
let body = resp.text().await.map_err(Error::Transport)?;
serde_json::from_str(&body).map_err(|e| Error::Deserialization {
message: format!("{e}"),
body,
})
}
pub async fn raw_put(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value, Error> {
let prefix = self.platform.legacy_prefix().unwrap_or("");
let base = self.base_url.as_str().trim_end_matches('/');
let prefix = prefix.trim_end_matches('/');
let url = Url::parse(&format!("{base}{prefix}/{path}")).expect("invalid raw URL");
debug!("PUT (raw) {}", url);
let builder = self.apply_csrf(self.http.put(url).json(body));
let resp = builder.send().await.map_err(Error::Transport)?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(Error::Authentication {
message: "session expired or invalid credentials".into(),
});
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::LegacyApi {
message: format!("HTTP {status}: {}", &body[..body.len().min(200)]),
});
}
let body = resp.text().await.map_err(Error::Transport)?;
serde_json::from_str(&body).map_err(|e| Error::Deserialization {
message: format!("{e}"),
body,
})
}
pub async fn raw_delete(&self, path: &str) -> Result<(), Error> {
let prefix = self.platform.legacy_prefix().unwrap_or("");
let base = self.base_url.as_str().trim_end_matches('/');
let prefix = prefix.trim_end_matches('/');
let url = Url::parse(&format!("{base}{prefix}/{path}")).expect("invalid raw URL");
debug!("DELETE (raw) {}", url);
let builder = self.apply_csrf(self.http.delete(url));
let resp = builder.send().await.map_err(Error::Transport)?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(Error::Authentication {
message: "session expired or invalid credentials".into(),
});
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::LegacyApi {
message: format!("HTTP {status}: {}", &body[..body.len().min(200)]),
});
}
Ok(())
}
async fn parse_envelope<T: DeserializeOwned>(
&self,
resp: reqwest::Response,
) -> Result<Vec<T>, Error> {
let status = resp.status();
self.update_csrf_from_response(resp.headers());
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(Error::Authentication {
message: "session expired or invalid credentials".into(),
});
}
if status == reqwest::StatusCode::FORBIDDEN {
return Err(Error::LegacyApi {
message: "insufficient permissions (HTTP 403)".into(),
});
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::LegacyApi {
message: format!("HTTP {status}: {}", &body[..body.len().min(200)]),
});
}
let body = resp.text().await.map_err(Error::Transport)?;
if let Ok(wrapper) = serde_json::from_str::<UnifiOsError>(&body)
&& let Some(err) = wrapper.error
{
let msg = err.message.unwrap_or_default();
return Err(if err.code == 401 {
Error::Authentication { message: msg }
} else {
Error::LegacyApi {
message: format!("UniFi OS error {}: {msg}", err.code),
}
});
}
let envelope: LegacyResponse<T> = serde_json::from_str(&body).map_err(|e| {
let preview = &body[..body.len().min(200)];
Error::Deserialization {
message: format!("{e} (body preview: {preview:?})"),
body: body.clone(),
}
})?;
match envelope.meta.rc.as_str() {
"ok" => Ok(envelope.data),
_ => Err(Error::LegacyApi {
message: envelope
.meta
.msg
.unwrap_or_else(|| format!("rc={}", envelope.meta.rc)),
}),
}
}
}