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