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