From e88dc07696df33cb36224e69d78029f691c7a6b6 Mon Sep 17 00:00:00 2001 From: netadmin Date: Fri, 23 Oct 2026 14:51:33 +0300 Subject: [PATCH] a.0.0.4 --- src/main.rs | 16 +-- src/models.rs | 149 +++++++++++++++++++++++- src/p2p.rs | 120 +++++++++++++++----- src/static/index.html | 174 +++++++++++++++++++++++----- src/ui.rs | 258 +++++++++++++++++++++++++++++++++++++----- 5 files changed, 613 insertions(+), 104 deletions(-) diff --git a/src/main.rs b/src/main.rs index b720ba7..412a8fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,8 @@ use axum::{ use axum_server::tls_rustls::RustlsConfig; use local_ip_address::local_ip; use models::{ - now_sec, AppState, BeaconPacket, DISCOVERY_PORT, MAX_PAYLOAD_SIZE, MAX_RAM_TEXTS, MEDIA_DIR, + now_sec, AppState, BeaconPacket, ScanProgress, DISCOVERY_PORT, MAX_PAYLOAD_SIZE, + MAX_RAM_TEXTS, MEDIA_DIR, }; use socket2::{Domain, Protocol, Socket, Type}; use std::{ @@ -22,7 +23,6 @@ use std::{ }; use tokio::net::UdpSocket; -/// Точное определение основного LAN IP через таблицу маршрутизации ядра ОС (игнорирует Docker/WSL) fn detect_lan_ip() -> String { if let Ok(sock) = StdUdpSocket::bind("0.0.0.0:0") { if sock.connect("8.8.8.8:80").is_ok() { @@ -39,14 +39,12 @@ fn detect_lan_ip() -> String { .unwrap_or_else(|_| "127.0.0.1".to_string()) } -/// Вычисляет широковещательный адрес подсети /24 (например, 192.168.1.255) fn subnet_broadcast_addr(ip_str: &str, port: u16) -> Option { let ipv4: Ipv4Addr = ip_str.parse().ok()?; let octets = ipv4.octets(); Some(format!("{}.{}.{}.255:{}", octets[0], octets[1], octets[2], port)) } -/// Опрос всех известных узлов и актуализация их статуса pub async fn ping_all_peers(state: &Arc) { let targets: Vec<(String, Vec, u16)> = { let guard = state.peers.read().unwrap(); @@ -96,9 +94,7 @@ pub async fn ping_all_peers(state: &Arc) { } } -/// Фоновый UDP-маяк (рассылает пакеты и в направленный broadcast подсети, и в 255.255.255.255) fn start_udp_discovery(state: Arc) { - // 1. Поток рассылки маяков let tx_state = state.clone(); tokio::spawn(async move { let bind_target = format!("{}:0", tx_state.my_ip); @@ -135,7 +131,6 @@ fn start_udp_discovery(state: Arc) { } }); - // 2. Поток приема маяков tokio::spawn(async move { let bind_addr: SocketAddr = format!("0.0.0.0:{}", DISCOVERY_PORT).parse().unwrap(); let socket = match Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)) { @@ -263,7 +258,6 @@ async fn main() { .await .expect("Не удалось создать папку received_media"); - // Загружаем постоянный сертификат и PIN-код (или создаем его один раз) let (cert_der, key_der, my_fingerprint) = tls::load_or_create_identity(&my_ip, p2p_port); let peers = Arc::new(RwLock::new(HashMap::new())); let http_client = tls::build_pinned_http_client(peers.clone()); @@ -281,6 +275,7 @@ async fn main() { http_client, probe_client, transfers: RwLock::new(Vec::new()), + scan_status: RwLock::new(ScanProgress::default()), logs: RwLock::new(VecDeque::new()), }); @@ -292,10 +287,8 @@ async fn main() { &my_fingerprint[..8].to_uppercase() )); - // Запуск UDP-обнаружения start_udp_discovery(state.clone()); - // Фоновый опрос известных узлов let ping_state = state.clone(); tokio::spawn(async move { loop { @@ -304,7 +297,6 @@ async fn main() { } }); - // Запуск P2P HTTPS-сервера let tls_config = RustlsConfig::from_der(vec![cert_der], key_der) .await .expect("Ошибка конфигурации TLS"); @@ -327,11 +319,11 @@ async fn main() { .expect("Ошибка P2P HTTPS-сервера"); }); - // Запуск локального UI-сервера (127.0.0.1) let ui_router = Router::new() .route("/", get(ui::ui_index)) .route("/ui/state", get(ui::ui_get_state)) .route("/ui/manual-peer", post(ui::ui_add_manual_peer)) + .route("/ui/scan-range", post(ui::ui_scan_range)) .route("/ui/ping", post(ui::ui_ping_peers)) .route("/ui/trust", post(ui::ui_trust_peer)) .route("/ui/clear-ram", post(ui::ui_clear_ram)) diff --git a/src/models.rs b/src/models.rs index 6638f3a..703e7bd 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, VecDeque}, + net::Ipv4Addr, sync::{Arc, RwLock}, time::{SystemTime, UNIX_EPOCH}, }; @@ -56,6 +57,30 @@ pub struct BeaconPacket { pub fingerprint: String, } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TransferProgress { + pub id: String, + pub filename: String, + pub peer_name: String, + /// "out" (отправка) или "in" (прием) + pub direction: String, + pub transferred_bytes: usize, + pub total_bytes: usize, + pub percent: u8, + pub done: bool, + pub error: Option, + pub updated_at: u64, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct ScanProgress { + pub active: bool, + pub range: String, + pub scanned: usize, + pub total: usize, + pub found: usize, +} + pub struct AppState { pub device_name: String, pub my_ip: String, @@ -64,11 +89,11 @@ pub struct AppState { pub ping_interval_sec: u64, pub ram_texts: RwLock>, pub disk_media: RwLock>, - /// Ключ таблицы — постоянный fingerprint (ID) устройства pub peers: Arc>>, pub http_client: reqwest::Client, pub probe_client: reqwest::Client, - pub transfers: RwLock>, + pub transfers: RwLock>, + pub scan_status: RwLock, pub logs: RwLock>, } @@ -83,13 +108,26 @@ impl AppState { logs.push_front(format!("[{}] {}", now_sec(), text)); } - /// Добавляет или обновляет найденное устройство по его постоянному fingerprint - pub fn upsert_peer(&self, name: String, ip: String, port: u16, fingerprint: String) { + pub fn upsert_transfer(&self, item: TransferProgress) { + let mut list = self.transfers.write().unwrap(); + if let Some(existing) = list.iter_mut().find(|t| t.id == item.id) { + *existing = item; + } else { + list.insert(0, item); + } + } + + pub fn remove_transfer(&self, id: &str) { + let mut list = self.transfers.write().unwrap(); + list.retain(|t| t.id != id); + } + + pub fn upsert_peer(&self, name: String, ip: String, port: u16, fingerprint: String) -> bool { if fingerprint == self.my_fingerprint || fingerprint.len() < 8 { - return; + return false; } if ip.is_empty() || ip == "0.0.0.0" || ip == "127.0.0.1" { - return; + return false; } let pin_code = fingerprint[..8].to_uppercase(); @@ -105,6 +143,7 @@ impl AppState { existing.ips.remove(pos); } existing.ips.insert(0, ip); + false } else { drop(peers); self.add_log(format!( @@ -126,6 +165,7 @@ impl AppState { last_seen: now, }, ); + true } } } @@ -155,6 +195,12 @@ pub struct UiManualPeerRequest { pub port: Option, } +#[derive(Deserialize)] +pub struct UiScanRangeRequest { + pub range: String, + pub port: Option, +} + pub fn now_sec() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -200,3 +246,94 @@ pub fn sanitize_filename(name: &str) -> String { trimmed.to_string() } } + +/// Парсер диапазонов IP-адресов: +/// Поддерживает: "all", "192.168.1.1-254", "192.168.1.10-50", "192.168.1.10-192.168.1.80", "192.168.1.0/24" +pub fn parse_ip_range(input: &str, my_ip: &str) -> Result<(String, Vec), String> { + let raw = input.trim(); + + let effective = if raw.is_empty() || raw.eq_ignore_ascii_case("all") || raw.eq_ignore_ascii_case("full") { + let base: Ipv4Addr = my_ip + .parse() + .map_err(|_| "Не удалось определить подсеть автоматически. Введите диапазон вручную.".to_string())?; + let o = base.octets(); + format!("{}.{}.{}.1-254", o[0], o[1], o[2]) + } else { + raw.to_string() + }; + + let mut result = Vec::new(); + + for part in effective.split(',') { + let token = part.trim(); + if token.is_empty() { + continue; + } + + if let Some(cidr_base) = token.strip_suffix("/24") { + let ip: Ipv4Addr = cidr_base + .trim() + .parse() + .map_err(|_| format!("Некорректный CIDR адрес: {}", token))?; + let o = ip.octets(); + for last in 1..=254u8 { + result.push(format!("{}.{}.{}.{}", o[0], o[1], o[2], last)); + } + } else if let Some((left, right)) = token.split_once('-') { + let left = left.trim(); + let right = right.trim(); + let start_ip: Ipv4Addr = left + .parse() + .map_err(|_| format!("Некорректный начальный IP: {}", left))?; + + if right.contains('.') { + let end_ip: Ipv4Addr = right + .parse() + .map_err(|_| format!("Некорректный конечный IP: {}", right))?; + let start_u = u32::from(start_ip); + let end_u = u32::from(end_ip); + if end_u < start_u { + return Err("Конечный IP меньше начального".into()); + } + if end_u - start_u > 1024 { + return Err("Диапазон слишком велик (максимум 1024 адреса за раз)".into()); + } + for val in start_u..=end_u { + let ip = Ipv4Addr::from(val); + let last = ip.octets()[3]; + if last != 0 && last != 255 { + result.push(ip.to_string()); + } + } + } else { + let end_last: u8 = right + .parse() + .map_err(|_| format!("Некорректный конец диапазона: {}", right))?; + let o = start_ip.octets(); + let start_last = o[3]; + if end_last < start_last { + return Err(format!("Конец диапазона ({}) меньше начала ({})", end_last, start_last)); + } + for last in start_last..=end_last { + if last != 0 && last != 255 { + result.push(format!("{}.{}.{}.{}", o[0], o[1], o[2], last)); + } + } + } + } else { + let ip: Ipv4Addr = token + .parse() + .map_err(|_| format!("Некорректный IP-адрес: {}", token))?; + result.push(ip.to_string()); + } + } + + result.retain(|ip| ip != my_ip && ip != "127.0.0.1" && ip != "0.0.0.0"); + result.dedup(); + + if result.is_empty() { + return Err("Диапазон не содержит доступных для сканирования IP-адресов".into()); + } + + Ok((effective, result)) +} diff --git a/src/p2p.rs b/src/p2p.rs index 9418f25..d786767 100644 --- a/src/p2p.rs +++ b/src/p2p.rs @@ -1,6 +1,6 @@ use crate::models::{ now_sec, sanitize_filename, url_decode, AppState, IncomingTextPayload, MediaItem, TextItem, - MAX_RAM_TEXTS, MEDIA_DIR, + TransferProgress, MAX_RAM_TEXTS, MEDIA_DIR, }; use axum::{ body::Bytes, @@ -8,7 +8,8 @@ use axum::{ http::{header, HeaderMap, StatusCode}, Json, }; -use std::{path::PathBuf, sync::Arc}; +use std::{path::PathBuf, sync::Arc, time::Duration}; +use tokio::io::AsyncWriteExt; /// Публичный эндпоинт рукопожатия и взаимного обнаружения pub async fn p2p_info( @@ -68,7 +69,7 @@ pub async fn p2p_receive_text( StatusCode::OK } -/// Прием медиаконтента -> сохраняется НА ДИСК в папку ./received_media +/// Прием медиаконтента (поддерживает почанковую передачу с прогресс-баром и записью на диск) pub async fn p2p_receive_media( State(state): State>, headers: HeaderMap, @@ -91,39 +92,104 @@ pub async fn p2p_receive_media( .unwrap_or("application/octet-stream") .to_string(); + let transfer_id = headers + .get("X-Transfer-Id") + .and_then(|v| v.to_str().ok()) + .map(sanitize_filename) + .unwrap_or_else(|| format!("rx_{}", now_sec())); + + let offset: usize = headers + .get("X-Offset") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + let total_size: usize = headers + .get("X-Total-Size") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()) + .unwrap_or_else(|| body.len()); + tokio::fs::create_dir_all(MEDIA_DIR) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let safe_name = sanitize_filename(raw_filename); - let final_filename = format!("{}_{}", now_sec(), safe_name); - let file_path = PathBuf::from(MEDIA_DIR).join(&final_filename); + let temp_path = PathBuf::from(MEDIA_DIR).join(format!(".part_{}_{}", transfer_id, safe_name)); - tokio::fs::write(&file_path, &body) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + if offset == 0 { + tokio::fs::write(&temp_path, &body) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } else { + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&temp_path) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + file.write_all(&body) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } - let abs_path = std::fs::canonicalize(&file_path) - .unwrap_or_else(|_| file_path.clone()) - .display() - .to_string(); + let received_bytes = offset + body.len(); + let is_done = received_bytes >= total_size; + let percent = if total_size == 0 { + 100 + } else { + ((received_bytes as f64 / total_size as f64) * 100.0).min(100.0) as u8 + }; - state.add_log(format!( - "Сохранен медиафайл '{}' ({} байт) от '{}'", - final_filename, - body.len(), - sender - )); - - let mut media_list = state.disk_media.write().unwrap(); - media_list.push_front(MediaItem { - sender, - filename: final_filename, - saved_path: abs_path, - mime, - size: body.len(), - timestamp: now_sec(), + let rx_id = format!("in_{}", transfer_id); + state.upsert_transfer(TransferProgress { + id: rx_id.clone(), + filename: safe_name.clone(), + peer_name: sender.clone(), + direction: "in".to_string(), + transferred_bytes: received_bytes, + total_bytes: total_size, + percent, + done: is_done, + error: None, + updated_at: now_sec(), }); + if is_done { + let final_filename = format!("{}_{}", now_sec(), safe_name); + let final_path = PathBuf::from(MEDIA_DIR).join(&final_filename); + + tokio::fs::rename(&temp_path, &final_path) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let abs_path = std::fs::canonicalize(&final_path) + .unwrap_or_else(|_| final_path.clone()) + .display() + .to_string(); + + state.add_log(format!( + "Принят медиафайл '{}' ({} байт) от '{}'", + final_filename, total_size, sender + )); + + let mut media_list = state.disk_media.write().unwrap(); + media_list.push_front(MediaItem { + sender, + filename: final_filename, + saved_path: abs_path, + mime, + size: total_size, + timestamp: now_sec(), + }); + drop(media_list); + + let st_cleanup = state.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(4)).await; + st_cleanup.remove_transfer(&rx_id); + }); + } + Ok(StatusCode::OK) } diff --git a/src/static/index.html b/src/static/index.html index b6c3e95..9a829f9 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -17,13 +17,14 @@ .tag { font-size: 0.75rem; padding: 3px 8px; border-radius: 6px; font-weight: 600; } .tag-ram { background: rgba(16, 185, 129, 0.2); color: #34d399; } .tag-disk { background: rgba(59, 130, 246, 0.2); color: #60a5fa; } - input[type="text"], select, textarea, input[type="file"] { width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: 0.95rem; } + input[type="text"], select, textarea, input[type="file"] { width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: 0.92rem; } textarea { resize: vertical; min-height: 80px; } button { background: var(--accent); color: white; border: none; border-radius: 8px; padding: 10px 16px; font-weight: 600; cursor: pointer; transition: opacity 0.15s; } button:hover { opacity: 0.9; } + button:disabled { opacity: 0.5; cursor: not-allowed; } button.btn-green { background: var(--green); } button.btn-red { background: var(--red); } - button.btn-sm { padding: 6px 12px; font-size: 0.82rem; } + button.btn-sm { padding: 7px 12px; font-size: 0.82rem; } .peer-item, .msg-item { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 12px; display: flex; justify-content: space-between; align-items: center; gap: 10px; } .msg-item { flex-direction: column; align-items: flex-start; } .msg-meta { width: 100%; display: flex; justify-content: space-between; font-size: 0.8rem; color: var(--muted); } @@ -33,6 +34,10 @@ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; } .dot-on { background: #10b981; } .dot-off { background: #64748b; } + .progress-wrap { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; } + .progress-bar-bg { width: 100%; height: 10px; background: #0b1120; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); } + .progress-bar-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #10b981); transition: width 0.2s ease; } + .sub-box { background: rgba(15, 23, 42, 0.55); border: 1px solid var(--border); border-radius: 8px; padding: 10px; display: flex; flex-direction: column; gap: 8px; } #status { grid-column: 1 / -1; padding: 10px 16px; border-radius: 8px; display: none; font-weight: 500; } @@ -51,16 +56,36 @@
- +

1. Устройства в сети - +

- + +
- - + + +
+ + +
+
Сканер диапазона IP-адресов:
+
+ + + +
+
@@ -68,7 +93,7 @@
- +

2. Отправка данных (по нажатию)

@@ -79,7 +104,10 @@
- + + + +
@@ -100,6 +128,7 @@ diff --git a/src/ui.rs b/src/ui.rs index d50f804..881883f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,6 +1,7 @@ use crate::models::{ - sanitize_filename, AppState, IncomingTextPayload, MediaItem, PeerDevice, TextItem, - UiManualPeerRequest, UiSendTextRequest, UiTrustRequest, MEDIA_DIR, + now_sec, parse_ip_range, sanitize_filename, url_decode, AppState, IncomingTextPayload, + MediaItem, PeerDevice, ScanProgress, TextItem, TransferProgress, UiManualPeerRequest, + UiScanRangeRequest, UiSendTextRequest, UiTrustRequest, MEDIA_DIR, }; use axum::{ body::Bytes, @@ -9,7 +10,8 @@ use axum::{ response::{Html, IntoResponse}, Json, }; -use std::{path::PathBuf, sync::Arc}; +use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; +use tokio::sync::Semaphore; const INDEX_HTML: &str = include_str!("static/index.html"); @@ -18,17 +20,14 @@ pub async fn ui_index() -> Html<&'static str> { } pub async fn ui_get_state(State(state): State>) -> Json { - let mut peers_list: Vec = state - .peers - .read() - .unwrap() - .values() - .cloned() - .collect(); + let mut peers_list: Vec = + state.peers.read().unwrap().values().cloned().collect(); peers_list.sort_by(|a, b| a.name.cmp(&b.name)); let texts: Vec = state.ram_texts.read().unwrap().iter().cloned().collect(); let media: Vec = state.disk_media.read().unwrap().iter().cloned().collect(); + let transfers: Vec = state.transfers.read().unwrap().clone(); + let scan = state.scan_status.read().unwrap().clone(); let logs: Vec = state.logs.read().unwrap().iter().cloned().collect(); Json(serde_json::json!({ @@ -40,6 +39,8 @@ pub async fn ui_get_state(State(state): State>) -> Json>, + Json(req): Json, +) -> Result { + { + let scan = state.scan_status.read().unwrap(); + if scan.active { + return Err((StatusCode::CONFLICT, "Сканирование уже выполняется".into())); + } + } + + let port = req.port.unwrap_or(state.p2p_port); + let (range_label, ips) = + parse_ip_range(&req.range, &state.my_ip).map_err(|e| (StatusCode::BAD_REQUEST, e))?; + + let total = ips.len(); + { + let mut scan = state.scan_status.write().unwrap(); + *scan = ScanProgress { + active: true, + range: range_label.clone(), + scanned: 0, + total, + found: 0, + }; + } + + state.add_log(format!( + "Запущено сканирование диапазона {} ({} адресов, порт {})", + range_label, total, port + )); + + let state_bg = state.clone(); + tokio::spawn(async move { + let semaphore = Arc::new(Semaphore::new(48)); + let mut handles = Vec::with_capacity(total); + + for ip in ips { + let sem = semaphore.clone(); + let st = state_bg.clone(); + handles.push(tokio::spawn(async move { + let _permit = sem.acquire().await.unwrap(); + + // Быстрая проверка открытости TCP-порта (350 мс) перед HTTPS-рукопожатием + let sock_addr: Option = format!("{}:{}", ip, port).parse().ok(); + let mut found_node = false; + + if let Some(addr) = sock_addr { + if tokio::time::timeout( + Duration::from_millis(350), + tokio::net::TcpStream::connect(addr), + ) + .await + .map(|r| r.is_ok()) + .unwrap_or(false) + { + let url = format!("https://{}:{}/p2p/info", ip, port); + if let Ok(resp) = st + .probe_client + .get(&url) + .header("X-Node-Fp", &st.my_fingerprint) + .header("X-Node-Name", &st.device_name) + .header("X-Node-Ip", &st.my_ip) + .header("X-Node-Port", st.p2p_port.to_string()) + .send() + .await + { + if let Ok(info) = resp.json::().await { + let name = info["name"].as_str().unwrap_or("Node").to_string(); + let fp = info["fingerprint"].as_str().unwrap_or("").to_string(); + let r_port = info["port"].as_u64().unwrap_or(port as u64) as u16; + if fp.len() >= 8 { + st.upsert_peer(name, ip, r_port, fp); + found_node = true; + } + } + } + } + } + + let mut scan = st.scan_status.write().unwrap(); + scan.scanned += 1; + if found_node { + scan.found += 1; + } + })); + } + + for h in handles { + let _ = h.await; + } + + let found_total = { + let mut scan = state_bg.scan_status.write().unwrap(); + scan.active = false; + scan.found + }; + + state_bg.add_log(format!( + "Сканирование {} завершено. Найдено устройств: {}", + range_label, found_total + )); + }); + + Ok(StatusCode::OK) +} + pub async fn ui_ping_peers(State(state): State>) -> StatusCode { let state_clone = state.clone(); tokio::spawn(async move { @@ -131,7 +240,10 @@ pub async fn ui_send_text( if !peer.verified { return Err(( StatusCode::FORBIDDEN, - format!("Сначала заверьте сертификат устройства '{}' [PIN: {}]!", peer.name, peer.pin_code), + format!( + "Сначала заверьте сертификат устройства '{}' [PIN: {}]!", + peer.name, peer.pin_code + ), )); } let ip = peer @@ -165,6 +277,7 @@ pub async fn ui_send_text( } } +/// Почанковая отправка медиафайла с реальным отслеживанием прогресса по сети pub async fn ui_send_media( State(state): State>, headers: HeaderMap, @@ -175,15 +288,19 @@ pub async fn ui_send_media( .and_then(|v| v.to_str().ok()) .ok_or((StatusCode::BAD_REQUEST, "Не указан ID получателя".to_string()))?; - let filename = headers + let raw_filename = headers .get("X-Filename") .and_then(|v| v.to_str().ok()) - .unwrap_or("file.bin"); + .unwrap_or("file.bin") + .to_string(); + + let display_name = sanitize_filename(&url_decode(&raw_filename)); let mime = headers .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) - .unwrap_or("application/octet-stream"); + .unwrap_or("application/octet-stream") + .to_string(); let (target_ip, port, peer_name) = { let peers = state.peers.read().unwrap(); @@ -193,7 +310,10 @@ pub async fn ui_send_media( if !peer.verified { return Err(( StatusCode::FORBIDDEN, - format!("Сначала заверьте сертификат устройства '{}' [PIN: {}]!", peer.name, peer.pin_code), + format!( + "Сначала заверьте сертификат устройства '{}' [PIN: {}]!", + peer.name, peer.pin_code + ), )); } let ip = peer @@ -204,25 +324,103 @@ pub async fn ui_send_media( (ip, peer.port, peer.name.clone()) }; + let total_size = body.len(); + let transfer_id = format!("tx_{}_{}", now_sec(), total_size); let url = format!("https://{}:{}/p2p/media", target_ip, port); - let resp = state - .http_client - .post(&url) - .header("X-Sender", &state.device_name) - .header("X-Filename", filename) - .header(header::CONTENT_TYPE, mime) - .body(body) - .send() - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Ошибка TLS/Сети: {}", e)))?; - - if resp.status().is_success() { - state.add_log(format!("Медиафайл отправлен на диск устройства '{}'", peer_name)); - Ok(StatusCode::OK) + // Адаптивный размер чанка для плавного обновления прогресс-бара на любых файлах + let chunk_size = if total_size < 1024 * 1024 { + 32 * 1024 // 32 КБ для файлов до 1 МБ + } else if total_size < 15 * 1024 * 1024 { + 128 * 1024 // 128 КБ для файлов до 15 МБ } else { - Err((resp.status(), "Удаленный узел отклонил медиафайл".to_string())) + 512 * 1024 // 512 КБ для крупных файлов + }; + + state.upsert_transfer(TransferProgress { + id: transfer_id.clone(), + filename: display_name.clone(), + peer_name: peer_name.clone(), + direction: "out".to_string(), + transferred_bytes: 0, + total_bytes: total_size, + percent: 0, + done: false, + error: None, + updated_at: now_sec(), + }); + + let mut offset = 0usize; + loop { + let end = (offset + chunk_size).min(total_size); + let chunk = body.slice(offset..end); + + let send_res = state + .http_client + .post(&url) + .header("X-Sender", &state.device_name) + .header("X-Filename", &raw_filename) + .header("X-Transfer-Id", &transfer_id) + .header("X-Offset", offset.to_string()) + .header("X-Total-Size", total_size.to_string()) + .header(header::CONTENT_TYPE, &mime) + .body(chunk) + .send() + .await; + + match send_res { + Ok(resp) if resp.status().is_success() => { + offset = end; + let is_done = offset >= total_size; + let percent = if total_size == 0 { + 100 + } else { + ((offset as f64 / total_size as f64) * 100.0).min(100.0) as u8 + }; + + state.upsert_transfer(TransferProgress { + id: transfer_id.clone(), + filename: display_name.clone(), + peer_name: peer_name.clone(), + direction: "out".to_string(), + transferred_bytes: offset, + total_bytes: total_size, + percent, + done: is_done, + error: None, + updated_at: now_sec(), + }); + + if is_done { + break; + } + } + Ok(resp) => { + let err_msg = format!("Узел отклонил чанк (код {})", resp.status()); + state.remove_transfer(&transfer_id); + return Err((resp.status(), err_msg)); + } + Err(e) => { + let err_msg = format!("Ошибка сети при передаче файла: {}", e); + state.remove_transfer(&transfer_id); + return Err((StatusCode::BAD_GATEWAY, err_msg)); + } + } } + + state.add_log(format!( + "Медиафайл '{}' ({} байт) успешно доставлен на '{}'", + display_name, total_size, peer_name + )); + + let st_cleanup = state.clone(); + let tid_cleanup = transfer_id.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(4)).await; + st_cleanup.remove_transfer(&tid_cleanup); + }); + + Ok(StatusCode::OK) } pub async fn ui_serve_saved_file(Path(filename): Path) -> impl IntoResponse {