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::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 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 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 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(¶ms.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 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
354fn 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