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