1use 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#[derive(Debug)]
31pub enum ServerTransferEvent {
32 DeviceRegistered {
34 ip: IpAddr,
35 info: RegisterDtoV2,
36 },
37 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 FileStarted {
48 session_id: String,
49 file_id: String,
50 file_name: String,
51 total_bytes: u64,
52 },
53 FileProgress {
55 session_id: String,
56 file_id: String,
57 file_name: String,
58 bytes_received: u64,
59 total_bytes: u64,
60 },
61 FileCompleted {
63 session_id: String,
64 file_id: String,
65 result: StorageCommitResult,
66 },
67 FileFailed {
69 session_id: String,
70 file_id: String,
71 error: String,
72 },
73 SessionEnded {
75 session_id: String,
76 reason: SessionEndReason,
77 },
78 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#[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
101pub 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
124pub 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_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 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 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
183pub 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 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 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 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
243pub 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
256pub fn empty_response(status: StatusCode) -> Response<Full<Bytes>> {
258 Response::builder()
259 .status(status)
260 .body(Full::new(Bytes::new()))
261 .unwrap()
262}
263
264pub 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
280pub const MAX_JSON_BODY_SIZE: usize = 10 * 1024 * 1024;
282
283pub 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
300pub 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
321pub async fn handle_info(remote_ip: IpAddr, state: &ServerState) -> Response<Full<Bytes>> {
323 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
393pub 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 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 state.registry.handle_http_register(remote_ip, &dto).await;
416
417 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
440pub 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 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 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 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 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 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 return empty_response(StatusCode::NO_CONTENT);
545 }
546
547 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
605pub 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 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 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 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 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
789pub 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
815pub 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 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 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
891pub 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 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 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
999fn 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('&', "&")
1020 .replace('<', "<")
1021 .replace('>', ">")
1022 .replace('"', """)
1023 .replace('\'', "'")
1024}
1025
1026pub 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 • 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}