Skip to main content

lanflare_core/server/
handlers.rs

1//! HTTP request handlers for LocalSend v2.2 endpoints.
2
3use std::collections::HashMap;
4use std::net::IpAddr;
5use std::sync::Arc;
6use base64::Engine;
7use bytes::Bytes;
8use http_body_util::{BodyExt, Full, Limited};
9use hyper::body::Incoming;
10use hyper::{Request, Response, StatusCode};
11use lanflare_proto::discovery::{InfoResponseDtoV2, ProtocolType, RegisterDtoV2, RegisterResponseDtoV2, PROTOCOL_VERSION_V2};
12use lanflare_proto::transfer::{
13    PrepareDownloadResponseDtoV2, PrepareUploadRequestDtoV2, PrepareUploadResponseDtoV2,
14};
15use tokio::sync::{mpsc, oneshot};
16use tokio_util::sync::CancellationToken;
17use uuid::Uuid;
18
19use crate::config::AppConfig;
20use crate::discovery::registry::PeerRegistry;
21use crate::server::session::{
22    DownloadManager, FileTransferStatus, PrepareUploadDecision, SessionEndReason, SessionFile,
23    SessionManager,
24};
25use crate::storage::path::resolve_unique_destination_path;
26use crate::storage::writer::{StorageCommitResult, StreamingFileWriter};
27
28/// Application events emitted by the HTTP server for GUI/TUI integration.
29#[derive(Debug)]
30pub enum ServerTransferEvent {
31    /// Peer registration received.
32    DeviceRegistered {
33        ip: IpAddr,
34        info: RegisterDtoV2,
35    },
36    /// An incoming upload preparation request.
37    PrepareUpload {
38        session_id: String,
39        sender_ip: IpAddr,
40        sender_info: RegisterDtoV2,
41        cert_fingerprint: Option<String>,
42        files: HashMap<String, lanflare_proto::transfer::FileDto>,
43        decision_tx: oneshot::Sender<PrepareUploadDecision>,
44    },
45    /// A file upload started.
46    FileStarted {
47        session_id: String,
48        file_id: String,
49        file_name: String,
50        total_bytes: u64,
51    },
52    /// A file upload progress update.
53    FileProgress {
54        session_id: String,
55        file_id: String,
56        file_name: String,
57        bytes_received: u64,
58        total_bytes: u64,
59    },
60    /// A file upload completed and verified.
61    FileCompleted {
62        session_id: String,
63        file_id: String,
64        result: StorageCommitResult,
65    },
66    /// A file upload failed.
67    FileFailed {
68        session_id: String,
69        file_id: String,
70        error: String,
71    },
72    /// The transfer session ended.
73    SessionEnded {
74        session_id: String,
75        reason: SessionEndReason,
76    },
77    /// A browser requested a file download in share via link mode.
78    BrowserDownload {
79        download_id: String,
80        remote_ip: IpAddr,
81        file_name: String,
82        file_size: u64,
83        decision_tx: oneshot::Sender<bool>,
84    },
85}
86
87/// Shared application state injected into hyper request handlers.
88#[derive(Clone)]
89pub struct ServerState {
90    pub config: Arc<tokio::sync::RwLock<AppConfig>>,
91    pub cert_fingerprint: String,
92    pub session_manager: Arc<SessionManager>,
93    pub download_manager: Arc<DownloadManager>,
94    pub registry: Arc<PeerRegistry>,
95    pub event_tx: Option<mpsc::Sender<ServerTransferEvent>>,
96}
97
98/// Helper decoding percent-encoded strings (e.g. `%40` -> `@`, `%3A` -> `:`).
99pub fn percent_decode(input: &str) -> String {
100    let mut result = Vec::with_capacity(input.len());
101    let bytes = input.as_bytes();
102    let mut i = 0;
103    while i < bytes.len() {
104        if bytes[i] == b'%'
105            && i + 2 < bytes.len()
106            && let (Some(h1), Some(h2)) = (
107                (bytes[i + 1] as char).to_digit(16),
108                (bytes[i + 2] as char).to_digit(16),
109            )
110        {
111            result.push((h1 * 16 + h2) as u8);
112            i += 3;
113            continue;
114        }
115        result.push(bytes[i]);
116        i += 1;
117    }
118    String::from_utf8(result).unwrap_or_else(|_| input.to_string())
119}
120
121/// Helper extracting password/PIN from a raw input string or full URL.
122///
123/// Specifically handles special cases such as:
124/// `https://:b7asiojoij@@:e7z3@10.40.1.133:53317`
125/// where `b7asiojoij@@:e7z3` is the password.
126pub fn extract_password_from_url(input: &str) -> String {
127    let trimmed = input.trim();
128    if trimmed.is_empty() {
129        return String::new();
130    }
131
132    let authority_and_beyond = if let Some(idx) = trimmed.find("://") {
133        &trimmed[idx + 3..]
134    } else if let Some(rest) = trimmed.strip_prefix("//") {
135        rest
136    } else if let Some(last_at) = trimmed.rfind('@') {
137        let after_at = &trimmed[last_at + 1..];
138        // If after the last '@' looks like a host (e.g. 192.168.1.1:53317, localhost:53317, not starting with ':')
139        if !after_at.starts_with(':') && (after_at.contains('.') || after_at.contains(':')) {
140            trimmed
141        } else {
142            return trimmed.to_string();
143        }
144    } else {
145        return trimmed.to_string();
146    };
147
148    let (authority, query_part) = match authority_and_beyond.find(['/', '?', '#']) {
149        Some(idx) => (&authority_and_beyond[..idx], &authority_and_beyond[idx..]),
150        None => (authority_and_beyond, ""),
151    };
152
153    // The userinfo delimiter is before the LAST '@' in the authority
154    if let Some(last_at) = authority.rfind('@') {
155        let userinfo = &authority[..last_at];
156        let pass = if let Some(stripped) = userinfo.strip_prefix(':') {
157            stripped
158        } else if let Some((_user, pass)) = userinfo.split_once(':') {
159            pass
160        } else {
161            userinfo
162        };
163        return percent_decode(pass);
164    }
165
166    // If no userinfo in authority, check query string for ?pin=... or &pin=...
167    if let Some(q_idx) = query_part.find('?') {
168        let q_str = &query_part[q_idx + 1..];
169        for pair in q_str.split('&') {
170            if let Some(val) = pair.strip_prefix("pin=") {
171                let clean_val = val.split('#').next().unwrap_or(val);
172                return percent_decode(clean_val);
173            }
174        }
175    }
176
177    trimmed.to_string()
178}
179
180/// Helper verifying HTTP Basic Auth or pin query param against share_link_pin.
181pub fn check_share_link_auth(req: &Request<Incoming>, config: &AppConfig) -> bool {
182    if !config.share_link_require_pin {
183        return true;
184    }
185
186    let expected_pin = config.share_link_pin.trim();
187    if expected_pin.is_empty() {
188        return true;
189    }
190    let expected_decoded = percent_decode(expected_pin);
191
192    // 1. Check Authorization header: Basic <base64>
193    if let Some(auth_val) = req.headers().get(hyper::header::AUTHORIZATION)
194        && let Ok(auth_str) = auth_val.to_str()
195        && let Some(token) = auth_str.strip_prefix("Basic ").or_else(|| auth_str.strip_prefix("basic "))
196        && let Ok(decoded_bytes) = base64::engine::general_purpose::STANDARD.decode(token.trim())
197        && let Ok(decoded_str) = std::str::from_utf8(&decoded_bytes)
198    {
199        // Decoded could be ":pass", "user:pass", "pass", or full URL
200        let pass_candidates = if let Some(stripped) = decoded_str.strip_prefix(':') {
201            vec![stripped, decoded_str]
202        } else if let Some((user, pass)) = decoded_str.split_once(':') {
203            vec![pass, user, decoded_str]
204        } else {
205            vec![decoded_str]
206        };
207
208        for cand in pass_candidates {
209            let cand_trimmed = cand.trim();
210            if cand_trimmed == expected_pin
211                || cand_trimmed == expected_decoded
212                || percent_decode(cand_trimmed) == expected_decoded
213                || extract_password_from_url(cand_trimmed) == expected_pin
214                || extract_password_from_url(cand_trimmed) == expected_decoded
215            {
216                return true;
217            }
218        }
219    }
220
221    // 2. Check query param: ?pin=...
222    if let Some(q) = req.uri().query() {
223        let query_map = parse_query(Some(q));
224        if let Some(pin) = query_map.get("pin") {
225            let pin_trimmed = pin.trim();
226            if pin_trimmed == expected_pin
227                || pin_trimmed == expected_decoded
228                || percent_decode(pin_trimmed) == expected_decoded
229                || extract_password_from_url(pin_trimmed) == expected_pin
230                || extract_password_from_url(pin_trimmed) == expected_decoded
231            {
232                return true;
233            }
234        }
235    }
236
237    false
238}
239
240/// Helper creating HTTP 401 response with WWW-Authenticate header.
241pub fn unauthorized_response() -> Response<Full<Bytes>> {
242    Response::builder()
243        .status(StatusCode::UNAUTHORIZED)
244        .header(
245            hyper::header::WWW_AUTHENTICATE,
246            "Basic realm=\"LanFlare Share\", charset=\"UTF-8\"",
247        )
248        .header(hyper::header::CONTENT_TYPE, "text/plain; charset=utf-8")
249        .body(Full::new(Bytes::from("401 Unauthorized - Authentication required\n")))
250        .unwrap()
251}
252
253/// Helper creating an empty hyper response with given status code.
254pub fn empty_response(status: StatusCode) -> Response<Full<Bytes>> {
255    Response::builder()
256        .status(status)
257        .body(Full::new(Bytes::new()))
258        .unwrap()
259}
260
261/// Helper creating a JSON hyper response with given status code.
262pub fn json_response<T: serde::Serialize>(status: StatusCode, data: &T) -> Response<Full<Bytes>> {
263    let body = match serde_json::to_vec(data) {
264        Ok(bytes) => bytes,
265        Err(_) => {
266            return empty_response(StatusCode::INTERNAL_SERVER_ERROR);
267        }
268    };
269
270    Response::builder()
271        .status(status)
272        .header(hyper::header::CONTENT_TYPE, "application/json")
273        .body(Full::new(Bytes::from(body)))
274        .unwrap()
275}
276
277/// Maximum allowed size for JSON control bodies (10 MB).
278pub const MAX_JSON_BODY_SIZE: usize = 10 * 1024 * 1024;
279
280/// Helper to read and deserialize a bounded JSON request body, preventing memory exhaustion attacks.
281pub async fn read_json_body<T: serde::de::DeserializeOwned>(
282    body: Incoming,
283) -> Result<T, StatusCode> {
284    let limited = Limited::new(body, MAX_JSON_BODY_SIZE);
285    let collected = match limited.collect().await {
286        Ok(c) => c.to_bytes(),
287        Err(err) => {
288            if err.downcast_ref::<http_body_util::LengthLimitError>().is_some() {
289                return Err(StatusCode::PAYLOAD_TOO_LARGE);
290            }
291            return Err(StatusCode::BAD_REQUEST);
292        }
293    };
294    serde_json::from_slice(&collected).map_err(|_| StatusCode::BAD_REQUEST)
295}
296
297/// Helper parsing query parameters into a HashMap.
298pub fn parse_query(query_str: Option<&str>) -> HashMap<String, String> {
299    let mut map = HashMap::new();
300    let Some(query) = query_str else {
301        return map;
302    };
303
304    for pair in query.split('&') {
305        if pair.is_empty() {
306            continue;
307        }
308        let mut parts = pair.splitn(2, '=');
309        let key = parts.next().unwrap_or("").to_string();
310        let value = parts.next().unwrap_or("").to_string();
311        if !key.is_empty() {
312            map.insert(key, value);
313        }
314    }
315    map
316}
317
318/// Handler for `GET /api/localsend/v2/info` and `GET /api/localsend/v1/info`.
319pub async fn handle_info(state: &ServerState) -> Response<Full<Bytes>> {
320    let files = state.download_manager.get_file_dtos().await;
321    let cfg = state.config.read().await;
322    let resp = InfoResponseDtoV2 {
323        alias: cfg.alias.clone(),
324        version: PROTOCOL_VERSION_V2.to_string(),
325        device_model: Some(std::env::consts::OS.to_string()),
326        device_type: Some(lanflare_proto::discovery::DeviceType::Desktop),
327        fingerprint: state.cert_fingerprint.clone(),
328        port: Some(cfg.port),
329        protocol: Some(ProtocolType::Https),
330        download: !files.is_empty(),
331    };
332    json_response(StatusCode::OK, &resp)
333}
334
335/// Handler for `POST /api/localsend/v2/register`.
336pub async fn handle_register(
337    req: Request<Incoming>,
338    remote_ip: IpAddr,
339    client_cert_fingerprint: Option<String>,
340    state: &ServerState,
341) -> Response<Full<Bytes>> {
342    let dto: RegisterDtoV2 = match read_json_body(req.into_body()).await {
343        Ok(d) => d,
344        Err(status) => return empty_response(status),
345    };
346
347    // If client cert fingerprint is present (mTLS), ensure claimed fingerprint matches or log warning
348    if let Some(ref cert_fp) = client_cert_fingerprint.filter(|fp| !dto.fingerprint.eq_ignore_ascii_case(fp)) {
349        tracing::warn!(
350            "Claimed fingerprint {} does not match client certificate fingerprint {}",
351            dto.fingerprint,
352            cert_fp
353        );
354    }
355
356    // Ingest into PeerRegistry
357    state.registry.handle_http_register(remote_ip, &dto).await;
358
359    // Emit event if configured
360    if let Some(ref tx) = state.event_tx {
361        let _ = tx
362            .send(ServerTransferEvent::DeviceRegistered {
363                ip: remote_ip,
364                info: dto,
365            })
366            .await;
367    }
368
369    let files = state.download_manager.get_file_dtos().await;
370    let cfg = state.config.read().await;
371    let resp = RegisterResponseDtoV2 {
372        alias: cfg.alias.clone(),
373        version: PROTOCOL_VERSION_V2.to_string(),
374        device_model: Some(std::env::consts::OS.to_string()),
375        device_type: Some(lanflare_proto::discovery::DeviceType::Desktop),
376        fingerprint: state.cert_fingerprint.clone(),
377        download: !files.is_empty(),
378    };
379    json_response(StatusCode::OK, &resp)
380}
381
382/// Handler for `POST /api/localsend/v2/prepare-upload`.
383pub async fn handle_prepare_upload(
384    req: Request<Incoming>,
385    remote_ip: IpAddr,
386    client_cert_fingerprint: Option<String>,
387    state: &ServerState,
388) -> Response<Full<Bytes>> {
389    let query = parse_query(req.uri().query());
390    let provided_pin = query.get("pin").map(|s| s.as_str());
391
392    let (auto_accept, pin_requirement) = {
393        let cfg = state.config.read().await;
394        (cfg.auto_accept, cfg.pin.clone())
395    };
396
397    // Validate PIN
398    if let Err(status) = state
399        .session_manager
400        .pin_tracker()
401        .check_pin(pin_requirement.as_deref(), provided_pin, remote_ip)
402        .await
403    {
404        return empty_response(status);
405    }
406
407    let payload: PrepareUploadRequestDtoV2 = match read_json_body(req.into_body()).await {
408        Ok(p) => p,
409        Err(status) => return empty_response(status),
410    };
411
412    if payload.files.is_empty() {
413        return empty_response(StatusCode::BAD_REQUEST);
414    }
415
416    let session_id = Uuid::new_v4().to_string();
417    let cancel = CancellationToken::new();
418
419    // Claim pending session slot
420    if let Err(status) = state
421        .session_manager
422        .claim_pending(
423            session_id.clone(),
424            remote_ip,
425            client_cert_fingerprint.clone(),
426            cancel.clone(),
427        )
428        .await
429    {
430        return empty_response(status);
431    }
432
433    // Determine decision: if auto_accept is enabled in AppConfig, accept all files immediately
434    let decision = if auto_accept {
435        PrepareUploadDecision::Accept(payload.files.keys().cloned().collect())
436    } else if let Some(ref tx) = state.event_tx {
437        let (decision_tx, decision_rx) = oneshot::channel();
438        let event = ServerTransferEvent::PrepareUpload {
439            session_id: session_id.clone(),
440            sender_ip: remote_ip,
441            sender_info: payload.info.clone(),
442            cert_fingerprint: client_cert_fingerprint.clone(),
443            files: payload.files.clone(),
444            decision_tx,
445        };
446
447        if tx.send(event).await.is_err() {
448            state.session_manager.clear_pending(&session_id).await;
449            return empty_response(StatusCode::INTERNAL_SERVER_ERROR);
450        }
451
452        tokio::select! {
453            res = decision_rx => {
454                match res {
455                    Ok(d) => d,
456                    Err(_) => {
457                        state.session_manager.clear_pending(&session_id).await;
458                        return empty_response(StatusCode::INTERNAL_SERVER_ERROR);
459                    }
460                }
461            }
462            _ = cancel.cancelled() => {
463                state.session_manager.clear_pending(&session_id).await;
464                return empty_response(StatusCode::FORBIDDEN);
465            }
466        }
467    } else {
468        // Without an event channel or auto_accept, accept all files by default for headless operation
469        PrepareUploadDecision::Accept(payload.files.keys().cloned().collect())
470    };
471
472    let accepted_file_ids = match decision {
473        PrepareUploadDecision::Decline => {
474            state.session_manager.clear_pending(&session_id).await;
475            return empty_response(StatusCode::FORBIDDEN);
476        }
477        PrepareUploadDecision::Accept(ids) => ids,
478    };
479
480    if accepted_file_ids.is_empty() {
481        state.session_manager.clear_pending(&session_id).await;
482        // LocalSend spec Section 4.1: If empty or no files accepted, return 204 No Content
483        return empty_response(StatusCode::NO_CONTENT);
484    }
485
486    // Prepare session files with tokens and safe target paths
487    let mut session_files = HashMap::new();
488    let mut response_tokens = HashMap::new();
489
490    for (file_id, file_dto) in payload.files {
491        if !accepted_file_ids.contains(&file_id) {
492            continue;
493        }
494
495        let token = Uuid::new_v4().to_string();
496        let download_dir = state.config.read().await.download_dir.clone();
497        let target_path = match resolve_unique_destination_path(&download_dir, &file_dto.file_name)
498            .or_else(|_| {
499                let fallback = format!("file_{}.bin", Uuid::new_v4().simple());
500                resolve_unique_destination_path(&download_dir, &fallback)
501            })
502        {
503            Ok(p) => p,
504            Err(_) => {
505                state.session_manager.clear_pending(&session_id).await;
506                return empty_response(StatusCode::INTERNAL_SERVER_ERROR);
507            }
508        };
509
510        session_files.insert(
511            file_id.clone(),
512            SessionFile {
513                dto: file_dto,
514                token: token.clone(),
515                status: FileTransferStatus::Pending,
516                attempts: 0,
517                target_path,
518            },
519        );
520        response_tokens.insert(file_id, token);
521    }
522
523    if let Err(status) = state
524        .session_manager
525        .activate_session(
526            &session_id,
527            remote_ip,
528            client_cert_fingerprint,
529            session_files,
530        )
531        .await
532    {
533        state.session_manager.clear_pending(&session_id).await;
534        return empty_response(status);
535    }
536
537    let resp = PrepareUploadResponseDtoV2 {
538        session_id,
539        files: response_tokens,
540    };
541    json_response(StatusCode::OK, &resp)
542}
543
544/// Handler for `POST /api/localsend/v2/upload`.
545pub async fn handle_upload(
546    req: Request<Incoming>,
547    remote_ip: IpAddr,
548    state: &ServerState,
549) -> Response<Full<Bytes>> {
550    let query = parse_query(req.uri().query());
551    let (Some(session_id), Some(file_id), Some(token)) = (
552        query.get("sessionId"),
553        query.get("fileId"),
554        query.get("token"),
555    ) else {
556        return empty_response(StatusCode::BAD_REQUEST);
557    };
558
559    // Validate authorization and mark file InProgress
560    let session_file = match state
561        .session_manager
562        .validate_and_start_upload(session_id, file_id, token, remote_ip)
563        .await
564    {
565        Ok(f) => f,
566        Err(status) => return empty_response(status),
567    };
568
569    if let Some(ref tx) = state.event_tx {
570        let _ = tx
571            .send(ServerTransferEvent::FileStarted {
572                session_id: session_id.clone(),
573                file_id: file_id.clone(),
574                file_name: session_file.dto.file_name.clone(),
575                total_bytes: session_file.dto.size,
576            })
577            .await;
578    }
579
580    // Create streaming disk writer
581    let expected_size = Some(session_file.dto.size);
582    let expected_sha256 = session_file.dto.sha256.clone();
583
584    let mut writer = match StreamingFileWriter::create(
585        &session_file.target_path,
586        expected_size,
587        expected_sha256.clone(),
588    )
589    .await
590    {
591        Ok(w) => w,
592        Err(err) => {
593            tracing::error!("Failed to initialize streaming writer: {err}");
594            state
595                .session_manager
596                .mark_file_status(session_id, file_id, FileTransferStatus::Failed)
597                .await;
598            return empty_response(StatusCode::INTERNAL_SERVER_ERROR);
599        }
600    };
601
602    // Stream chunks directly from request body into disk writer
603    let mut body = req.into_body();
604    let mut stream_error = None;
605    let mut last_progress_emit = std::time::Instant::now();
606    let progress_interval = std::time::Duration::from_millis(100);
607
608    while let Some(chunk_res) = body.frame().await {
609        match chunk_res {
610            Ok(frame) => {
611                let data = match frame.into_data() {
612                    Ok(d) => d,
613                    Err(_) => continue,
614                };
615                if let Err(err) = writer.write_chunk(&data).await {
616                    stream_error = Some(err);
617                    break;
618                }
619
620                let now = std::time::Instant::now();
621                if now.duration_since(last_progress_emit) >= progress_interval {
622                    last_progress_emit = now;
623                    if let Some(ref tx) = state.event_tx {
624                        let _ = tx.try_send(ServerTransferEvent::FileProgress {
625                            session_id: session_id.clone(),
626                            file_id: file_id.clone(),
627                            file_name: session_file.dto.file_name.clone(),
628                            bytes_received: writer.bytes_written(),
629                            total_bytes: session_file.dto.size,
630                        });
631                    }
632                }
633            }
634            Err(e) => {
635                stream_error = Some(crate::storage::writer::StorageError::StreamError(e.to_string()));
636                break;
637            }
638        }
639    }
640
641    if let Some(err) = stream_error {
642        tracing::warn!("Upload stream failed for file {file_id}: {err}");
643        let _ = writer.abort().await;
644        state
645            .session_manager
646            .mark_file_status(session_id, file_id, FileTransferStatus::Failed)
647            .await;
648        if let Some(ref tx) = state.event_tx {
649            let _ = tx
650                .send(ServerTransferEvent::FileFailed {
651                    session_id: session_id.clone(),
652                    file_id: file_id.clone(),
653                    error: err.to_string(),
654                })
655                .await;
656        }
657
658        return match err {
659            crate::storage::writer::StorageError::ChecksumMismatch { .. } => {
660                empty_response(StatusCode::UNPROCESSABLE_ENTITY)
661            }
662            crate::storage::writer::StorageError::SizeExceeded { .. } => {
663                empty_response(StatusCode::BAD_REQUEST)
664            }
665            _ => empty_response(StatusCode::INTERNAL_SERVER_ERROR),
666        };
667    }
668
669    // Commit file verification (validates length and expected SHA-256)
670    match writer.commit().await {
671        Ok(commit_result) => {
672            let session_finished = state
673                .session_manager
674                .mark_file_status(session_id, file_id, FileTransferStatus::Completed)
675                .await;
676
677            if let Some(ref tx) = state.event_tx {
678                let _ = tx
679                    .send(ServerTransferEvent::FileCompleted {
680                        session_id: session_id.clone(),
681                        file_id: file_id.clone(),
682                        result: commit_result,
683                    })
684                    .await;
685
686                if session_finished {
687                    let _ = tx
688                        .send(ServerTransferEvent::SessionEnded {
689                            session_id: session_id.clone(),
690                            reason: SessionEndReason::Finished,
691                        })
692                        .await;
693                }
694            }
695
696            empty_response(StatusCode::OK)
697        }
698        Err(err) => {
699            tracing::warn!("Commit verification failed for file {file_id}: {err}");
700            state
701                .session_manager
702                .mark_file_status(session_id, file_id, FileTransferStatus::Failed)
703                .await;
704
705            if let Some(ref tx) = state.event_tx {
706                let _ = tx
707                    .send(ServerTransferEvent::FileFailed {
708                        session_id: session_id.clone(),
709                        file_id: file_id.clone(),
710                        error: err.to_string(),
711                    })
712                    .await;
713            }
714
715            match err {
716                crate::storage::writer::StorageError::ChecksumMismatch { .. } => {
717                    empty_response(StatusCode::UNPROCESSABLE_ENTITY)
718                }
719                crate::storage::writer::StorageError::SizeMismatch { .. } => {
720                    empty_response(StatusCode::BAD_REQUEST)
721                }
722                _ => empty_response(StatusCode::INTERNAL_SERVER_ERROR),
723            }
724        }
725    }
726}
727
728/// Handler for `POST /api/localsend/v2/cancel`.
729pub async fn handle_cancel(
730    req: Request<Incoming>,
731    remote_ip: IpAddr,
732    state: &ServerState,
733) -> Response<Full<Bytes>> {
734    let query = parse_query(req.uri().query());
735    let session_id = query.get("sessionId").map(|s| s.as_str());
736
737    let reason = state
738        .session_manager
739        .cancel_session(session_id, remote_ip)
740        .await;
741
742    if let (Some(reason), Some(tx)) = (reason, &state.event_tx) {
743        let _ = tx
744            .send(ServerTransferEvent::SessionEnded {
745                session_id: session_id.unwrap_or("unknown").to_string(),
746                reason,
747            })
748            .await;
749    }
750
751    empty_response(StatusCode::OK)
752}
753
754/// Handler for `POST /api/localsend/v2/prepare-download` (Reverse file transfer / browser download metadata).
755pub async fn handle_prepare_download(
756    req: Request<Incoming>,
757    remote_ip: IpAddr,
758    state: &ServerState,
759) -> Response<Full<Bytes>> {
760    let query = parse_query(req.uri().query());
761    let provided_pin = query.get("pin").map(|s| s.as_str());
762
763    let (alias, port, pin_req) = {
764        let cfg = state.config.read().await;
765        (cfg.alias.clone(), cfg.port, cfg.pin.clone())
766    };
767
768    // Check if client is already accepted for this session
769    if let Some(session_id) = query.get("sessionId")
770        && state
771            .download_manager
772            .is_session_valid(session_id, remote_ip)
773            .await
774    {
775        let files = state.download_manager.get_file_dtos().await;
776        let resp = PrepareDownloadResponseDtoV2 {
777            info: InfoResponseDtoV2 {
778                alias,
779                version: PROTOCOL_VERSION_V2.to_string(),
780                device_model: Some(std::env::consts::OS.to_string()),
781                device_type: Some(lanflare_proto::discovery::DeviceType::Desktop),
782                fingerprint: state.cert_fingerprint.clone(),
783                port: Some(port),
784                protocol: Some(ProtocolType::Https),
785                download: true,
786            },
787            session_id: session_id.clone(),
788            files,
789        };
790        return json_response(StatusCode::OK, &resp);
791    }
792
793    // Validate PIN
794    if let Err(status) = state
795        .session_manager
796        .pin_tracker()
797        .check_pin(pin_req.as_deref(), provided_pin, remote_ip)
798        .await
799    {
800        return empty_response(status);
801    }
802
803    let files = state.download_manager.get_file_dtos().await;
804    if files.is_empty() {
805        return empty_response(StatusCode::FORBIDDEN);
806    }
807
808    let session_id = state
809        .download_manager
810        .prepare_session(query.get("sessionId").map(|s| s.as_str()), remote_ip, true)
811        .await;
812
813    let resp = PrepareDownloadResponseDtoV2 {
814        info: InfoResponseDtoV2 {
815            alias,
816            version: PROTOCOL_VERSION_V2.to_string(),
817            device_model: Some(std::env::consts::OS.to_string()),
818            device_type: Some(lanflare_proto::discovery::DeviceType::Desktop),
819            fingerprint: state.cert_fingerprint.clone(),
820            port: Some(port),
821            protocol: Some(ProtocolType::Https),
822            download: true,
823        },
824        session_id,
825        files,
826    };
827    json_response(StatusCode::OK, &resp)
828}
829
830/// Handler for `GET /api/localsend/v2/download?sessionId=...&fileId=...`.
831pub async fn handle_download(
832    req: Request<Incoming>,
833    remote_ip: IpAddr,
834    state: &ServerState,
835) -> Response<Full<Bytes>> {
836    let query = parse_query(req.uri().query());
837    let file_id = match query.get("fileId") {
838        Some(id) => id,
839        None => return empty_response(StatusCode::BAD_REQUEST),
840    };
841
842    let session_id = match query.get("sessionId") {
843        Some(s) if !s.is_empty() => s.to_string(),
844        _ => "web".to_string(),
845    };
846
847    let (is_web, auto_accept) = {
848        let is_web = session_id == "web";
849        let cfg = state.config.read().await;
850        if is_web && !check_share_link_auth(&req, &cfg) {
851            return unauthorized_response();
852        }
853        (is_web, cfg.share_link_auto_accept)
854    };
855
856    // For browser direct downloads, ensure session is prepared
857    if session_id == "web" {
858        state
859            .download_manager
860            .prepare_session(Some("web"), remote_ip, true)
861            .await;
862    }
863
864    let file_path = match state
865        .download_manager
866        .get_file_path(&session_id, file_id, remote_ip)
867        .await
868    {
869        Ok(p) => p,
870        Err(status) => return empty_response(status),
871    };
872
873    let file_name = file_path
874        .file_name()
875        .and_then(|n| n.to_str())
876        .unwrap_or("download.bin")
877        .to_string();
878
879    let file_size = tokio::fs::metadata(&file_path)
880        .await
881        .map(|m| m.len())
882        .unwrap_or(0);
883
884    // If share_link_auto_accept is true and this is a browser download, user has to click to accept!
885    if is_web && auto_accept
886        && let Some(event_tx) = &state.event_tx
887    {
888        let (decision_tx, decision_rx) = oneshot::channel::<bool>();
889            let download_id = Uuid::new_v4().to_string();
890
891            let event = ServerTransferEvent::BrowserDownload {
892                download_id: download_id.clone(),
893                remote_ip,
894                file_name: file_name.clone(),
895                file_size,
896                decision_tx,
897            };
898
899            if event_tx.send(event).await.is_err() {
900                return empty_response(StatusCode::INTERNAL_SERVER_ERROR);
901            }
902
903            match tokio::time::timeout(std::time::Duration::from_secs(60), decision_rx).await {
904                Ok(Ok(true)) => {
905                    tracing::info!("Browser download approved for {file_name} from {remote_ip}");
906                }
907                Ok(Ok(false)) => {
908                    tracing::info!("Browser download declined for {file_name} from {remote_ip}");
909                    return empty_response(StatusCode::FORBIDDEN);
910                }
911                _ => {
912                    tracing::warn!("Browser download timed out or cancelled for {file_name}");
913                    return empty_response(StatusCode::REQUEST_TIMEOUT);
914                }
915            }
916        }
917
918    let content = match tokio::fs::read(&file_path).await {
919        Ok(c) => c,
920        Err(e) => {
921            tracing::error!("Failed to read download file {:?}: {e}", file_path);
922            return empty_response(StatusCode::INTERNAL_SERVER_ERROR);
923        }
924    };
925
926    Response::builder()
927        .status(StatusCode::OK)
928        .header(hyper::header::CONTENT_TYPE, "application/octet-stream")
929        .header(
930            hyper::header::CONTENT_DISPOSITION,
931            format!("attachment; filename=\"{file_name}\""),
932        )
933        .header(hyper::header::CONTENT_LENGTH, content.len())
934        .body(Full::new(Bytes::from(content)))
935        .unwrap()
936}
937
938/// Helper converting byte size into human-readable representation.
939fn format_file_size(bytes: u64) -> String {
940    const KB: f64 = 1024.0;
941    const MB: f64 = KB * 1024.0;
942    const GB: f64 = MB * 1024.0;
943
944    let b = bytes as f64;
945    if b >= GB {
946        format!("{:.2} GB", b / GB)
947    } else if b >= MB {
948        format!("{:.1} MB", b / MB)
949    } else if b >= KB {
950        format!("{:.1} KB", b / KB)
951    } else {
952        format!("{bytes} B")
953    }
954}
955
956fn escape_html(input: &str) -> String {
957    input
958        .replace('&', "&amp;")
959        .replace('<', "&lt;")
960        .replace('>', "&gt;")
961        .replace('"', "&quot;")
962        .replace('\'', "&#39;")
963}
964
965/// Handler serving the web portal for browser downloads (Share via link).
966pub async fn handle_web_portal(req: &Request<Incoming>, state: &ServerState) -> Response<Full<Bytes>> {
967    let alias = {
968        let cfg = state.config.read().await;
969        if !check_share_link_auth(req, &cfg) {
970            return unauthorized_response();
971        }
972        escape_html(&cfg.alias)
973    };
974
975    let files = state.download_manager.get_file_dtos().await;
976
977    let mut files_html = String::new();
978    if files.is_empty() {
979        files_html.push_str(
980            r#"<div class="empty-state">
981                <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
982                <p>No files are currently being shared.</p>
983                <span>Keep this page open and wait for the sender to share files.</span>
984            </div>"#,
985        );
986    } else {
987        for (id, file) in &files {
988            let encoded_name = escape_html(&file.file_name);
989            let size_str = format_file_size(file.size);
990            files_html.push_str(&format!(
991                r#"<div class="file-item">
992                    <div class="file-info">
993                        <div class="file-icon">
994                            <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
995                        </div>
996                        <div class="file-details">
997                            <div class="file-name" title="{encoded_name}">{encoded_name}</div>
998                            <div class="file-size">{size_str}</div>
999                        </div>
1000                    </div>
1001                    <a class="download-btn" href="/api/localsend/v2/download?sessionId=web&fileId={id}" download="{encoded_name}">
1002                        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
1003                        Download
1004                    </a>
1005                </div>"#
1006            ));
1007        }
1008    }
1009
1010    let html = format!(
1011        r#"<!DOCTYPE html>
1012<html lang="en">
1013<head>
1014    <meta charset="UTF-8">
1015    <meta name="viewport" content="width=device-width, initial-scale=1.0">
1016    <title>LanFlare - Download Files</title>
1017    <style>
1018        * {{ box-sizing: border-box; margin: 0; padding: 0; }}
1019        body {{
1020            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
1021            background: #090d16;
1022            color: #f1f5f9;
1023            min-height: 100vh;
1024            display: flex;
1025            align-items: center;
1026            justify-content: center;
1027            padding: 20px;
1028        }}
1029        .card {{
1030            width: 100%;
1031            max-width: 520px;
1032            background: #111827;
1033            border: 1px solid #1f2937;
1034            border-radius: 20px;
1035            padding: 28px;
1036            box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
1037        }}
1038        .header {{
1039            display: flex;
1040            align-items: center;
1041            gap: 14px;
1042            margin-bottom: 24px;
1043            padding-bottom: 18px;
1044            border-bottom: 1px solid #1f2937;
1045        }}
1046        .logo {{
1047            width: 44px;
1048            height: 44px;
1049            border-radius: 12px;
1050            background: linear-gradient(135deg, #6366f1, #8b5cf6);
1051            display: flex;
1052            align-items: center;
1053            justify-content: center;
1054            color: white;
1055            box-shadow: 0 4px 14px rgba(99, 102, 241, 0.35);
1056        }}
1057        .title h1 {{ font-size: 18px; font-weight: 700; color: #f8fafc; }}
1058        .title p {{ font-size: 13px; color: #94a3b8; margin-top: 2px; }}
1059        .title p strong {{ color: #e2e8f0; }}
1060        .file-list {{ display: flex; flex-direction: column; gap: 10px; }}
1061        .file-item {{
1062            display: flex;
1063            align-items: center;
1064            justify-content: space-between;
1065            padding: 12px 14px;
1066            background: #1e293b;
1067            border: 1px solid #334155;
1068            border-radius: 12px;
1069            gap: 12px;
1070            transition: all 0.15s ease;
1071        }}
1072        .file-item:hover {{
1073            background: #243248;
1074            border-color: #6366f1;
1075        }}
1076        .file-info {{ display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1; }}
1077        .file-icon {{
1078            width: 38px;
1079            height: 38px;
1080            border-radius: 8px;
1081            background: rgba(99, 102, 241, 0.15);
1082            color: #818cf8;
1083            display: flex;
1084            align-items: center;
1085            justify-content: center;
1086            flex-shrink: 0;
1087        }}
1088        .file-details {{ min-width: 0; flex: 1; }}
1089        .file-name {{
1090            font-size: 13px;
1091            font-weight: 600;
1092            color: #f1f5f9;
1093            white-space: nowrap;
1094            overflow: hidden;
1095            text-overflow: ellipsis;
1096        }}
1097        .file-size {{ font-size: 11px; color: #94a3b8; font-family: monospace; margin-top: 2px; }}
1098        .download-btn {{
1099            display: inline-flex;
1100            align-items: center;
1101            gap: 6px;
1102            padding: 8px 14px;
1103            border-radius: 10px;
1104            background: #4f46e5;
1105            color: white;
1106            text-decoration: none;
1107            font-size: 12px;
1108            font-weight: 600;
1109            flex-shrink: 0;
1110            transition: background 0.15s ease;
1111        }}
1112        .download-btn:hover {{ background: #4338ca; }}
1113        .empty-state {{
1114            padding: 40px 20px;
1115            text-align: center;
1116            color: #64748b;
1117        }}
1118        .empty-state svg {{ margin: 0 auto 12px; stroke: #475569; }}
1119        .empty-state p {{ font-size: 14px; font-weight: 500; color: #94a3b8; }}
1120        .empty-state span {{ font-size: 12px; color: #64748b; margin-top: 4px; display: block; }}
1121        .footer {{
1122            margin-top: 20px;
1123            text-align: center;
1124            font-size: 11px;
1125            color: #64748b;
1126        }}
1127    </style>
1128</head>
1129<body>
1130    <div class="card">
1131        <div class="header">
1132            <div class="logo">
1133                <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>
1134            </div>
1135            <div class="title">
1136                <h1>LanFlare Web Share</h1>
1137                <p>Shared by <strong>{alias}</strong></p>
1138            </div>
1139        </div>
1140        <div class="file-list">
1141            {files_html}
1142        </div>
1143        <div class="footer">
1144            LocalSend Protocol v2.2 Compatible &bull; Fast Local Wi-Fi Transfer
1145        </div>
1146    </div>
1147</body>
1148</html>"#
1149    );
1150
1151    Response::builder()
1152        .status(StatusCode::OK)
1153        .header(hyper::header::CONTENT_TYPE, "text/html; charset=utf-8")
1154        .body(Full::new(Bytes::from(html)))
1155        .unwrap()
1156}