1pub 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#[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#[derive(Clone, Debug)]
61pub struct LsClientConfig {
62 pub timeout: Duration,
64 pub connect_timeout: Duration,
66 pub tls_identity: Option<TlsIdentity>,
68 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#[derive(Clone)]
85pub struct LsClient {
86 client: Client,
87 target_fingerprint: Option<String>,
88}
89
90impl LsClient {
91 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 let dummy_cert_pem = match &config.tls_identity {
101 Some(ident) => ident.cert_pem.clone(),
102 None => {
103 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 pub fn inner(&self) -> &Client {
145 &self.client
146 }
147
148 pub fn target_fingerprint(&self) -> Option<&str> {
150 self.target_fingerprint.as_deref()
151 }
152
153 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 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 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 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 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 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(¶ms.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 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
353fn 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