1use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use futures_util::stream::Stream;
17use reqwest::Body;
18use sha2::{Digest, Sha256};
19use tokio::fs::File;
20use tokio::io::AsyncReadExt;
21use tokio::sync::{mpsc, Semaphore};
22use tokio_util::io::ReaderStream;
23use tokio_util::sync::CancellationToken;
24use uuid::Uuid;
25
26use lanflare_proto::discovery::RegisterDtoV2;
27use lanflare_proto::transfer::{FileDto, FileMetadataDto, PrepareUploadRequestDtoV2};
28
29use crate::client::{ClientError, LsClient};
30
31pub const DEFAULT_STREAM_CHUNK_SIZE: usize = 128 * 1024;
33
34#[derive(Clone, Debug)]
36pub struct OutgoingFile {
37 pub id: String,
39 pub path: PathBuf,
41 pub file_name: String,
43 pub size: u64,
45 pub file_type: String,
47 pub sha256: Option<String>,
49}
50
51impl OutgoingFile {
52 pub async fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, ClientError> {
56 let p = path.as_ref().to_path_buf();
57 let meta = tokio::fs::metadata(&p).await?;
58
59 let file_name = p
60 .file_name()
61 .and_then(|n| n.to_str())
62 .unwrap_or("file.bin")
63 .to_string();
64
65 let file_type = mime_guess_simple(&file_name);
66
67 Ok(Self {
68 id: Uuid::new_v4().to_string(),
69 path: p,
70 file_name,
71 size: meta.len(),
72 file_type,
73 sha256: None,
74 })
75 }
76
77 pub async fn from_path_with_name<P: AsRef<Path>>(
79 path: P,
80 file_name: String,
81 ) -> Result<Self, ClientError> {
82 let p = path.as_ref().to_path_buf();
83 let meta = tokio::fs::metadata(&p).await?;
84 let file_type = mime_guess_simple(&file_name);
85
86 Ok(Self {
87 id: Uuid::new_v4().to_string(),
88 path: p,
89 file_name,
90 size: meta.len(),
91 file_type,
92 sha256: None,
93 })
94 }
95
96 pub async fn with_computed_sha256(mut self) -> Result<Self, ClientError> {
98 let mut file = File::open(&self.path).await?;
99 let mut hasher = Sha256::new();
100 let mut buffer = vec![0u8; DEFAULT_STREAM_CHUNK_SIZE];
101
102 loop {
103 let n = file.read(&mut buffer).await?;
104 if n == 0 {
105 break;
106 }
107 hasher.update(&buffer[..n]);
108 }
109
110 let hash_bytes = hasher.finalize();
111 self.sha256 = Some(format!("{:x}", hash_bytes));
112 Ok(self)
113 }
114
115 pub fn to_dto(&self) -> FileDto {
117 FileDto {
118 id: self.id.clone(),
119 file_name: self.file_name.clone(),
120 size: self.size,
121 file_type: self.file_type.clone(),
122 sha256: self.sha256.clone(),
123 preview: None,
124 metadata: Some(FileMetadataDto {
125 modified: None,
126 accessed: None,
127 }),
128 }
129 }
130}
131
132pub fn mime_guess_simple(file_name: &str) -> String {
134 let lower = file_name.to_ascii_lowercase();
135 if lower.ends_with(".png") {
136 "image/png".to_string()
137 } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
138 "image/jpeg".to_string()
139 } else if lower.ends_with(".gif") {
140 "image/gif".to_string()
141 } else if lower.ends_with(".webp") {
142 "image/webp".to_string()
143 } else if lower.ends_with(".mp4") {
144 "video/mp4".to_string()
145 } else if lower.ends_with(".mkv") {
146 "video/x-matroska".to_string()
147 } else if lower.ends_with(".mp3") {
148 "audio/mpeg".to_string()
149 } else if lower.ends_with(".pdf") {
150 "application/pdf".to_string()
151 } else if lower.ends_with(".zip") {
152 "application/zip".to_string()
153 } else if lower.ends_with(".tar") || lower.ends_with(".gz") {
154 "application/gzip".to_string()
155 } else if lower.ends_with(".txt") {
156 "text/plain".to_string()
157 } else if lower.ends_with(".json") {
158 "application/json".to_string()
159 } else {
160 "application/octet-stream".to_string()
161 }
162}
163
164#[derive(Clone, Debug)]
166pub enum TransferProgress {
167 Started {
169 session_id: String,
170 accepted_files: Vec<String>,
171 total_bytes: u64,
172 },
173 FileProgress {
175 session_id: String,
176 file_id: String,
177 file_name: String,
178 file_bytes_sent: u64,
179 file_total_bytes: u64,
180 session_bytes_sent: u64,
181 session_total_bytes: u64,
182 bytes_per_second: u64,
184 eta: Option<Duration>,
186 },
187 FileFinished {
189 session_id: String,
190 file_id: String,
191 file_name: String,
192 },
193 FileFailed {
195 session_id: String,
196 file_id: String,
197 file_name: String,
198 error: String,
199 },
200 SessionCompleted {
202 session_id: String,
203 total_files: usize,
204 total_bytes: u64,
205 duration: Duration,
206 },
207 SessionCancelled {
209 session_id: String,
210 reason: String,
211 },
212}
213
214#[derive(Clone, Debug)]
216pub struct TransferOptions {
217 pub max_concurrency: usize,
219 pub chunk_size: usize,
221 pub pin: Option<String>,
223 pub cancel_token: Option<CancellationToken>,
225 pub progress_interval: Duration,
227}
228
229impl Default for TransferOptions {
230 fn default() -> Self {
231 Self {
232 max_concurrency: 4,
233 chunk_size: DEFAULT_STREAM_CHUNK_SIZE,
234 pin: None,
235 cancel_token: None,
236 progress_interval: Duration::from_millis(100),
237 }
238 }
239}
240
241pub struct TransferOrchestrator {
243 client: LsClient,
244 sender_info: RegisterDtoV2,
245}
246
247impl TransferOrchestrator {
248 pub fn new(client: LsClient, sender_info: RegisterDtoV2) -> Self {
250 Self {
251 client,
252 sender_info,
253 }
254 }
255
256 pub async fn transfer(
260 &self,
261 base_url: &str,
262 files: Vec<OutgoingFile>,
263 options: TransferOptions,
264 progress_tx: Option<mpsc::Sender<TransferProgress>>,
265 ) -> Result<(), ClientError> {
266 if files.is_empty() {
267 return Ok(());
268 }
269
270 let cancel_token = options
271 .cancel_token
272 .unwrap_or_default();
273
274 let mut dler_files = HashMap::new();
276 let mut files_by_id = HashMap::new();
277
278 for file in files {
279 dler_files.insert(file.id.clone(), file.to_dto());
280 files_by_id.insert(file.id.clone(), file);
281 }
282
283 let prep_req = PrepareUploadRequestDtoV2 {
284 info: self.sender_info.clone(),
285 files: dler_files,
286 };
287
288 tokio::select! {
290 _ = cancel_token.cancelled() => {
291 Err(ClientError::SessionRejected)
292 }
293 res = self.client.prepare_upload(base_url, &prep_req, options.pin.as_deref()) => {
294 let prep_resp = res?;
295 self.run_upload_loop(
296 base_url,
297 prep_resp.session_id,
298 prep_resp.files,
299 files_by_id,
300 options.max_concurrency,
301 options.progress_interval,
302 options.chunk_size,
303 cancel_token,
304 progress_tx,
305 ).await
306 }
307 }
308 }
309
310 #[allow(clippy::too_many_arguments)]
311 async fn run_upload_loop(
312 &self,
313 base_url: &str,
314 session_id: String,
315 accepted_tokens: HashMap<String, String>,
316 mut files_by_id: HashMap<String, OutgoingFile>,
317 concurrency: usize,
318 progress_interval: Duration,
319 chunk_size: usize,
320 cancel_token: CancellationToken,
321 progress_tx: Option<mpsc::Sender<TransferProgress>>,
322 ) -> Result<(), ClientError> {
323 let start_time = Instant::now();
324
325 let accepted_file_ids: Vec<String> = accepted_tokens.keys().cloned().collect();
327 let mut session_total_bytes: u64 = 0;
328 for file_id in &accepted_file_ids {
329 if let Some(f) = files_by_id.get(file_id) {
330 session_total_bytes += f.size;
331 }
332 }
333
334 if let Some(ref tx) = progress_tx {
335 let _ = tx
336 .send(TransferProgress::Started {
337 session_id: session_id.clone(),
338 accepted_files: accepted_file_ids.clone(),
339 total_bytes: session_total_bytes,
340 })
341 .await;
342 }
343
344 let session_bytes_counter = Arc::new(AtomicU64::new(0));
345 let semaphore = Arc::new(Semaphore::new(concurrency.max(1)));
346 let mut join_set = tokio::task::JoinSet::new();
347
348 for (file_id, token) in accepted_tokens {
349 let Some(file) = files_by_id.remove(&file_id) else {
350 continue;
351 };
352
353 let client = self.client.clone();
354 let base_url = base_url.to_string();
355 let session_id = session_id.clone();
356 let sem = Arc::clone(&semaphore);
357 let cancel = cancel_token.clone();
358 let tx = progress_tx.clone();
359 let session_bytes = Arc::clone(&session_bytes_counter);
360
361 join_set.spawn(async move {
362 let _permit = sem.acquire().await.unwrap();
363
364 if cancel.is_cancelled() {
365 return Err(ClientError::SessionRejected);
366 }
367
368 upload_single_file(
369 client,
370 base_url,
371 session_id,
372 file,
373 token,
374 session_bytes,
375 session_total_bytes,
376 start_time,
377 progress_interval,
378 chunk_size,
379 cancel,
380 tx,
381 )
382 .await
383 });
384 }
385
386 let mut first_error: Option<ClientError> = None;
387
388 while let Some(res) = join_set.join_next().await {
389 match res {
390 Ok(Ok(())) => {}
391 Ok(Err(err)) => {
392 if first_error.is_none() {
393 first_error = Some(err);
394 cancel_token.cancel();
396 }
397 }
398 Err(join_err) => {
399 if first_error.is_none() {
400 first_error = Some(ClientError::Tls(format!("Task join error: {join_err}")));
401 cancel_token.cancel();
402 }
403 }
404 }
405 }
406
407 if let Some(err) = first_error {
408 let _ = self.client.cancel_session(base_url, &session_id).await;
410
411 if let Some(ref tx) = progress_tx {
412 let _ = tx
413 .send(TransferProgress::SessionCancelled {
414 session_id: session_id.clone(),
415 reason: err.to_string(),
416 })
417 .await;
418 }
419 return Err(err);
420 }
421
422 if cancel_token.is_cancelled() {
423 let _ = self.client.cancel_session(base_url, &session_id).await;
424 if let Some(ref tx) = progress_tx {
425 let _ = tx
426 .send(TransferProgress::SessionCancelled {
427 session_id: session_id.clone(),
428 reason: "Cancelled by user".to_string(),
429 })
430 .await;
431 }
432 return Err(ClientError::SessionRejected);
433 }
434
435 let duration = start_time.elapsed();
436 if let Some(ref tx) = progress_tx {
437 let _ = tx
438 .send(TransferProgress::SessionCompleted {
439 session_id,
440 total_files: accepted_file_ids.len(),
441 total_bytes: session_total_bytes,
442 duration,
443 })
444 .await;
445 }
446
447 Ok(())
448 }
449}
450
451#[allow(clippy::too_many_arguments)]
453async fn upload_single_file(
454 client: LsClient,
455 base_url: String,
456 session_id: String,
457 file: OutgoingFile,
458 token: String,
459 session_bytes_counter: Arc<AtomicU64>,
460 session_total_bytes: u64,
461 session_start_time: Instant,
462 progress_interval: Duration,
463 chunk_size: usize,
464 cancel_token: CancellationToken,
465 progress_tx: Option<mpsc::Sender<TransferProgress>>,
466) -> Result<(), ClientError> {
467 let file_id = file.id.clone();
468 let file_name = file.file_name.clone();
469 let file_size = file.size;
470
471 let fs_file = File::open(&file.path).await.map_err(ClientError::Io)?;
472 let reader_stream = ReaderStream::with_capacity(fs_file, chunk_size);
473
474 let progress_stream = ProgressStream::new(
475 reader_stream,
476 session_id.clone(),
477 file_id.clone(),
478 file_name.clone(),
479 file_size,
480 session_bytes_counter,
481 session_total_bytes,
482 session_start_time,
483 progress_interval,
484 cancel_token.clone(),
485 progress_tx.clone(),
486 );
487
488 let body = Body::wrap_stream(progress_stream);
489
490 let upload_res = client
491 .upload_file(
492 &base_url,
493 &session_id,
494 &file_id,
495 &token,
496 body,
497 file_size,
498 )
499 .await;
500
501 match upload_res {
502 Ok(()) => {
503 if let Some(ref tx) = progress_tx {
504 let _ = tx
505 .send(TransferProgress::FileFinished {
506 session_id,
507 file_id,
508 file_name,
509 })
510 .await;
511 }
512 Ok(())
513 }
514 Err(err) => {
515 if let Some(ref tx) = progress_tx {
516 let _ = tx
517 .send(TransferProgress::FileFailed {
518 session_id,
519 file_id,
520 file_name,
521 error: err.to_string(),
522 })
523 .await;
524 }
525 Err(err)
526 }
527 }
528}
529
530struct ProgressStream<S> {
532 inner: S,
533 session_id: String,
534 file_id: String,
535 file_name: String,
536 file_total: u64,
537 file_sent: u64,
538 session_bytes: Arc<AtomicU64>,
539 session_total: u64,
540 session_start: Instant,
541 last_progress_emit: Instant,
542 progress_interval: Duration,
543 cancel: CancellationToken,
544 progress_tx: Option<mpsc::Sender<TransferProgress>>,
545}
546
547impl<S> ProgressStream<S> {
548 #[allow(clippy::too_many_arguments)]
549 fn new(
550 inner: S,
551 session_id: String,
552 file_id: String,
553 file_name: String,
554 file_total: u64,
555 session_bytes: Arc<AtomicU64>,
556 session_total: u64,
557 session_start: Instant,
558 progress_interval: Duration,
559 cancel: CancellationToken,
560 progress_tx: Option<mpsc::Sender<TransferProgress>>,
561 ) -> Self {
562 Self {
563 inner,
564 session_id,
565 file_id,
566 file_name,
567 file_total,
568 file_sent: 0,
569 session_bytes,
570 session_total,
571 session_start,
572 last_progress_emit: Instant::now(),
573 progress_interval,
574 cancel,
575 progress_tx,
576 }
577 }
578}
579
580impl<S, B, E> Stream for ProgressStream<S>
581where
582 S: Stream<Item = Result<B, E>> + Unpin,
583 B: bytes::Buf,
584 E: Into<Box<dyn std::error::Error + Send + Sync>>,
585{
586 type Item = Result<B, E>;
587
588 fn poll_next(
589 mut self: std::pin::Pin<&mut Self>,
590 cx: &mut std::task::Context<'_>,
591 ) -> std::task::Poll<Option<Self::Item>> {
592 if self.cancel.is_cancelled() {
593 return std::task::Poll::Ready(None);
594 }
595
596 let this = &mut *self;
597 match std::pin::Pin::new(&mut this.inner).poll_next(cx) {
598 std::task::Poll::Ready(Some(Ok(chunk))) => {
599 let chunk_len = chunk.remaining() as u64;
600 this.file_sent += chunk_len;
601 let cur_session_sent =
602 this.session_bytes.fetch_add(chunk_len, Ordering::Relaxed) + chunk_len;
603
604 let now = Instant::now();
605 if now.duration_since(this.last_progress_emit) >= this.progress_interval {
606 this.last_progress_emit = now;
607
608 let elapsed = now.duration_since(this.session_start).as_secs_f64();
609 let bytes_per_second = if elapsed > 0.0 {
610 (cur_session_sent as f64 / elapsed) as u64
611 } else {
612 0
613 };
614
615 let eta = if bytes_per_second > 0 && cur_session_sent < this.session_total {
616 let remaining_bytes = this.session_total - cur_session_sent;
617 Some(Duration::from_secs_f64(
618 remaining_bytes as f64 / bytes_per_second as f64,
619 ))
620 } else {
621 None
622 };
623
624 if let Some(ref tx) = this.progress_tx {
625 let _ = tx.try_send(TransferProgress::FileProgress {
626 session_id: this.session_id.clone(),
627 file_id: this.file_id.clone(),
628 file_name: this.file_name.clone(),
629 file_bytes_sent: this.file_sent,
630 file_total_bytes: this.file_total,
631 session_bytes_sent: cur_session_sent,
632 session_total_bytes: this.session_total,
633 bytes_per_second,
634 eta,
635 });
636 }
637 }
638
639 std::task::Poll::Ready(Some(Ok(chunk)))
640 }
641 other => other,
642 }
643 }
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649
650 #[test]
651 fn test_mime_guess_simple() {
652 assert_eq!(mime_guess_simple("photo.jpg"), "image/jpeg");
653 assert_eq!(mime_guess_simple("photo.JPEG"), "image/jpeg");
654 assert_eq!(mime_guess_simple("graphic.PNG"), "image/png");
655 assert_eq!(mime_guess_simple("doc.pdf"), "application/pdf");
656 assert_eq!(mime_guess_simple("notes.txt"), "text/plain");
657 assert_eq!(mime_guess_simple("binary.bin"), "application/octet-stream");
658 }
659
660 #[tokio::test]
661 async fn test_outgoing_file_from_path_and_sha256() {
662 let temp_dir = std::env::temp_dir().join(format!("lanflare_outgoing_test_{}", Uuid::new_v4()));
663 tokio::fs::create_dir_all(&temp_dir).await.unwrap();
664 let file_path = temp_dir.join("sample.txt");
665 tokio::fs::write(&file_path, b"Hello LanFlare!").await.unwrap();
666
667 let outgoing = OutgoingFile::from_path(&file_path)
668 .await
669 .expect("Should construct from path");
670 assert_eq!(outgoing.file_name, "sample.txt");
671 assert_eq!(outgoing.size, 15);
672 assert_eq!(outgoing.file_type, "text/plain");
673 assert!(outgoing.sha256.is_none());
674
675 let outgoing_with_hash = outgoing
676 .with_computed_sha256()
677 .await
678 .expect("Should compute hash");
679 assert!(outgoing_with_hash.sha256.is_some());
680 let dto = outgoing_with_hash.to_dto();
681 assert_eq!(dto.file_name, "sample.txt");
682 assert_eq!(dto.size, 15);
683 assert_eq!(dto.sha256, outgoing_with_hash.sha256);
684
685 let _ = tokio::fs::remove_dir_all(&temp_dir).await;
686 }
687}
688