1use std::collections::HashMap;
4use std::net::IpAddr;
5use std::path::PathBuf;
6use std::time::Instant;
7use lanflare_proto::transfer::FileDto;
8use tokio::sync::Mutex;
9use tokio_util::sync::CancellationToken;
10
11pub const MAX_PIN_ATTEMPTS: u32 = 3;
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum FileTransferStatus {
17 Pending,
19 InProgress,
21 Completed,
23 Failed,
25}
26
27#[derive(Clone, Debug)]
29pub struct SessionFile {
30 pub dto: FileDto,
32 pub token: String,
34 pub status: FileTransferStatus,
36 pub attempts: u32,
38 pub target_path: PathBuf,
40}
41
42#[derive(Debug)]
44pub struct PendingSession {
45 pub session_id: String,
47 pub sender_ip: IpAddr,
49 pub cert_fingerprint: Option<String>,
51 pub cancel: CancellationToken,
53 pub created_at: Instant,
55}
56
57#[derive(Debug)]
59pub struct ActiveUploadSession {
60 pub session_id: String,
62 pub sender_ip: IpAddr,
64 pub cert_fingerprint: Option<String>,
66 pub files: HashMap<String, SessionFile>,
68 pub created_at: Instant,
70}
71
72impl ActiveUploadSession {
73 pub fn is_finished(&self) -> bool {
75 self.files.values().all(|f| {
76 matches!(
77 f.status,
78 FileTransferStatus::Completed | FileTransferStatus::Failed
79 )
80 })
81 }
82
83 pub fn all_succeeded(&self) -> bool {
85 self.files
86 .values()
87 .all(|f| f.status == FileTransferStatus::Completed)
88 }
89}
90
91#[derive(Debug)]
93pub enum SessionSlotState {
94 Pending(PendingSession),
96 Active(ActiveUploadSession),
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum PrepareUploadDecision {
103 Accept(Vec<String>),
105 Decline,
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub enum SessionEndReason {
112 Finished,
114 CancelledBySender,
116 CancelledLocally,
118 TimedOut,
120}
121
122#[derive(Debug, Default)]
124pub struct PinTracker {
125 attempts: Mutex<HashMap<IpAddr, u32>>,
126}
127
128impl PinTracker {
129 pub fn new() -> Self {
130 Self {
131 attempts: Mutex::new(HashMap::new()),
132 }
133 }
134
135 pub async fn check_pin(
142 &self,
143 configured_pin: Option<&str>,
144 provided_pin: Option<&str>,
145 remote_ip: IpAddr,
146 ) -> Result<(), hyper::StatusCode> {
147 let Some(required) = configured_pin else {
148 return Ok(());
149 };
150
151 let mut lock = self.attempts.lock().await;
152 let count = lock.get(&remote_ip).copied().unwrap_or(0);
153
154 if count >= MAX_PIN_ATTEMPTS {
155 return Err(hyper::StatusCode::TOO_MANY_REQUESTS);
156 }
157
158 match provided_pin {
159 Some(pin) if pin == required => {
160 lock.remove(&remote_ip);
161 Ok(())
162 }
163 Some(_) => {
164 lock.insert(remote_ip, count + 1);
165 Err(hyper::StatusCode::UNAUTHORIZED)
166 }
167 None => {
168 Err(hyper::StatusCode::UNAUTHORIZED)
170 }
171 }
172 }
173
174 pub async fn reset(&self, ip: IpAddr) {
176 self.attempts.lock().await.remove(&ip);
177 }
178}
179
180#[derive(Debug)]
182pub struct SessionManager {
183 slot: Mutex<Option<SessionSlotState>>,
185 pin_tracker: PinTracker,
187}
188
189impl Default for SessionManager {
190 fn default() -> Self {
191 Self::new()
192 }
193}
194
195impl SessionManager {
196 pub fn new() -> Self {
197 Self {
198 slot: Mutex::new(None),
199 pin_tracker: PinTracker::new(),
200 }
201 }
202
203 pub fn pin_tracker(&self) -> &PinTracker {
205 &self.pin_tracker
206 }
207
208 pub async fn has_active_session(&self) -> bool {
210 self.slot.lock().await.is_some()
211 }
212
213 pub async fn claim_pending(
216 &self,
217 session_id: String,
218 sender_ip: IpAddr,
219 cert_fingerprint: Option<String>,
220 cancel: CancellationToken,
221 ) -> Result<(), hyper::StatusCode> {
222 let mut slot = self.slot.lock().await;
223 if slot.is_some() {
224 return Err(hyper::StatusCode::CONFLICT);
225 }
226
227 *slot = Some(SessionSlotState::Pending(PendingSession {
228 session_id,
229 sender_ip,
230 cert_fingerprint,
231 cancel,
232 created_at: Instant::now(),
233 }));
234 Ok(())
235 }
236
237 pub async fn clear_pending(&self, session_id: &str) {
239 let mut slot = self.slot.lock().await;
240 if matches!(slot.as_ref(), Some(SessionSlotState::Pending(p)) if p.session_id == session_id) {
241 *slot = None;
242 }
243 }
244
245 pub async fn activate_session(
247 &self,
248 session_id: &str,
249 sender_ip: IpAddr,
250 cert_fingerprint: Option<String>,
251 files: HashMap<String, SessionFile>,
252 ) -> Result<(), hyper::StatusCode> {
253 let mut slot = self.slot.lock().await;
254 match slot.as_ref() {
256 Some(SessionSlotState::Pending(p)) if p.session_id == session_id => {}
257 _ => return Err(hyper::StatusCode::CONFLICT),
258 }
259
260 *slot = Some(SessionSlotState::Active(ActiveUploadSession {
261 session_id: session_id.to_string(),
262 sender_ip,
263 cert_fingerprint,
264 files,
265 created_at: Instant::now(),
266 }));
267 Ok(())
268 }
269
270 pub async fn validate_and_start_upload(
280 &self,
281 session_id: &str,
282 file_id: &str,
283 token: &str,
284 sender_ip: IpAddr,
285 ) -> Result<SessionFile, hyper::StatusCode> {
286 let mut slot = self.slot.lock().await;
287 let Some(SessionSlotState::Active(session)) = slot.as_mut() else {
288 return Err(hyper::StatusCode::FORBIDDEN);
289 };
290
291 if session.session_id != session_id || session.sender_ip != sender_ip {
292 return Err(hyper::StatusCode::FORBIDDEN);
293 }
294
295 let Some(file) = session.files.get_mut(file_id) else {
296 return Err(hyper::StatusCode::FORBIDDEN);
297 };
298
299 if file.token != token {
300 return Err(hyper::StatusCode::FORBIDDEN);
301 }
302
303 if file.status == FileTransferStatus::Completed {
304 return Err(hyper::StatusCode::BAD_REQUEST);
306 }
307
308 file.status = FileTransferStatus::InProgress;
309 file.attempts = file.attempts.saturating_add(1);
310 Ok(file.clone())
311 }
312
313 pub async fn mark_file_status(
317 &self,
318 session_id: &str,
319 file_id: &str,
320 status: FileTransferStatus,
321 ) -> bool {
322 let mut slot = self.slot.lock().await;
323 let Some(SessionSlotState::Active(session)) = slot.as_mut() else {
324 return false;
325 };
326
327 if session.session_id != session_id {
328 return false;
329 }
330
331 if let Some(file) = session.files.get_mut(file_id) {
332 file.status = status;
333 }
334
335 if session.is_finished() {
336 *slot = None;
337 true
338 } else {
339 false
340 }
341 }
342
343 pub async fn cancel_session(
346 &self,
347 session_id: Option<&str>,
348 remote_ip: IpAddr,
349 ) -> Option<SessionEndReason> {
350 let mut slot = self.slot.lock().await;
351 match slot.as_ref() {
352 Some(SessionSlotState::Pending(p))
353 if p.sender_ip == remote_ip && session_id.is_none_or(|id| id == p.session_id) =>
354 {
355 p.cancel.cancel();
356 *slot = None;
357 Some(SessionEndReason::CancelledBySender)
358 }
359 Some(SessionSlotState::Active(a))
360 if a.sender_ip == remote_ip && session_id.is_some_and(|id| id == a.session_id) =>
361 {
362 *slot = None;
363 Some(SessionEndReason::CancelledBySender)
364 }
365 _ => None,
366 }
367 }
368
369 pub async fn abort_locally(&self) -> Option<SessionEndReason> {
371 let mut slot = self.slot.lock().await;
372 if slot.is_some() {
373 *slot = None;
374 Some(SessionEndReason::CancelledLocally)
375 } else {
376 None
377 }
378 }
379}
380
381#[derive(Clone, Debug)]
383pub struct DownloadFile {
384 pub dto: FileDto,
386 pub file_path: PathBuf,
388}
389
390#[derive(Debug, Clone)]
392pub struct DownloadSession {
393 pub session_id: String,
395 pub client_ip: IpAddr,
397 pub accepted: bool,
399 pub created_at: Instant,
401}
402
403#[derive(Debug, Default)]
405pub struct DownloadManager {
406 files: Mutex<HashMap<String, DownloadFile>>,
408 sessions: Mutex<HashMap<String, DownloadSession>>,
410}
411
412impl DownloadManager {
413 pub fn new() -> Self {
414 Self {
415 files: Mutex::new(HashMap::new()),
416 sessions: Mutex::new(HashMap::new()),
417 }
418 }
419
420 pub async fn set_files(&self, files: Vec<DownloadFile>) {
422 let mut map = self.files.lock().await;
423 map.clear();
424 for file in files {
425 map.insert(file.dto.id.clone(), file);
426 }
427 }
428
429 pub async fn clear_files(&self) {
431 self.files.lock().await.clear();
432 self.sessions.lock().await.clear();
433 }
434
435 pub async fn get_file_dtos(&self) -> HashMap<String, FileDto> {
437 let map = self.files.lock().await;
438 map.iter()
439 .map(|(k, v)| (k.clone(), v.dto.clone()))
440 .collect()
441 }
442
443 pub async fn is_session_valid(&self, session_id: &str, client_ip: IpAddr) -> bool {
445 let sessions = self.sessions.lock().await;
446 sessions
447 .get(session_id)
448 .is_some_and(|s| s.accepted && s.client_ip == client_ip)
449 }
450
451 pub async fn prepare_session(
454 &self,
455 requested_session_id: Option<&str>,
456 client_ip: IpAddr,
457 accepted: bool,
458 ) -> String {
459 let session_id = requested_session_id
460 .map(str::to_string)
461 .unwrap_or_else(|| client_ip.to_string());
462
463 let mut sessions = self.sessions.lock().await;
464 sessions.insert(
465 session_id.clone(),
466 DownloadSession {
467 session_id: session_id.clone(),
468 client_ip,
469 accepted,
470 created_at: Instant::now(),
471 },
472 );
473 session_id
474 }
475
476 pub async fn get_file_path(
478 &self,
479 session_id: &str,
480 file_id: &str,
481 client_ip: IpAddr,
482 ) -> Result<PathBuf, hyper::StatusCode> {
483 let sessions = self.sessions.lock().await;
484 let valid = sessions
485 .get(session_id)
486 .is_some_and(|s| s.accepted && s.client_ip == client_ip);
487 if !valid {
488 return Err(hyper::StatusCode::FORBIDDEN);
489 }
490
491 let files = self.files.lock().await;
492 match files.get(file_id) {
493 Some(f) => Ok(f.file_path.clone()),
494 None => Err(hyper::StatusCode::NOT_FOUND),
495 }
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use std::net::Ipv4Addr;
503
504 #[tokio::test]
505 async fn test_pin_tracker_flow() {
506 let tracker = PinTracker::new();
507 let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
508
509 assert!(tracker.check_pin(None, None, ip).await.is_ok());
511 assert!(tracker.check_pin(None, Some("123"), ip).await.is_ok());
512
513 assert_eq!(
515 tracker.check_pin(Some("1234"), None, ip).await,
516 Err(hyper::StatusCode::UNAUTHORIZED)
517 );
518
519 assert_eq!(
521 tracker.check_pin(Some("1234"), Some("0000"), ip).await,
522 Err(hyper::StatusCode::UNAUTHORIZED)
523 );
524
525 assert_eq!(
527 tracker.check_pin(Some("1234"), Some("0001"), ip).await,
528 Err(hyper::StatusCode::UNAUTHORIZED)
529 );
530
531 assert_eq!(
533 tracker.check_pin(Some("1234"), Some("0002"), ip).await,
534 Err(hyper::StatusCode::UNAUTHORIZED)
535 );
536
537 assert_eq!(
539 tracker.check_pin(Some("1234"), Some("0003"), ip).await,
540 Err(hyper::StatusCode::TOO_MANY_REQUESTS)
541 );
542
543 assert_eq!(
545 tracker.check_pin(Some("1234"), Some("1234"), ip).await,
546 Err(hyper::StatusCode::TOO_MANY_REQUESTS)
547 );
548
549 tracker.reset(ip).await;
551 assert!(tracker.check_pin(Some("1234"), Some("1234"), ip).await.is_ok());
552 }
553
554 #[tokio::test]
555 async fn test_session_slot_concurrency_and_lifecycle() {
556 let manager = SessionManager::new();
557 let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10));
558 let cancel = CancellationToken::new();
559
560 assert!(!manager.has_active_session().await);
561
562 assert!(manager
564 .claim_pending("sess-1".to_string(), ip, None, cancel.clone())
565 .await
566 .is_ok());
567 assert!(manager.has_active_session().await);
568
569 assert_eq!(
571 manager
572 .claim_pending("sess-2".to_string(), ip, None, CancellationToken::new())
573 .await,
574 Err(hyper::StatusCode::CONFLICT)
575 );
576
577 let mut files = HashMap::new();
579 files.insert(
580 "file-1".to_string(),
581 SessionFile {
582 dto: FileDto {
583 id: "file-1".to_string(),
584 file_name: "test.txt".to_string(),
585 size: 100,
586 file_type: "text/plain".to_string(),
587 sha256: None,
588 preview: None,
589 metadata: None,
590 },
591 token: "token-abc".to_string(),
592 status: FileTransferStatus::Pending,
593 attempts: 0,
594 target_path: PathBuf::from("/tmp/test.txt"),
595 },
596 );
597
598 assert!(manager
599 .activate_session("sess-1", ip, None, files)
600 .await
601 .is_ok());
602
603 assert_eq!(
605 manager
606 .validate_and_start_upload("sess-1", "file-1", "wrong-tok", ip)
607 .await
608 .err(),
609 Some(hyper::StatusCode::FORBIDDEN)
610 );
611
612 let other_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
614 assert_eq!(
615 manager
616 .validate_and_start_upload("sess-1", "file-1", "token-abc", other_ip)
617 .await
618 .err(),
619 Some(hyper::StatusCode::FORBIDDEN)
620 );
621
622 let file = manager
624 .validate_and_start_upload("sess-1", "file-1", "token-abc", ip)
625 .await
626 .expect("should validate");
627 assert_eq!(file.dto.file_name, "test.txt");
628
629 let finished = manager
631 .mark_file_status("sess-1", "file-1", FileTransferStatus::Completed)
632 .await;
633 assert!(finished);
634 assert!(!manager.has_active_session().await);
635 }
636
637 #[tokio::test]
638 async fn test_cancel_pending_session() {
639 let manager = SessionManager::new();
640 let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10));
641 let cancel = CancellationToken::new();
642
643 manager
644 .claim_pending("sess-1".to_string(), ip, None, cancel.clone())
645 .await
646 .unwrap();
647
648 let reason = manager.cancel_session(None, ip).await;
649 assert_eq!(reason, Some(SessionEndReason::CancelledBySender));
650 assert!(cancel.is_cancelled());
651 assert!(!manager.has_active_session().await);
652 }
653}