291 lines
12 KiB
Rust
291 lines
12 KiB
Rust
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<String>,
|
|
}
|
|
|
|
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<Arc<UiGuard>>, 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<Arc<AppState>>) -> Json<serde_json::Value> {
|
|
if let Some(ip) = net::detect_lan_ip() {
|
|
state.update_my_ip(ip);
|
|
}
|
|
let mut peers: Vec<PeerDevice> = state.peers.read().values().cloned().collect();
|
|
peers.sort_by(|a, b| a.name.cmp(&b.name));
|
|
let texts: Vec<TextItem> = state.ram_texts.read().iter().cloned().collect();
|
|
let media: Vec<MediaItem> = state.disk_media.read().iter().cloned().collect();
|
|
let transfers: Vec<TransferProgress> = 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<Arc<AppState>>,
|
|
Json(req): Json<UiManualPeerRequest>,
|
|
) -> Result<StatusCode, AppError> {
|
|
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<Arc<AppState>>,
|
|
Json(req): Json<UiScanRangeRequest>,
|
|
) -> Result<StatusCode, AppError> {
|
|
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<Arc<AppState>>) -> StatusCode {
|
|
net::ping_all_peers(&state).await;
|
|
StatusCode::OK
|
|
}
|
|
|
|
pub async fn ui_trust_peer(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<UiTrustRequest>,
|
|
) -> Result<StatusCode, AppError> {
|
|
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<Arc<AppState>>,
|
|
Json(req): Json<UiSettingsRequest>,
|
|
) -> Result<StatusCode, AppError> {
|
|
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<Arc<AppState>>) -> StatusCode {
|
|
state.ram_texts.write().clear();
|
|
state.add_log("Текстовый буфер в ОЗУ очищен");
|
|
StatusCode::OK
|
|
}
|
|
|
|
// ───────────────────────── Отправка (логика — в transfer.rs, общая с CLI) ─────────────────────────
|
|
pub async fn ui_send_text(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(req): Json<UiSendTextRequest>,
|
|
) -> Result<StatusCode, AppError> {
|
|
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<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
body: Body,
|
|
) -> Result<StatusCode, AppError> {
|
|
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<Arc<AppState>>, Path(filename): Path<String>) -> 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
|
|
}
|