From 1048b71330eca6c4cfb9c4e6a0ae2b3c94c10d78 Mon Sep 17 00:00:00 2001 From: Tien Do Nam Date: Sat, 28 Feb 2026 02:26:43 +0100 Subject: [PATCH] feat: add thiserror to client, prepare_upload should also return status code --- core/src/http/client/mod.rs | 63 ++++++++++++ core/src/http/client/url.rs | 47 +++++++++ core/src/http/client/v2.rs | 200 ++++++++++-------------------------- core/src/http/client/v3.rs | 149 ++++++++++++--------------- core/src/http/dto_v2.rs | 5 + core/src/http/mod.rs | 2 +- 6 files changed, 233 insertions(+), 233 deletions(-) diff --git a/core/src/http/client/mod.rs b/core/src/http/client/mod.rs index 8d7230bb..bfd5a47a 100644 --- a/core/src/http/client/mod.rs +++ b/core/src/http/client/mod.rs @@ -6,13 +6,52 @@ pub use v2::LsHttpClientV2; pub use v3::LsHttpClientV3; use crate::crypto; +use crate::http::StatusCodeError; use reqwest::Response; +use serde::{Deserialize, Serialize}; +use thiserror::Error; pub enum LsHttpClient { V2(LsHttpClientV2), V3(LsHttpClientV3), } +#[derive(Debug, Error)] +pub enum ClientError { + #[error(transparent)] + StatusCode(StatusCodeError), + + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + + #[error(transparent)] + Json(#[from] serde_json::Error), + + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +pub(super) fn create_reqwest_client(private_key: &str, cert: &str) -> Result { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let identity = { + let pem = &[cert.as_bytes(), "\n".as_bytes(), private_key.as_bytes()].concat(); + reqwest::Identity::from_pem(pem)? + }; + + let client = reqwest::Client::builder() + .use_rustls_tls() + .danger_accept_invalid_certs(true) + .tls_info(true) + .identity(identity) + .build()?; + + Ok(client) +} + /// Verifies the certificate from the response. /// Returns the public key extracted from the certificate. pub(super) fn verify_cert_from_res( @@ -33,3 +72,27 @@ pub(super) fn verify_cert_from_res( }; Ok(public_key) } + +#[derive(Serialize, Deserialize)] +struct ErrorResponse { + message: String, +} + +pub(super) trait ResponseExt { + async fn into_error(self) -> Result; +} + +impl ResponseExt for Response { + async fn into_error(self) -> Result { + let status = self.status().as_u16(); + let body = self.text().await.unwrap_or_default(); + let message = match serde_json::from_str::(&body) { + Ok(error) => error.message, + Err(_) => body, + }; + Err(ClientError::StatusCode(StatusCodeError { + status, + message: if message.is_empty() { None } else { Some(message) }, + })) + } +} diff --git a/core/src/http/client/url.rs b/core/src/http/client/url.rs index cc02f3af..535cdde8 100644 --- a/core/src/http/client/url.rs +++ b/core/src/http/client/url.rs @@ -46,3 +46,50 @@ impl<'a> TargetUrl<'a> { } } } + +#[cfg(test)] +mod tests { + use super::{ApiVersion, TargetUrl}; + + #[test] + fn test_build_url_ipv4() { + let url = TargetUrl { + version: ApiVersion::V2, + protocol: "https", + host: "192.168.1.1".to_string(), + port: 53317, + path: "/register", + params: &[], + } + .to_string(); + assert_eq!(url, "https://192.168.1.1:53317/api/localsend/v2/register"); + } + + #[test] + fn test_build_url_ipv6() { + let url = TargetUrl { + version: ApiVersion::V2, + protocol: "https", + host: "::1".to_string(), + port: 53317, + path: "/register", + params: &[], + } + .to_string(); + assert_eq!(url, "https://[::1]:53317/api/localsend/v2/register"); + } + + #[test] + fn test_build_url_http() { + let url = TargetUrl { + version: ApiVersion::V2, + protocol: "http", + host: "192.168.1.1".to_string(), + port: 53317, + path: "/info", + params: &[], + } + .to_string(); + assert_eq!(url, "http://192.168.1.1:53317/api/localsend/v2/info"); + } +} diff --git a/core/src/http/client/v2.rs b/core/src/http/client/v2.rs index c1e877c3..e8ae7ada 100644 --- a/core/src/http/client/v2.rs +++ b/core/src/http/client/v2.rs @@ -1,12 +1,8 @@ +use super::{ClientError, ResponseExt}; use crate::http::client::url::{ApiVersion, TargetUrl}; -use crate::http::dto_v2::{ - InfoResponseDtoV2, PrepareDownloadResponseDtoV2, PrepareUploadRequestDtoV2, - PrepareUploadResponseDtoV2, ProtocolTypeV2, RegisterDtoV2, RegisterResponseDtoV2, -}; -use crate::http::StatusCodeError; +use crate::http::dto_v2::{InfoResponseDtoV2, PrepareDownloadResponseDtoV2, PrepareUploadRequestDtoV2, PrepareUploadResponseDtoV2, PrepareUploadResultV2, ProtocolTypeV2, RegisterDtoV2, RegisterResponseDtoV2}; use futures_util::StreamExt; use reqwest::{Response, StatusCode}; -use serde::{Deserialize, Serialize}; use tokio::io::AsyncWriteExt; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; @@ -36,30 +32,16 @@ impl LsHttpClientV2 { /// /// # Returns /// A new client instance or an error if TLS setup fails. - pub fn try_new(private_key: &str, cert: &str) -> anyhow::Result { - let _ = rustls::crypto::ring::default_provider().install_default(); - - let identity = { - let pem = &[cert.as_bytes(), "\n".as_bytes(), private_key.as_bytes()].concat(); - reqwest::Identity::from_pem(pem)? - }; - - let client = reqwest::Client::builder() - .use_rustls_tls() - .danger_accept_invalid_certs(true) - .tls_info(true) - .identity(identity) - .build()?; - + pub fn try_new(private_key: &str, cert: &str) -> Result { Ok(Self { - client, + client: super::create_reqwest_client(private_key, cert)?, }) } /// Creates a new HTTP client without TLS client certificate. /// /// Use this for HTTP-only connections or when client authentication is not needed. - pub fn try_new_without_cert() -> anyhow::Result { + pub fn try_new_without_cert() -> Result { let _ = rustls::crypto::ring::default_provider().install_default(); let client = reqwest::Client::builder() @@ -68,21 +50,7 @@ impl LsHttpClientV2 { .tls_info(true) .build()?; - Ok(Self { - client, - }) - } - - fn url(&self, protocol: &ProtocolTypeV2, ip: &str, port: u16, path: &'static str) -> String { - TargetUrl { - version: ApiVersion::V2, - protocol: protocol.as_str(), - host: ip.to_string(), - port, - path, - params: &[], - } - .to_string() + Ok(Self { client }) } /// Registers with another device for discovery. @@ -103,8 +71,16 @@ impl LsHttpClientV2 { ip: &str, port: u16, payload: RegisterDtoV2, - ) -> anyhow::Result { - let url = self.url(protocol, ip, port, "/register"); + ) -> Result { + let url = TargetUrl { + version: ApiVersion::V2, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/register", + params: &[], + } + .to_string(); let res = self .client @@ -115,7 +91,7 @@ impl LsHttpClientV2 { .await?; if res.status() != StatusCode::OK { - return Err(status_code_error_from_res(res).await?); + return res.into_error().await; } let public_key = match protocol { @@ -146,7 +122,7 @@ impl LsHttpClientV2 { /// Session ID and accepted file tokens, or an error. /// /// # Errors - /// * 204 - No file transfer needed (all files already exist) + /// * 204 - No file transfer needed (e.g. text-only transfer) /// * 400 - Invalid body /// * 401 - PIN required or invalid /// * 403 - Rejected by user @@ -161,7 +137,7 @@ impl LsHttpClientV2 { public_key: Option, payload: PrepareUploadRequestDtoV2, pin: Option<&str>, - ) -> anyhow::Result { + ) -> Result { let pin_params: &[(&'static str, &str)] = match &pin { Some(pin) => &[("pin", pin)], None => &[], @@ -188,21 +164,18 @@ impl LsHttpClientV2 { super::verify_cert_from_res(&res, Some(public_key))?; } - if res.status() == StatusCode::NO_CONTENT { - // 204 - No file transfer needed - return Ok(PrepareUploadResponseDtoV2 { - session_id: String::new(), - files: std::collections::HashMap::new(), - }); - } + let status = res.status(); - if res.status() != StatusCode::OK { - return Err(status_code_error_from_res(res).await?); + if status.as_u16() >= 400 { + return res.into_error().await; } let body = res.json::().await?; - Ok(body) + Ok(PrepareUploadResultV2 { + status_code: status.as_u16(), + response: body, + }) } /// Uploads a file to the receiver. @@ -235,14 +208,18 @@ impl LsHttpClientV2 { file_id: &str, token: &str, binary: mpsc::Receiver>, - ) -> anyhow::Result<()> { + ) -> Result<(), ClientError> { let url = TargetUrl { version: ApiVersion::V2, protocol: protocol.as_str(), host: ip.to_string(), port, path: "/upload", - params: &[("sessionId", session_id), ("fileId", file_id), ("token", token)], + params: &[ + ("sessionId", session_id), + ("fileId", file_id), + ("token", token), + ], } .to_string(); @@ -252,39 +229,7 @@ impl LsHttpClientV2 { let res = self.client.post(&url).body(body).send().await?; if res.status() != StatusCode::OK { - return Err(status_code_error_from_res(res).await?); - } - - Ok(()) - } - - /// Uploads a file from bytes. - /// - /// Convenience method that wraps upload() for simple byte array uploads. - pub async fn upload_bytes( - &self, - protocol: &ProtocolTypeV2, - ip: &str, - port: u16, - session_id: &str, - file_id: &str, - token: &str, - data: Vec, - ) -> anyhow::Result<()> { - let url = TargetUrl { - version: ApiVersion::V2, - protocol: protocol.as_str(), - host: ip.to_string(), - port, - path: "/upload", - params: &[("sessionId", session_id), ("fileId", file_id), ("token", token)], - } - .to_string(); - - let res = self.client.post(&url).body(data).send().await?; - - if res.status() != StatusCode::OK { - return Err(status_code_error_from_res(res).await?); + return res.into_error().await; } Ok(()) @@ -305,7 +250,7 @@ impl LsHttpClientV2 { ip: &str, port: u16, session_id: &str, - ) -> anyhow::Result<()> { + ) -> Result<(), ClientError> { let url = TargetUrl { version: ApiVersion::V2, protocol: protocol.as_str(), @@ -339,13 +284,21 @@ impl LsHttpClientV2 { protocol: &ProtocolTypeV2, ip: &str, port: u16, - ) -> anyhow::Result { - let url = self.url(protocol, ip, port, "/info"); + ) -> Result { + let url = TargetUrl { + version: ApiVersion::V2, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/info", + params: &[], + } + .to_string(); let res = self.client.get(&url).send().await?; if res.status() != StatusCode::OK { - return Err(status_code_error_from_res(res).await?); + return res.into_error().await; } let body = res.json::().await?; @@ -382,7 +335,7 @@ impl LsHttpClientV2 { port: u16, session_id: Option<&str>, pin: Option<&str>, - ) -> anyhow::Result { + ) -> Result { let mut params: Vec<(&'static str, &str)> = Vec::new(); if let Some(session_id) = session_id { params.push(("sessionId", session_id)); @@ -403,7 +356,7 @@ impl LsHttpClientV2 { let res = self.client.post(&url).send().await?; if res.status() != StatusCode::OK { - return Err(status_code_error_from_res(res).await?); + return res.into_error().await; } let body = res.json::().await?; @@ -433,7 +386,7 @@ impl LsHttpClientV2 { port: u16, session_id: &str, file_id: &str, - ) -> anyhow::Result { + ) -> Result { let url = TargetUrl { version: ApiVersion::V2, protocol: protocol.as_str(), @@ -447,7 +400,7 @@ impl LsHttpClientV2 { let res = self.client.get(&url).send().await?; if res.status() != StatusCode::OK { - return Err(status_code_error_from_res(res).await?); + return res.into_error().await; } Ok(res) @@ -473,8 +426,10 @@ impl LsHttpClientV2 { session_id: &str, file_id: &str, writer: &mut W, - ) -> anyhow::Result { - let response = self.download(protocol, ip, port, session_id, file_id).await?; + ) -> Result { + let response = self + .download(protocol, ip, port, session_id, file_id) + .await?; let mut stream = response.bytes_stream(); let mut total_bytes = 0u64; @@ -490,52 +445,3 @@ impl LsHttpClientV2 { Ok(total_bytes) } } - -#[derive(Serialize, Deserialize)] -struct ErrorResponse { - message: String, -} - -async fn status_code_error_from_res(response: Response) -> anyhow::Result { - let status = response.status().as_u16(); - let body = response.text().await?; - let body = match serde_json::from_str::(&body) { - Ok(error) => error.message, - Err(_) => body, - }; - - Ok(anyhow::Error::new(StatusCodeError { - status, - message: match body { - _ if body.is_empty() => None, - _ => Some(body), - }, - })) -} - - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_build_url_ipv4() { - let client = LsHttpClientV2::try_new_without_cert().unwrap(); - let url = client.url(&ProtocolTypeV2::Https, "192.168.1.1", 53317, "/register"); - assert_eq!(url, "https://192.168.1.1:53317/api/localsend/v2/register"); - } - - #[test] - fn test_build_url_ipv6() { - let client = LsHttpClientV2::try_new_without_cert().unwrap(); - let url = client.url(&ProtocolTypeV2::Https, "::1", 53317, "/register"); - assert_eq!(url, "https://[::1]:53317/api/localsend/v2/register"); - } - - #[test] - fn test_build_url_http() { - let client = LsHttpClientV2::try_new_without_cert().unwrap(); - let url = client.url(&ProtocolTypeV2::Http, "192.168.1.1", 53317, "/info"); - assert_eq!(url, "http://192.168.1.1:53317/api/localsend/v2/info"); - } -} diff --git a/core/src/http/client/v3.rs b/core/src/http/client/v3.rs index 527c0e40..34e5e756 100644 --- a/core/src/http/client/v3.rs +++ b/core/src/http/client/v3.rs @@ -1,12 +1,11 @@ +use super::{ClientError, ResponseExt}; use crate::http; use crate::http::client::url::{ApiVersion, TargetUrl}; use crate::http::dto::ProtocolType; -use crate::http::StatusCodeError; use crate::{crypto, util}; use futures_util::StreamExt; use lru::LruCache; use reqwest::{Response, StatusCode}; -use serde::{Deserialize, Serialize}; use std::num::NonZeroUsize; use std::sync::Arc; use tokio::sync::{mpsc, Mutex}; @@ -33,23 +32,9 @@ pub struct RegisterResult { } impl LsHttpClientV3 { - pub fn try_new(private_key: &str, cert: &str) -> anyhow::Result { - let _ = rustls::crypto::ring::default_provider().install_default(); - - let identity = { - let pem = &[cert.as_bytes(), "\n".as_bytes(), private_key.as_bytes()].concat(); - reqwest::Identity::from_pem(pem)? - }; - - let client = reqwest::Client::builder() - .use_rustls_tls() - .danger_accept_invalid_certs(true) - .tls_info(true) - .identity(identity) - .build()?; - + pub fn try_new(private_key: &str, cert: &str) -> Result { Ok(Self { - client, + client: super::create_reqwest_client(private_key, cert)?, received_nonce_map: Arc::new(Mutex::new(LruCache::new( NonZeroUsize::new(200).unwrap(), ))), @@ -64,7 +49,7 @@ impl LsHttpClientV3 { protocol: &ProtocolType, ip: &str, port: u16, - ) -> anyhow::Result { + ) -> Result { // Generate nonce to send to server let generated_nonce = crypto::nonce::generate_nonce(); let generated_nonce_base64 = util::base64::encode(&generated_nonce); @@ -91,14 +76,14 @@ impl LsHttpClientV3 { .await?; if res.status() != StatusCode::OK { - return Err(status_code_error_from_res(res).await?); + return res.into_error().await; } let remote_key = to_identifier(&res, protocol == &ProtocolType::Https, None)?; let body = res.json::().await?; // Save the response nonce and our generated nonce - let response_nonce = util::base64::decode(&body.nonce)?; + let response_nonce = util::base64::decode(&body.nonce).map_err(|e| anyhow::anyhow!(e))?; let mut received_nonce_map = self.received_nonce_map.lock().await; received_nonce_map.put(remote_key.clone(), response_nonce); @@ -126,17 +111,20 @@ impl LsHttpClientV3 { ip: &str, port: u16, payload: http::dto::RegisterDto, - ) -> anyhow::Result { + ) -> Result { let res = self .client - .post(TargetUrl { - version: ApiVersion::V3, - protocol: protocol.as_str(), - host: ip.to_string(), - port, - path: "/register", - params: &[], - }.to_string()) + .post( + TargetUrl { + version: ApiVersion::V3, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/register", + params: &[], + } + .to_string(), + ) .body(serde_json::to_string(&payload)?) .send() .await?; @@ -158,17 +146,20 @@ impl LsHttpClientV3 { port: u16, public_key: Option, payload: http::dto::PrepareUploadRequestDto, - ) -> anyhow::Result { + ) -> Result { let res = self .client - .post(TargetUrl { - version: ApiVersion::V3, - protocol: protocol.as_str(), - host: ip.to_string(), - port, - path: "/prepare-upload", - params: &[], - }.to_string()) + .post( + TargetUrl { + version: ApiVersion::V3, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/prepare-upload", + params: &[], + } + .to_string(), + ) .body(serde_json::to_string(&payload)?) .send() .await?; @@ -178,7 +169,7 @@ impl LsHttpClientV3 { } if res.status() != StatusCode::OK { - return Err(status_code_error_from_res(res).await?); + return res.into_error().await; } let body = res.json::().await?; @@ -196,17 +187,24 @@ impl LsHttpClientV3 { file_id: String, token: String, binary: mpsc::Receiver>, - ) -> anyhow::Result<()> { + ) -> Result<(), ClientError> { let res = self .client - .post(TargetUrl { - version: ApiVersion::V3, - protocol: protocol.as_str(), - host: ip.to_string(), - port, - path: "/upload", - params: &[("sessionId", &session_id), ("fileId", &file_id), ("token", &token)], - }.to_string()) + .post( + TargetUrl { + version: ApiVersion::V3, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/upload", + params: &[ + ("sessionId", &session_id), + ("fileId", &file_id), + ("token", &token), + ], + } + .to_string(), + ) .body({ let stream = ReceiverStream::new(binary).map(Ok::, anyhow::Error>); reqwest::Body::wrap_stream(stream) @@ -215,7 +213,7 @@ impl LsHttpClientV3 { .await?; if res.status() != StatusCode::OK { - return Err(status_code_error_from_res(res).await?); + return res.into_error().await; } Ok(()) @@ -227,16 +225,19 @@ impl LsHttpClientV3 { ip: &str, port: u16, session_id: String, - ) -> anyhow::Result<()> { + ) -> Result<(), ClientError> { self.client - .post(TargetUrl { - version: ApiVersion::V3, - protocol: protocol.as_str(), - host: ip.to_string(), - port, - path: "/cancel", - params: &[("sessionId", &session_id)], - }.to_string()) + .post( + TargetUrl { + version: ApiVersion::V3, + protocol: protocol.as_str(), + host: ip.to_string(), + port, + path: "/cancel", + params: &[("sessionId", &session_id)], + } + .to_string(), + ) .send() .await?; @@ -244,39 +245,17 @@ impl LsHttpClientV3 { } } -#[derive(Serialize, Deserialize)] -struct ErrorResponse { - message: String, -} - -async fn status_code_error_from_res(response: Response) -> anyhow::Result { - let status = response.status().as_u16(); - let body = response.text().await?; - let body = match serde_json::from_str::(&body) { - Ok(error) => error.message, - Err(_) => body, - }; - - Ok(anyhow::Error::new(StatusCodeError { - status, - message: match body { - _ if body.is_empty() => None, - _ => Some(body), - }, - })) -} - fn to_identifier( response: &Response, require_cert: bool, public_key: Option, -) -> anyhow::Result { +) -> Result { match require_cert { - true => super::verify_cert_from_res(response, public_key), + true => Ok(super::verify_cert_from_res(response, public_key)?), false => response .remote_addr() .map(|addr| addr.ip().to_string()) - .ok_or_else(|| anyhow::anyhow!("Remote address not found in response")), + .ok_or_else(|| anyhow::anyhow!("Remote address not found in response")) + .map_err(ClientError::Other), } } - diff --git a/core/src/http/dto_v2.rs b/core/src/http/dto_v2.rs index ebee43bf..835a5e98 100644 --- a/core/src/http/dto_v2.rs +++ b/core/src/http/dto_v2.rs @@ -154,6 +154,11 @@ pub struct PrepareUploadResponseDtoV2 { pub files: HashMap, } +pub struct PrepareUploadResultV2 { + pub status_code: u16, + pub response: PrepareUploadResponseDtoV2, +} + /// Prepare download response DTO for v2.1 protocol (Download API). /// /// Response from POST /api/localsend/v2/prepare-download. diff --git a/core/src/http/mod.rs b/core/src/http/mod.rs index 8de706d7..854a62fc 100644 --- a/core/src/http/mod.rs +++ b/core/src/http/mod.rs @@ -8,7 +8,7 @@ pub mod state; #[derive(Debug, Error)] #[error("{status};{message:?}")] -pub(crate) struct StatusCodeError { +pub struct StatusCodeError { status: u16, message: Option, }