Skip to main content

lanflare_core/client/
mod.rs

1//! HTTP/HTTPS Client & Transfer Orchestrator implementing LocalSend v2.2 specification.
2
3pub mod transfer;
4
5use std::sync::Arc;
6use std::time::Duration;
7use reqwest::Client;
8use rustls::pki_types::pem::PemObject;
9use rustls::pki_types::{CertificateDer, PrivateKeyDer};
10use thiserror::Error;
11
12use lanflare_proto::discovery::{InfoResponseDtoV2, RegisterDtoV2, RegisterResponseDtoV2};
13use lanflare_proto::transfer::{
14    PrepareDownloadResponseDtoV2, PrepareUploadRequestDtoV2, PrepareUploadResponseDtoV2,
15};
16use crate::crypto::cert::TlsIdentity;
17use crate::crypto::verifier::PinnedServerCertVerifier;
18
19/// Errors arising from LocalSend client operations.
20#[derive(Debug, Error)]
21pub enum ClientError {
22    #[error("Network / HTTP request error: {0}")]
23    Reqwest(#[from] reqwest::Error),
24
25    #[error("TLS configuration error: {0}")]
26    Tls(String),
27
28    #[error("Server returned rejection or error HTTP status: {status} ({body})")]
29    ServerError {
30        status: reqwest::StatusCode,
31        body: String,
32    },
33
34    #[error("Session preparation was rejected by the receiver")]
35    SessionRejected,
36
37    #[error("Recipient required a PIN or provided PIN was invalid (HTTP 401 Unauthorized)")]
38    UnauthorizedPin,
39
40    #[error("Recipient PIN attempts exceeded; rate limited (HTTP 429 Too Many Requests)")]
41    PinRateLimited,
42
43    #[error("Recipient transfer slot is busy with another session (HTTP 409 Conflict)")]
44    SessionConflict,
45
46    #[error("File upload failed: HTTP {status} ({reason})")]
47    UploadFailed {
48        status: reqwest::StatusCode,
49        reason: String,
50    },
51
52    #[error("I/O error reading file: {0}")]
53    Io(#[from] std::io::Error),
54
55    #[error("Invalid URL: {0}")]
56    InvalidUrl(String),
57}
58
59/// Configuration options for building an [`LsClient`].
60#[derive(Clone, Debug)]
61pub struct LsClientConfig {
62    /// Client timeout for requests (default: 30s).
63    pub timeout: Duration,
64    /// Connect timeout (default: 5s).
65    pub connect_timeout: Duration,
66    /// Local TLS identity used for mTLS client authentication.
67    pub tls_identity: Option<TlsIdentity>,
68    /// Pinned SHA-256 server certificate fingerprint (expected from receiver).
69    pub target_fingerprint: Option<String>,
70}
71
72impl Default for LsClientConfig {
73    fn default() -> Self {
74        Self {
75            timeout: Duration::from_secs(30),
76            connect_timeout: Duration::from_secs(5),
77            tls_identity: None,
78            target_fingerprint: None,
79        }
80    }
81}
82
83/// HTTP/HTTPS client tailored for the LocalSend v2.2 wire protocol.
84#[derive(Clone)]
85pub struct LsClient {
86    client: Client,
87    target_fingerprint: Option<String>,
88}
89
90impl LsClient {
91    /// Constructs a new [`LsClient`] with the provided configuration.
92    pub fn new(config: LsClientConfig) -> Result<Self, ClientError> {
93        let mut builder = Client::builder()
94            .timeout(config.timeout)
95            .connect_timeout(config.connect_timeout)
96            .tcp_nodelay(true);
97
98        if config.tls_identity.is_some() || config.target_fingerprint.is_some() {
99            // Build custom rustls ClientConfig with PinnedServerCertVerifier
100            let dummy_cert_pem = match &config.tls_identity {
101                Some(ident) => ident.cert_pem.clone(),
102                None => {
103                    // Generate minimal ephemeral self-signed cert to populate root store
104                    let ephemeral = crate::crypto::cert::generate_self_signed()
105                        .map_err(|e| ClientError::Tls(e.to_string()))?;
106                    ephemeral.cert_pem
107                }
108            };
109
110            let server_verifier = Arc::new(
111                PinnedServerCertVerifier::new(&dummy_cert_pem, config.target_fingerprint.clone())
112                    .map_err(|e| ClientError::Tls(e.to_string()))?,
113            );
114
115            let rustls_builder = rustls::ClientConfig::builder()
116                .dangerous()
117                .with_custom_certificate_verifier(server_verifier);
118
119            let rustls_config = match &config.tls_identity {
120                Some(ident) => {
121                    let cert_der = CertificateDer::from_pem_slice(ident.cert_pem.as_bytes())
122                        .map_err(|e| ClientError::Tls(format!("Failed to parse client cert DER: {e}")))?;
123                    let key_der = PrivateKeyDer::from_pem_slice(ident.key_pem.as_bytes())
124                        .map_err(|e| ClientError::Tls(format!("Failed to parse client key DER: {e}")))?;
125
126                    rustls_builder
127                        .with_client_auth_cert(vec![cert_der], key_der)
128                        .map_err(|e| ClientError::Tls(format!("Failed to set client auth cert: {e}")))?
129                }
130                None => rustls_builder.with_no_client_auth(),
131            };
132
133            builder = builder.use_preconfigured_tls(rustls_config);
134        }
135
136        let client = builder.build().map_err(ClientError::Reqwest)?;
137        Ok(Self {
138            client,
139            target_fingerprint: config.target_fingerprint,
140        })
141    }
142
143    /// Access the underlying `reqwest::Client`.
144    pub fn inner(&self) -> &Client {
145        &self.client
146    }
147
148    /// The pinned target fingerprint, if any.
149    pub fn target_fingerprint(&self) -> Option<&str> {
150        self.target_fingerprint.as_deref()
151    }
152
153    /// Fetches remote peer info via `GET /api/localsend/v2/info` (or `/api/localsend/v1/info`).
154    pub async fn get_info(&self, base_url: &str) -> Result<InfoResponseDtoV2, ClientError> {
155        let url = format!("{}/api/localsend/v2/info", base_url.trim_end_matches('/'));
156        let resp = self.client.get(&url).send().await?;
157
158        if !resp.status().is_success() {
159            let status = resp.status();
160            let body = resp.text().await.unwrap_or_default();
161            return Err(ClientError::ServerError { status, body });
162        }
163
164        let info: InfoResponseDtoV2 = resp.json().await?;
165        Ok(info)
166    }
167
168    /// Registers this device with the remote peer via `POST /api/localsend/v2/register`.
169    pub async fn register(
170        &self,
171        base_url: &str,
172        dto: &RegisterDtoV2,
173    ) -> Result<RegisterResponseDtoV2, ClientError> {
174        let url = format!("{}/api/localsend/v2/register", base_url.trim_end_matches('/'));
175        let resp = self.client.post(&url).json(dto).send().await?;
176
177        if !resp.status().is_success() {
178            let status = resp.status();
179            let body = resp.text().await.unwrap_or_default();
180            return Err(ClientError::ServerError { status, body });
181        }
182
183        let reg_resp: RegisterResponseDtoV2 = resp.json().await?;
184        Ok(reg_resp)
185    }
186
187    /// Initiates a transfer session via `POST /api/localsend/v2/prepare-upload`.
188    pub async fn prepare_upload(
189        &self,
190        base_url: &str,
191        request: &PrepareUploadRequestDtoV2,
192        pin: Option<&str>,
193    ) -> Result<PrepareUploadResponseDtoV2, ClientError> {
194        let mut url = format!("{}/api/localsend/v2/prepare-upload", base_url.trim_end_matches('/'));
195        if let Some(p) = pin {
196            url.push_str("?pin=");
197            url.push_str(&urlencoding(p));
198        }
199
200        let resp = self.client.post(&url).json(request).send().await?;
201        let status = resp.status();
202
203        match status {
204            reqwest::StatusCode::OK => {
205                let prep_resp: PrepareUploadResponseDtoV2 = resp.json().await?;
206                if prep_resp.files.is_empty() {
207                    return Err(ClientError::SessionRejected);
208                }
209                Ok(prep_resp)
210            }
211            reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
212                Err(ClientError::UnauthorizedPin)
213            }
214            reqwest::StatusCode::TOO_MANY_REQUESTS => Err(ClientError::PinRateLimited),
215            reqwest::StatusCode::CONFLICT => Err(ClientError::SessionConflict),
216            _ => {
217                let body = resp.text().await.unwrap_or_default();
218                Err(ClientError::ServerError { status, body })
219            }
220        }
221    }
222
223    /// Uploads an individual file stream via `POST /api/localsend/v2/upload?sessionId=...&fileId=...&token=...`.
224    pub async fn upload_file<B>(
225        &self,
226        base_url: &str,
227        session_id: &str,
228        file_id: &str,
229        token: &str,
230        body: B,
231        content_length: u64,
232    ) -> Result<(), ClientError>
233    where
234        B: Into<reqwest::Body>,
235    {
236        let url = format!(
237            "{}/api/localsend/v2/upload?sessionId={}&fileId={}&token={}",
238            base_url.trim_end_matches('/'),
239            urlencoding(session_id),
240            urlencoding(file_id),
241            urlencoding(token)
242        );
243
244        let resp = self
245            .client
246            .post(&url)
247            .header(reqwest::header::CONTENT_LENGTH, content_length)
248            .header(
249                reqwest::header::CONTENT_TYPE,
250                "application/octet-stream",
251            )
252            .body(body)
253            .send()
254            .await?;
255
256        let status = resp.status();
257        if status.is_success() {
258            Ok(())
259        } else {
260            let reason = resp.text().await.unwrap_or_default();
261            Err(ClientError::UploadFailed { status, reason })
262        }
263    }
264
265    /// Cancels an active or pending session via `POST /api/localsend/v2/cancel?sessionId=...`.
266    pub async fn cancel_session(
267        &self,
268        base_url: &str,
269        session_id: &str,
270    ) -> Result<(), ClientError> {
271        let url = format!(
272            "{}/api/localsend/v2/cancel?sessionId={}",
273            base_url.trim_end_matches('/'),
274            urlencoding(session_id)
275        );
276
277        let resp = self.client.post(&url).send().await?;
278        let status = resp.status();
279        if status.is_success() {
280            Ok(())
281        } else {
282            let body = resp.text().await.unwrap_or_default();
283            Err(ClientError::ServerError { status, body })
284        }
285    }
286
287    /// Prepares to download files from a sender hosting them (Browser Download / Reverse Transfer API).
288    /// Calls `POST /api/localsend/v2/prepare-download`.
289    pub async fn prepare_download(
290        &self,
291        base_url: &str,
292        session_id: Option<&str>,
293        pin: Option<&str>,
294    ) -> Result<PrepareDownloadResponseDtoV2, ClientError> {
295        let mut url = format!("{}/api/localsend/v2/prepare-download", base_url.trim_end_matches('/'));
296        let mut params = Vec::new();
297        if let Some(sid) = session_id {
298            params.push(format!("sessionId={}", urlencoding(sid)));
299        }
300        if let Some(p) = pin {
301            params.push(format!("pin={}", urlencoding(p)));
302        }
303        if !params.is_empty() {
304            url.push('?');
305            url.push_str(&params.join("&"));
306        }
307
308        let resp = self.client.post(&url).send().await?;
309        let status = resp.status();
310
311        match status {
312            reqwest::StatusCode::OK => {
313                let prep_resp: PrepareDownloadResponseDtoV2 = resp.json().await?;
314                Ok(prep_resp)
315            }
316            reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
317                Err(ClientError::UnauthorizedPin)
318            }
319            reqwest::StatusCode::TOO_MANY_REQUESTS => Err(ClientError::PinRateLimited),
320            _ => {
321                let body = resp.text().await.unwrap_or_default();
322                Err(ClientError::ServerError { status, body })
323            }
324        }
325    }
326
327    /// Downloads a file binary stream from a sender hosting it (Reverse Transfer API).
328    /// Calls `GET /api/localsend/v2/download?sessionId=...&fileId=...`.
329    pub async fn download_file(
330        &self,
331        base_url: &str,
332        session_id: &str,
333        file_id: &str,
334    ) -> Result<reqwest::Response, ClientError> {
335        let url = format!(
336            "{}/api/localsend/v2/download?sessionId={}&fileId={}",
337            base_url.trim_end_matches('/'),
338            urlencoding(session_id),
339            urlencoding(file_id)
340        );
341
342        let resp = self.client.get(&url).send().await?;
343        let status = resp.status();
344
345        if status.is_success() {
346            Ok(resp)
347        } else {
348            let body = resp.text().await.unwrap_or_default();
349            Err(ClientError::ServerError { status, body })
350        }
351    }
352}
353
354/// Minimal percent-encoding helper for query params without pulling extra dependencies.
355fn urlencoding(input: &str) -> String {
356    let mut out = String::with_capacity(input.len());
357    for b in input.bytes() {
358        match b {
359            b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
360                out.push(b as char);
361            }
362            _ => {
363                out.push_str(&format!("%{:02X}", b));
364            }
365        }
366    }
367    out
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[tokio::test]
375    async fn test_ls_client_builder_defaults() {
376        let config = LsClientConfig::default();
377        let client = LsClient::new(config).expect("Default client should build cleanly");
378        assert!(client.target_fingerprint().is_none());
379    }
380
381    #[tokio::test]
382    async fn test_ls_client_with_tls_identity_and_pinned_fingerprint() {
383        let ident = crate::crypto::cert::generate_self_signed().expect("Cert generation should succeed");
384        let fp = ident.fingerprint.clone();
385
386        let config = LsClientConfig {
387            timeout: Duration::from_secs(10),
388            connect_timeout: Duration::from_secs(3),
389            tls_identity: Some(ident),
390            target_fingerprint: Some(fp.clone()),
391        };
392
393        let client = LsClient::new(config).expect("Client with TLS and fingerprint should build cleanly");
394        assert_eq!(client.target_fingerprint(), Some(fp.as_str()));
395    }
396
397    #[test]
398    fn test_urlencoding() {
399        assert_eq!(urlencoding("hello world"), "hello%20world");
400        assert_eq!(urlencoding("abc_123.-~"), "abc_123.-~");
401        assert_eq!(urlencoding("session/123?x=1"), "session%2F123%3Fx%3D1");
402    }
403}
404