mirror of
https://github.com/localsend/localsend.git
synced 2026-06-23 04:10:07 +00:00
feat: add v2 client
This commit is contained in:
+7
-298
@@ -1,302 +1,11 @@
|
||||
mod url;
|
||||
pub mod v2;
|
||||
pub mod v3;
|
||||
|
||||
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};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
pub use v2::LsHttpClientV2;
|
||||
pub use v3::LsHttpClientV3;
|
||||
|
||||
const BASE_PATH: &str = "/api/localsend/v3";
|
||||
|
||||
pub struct LsHttpClient {
|
||||
client: reqwest::Client,
|
||||
|
||||
/// Maps client identifiers to nonces that have been received from remote.
|
||||
received_nonce_map: Arc<Mutex<LruCache<String, Vec<u8>>>>,
|
||||
|
||||
/// Maps client identifiers to nonces that are expected to be received from remote.
|
||||
generated_nonce_map: Arc<Mutex<LruCache<String, Vec<u8>>>>,
|
||||
}
|
||||
|
||||
pub struct RegisterResult {
|
||||
/// The public key extracted from the certificate.
|
||||
/// Encoded in PEM format.
|
||||
/// Only available in HTTPS.
|
||||
pub public_key: Option<String>,
|
||||
|
||||
/// The response body from the register request.
|
||||
pub body: http::dto::RegisterResponseDto,
|
||||
}
|
||||
|
||||
impl LsHttpClient {
|
||||
pub fn try_new(private_key: &str, cert: &str) -> anyhow::Result<Self> {
|
||||
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(Self {
|
||||
client,
|
||||
received_nonce_map: Arc::new(Mutex::new(LruCache::new(
|
||||
NonZeroUsize::new(200).unwrap(),
|
||||
))),
|
||||
generated_nonce_map: Arc::new(Mutex::new(LruCache::new(
|
||||
NonZeroUsize::new(200).unwrap(),
|
||||
))),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn nonce(
|
||||
&self,
|
||||
protocol: &ProtocolType,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
) -> anyhow::Result<String> {
|
||||
// Generate nonce to send to server
|
||||
let generated_nonce = crypto::nonce::generate_nonce();
|
||||
let generated_nonce_base64 = util::base64::encode(&generated_nonce);
|
||||
|
||||
let request_body = http::dto::NonceRequest {
|
||||
nonce: generated_nonce_base64,
|
||||
};
|
||||
|
||||
let res = self
|
||||
.client
|
||||
.post(
|
||||
TargetUrl {
|
||||
version: ApiVersion::V3,
|
||||
protocol: protocol.clone(),
|
||||
host: ip.to_string(),
|
||||
port,
|
||||
path: "/nonce",
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
.body(serde_json::to_string(&request_body)?)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if res.status() != StatusCode::OK {
|
||||
return Err(status_code_error_from_res(res).await?);
|
||||
}
|
||||
|
||||
let remote_key = to_identifier(&res, protocol == &ProtocolType::Https, None)?;
|
||||
let body = res.json::<http::dto::NonceResponse>().await?;
|
||||
|
||||
// Save the response nonce and our generated nonce
|
||||
let response_nonce = util::base64::decode(&body.nonce)?;
|
||||
|
||||
let mut received_nonce_map = self.received_nonce_map.lock().await;
|
||||
received_nonce_map.put(remote_key.clone(), response_nonce);
|
||||
|
||||
let mut generated_nonce_map = self.generated_nonce_map.lock().await;
|
||||
generated_nonce_map.put(remote_key.clone(), generated_nonce);
|
||||
|
||||
tracing::info!("Nonce exchange successful for server: {ip} (ID: {remote_key})");
|
||||
|
||||
tracing::debug!(
|
||||
"Received map: {:?}",
|
||||
received_nonce_map.get(&remote_key).unwrap()
|
||||
);
|
||||
tracing::debug!(
|
||||
"Generated map: {:?}",
|
||||
generated_nonce_map.get(&remote_key).unwrap()
|
||||
);
|
||||
|
||||
Ok(body.nonce)
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
&self,
|
||||
protocol: &ProtocolType,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
payload: http::dto::RegisterDto,
|
||||
) -> anyhow::Result<RegisterResult> {
|
||||
let res = self
|
||||
.client
|
||||
.post(format!(
|
||||
"{}://{}:{}{}/register",
|
||||
protocol.as_str(),
|
||||
ip,
|
||||
port,
|
||||
BASE_PATH
|
||||
))
|
||||
.body(serde_json::to_string(&payload)?)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let public_key = match protocol {
|
||||
ProtocolType::Https => Some(verify_cert_from_res(&res, None)?),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let body = res.json::<http::dto::RegisterResponseDto>().await?;
|
||||
|
||||
Ok(RegisterResult { public_key, body })
|
||||
}
|
||||
|
||||
pub async fn prepare_upload(
|
||||
&self,
|
||||
protocol: &ProtocolType,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
public_key: Option<String>,
|
||||
payload: http::dto::PrepareUploadRequestDto,
|
||||
) -> anyhow::Result<http::dto::PrepareUploadResponseDto> {
|
||||
let res = self
|
||||
.client
|
||||
.post(format!(
|
||||
"{}://{}:{}{}/prepare-upload",
|
||||
protocol.as_str(),
|
||||
ip,
|
||||
port,
|
||||
BASE_PATH
|
||||
))
|
||||
.body(serde_json::to_string(&payload)?)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if let Some(public_key) = public_key {
|
||||
verify_cert_from_res(&res, Some(public_key))?;
|
||||
}
|
||||
|
||||
if res.status() != StatusCode::OK {
|
||||
return Err(status_code_error_from_res(res).await?);
|
||||
}
|
||||
|
||||
let body = res.json::<http::dto::PrepareUploadResponseDto>().await?;
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Uploads a file to the server.
|
||||
pub async fn upload(
|
||||
&self,
|
||||
protocol: &ProtocolType,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
session_id: String,
|
||||
file_id: String,
|
||||
token: String,
|
||||
binary: mpsc::Receiver<Vec<u8>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let res = self
|
||||
.client
|
||||
.post(format!(
|
||||
"{}://{}:{}{}/upload?sessionId={}&fileId={}&token={}",
|
||||
protocol.as_str(),
|
||||
ip,
|
||||
port,
|
||||
BASE_PATH,
|
||||
session_id,
|
||||
file_id,
|
||||
token
|
||||
))
|
||||
.body({
|
||||
let stream = ReceiverStream::new(binary).map(Ok::<Vec<u8>, anyhow::Error>);
|
||||
reqwest::Body::wrap_stream(stream)
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if res.status() != StatusCode::OK {
|
||||
return Err(status_code_error_from_res(res).await?);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cancel(
|
||||
&self,
|
||||
protocol: &ProtocolType,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
session_id: String,
|
||||
) -> anyhow::Result<()> {
|
||||
self.client
|
||||
.post(format!(
|
||||
"{}://{}:{}{}/cancel?sessionId={}",
|
||||
protocol.as_str(),
|
||||
ip,
|
||||
port,
|
||||
BASE_PATH,
|
||||
session_id
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ErrorResponse {
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn status_code_error_from_res(response: Response) -> anyhow::Result<anyhow::Error> {
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await?;
|
||||
let body = match serde_json::from_str::<ErrorResponse>(&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<String>,
|
||||
) -> anyhow::Result<String> {
|
||||
match require_cert {
|
||||
true => 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")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies the certificate from the response.
|
||||
/// Returns the public key extracted from the certificate.
|
||||
fn verify_cert_from_res(response: &Response, public_key: Option<String>) -> anyhow::Result<String> {
|
||||
let tls_info_ext = response
|
||||
.extensions()
|
||||
.get::<reqwest::tls::TlsInfo>()
|
||||
.ok_or_else(|| anyhow::anyhow!("TLS info not found"))?;
|
||||
let cert = tls_info_ext
|
||||
.peer_certificate()
|
||||
.ok_or_else(|| anyhow::anyhow!("Certificate not found"))?;
|
||||
let public_key = match public_key {
|
||||
Some(public_key) => public_key.to_owned(),
|
||||
None => crypto::cert::public_key_from_cert_der(cert)?,
|
||||
};
|
||||
crypto::cert::verify_cert_from_der(cert, Some(public_key.clone()))?;
|
||||
Ok(public_key)
|
||||
pub enum LsHttpClient {
|
||||
V2(LsHttpClientV2),
|
||||
V3(LsHttpClientV3),
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use crate::http::dto::ProtocolType;
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub struct TargetUrl {
|
||||
pub struct TargetUrl<'a> {
|
||||
pub version: ApiVersion,
|
||||
pub protocol: ProtocolType,
|
||||
pub protocol: &'static str,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub path: &'static str,
|
||||
|
||||
/// Query parameters as key-value pairs.
|
||||
/// Note: It is expected that the caller will URL-encode the values if necessary.
|
||||
pub params: &'a [(&'static str, &'a str)],
|
||||
}
|
||||
|
||||
pub enum ApiVersion {
|
||||
@@ -14,11 +17,11 @@ pub enum ApiVersion {
|
||||
V3,
|
||||
}
|
||||
|
||||
impl TargetUrl {
|
||||
impl<'a> TargetUrl<'a> {
|
||||
pub fn to_string(&self) -> String {
|
||||
format!(
|
||||
let base = format!(
|
||||
"{}://{}:{}/api/localsend/{}{}",
|
||||
self.protocol.as_str(),
|
||||
self.protocol,
|
||||
match self.host.contains(':') {
|
||||
true => Cow::Owned(format!("[{}]", self.host)), // IPv6 addresses need to be enclosed in brackets
|
||||
false => Cow::Borrowed(&self.host),
|
||||
@@ -29,6 +32,17 @@ impl TargetUrl {
|
||||
ApiVersion::V3 => "v3",
|
||||
},
|
||||
self.path
|
||||
)
|
||||
);
|
||||
if self.params.is_empty() {
|
||||
base
|
||||
} else {
|
||||
let query = self
|
||||
.params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
format!("{}?{}", base, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
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::crypto;
|
||||
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;
|
||||
|
||||
/// Result of a successful register request.
|
||||
pub struct RegisterResultV2 {
|
||||
/// The public key extracted from the certificate.
|
||||
/// Encoded in PEM format.
|
||||
/// Only available in HTTPS mode.
|
||||
pub public_key: Option<String>,
|
||||
|
||||
/// The response body from the register request.
|
||||
pub body: RegisterResponseDtoV2,
|
||||
}
|
||||
|
||||
/// HTTP client for LocalSend Protocol v2.1.
|
||||
pub struct LsHttpClientV2 {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl LsHttpClientV2 {
|
||||
/// Creates a new HTTP client for v2.1 protocol.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `private_key` - PEM-encoded private key for client certificate
|
||||
/// * `cert` - PEM-encoded certificate for client authentication
|
||||
///
|
||||
/// # Returns
|
||||
/// A new client instance or an error if TLS setup fails.
|
||||
pub fn try_new(private_key: &str, cert: &str) -> anyhow::Result<Self> {
|
||||
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(Self {
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Self> {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.use_rustls_tls()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.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()
|
||||
}
|
||||
|
||||
/// Registers with another device for discovery.
|
||||
///
|
||||
/// POST /api/localsend/v2/register
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `protocol` - HTTP or HTTPS
|
||||
/// * `ip` - Target device IP address
|
||||
/// * `port` - Target device port
|
||||
/// * `payload` - Device information to register
|
||||
///
|
||||
/// # Returns
|
||||
/// Registration result containing the remote device info and optional public key.
|
||||
pub async fn register(
|
||||
&self,
|
||||
protocol: &ProtocolTypeV2,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
payload: RegisterDtoV2,
|
||||
) -> anyhow::Result<RegisterResultV2> {
|
||||
let url = self.url(protocol, ip, port, "/register");
|
||||
|
||||
let res = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(&payload)?)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if res.status() != StatusCode::OK {
|
||||
return Err(status_code_error_from_res(res).await?);
|
||||
}
|
||||
|
||||
let public_key = match protocol {
|
||||
ProtocolTypeV2::Https => Some(verify_cert_from_res(&res, None)?),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let body = res.json::<RegisterResponseDtoV2>().await?;
|
||||
|
||||
Ok(RegisterResultV2 { public_key, body })
|
||||
}
|
||||
|
||||
/// Prepares a file upload session with the receiver.
|
||||
///
|
||||
/// POST /api/localsend/v2/prepare-upload
|
||||
///
|
||||
/// The receiver will decide if this request gets accepted, partially accepted, or rejected.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `protocol` - HTTP or HTTPS
|
||||
/// * `ip` - Receiver's IP address
|
||||
/// * `port` - Receiver's port
|
||||
/// * `public_key` - Expected public key for verification (HTTPS only)
|
||||
/// * `payload` - Upload request with device info and file metadata
|
||||
/// * `pin` - Optional PIN if required by receiver
|
||||
///
|
||||
/// # Returns
|
||||
/// Session ID and accepted file tokens, or an error.
|
||||
///
|
||||
/// # Errors
|
||||
/// * 204 - No file transfer needed (all files already exist)
|
||||
/// * 400 - Invalid body
|
||||
/// * 401 - PIN required or invalid
|
||||
/// * 403 - Rejected by user
|
||||
/// * 409 - Blocked by another session
|
||||
/// * 429 - Too many requests
|
||||
/// * 500 - Unknown error
|
||||
pub async fn prepare_upload(
|
||||
&self,
|
||||
protocol: &ProtocolTypeV2,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
public_key: Option<String>,
|
||||
payload: PrepareUploadRequestDtoV2,
|
||||
pin: Option<&str>,
|
||||
) -> anyhow::Result<PrepareUploadResponseDtoV2> {
|
||||
let pin_params: &[(&'static str, &str)] = match &pin {
|
||||
Some(pin) => &[("pin", pin)],
|
||||
None => &[],
|
||||
};
|
||||
let url = TargetUrl {
|
||||
version: ApiVersion::V2,
|
||||
protocol: protocol.as_str(),
|
||||
host: ip.to_string(),
|
||||
port,
|
||||
path: "/prepare-upload",
|
||||
params: pin_params,
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let res = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(&payload)?)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if let Some(public_key) = public_key {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
if res.status() != StatusCode::OK {
|
||||
return Err(status_code_error_from_res(res).await?);
|
||||
}
|
||||
|
||||
let body = res.json::<PrepareUploadResponseDtoV2>().await?;
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Uploads a file to the receiver.
|
||||
///
|
||||
/// POST /api/localsend/v2/upload?sessionId=...&fileId=...&token=...
|
||||
///
|
||||
/// Use the session_id, file_id, and token from prepare_upload response.
|
||||
/// This method can be called in parallel for multiple files.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `protocol` - HTTP or HTTPS
|
||||
/// * `ip` - Receiver's IP address
|
||||
/// * `port` - Receiver's port
|
||||
/// * `session_id` - Session ID from prepare_upload
|
||||
/// * `file_id` - File ID to upload
|
||||
/// * `token` - File-specific token from prepare_upload
|
||||
/// * `binary` - Channel receiving file chunks
|
||||
///
|
||||
/// # Errors
|
||||
/// * 400 - Missing parameters
|
||||
/// * 403 - Invalid token or IP address
|
||||
/// * 409 - Blocked by another session
|
||||
/// * 500 - Unknown error
|
||||
pub async fn upload(
|
||||
&self,
|
||||
protocol: &ProtocolTypeV2,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
session_id: &str,
|
||||
file_id: &str,
|
||||
token: &str,
|
||||
binary: mpsc::Receiver<Vec<u8>>,
|
||||
) -> 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 stream = ReceiverStream::new(binary).map(Ok::<Vec<u8>, anyhow::Error>);
|
||||
let body = reqwest::Body::wrap_stream(stream);
|
||||
|
||||
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<u8>,
|
||||
) -> 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?);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancels an ongoing file transfer session.
|
||||
///
|
||||
/// POST /api/localsend/v2/cancel?sessionId=...
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `protocol` - HTTP or HTTPS
|
||||
/// * `ip` - Receiver's IP address
|
||||
/// * `port` - Receiver's port
|
||||
/// * `session_id` - Session ID to cancel
|
||||
pub async fn cancel(
|
||||
&self,
|
||||
protocol: &ProtocolTypeV2,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
session_id: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let url = TargetUrl {
|
||||
version: ApiVersion::V2,
|
||||
protocol: protocol.as_str(),
|
||||
host: ip.to_string(),
|
||||
port,
|
||||
path: "/cancel",
|
||||
params: &[("sessionId", session_id)],
|
||||
}
|
||||
.to_string();
|
||||
|
||||
self.client.post(&url).send().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gets device info from a remote device.
|
||||
///
|
||||
/// GET /api/localsend/v2/info
|
||||
///
|
||||
/// This is primarily for debugging purposes.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `protocol` - HTTP or HTTPS
|
||||
/// * `ip` - Target device IP address
|
||||
/// * `port` - Target device port
|
||||
///
|
||||
/// # Returns
|
||||
/// Device information including alias, version, device type, fingerprint, etc.
|
||||
pub async fn info(
|
||||
&self,
|
||||
protocol: &ProtocolTypeV2,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
) -> anyhow::Result<InfoResponseDtoV2> {
|
||||
let url = self.url(protocol, ip, port, "/info");
|
||||
|
||||
let res = self.client.get(&url).send().await?;
|
||||
|
||||
if res.status() != StatusCode::OK {
|
||||
return Err(status_code_error_from_res(res).await?);
|
||||
}
|
||||
|
||||
let body = res.json::<InfoResponseDtoV2>().await?;
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Prepares to download files from a sender (Download API).
|
||||
///
|
||||
/// POST /api/localsend/v2/prepare-download
|
||||
///
|
||||
/// This is used in reverse file transfer mode where the sender hosts the files
|
||||
/// and receivers download them.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `protocol` - HTTP or HTTPS
|
||||
/// * `ip` - Sender's IP address
|
||||
/// * `port` - Sender's port
|
||||
/// * `session_id` - Optional existing session ID (for browser refresh scenarios)
|
||||
/// * `pin` - Optional PIN if required by sender
|
||||
///
|
||||
/// # Returns
|
||||
/// Sender info, session ID, and available files.
|
||||
///
|
||||
/// # Errors
|
||||
/// * 401 - PIN required or invalid
|
||||
/// * 403 - Rejected
|
||||
/// * 429 - Too many requests
|
||||
/// * 500 - Unknown error
|
||||
pub async fn prepare_download(
|
||||
&self,
|
||||
protocol: &ProtocolTypeV2,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
session_id: Option<&str>,
|
||||
pin: Option<&str>,
|
||||
) -> anyhow::Result<PrepareDownloadResponseDtoV2> {
|
||||
let mut params: Vec<(&'static str, &str)> = Vec::new();
|
||||
if let Some(session_id) = session_id {
|
||||
params.push(("sessionId", session_id));
|
||||
}
|
||||
if let Some(pin) = pin {
|
||||
params.push(("pin", pin));
|
||||
}
|
||||
let url = TargetUrl {
|
||||
version: ApiVersion::V2,
|
||||
protocol: protocol.as_str(),
|
||||
host: ip.to_string(),
|
||||
port,
|
||||
path: "/prepare-download",
|
||||
params: ¶ms,
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let res = self.client.post(&url).send().await?;
|
||||
|
||||
if res.status() != StatusCode::OK {
|
||||
return Err(status_code_error_from_res(res).await?);
|
||||
}
|
||||
|
||||
let body = res.json::<PrepareDownloadResponseDtoV2>().await?;
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Downloads a file from a sender (Download API).
|
||||
///
|
||||
/// GET /api/localsend/v2/download?sessionId=...&fileId=...
|
||||
///
|
||||
/// This method can be called in parallel for multiple files.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `protocol` - HTTP or HTTPS
|
||||
/// * `ip` - Sender's IP address
|
||||
/// * `port` - Sender's port
|
||||
/// * `session_id` - Session ID from prepare_download
|
||||
/// * `file_id` - File ID to download
|
||||
///
|
||||
/// # Returns
|
||||
/// Response containing the file data stream.
|
||||
pub async fn download(
|
||||
&self,
|
||||
protocol: &ProtocolTypeV2,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
session_id: &str,
|
||||
file_id: &str,
|
||||
) -> anyhow::Result<Response> {
|
||||
let url = TargetUrl {
|
||||
version: ApiVersion::V2,
|
||||
protocol: protocol.as_str(),
|
||||
host: ip.to_string(),
|
||||
port,
|
||||
path: "/download",
|
||||
params: &[("sessionId", session_id), ("fileId", file_id)],
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let res = self.client.get(&url).send().await?;
|
||||
|
||||
if res.status() != StatusCode::OK {
|
||||
return Err(status_code_error_from_res(res).await?);
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Downloads a file to a writer (convenience method).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `protocol` - HTTP or HTTPS
|
||||
/// * `ip` - Sender's IP address
|
||||
/// * `port` - Sender's port
|
||||
/// * `session_id` - Session ID from prepare_download
|
||||
/// * `file_id` - File ID to download
|
||||
/// * `writer` - AsyncWrite destination for file data
|
||||
///
|
||||
/// # Returns
|
||||
/// Total bytes written.
|
||||
pub async fn download_to_writer<W: tokio::io::AsyncWrite + Unpin>(
|
||||
&self,
|
||||
protocol: &ProtocolTypeV2,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
session_id: &str,
|
||||
file_id: &str,
|
||||
writer: &mut W,
|
||||
) -> anyhow::Result<u64> {
|
||||
let response = self.download(protocol, ip, port, session_id, file_id).await?;
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut total_bytes = 0u64;
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk?;
|
||||
writer.write_all(&chunk).await?;
|
||||
total_bytes += chunk.len() as u64;
|
||||
}
|
||||
|
||||
writer.flush().await?;
|
||||
|
||||
Ok(total_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ErrorResponse {
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn status_code_error_from_res(response: Response) -> anyhow::Result<anyhow::Error> {
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await?;
|
||||
let body = match serde_json::from_str::<ErrorResponse>(&body) {
|
||||
Ok(error) => error.message,
|
||||
Err(_) => body,
|
||||
};
|
||||
|
||||
Ok(anyhow::Error::new(StatusCodeError {
|
||||
status,
|
||||
message: match body {
|
||||
_ if body.is_empty() => None,
|
||||
_ => Some(body),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/// Verifies the certificate from the response.
|
||||
/// Returns the public key extracted from the certificate.
|
||||
fn verify_cert_from_res(response: &Response, public_key: Option<String>) -> anyhow::Result<String> {
|
||||
let tls_info_ext = response
|
||||
.extensions()
|
||||
.get::<reqwest::tls::TlsInfo>()
|
||||
.ok_or_else(|| anyhow::anyhow!("TLS info not found"))?;
|
||||
let cert = tls_info_ext
|
||||
.peer_certificate()
|
||||
.ok_or_else(|| anyhow::anyhow!("Certificate not found"))?;
|
||||
let public_key = match public_key {
|
||||
Some(public_key) => public_key.to_owned(),
|
||||
None => crypto::cert::public_key_from_cert_der(cert)?,
|
||||
};
|
||||
crypto::cert::verify_cert_from_der(cert, Some(public_key.clone()))?;
|
||||
Ok(public_key)
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
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};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
pub struct LsHttpClientV3 {
|
||||
client: reqwest::Client,
|
||||
|
||||
/// Maps client identifiers to nonces that have been received from remote.
|
||||
received_nonce_map: Arc<Mutex<LruCache<String, Vec<u8>>>>,
|
||||
|
||||
/// Maps client identifiers to nonces that are expected to be received from remote.
|
||||
generated_nonce_map: Arc<Mutex<LruCache<String, Vec<u8>>>>,
|
||||
}
|
||||
|
||||
pub struct RegisterResult {
|
||||
/// The public key extracted from the certificate.
|
||||
/// Encoded in PEM format.
|
||||
/// Only available in HTTPS.
|
||||
pub public_key: Option<String>,
|
||||
|
||||
/// The response body from the register request.
|
||||
pub body: http::dto::RegisterResponseDto,
|
||||
}
|
||||
|
||||
impl LsHttpClientV3 {
|
||||
pub fn try_new(private_key: &str, cert: &str) -> anyhow::Result<Self> {
|
||||
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(Self {
|
||||
client,
|
||||
received_nonce_map: Arc::new(Mutex::new(LruCache::new(
|
||||
NonZeroUsize::new(200).unwrap(),
|
||||
))),
|
||||
generated_nonce_map: Arc::new(Mutex::new(LruCache::new(
|
||||
NonZeroUsize::new(200).unwrap(),
|
||||
))),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn nonce(
|
||||
&self,
|
||||
protocol: &ProtocolType,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
) -> anyhow::Result<String> {
|
||||
// Generate nonce to send to server
|
||||
let generated_nonce = crypto::nonce::generate_nonce();
|
||||
let generated_nonce_base64 = util::base64::encode(&generated_nonce);
|
||||
|
||||
let request_body = http::dto::NonceRequest {
|
||||
nonce: generated_nonce_base64,
|
||||
};
|
||||
|
||||
let res = self
|
||||
.client
|
||||
.post(
|
||||
TargetUrl {
|
||||
version: ApiVersion::V3,
|
||||
protocol: protocol.as_str(),
|
||||
host: ip.to_string(),
|
||||
port,
|
||||
path: "/nonce",
|
||||
params: &[],
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
.body(serde_json::to_string(&request_body)?)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if res.status() != StatusCode::OK {
|
||||
return Err(status_code_error_from_res(res).await?);
|
||||
}
|
||||
|
||||
let remote_key = to_identifier(&res, protocol == &ProtocolType::Https, None)?;
|
||||
let body = res.json::<http::dto::NonceResponse>().await?;
|
||||
|
||||
// Save the response nonce and our generated nonce
|
||||
let response_nonce = util::base64::decode(&body.nonce)?;
|
||||
|
||||
let mut received_nonce_map = self.received_nonce_map.lock().await;
|
||||
received_nonce_map.put(remote_key.clone(), response_nonce);
|
||||
|
||||
let mut generated_nonce_map = self.generated_nonce_map.lock().await;
|
||||
generated_nonce_map.put(remote_key.clone(), generated_nonce);
|
||||
|
||||
tracing::info!("Nonce exchange successful for server: {ip} (ID: {remote_key})");
|
||||
|
||||
tracing::debug!(
|
||||
"Received map: {:?}",
|
||||
received_nonce_map.get(&remote_key).unwrap()
|
||||
);
|
||||
tracing::debug!(
|
||||
"Generated map: {:?}",
|
||||
generated_nonce_map.get(&remote_key).unwrap()
|
||||
);
|
||||
|
||||
Ok(body.nonce)
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
&self,
|
||||
protocol: &ProtocolType,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
payload: http::dto::RegisterDto,
|
||||
) -> anyhow::Result<RegisterResult> {
|
||||
let res = self
|
||||
.client
|
||||
.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?;
|
||||
|
||||
let public_key = match protocol {
|
||||
ProtocolType::Https => Some(verify_cert_from_res(&res, None)?),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let body = res.json::<http::dto::RegisterResponseDto>().await?;
|
||||
|
||||
Ok(RegisterResult { public_key, body })
|
||||
}
|
||||
|
||||
pub async fn prepare_upload(
|
||||
&self,
|
||||
protocol: &ProtocolType,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
public_key: Option<String>,
|
||||
payload: http::dto::PrepareUploadRequestDto,
|
||||
) -> anyhow::Result<http::dto::PrepareUploadResponseDto> {
|
||||
let res = self
|
||||
.client
|
||||
.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?;
|
||||
|
||||
if let Some(public_key) = public_key {
|
||||
verify_cert_from_res(&res, Some(public_key))?;
|
||||
}
|
||||
|
||||
if res.status() != StatusCode::OK {
|
||||
return Err(status_code_error_from_res(res).await?);
|
||||
}
|
||||
|
||||
let body = res.json::<http::dto::PrepareUploadResponseDto>().await?;
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Uploads a file to the server.
|
||||
pub async fn upload(
|
||||
&self,
|
||||
protocol: &ProtocolType,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
session_id: String,
|
||||
file_id: String,
|
||||
token: String,
|
||||
binary: mpsc::Receiver<Vec<u8>>,
|
||||
) -> anyhow::Result<()> {
|
||||
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())
|
||||
.body({
|
||||
let stream = ReceiverStream::new(binary).map(Ok::<Vec<u8>, anyhow::Error>);
|
||||
reqwest::Body::wrap_stream(stream)
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if res.status() != StatusCode::OK {
|
||||
return Err(status_code_error_from_res(res).await?);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cancel(
|
||||
&self,
|
||||
protocol: &ProtocolType,
|
||||
ip: &str,
|
||||
port: u16,
|
||||
session_id: String,
|
||||
) -> anyhow::Result<()> {
|
||||
self.client
|
||||
.post(TargetUrl {
|
||||
version: ApiVersion::V3,
|
||||
protocol: protocol.as_str(),
|
||||
host: ip.to_string(),
|
||||
port,
|
||||
path: "/cancel",
|
||||
params: &[("sessionId", &session_id)],
|
||||
}.to_string())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ErrorResponse {
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn status_code_error_from_res(response: Response) -> anyhow::Result<anyhow::Error> {
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await?;
|
||||
let body = match serde_json::from_str::<ErrorResponse>(&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<String>,
|
||||
) -> anyhow::Result<String> {
|
||||
match require_cert {
|
||||
true => 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")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies the certificate from the response.
|
||||
/// Returns the public key extracted from the certificate.
|
||||
fn verify_cert_from_res(response: &Response, public_key: Option<String>) -> anyhow::Result<String> {
|
||||
let tls_info_ext = response
|
||||
.extensions()
|
||||
.get::<reqwest::tls::TlsInfo>()
|
||||
.ok_or_else(|| anyhow::anyhow!("TLS info not found"))?;
|
||||
let cert = tls_info_ext
|
||||
.peer_certificate()
|
||||
.ok_or_else(|| anyhow::anyhow!("Certificate not found"))?;
|
||||
let public_key = match public_key {
|
||||
Some(public_key) => public_key.to_owned(),
|
||||
None => crypto::cert::public_key_from_cert_der(cert)?,
|
||||
};
|
||||
crypto::cert::verify_cert_from_der(cert, Some(public_key.clone()))?;
|
||||
Ok(public_key)
|
||||
}
|
||||
@@ -52,7 +52,7 @@ pub enum ProtocolType {
|
||||
}
|
||||
|
||||
impl ProtocolType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ProtocolType::Http => "http",
|
||||
ProtocolType::Https => "https",
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
use crate::model::discovery::DeviceType;
|
||||
use crate::model::transfer::FileDto;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Protocol type for HTTP or HTTPS connections.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProtocolTypeV2 {
|
||||
Http,
|
||||
Https,
|
||||
}
|
||||
|
||||
impl ProtocolTypeV2 {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ProtocolTypeV2::Http => "http",
|
||||
ProtocolTypeV2::Https => "https",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Multicast announcement/response message for UDP discovery (v2.1).
|
||||
///
|
||||
/// Used for both sending announcements and responding to announcements.
|
||||
/// When `announce` is true, other devices should respond.
|
||||
/// When `announce` is false, this is a response to an announcement.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MulticastMessageV2 {
|
||||
/// The display name of the device.
|
||||
pub alias: String,
|
||||
|
||||
/// Protocol version (e.g., "2.1").
|
||||
pub version: String,
|
||||
|
||||
/// Device model (e.g., "Samsung", "Windows"). Optional.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_model: Option<String>,
|
||||
|
||||
/// Device type category. Optional.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_type: Option<DeviceType>,
|
||||
|
||||
/// Fingerprint for device identification.
|
||||
/// In HTTPS mode: SHA-256 hash of the certificate.
|
||||
/// In HTTP mode: randomly generated string.
|
||||
pub fingerprint: String,
|
||||
|
||||
/// Port number the device is listening on.
|
||||
pub port: u16,
|
||||
|
||||
/// Protocol type (http or https).
|
||||
pub protocol: ProtocolTypeV2,
|
||||
|
||||
/// Whether the download API (sections 5.2, 5.3) is active.
|
||||
#[serde(default)]
|
||||
pub download: bool,
|
||||
|
||||
/// Whether this is an announcement (true) or a response (false).
|
||||
/// Other devices should only respond when this is true.
|
||||
pub announce: bool,
|
||||
}
|
||||
|
||||
/// Register request DTO for v2.1 protocol.
|
||||
///
|
||||
/// Sent to POST /api/localsend/v2/register for device discovery.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RegisterDtoV2 {
|
||||
/// The display name of the device.
|
||||
pub alias: String,
|
||||
|
||||
/// Protocol version (e.g., "2.0", "2.1").
|
||||
pub version: String,
|
||||
|
||||
/// Device model (e.g., "Samsung", "Windows"). Optional.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_model: Option<String>,
|
||||
|
||||
/// Device type category. Optional.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_type: Option<DeviceType>,
|
||||
|
||||
/// Fingerprint for device identification.
|
||||
/// Ignored in HTTPS mode (certificate is used instead).
|
||||
pub fingerprint: String,
|
||||
|
||||
/// Port number the device is listening on.
|
||||
pub port: u16,
|
||||
|
||||
/// Protocol type (http or https).
|
||||
pub protocol: ProtocolTypeV2,
|
||||
|
||||
/// Whether the download API (sections 5.2, 5.3) is active.
|
||||
#[serde(default)]
|
||||
pub download: bool,
|
||||
}
|
||||
|
||||
/// Register response DTO for v2.1 protocol.
|
||||
///
|
||||
/// Response from POST /api/localsend/v2/register.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RegisterResponseDtoV2 {
|
||||
/// The display name of the device.
|
||||
pub alias: String,
|
||||
|
||||
/// Protocol version (e.g., "2.0", "2.1").
|
||||
pub version: String,
|
||||
|
||||
/// Device model. Optional.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_model: Option<String>,
|
||||
|
||||
/// Device type category. Optional.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_type: Option<DeviceType>,
|
||||
|
||||
/// Fingerprint for device identification.
|
||||
/// Ignored in HTTPS mode (certificate is used instead).
|
||||
#[serde(default)]
|
||||
pub fingerprint: String,
|
||||
|
||||
/// Whether the download API (sections 5.2, 5.3) is active.
|
||||
#[serde(default)]
|
||||
pub download: bool,
|
||||
}
|
||||
|
||||
/// Prepare upload request DTO for v2.1 protocol.
|
||||
///
|
||||
/// Sent to POST /api/localsend/v2/prepare-upload to initiate a file transfer.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrepareUploadRequestDtoV2 {
|
||||
/// Sender's device information.
|
||||
pub info: RegisterDtoV2,
|
||||
|
||||
/// Map of file ID to file metadata.
|
||||
pub files: HashMap<String, FileDto>,
|
||||
}
|
||||
|
||||
/// Prepare upload response DTO for v2.1 protocol.
|
||||
///
|
||||
/// Response from POST /api/localsend/v2/prepare-upload.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrepareUploadResponseDtoV2 {
|
||||
/// Session ID for the file transfer.
|
||||
pub session_id: String,
|
||||
|
||||
/// Map of file ID to file token.
|
||||
/// Only contains files that were accepted by the receiver.
|
||||
pub files: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Prepare download response DTO for v2.1 protocol (Download API).
|
||||
///
|
||||
/// Response from POST /api/localsend/v2/prepare-download.
|
||||
/// Used when the sender provides files for others to download.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrepareDownloadResponseDtoV2 {
|
||||
/// Sender's device information.
|
||||
pub info: InfoResponseDtoV2,
|
||||
|
||||
/// Session ID for the download session.
|
||||
pub session_id: String,
|
||||
|
||||
/// Map of file ID to file metadata.
|
||||
pub files: HashMap<String, FileDto>,
|
||||
}
|
||||
|
||||
/// Info response DTO for v2.1 protocol.
|
||||
///
|
||||
/// Response from GET /api/localsend/v2/info.
|
||||
/// Also used as the `info` field in PrepareDownloadResponseDtoV2.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InfoResponseDtoV2 {
|
||||
/// The display name of the device.
|
||||
pub alias: String,
|
||||
|
||||
/// Protocol version (e.g., "2.0", "2.1").
|
||||
pub version: String,
|
||||
|
||||
/// Device model. Optional.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_model: Option<String>,
|
||||
|
||||
/// Device type category. Optional.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_type: Option<DeviceType>,
|
||||
|
||||
/// Fingerprint for device identification.
|
||||
pub fingerprint: String,
|
||||
|
||||
/// Whether the download API (sections 5.2, 5.3) is active.
|
||||
#[serde(default)]
|
||||
pub download: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_multicast_message_serialization() {
|
||||
let msg = MulticastMessageV2 {
|
||||
alias: "Nice Orange".to_string(),
|
||||
version: "2.1".to_string(),
|
||||
device_model: Some("Samsung".to_string()),
|
||||
device_type: Some(DeviceType::Mobile),
|
||||
fingerprint: "random string".to_string(),
|
||||
port: 53317,
|
||||
protocol: ProtocolTypeV2::Https,
|
||||
download: true,
|
||||
announce: true,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert!(json.contains("\"alias\":\"Nice Orange\""));
|
||||
assert!(json.contains("\"version\":\"2.1\""));
|
||||
assert!(json.contains("\"fingerprint\":\"random string\""));
|
||||
assert!(json.contains("\"announce\":true"));
|
||||
assert!(json.contains("\"download\":true"));
|
||||
assert!(json.contains("\"protocol\":\"https\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_dto_v2_deserialization() {
|
||||
let json = r#"{
|
||||
"alias": "Secret Banana",
|
||||
"version": "2.0",
|
||||
"deviceModel": "Windows",
|
||||
"deviceType": "DESKTOP",
|
||||
"fingerprint": "random string",
|
||||
"port": 53317,
|
||||
"protocol": "https",
|
||||
"download": true
|
||||
}"#;
|
||||
|
||||
let dto: RegisterDtoV2 = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(dto.alias, "Secret Banana");
|
||||
assert_eq!(dto.version, "2.0");
|
||||
assert_eq!(dto.device_model, Some("Windows".to_string()));
|
||||
assert_eq!(dto.device_type, Some(DeviceType::Desktop));
|
||||
assert_eq!(dto.fingerprint, "random string");
|
||||
assert_eq!(dto.port, 53317);
|
||||
assert_eq!(dto.protocol, ProtocolTypeV2::Https);
|
||||
assert!(dto.download);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_response_without_download_field() {
|
||||
// Test that download defaults to false when not present
|
||||
let json = r#"{
|
||||
"alias": "Test Device",
|
||||
"version": "2.0",
|
||||
"fingerprint": "abc123"
|
||||
}"#;
|
||||
|
||||
let dto: RegisterResponseDtoV2 = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(dto.alias, "Test Device");
|
||||
assert!(!dto.download);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prepare_upload_request_v2() {
|
||||
let request = PrepareUploadRequestDtoV2 {
|
||||
info: RegisterDtoV2 {
|
||||
alias: "Sender".to_string(),
|
||||
version: "2.1".to_string(),
|
||||
device_model: None,
|
||||
device_type: None,
|
||||
fingerprint: "sender-fingerprint".to_string(),
|
||||
port: 53317,
|
||||
protocol: ProtocolTypeV2::Https,
|
||||
download: false,
|
||||
},
|
||||
files: HashMap::from([(
|
||||
"file1".to_string(),
|
||||
FileDto {
|
||||
id: "file1".to_string(),
|
||||
file_name: "test.png".to_string(),
|
||||
size: 1024,
|
||||
file_type: "image/png".to_string(),
|
||||
sha256: None,
|
||||
preview: None,
|
||||
metadata: None,
|
||||
},
|
||||
)]),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
assert!(json.contains("\"info\""));
|
||||
assert!(json.contains("\"files\""));
|
||||
assert!(json.contains("\"fingerprint\":\"sender-fingerprint\""));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use thiserror::Error;
|
||||
|
||||
pub mod client;
|
||||
pub mod dto;
|
||||
pub mod dto_v2;
|
||||
pub mod server;
|
||||
pub mod state;
|
||||
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ mod util;
|
||||
mod webrtc;
|
||||
|
||||
use crate::crypto::token;
|
||||
use crate::http::client::LsHttpClient;
|
||||
use crate::http::client::LsHttpClientV3;
|
||||
use crate::http::dto::{PrepareUploadRequestDto, ProtocolType, RegisterDto};
|
||||
use crate::http::server::TlsConfig;
|
||||
use crate::model::discovery::DeviceType;
|
||||
@@ -158,7 +158,7 @@ async fn server_test() -> Result<()> {
|
||||
}
|
||||
|
||||
async fn client_test() -> Result<()> {
|
||||
let client = LsHttpClient::try_new(PRIVATE_KEY, CERT)?;
|
||||
let client = LsHttpClientV3::try_new(PRIVATE_KEY, CERT)?;
|
||||
|
||||
let nonce = client
|
||||
.nonce(&ProtocolType::Https, "localhost", 53317)
|
||||
|
||||
@@ -443,7 +443,7 @@ mod tests {
|
||||
"alias": "Cute Apple",
|
||||
"version": "2.3",
|
||||
"deviceModel": "Dell",
|
||||
"deviceType": "desktop",
|
||||
"deviceType": "DESKTOP",
|
||||
"token": "123"
|
||||
},
|
||||
"peers": []
|
||||
@@ -480,7 +480,7 @@ mod tests {
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"alias": "Cute Apple",
|
||||
"version": "2.3",
|
||||
"deviceType": "desktop",
|
||||
"deviceType": "DESKTOP",
|
||||
"token": "123"
|
||||
},
|
||||
"sessionId": "456",
|
||||
@@ -515,7 +515,7 @@ mod tests {
|
||||
"alias": "Cute Apple",
|
||||
"version": "2.3",
|
||||
"deviceModel": "Dell",
|
||||
"deviceType": "desktop",
|
||||
"deviceType": "DESKTOP",
|
||||
"token": "123"
|
||||
}
|
||||
}"#
|
||||
|
||||
Reference in New Issue
Block a user