Skip to main content

lanflare_core/server/
session.rs

1//! Transfer session state and PIN protection for the LocalSend HTTP server.
2
3use 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
11/// Maximum failed PIN attempts per IP before temporary lockout (HTTP 429).
12pub const MAX_PIN_ATTEMPTS: u32 = 3;
13
14/// Status of an individual file in an active upload session.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum FileTransferStatus {
17    /// File is pending upload.
18    Pending,
19    /// File upload is currently in progress.
20    InProgress,
21    /// File upload completed and verified successfully.
22    Completed,
23    /// File upload failed or was rejected.
24    Failed,
25}
26
27/// Metadata and state for an individual file in an active upload session.
28#[derive(Clone, Debug)]
29pub struct SessionFile {
30    /// File metadata DTO from prepare-upload.
31    pub dto: FileDto,
32    /// Unique upload token assigned for this file.
33    pub token: String,
34    /// Current transfer status.
35    pub status: FileTransferStatus,
36    /// Number of upload attempts.
37    pub attempts: u32,
38    /// Resolved target path on disk.
39    pub target_path: PathBuf,
40}
41
42/// A pending session awaiting user decision (or auto-accept).
43#[derive(Debug)]
44pub struct PendingSession {
45    /// Unique session identifier.
46    pub session_id: String,
47    /// IP address of the sender.
48    pub sender_ip: IpAddr,
49    /// Certificate fingerprint of sender (if mTLS used).
50    pub cert_fingerprint: Option<String>,
51    /// Cancellation token if sender disconnects or cancels before decision.
52    pub cancel: CancellationToken,
53    /// When the pending session was initiated.
54    pub created_at: Instant,
55}
56
57/// An active upload session with accepted files.
58#[derive(Debug)]
59pub struct ActiveUploadSession {
60    /// Unique session identifier.
61    pub session_id: String,
62    /// IP address of the sender.
63    pub sender_ip: IpAddr,
64    /// Certificate fingerprint of sender (if mTLS used).
65    pub cert_fingerprint: Option<String>,
66    /// Files accepted for upload keyed by file ID.
67    pub files: HashMap<String, SessionFile>,
68    /// When the session became active.
69    pub created_at: Instant,
70}
71
72impl ActiveUploadSession {
73    /// Checks if all accepted files have reached a terminal state (Completed or Failed).
74    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    /// Checks if all accepted files were successfully completed.
84    pub fn all_succeeded(&self) -> bool {
85        self.files
86            .values()
87            .all(|f| f.status == FileTransferStatus::Completed)
88    }
89}
90
91/// Lifecycle state machine for the server's transfer session slot.
92#[derive(Debug)]
93pub enum SessionSlotState {
94    /// Pending decision from receiver/user.
95    Pending(PendingSession),
96    /// Active upload session in progress.
97    Active(ActiveUploadSession),
98}
99
100/// Decision made on an incoming prepare-upload request.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum PrepareUploadDecision {
103    /// Accept specific file IDs (can be a subset or all).
104    Accept(Vec<String>),
105    /// Reject the upload request.
106    Decline,
107}
108
109/// Reason why an upload session was closed.
110#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub enum SessionEndReason {
112    /// All accepted files finished transferring.
113    Finished,
114    /// Cancelled by the remote sender.
115    CancelledBySender,
116    /// Cancelled locally by the receiver.
117    CancelledLocally,
118    /// Session timed out due to inactivity.
119    TimedOut,
120}
121
122/// Thread-safe PIN attempt tracker with lockout capability.
123#[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    /// Validates the provided query PIN against the server's configured PIN.
136    ///
137    /// - If no PIN is configured on the server, Ok(()) is returned immediately.
138    /// - If too many failed attempts have occurred from this IP, returns Err(429).
139    /// - If the provided PIN matches, resets attempt counter and returns Ok(()).
140    /// - If PIN is missing or invalid, increments attempt counter and returns Err(401).
141    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                // LocalSend spec: If PIN is required and not provided in query param, return 401
169                Err(hyper::StatusCode::UNAUTHORIZED)
170            }
171        }
172    }
173
174    /// Resets the failed attempts for an IP.
175    pub async fn reset(&self, ip: IpAddr) {
176        self.attempts.lock().await.remove(&ip);
177    }
178}
179
180/// Manager maintaining session slots, concurrency bounds, and token authentication.
181#[derive(Debug)]
182pub struct SessionManager {
183    /// The current upload session slot (single session active at a time as per LocalSend v2 spec).
184    slot: Mutex<Option<SessionSlotState>>,
185    /// PIN verification tracker.
186    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    /// Returns a reference to the PIN attempt tracker.
204    pub fn pin_tracker(&self) -> &PinTracker {
205        &self.pin_tracker
206    }
207
208    /// Checks if a session is currently pending or active.
209    pub async fn has_active_session(&self) -> bool {
210        self.slot.lock().await.is_some()
211    }
212
213    /// Claims the session slot for a pending prepare-upload request.
214    /// Returns hyper::StatusCode::CONFLICT (409) if a session is already active or pending.
215    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    /// Releases the pending session if it still matches `session_id`.
238    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    /// Promotes a pending session into an active upload session with accepted files.
246    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        // Verify slot state is still pending for this session
255        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    /// Validates upload authorization parameters for `/api/localsend/v2/upload`.
271    ///
272    /// Verifies:
273    /// 1. An active session exists matching `session_id`.
274    /// 2. Sender IP matches the session's recorded IP.
275    /// 3. The requested `file_id` was accepted and has not completed.
276    /// 4. The upload `token` matches the token issued during prepare-upload.
277    ///
278    /// Returns the corresponding `SessionFile` and sets its status to `InProgress`.
279    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            // Already finished
305            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    /// Updates the status of a file in the active session.
314    /// If all files reach terminal status, the session is completed and the slot is freed.
315    /// Returns whether the entire session finished.
316    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    /// Cancels the session if the request matches the current pending or active session.
344    /// Returns `Some(SessionEndReason)` if an active or pending session was cancelled.
345    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    /// Forcibly aborts and clears the current session locally.
370    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/// A download file registered with the download manager.
382#[derive(Clone, Debug)]
383pub struct DownloadFile {
384    /// File metadata DTO.
385    pub dto: FileDto,
386    /// Absolute path to the source file on disk.
387    pub file_path: PathBuf,
388}
389
390/// An active or accepted reverse transfer download session for a client IP.
391#[derive(Debug, Clone)]
392pub struct DownloadSession {
393    /// Unique session identifier (often client IP string or UUID).
394    pub session_id: String,
395    /// Client remote IP.
396    pub client_ip: IpAddr,
397    /// Whether this session has been approved / accepted.
398    pub accepted: bool,
399    /// When this session was created.
400    pub created_at: Instant,
401}
402
403/// Manager maintaining registered files and sessions for the reverse transfer / browser download API.
404#[derive(Debug, Default)]
405pub struct DownloadManager {
406    /// Registered files available for downloading, keyed by fileId.
407    files: Mutex<HashMap<String, DownloadFile>>,
408    /// Active/accepted download sessions keyed by sessionId.
409    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    /// Registers a set of files available for downloading.
421    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    /// Clears all offered download files and resets download sessions.
430    pub async fn clear_files(&self) {
431        self.files.lock().await.clear();
432        self.sessions.lock().await.clear();
433    }
434
435    /// Returns a map of all offered file DTOs.
436    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    /// Checks if a session with given ID is currently valid and accepted for the given remote IP.
444    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    /// Prepares or accepts a download session for a client.
452    /// Returns the session ID.
453    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    /// Retrieves the local filesystem path for a valid file in an authorized session.
477    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        // No PIN configured -> always Ok
510        assert!(tracker.check_pin(None, None, ip).await.is_ok());
511        assert!(tracker.check_pin(None, Some("123"), ip).await.is_ok());
512
513        // PIN configured -> missing -> 401
514        assert_eq!(
515            tracker.check_pin(Some("1234"), None, ip).await,
516            Err(hyper::StatusCode::UNAUTHORIZED)
517        );
518
519        // Wrong PIN 1st try -> 401 (count: 1)
520        assert_eq!(
521            tracker.check_pin(Some("1234"), Some("0000"), ip).await,
522            Err(hyper::StatusCode::UNAUTHORIZED)
523        );
524
525        // Wrong PIN 2nd try -> 401 (count: 2)
526        assert_eq!(
527            tracker.check_pin(Some("1234"), Some("0001"), ip).await,
528            Err(hyper::StatusCode::UNAUTHORIZED)
529        );
530
531        // Wrong PIN 3rd try -> 401 (count: 3)
532        assert_eq!(
533            tracker.check_pin(Some("1234"), Some("0002"), ip).await,
534            Err(hyper::StatusCode::UNAUTHORIZED)
535        );
536
537        // 4th try -> 429 lockout triggered (count >= 3)
538        assert_eq!(
539            tracker.check_pin(Some("1234"), Some("0003"), ip).await,
540            Err(hyper::StatusCode::TOO_MANY_REQUESTS)
541        );
542
543        // Even correct PIN is blocked once locked out
544        assert_eq!(
545            tracker.check_pin(Some("1234"), Some("1234"), ip).await,
546            Err(hyper::StatusCode::TOO_MANY_REQUESTS)
547        );
548
549        // Reset clears lockout
550        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        // Claim slot
563        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        // Second claim while pending -> 409 Conflict
570        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        // Activate session with file
578        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        // Validate upload with wrong token -> 403 Forbidden
604        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        // Validate upload with wrong IP -> 403 Forbidden
613        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        // Validate correct upload
623        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        // Mark file completed -> finishes session
630        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}