version a0.0.1
This commit is contained in:
commit
9469ef10b6
19 files changed
+4281
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
/received_media
|
||||||
|
Cargo.lock
|
||||||
Generated
+1935
File diff suppressed because it is too large.
Load diff
+23
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "ramshare_ui"
|
||||||
|
version = "0.2.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1.40", features = ["full"] }
|
||||||
|
axum = { version = "0.7" }
|
||||||
|
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||||
|
bytes = "1.7"
|
||||||
|
socket2 = "0.5"
|
||||||
|
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
|
|
||||||
|
rcgen = "0.13"
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
|
local-ip-address = "0.6"
|
||||||
|
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "ramshare_ui"
|
||||||
|
version = "0.2.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1.40", features = ["full"] }
|
||||||
|
axum = { version = "0.7" }
|
||||||
|
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||||
|
bytes = "1.7"
|
||||||
|
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
|
|
||||||
|
rcgen = "0.13"
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
|
mdns-sd = "0.11"
|
||||||
|
local-ip-address = "0.6"
|
||||||
|
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
mod mdns;
|
||||||
|
mod models;
|
||||||
|
mod p2p;
|
||||||
|
mod tls;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use axum_server::tls_rustls::RustlsConfig;
|
||||||
|
use local_ip_address::local_ip;
|
||||||
|
use models::{AppState, MAX_PAYLOAD_SIZE, MAX_RAM_TEXTS, MEDIA_DIR};
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, VecDeque},
|
||||||
|
net::SocketAddr,
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// 0. Регистрация глобального CryptoProvider (ring) для rustls 0.23+
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
||||||
|
// Значения по умолчанию
|
||||||
|
let mut device_name = "My-Device".to_string();
|
||||||
|
let mut p2p_port: u16 = 8443;
|
||||||
|
let mut ui_port: u16 = 8080;
|
||||||
|
let mut custom_ip: Option<String> = None;
|
||||||
|
|
||||||
|
// Парсинг как именованных флагов (--ip, --name), так и позиционных аргументов
|
||||||
|
let mut i = 1;
|
||||||
|
let mut positional_idx = 0;
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--ip" => {
|
||||||
|
if i + 1 < args.len() {
|
||||||
|
custom_ip = Some(args[i + 1].clone());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--name" => {
|
||||||
|
if i + 1 < args.len() {
|
||||||
|
device_name = args[i + 1].clone();
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--p2p-port" => {
|
||||||
|
if i + 1 < args.len() {
|
||||||
|
if let Ok(p) = args[i + 1].parse() {
|
||||||
|
p2p_port = p;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--ui-port" => {
|
||||||
|
if i + 1 < args.len() {
|
||||||
|
if let Ok(p) = args[i + 1].parse() {
|
||||||
|
ui_port = p;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
arg if !arg.starts_with("--") => {
|
||||||
|
match positional_idx {
|
||||||
|
0 => device_name = arg.to_string(),
|
||||||
|
1 => {
|
||||||
|
if let Ok(p) = arg.parse() {
|
||||||
|
p2p_port = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
if let Ok(p) = arg.parse() {
|
||||||
|
ui_port = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 => custom_ip = Some(arg.to_string()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
positional_idx += 1;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определение адреса привязки (bind_ip) и сетевого IP для сертификата/mDNS
|
||||||
|
let (bind_ip, my_ip) = match custom_ip {
|
||||||
|
Some(ip) => {
|
||||||
|
if ip == "0.0.0.0" {
|
||||||
|
let detected = local_ip()
|
||||||
|
.map(|addr| addr.to_string())
|
||||||
|
.unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||||
|
("0.0.0.0".to_string(), detected)
|
||||||
|
} else {
|
||||||
|
(ip.clone(), ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let detected = local_ip().expect("Не удалось определить локальный IP").to_string();
|
||||||
|
("0.0.0.0".to_string(), detected)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::fs::create_dir_all(MEDIA_DIR)
|
||||||
|
.await
|
||||||
|
.expect("Не удалось создать папку received_media на диске");
|
||||||
|
|
||||||
|
// 1. Генерация сертификата в ОЗУ под выбранный IP и настройка HTTPS-клиента
|
||||||
|
let (cert_der, key_der, my_fingerprint) = tls::generate_in_memory_cert(&my_ip);
|
||||||
|
let trusted_fingerprints = Arc::new(RwLock::new(HashMap::new()));
|
||||||
|
let http_client = tls::build_pinned_http_client(trusted_fingerprints.clone());
|
||||||
|
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
device_name: device_name.clone(),
|
||||||
|
my_ip: my_ip.clone(),
|
||||||
|
p2p_port,
|
||||||
|
my_fingerprint: my_fingerprint.clone(),
|
||||||
|
ram_texts: RwLock::new(VecDeque::with_capacity(MAX_RAM_TEXTS)),
|
||||||
|
disk_media: RwLock::new(VecDeque::new()),
|
||||||
|
peers: RwLock::new(HashMap::new()),
|
||||||
|
trusted_fingerprints,
|
||||||
|
http_client,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Запуск mDNS-обнаружения
|
||||||
|
mdns::start_mdns_service(state.clone());
|
||||||
|
|
||||||
|
// 3. Запуск внешнего P2P HTTPS-сервера на указанном IP
|
||||||
|
let tls_config = RustlsConfig::from_der(vec![cert_der], key_der)
|
||||||
|
.await
|
||||||
|
.expect("Ошибка конфигурации TLS");
|
||||||
|
|
||||||
|
let p2p_router = Router::new()
|
||||||
|
.route("/p2p/text", post(p2p::p2p_receive_text))
|
||||||
|
.route("/p2p/media", post(p2p::p2p_receive_media))
|
||||||
|
.layer(DefaultBodyLimit::max(MAX_PAYLOAD_SIZE))
|
||||||
|
.with_state(state.clone());
|
||||||
|
|
||||||
|
let p2p_addr: SocketAddr = format!("{}:{}", bind_ip, p2p_port)
|
||||||
|
.parse()
|
||||||
|
.unwrap_or_else(|_| panic!("Некорректный IP или порт: {}:{}", bind_ip, p2p_port));
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum_server::bind_rustls(p2p_addr, tls_config)
|
||||||
|
.serve(p2p_router.into_make_service())
|
||||||
|
.await
|
||||||
|
.expect("Ошибка P2P HTTPS-сервера");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Запуск локального 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/trust", post(ui::ui_trust_peer))
|
||||||
|
.route("/ui/clear-ram", post(ui::ui_clear_ram))
|
||||||
|
.route("/ui/send-text", post(ui::ui_send_text))
|
||||||
|
.route("/ui/send-media", post(ui::ui_send_media))
|
||||||
|
.route("/ui/files/:filename", get(ui::ui_serve_saved_file))
|
||||||
|
.layer(DefaultBodyLimit::max(MAX_PAYLOAD_SIZE))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let ui_addr: SocketAddr = format!("127.0.0.1:{}", ui_port).parse().unwrap();
|
||||||
|
println!("==============================================================");
|
||||||
|
println!(" Устройство: {}", device_name);
|
||||||
|
println!(" P2P сокет (порт открыт на): https://{}:{}", bind_ip, p2p_port);
|
||||||
|
println!(" Сетевой IP (mDNS / TLS SAN): {}", my_ip);
|
||||||
|
println!(" КОД СВЕРКИ СЕРТИФИКАТА (PIN): [ {} ]", &my_fingerprint[..8].to_uppercase());
|
||||||
|
println!(" Папка для медиа на диске: ./{}/", MEDIA_DIR);
|
||||||
|
println!("--------------------------------------------------------------");
|
||||||
|
println!(" ОТКРОЙТЕ ИНТЕРФЕЙС В БРАУЗЕРЕ: http://127.0.0.1:{}", ui_port);
|
||||||
|
println!("==============================================================");
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(ui_addr).await.unwrap();
|
||||||
|
axum::serve(listener, ui_router).await.unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
use crate::models::{AppState, PeerNode, SERVICE_TYPE};
|
||||||
|
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub fn start_mdns_service(state: Arc<AppState>) {
|
||||||
|
let mdns = ServiceDaemon::new().expect("Не удалось запустить mDNS");
|
||||||
|
|
||||||
|
let instance_name = format!("{}_{}", state.device_name, &state.my_fingerprint[..6]);
|
||||||
|
let host_name = format!("{}.local.", instance_name);
|
||||||
|
let properties = [
|
||||||
|
("fp", state.my_fingerprint.as_str()),
|
||||||
|
("name", state.device_name.as_str()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let service_info = ServiceInfo::new(
|
||||||
|
SERVICE_TYPE,
|
||||||
|
&instance_name,
|
||||||
|
&host_name,
|
||||||
|
&state.my_ip,
|
||||||
|
state.p2p_port,
|
||||||
|
&properties[..],
|
||||||
|
)
|
||||||
|
.expect("Ошибка создания mDNS записи");
|
||||||
|
|
||||||
|
mdns.register(service_info).expect("Ошибка регистрации mDNS");
|
||||||
|
|
||||||
|
let receiver = mdns.browse(SERVICE_TYPE).expect("Ошибка mDNS browse");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok(event) = receiver.recv_async().await {
|
||||||
|
if let ServiceEvent::ServiceResolved(info) = event {
|
||||||
|
let fp = info
|
||||||
|
.get_property_val_str("fp")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let name = info
|
||||||
|
.get_property_val_str("name")
|
||||||
|
.unwrap_or("Unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if fp == state.my_fingerprint || fp.len() < 8 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ip) = info.get_addresses().iter().next() {
|
||||||
|
let ip_str = ip.to_string();
|
||||||
|
let pin_code = fp[..8].to_uppercase();
|
||||||
|
let peer = PeerNode {
|
||||||
|
name,
|
||||||
|
ip: ip_str.clone(),
|
||||||
|
port: info.get_port(),
|
||||||
|
fingerprint: fp,
|
||||||
|
pin_code,
|
||||||
|
verified: false,
|
||||||
|
};
|
||||||
|
state.peers.write().unwrap().insert(ip_str, peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, VecDeque},
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Лимит одного передаваемого медиафайла (200 МБ)
|
||||||
|
pub const MAX_PAYLOAD_SIZE: usize = 200 * 1024 * 1024;
|
||||||
|
/// Максимум текстовых сообщений, хранимых в ОЗУ
|
||||||
|
pub const MAX_RAM_TEXTS: usize = 50;
|
||||||
|
/// Папка на диске для сохранения входящего медиаконтента
|
||||||
|
pub const MEDIA_DIR: &str = "received_media";
|
||||||
|
/// Тип сервиса для обнаружения через mDNS
|
||||||
|
pub const SERVICE_TYPE: &str = "_ramshare._tcp.local.";
|
||||||
|
|
||||||
|
/// Текстовое сообщение (хранится ТОЛЬКО в ОЗУ)
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TextItem {
|
||||||
|
pub sender: String,
|
||||||
|
pub text: String,
|
||||||
|
pub timestamp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Метаданные медиафайла (сам файл сохраняется НА ДИСК)
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MediaItem {
|
||||||
|
pub sender: String,
|
||||||
|
pub filename: String,
|
||||||
|
pub saved_path: String,
|
||||||
|
pub mime: String,
|
||||||
|
pub size: usize,
|
||||||
|
pub timestamp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Устройство в локальной сети, найденное через mDNS
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PeerNode {
|
||||||
|
pub name: String,
|
||||||
|
pub ip: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub fingerprint: String,
|
||||||
|
pub pin_code: String,
|
||||||
|
pub verified: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Глобальное состояние приложения
|
||||||
|
pub struct AppState {
|
||||||
|
pub device_name: String,
|
||||||
|
pub my_ip: String,
|
||||||
|
pub p2p_port: u16,
|
||||||
|
pub my_fingerprint: String,
|
||||||
|
pub ram_texts: RwLock<VecDeque<TextItem>>,
|
||||||
|
pub disk_media: RwLock<VecDeque<MediaItem>>,
|
||||||
|
pub peers: RwLock<HashMap<String, PeerNode>>,
|
||||||
|
pub trusted_fingerprints: Arc<RwLock<HashMap<String, String>>>,
|
||||||
|
pub http_client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct IncomingTextPayload {
|
||||||
|
pub sender: String,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UiSendTextRequest {
|
||||||
|
pub target_ip: String,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UiTrustRequest {
|
||||||
|
pub target_ip: String,
|
||||||
|
pub trust: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_sec() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Декодирование URL-encoded имени файла (для поддержки кириллицы и пробелов)
|
||||||
|
pub fn url_decode(input: &str) -> String {
|
||||||
|
let mut bytes = Vec::with_capacity(input.len());
|
||||||
|
let mut chars = input.as_bytes().iter().copied();
|
||||||
|
while let Some(b) = chars.next() {
|
||||||
|
if b == b'%' {
|
||||||
|
if let (Some(c1), Some(c2)) = (chars.next(), chars.next()) {
|
||||||
|
if let Ok(decoded) = u8::from_str_radix(&format!("{}{}", c1 as char, c2 as char), 16)
|
||||||
|
{
|
||||||
|
bytes.push(decoded);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if b == b'+' {
|
||||||
|
bytes.push(b' ');
|
||||||
|
} else {
|
||||||
|
bytes.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&bytes).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Защита от Path Traversal при сохранении медиа на диск
|
||||||
|
pub fn sanitize_filename(name: &str) -> String {
|
||||||
|
let decoded = url_decode(name);
|
||||||
|
let clean: String = decoded
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
|
||||||
|
_ => c,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let trimmed = clean.trim_matches('.').trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"media_file.bin".to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
use crate::models::{
|
||||||
|
now_sec, sanitize_filename, url_decode, AppState, IncomingTextPayload, MediaItem, TextItem,
|
||||||
|
MAX_RAM_TEXTS, MEDIA_DIR,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
body::Bytes,
|
||||||
|
extract::State,
|
||||||
|
http::{header, HeaderMap, StatusCode},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use std::{path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
|
/// Прием текста от другого устройства -> сохраняется СТРОГО В ОЗУ
|
||||||
|
pub async fn p2p_receive_text(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(payload): Json<IncomingTextPayload>,
|
||||||
|
) -> StatusCode {
|
||||||
|
let mut ram_texts = state.ram_texts.write().unwrap();
|
||||||
|
if ram_texts.len() >= MAX_RAM_TEXTS {
|
||||||
|
ram_texts.pop_back();
|
||||||
|
}
|
||||||
|
ram_texts.push_front(TextItem {
|
||||||
|
sender: payload.sender,
|
||||||
|
text: payload.text,
|
||||||
|
timestamp: now_sec(),
|
||||||
|
});
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Прием медиаконтента от другого устройства -> сохраняется НА ДИСК
|
||||||
|
pub async fn p2p_receive_media(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
let sender = headers
|
||||||
|
.get("X-Sender")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(url_decode)
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
|
||||||
|
let raw_filename = headers
|
||||||
|
.get("X-Filename")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("file.bin");
|
||||||
|
|
||||||
|
let mime = headers
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("application/octet-stream")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
tokio::fs::write(&file_path, &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 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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LAN Share — Текст в ОЗУ / Медиа на Диск</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg: #0f172a; --card: #1e293b; --border: #334155; --accent: #3b82f6; --green: #10b981; --red: #ef4444; --text: #f8fafc; --muted: #94a3b8; }
|
||||||
|
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
|
||||||
|
body { margin: 0; padding: 20px; background: var(--bg); color: var(--text); }
|
||||||
|
.container { max-width: 1100px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||||
|
@media (max-width: 800px) { .container { grid-template-columns: 1fr; } }
|
||||||
|
.header { grid-column: 1 / -1; background: var(--card); border: 1px solid var(--border); padding: 16px 20px; border-radius: 12px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.pin-badge { background: #1d4ed8; padding: 6px 14px; border-radius: 8px; font-weight: 700; letter-spacing: 1px; font-family: monospace; font-size: 1.1rem; }
|
||||||
|
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
h2 { margin: 0; font-size: 1.1rem; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.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; }
|
||||||
|
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; }
|
||||||
|
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.btn-green { background: var(--green); }
|
||||||
|
button.btn-red { background: var(--red); }
|
||||||
|
button.btn-sm { padding: 6px 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); }
|
||||||
|
.msg-text { font-size: 0.95rem; white-space: pre-wrap; word-break: break-word; width: 100%; margin: 4px 0; }
|
||||||
|
.path-box { font-family: monospace; font-size: 0.78rem; color: #93c5fd; background: rgba(59,130,246,0.1); padding: 4px 8px; border-radius: 4px; word-break: break-all; }
|
||||||
|
.preview-img { max-width: 100%; max-height: 180px; border-radius: 6px; margin-top: 6px; }
|
||||||
|
#status { grid-column: 1 / -1; padding: 10px 16px; border-radius: 8px; display: none; font-weight: 500; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 1.2rem; font-weight: 700;" id="my-name">Загрузка...</div>
|
||||||
|
<div style="font-size: 0.85rem; color: var(--muted);" id="my-ip"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="font-size: 0.85rem; color: var(--muted); margin-right: 8px;">Код сверки сертификата (PIN):</span>
|
||||||
|
<span class="pin-badge" id="my-pin">--------</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status"></div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>1. Устройства в сети (mDNS) <span class="tag tag-disk">TLS Pinning</span></h2>
|
||||||
|
<div id="peers-list" style="display:flex; flex-direction:column; gap:8px;">
|
||||||
|
<div style="color:var(--muted); font-size:0.9rem;">Поиск устройств...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>2. Отправка данных</h2>
|
||||||
|
<label style="font-size:0.85rem; color:var(--muted);">Получатель (должен быть заверен):</label>
|
||||||
|
<select id="target-peer"><option value="">-- Выберите устройство --</option></select>
|
||||||
|
|
||||||
|
<textarea id="text-input" placeholder="Введите текст (Enter — отправить, Shift+Enter — новая строка)..."></textarea>
|
||||||
|
<button onclick="sendText()">Отправить текст в ОЗУ</button>
|
||||||
|
|
||||||
|
<hr style="border:0; border-top:1px solid var(--border); width:100%; margin: 4px 0;">
|
||||||
|
<input type="file" id="media-input">
|
||||||
|
<button class="btn-green" onclick="sendMedia()">Отправить медиа на диск получателя</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
<span>Входящий текст <span class="tag tag-ram">ТОЛЬКО В ОЗУ</span></span>
|
||||||
|
<button class="btn-red btn-sm" onclick="clearRam()">Очистить ОЗУ</button>
|
||||||
|
</h2>
|
||||||
|
<div id="ram-texts" style="display:flex; flex-direction:column; gap:10px; max-height: 400px; overflow-y: auto;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Входящие медиафайлы <span class="tag tag-disk">НА ДИСКЕ (./received_media)</span></h2>
|
||||||
|
<div id="disk-media" style="display:flex; flex-direction:column; gap:10px; max-height: 400px; overflow-y: auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let knownPeersKey = "";
|
||||||
|
|
||||||
|
function showStatus(msg, isError = false) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.style.background = isError ? 'rgba(239, 68, 68, 0.2)' : 'rgba(16, 185, 129, 0.2)';
|
||||||
|
el.style.border = `1px solid ${isError ? '#ef4444' : '#10b981'}`;
|
||||||
|
el.textContent = msg;
|
||||||
|
setTimeout(() => { el.style.display = 'none'; }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchState() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/ui/state');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
document.getElementById('my-name').textContent = `Устройство: ${data.device_name}`;
|
||||||
|
document.getElementById('my-ip').textContent = `HTTPS P2P: ${data.my_ip}:${data.p2p_port} | SHA-256: ${data.my_fingerprint.slice(0, 24)}...`;
|
||||||
|
document.getElementById('my-pin').textContent = data.my_pin;
|
||||||
|
|
||||||
|
const peersContainer = document.getElementById('peers-list');
|
||||||
|
if (data.peers.length === 0) {
|
||||||
|
peersContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Устройства в локальной сети пока не обнаружены</div>';
|
||||||
|
} else {
|
||||||
|
peersContainer.innerHTML = data.peers.map(p => `
|
||||||
|
<div class="peer-item">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;">${escapeHtml(p.name)} <span style="color:var(--muted); font-size:0.85rem;">(${p.ip}:${p.port})</span></div>
|
||||||
|
<div style="font-size:0.8rem; color:var(--muted); font-family:monospace;">PIN: <b style="color:#fff;">[${p.pin_code}]</b> | Статус: ${p.verified ? '<span style="color:#34d399;">Заверен</span>' : '<span style="color:#f87171;">Не заверен</span>'}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-sm ${p.verified ? 'btn-red' : 'btn-green'}" onclick="toggleTrust('${p.ip}', ${!p.verified})">
|
||||||
|
${p.verified ? 'Отозвать' : 'Заверить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSelectKey = data.peers.map(p => `${p.ip}:${p.verified}`).join('|');
|
||||||
|
if (currentSelectKey !== knownPeersKey) {
|
||||||
|
knownPeersKey = currentSelectKey;
|
||||||
|
const select = document.getElementById('target-peer');
|
||||||
|
const prevVal = select.value;
|
||||||
|
select.innerHTML = '<option value="">-- Выберите устройство --</option>' +
|
||||||
|
data.peers.map(p => `<option value="${p.ip}">${escapeHtml(p.name)} (${p.ip}) ${p.verified ? '✓' : '🔒 [Не заверен]'}</option>`).join('');
|
||||||
|
if (prevVal) select.value = prevVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textsContainer = document.getElementById('ram-texts');
|
||||||
|
if (data.texts.length === 0) {
|
||||||
|
textsContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Буфер ОЗУ пуст</div>';
|
||||||
|
} else {
|
||||||
|
textsContainer.innerHTML = data.texts.map(t => `
|
||||||
|
<div class="msg-item">
|
||||||
|
<div class="msg-meta">
|
||||||
|
<span>От: <b>${escapeHtml(t.sender)}</b></span>
|
||||||
|
<button class="btn-sm" onclick="copyText(${JSON.stringify(t.text).replace(/"/g, '"')})">Скопировать</button>
|
||||||
|
</div>
|
||||||
|
<div class="msg-text">${escapeHtml(t.text)}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaContainer = document.getElementById('disk-media');
|
||||||
|
if (data.media.length === 0) {
|
||||||
|
mediaContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Нет сохраненных медиафайлов</div>';
|
||||||
|
} else {
|
||||||
|
mediaContainer.innerHTML = data.media.map(m => {
|
||||||
|
const isImg = m.mime.startsWith('image/');
|
||||||
|
const fileUrl = `/ui/files/${encodeURIComponent(m.filename)}`;
|
||||||
|
return `
|
||||||
|
<div class="msg-item">
|
||||||
|
<div class="msg-meta">
|
||||||
|
<span>От: <b>${escapeHtml(m.sender)}</b></span>
|
||||||
|
<span>${(m.size / 1024).toFixed(1)} КБ</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-weight:600; margin:2px 0;">
|
||||||
|
<a href="${fileUrl}" target="_blank" style="color:#60a5fa; text-decoration:none;">${escapeHtml(m.filename)} ↗</a>
|
||||||
|
</div>
|
||||||
|
<div class="path-box">Диск: ${escapeHtml(m.saved_path)}</div>
|
||||||
|
${isImg ? `<img src="${fileUrl}" class="preview-img">` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTrust(ip, trust) {
|
||||||
|
const res = await fetch('/ui/trust', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target_ip: ip, trust })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
showStatus(trust ? `Сертификат узла ${ip} заверен в ОЗУ` : `Доверие узлу ${ip} отозвано`);
|
||||||
|
fetchState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendText() {
|
||||||
|
const target_ip = document.getElementById('target-peer').value;
|
||||||
|
const input = document.getElementById('text-input');
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!target_ip) return showStatus('Выберите устройство-получатель!', true);
|
||||||
|
if (!text) return showStatus('Введите текст для отправки!', true);
|
||||||
|
|
||||||
|
const res = await fetch('/ui/send-text', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target_ip, text })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
input.value = '';
|
||||||
|
showStatus(`Текст доставлен в ОЗУ узла ${target_ip}`);
|
||||||
|
} else {
|
||||||
|
const err = await res.text();
|
||||||
|
showStatus(err, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMedia() {
|
||||||
|
const target_ip = document.getElementById('target-peer').value;
|
||||||
|
const fileInput = document.getElementById('media-input');
|
||||||
|
if (!target_ip) return showStatus('Выберите устройство-получатель!', true);
|
||||||
|
if (!fileInput.files.length) return showStatus('Выберите медиафайл!', true);
|
||||||
|
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
showStatus(`Отправка файла "${file.name}"...`);
|
||||||
|
|
||||||
|
const res = await fetch('/ui/send-media', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Target-Ip': target_ip,
|
||||||
|
'X-Filename': encodeURIComponent(file.name),
|
||||||
|
'Content-Type': file.type || 'application/octet-stream'
|
||||||
|
},
|
||||||
|
body: file
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
fileInput.value = '';
|
||||||
|
showStatus(`Файл "${file.name}" сохранен на диске узла ${target_ip}`);
|
||||||
|
} else {
|
||||||
|
const err = await res.text();
|
||||||
|
showStatus(err, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearRam() {
|
||||||
|
await fetch('/ui/clear-ram', { method: 'POST' });
|
||||||
|
fetchState();
|
||||||
|
showStatus('Буфер текстовых сообщений в ОЗУ очищен');
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(text) {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
showStatus('Текст скопирован в буфер обмена');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str).replace(/[&<>"']/g, s => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[s]));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('text-input').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendText();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(fetchState, 1500);
|
||||||
|
fetchState();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
use rcgen::{generate_simple_self_signed, CertifiedKey};
|
||||||
|
use rustls::{
|
||||||
|
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
|
||||||
|
crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider},
|
||||||
|
pki_types::{CertificateDer, ServerName, UnixTime},
|
||||||
|
ClientConfig, DigitallySignedStruct, Error as TlsError, SignatureScheme,
|
||||||
|
};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PinnedCertVerifier {
|
||||||
|
trusted_fingerprints: Arc<RwLock<HashMap<String, String>>>,
|
||||||
|
provider: Arc<CryptoProvider>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PinnedCertVerifier {
|
||||||
|
pub fn new(trusted_fingerprints: Arc<RwLock<HashMap<String, String>>>) -> Self {
|
||||||
|
Self {
|
||||||
|
trusted_fingerprints,
|
||||||
|
provider: Arc::new(rustls::crypto::ring::default_provider()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cert_fingerprint(cert: &CertificateDer<'_>) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(cert.as_ref());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerCertVerifier for PinnedCertVerifier {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
end_entity: &CertificateDer<'_>,
|
||||||
|
_intermediates: &[CertificateDer<'_>],
|
||||||
|
server_name: &ServerName<'_>,
|
||||||
|
_ocsp_response: &[u8],
|
||||||
|
_now: UnixTime,
|
||||||
|
) -> Result<ServerCertVerified, TlsError> {
|
||||||
|
let actual_fp = Self::cert_fingerprint(end_entity);
|
||||||
|
let host = match server_name {
|
||||||
|
ServerName::DnsName(name) => name.as_ref().to_string(),
|
||||||
|
ServerName::IpAddress(ip) => std::net::IpAddr::from(*ip).to_string(),
|
||||||
|
_ => return Err(TlsError::General("Неподдерживаемое имя узла".into())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let trusted = self.trusted_fingerprints.read().unwrap();
|
||||||
|
match trusted.get(&host) {
|
||||||
|
Some(expected_fp) if expected_fp.eq_ignore_ascii_case(&actual_fp) => {
|
||||||
|
Ok(ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
Some(_) => Err(TlsError::General(format!(
|
||||||
|
"ВНИМАНИЕ: Сертификат узла {} не совпадает с заверенным!",
|
||||||
|
host
|
||||||
|
))),
|
||||||
|
None => Err(TlsError::General(format!(
|
||||||
|
"Узел {} не заверен! Нажмите «Заверить» в списке устройств.",
|
||||||
|
host
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &CertificateDer<'_>,
|
||||||
|
dss: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, TlsError> {
|
||||||
|
verify_tls12_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&self.provider.signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &CertificateDer<'_>,
|
||||||
|
dss: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, TlsError> {
|
||||||
|
verify_tls13_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&self.provider.signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||||
|
self.provider
|
||||||
|
.signature_verification_algorithms
|
||||||
|
.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Генерирует самоподписанный сертификат и приватный ключ в ОЗУ
|
||||||
|
pub fn generate_in_memory_cert(ip: &str) -> (Vec<u8>, Vec<u8>, String) {
|
||||||
|
let san = vec!["localhost".to_string(), ip.to_string()];
|
||||||
|
let CertifiedKey { cert, key_pair } =
|
||||||
|
generate_simple_self_signed(san).expect("Ошибка генерации сертификата");
|
||||||
|
|
||||||
|
let cert_der = cert.der().to_vec();
|
||||||
|
let key_der = key_pair.serialize_der();
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&cert_der);
|
||||||
|
let fingerprint = hex::encode(hasher.finalize());
|
||||||
|
|
||||||
|
(cert_der, key_der, fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Создает преднастроенный HTTPS-клиент с кастомным верификатором
|
||||||
|
pub fn build_pinned_http_client(
|
||||||
|
trusted_fingerprints: Arc<RwLock<HashMap<String, String>>>,
|
||||||
|
) -> reqwest::Client {
|
||||||
|
let verifier = Arc::new(PinnedCertVerifier::new(trusted_fingerprints));
|
||||||
|
let rustls_client_config = ClientConfig::builder_with_provider(Arc::new(
|
||||||
|
rustls::crypto::ring::default_provider(),
|
||||||
|
))
|
||||||
|
.with_safe_default_protocol_versions()
|
||||||
|
.unwrap()
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(verifier)
|
||||||
|
.with_no_client_auth();
|
||||||
|
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.use_preconfigured_tls(rustls_client_config)
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.expect("Ошибка создания HTTPS-клиента")
|
||||||
|
}
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
use crate::models::{
|
||||||
|
sanitize_filename, AppState, IncomingTextPayload, MediaItem, PeerNode, TextItem,
|
||||||
|
UiSendTextRequest, UiTrustRequest, MEDIA_DIR,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
body::Bytes,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{header, HeaderMap, StatusCode},
|
||||||
|
response::{Html, IntoResponse},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use std::{path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
|
/// Вшиваем внешний файл src/static/index.html в бинарник на этапе компиляции
|
||||||
|
const INDEX_HTML: &str = include_str!("static/index.html");
|
||||||
|
|
||||||
|
pub async fn ui_index() -> Html<&'static str> {
|
||||||
|
Html(INDEX_HTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ui_get_state(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
|
||||||
|
let trusted_map = state.trusted_fingerprints.read().unwrap();
|
||||||
|
let mut peers_list: Vec<PeerNode> = state
|
||||||
|
.peers
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.cloned()
|
||||||
|
.map(|mut p| {
|
||||||
|
p.verified = trusted_map
|
||||||
|
.get(&p.ip)
|
||||||
|
.map(|fp| fp.eq_ignore_ascii_case(&p.fingerprint))
|
||||||
|
.unwrap_or(false);
|
||||||
|
p
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
peers_list.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
|
||||||
|
let texts: Vec<TextItem> = state.ram_texts.read().unwrap().iter().cloned().collect();
|
||||||
|
let media: Vec<MediaItem> = state.disk_media.read().unwrap().iter().cloned().collect();
|
||||||
|
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"device_name": state.device_name,
|
||||||
|
"my_ip": state.my_ip,
|
||||||
|
"p2p_port": state.p2p_port,
|
||||||
|
"my_fingerprint": state.my_fingerprint,
|
||||||
|
"my_pin": state.my_fingerprint[..8].to_uppercase(),
|
||||||
|
"peers": peers_list,
|
||||||
|
"texts": texts,
|
||||||
|
"media": media
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ui_trust_peer(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(req): Json<UiTrustRequest>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
let peers = state.peers.read().unwrap();
|
||||||
|
let peer = peers
|
||||||
|
.get(&req.target_ip)
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "Узел не найден".to_string()))?;
|
||||||
|
|
||||||
|
let mut trusted = state.trusted_fingerprints.write().unwrap();
|
||||||
|
if req.trust {
|
||||||
|
trusted.insert(peer.ip.clone(), peer.fingerprint.clone());
|
||||||
|
} else {
|
||||||
|
trusted.remove(&peer.ip);
|
||||||
|
}
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ui_clear_ram(State(state): State<Arc<AppState>>) -> StatusCode {
|
||||||
|
state.ram_texts.write().unwrap().clear();
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ui_send_text(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(req): Json<UiSendTextRequest>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
let port = state
|
||||||
|
.peers
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.get(&req.target_ip)
|
||||||
|
.map(|p| p.port)
|
||||||
|
.unwrap_or(state.p2p_port);
|
||||||
|
|
||||||
|
let url = format!("https://{}:{}/p2p/text", req.target_ip, port);
|
||||||
|
let payload = IncomingTextPayload {
|
||||||
|
sender: state.device_name.clone(),
|
||||||
|
text: req.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = state
|
||||||
|
.http_client
|
||||||
|
.post(&url)
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Ошибка TLS/Сети: {}", e)))?;
|
||||||
|
|
||||||
|
if resp.status().is_success() {
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
} else {
|
||||||
|
Err((resp.status(), "Удаленный узел отклонил запрос".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ui_send_media(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
let target_ip = headers
|
||||||
|
.get("X-Target-Ip")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.ok_or((StatusCode::BAD_REQUEST, "Не указан IP получателя".to_string()))?;
|
||||||
|
|
||||||
|
let filename = headers
|
||||||
|
.get("X-Filename")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("file.bin");
|
||||||
|
|
||||||
|
let mime = headers
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("application/octet-stream");
|
||||||
|
|
||||||
|
let port = state
|
||||||
|
.peers
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.get(target_ip)
|
||||||
|
.map(|p| p.port)
|
||||||
|
.unwrap_or(state.p2p_port);
|
||||||
|
|
||||||
|
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() {
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
} else {
|
||||||
|
Err((resp.status(), "Удаленный узел отклонил медиафайл".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ui_serve_saved_file(Path(filename): Path<String>) -> impl IntoResponse {
|
||||||
|
let safe_name = sanitize_filename(&filename);
|
||||||
|
let path = PathBuf::from(MEDIA_DIR).join(&safe_name);
|
||||||
|
match tokio::fs::read(&path).await {
|
||||||
|
Ok(bytes) => (StatusCode::OK, bytes).into_response(),
|
||||||
|
Err(_) => (StatusCode::NOT_FOUND, "Файл не найден на диске").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
use crate::models::{AppState, PeerNode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use socket2::{Domain, Protocol, Socket, Type};
|
||||||
|
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
|
||||||
|
const BROADCAST_PORT: u16 = 5354;
|
||||||
|
const BROADCAST_TARGET: &str = "255.255.255.255:5354";
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct BeaconPayload {
|
||||||
|
name: String,
|
||||||
|
port: u16,
|
||||||
|
fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_discovery(state: Arc<AppState>) {
|
||||||
|
// 1. Поток передачи маяка (Broadcast Transmitter)
|
||||||
|
let send_state = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let sender = match UdpSocket::bind("0.0.0.0:0").await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[UDP Beacon] Не удалось открыть сокет отправки: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = sender.set_broadcast(true);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let payload = BeaconPayload {
|
||||||
|
name: send_state.device_name.clone(),
|
||||||
|
port: send_state.p2p_port,
|
||||||
|
fingerprint: send_state.my_fingerprint.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(data) = serde_json::to_vec(&payload) {
|
||||||
|
let _ = sender.send_to(&data, BROADCAST_TARGET).await;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Поток прослушивания маяков (Broadcast Receiver)
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let bind_addr: SocketAddr = format!("0.0.0.0:{}", BROADCAST_PORT).parse().unwrap();
|
||||||
|
let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))
|
||||||
|
.expect("Ошибка создания UDP сокета");
|
||||||
|
|
||||||
|
// Кроссплатформенное разрешение повторного использования порта
|
||||||
|
let _ = socket.set_reuse_address(true);
|
||||||
|
let _ = socket.set_broadcast(true);
|
||||||
|
let _ = socket.set_nonblocking(true);
|
||||||
|
|
||||||
|
if let Err(e) = socket.bind(&bind_addr.into()) {
|
||||||
|
eprintln!(
|
||||||
|
"[UDP Beacon] Порт {} занят. Фоновое автообнаружение ограничено: {}",
|
||||||
|
BROADCAST_PORT, e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let std_socket: std::net::UdpSocket = socket.into();
|
||||||
|
let receiver = match UdpSocket::from_std(std_socket) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[UDP Beacon] Ошибка tokio::UdpSocket: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut buf = [0u8; 1500];
|
||||||
|
loop {
|
||||||
|
match receiver.recv_from(&mut buf).await {
|
||||||
|
Ok((len, remote_addr)) => {
|
||||||
|
if let Ok(beacon) = serde_json::from_slice::<BeaconPayload>(&buf[..len]) {
|
||||||
|
// Игнорируем собственный маяк
|
||||||
|
if beacon.fingerprint == state.my_fingerprint || beacon.fingerprint.len() < 8 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip_str = remote_addr.ip().to_string();
|
||||||
|
let pin_code = beacon.fingerprint[..8].to_uppercase();
|
||||||
|
|
||||||
|
let mut peers = state.peers.write().unwrap();
|
||||||
|
let is_verified = state
|
||||||
|
.trusted_fingerprints
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.get(&ip_str)
|
||||||
|
.map(|trusted_fp| trusted_fp.eq_ignore_ascii_case(&beacon.fingerprint))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
peers.insert(
|
||||||
|
ip_str.clone(),
|
||||||
|
PeerNode {
|
||||||
|
name: beacon.name,
|
||||||
|
ip: ip_str,
|
||||||
|
port: beacon.port,
|
||||||
|
fingerprint: beacon.fingerprint,
|
||||||
|
pin_code,
|
||||||
|
verified: is_verified,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
mod discovery;
|
||||||
|
mod models;
|
||||||
|
mod p2p;
|
||||||
|
mod tls;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use axum_server::tls_rustls::RustlsConfig;
|
||||||
|
use local_ip_address::local_ip;
|
||||||
|
use models::{AppState, MAX_PAYLOAD_SIZE, MAX_RAM_TEXTS, MEDIA_DIR};
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, VecDeque},
|
||||||
|
net::SocketAddr,
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// 0. Регистрация глобального CryptoProvider (ring) для rustls 0.23+
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
||||||
|
let mut device_name = "My-Device".to_string();
|
||||||
|
let mut p2p_port: u16 = 8443;
|
||||||
|
let mut ui_port: u16 = 8080;
|
||||||
|
let mut custom_ip: Option<String> = None;
|
||||||
|
|
||||||
|
let mut i = 1;
|
||||||
|
let mut positional_idx = 0;
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--ip" => {
|
||||||
|
if i + 1 < args.len() {
|
||||||
|
custom_ip = Some(args[i + 1].clone());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--name" => {
|
||||||
|
if i + 1 < args.len() {
|
||||||
|
device_name = args[i + 1].clone();
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--p2p-port" => {
|
||||||
|
if i + 1 < args.len() {
|
||||||
|
if let Ok(p) = args[i + 1].parse() {
|
||||||
|
p2p_port = p;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--ui-port" => {
|
||||||
|
if i + 1 < args.len() {
|
||||||
|
if let Ok(p) = args[i + 1].parse() {
|
||||||
|
ui_port = p;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
arg if !arg.starts_with("--") => {
|
||||||
|
match positional_idx {
|
||||||
|
0 => device_name = arg.to_string(),
|
||||||
|
1 => {
|
||||||
|
if let Ok(p) = arg.parse() {
|
||||||
|
p2p_port = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
if let Ok(p) = arg.parse() {
|
||||||
|
ui_port = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 => custom_ip = Some(arg.to_string()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
positional_idx += 1;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (bind_ip, my_ip) = match custom_ip {
|
||||||
|
Some(ip) => {
|
||||||
|
if ip == "0.0.0.0" {
|
||||||
|
let detected = local_ip()
|
||||||
|
.map(|addr| addr.to_string())
|
||||||
|
.unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||||
|
("0.0.0.0".to_string(), detected)
|
||||||
|
} else {
|
||||||
|
(ip.clone(), ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let detected = local_ip().expect("Не удалось определить локальный IP").to_string();
|
||||||
|
("0.0.0.0".to_string(), detected)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::fs::create_dir_all(MEDIA_DIR)
|
||||||
|
.await
|
||||||
|
.expect("Не удалось создать папку received_media на диске");
|
||||||
|
|
||||||
|
// 1. Генерация сертификата в ОЗУ под выбранный IP и настройка HTTPS-клиентов
|
||||||
|
let (cert_der, key_der, my_fingerprint) = tls::generate_in_memory_cert(&my_ip);
|
||||||
|
let trusted_fingerprints = Arc::new(RwLock::new(HashMap::new()));
|
||||||
|
let http_client = tls::build_pinned_http_client(trusted_fingerprints.clone());
|
||||||
|
let probe_client = tls::build_probe_http_client();
|
||||||
|
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
device_name: device_name.clone(),
|
||||||
|
my_ip: my_ip.clone(),
|
||||||
|
p2p_port,
|
||||||
|
my_fingerprint: my_fingerprint.clone(),
|
||||||
|
ram_texts: RwLock::new(VecDeque::with_capacity(MAX_RAM_TEXTS)),
|
||||||
|
disk_media: RwLock::new(VecDeque::new()),
|
||||||
|
peers: RwLock::new(HashMap::new()),
|
||||||
|
trusted_fingerprints,
|
||||||
|
http_client,
|
||||||
|
probe_client,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Запуск широковещательного UDP-маяка на порт 5354
|
||||||
|
discovery::start_discovery(state.clone());
|
||||||
|
|
||||||
|
// 3. Запуск внешнего P2P HTTPS-сервера
|
||||||
|
let tls_config = RustlsConfig::from_der(vec![cert_der], key_der)
|
||||||
|
.await
|
||||||
|
.expect("Ошибка конфигурации TLS");
|
||||||
|
|
||||||
|
let p2p_router = Router::new()
|
||||||
|
.route("/p2p/info", get(p2p::p2p_info))
|
||||||
|
.route("/p2p/text", post(p2p::p2p_receive_text))
|
||||||
|
.route("/p2p/media", post(p2p::p2p_receive_media))
|
||||||
|
.layer(DefaultBodyLimit::max(MAX_PAYLOAD_SIZE))
|
||||||
|
.with_state(state.clone());
|
||||||
|
|
||||||
|
let p2p_addr: SocketAddr = format!("{}:{}", bind_ip, p2p_port)
|
||||||
|
.parse()
|
||||||
|
.unwrap_or_else(|_| panic!("Некорректный сокет: {}:{}", bind_ip, p2p_port));
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum_server::bind_rustls(p2p_addr, tls_config)
|
||||||
|
.serve(p2p_router.into_make_service())
|
||||||
|
.await
|
||||||
|
.expect("Ошибка P2P HTTPS-сервера");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Запуск локального 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/trust", post(ui::ui_trust_peer))
|
||||||
|
.route("/ui/clear-ram", post(ui::ui_clear_ram))
|
||||||
|
.route("/ui/send-text", post(ui::ui_send_text))
|
||||||
|
.route("/ui/send-media", post(ui::ui_send_media))
|
||||||
|
.route("/ui/files/:filename", get(ui::ui_serve_saved_file))
|
||||||
|
.layer(DefaultBodyLimit::max(MAX_PAYLOAD_SIZE))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let ui_addr: SocketAddr = format!("127.0.0.1:{}", ui_port).parse().unwrap();
|
||||||
|
println!("==============================================================");
|
||||||
|
println!(" Устройство: {}", device_name);
|
||||||
|
println!(" P2P сокет (HTTPS): https://{}:{}", bind_ip, p2p_port);
|
||||||
|
println!(" Маяк обнаружения (UDP): 255.255.255.255:5354");
|
||||||
|
println!(" КОД СВЕРКИ СЕРТИФИКАТА (PIN): [ {} ]", &my_fingerprint[..8].to_uppercase());
|
||||||
|
println!(" Хранилище медиа: ./{}/", MEDIA_DIR);
|
||||||
|
println!("--------------------------------------------------------------");
|
||||||
|
println!(" ОТКРОЙТЕ ИНТЕРФЕЙС В БРАУЗЕРЕ: http://127.0.0.1:{}", ui_port);
|
||||||
|
println!("==============================================================");
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(ui_addr).await.unwrap();
|
||||||
|
axum::serve(listener, ui_router).await.unwrap();
|
||||||
|
}
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
use crate::models::{AppState, PeerNode, SERVICE_TYPE};
|
||||||
|
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub fn start_mdns_service(state: Arc<AppState>) {
|
||||||
|
let mdns = ServiceDaemon::new().expect("Не удалось запустить mDNS");
|
||||||
|
|
||||||
|
let instance_name = format!("{}_{}", state.device_name, &state.my_fingerprint[..6]);
|
||||||
|
let host_name = format!("{}.local.", instance_name);
|
||||||
|
let properties = [
|
||||||
|
("fp", state.my_fingerprint.as_str()),
|
||||||
|
("name", state.device_name.as_str()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let service_info = ServiceInfo::new(
|
||||||
|
SERVICE_TYPE,
|
||||||
|
&instance_name,
|
||||||
|
&host_name,
|
||||||
|
&state.my_ip,
|
||||||
|
state.p2p_port,
|
||||||
|
&properties[..],
|
||||||
|
)
|
||||||
|
.expect("Ошибка создания mDNS записи");
|
||||||
|
|
||||||
|
mdns.register(service_info).expect("Ошибка регистрации mDNS");
|
||||||
|
|
||||||
|
let receiver = mdns.browse(SERVICE_TYPE).expect("Ошибка mDNS browse");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok(event) = receiver.recv_async().await {
|
||||||
|
if let ServiceEvent::ServiceResolved(info) = event {
|
||||||
|
let fp = info
|
||||||
|
.get_property_val_str("fp")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let name = info
|
||||||
|
.get_property_val_str("name")
|
||||||
|
.unwrap_or("Unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if fp == state.my_fingerprint || fp.len() < 8 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ip) = info.get_addresses().iter().next() {
|
||||||
|
let ip_str = ip.to_string();
|
||||||
|
let pin_code = fp[..8].to_uppercase();
|
||||||
|
let peer = PeerNode {
|
||||||
|
name,
|
||||||
|
ip: ip_str.clone(),
|
||||||
|
port: info.get_port(),
|
||||||
|
fingerprint: fp,
|
||||||
|
pin_code,
|
||||||
|
verified: false,
|
||||||
|
};
|
||||||
|
state.peers.write().unwrap().insert(ip_str, peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, VecDeque},
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MAX_PAYLOAD_SIZE: usize = 200 * 1024 * 1024;
|
||||||
|
pub const MAX_RAM_TEXTS: usize = 50;
|
||||||
|
pub const MEDIA_DIR: &str = "received_media";
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TextItem {
|
||||||
|
pub sender: String,
|
||||||
|
pub text: String,
|
||||||
|
pub timestamp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MediaItem {
|
||||||
|
pub sender: String,
|
||||||
|
pub filename: String,
|
||||||
|
pub saved_path: String,
|
||||||
|
pub mime: String,
|
||||||
|
pub size: usize,
|
||||||
|
pub timestamp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PeerNode {
|
||||||
|
pub name: String,
|
||||||
|
pub ip: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub fingerprint: String,
|
||||||
|
pub pin_code: String,
|
||||||
|
pub verified: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub device_name: String,
|
||||||
|
pub my_ip: String,
|
||||||
|
pub p2p_port: u16,
|
||||||
|
pub my_fingerprint: String,
|
||||||
|
pub ram_texts: RwLock<VecDeque<TextItem>>,
|
||||||
|
pub disk_media: RwLock<VecDeque<MediaItem>>,
|
||||||
|
pub peers: RwLock<HashMap<String, PeerNode>>,
|
||||||
|
pub trusted_fingerprints: Arc<RwLock<HashMap<String, String>>>,
|
||||||
|
pub http_client: reqwest::Client,
|
||||||
|
pub probe_client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct IncomingTextPayload {
|
||||||
|
pub sender: String,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UiSendTextRequest {
|
||||||
|
pub target_ip: String,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UiTrustRequest {
|
||||||
|
pub target_ip: String,
|
||||||
|
pub trust: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UiManualPeerRequest {
|
||||||
|
pub ip: String,
|
||||||
|
pub port: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_sec() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn url_decode(input: &str) -> String {
|
||||||
|
let mut bytes = Vec::with_capacity(input.len());
|
||||||
|
let mut chars = input.as_bytes().iter().copied();
|
||||||
|
while let Some(b) = chars.next() {
|
||||||
|
if b == b'%' {
|
||||||
|
if let (Some(c1), Some(c2)) = (chars.next(), chars.next()) {
|
||||||
|
if let Ok(decoded) = u8::from_str_radix(&format!("{}{}", c1 as char, c2 as char), 16)
|
||||||
|
{
|
||||||
|
bytes.push(decoded);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if b == b'+' {
|
||||||
|
bytes.push(b' ');
|
||||||
|
} else {
|
||||||
|
bytes.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&bytes).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sanitize_filename(name: &str) -> String {
|
||||||
|
let decoded = url_decode(name);
|
||||||
|
let clean: String = decoded
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
|
||||||
|
_ => c,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let trimmed = clean.trim_matches('.').trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"media_file.bin".to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
use crate::models::{
|
||||||
|
now_sec, sanitize_filename, url_decode, AppState, IncomingTextPayload, MediaItem, TextItem,
|
||||||
|
MAX_RAM_TEXTS, MEDIA_DIR,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
body::Bytes,
|
||||||
|
extract::State,
|
||||||
|
http::{header, HeaderMap, StatusCode},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use std::{path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
|
/// Информационный эндпоинт для прямого зондирования узла
|
||||||
|
pub async fn p2p_info(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"name": state.device_name,
|
||||||
|
"port": state.p2p_port,
|
||||||
|
"fingerprint": state.my_fingerprint,
|
||||||
|
"pin": state.my_fingerprint[..8].to_uppercase()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Прием текста -> СТРОГО В ОЗУ
|
||||||
|
pub async fn p2p_receive_text(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(payload): Json<IncomingTextPayload>,
|
||||||
|
) -> StatusCode {
|
||||||
|
let mut ram_texts = state.ram_texts.write().unwrap();
|
||||||
|
if ram_texts.len() >= MAX_RAM_TEXTS {
|
||||||
|
ram_texts.pop_back();
|
||||||
|
}
|
||||||
|
ram_texts.push_front(TextItem {
|
||||||
|
sender: payload.sender,
|
||||||
|
text: payload.text,
|
||||||
|
timestamp: now_sec(),
|
||||||
|
});
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Прием медиаконтента -> НА ДИСК
|
||||||
|
pub async fn p2p_receive_media(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
let sender = headers
|
||||||
|
.get("X-Sender")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(url_decode)
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
|
||||||
|
let raw_filename = headers
|
||||||
|
.get("X-Filename")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("file.bin");
|
||||||
|
|
||||||
|
let mime = headers
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("application/octet-stream")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
tokio::fs::write(&file_path, &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 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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LAN Share — Текст в ОЗУ / Медиа на Диск</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg: #0f172a; --card: #1e293b; --border: #334155; --accent: #3b82f6; --green: #10b981; --red: #ef4444; --text: #f8fafc; --muted: #94a3b8; }
|
||||||
|
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
|
||||||
|
body { margin: 0; padding: 20px; background: var(--bg); color: var(--text); }
|
||||||
|
.container { max-width: 1100px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||||
|
@media (max-width: 800px) { .container { grid-template-columns: 1fr; } }
|
||||||
|
.header { grid-column: 1 / -1; background: var(--card); border: 1px solid var(--border); padding: 16px 20px; border-radius: 12px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.pin-badge { background: #1d4ed8; padding: 6px 14px; border-radius: 8px; font-weight: 700; letter-spacing: 1px; font-family: monospace; font-size: 1.1rem; }
|
||||||
|
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
h2 { margin: 0; font-size: 1.1rem; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.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; }
|
||||||
|
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.btn-green { background: var(--green); }
|
||||||
|
button.btn-red { background: var(--red); }
|
||||||
|
button.btn-sm { padding: 6px 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); }
|
||||||
|
.msg-text { font-size: 0.95rem; white-space: pre-wrap; word-break: break-word; width: 100%; margin: 4px 0; }
|
||||||
|
.path-box { font-family: monospace; font-size: 0.78rem; color: #93c5fd; background: rgba(59,130,246,0.1); padding: 4px 8px; border-radius: 4px; word-break: break-all; }
|
||||||
|
.preview-img { max-width: 100%; max-height: 180px; border-radius: 6px; margin-top: 6px; }
|
||||||
|
#status { grid-column: 1 / -1; padding: 10px 16px; border-radius: 8px; display: none; font-weight: 500; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 1.2rem; font-weight: 700;" id="my-name">Загрузка...</div>
|
||||||
|
<div style="font-size: 0.85rem; color: var(--muted);" id="my-ip"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="font-size: 0.85rem; color: var(--muted); margin-right: 8px;">Код сверки сертификата (PIN):</span>
|
||||||
|
<span class="pin-badge" id="my-pin">--------</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status"></div>
|
||||||
|
|
||||||
|
<!-- 1. Устройства в сети + ручной ввод -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>1. Устройства в сети <span class="tag tag-disk">UDP Broadcast</span></h2>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<input type="text" id="manual-ip-input" placeholder="IP устройства (напр. 192.168.1.55)">
|
||||||
|
<button class="btn-sm" style="white-space: nowrap;" onclick="addManualPeer()">Найти по IP</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="peers-list" style="display:flex; flex-direction:column; gap:8px;">
|
||||||
|
<div style="color:var(--muted); font-size:0.9rem;">Поиск устройств через маяк 255.255.255.255:5354...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Панель отправки (Только по нажатию) -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>2. Отправка данных</h2>
|
||||||
|
<label style="font-size:0.85rem; color:var(--muted);">Получатель (должен быть заверен):</label>
|
||||||
|
<select id="target-peer"><option value="">-- Выберите устройство --</option></select>
|
||||||
|
|
||||||
|
<textarea id="text-input" placeholder="Введите текст (Enter — отправить, Shift+Enter — новая строка)..."></textarea>
|
||||||
|
<button onclick="sendText()">Отправить текст в ОЗУ</button>
|
||||||
|
|
||||||
|
<hr style="border:0; border-top:1px solid var(--border); width:100%; margin: 4px 0;">
|
||||||
|
<input type="file" id="media-input">
|
||||||
|
<button class="btn-green" onclick="sendMedia()">Отправить медиа на диск получателя</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Принятый текст (Строго в ОЗУ) -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
<span>Входящий текст <span class="tag tag-ram">ТОЛЬКО В ОЗУ</span></span>
|
||||||
|
<button class="btn-red btn-sm" onclick="clearRam()">Очистить ОЗУ</button>
|
||||||
|
</h2>
|
||||||
|
<div id="ram-texts" style="display:flex; flex-direction:column; gap:10px; max-height: 400px; overflow-y: auto;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. Принятые медиафайлы (Сохранены на диске) -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Входящие медиафайлы <span class="tag tag-disk">НА ДИСКЕ (./received_media)</span></h2>
|
||||||
|
<div id="disk-media" style="display:flex; flex-direction:column; gap:10px; max-height: 400px; overflow-y: auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let knownPeersKey = "";
|
||||||
|
|
||||||
|
function showStatus(msg, isError = false) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.style.background = isError ? 'rgba(239, 68, 68, 0.2)' : 'rgba(16, 185, 129, 0.2)';
|
||||||
|
el.style.border = `1px solid ${isError ? '#ef4444' : '#10b981'}`;
|
||||||
|
el.textContent = msg;
|
||||||
|
setTimeout(() => { el.style.display = 'none'; }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchState() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/ui/state');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
document.getElementById('my-name').textContent = `Устройство: ${data.device_name}`;
|
||||||
|
document.getElementById('my-ip').textContent = `HTTPS P2P: ${data.my_ip}:${data.p2p_port} | SHA-256: ${data.my_fingerprint.slice(0, 24)}...`;
|
||||||
|
document.getElementById('my-pin').textContent = data.my_pin;
|
||||||
|
|
||||||
|
const peersContainer = document.getElementById('peers-list');
|
||||||
|
if (data.peers.length === 0) {
|
||||||
|
peersContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Устройства пока не найдены. Введите IP вручную, если в сети отключен broadcast.</div>';
|
||||||
|
} else {
|
||||||
|
peersContainer.innerHTML = data.peers.map(p => `
|
||||||
|
<div class="peer-item">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;">${escapeHtml(p.name)} <span style="color:var(--muted); font-size:0.85rem;">(${p.ip}:${p.port})</span></div>
|
||||||
|
<div style="font-size:0.8rem; color:var(--muted); font-family:monospace;">PIN: <b style="color:#fff;">[${p.pin_code}]</b> | Статус: ${p.verified ? '<span style="color:#34d399;">Заверен</span>' : '<span style="color:#f87171;">Не заверен</span>'}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-sm ${p.verified ? 'btn-red' : 'btn-green'}" onclick="toggleTrust('${p.ip}', ${!p.verified})">
|
||||||
|
${p.verified ? 'Отозвать' : 'Заверить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSelectKey = data.peers.map(p => `${p.ip}:${p.verified}`).join('|');
|
||||||
|
if (currentSelectKey !== knownPeersKey) {
|
||||||
|
knownPeersKey = currentSelectKey;
|
||||||
|
const select = document.getElementById('target-peer');
|
||||||
|
const prevVal = select.value;
|
||||||
|
select.innerHTML = '<option value="">-- Выберите устройство --</option>' +
|
||||||
|
data.peers.map(p => `<option value="${p.ip}">${escapeHtml(p.name)} (${p.ip}) ${p.verified ? '✓' : '🔒 [Не заверен]'}</option>`).join('');
|
||||||
|
if (prevVal) select.value = prevVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textsContainer = document.getElementById('ram-texts');
|
||||||
|
if (data.texts.length === 0) {
|
||||||
|
textsContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Буфер ОЗУ пуст</div>';
|
||||||
|
} else {
|
||||||
|
textsContainer.innerHTML = data.texts.map(t => `
|
||||||
|
<div class="msg-item">
|
||||||
|
<div class="msg-meta">
|
||||||
|
<span>От: <b>${escapeHtml(t.sender)}</b></span>
|
||||||
|
<button class="btn-sm" onclick="copyText(${JSON.stringify(t.text).replace(/"/g, '"')})">Скопировать</button>
|
||||||
|
</div>
|
||||||
|
<div class="msg-text">${escapeHtml(t.text)}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaContainer = document.getElementById('disk-media');
|
||||||
|
if (data.media.length === 0) {
|
||||||
|
mediaContainer.innerHTML = '<div style="color:var(--muted); font-size:0.9rem;">Нет сохраненных медиафайлов</div>';
|
||||||
|
} else {
|
||||||
|
mediaContainer.innerHTML = data.media.map(m => {
|
||||||
|
const isImg = m.mime.startsWith('image/');
|
||||||
|
const fileUrl = `/ui/files/${encodeURIComponent(m.filename)}`;
|
||||||
|
return `
|
||||||
|
<div class="msg-item">
|
||||||
|
<div class="msg-meta">
|
||||||
|
<span>От: <b>${escapeHtml(m.sender)}</b></span>
|
||||||
|
<span>${(m.size / 1024).toFixed(1)} КБ</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-weight:600; margin:2px 0;">
|
||||||
|
<a href="${fileUrl}" target="_blank" style="color:#60a5fa; text-decoration:none;">${escapeHtml(m.filename)} ↗</a>
|
||||||
|
</div>
|
||||||
|
<div class="path-box">Диск: ${escapeHtml(m.saved_path)}</div>
|
||||||
|
${isImg ? `<img src="${fileUrl}" class="preview-img">` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addManualPeer() {
|
||||||
|
const input = document.getElementById('manual-ip-input');
|
||||||
|
const ip = input.value.trim();
|
||||||
|
if (!ip) return showStatus('Введите IP-адрес для поиска', true);
|
||||||
|
|
||||||
|
showStatus(`Опрос узла ${ip}...`);
|
||||||
|
const res = await fetch('/ui/manual-peer', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ip, port: 8443 })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
input.value = '';
|
||||||
|
showStatus(`Узел ${ip} успешно найден!`);
|
||||||
|
fetchState();
|
||||||
|
} else {
|
||||||
|
const err = await res.text();
|
||||||
|
showStatus(err, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTrust(ip, trust) {
|
||||||
|
const res = await fetch('/ui/trust', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target_ip: ip, trust })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
showStatus(trust ? `Сертификат узла ${ip} заверен в ОЗУ` : `Доверие узлу ${ip} отозвано`);
|
||||||
|
fetchState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendText() {
|
||||||
|
const target_ip = document.getElementById('target-peer').value;
|
||||||
|
const input = document.getElementById('text-input');
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!target_ip) return showStatus('Выберите устройство-получатель!', true);
|
||||||
|
if (!text) return showStatus('Введите текст для отправки!', true);
|
||||||
|
|
||||||
|
const res = await fetch('/ui/send-text', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target_ip, text })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
input.value = '';
|
||||||
|
showStatus(`Текст доставлен в ОЗУ узла ${target_ip}`);
|
||||||
|
} else {
|
||||||
|
const err = await res.text();
|
||||||
|
showStatus(err, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMedia() {
|
||||||
|
const target_ip = document.getElementById('target-peer').value;
|
||||||
|
const fileInput = document.getElementById('media-input');
|
||||||
|
if (!target_ip) return showStatus('Выберите устройство-получатель!', true);
|
||||||
|
if (!fileInput.files.length) return showStatus('Выберите медиафайл!', true);
|
||||||
|
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
showStatus(`Отправка файла "${file.name}"...`);
|
||||||
|
|
||||||
|
const res = await fetch('/ui/send-media', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Target-Ip': target_ip,
|
||||||
|
'X-Filename': encodeURIComponent(file.name),
|
||||||
|
'Content-Type': file.type || 'application/octet-stream'
|
||||||
|
},
|
||||||
|
body: file
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
fileInput.value = '';
|
||||||
|
showStatus(`Файл "${file.name}" сохранен на диске узла ${target_ip}`);
|
||||||
|
} else {
|
||||||
|
const err = await res.text();
|
||||||
|
showStatus(err, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearRam() {
|
||||||
|
await fetch('/ui/clear-ram', { method: 'POST' });
|
||||||
|
fetchState();
|
||||||
|
showStatus('Буфер текстовых сообщений в ОЗУ очищен');
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(text) {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
showStatus('Текст скопирован в буфер обмена');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str).replace(/[&<>"']/g, s => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[s]));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('text-input').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendText();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(fetchState, 1500);
|
||||||
|
fetchState();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
use rcgen::{generate_simple_self_signed, CertifiedKey};
|
||||||
|
use rustls::{
|
||||||
|
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
|
||||||
|
crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider},
|
||||||
|
pki_types::{CertificateDer, ServerName, UnixTime},
|
||||||
|
ClientConfig, DigitallySignedStruct, Error as TlsError, SignatureScheme,
|
||||||
|
};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PinnedCertVerifier {
|
||||||
|
trusted_fingerprints: Arc<RwLock<HashMap<String, String>>>,
|
||||||
|
provider: Arc<CryptoProvider>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PinnedCertVerifier {
|
||||||
|
pub fn new(trusted_fingerprints: Arc<RwLock<HashMap<String, String>>>) -> Self {
|
||||||
|
Self {
|
||||||
|
trusted_fingerprints,
|
||||||
|
provider: Arc::new(rustls::crypto::ring::default_provider()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cert_fingerprint(cert: &CertificateDer<'_>) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(cert.as_ref());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerCertVerifier for PinnedCertVerifier {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
end_entity: &CertificateDer<'_>,
|
||||||
|
_intermediates: &[CertificateDer<'_>],
|
||||||
|
server_name: &ServerName<'_>,
|
||||||
|
_ocsp_response: &[u8],
|
||||||
|
_now: UnixTime,
|
||||||
|
) -> Result<ServerCertVerified, TlsError> {
|
||||||
|
let actual_fp = Self::cert_fingerprint(end_entity);
|
||||||
|
let host = match server_name {
|
||||||
|
ServerName::DnsName(name) => name.as_ref().to_string(),
|
||||||
|
ServerName::IpAddress(ip) => std::net::IpAddr::from(*ip).to_string(),
|
||||||
|
_ => return Err(TlsError::General("Неподдерживаемое имя узла".into())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let trusted = self.trusted_fingerprints.read().unwrap();
|
||||||
|
match trusted.get(&host) {
|
||||||
|
Some(expected_fp) if expected_fp.eq_ignore_ascii_case(&actual_fp) => {
|
||||||
|
Ok(ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
Some(_) => Err(TlsError::General(format!(
|
||||||
|
"ВНИМАНИЕ: Сертификат узла {} не совпадает с заверенным!",
|
||||||
|
host
|
||||||
|
))),
|
||||||
|
None => Err(TlsError::General(format!(
|
||||||
|
"Узел {} не заверен! Нажмите «Заверить» в списке устройств.",
|
||||||
|
host
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &CertificateDer<'_>,
|
||||||
|
dss: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, TlsError> {
|
||||||
|
verify_tls12_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&self.provider.signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &CertificateDer<'_>,
|
||||||
|
dss: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, TlsError> {
|
||||||
|
verify_tls13_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&self.provider.signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||||
|
self.provider
|
||||||
|
.signature_verification_algorithms
|
||||||
|
.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Верификатор, используемый исключительно для зондирования узла при ручном добавлении по IP
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AnyCertVerifier {
|
||||||
|
provider: Arc<CryptoProvider>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnyCertVerifier {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
provider: Arc::new(rustls::crypto::ring::default_provider()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerCertVerifier for AnyCertVerifier {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
_end_entity: &CertificateDer<'_>,
|
||||||
|
_intermediates: &[CertificateDer<'_>],
|
||||||
|
_server_name: &ServerName<'_>,
|
||||||
|
_ocsp_response: &[u8],
|
||||||
|
_now: UnixTime,
|
||||||
|
) -> Result<ServerCertVerified, TlsError> {
|
||||||
|
Ok(ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &CertificateDer<'_>,
|
||||||
|
dss: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, TlsError> {
|
||||||
|
verify_tls12_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&self.provider.signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &CertificateDer<'_>,
|
||||||
|
dss: &DigitallySignedStruct,
|
||||||
|
) -> Result<HandshakeSignatureValid, TlsError> {
|
||||||
|
verify_tls13_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&self.provider.signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||||
|
self.provider
|
||||||
|
.signature_verification_algorithms
|
||||||
|
.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_in_memory_cert(ip: &str) -> (Vec<u8>, Vec<u8>, String) {
|
||||||
|
let san = vec!["localhost".to_string(), ip.to_string()];
|
||||||
|
let CertifiedKey { cert, key_pair } =
|
||||||
|
generate_simple_self_signed(san).expect("Ошибка генерации сертификата");
|
||||||
|
|
||||||
|
let cert_der = cert.der().to_vec();
|
||||||
|
let key_der = key_pair.serialize_der();
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&cert_der);
|
||||||
|
let fingerprint = hex::encode(hasher.finalize());
|
||||||
|
|
||||||
|
(cert_der, key_der, fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_pinned_http_client(
|
||||||
|
trusted_fingerprints: Arc<RwLock<HashMap<String, String>>>,
|
||||||
|
) -> reqwest::Client {
|
||||||
|
let verifier = Arc::new(PinnedCertVerifier::new(trusted_fingerprints));
|
||||||
|
let rustls_client_config = ClientConfig::builder_with_provider(Arc::new(
|
||||||
|
rustls::crypto::ring::default_provider(),
|
||||||
|
))
|
||||||
|
.with_safe_default_protocol_versions()
|
||||||
|
.unwrap()
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(verifier)
|
||||||
|
.with_no_client_auth();
|
||||||
|
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.use_preconfigured_tls(rustls_client_config)
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.expect("Ошибка создания HTTPS-клиента")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_probe_http_client() -> reqwest::Client {
|
||||||
|
let verifier = Arc::new(AnyCertVerifier::new());
|
||||||
|
let rustls_client_config = ClientConfig::builder_with_provider(Arc::new(
|
||||||
|
rustls::crypto::ring::default_provider(),
|
||||||
|
))
|
||||||
|
.with_safe_default_protocol_versions()
|
||||||
|
.unwrap()
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(verifier)
|
||||||
|
.with_no_client_auth();
|
||||||
|
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.use_preconfigured_tls(rustls_client_config)
|
||||||
|
.timeout(Duration::from_secs(4))
|
||||||
|
.build()
|
||||||
|
.expect("Ошибка создания probe-клиента")
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
use crate::models::{
|
||||||
|
sanitize_filename, AppState, IncomingTextPayload, MediaItem, PeerNode, TextItem,
|
||||||
|
UiManualPeerRequest, UiSendTextRequest, UiTrustRequest, MEDIA_DIR,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
body::Bytes,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{header, HeaderMap, StatusCode},
|
||||||
|
response::{Html, IntoResponse},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use std::{path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
|
const INDEX_HTML: &str = include_str!("static/index.html");
|
||||||
|
|
||||||
|
pub async fn ui_index() -> Html<&'static str> {
|
||||||
|
Html(INDEX_HTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ui_get_state(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
|
||||||
|
let trusted_map = state.trusted_fingerprints.read().unwrap();
|
||||||
|
let mut peers_list: Vec<PeerNode> = state
|
||||||
|
.peers
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.cloned()
|
||||||
|
.map(|mut p| {
|
||||||
|
p.verified = trusted_map
|
||||||
|
.get(&p.ip)
|
||||||
|
.map(|fp| fp.eq_ignore_ascii_case(&p.fingerprint))
|
||||||
|
.unwrap_or(false);
|
||||||
|
p
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
peers_list.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
|
||||||
|
let texts: Vec<TextItem> = state.ram_texts.read().unwrap().iter().cloned().collect();
|
||||||
|
let media: Vec<MediaItem> = state.disk_media.read().unwrap().iter().cloned().collect();
|
||||||
|
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"device_name": state.device_name,
|
||||||
|
"my_ip": state.my_ip,
|
||||||
|
"p2p_port": state.p2p_port,
|
||||||
|
"my_fingerprint": state.my_fingerprint,
|
||||||
|
"my_pin": state.my_fingerprint[..8].to_uppercase(),
|
||||||
|
"peers": peers_list,
|
||||||
|
"texts": texts,
|
||||||
|
"media": media
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ui_add_manual_peer(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(req): Json<UiManualPeerRequest>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
let ip = req.ip.trim().to_string();
|
||||||
|
let port = req.port.unwrap_or(8443);
|
||||||
|
|
||||||
|
let url = format!("https://{}:{}/p2p/info", ip, port);
|
||||||
|
let resp = state
|
||||||
|
.probe_client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Узел недоступен: {}", e)))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err((StatusCode::BAD_GATEWAY, "Узел вернул ошибку".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let info: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|_| (StatusCode::BAD_GATEWAY, "Неверный ответ узла".into()))?;
|
||||||
|
|
||||||
|
let name = info["name"].as_str().unwrap_or("Manual-Node").to_string();
|
||||||
|
let fingerprint = info["fingerprint"].as_str().unwrap_or("").to_string();
|
||||||
|
|
||||||
|
if fingerprint.is_empty() {
|
||||||
|
return Err((StatusCode::BAD_GATEWAY, "Отсутствует отпечаток узла".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pin_code = fingerprint[..8].to_uppercase();
|
||||||
|
let is_verified = state
|
||||||
|
.trusted_fingerprints
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.get(&ip)
|
||||||
|
.map(|fp| fp.eq_ignore_ascii_case(&fingerprint))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
state.peers.write().unwrap().insert(
|
||||||
|
ip.clone(),
|
||||||
|
PeerNode {
|
||||||
|
name,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
fingerprint,
|
||||||
|
pin_code,
|
||||||
|
verified: is_verified,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ui_trust_peer(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(req): Json<UiTrustRequest>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
let peers = state.peers.read().unwrap();
|
||||||
|
let peer = peers
|
||||||
|
.get(&req.target_ip)
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "Узел не найден".to_string()))?;
|
||||||
|
|
||||||
|
let mut trusted = state.trusted_fingerprints.write().unwrap();
|
||||||
|
if req.trust {
|
||||||
|
trusted.insert(peer.ip.clone(), peer.fingerprint.clone());
|
||||||
|
} else {
|
||||||
|
trusted.remove(&peer.ip);
|
||||||
|
}
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ui_clear_ram(State(state): State<Arc<AppState>>) -> StatusCode {
|
||||||
|
state.ram_texts.write().unwrap().clear();
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ui_send_text(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(req): Json<UiSendTextRequest>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
let port = state
|
||||||
|
.peers
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.get(&req.target_ip)
|
||||||
|
.map(|p| p.port)
|
||||||
|
.unwrap_or(state.p2p_port);
|
||||||
|
|
||||||
|
let url = format!("https://{}:{}/p2p/text", req.target_ip, port);
|
||||||
|
let payload = IncomingTextPayload {
|
||||||
|
sender: state.device_name.clone(),
|
||||||
|
text: req.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = state
|
||||||
|
.http_client
|
||||||
|
.post(&url)
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Ошибка TLS/Сети: {}", e)))?;
|
||||||
|
|
||||||
|
if resp.status().is_success() {
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
} else {
|
||||||
|
Err((resp.status(), "Удаленный узел отклонил запрос".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ui_send_media(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
let target_ip = headers
|
||||||
|
.get("X-Target-Ip")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.ok_or((StatusCode::BAD_REQUEST, "Не указан IP получателя".to_string()))?;
|
||||||
|
|
||||||
|
let filename = headers
|
||||||
|
.get("X-Filename")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("file.bin");
|
||||||
|
|
||||||
|
let mime = headers
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("application/octet-stream");
|
||||||
|
|
||||||
|
let port = state
|
||||||
|
.peers
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.get(target_ip)
|
||||||
|
.map(|p| p.port)
|
||||||
|
.unwrap_or(state.p2p_port);
|
||||||
|
|
||||||
|
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() {
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
} else {
|
||||||
|
Err((resp.status(), "Удаленный узел отклонил медиафайл".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ui_serve_saved_file(Path(filename): Path<String>) -> impl IntoResponse {
|
||||||
|
let safe_name = sanitize_filename(&filename);
|
||||||
|
let path = PathBuf::from(MEDIA_DIR).join(&safe_name);
|
||||||
|
match tokio::fs::read(&path).await {
|
||||||
|
Ok(bytes) => (StatusCode::OK, bytes).into_response(),
|
||||||
|
Err(_) => (StatusCode::NOT_FOUND, "Файл не найден на диске").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user