use crate::{models::*, net, storage, transfer}; use axum::{ body::Body, extract::{Path, Request, State}, http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode}, middleware::Next, response::{Html, IntoResponse, Response}, Json, }; use futures_util::StreamExt; use std::{ io, net::IpAddr, sync::{atomic::Ordering, Arc}, }; use tokio_util::io::ReaderStream; const INDEX_HTML: &str = include_str!("static/index.html"); const APP_JS: &str = include_str!("static/app.js"); const CSP: &str = "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self'; \ connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; // ───────────────────────── Защита UI (анти-CSRF / DNS-rebinding без токена) ───────────────────────── pub struct UiGuard { pub hosts: Vec, } fn with_security_headers(mut resp: Response) -> Response { let items: [(HeaderName, &'static str); 5] = [ (header::CONTENT_SECURITY_POLICY, CSP), (HeaderName::from_static("x-content-type-options"), "nosniff"), (header::X_FRAME_OPTIONS, "DENY"), (header::REFERRER_POLICY, "no-referrer"), (header::CACHE_CONTROL, "no-store"), ]; let h = resp.headers_mut(); for (k, v) in items { h.entry(k).or_insert(HeaderValue::from_static(v)); // не перетираем заголовки обработчика } resp } /// 1) Host только 127.0.0.1/localhost:порт (анти-DNS-rebinding) /// 2) POST только с Origin нашего же локального хоста (анти-CSRF) pub async fn ui_guard(State(g): State>, req: Request, next: Next) -> Response { let headers = req.headers(); let host_ok = headers .get(header::HOST) .and_then(|v| v.to_str().ok()) .map_or(false, |h| g.hosts.iter().any(|a| a.eq_ignore_ascii_case(h))); if !host_ok { return StatusCode::FORBIDDEN.into_response(); } if !matches!(*req.method(), Method::GET | Method::HEAD) { let origin_ok = headers .get(header::ORIGIN) .and_then(|v| v.to_str().ok()) .and_then(|o| o.strip_prefix("http://")) .map_or(false, |o| g.hosts.iter().any(|a| a.eq_ignore_ascii_case(o))); if !origin_ok { return StatusCode::FORBIDDEN.into_response(); } } with_security_headers(next.run(req).await) } // ───────────────────────── Статика и состояние ───────────────────────── pub async fn ui_index() -> Html<&'static str> { Html(INDEX_HTML) } pub async fn ui_js() -> impl IntoResponse { ([(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], APP_JS) } pub async fn ui_get_state(State(state): State>) -> Json { if let Some(ip) = net::detect_lan_ip() { state.update_my_ip(ip); } let mut peers: Vec = state.peers.read().values().cloned().collect(); peers.sort_by(|a, b| a.name.cmp(&b.name)); let texts: Vec = state.ram_texts.read().iter().cloned().collect(); let media: Vec = state.disk_media.read().iter().cloned().collect(); let transfers: Vec = state.transfers.read().clone(); let scan = state.scan_status.read().clone(); Json(serde_json::json!({ "device_name": state.device_name, "my_ip": state.current_ip(), "p2p_port": state.p2p_port, "my_fingerprint": state.my_fingerprint, "my_pin": short_id(&state.my_fingerprint), "media_dir": state.media_dir.display().to_string(), "ping_interval_sec": state.ping_interval_sec.load(Ordering::Relaxed), "last_ping": state.last_ping.load(Ordering::Relaxed), "now": now_sec(), "ping_min": MIN_PING_SEC, "ping_max": MAX_PING_SEC, "peers": peers, "texts": texts, "media": media, "transfers": transfers, "scan": scan, })) } // ───────────────────────── Устройства ───────────────────────── pub async fn ui_add_manual_peer( State(state): State>, Json(req): Json, ) -> Result { let ip: IpAddr = req .ip .trim() .parse() .map_err(|_| AppError::new(StatusCode::BAD_REQUEST, "Некорректный IP-адрес"))?; match ip { IpAddr::V4(v4) if is_lan_v4(v4) => {} _ => return Err(AppError::new(StatusCode::BAD_REQUEST, "Разрешены только локальные IPv4-адреса")), } let port = req.port.filter(|p| *p != 0).unwrap_or(state.p2p_port); net::probe_and_register(&state, ip, port) .await .map_err(|e| AppError::new(StatusCode::BAD_GATEWAY, format!("Узел {}: {}", ip, e)))?; Ok(StatusCode::OK) } pub async fn ui_scan_range( State(state): State>, Json(req): Json, ) -> Result { if let Some(ip) = net::detect_lan_ip() { state.update_my_ip(ip); } let port = req.port.filter(|p| *p != 0).unwrap_or(state.p2p_port); let my_ip = state.current_ip(); let (label, ips) = parse_ip_range(&req.range, &my_ip).map_err(|e| AppError::new(StatusCode::BAD_REQUEST, e))?; let total = ips.len(); { // проверка и установка флага — атомарно, под одним write-замком let mut s = state.scan_status.write(); if s.active { return Err(AppError::new(StatusCode::CONFLICT, "Сканирование уже выполняется")); } *s = ScanProgress { active: true, range: label.clone(), scanned: 0, total, found: 0, }; } state.add_log(format!("Запущено сканирование {} ({} адресов, порт {})", label, total, port)); let st = state.clone(); let worker = tokio::spawn(net::run_scan(state.clone(), ips, port)); tokio::spawn(async move { let _ = worker.await; // даже при панике воркера флаг active будет сброшен let found = { let mut s = st.scan_status.write(); s.active = false; s.found }; st.add_log(format!("Сканирование {} завершено. Найдено устройств: {}", label, found)); }); Ok(StatusCode::OK) } pub async fn ui_ping_peers(State(state): State>) -> StatusCode { net::ping_all_peers(&state).await; StatusCode::OK } pub async fn ui_trust_peer( State(state): State>, Json(req): Json, ) -> Result { let msg = { let mut peers = state.peers.write(); let p = peers .get_mut(&req.peer_id) .ok_or_else(|| AppError::new(StatusCode::NOT_FOUND, "Устройство не найдено"))?; p.verified = req.trust; if req.trust { format!("Устройство '{}' [{}] ЗАВЕРЕНО", p.name, p.pin_code) } else { format!("Доверие устройству '{}' [{}] ОТОЗВАНО", p.name, p.pin_code) } }; state.persist_trust(); state.add_log(msg); // сообщаем собеседнику сразу: он увидит запрос на сверку (или отзыв) и должен подтвердить у себя let (st, id) = (state.clone(), req.peer_id.clone()); tokio::spawn(async move { net::push_trust(&st, &id).await }); Ok(StatusCode::OK) } pub async fn ui_set_settings( State(state): State>, Json(req): Json, ) -> Result { state .set_ping_interval(req.ping_interval_sec) .map_err(|e| AppError::new(StatusCode::BAD_REQUEST, e))?; Ok(StatusCode::OK) } pub async fn ui_clear_ram(State(state): State>) -> StatusCode { state.ram_texts.write().clear(); state.add_log("Текстовый буфер в ОЗУ очищен"); StatusCode::OK } // ───────────────────────── Отправка (логика — в transfer.rs, общая с CLI) ───────────────────────── pub async fn ui_send_text( State(state): State>, Json(req): Json, ) -> Result { transfer::send_text(&state, &req.peer_id, req.text).await?; Ok(StatusCode::OK) } /// Потоковая отправка: браузер → канал с backpressure → TLS → получатель. Память O(1). pub async fn ui_send_media( State(state): State>, headers: HeaderMap, body: Body, ) -> Result { let peer_id = hdr(&headers, "x-target-id") .ok_or_else(|| AppError::new(StatusCode::BAD_REQUEST, "Не указан получатель"))? .to_string(); let name = pct_decode(hdr(&headers, "x-filename").unwrap_or("file.bin")); let total: u64 = hdr(&headers, "content-length") .and_then(|v| v.parse().ok()) .ok_or_else(|| AppError::new(StatusCode::LENGTH_REQUIRED, "Не указан размер файла"))?; let stream = body .into_data_stream() .map(|r| r.map_err(|_| io::Error::new(io::ErrorKind::Other, "client aborted"))); transfer::send_stream(&state, &peer_id, &name, total, stream).await?; Ok(StatusCode::OK) } // ───────────────────────── Раздача принятых файлов ───────────────────────── pub async fn ui_serve_saved_file(State(state): State>, Path(filename): Path) -> Response { let not_found = || (StatusCode::NOT_FOUND, "Файл не найден").into_response(); if filename.is_empty() || filename.starts_with('.') || filename.contains(|c: char| matches!(c, '/' | '\\' | '\0')) { return not_found(); } let path = state.media_dir.join(&filename); let Ok(meta) = tokio::fs::metadata(&path).await else { return not_found() }; if !meta.is_file() { return not_found(); } let Ok(file) = tokio::fs::File::open(&path).await else { return not_found() }; let inline = storage::is_previewable(&filename); // Content-Type определяем САМИ по расширению, а не по заявлению отправителя let ctype = if inline { storage::mime_from_name(&filename) } else { "application/octet-stream" }; let disposition = format!( "{}; filename*=UTF-8''{}", if inline { "inline" } else { "attachment" }, pct_encode(&filename) ); let mut resp = Response::new(Body::from_stream(ReaderStream::new(file))); let h = resp.headers_mut(); h.insert(header::CONTENT_TYPE, HeaderValue::from_static(ctype)); h.insert(header::CONTENT_LENGTH, HeaderValue::from(meta.len())); if let Ok(v) = HeaderValue::from_str(&disposition) { h.insert(header::CONTENT_DISPOSITION, v); } h.insert( header::CONTENT_SECURITY_POLICY, HeaderValue::from_static("default-src 'none'; sandbox"), ); h.insert( HeaderName::from_static("cross-origin-resource-policy"), HeaderValue::from_static("same-origin"), ); resp }