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::FORBIDDEN => Err(ClientError::SessionRejected),
212            reqwest::StatusCode::UNAUTHORIZED => Err(ClientError::UnauthorizedPin),
213            reqwest::StatusCode::TOO_MANY_REQUESTS => Err(ClientError::PinRateLimited),
214            reqwest::StatusCode::CONFLICT => Err(ClientError::SessionConflict),
215            _ => {
216                let body = resp.text().await.unwrap_or_default();
217                Err(ClientError::ServerError { status, body })
218            }
219        }
220    }
221
222    /// Uploads an individual file stream via `POST /api/localsend/v2/upload?sessionId=...&fileId=...&token=...`.
223    pub async fn upload_file<B>(
224        &self,
225        base_url: &str,
226        session_id: &str,
227        file_id: &str,
228        token: &str,
229        body: B,
230        content_length: u64,
231    ) -> Result<(), ClientError>
232    where
233        B: Into<reqwest::Body>,
234    {
235        let url = format!(
236            "{}/api/localsend/v2/upload?sessionId={}&fileId={}&token={}",
237            base_url.trim_end_matches('/'),
238            urlencoding(session_id),
239            urlencoding(file_id),
240            urlencoding(token)
241        );
242
243        let resp = self
244            .client
245            .post(&url)
246            .header(reqwest::header::CONTENT_LENGTH, content_length)
247            .header(
248                reqwest::header::CONTENT_TYPE,
249                "application/octet-stream",
250            )
251            .body(body)
252            .send()
253            .await?;
254
255        let status = resp.status();
256        if status.is_success() {
257            Ok(())
258        } else {
259            let reason = resp.text().await.unwrap_or_default();
260            Err(ClientError::UploadFailed { status, reason })
261        }
262    }
263
264    /// Cancels an active or pending session via `POST /api/localsend/v2/cancel?sessionId=...`.
265    pub async fn cancel_session(
266        &self,
267        base_url: &str,
268        session_id: &str,
269    ) -> Result<(), ClientError> {
270        let url = format!(
271            "{}/api/localsend/v2/cancel?sessionId={}",
272            base_url.trim_end_matches('/'),
273            urlencoding(session_id)
274        );
275
276        let resp = self.client.post(&url).send().await?;
277        let status = resp.status();
278        if status.is_success() {
279            Ok(())
280        } else {
281            let body = resp.text().await.unwrap_or_default();
282            Err(ClientError::ServerError { status, body })
283        }
284    }
285
286    /// Prepares to download files from a sender hosting them (Browser Download / Reverse Transfer API).
287    /// Calls `POST /api/localsend/v2/prepare-download`.
288    pub async fn prepare_download(
289        &self,
290        base_url: &str,
291        session_id: Option<&str>,
292        pin: Option<&str>,
293    ) -> Result<PrepareDownloadResponseDtoV2, ClientError> {
294        let mut url = format!("{}/api/localsend/v2/prepare-download", base_url.trim_end_matches('/'));
295        let mut params = Vec::new();
296        if let Some(sid) = session_id {
297            params.push(format!("sessionId={}", urlencoding(sid)));
298        }
299        if let Some(p) = pin {
300            params.push(format!("pin={}", urlencoding(p)));
301        }
302        if !params.is_empty() {
303            url.push('?');
304            url.push_str(&params.join("&"));
305        }
306
307        let resp = self.client.post(&url).send().await?;
308        let status = resp.status();
309
310        match status {
311            reqwest::StatusCode::OK => {
312                let prep_resp: PrepareDownloadResponseDtoV2 = resp.json().await?;
313                Ok(prep_resp)
314            }
315            reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
316                Err(ClientError::UnauthorizedPin)
317            }
318            reqwest::StatusCode::TOO_MANY_REQUESTS => Err(ClientError::PinRateLimited),
319            _ => {
320                let body = resp.text().await.unwrap_or_default();
321                Err(ClientError::ServerError { status, body })
322            }
323        }
324    }
325
326    /// Downloads a file binary stream from a sender hosting it (Reverse Transfer API).
327    /// Calls `GET /api/localsend/v2/download?sessionId=...&fileId=...`.
328    pub async fn download_file(
329        &self,
330        base_url: &str,
331        session_id: &str,
332        file_id: &str,
333    ) -> Result<reqwest::Response, ClientError> {
334        let url = format!(
335            "{}/api/localsend/v2/download?sessionId={}&fileId={}",
336            base_url.trim_end_matches('/'),
337            urlencoding(session_id),
338            urlencoding(file_id)
339        );
340
341        let resp = self.client.get(&url).send().await?;
342        let status = resp.status();
343
344        if status.is_success() {
345            Ok(resp)
346        } else {
347            let body = resp.text().await.unwrap_or_default();
348            Err(ClientError::ServerError { status, body })
349        }
350    }
351}
352
353/// Minimal percent-encoding helper for query params without pulling extra dependencies.
354fn urlencoding(input: &str) -> String {
355    let mut out = String::with_capacity(input.len());
356    for b in input.bytes() {
357        match b {
358            b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
359                out.push(b as char);
360            }
361            _ => {
362                out.push_str(&format!("%{:02X}", b));
363            }
364        }
365    }
366    out
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[tokio::test]
374    async fn test_ls_client_builder_defaults() {
375        let config = LsClientConfig::default();
376        let client = LsClient::new(config).expect("Default client should build cleanly");
377        assert!(client.target_fingerprint().is_none());
378    }
379
380    #[tokio::test]
381    async fn test_ls_client_with_tls_identity_and_pinned_fingerprint() {
382        let ident = crate::crypto::cert::generate_self_signed().expect("Cert generation should succeed");
383        let fp = ident.fingerprint.clone();
384
385        let config = LsClientConfig {
386            timeout: Duration::from_secs(10),
387            connect_timeout: Duration::from_secs(3),
388            tls_identity: Some(ident),
389            target_fingerprint: Some(fp.clone()),
390        };
391
392        let client = LsClient::new(config).expect("Client with TLS and fingerprint should build cleanly");
393        assert_eq!(client.target_fingerprint(), Some(fp.as_str()));
394    }
395
396    #[test]
397    fn test_urlencoding() {
398        assert_eq!(urlencoding("hello world"), "hello%20world");
399        assert_eq!(urlencoding("abc_123.-~"), "abc_123.-~");
400        assert_eq!(urlencoding("session/123?x=1"), "session%2F123%3Fx%3D1");
401    }
402}
403