a.0.0.4
This commit is contained in:
1 parent
5723bfc532
commit
e88dc07696
5 files changed
+579
-70
No files matched your search
+4
-12
@@ -11,7 +11,8 @@ use axum::{
|
|||||||
use axum_server::tls_rustls::RustlsConfig;
|
use axum_server::tls_rustls::RustlsConfig;
|
||||||
use local_ip_address::local_ip;
|
use local_ip_address::local_ip;
|
||||||
use models::{
|
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 socket2::{Domain, Protocol, Socket, Type};
|
||||||
use std::{
|
use std::{
|
||||||
@@ -22,7 +23,6 @@ use std::{
|
|||||||
};
|
};
|
||||||
use tokio::net::UdpSocket;
|
use tokio::net::UdpSocket;
|
||||||
|
|
||||||
/// Точное определение основного LAN IP через таблицу маршрутизации ядра ОС (игнорирует Docker/WSL)
|
|
||||||
fn detect_lan_ip() -> String {
|
fn detect_lan_ip() -> String {
|
||||||
if let Ok(sock) = StdUdpSocket::bind("0.0.0.0:0") {
|
if let Ok(sock) = StdUdpSocket::bind("0.0.0.0:0") {
|
||||||
if sock.connect("8.8.8.8:80").is_ok() {
|
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())
|
.unwrap_or_else(|_| "127.0.0.1".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Вычисляет широковещательный адрес подсети /24 (например, 192.168.1.255)
|
|
||||||
fn subnet_broadcast_addr(ip_str: &str, port: u16) -> Option<String> {
|
fn subnet_broadcast_addr(ip_str: &str, port: u16) -> Option<String> {
|
||||||
let ipv4: Ipv4Addr = ip_str.parse().ok()?;
|
let ipv4: Ipv4Addr = ip_str.parse().ok()?;
|
||||||
let octets = ipv4.octets();
|
let octets = ipv4.octets();
|
||||||
Some(format!("{}.{}.{}.255:{}", octets[0], octets[1], octets[2], port))
|
Some(format!("{}.{}.{}.255:{}", octets[0], octets[1], octets[2], port))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Опрос всех известных узлов и актуализация их статуса
|
|
||||||
pub async fn ping_all_peers(state: &Arc<AppState>) {
|
pub async fn ping_all_peers(state: &Arc<AppState>) {
|
||||||
let targets: Vec<(String, Vec<String>, u16)> = {
|
let targets: Vec<(String, Vec<String>, u16)> = {
|
||||||
let guard = state.peers.read().unwrap();
|
let guard = state.peers.read().unwrap();
|
||||||
@@ -96,9 +94,7 @@ pub async fn ping_all_peers(state: &Arc<AppState>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Фоновый UDP-маяк (рассылает пакеты и в направленный broadcast подсети, и в 255.255.255.255)
|
|
||||||
fn start_udp_discovery(state: Arc<AppState>) {
|
fn start_udp_discovery(state: Arc<AppState>) {
|
||||||
// 1. Поток рассылки маяков
|
|
||||||
let tx_state = state.clone();
|
let tx_state = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let bind_target = format!("{}:0", tx_state.my_ip);
|
let bind_target = format!("{}:0", tx_state.my_ip);
|
||||||
@@ -135,7 +131,6 @@ fn start_udp_discovery(state: Arc<AppState>) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Поток приема маяков
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let bind_addr: SocketAddr = format!("0.0.0.0:{}", DISCOVERY_PORT).parse().unwrap();
|
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)) {
|
let socket = match Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)) {
|
||||||
@@ -263,7 +258,6 @@ async fn main() {
|
|||||||
.await
|
.await
|
||||||
.expect("Не удалось создать папку received_media");
|
.expect("Не удалось создать папку received_media");
|
||||||
|
|
||||||
// Загружаем постоянный сертификат и PIN-код (или создаем его один раз)
|
|
||||||
let (cert_der, key_der, my_fingerprint) = tls::load_or_create_identity(&my_ip, p2p_port);
|
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 peers = Arc::new(RwLock::new(HashMap::new()));
|
||||||
let http_client = tls::build_pinned_http_client(peers.clone());
|
let http_client = tls::build_pinned_http_client(peers.clone());
|
||||||
@@ -281,6 +275,7 @@ async fn main() {
|
|||||||
http_client,
|
http_client,
|
||||||
probe_client,
|
probe_client,
|
||||||
transfers: RwLock::new(Vec::new()),
|
transfers: RwLock::new(Vec::new()),
|
||||||
|
scan_status: RwLock::new(ScanProgress::default()),
|
||||||
logs: RwLock::new(VecDeque::new()),
|
logs: RwLock::new(VecDeque::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -292,10 +287,8 @@ async fn main() {
|
|||||||
&my_fingerprint[..8].to_uppercase()
|
&my_fingerprint[..8].to_uppercase()
|
||||||
));
|
));
|
||||||
|
|
||||||
// Запуск UDP-обнаружения
|
|
||||||
start_udp_discovery(state.clone());
|
start_udp_discovery(state.clone());
|
||||||
|
|
||||||
// Фоновый опрос известных узлов
|
|
||||||
let ping_state = state.clone();
|
let ping_state = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
@@ -304,7 +297,6 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Запуск P2P HTTPS-сервера
|
|
||||||
let tls_config = RustlsConfig::from_der(vec![cert_der], key_der)
|
let tls_config = RustlsConfig::from_der(vec![cert_der], key_der)
|
||||||
.await
|
.await
|
||||||
.expect("Ошибка конфигурации TLS");
|
.expect("Ошибка конфигурации TLS");
|
||||||
@@ -327,11 +319,11 @@ async fn main() {
|
|||||||
.expect("Ошибка P2P HTTPS-сервера");
|
.expect("Ошибка P2P HTTPS-сервера");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Запуск локального UI-сервера (127.0.0.1)
|
|
||||||
let ui_router = Router::new()
|
let ui_router = Router::new()
|
||||||
.route("/", get(ui::ui_index))
|
.route("/", get(ui::ui_index))
|
||||||
.route("/ui/state", get(ui::ui_get_state))
|
.route("/ui/state", get(ui::ui_get_state))
|
||||||
.route("/ui/manual-peer", post(ui::ui_add_manual_peer))
|
.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/ping", post(ui::ui_ping_peers))
|
||||||
.route("/ui/trust", post(ui::ui_trust_peer))
|
.route("/ui/trust", post(ui::ui_trust_peer))
|
||||||
.route("/ui/clear-ram", post(ui::ui_clear_ram))
|
.route("/ui/clear-ram", post(ui::ui_clear_ram))
|
||||||
|
|||||||
+143
-6
@@ -1,6 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, VecDeque},
|
collections::{HashMap, VecDeque},
|
||||||
|
net::Ipv4Addr,
|
||||||
sync::{Arc, RwLock},
|
sync::{Arc, RwLock},
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
@@ -56,6 +57,30 @@ pub struct BeaconPacket {
|
|||||||
pub fingerprint: String,
|
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<String>,
|
||||||
|
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 struct AppState {
|
||||||
pub device_name: String,
|
pub device_name: String,
|
||||||
pub my_ip: String,
|
pub my_ip: String,
|
||||||
@@ -64,11 +89,11 @@ pub struct AppState {
|
|||||||
pub ping_interval_sec: u64,
|
pub ping_interval_sec: u64,
|
||||||
pub ram_texts: RwLock<VecDeque<TextItem>>,
|
pub ram_texts: RwLock<VecDeque<TextItem>>,
|
||||||
pub disk_media: RwLock<VecDeque<MediaItem>>,
|
pub disk_media: RwLock<VecDeque<MediaItem>>,
|
||||||
/// Ключ таблицы — постоянный fingerprint (ID) устройства
|
|
||||||
pub peers: Arc<RwLock<HashMap<String, PeerDevice>>>,
|
pub peers: Arc<RwLock<HashMap<String, PeerDevice>>>,
|
||||||
pub http_client: reqwest::Client,
|
pub http_client: reqwest::Client,
|
||||||
pub probe_client: reqwest::Client,
|
pub probe_client: reqwest::Client,
|
||||||
pub transfers: RwLock<Vec<String>>,
|
pub transfers: RwLock<Vec<TransferProgress>>,
|
||||||
|
pub scan_status: RwLock<ScanProgress>,
|
||||||
pub logs: RwLock<VecDeque<String>>,
|
pub logs: RwLock<VecDeque<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,13 +108,26 @@ impl AppState {
|
|||||||
logs.push_front(format!("[{}] {}", now_sec(), text));
|
logs.push_front(format!("[{}] {}", now_sec(), text));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Добавляет или обновляет найденное устройство по его постоянному fingerprint
|
pub fn upsert_transfer(&self, item: TransferProgress) {
|
||||||
pub fn upsert_peer(&self, name: String, ip: String, port: u16, fingerprint: String) {
|
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 {
|
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" {
|
if ip.is_empty() || ip == "0.0.0.0" || ip == "127.0.0.1" {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let pin_code = fingerprint[..8].to_uppercase();
|
let pin_code = fingerprint[..8].to_uppercase();
|
||||||
@@ -105,6 +143,7 @@ impl AppState {
|
|||||||
existing.ips.remove(pos);
|
existing.ips.remove(pos);
|
||||||
}
|
}
|
||||||
existing.ips.insert(0, ip);
|
existing.ips.insert(0, ip);
|
||||||
|
false
|
||||||
} else {
|
} else {
|
||||||
drop(peers);
|
drop(peers);
|
||||||
self.add_log(format!(
|
self.add_log(format!(
|
||||||
@@ -126,6 +165,7 @@ impl AppState {
|
|||||||
last_seen: now,
|
last_seen: now,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,6 +195,12 @@ pub struct UiManualPeerRequest {
|
|||||||
pub port: Option<u16>,
|
pub port: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UiScanRangeRequest {
|
||||||
|
pub range: String,
|
||||||
|
pub port: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn now_sec() -> u64 {
|
pub fn now_sec() -> u64 {
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
@@ -200,3 +246,94 @@ pub fn sanitize_filename(name: &str) -> String {
|
|||||||
trimmed.to_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>), 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))
|
||||||
|
}
|
||||||
+79
-13
@@ -1,6 +1,6 @@
|
|||||||
use crate::models::{
|
use crate::models::{
|
||||||
now_sec, sanitize_filename, url_decode, AppState, IncomingTextPayload, MediaItem, TextItem,
|
now_sec, sanitize_filename, url_decode, AppState, IncomingTextPayload, MediaItem, TextItem,
|
||||||
MAX_RAM_TEXTS, MEDIA_DIR,
|
TransferProgress, MAX_RAM_TEXTS, MEDIA_DIR,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
@@ -8,7 +8,8 @@ use axum::{
|
|||||||
http::{header, HeaderMap, StatusCode},
|
http::{header, HeaderMap, StatusCode},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use std::{path::PathBuf, sync::Arc};
|
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
/// Публичный эндпоинт рукопожатия и взаимного обнаружения
|
/// Публичный эндпоинт рукопожатия и взаимного обнаружения
|
||||||
pub async fn p2p_info(
|
pub async fn p2p_info(
|
||||||
@@ -68,7 +69,7 @@ pub async fn p2p_receive_text(
|
|||||||
StatusCode::OK
|
StatusCode::OK
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Прием медиаконтента -> сохраняется НА ДИСК в папку ./received_media
|
/// Прием медиаконтента (поддерживает почанковую передачу с прогресс-баром и записью на диск)
|
||||||
pub async fn p2p_receive_media(
|
pub async fn p2p_receive_media(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -91,28 +92,85 @@ pub async fn p2p_receive_media(
|
|||||||
.unwrap_or("application/octet-stream")
|
.unwrap_or("application/octet-stream")
|
||||||
.to_string();
|
.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)
|
tokio::fs::create_dir_all(MEDIA_DIR)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let safe_name = sanitize_filename(raw_filename);
|
let safe_name = sanitize_filename(raw_filename);
|
||||||
let final_filename = format!("{}_{}", now_sec(), safe_name);
|
let temp_path = PathBuf::from(MEDIA_DIR).join(format!(".part_{}_{}", transfer_id, safe_name));
|
||||||
let file_path = PathBuf::from(MEDIA_DIR).join(&final_filename);
|
|
||||||
|
|
||||||
tokio::fs::write(&file_path, &body)
|
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 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
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let abs_path = std::fs::canonicalize(&file_path)
|
let abs_path = std::fs::canonicalize(&final_path)
|
||||||
.unwrap_or_else(|_| file_path.clone())
|
.unwrap_or_else(|_| final_path.clone())
|
||||||
.display()
|
.display()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
state.add_log(format!(
|
state.add_log(format!(
|
||||||
"Сохранен медиафайл '{}' ({} байт) от '{}'",
|
"Принят медиафайл '{}' ({} байт) от '{}'",
|
||||||
final_filename,
|
final_filename, total_size, sender
|
||||||
body.len(),
|
|
||||||
sender
|
|
||||||
));
|
));
|
||||||
|
|
||||||
let mut media_list = state.disk_media.write().unwrap();
|
let mut media_list = state.disk_media.write().unwrap();
|
||||||
@@ -121,9 +179,17 @@ pub async fn p2p_receive_media(
|
|||||||
filename: final_filename,
|
filename: final_filename,
|
||||||
saved_path: abs_path,
|
saved_path: abs_path,
|
||||||
mime,
|
mime,
|
||||||
size: body.len(),
|
size: total_size,
|
||||||
timestamp: now_sec(),
|
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)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
+130
-14
@@ -17,13 +17,14 @@
|
|||||||
.tag { font-size: 0.75rem; padding: 3px 8px; border-radius: 6px; font-weight: 600; }
|
.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-ram { background: rgba(16, 185, 129, 0.2); color: #34d399; }
|
||||||
.tag-disk { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
|
.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; }
|
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 { 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:hover { opacity: 0.9; }
|
||||||
|
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
button.btn-green { background: var(--green); }
|
button.btn-green { background: var(--green); }
|
||||||
button.btn-red { background: var(--red); }
|
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; }
|
.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-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); }
|
.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 { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
||||||
.dot-on { background: #10b981; }
|
.dot-on { background: #10b981; }
|
||||||
.dot-off { background: #64748b; }
|
.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; }
|
#status { grid-column: 1 / -1; padding: 10px 16px; border-radius: 8px; display: none; font-weight: 500; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -51,16 +56,36 @@
|
|||||||
|
|
||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
|
|
||||||
<!-- 1. Устройства в сети -->
|
<!-- 1. Устройства в сети и сканер диапазона IP -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>
|
<h2>
|
||||||
<span>1. Устройства в сети</span>
|
<span>1. Устройства в сети</span>
|
||||||
<button class="btn-sm" onclick="pingPeers()">Обновить сеть</button>
|
<button class="btn-sm" onclick="pingPeers()">Опросить известных</button>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
<!-- Одиночное добавление по IP -->
|
||||||
<div style="display: flex; gap: 8px;">
|
<div style="display: flex; gap: 8px;">
|
||||||
<input type="text" id="manual-ip-input" placeholder="IP:порт (напр. 192.168.1.55 или 192.168.1.55:8443)">
|
<input type="text" id="manual-ip-input" placeholder="Один IP:порт (напр. 192.168.1.55 или 192.168.1.55:8443)">
|
||||||
<button class="btn-sm" style="white-space: nowrap;" onclick="addManualPeer()">Найти по IP</button>
|
<button class="btn-sm" style="white-space: nowrap;" onclick="addManualPeer()">Найти IP</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Сканер диапазона IP -->
|
||||||
|
<div class="sub-box">
|
||||||
|
<div style="font-size: 0.82rem; color: var(--muted); font-weight: 600;">Сканер диапазона IP-адресов:</div>
|
||||||
|
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<input type="text" id="scan-range-input" style="flex: 1; min-width: 190px;" placeholder="Напр. 192.168.1.1-254 или 192.168.1.10-50">
|
||||||
|
<button class="btn-sm" id="btn-scan-range" onclick="scanIpRange(false)">Сканировать диапазон</button>
|
||||||
|
<button class="btn-sm btn-green" id="btn-scan-all" onclick="scanIpRange(true)">Вся подсеть (.1–254)</button>
|
||||||
|
</div>
|
||||||
|
<div id="scan-progress-box" style="display: none;">
|
||||||
|
<div style="display:flex; justify-content:space-between; font-size:0.78rem; color:var(--muted); margin-bottom:4px;">
|
||||||
|
<span id="scan-label">Сканирование...</span>
|
||||||
|
<span id="scan-numbers">0 / 0</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar-bg">
|
||||||
|
<div class="progress-bar-fill" id="scan-bar-fill" style="width: 0%;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="peers-list" style="display:flex; flex-direction:column; gap:8px;">
|
<div id="peers-list" style="display:flex; flex-direction:column; gap:8px;">
|
||||||
@@ -68,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 2. Отправка данных -->
|
<!-- 2. Отправка данных и прогресс-бар передачи -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>2. Отправка данных (по нажатию)</h2>
|
<h2>2. Отправка данных (по нажатию)</h2>
|
||||||
<label style="font-size:0.85rem; color:var(--muted);">Получатель (должен быть заверен):</label>
|
<label style="font-size:0.85rem; color:var(--muted);">Получатель (должен быть заверен):</label>
|
||||||
@@ -79,7 +104,10 @@
|
|||||||
|
|
||||||
<hr style="border:0; border-top:1px solid var(--border); width:100%; margin: 4px 0;">
|
<hr style="border:0; border-top:1px solid var(--border); width:100%; margin: 4px 0;">
|
||||||
<input type="file" id="media-input">
|
<input type="file" id="media-input">
|
||||||
<button class="btn-green" onclick="sendMedia()">Отправить медиа на диск получателя</button>
|
<button class="btn-green" id="send-media-btn" onclick="sendMedia()">Отправить медиа на диск получателя</button>
|
||||||
|
|
||||||
|
<!-- Контейнер прогресс-баров активных передач (исходящих и входящих) -->
|
||||||
|
<div id="transfers-list" style="display:flex; flex-direction:column; gap:8px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 3. Принятый текст в ОЗУ -->
|
<!-- 3. Принятый текст в ОЗУ -->
|
||||||
@@ -100,6 +128,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
let knownPeersKey = "";
|
let knownPeersKey = "";
|
||||||
|
let defaultRangeInitialized = false;
|
||||||
|
|
||||||
function showStatus(msg, isError = false) {
|
function showStatus(msg, isError = false) {
|
||||||
const el = document.getElementById('status');
|
const el = document.getElementById('status');
|
||||||
@@ -110,6 +139,12 @@ function showStatus(msg, isError = false) {
|
|||||||
setTimeout(() => { el.style.display = 'none'; }, 4000);
|
setTimeout(() => { el.style.display = 'none'; }, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes < 1024) return `${bytes} Б`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)} МБ`;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchState() {
|
async function fetchState() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/ui/state');
|
const res = await fetch('/ui/state');
|
||||||
@@ -119,9 +154,61 @@ async function fetchState() {
|
|||||||
document.getElementById('my-ip').textContent = `Наш адрес: ${data.my_ip}:${data.p2p_port} | ID: ${data.my_fingerprint.slice(0, 20)}...`;
|
document.getElementById('my-ip').textContent = `Наш адрес: ${data.my_ip}:${data.p2p_port} | ID: ${data.my_fingerprint.slice(0, 20)}...`;
|
||||||
document.getElementById('my-pin').textContent = data.my_pin;
|
document.getElementById('my-pin').textContent = data.my_pin;
|
||||||
|
|
||||||
|
// Автоподстановка подсети в поле диапазона при первой загрузке
|
||||||
|
if (!defaultRangeInitialized && data.my_ip && data.my_ip.includes('.')) {
|
||||||
|
const parts = data.my_ip.split('.');
|
||||||
|
if (parts.length === 4 && parts[0] !== '127') {
|
||||||
|
document.getElementById('scan-range-input').value = `${parts[0]}.${parts[1]}.${parts[2]}.1-254`;
|
||||||
|
defaultRangeInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображение статуса сканирования IP
|
||||||
|
const scanBox = document.getElementById('scan-progress-box');
|
||||||
|
const btnScanRange = document.getElementById('btn-scan-range');
|
||||||
|
const btnScanAll = document.getElementById('btn-scan-all');
|
||||||
|
if (data.scan && (data.scan.active || data.scan.total > 0)) {
|
||||||
|
scanBox.style.display = 'block';
|
||||||
|
const pct = data.scan.total > 0 ? Math.round((data.scan.scanned / data.scan.total) * 100) : 0;
|
||||||
|
document.getElementById('scan-label').textContent = data.scan.active
|
||||||
|
? `Сканирование ${data.scan.range}... (найдено: ${data.scan.found})`
|
||||||
|
: `Сканирование ${data.scan.range} завершено (найдено: ${data.scan.found})`;
|
||||||
|
document.getElementById('scan-numbers').textContent = `${data.scan.scanned} / ${data.scan.total} (${pct}%)`;
|
||||||
|
document.getElementById('scan-bar-fill').style.width = `${pct}%`;
|
||||||
|
btnScanRange.disabled = data.scan.active;
|
||||||
|
btnScanAll.disabled = data.scan.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображение прогресс-баров передачи файлов
|
||||||
|
const transfersContainer = document.getElementById('transfers-list');
|
||||||
|
if (!data.transfers || data.transfers.length === 0) {
|
||||||
|
transfersContainer.innerHTML = '';
|
||||||
|
} else {
|
||||||
|
transfersContainer.innerHTML = data.transfers.map(tr => {
|
||||||
|
const dirBadge = tr.direction === 'out' ? '⬆️ Отправка на' : '⬇️ Прием от';
|
||||||
|
const statusText = tr.done ? '✓ Завершено' : `${tr.percent}%`;
|
||||||
|
return `
|
||||||
|
<div class="progress-wrap">
|
||||||
|
<div style="display:flex; justify-content:space-between; font-size:0.83rem; font-weight:600;">
|
||||||
|
<span>${dirBadge} ${escapeHtml(tr.peer_name)}: <span style="color:#60a5fa;">${escapeHtml(tr.filename)}</span></span>
|
||||||
|
<span>${statusText}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar-bg">
|
||||||
|
<div class="progress-bar-fill" style="width: ${tr.percent}%;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; justify-content:space-between; font-size:0.76rem; color:var(--muted);">
|
||||||
|
<span>${formatBytes(tr.transferred_bytes)} из ${formatBytes(tr.total_bytes)}</span>
|
||||||
|
<span>${tr.done ? 'Сохранено на диске' : 'Передача по TLS...'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Список устройств
|
||||||
const peersContainer = document.getElementById('peers-list');
|
const peersContainer = document.getElementById('peers-list');
|
||||||
if (data.peers.length === 0) {
|
if (data.peers.length === 0) {
|
||||||
peersContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Устройства пока не найдены. Если роутер блокирует broadcast, введите IP соседа выше — оба устройства сразу увидят друг друга.</div>';
|
peersContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Устройства пока не найдены. Нажмите «Вся подсеть (.1–254)» или введите IP вручную.</div>';
|
||||||
} else {
|
} else {
|
||||||
peersContainer.innerHTML = data.peers.map(p => {
|
peersContainer.innerHTML = data.peers.map(p => {
|
||||||
const ipStr = p.ips.join(', ');
|
const ipStr = p.ips.join(', ');
|
||||||
@@ -182,7 +269,7 @@ async function fetchState() {
|
|||||||
<div class="msg-item">
|
<div class="msg-item">
|
||||||
<div class="msg-meta">
|
<div class="msg-meta">
|
||||||
<span>От: <b>${escapeHtml(m.sender)}</b></span>
|
<span>От: <b>${escapeHtml(m.sender)}</b></span>
|
||||||
<span>${(m.size / 1024).toFixed(1)} КБ</span>
|
<span>${formatBytes(m.size)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-weight:600; margin:2px 0;">
|
<div style="font-weight:600; margin:2px 0;">
|
||||||
<a href="${fileUrl}" target="_blank" style="color:#60a5fa; text-decoration:none;">${escapeHtml(m.filename)} ↗</a>
|
<a href="${fileUrl}" target="_blank" style="color:#60a5fa; text-decoration:none;">${escapeHtml(m.filename)} ↗</a>
|
||||||
@@ -196,6 +283,28 @@ async function fetchState() {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function scanIpRange(fullSubnet = false) {
|
||||||
|
const input = document.getElementById('scan-range-input');
|
||||||
|
const range = fullSubnet ? 'all' : input.value.trim();
|
||||||
|
if (!fullSubnet && !range) {
|
||||||
|
return showStatus('Введите диапазон IP (например 192.168.1.1-254 или 192.168.1.10-50)', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/ui/scan-range', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ range })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showStatus('Сканирование диапазона запущено...');
|
||||||
|
fetchState();
|
||||||
|
} else {
|
||||||
|
const err = await res.text();
|
||||||
|
showStatus(err, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function addManualPeer() {
|
async function addManualPeer() {
|
||||||
const input = document.getElementById('manual-ip-input');
|
const input = document.getElementById('manual-ip-input');
|
||||||
const raw = input.value.trim();
|
const raw = input.value.trim();
|
||||||
@@ -228,7 +337,7 @@ async function addManualPeer() {
|
|||||||
|
|
||||||
async function pingPeers() {
|
async function pingPeers() {
|
||||||
await fetch('/ui/ping', { method: 'POST' });
|
await fetch('/ui/ping', { method: 'POST' });
|
||||||
showStatus('Запущен опрос узлов сети');
|
showStatus('Запущен опрос известных узлов');
|
||||||
setTimeout(fetchState, 600);
|
setTimeout(fetchState, 600);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,12 +378,14 @@ async function sendText() {
|
|||||||
async function sendMedia() {
|
async function sendMedia() {
|
||||||
const peer_id = document.getElementById('target-peer').value;
|
const peer_id = document.getElementById('target-peer').value;
|
||||||
const fileInput = document.getElementById('media-input');
|
const fileInput = document.getElementById('media-input');
|
||||||
|
const sendBtn = document.getElementById('send-media-btn');
|
||||||
if (!peer_id) return showStatus('Выберите устройство-получатель!', true);
|
if (!peer_id) return showStatus('Выберите устройство-получатель!', true);
|
||||||
if (!fileInput.files.length) return showStatus('Выберите медиафайл!', true);
|
if (!fileInput.files.length) return showStatus('Выберите медиафайл!', true);
|
||||||
|
|
||||||
const file = fileInput.files[0];
|
const file = fileInput.files[0];
|
||||||
showStatus(`Отправка файла "${file.name}"...`);
|
sendBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
const res = await fetch('/ui/send-media', {
|
const res = await fetch('/ui/send-media', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -287,11 +398,15 @@ async function sendMedia() {
|
|||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
showStatus(`Файл "${file.name}" сохранен на диске получателя`);
|
showStatus(`Файл "${file.name}" успешно передан на диск получателя`);
|
||||||
} else {
|
} else {
|
||||||
const err = await res.text();
|
const err = await res.text();
|
||||||
showStatus(err, true);
|
showStatus(err, true);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
fetchState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearRam() {
|
async function clearRam() {
|
||||||
@@ -316,7 +431,8 @@ document.getElementById('text-input').addEventListener('keydown', (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setInterval(fetchState, 1500);
|
// Опрос состояния каждые 500 мс для плавного отображения прогресс-баров
|
||||||
|
setInterval(fetchState, 500);
|
||||||
fetchState();
|
fetchState();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::models::{
|
use crate::models::{
|
||||||
sanitize_filename, AppState, IncomingTextPayload, MediaItem, PeerDevice, TextItem,
|
now_sec, parse_ip_range, sanitize_filename, url_decode, AppState, IncomingTextPayload,
|
||||||
UiManualPeerRequest, UiSendTextRequest, UiTrustRequest, MEDIA_DIR,
|
MediaItem, PeerDevice, ScanProgress, TextItem, TransferProgress, UiManualPeerRequest,
|
||||||
|
UiScanRangeRequest, UiSendTextRequest, UiTrustRequest, MEDIA_DIR,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
@@ -9,7 +10,8 @@ use axum::{
|
|||||||
response::{Html, IntoResponse},
|
response::{Html, IntoResponse},
|
||||||
Json,
|
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");
|
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<Arc<AppState>>) -> Json<serde_json::Value> {
|
pub async fn ui_get_state(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
|
||||||
let mut peers_list: Vec<PeerDevice> = state
|
let mut peers_list: Vec<PeerDevice> =
|
||||||
.peers
|
state.peers.read().unwrap().values().cloned().collect();
|
||||||
.read()
|
|
||||||
.unwrap()
|
|
||||||
.values()
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
peers_list.sort_by(|a, b| a.name.cmp(&b.name));
|
peers_list.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
|
||||||
let texts: Vec<TextItem> = state.ram_texts.read().unwrap().iter().cloned().collect();
|
let texts: Vec<TextItem> = state.ram_texts.read().unwrap().iter().cloned().collect();
|
||||||
let media: Vec<MediaItem> = state.disk_media.read().unwrap().iter().cloned().collect();
|
let media: Vec<MediaItem> = state.disk_media.read().unwrap().iter().cloned().collect();
|
||||||
|
let transfers: Vec<TransferProgress> = state.transfers.read().unwrap().clone();
|
||||||
|
let scan = state.scan_status.read().unwrap().clone();
|
||||||
let logs: Vec<String> = state.logs.read().unwrap().iter().cloned().collect();
|
let logs: Vec<String> = state.logs.read().unwrap().iter().cloned().collect();
|
||||||
|
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
@@ -40,6 +39,8 @@ pub async fn ui_get_state(State(state): State<Arc<AppState>>) -> Json<serde_json
|
|||||||
"peers": peers_list,
|
"peers": peers_list,
|
||||||
"texts": texts,
|
"texts": texts,
|
||||||
"media": media,
|
"media": media,
|
||||||
|
"transfers": transfers,
|
||||||
|
"scan": scan,
|
||||||
"logs": logs
|
"logs": logs
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -84,6 +85,114 @@ pub async fn ui_add_manual_peer(
|
|||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Асинхронное сканирование всего или части диапазона IP-адресов
|
||||||
|
pub async fn ui_scan_range(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(req): Json<UiScanRangeRequest>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
{
|
||||||
|
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<SocketAddr> = 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::<serde_json::Value>().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<Arc<AppState>>) -> StatusCode {
|
pub async fn ui_ping_peers(State(state): State<Arc<AppState>>) -> StatusCode {
|
||||||
let state_clone = state.clone();
|
let state_clone = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -131,7 +240,10 @@ pub async fn ui_send_text(
|
|||||||
if !peer.verified {
|
if !peer.verified {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
format!("Сначала заверьте сертификат устройства '{}' [PIN: {}]!", peer.name, peer.pin_code),
|
format!(
|
||||||
|
"Сначала заверьте сертификат устройства '{}' [PIN: {}]!",
|
||||||
|
peer.name, peer.pin_code
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let ip = peer
|
let ip = peer
|
||||||
@@ -165,6 +277,7 @@ pub async fn ui_send_text(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Почанковая отправка медиафайла с реальным отслеживанием прогресса по сети
|
||||||
pub async fn ui_send_media(
|
pub async fn ui_send_media(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -175,15 +288,19 @@ pub async fn ui_send_media(
|
|||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.ok_or((StatusCode::BAD_REQUEST, "Не указан ID получателя".to_string()))?;
|
.ok_or((StatusCode::BAD_REQUEST, "Не указан ID получателя".to_string()))?;
|
||||||
|
|
||||||
let filename = headers
|
let raw_filename = headers
|
||||||
.get("X-Filename")
|
.get("X-Filename")
|
||||||
.and_then(|v| v.to_str().ok())
|
.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
|
let mime = headers
|
||||||
.get(header::CONTENT_TYPE)
|
.get(header::CONTENT_TYPE)
|
||||||
.and_then(|v| v.to_str().ok())
|
.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 (target_ip, port, peer_name) = {
|
||||||
let peers = state.peers.read().unwrap();
|
let peers = state.peers.read().unwrap();
|
||||||
@@ -193,7 +310,10 @@ pub async fn ui_send_media(
|
|||||||
if !peer.verified {
|
if !peer.verified {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
format!("Сначала заверьте сертификат устройства '{}' [PIN: {}]!", peer.name, peer.pin_code),
|
format!(
|
||||||
|
"Сначала заверьте сертификат устройства '{}' [PIN: {}]!",
|
||||||
|
peer.name, peer.pin_code
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let ip = peer
|
let ip = peer
|
||||||
@@ -204,25 +324,103 @@ pub async fn ui_send_media(
|
|||||||
(ip, peer.port, peer.name.clone())
|
(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 url = format!("https://{}:{}/p2p/media", target_ip, port);
|
||||||
|
|
||||||
let resp = state
|
// Адаптивный размер чанка для плавного обновления прогресс-бара на любых файлах
|
||||||
|
let chunk_size = if total_size < 1024 * 1024 {
|
||||||
|
32 * 1024 // 32 КБ для файлов до 1 МБ
|
||||||
|
} else if total_size < 15 * 1024 * 1024 {
|
||||||
|
128 * 1024 // 128 КБ для файлов до 15 МБ
|
||||||
|
} else {
|
||||||
|
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
|
.http_client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("X-Sender", &state.device_name)
|
.header("X-Sender", &state.device_name)
|
||||||
.header("X-Filename", filename)
|
.header("X-Filename", &raw_filename)
|
||||||
.header(header::CONTENT_TYPE, mime)
|
.header("X-Transfer-Id", &transfer_id)
|
||||||
.body(body)
|
.header("X-Offset", offset.to_string())
|
||||||
|
.header("X-Total-Size", total_size.to_string())
|
||||||
|
.header(header::CONTENT_TYPE, &mime)
|
||||||
|
.body(chunk)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await;
|
||||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Ошибка TLS/Сети: {}", e)))?;
|
|
||||||
|
|
||||||
if resp.status().is_success() {
|
match send_res {
|
||||||
state.add_log(format!("Медиафайл отправлен на диск устройства '{}'", peer_name));
|
Ok(resp) if resp.status().is_success() => {
|
||||||
Ok(StatusCode::OK)
|
offset = end;
|
||||||
|
let is_done = offset >= total_size;
|
||||||
|
let percent = if total_size == 0 {
|
||||||
|
100
|
||||||
} else {
|
} else {
|
||||||
Err((resp.status(), "Удаленный узел отклонил медиафайл".to_string()))
|
((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<String>) -> impl IntoResponse {
|
pub async fn ui_serve_saved_file(Path(filename): Path<String>) -> impl IntoResponse {
|
||||||
|
|||||||
Reference in new issue
Block a user