use std::collections::HashMap;
use std::net::IpAddr;
use std::path::PathBuf;
use std::time::Instant;
use lanflare_proto::transfer::FileDto;
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
pub const MAX_PIN_ATTEMPTS: u32 = 3;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileTransferStatus {
Pending,
InProgress,
Completed,
Failed,
}
#[derive(Clone, Debug)]
pub struct SessionFile {
pub dto: FileDto,
pub token: String,
pub status: FileTransferStatus,
pub attempts: u32,
pub target_path: PathBuf,
}
#[derive(Debug)]
pub struct PendingSession {
pub session_id: String,
pub sender_ip: IpAddr,
pub cert_fingerprint: Option<String>,
pub cancel: CancellationToken,
pub created_at: Instant,
}
#[derive(Debug)]
pub struct ActiveUploadSession {
pub session_id: String,
pub sender_ip: IpAddr,
pub cert_fingerprint: Option<String>,
pub files: HashMap<String, SessionFile>,
pub created_at: Instant,
}
impl ActiveUploadSession {
pub fn is_finished(&self) -> bool {
self.files.values().all(|f| {
matches!(
f.status,
FileTransferStatus::Completed | FileTransferStatus::Failed
)
})
}
pub fn all_succeeded(&self) -> bool {
self.files
.values()
.all(|f| f.status == FileTransferStatus::Completed)
}
}
#[derive(Debug)]
pub enum SessionSlotState {
Pending(PendingSession),
Active(ActiveUploadSession),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PrepareUploadDecision {
Accept(Vec<String>),
Decline,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SessionEndReason {
Finished,
CancelledBySender,
CancelledLocally,
TimedOut,
}
#[derive(Debug, Default)]
pub struct PinTracker {
attempts: Mutex<HashMap<IpAddr, u32>>,
}
impl PinTracker {
pub fn new() -> Self {
Self {
attempts: Mutex::new(HashMap::new()),
}
}
pub async fn check_pin(
&self,
configured_pin: Option<&str>,
provided_pin: Option<&str>,
remote_ip: IpAddr,
) -> Result<(), hyper::StatusCode> {
let Some(required) = configured_pin else {
return Ok(());
};
let mut lock = self.attempts.lock().await;
let count = lock.get(&remote_ip).copied().unwrap_or(0);
if count >= MAX_PIN_ATTEMPTS {
return Err(hyper::StatusCode::TOO_MANY_REQUESTS);
}
match provided_pin {
Some(pin) if pin == required => {
lock.remove(&remote_ip);
Ok(())
}
Some(_) => {
lock.insert(remote_ip, count + 1);
Err(hyper::StatusCode::UNAUTHORIZED)
}
None => {
Err(hyper::StatusCode::UNAUTHORIZED)
}
}
}
pub async fn reset(&self, ip: IpAddr) {
self.attempts.lock().await.remove(&ip);
}
}
#[derive(Debug)]
pub struct SessionManager {
slot: Mutex<Option<SessionSlotState>>,
pin_tracker: PinTracker,
}
impl Default for SessionManager {
fn default() -> Self {
Self::new()
}
}
impl SessionManager {
pub fn new() -> Self {
Self {
slot: Mutex::new(None),
pin_tracker: PinTracker::new(),
}
}
pub fn pin_tracker(&self) -> &PinTracker {
&self.pin_tracker
}
pub async fn has_active_session(&self) -> bool {
self.slot.lock().await.is_some()
}
pub async fn claim_pending(
&self,
session_id: String,
sender_ip: IpAddr,
cert_fingerprint: Option<String>,
cancel: CancellationToken,
) -> Result<(), hyper::StatusCode> {
let mut slot = self.slot.lock().await;
if slot.is_some() {
return Err(hyper::StatusCode::CONFLICT);
}
*slot = Some(SessionSlotState::Pending(PendingSession {
session_id,
sender_ip,
cert_fingerprint,
cancel,
created_at: Instant::now(),
}));
Ok(())
}
pub async fn clear_pending(&self, session_id: &str) {
let mut slot = self.slot.lock().await;
if matches!(slot.as_ref(), Some(SessionSlotState::Pending(p)) if p.session_id == session_id) {
*slot = None;
}
}
pub async fn activate_session(
&self,
session_id: &str,
sender_ip: IpAddr,
cert_fingerprint: Option<String>,
files: HashMap<String, SessionFile>,
) -> Result<(), hyper::StatusCode> {
let mut slot = self.slot.lock().await;
match slot.as_ref() {
Some(SessionSlotState::Pending(p)) if p.session_id == session_id => {}
_ => return Err(hyper::StatusCode::CONFLICT),
}
*slot = Some(SessionSlotState::Active(ActiveUploadSession {
session_id: session_id.to_string(),
sender_ip,
cert_fingerprint,
files,
created_at: Instant::now(),
}));
Ok(())
}
pub async fn validate_and_start_upload(
&self,
session_id: &str,
file_id: &str,
token: &str,
sender_ip: IpAddr,
) -> Result<SessionFile, hyper::StatusCode> {
let mut slot = self.slot.lock().await;
let Some(SessionSlotState::Active(session)) = slot.as_mut() else {
return Err(hyper::StatusCode::FORBIDDEN);
};
if session.session_id != session_id || session.sender_ip != sender_ip {
return Err(hyper::StatusCode::FORBIDDEN);
}
let Some(file) = session.files.get_mut(file_id) else {
return Err(hyper::StatusCode::FORBIDDEN);
};
if file.token != token {
return Err(hyper::StatusCode::FORBIDDEN);
}
if file.status == FileTransferStatus::Completed {
return Err(hyper::StatusCode::BAD_REQUEST);
}
file.status = FileTransferStatus::InProgress;
file.attempts = file.attempts.saturating_add(1);
Ok(file.clone())
}
pub async fn mark_file_status(
&self,
session_id: &str,
file_id: &str,
status: FileTransferStatus,
) -> bool {
let mut slot = self.slot.lock().await;
let Some(SessionSlotState::Active(session)) = slot.as_mut() else {
return false;
};
if session.session_id != session_id {
return false;
}
if let Some(file) = session.files.get_mut(file_id) {
file.status = status;
}
if session.is_finished() {
*slot = None;
true
} else {
false
}
}
pub async fn cancel_session(
&self,
session_id: Option<&str>,
remote_ip: IpAddr,
) -> Option<SessionEndReason> {
let mut slot = self.slot.lock().await;
match slot.as_ref() {
Some(SessionSlotState::Pending(p))
if p.sender_ip == remote_ip && session_id.is_none_or(|id| id == p.session_id) =>
{
p.cancel.cancel();
*slot = None;
Some(SessionEndReason::CancelledBySender)
}
Some(SessionSlotState::Active(a))
if a.sender_ip == remote_ip && session_id.is_some_and(|id| id == a.session_id) =>
{
*slot = None;
Some(SessionEndReason::CancelledBySender)
}
_ => None,
}
}
pub async fn abort_locally(&self) -> Option<SessionEndReason> {
let mut slot = self.slot.lock().await;
if slot.is_some() {
*slot = None;
Some(SessionEndReason::CancelledLocally)
} else {
None
}
}
}
#[derive(Clone, Debug)]
pub struct DownloadFile {
pub dto: FileDto,
pub file_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct DownloadSession {
pub session_id: String,
pub client_ip: IpAddr,
pub accepted: bool,
pub created_at: Instant,
}
#[derive(Debug, Default)]
pub struct DownloadManager {
files: Mutex<HashMap<String, DownloadFile>>,
sessions: Mutex<HashMap<String, DownloadSession>>,
}
impl DownloadManager {
pub fn new() -> Self {
Self {
files: Mutex::new(HashMap::new()),
sessions: Mutex::new(HashMap::new()),
}
}
pub async fn set_files(&self, files: Vec<DownloadFile>) {
let mut map = self.files.lock().await;
map.clear();
for file in files {
map.insert(file.dto.id.clone(), file);
}
}
pub async fn clear_files(&self) {
self.files.lock().await.clear();
self.sessions.lock().await.clear();
}
pub async fn get_file_dtos(&self) -> HashMap<String, FileDto> {
let map = self.files.lock().await;
map.iter()
.map(|(k, v)| (k.clone(), v.dto.clone()))
.collect()
}
pub async fn is_session_valid(&self, session_id: &str, client_ip: IpAddr) -> bool {
let sessions = self.sessions.lock().await;
sessions
.get(session_id)
.is_some_and(|s| s.accepted && s.client_ip == client_ip)
}
pub async fn prepare_session(
&self,
requested_session_id: Option<&str>,
client_ip: IpAddr,
accepted: bool,
) -> String {
let session_id = requested_session_id
.map(str::to_string)
.unwrap_or_else(|| client_ip.to_string());
let mut sessions = self.sessions.lock().await;
sessions.insert(
session_id.clone(),
DownloadSession {
session_id: session_id.clone(),
client_ip,
accepted,
created_at: Instant::now(),
},
);
session_id
}
pub async fn get_file_path(
&self,
session_id: &str,
file_id: &str,
client_ip: IpAddr,
) -> Result<PathBuf, hyper::StatusCode> {
let sessions = self.sessions.lock().await;
let valid = sessions
.get(session_id)
.is_some_and(|s| s.accepted && s.client_ip == client_ip);
if !valid {
return Err(hyper::StatusCode::FORBIDDEN);
}
let files = self.files.lock().await;
match files.get(file_id) {
Some(f) => Ok(f.file_path.clone()),
None => Err(hyper::StatusCode::NOT_FOUND),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
#[tokio::test]
async fn test_pin_tracker_flow() {
let tracker = PinTracker::new();
let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
assert!(tracker.check_pin(None, None, ip).await.is_ok());
assert!(tracker.check_pin(None, Some("123"), ip).await.is_ok());
assert_eq!(
tracker.check_pin(Some("1234"), None, ip).await,
Err(hyper::StatusCode::UNAUTHORIZED)
);
assert_eq!(
tracker.check_pin(Some("1234"), Some("0000"), ip).await,
Err(hyper::StatusCode::UNAUTHORIZED)
);
assert_eq!(
tracker.check_pin(Some("1234"), Some("0001"), ip).await,
Err(hyper::StatusCode::UNAUTHORIZED)
);
assert_eq!(
tracker.check_pin(Some("1234"), Some("0002"), ip).await,
Err(hyper::StatusCode::UNAUTHORIZED)
);
assert_eq!(
tracker.check_pin(Some("1234"), Some("0003"), ip).await,
Err(hyper::StatusCode::TOO_MANY_REQUESTS)
);
assert_eq!(
tracker.check_pin(Some("1234"), Some("1234"), ip).await,
Err(hyper::StatusCode::TOO_MANY_REQUESTS)
);
tracker.reset(ip).await;
assert!(tracker.check_pin(Some("1234"), Some("1234"), ip).await.is_ok());
}
#[tokio::test]
async fn test_session_slot_concurrency_and_lifecycle() {
let manager = SessionManager::new();
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10));
let cancel = CancellationToken::new();
assert!(!manager.has_active_session().await);
assert!(manager
.claim_pending("sess-1".to_string(), ip, None, cancel.clone())
.await
.is_ok());
assert!(manager.has_active_session().await);
assert_eq!(
manager
.claim_pending("sess-2".to_string(), ip, None, CancellationToken::new())
.await,
Err(hyper::StatusCode::CONFLICT)
);
let mut files = HashMap::new();
files.insert(
"file-1".to_string(),
SessionFile {
dto: FileDto {
id: "file-1".to_string(),
file_name: "test.txt".to_string(),
size: 100,
file_type: "text/plain".to_string(),
sha256: None,
preview: None,
metadata: None,
},
token: "token-abc".to_string(),
status: FileTransferStatus::Pending,
attempts: 0,
target_path: PathBuf::from("/tmp/test.txt"),
},
);
assert!(manager
.activate_session("sess-1", ip, None, files)
.await
.is_ok());
assert_eq!(
manager
.validate_and_start_upload("sess-1", "file-1", "wrong-tok", ip)
.await
.err(),
Some(hyper::StatusCode::FORBIDDEN)
);
let other_ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
assert_eq!(
manager
.validate_and_start_upload("sess-1", "file-1", "token-abc", other_ip)
.await
.err(),
Some(hyper::StatusCode::FORBIDDEN)
);
let file = manager
.validate_and_start_upload("sess-1", "file-1", "token-abc", ip)
.await
.expect("should validate");
assert_eq!(file.dto.file_name, "test.txt");
let finished = manager
.mark_file_status("sess-1", "file-1", FileTransferStatus::Completed)
.await;
assert!(finished);
assert!(!manager.has_active_session().await);
}
#[tokio::test]
async fn test_cancel_pending_session() {
let manager = SessionManager::new();
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10));
let cancel = CancellationToken::new();
manager
.claim_pending("sess-1".to_string(), ip, None, cancel.clone())
.await
.unwrap();
let reason = manager.cancel_session(None, ip).await;
assert_eq!(reason, Some(SessionEndReason::CancelledBySender));
assert!(cancel.is_cancelled());
assert!(!manager.has_active_session().await);
}
}