2553 lines
95 KiB
Rust
2553 lines
95 KiB
Rust
use super::archive::extract_zip_archive;
|
|
use super::protocol::{
|
|
FileMetadata, PrepareTransferRequest, PrepareTransferResponse,
|
|
SharedFileEntry, SharedFolderInfo, SharedFolderTreeResponse, SharedUploadResponse,
|
|
TextMessage, TransferProgress, TransferState,
|
|
};
|
|
use crate::config::SharedFolderConfig;
|
|
use crate::network::DiscoveryBeacon;
|
|
use axum::{
|
|
body::Body,
|
|
extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
|
|
http::{header, HeaderMap, StatusCode},
|
|
response::{Html, IntoResponse, Json, Response},
|
|
routing::{get, post},
|
|
Router,
|
|
};
|
|
use serde::Serialize;
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::HashMap;
|
|
use std::net::SocketAddr;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
use tokio::fs::File;
|
|
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
|
use tokio::sync::{broadcast, mpsc, oneshot, Mutex, RwLock};
|
|
use tower_http::cors::CorsLayer;
|
|
use tracing::{error, info};
|
|
use uuid::Uuid;
|
|
|
|
pub struct ServerState {
|
|
pub my_info: DiscoveryBeacon,
|
|
pub download_dir: PathBuf,
|
|
pub auto_accept: Arc<RwLock<bool>>,
|
|
pub require_pin: Arc<RwLock<bool>>,
|
|
pub pin_code: Arc<RwLock<String>>,
|
|
pub auto_extract_zip: Arc<RwLock<bool>>,
|
|
pub shared_folders: Arc<RwLock<Vec<SharedFolderConfig>>>,
|
|
pub pending_requests: Arc<RwLock<HashMap<String, PrepareTransferRequest>>>,
|
|
pub accepted_requests: Arc<RwLock<HashMap<String, PrepareTransferRequest>>>,
|
|
pub pending_confirmations: Arc<Mutex<HashMap<String, oneshot::Sender<bool>>>>,
|
|
pub active_transfers: Arc<RwLock<HashMap<String, TransferProgress>>>,
|
|
pub transfer_speed_stats: Arc<Mutex<HashMap<String, (Instant, u64, f64)>>>,
|
|
pub transfer_events: broadcast::Sender<TransferProgress>,
|
|
pub incoming_request_events: broadcast::Sender<PrepareTransferRequest>,
|
|
pub incoming_text_events: broadcast::Sender<TextMessage>,
|
|
pub incoming_request_tx: mpsc::UnboundedSender<PrepareTransferRequest>,
|
|
pub incoming_text_tx: mpsc::UnboundedSender<TextMessage>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct TransferServer {
|
|
state: Arc<ServerState>,
|
|
port: u16,
|
|
shutdown_tx: Option<broadcast::Sender<()>>,
|
|
}
|
|
|
|
pub struct ServerChannels {
|
|
pub incoming_request_rx: mpsc::UnboundedReceiver<PrepareTransferRequest>,
|
|
pub incoming_text_rx: mpsc::UnboundedReceiver<TextMessage>,
|
|
}
|
|
|
|
impl TransferServer {
|
|
pub fn new(
|
|
my_info: DiscoveryBeacon,
|
|
download_dir: PathBuf,
|
|
port: u16,
|
|
auto_accept: bool,
|
|
require_pin: bool,
|
|
pin_code: String,
|
|
auto_extract_zip: bool,
|
|
shared_folders: Vec<SharedFolderConfig>,
|
|
) -> (Self, ServerChannels) {
|
|
let (transfer_events, _) = broadcast::channel(100);
|
|
let (incoming_request_events, _) = broadcast::channel(50);
|
|
let (incoming_text_events, _) = broadcast::channel(50);
|
|
let (incoming_request_tx, incoming_request_rx) = mpsc::unbounded_channel();
|
|
let (incoming_text_tx, incoming_text_rx) = mpsc::unbounded_channel();
|
|
|
|
let state = Arc::new(ServerState {
|
|
my_info,
|
|
download_dir,
|
|
auto_accept: Arc::new(RwLock::new(auto_accept)),
|
|
require_pin: Arc::new(RwLock::new(require_pin)),
|
|
pin_code: Arc::new(RwLock::new(pin_code)),
|
|
auto_extract_zip: Arc::new(RwLock::new(auto_extract_zip)),
|
|
shared_folders: Arc::new(RwLock::new(shared_folders)),
|
|
pending_requests: Arc::new(RwLock::new(HashMap::new())),
|
|
accepted_requests: Arc::new(RwLock::new(HashMap::new())),
|
|
pending_confirmations: Arc::new(Mutex::new(HashMap::new())),
|
|
active_transfers: Arc::new(RwLock::new(HashMap::new())),
|
|
transfer_speed_stats: Arc::new(Mutex::new(HashMap::new())),
|
|
transfer_events,
|
|
incoming_request_events,
|
|
incoming_text_events,
|
|
incoming_request_tx,
|
|
incoming_text_tx,
|
|
});
|
|
|
|
(
|
|
Self {
|
|
state,
|
|
port,
|
|
shutdown_tx: None,
|
|
},
|
|
ServerChannels {
|
|
incoming_request_rx,
|
|
incoming_text_rx,
|
|
},
|
|
)
|
|
}
|
|
|
|
pub fn state(&self) -> Arc<ServerState> {
|
|
self.state.clone()
|
|
}
|
|
|
|
pub async fn update_auto_accept(&self, enabled: bool) {
|
|
let mut aa = self.state.auto_accept.write().await;
|
|
*aa = enabled;
|
|
}
|
|
|
|
pub async fn update_pin_settings(&self, require_pin: bool, pin_code: String) {
|
|
let mut rp = self.state.require_pin.write().await;
|
|
*rp = require_pin;
|
|
let mut pc = self.state.pin_code.write().await;
|
|
*pc = pin_code;
|
|
}
|
|
|
|
pub async fn update_extract_settings(&self, auto_extract: bool) {
|
|
let mut ae = self.state.auto_extract_zip.write().await;
|
|
*ae = auto_extract;
|
|
}
|
|
|
|
pub async fn update_shared_folders(&self, shares: Vec<SharedFolderConfig>) {
|
|
let mut sf = self.state.shared_folders.write().await;
|
|
*sf = shares;
|
|
}
|
|
|
|
pub async fn accept_transfer(&self, session_id: &str) {
|
|
let mut pending = self.state.pending_requests.write().await;
|
|
if let Some(req) = pending.remove(session_id) {
|
|
let mut accepted = self.state.accepted_requests.write().await;
|
|
accepted.insert(session_id.to_string(), req);
|
|
|
|
let mut confirms = self.state.pending_confirmations.lock().await;
|
|
if let Some(tx) = confirms.remove(session_id) {
|
|
let _ = tx.send(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn reject_transfer(&self, session_id: &str) {
|
|
let mut pending = self.state.pending_requests.write().await;
|
|
pending.remove(session_id);
|
|
|
|
let mut confirms = self.state.pending_confirmations.lock().await;
|
|
if let Some(tx) = confirms.remove(session_id) {
|
|
let _ = tx.send(false);
|
|
}
|
|
}
|
|
|
|
pub async fn clear_finished(&self) {
|
|
let mut transfers = self.state.active_transfers.write().await;
|
|
transfers.retain(|_, t| t.state == TransferState::InProgress || t.state == TransferState::PendingConfirmation);
|
|
}
|
|
|
|
pub async fn start(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
let (shutdown_tx, _) = broadcast::channel::<()>(1);
|
|
self.shutdown_tx = Some(shutdown_tx.clone());
|
|
|
|
tokio::fs::create_dir_all(&self.state.download_dir).await?;
|
|
|
|
let app = Router::new()
|
|
.route("/", get(handle_web_ui))
|
|
.route("/api/info", get(handle_info))
|
|
.route("/api/transfer/prepare", post(handle_prepare))
|
|
.route("/api/transfer/text", post(handle_incoming_text))
|
|
.route(
|
|
"/api/transfer/chunk/{session_id}",
|
|
post(handle_upload_chunk).layer(DefaultBodyLimit::disable()),
|
|
)
|
|
.route(
|
|
"/api/transfer/finish/{session_id}",
|
|
post(handle_finish_transfer),
|
|
)
|
|
.route(
|
|
"/api/transfer/upload/{session_id}",
|
|
post(handle_upload).layer(DefaultBodyLimit::disable()),
|
|
)
|
|
.route(
|
|
"/api/web/upload",
|
|
post(handle_web_upload).layer(DefaultBodyLimit::disable()),
|
|
)
|
|
.route(
|
|
"/api/web/send-text",
|
|
post(handle_web_text),
|
|
)
|
|
.route("/api/web/files", get(handle_list_downloadable_files))
|
|
.route("/api/web/download/{filename}", get(handle_web_download))
|
|
.route("/manifest.json", get(handle_manifest))
|
|
.route("/api/shared/list", get(handle_shared_list))
|
|
.route("/api/shared/tree/{folder_id}", get(handle_shared_tree))
|
|
.route("/api/shared/stream/{folder_id}/{*subpath}", get(handle_shared_stream))
|
|
.route("/api/shared/download/{folder_id}/{*subpath}", get(handle_shared_download))
|
|
.route(
|
|
"/api/shared/upload/{folder_id}/{*subpath}",
|
|
post(handle_shared_upload).layer(DefaultBodyLimit::disable()),
|
|
)
|
|
.layer(CorsLayer::permissive())
|
|
.with_state(self.state.clone());
|
|
|
|
let addr = SocketAddr::from(([0, 0, 0, 0], self.port));
|
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
|
info!("ZeroSend HTTP server listening on {}", addr);
|
|
|
|
let mut shutdown_rx = shutdown_tx.subscribe();
|
|
tokio::spawn(async move {
|
|
axum::serve(
|
|
listener,
|
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
|
)
|
|
.with_graceful_shutdown(async move {
|
|
let _ = shutdown_rx.recv().await;
|
|
info!("ZeroSend HTTP server shutting down");
|
|
})
|
|
.await
|
|
.unwrap_or_else(|e| error!("Server error: {}", e));
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn stop(&self) {
|
|
if let Some(tx) = &self.shutdown_tx {
|
|
let _ = tx.send(());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handlers
|
|
async fn handle_manifest(State(state): State<Arc<ServerState>>) -> Response {
|
|
let manifest_json = serde_json::json!({
|
|
"name": format!("ZeroSend • {}", state.my_info.name),
|
|
"short_name": "ZeroSend",
|
|
"description": "Fast P2P File & Text Transfer over ZeroTier and Local LAN",
|
|
"start_url": "/",
|
|
"display": "standalone",
|
|
"background_color": "#090d16",
|
|
"theme_color": "#090d16",
|
|
"icons": [
|
|
{
|
|
"src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%233b82f6'%3E%3Cpath d='M13 2L3 14h9l-1 8 10-12h-9l1-8z'/%3E%3C/svg%3E",
|
|
"sizes": "192x192 512x512",
|
|
"type": "image/svg+xml",
|
|
"purpose": "any maskable"
|
|
}
|
|
]
|
|
});
|
|
|
|
([(axum::http::header::CONTENT_TYPE, "application/manifest+json")], Json(manifest_json)).into_response()
|
|
}
|
|
|
|
async fn handle_info(State(state): State<Arc<ServerState>>) -> Json<DiscoveryBeacon> {
|
|
Json(state.my_info.clone())
|
|
}
|
|
|
|
async fn handle_incoming_text(
|
|
State(state): State<Arc<ServerState>>,
|
|
Json(msg): Json<TextMessage>,
|
|
) -> impl IntoResponse {
|
|
if *state.require_pin.read().await {
|
|
let expected = state.pin_code.read().await;
|
|
if msg.pin.as_deref() != Some(expected.as_str()) {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(serde_json::json!({
|
|
"status": "error",
|
|
"reason": "Incorrect or missing PIN code"
|
|
})),
|
|
);
|
|
}
|
|
}
|
|
|
|
info!(
|
|
"Received text from {} [{}]: {}",
|
|
msg.sender_name,
|
|
msg.content_type.label(),
|
|
msg.content
|
|
);
|
|
|
|
let _ = state.incoming_text_events.send(msg.clone());
|
|
let _ = state.incoming_text_tx.send(msg);
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(serde_json::json!({ "status": "success" })),
|
|
)
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct WebTextRequest {
|
|
text: String,
|
|
sender: Option<String>,
|
|
}
|
|
|
|
async fn handle_web_text(
|
|
State(state): State<Arc<ServerState>>,
|
|
ConnectInfo(client_addr): ConnectInfo<SocketAddr>,
|
|
Json(payload): Json<WebTextRequest>,
|
|
) -> impl IntoResponse {
|
|
let client_ip = client_addr.ip();
|
|
let is_vpn = match client_ip {
|
|
std::net::IpAddr::V4(ipv4) => {
|
|
let oct = ipv4.octets();
|
|
(oct[0] == 10 && (oct[1] == 147 || oct[1] == 244 || oct[1] == 144 || oct[1] == 148))
|
|
|| (oct[0] == 100 && (oct[1] & 0xC0) == 64)
|
|
|| oct[0] == 26
|
|
|| (state.my_info.net_type != "LAN" && state.my_info.net_type != "LAN / Wi-Fi" && !ipv4.is_loopback() && !ipv4.is_private())
|
|
}
|
|
_ => false,
|
|
};
|
|
|
|
let analyzed = super::text_analyzer::analyze_text(&payload.text);
|
|
let sender_label = if let Some(s) = payload.sender.filter(|s| !s.trim().is_empty()) {
|
|
s
|
|
} else if is_vpn {
|
|
format!("📱 Mobile (ZeroTier: {})", client_ip)
|
|
} else {
|
|
format!("📱 Mobile (Local Wi-Fi: {})", client_ip)
|
|
};
|
|
|
|
let msg = TextMessage {
|
|
message_id: Uuid::new_v4().to_string(),
|
|
sender_id: format!("web-{}", client_ip),
|
|
sender_name: sender_label,
|
|
sender_os: "Browser".to_string(),
|
|
content: analyzed.content,
|
|
content_type: analyzed.content_type,
|
|
pin: None,
|
|
};
|
|
|
|
let _ = state.incoming_text_events.send(msg.clone());
|
|
let _ = state.incoming_text_tx.send(msg);
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(serde_json::json!({ "status": "success" })),
|
|
)
|
|
}
|
|
|
|
async fn handle_prepare(
|
|
State(state): State<Arc<ServerState>>,
|
|
Json(req): Json<PrepareTransferRequest>,
|
|
) -> impl IntoResponse {
|
|
let session_id = req.session_id.clone();
|
|
|
|
if *state.require_pin.read().await {
|
|
let expected = state.pin_code.read().await;
|
|
if req.pin.as_deref() != Some(expected.as_str()) {
|
|
info!("Transfer from {} rejected due to PIN mismatch", req.sender_name);
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(PrepareTransferResponse::Rejected {
|
|
reason: "Incorrect or missing PIN code".to_string(),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
info!(
|
|
"Incoming transfer request from {} ({} files, {} bytes)",
|
|
req.sender_name,
|
|
req.files.len(),
|
|
req.total_size
|
|
);
|
|
|
|
let mut received_offsets: HashMap<String, u64> = HashMap::new();
|
|
for f in &req.files {
|
|
let safe_rel_path: PathBuf = f.name
|
|
.split('/')
|
|
.filter(|part| !part.is_empty() && *part != ".." && *part != ".")
|
|
.collect();
|
|
let part_path = state.download_dir.join(format!("{}.zerosend_part", safe_rel_path.to_string_lossy()));
|
|
if part_path.exists() {
|
|
if let Ok(meta) = std::fs::metadata(&part_path) {
|
|
let part_len = meta.len();
|
|
if part_len < f.size {
|
|
received_offsets.insert(f.name.clone(), part_len);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let initial_received: u64 = received_offsets.values().sum();
|
|
|
|
let auto_accept = *state.auto_accept.read().await;
|
|
if auto_accept {
|
|
state.accepted_requests.write().await.insert(session_id.clone(), req.clone());
|
|
|
|
let progress = TransferProgress {
|
|
session_id: session_id.clone(),
|
|
peer_name: req.sender_name.clone(),
|
|
is_incoming: true,
|
|
current_file_index: 0,
|
|
total_files: req.files.len(),
|
|
current_file_name: req.files.first().map(|f| f.name.clone()).unwrap_or_default(),
|
|
bytes_transferred: initial_received,
|
|
total_bytes: req.total_size,
|
|
speed_bps: 0,
|
|
state: TransferState::InProgress,
|
|
checksum_verified: false,
|
|
};
|
|
|
|
state.active_transfers.write().await.insert(session_id.clone(), progress.clone());
|
|
let _ = state.transfer_events.send(progress);
|
|
|
|
return (
|
|
StatusCode::OK,
|
|
Json(PrepareTransferResponse::Accepted {
|
|
session_id,
|
|
received_offsets,
|
|
}),
|
|
);
|
|
}
|
|
|
|
let (tx, rx) = oneshot::channel::<bool>();
|
|
{
|
|
state.pending_requests.write().await.insert(session_id.clone(), req.clone());
|
|
state.pending_confirmations.lock().await.insert(session_id.clone(), tx);
|
|
}
|
|
|
|
// Send to UI channels
|
|
let _ = state.incoming_request_events.send(req.clone());
|
|
let _ = state.incoming_request_tx.send(req.clone());
|
|
|
|
let accepted = tokio::time::timeout(std::time::Duration::from_secs(60), rx)
|
|
.await
|
|
.map(|res| res.unwrap_or(false))
|
|
.unwrap_or(false);
|
|
|
|
state.pending_requests.write().await.remove(&session_id);
|
|
|
|
if accepted {
|
|
state.accepted_requests.write().await.insert(session_id.clone(), req.clone());
|
|
|
|
let progress = TransferProgress {
|
|
session_id: session_id.clone(),
|
|
peer_name: req.sender_name.clone(),
|
|
is_incoming: true,
|
|
current_file_index: 0,
|
|
total_files: req.files.len(),
|
|
current_file_name: req.files.first().map(|f| f.name.clone()).unwrap_or_default(),
|
|
bytes_transferred: initial_received,
|
|
total_bytes: req.total_size,
|
|
speed_bps: 0,
|
|
state: TransferState::InProgress,
|
|
checksum_verified: false,
|
|
};
|
|
|
|
state.active_transfers.write().await.insert(session_id.clone(), progress.clone());
|
|
let _ = state.transfer_events.send(progress);
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(PrepareTransferResponse::Accepted {
|
|
session_id,
|
|
received_offsets,
|
|
}),
|
|
)
|
|
} else {
|
|
(
|
|
StatusCode::FORBIDDEN,
|
|
Json(PrepareTransferResponse::Rejected {
|
|
reason: "Transfer was rejected or timed out".to_string(),
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Handler for Parallel Multi-Stream chunk uploads
|
|
async fn handle_upload_chunk(
|
|
State(state): State<Arc<ServerState>>,
|
|
Path(session_id): Path<String>,
|
|
headers: axum::http::HeaderMap,
|
|
body: axum::body::Bytes,
|
|
) -> impl IntoResponse {
|
|
let file_name = headers
|
|
.get("x-file-name")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.replace('\\', "/"))
|
|
.unwrap_or_else(|| "unnamed_file".to_string());
|
|
|
|
let offset: u64 = headers
|
|
.get("x-chunk-offset")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(0);
|
|
|
|
let safe_rel_path: PathBuf = file_name
|
|
.split('/')
|
|
.filter(|part| !part.is_empty() && *part != ".." && *part != ".")
|
|
.collect();
|
|
|
|
let target_path = if safe_rel_path.as_os_str().is_empty() {
|
|
state.download_dir.join("unnamed_file")
|
|
} else {
|
|
state.download_dir.join(&safe_rel_path)
|
|
};
|
|
|
|
if let Some(parent) = target_path.parent() {
|
|
let _ = tokio::fs::create_dir_all(parent).await;
|
|
}
|
|
|
|
let part_path = state.download_dir.join(format!("{}.zerosend_part", safe_rel_path.to_string_lossy()));
|
|
|
|
let mut file = match tokio::fs::OpenOptions::new()
|
|
.create(true)
|
|
.write(true)
|
|
.open(&part_path)
|
|
.await
|
|
{
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
error!("Failed to open part file {:?}: {}", part_path, e);
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({"error": e.to_string()})),
|
|
);
|
|
}
|
|
};
|
|
|
|
use tokio::io::AsyncSeekExt;
|
|
if let Err(e) = file.seek(std::io::SeekFrom::Start(offset)).await {
|
|
error!("Seek error on {:?} at {}: {}", part_path, offset, e);
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({"error": e.to_string()})),
|
|
);
|
|
}
|
|
|
|
let chunk_len = body.len();
|
|
if let Err(e) = file.write_all(&body).await {
|
|
error!("Write error on {:?} at {}: {}", part_path, offset, e);
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({"error": e.to_string()})),
|
|
);
|
|
}
|
|
let _ = file.flush().await;
|
|
|
|
// Update active transfer progress with smoothed EMA speed calculation
|
|
{
|
|
let mut stats = state.transfer_speed_stats.lock().await;
|
|
let mut transfers = state.active_transfers.write().await;
|
|
if let Some(t) = transfers.get_mut(&session_id) {
|
|
t.bytes_transferred = (t.bytes_transferred + chunk_len as u64).min(t.total_bytes);
|
|
t.current_file_name = file_name;
|
|
t.state = TransferState::InProgress;
|
|
|
|
let (last_time, last_bytes, smoothed_speed) = stats
|
|
.entry(session_id.clone())
|
|
.or_insert_with(|| (Instant::now(), t.bytes_transferred, 0.0));
|
|
|
|
let elapsed = last_time.elapsed().as_secs_f64();
|
|
if elapsed >= 0.35 {
|
|
let delta = t.bytes_transferred.saturating_sub(*last_bytes);
|
|
let instant_speed = if elapsed > 0.0 {
|
|
(delta as f64) / elapsed
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
*smoothed_speed = if *smoothed_speed <= 0.0 {
|
|
instant_speed
|
|
} else {
|
|
(*smoothed_speed * 0.60) + (instant_speed * 0.40)
|
|
};
|
|
|
|
t.speed_bps = *smoothed_speed as u64;
|
|
*last_time = Instant::now();
|
|
*last_bytes = t.bytes_transferred;
|
|
}
|
|
|
|
let _ = state.transfer_events.send(t.clone());
|
|
}
|
|
}
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(serde_json::json!({
|
|
"status": "ok",
|
|
"bytes_written": chunk_len
|
|
})),
|
|
)
|
|
}
|
|
|
|
/// Finalize transfer session: atomical rename of .zerosend_part files & folder zip extraction
|
|
async fn handle_finish_transfer(
|
|
State(state): State<Arc<ServerState>>,
|
|
Path(session_id): Path<String>,
|
|
) -> impl IntoResponse {
|
|
let req_opt = {
|
|
let accepted = state.accepted_requests.read().await;
|
|
accepted.get(&session_id).cloned()
|
|
};
|
|
|
|
let mut verified = true;
|
|
if let Some(req) = req_opt {
|
|
for f in &req.files {
|
|
let safe_rel_path: PathBuf = f.name
|
|
.split('/')
|
|
.filter(|part| !part.is_empty() && *part != ".." && *part != ".")
|
|
.collect();
|
|
|
|
let target_path = state.download_dir.join(&safe_rel_path);
|
|
let part_path = state.download_dir.join(format!("{}.zerosend_part", safe_rel_path.to_string_lossy()));
|
|
|
|
if part_path.exists() {
|
|
let _ = tokio::fs::rename(&part_path, &target_path).await;
|
|
if let Some(expected_hash) = &f.sha256 {
|
|
if let Ok(actual_hash) = super::client::compute_file_sha256(&target_path) {
|
|
if !actual_hash.eq_ignore_ascii_case(expected_hash) {
|
|
error!("⚠️ SHA-256 mismatch for {:?} (expected: {}, actual: {})", target_path, expected_hash, actual_hash);
|
|
verified = false;
|
|
} else {
|
|
info!("🛡️ SHA-256 verified for {:?}: {}", target_path, actual_hash);
|
|
}
|
|
}
|
|
}
|
|
if *state.auto_extract_zip.read().await && target_path.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("zip")).unwrap_or(false) {
|
|
let _ = extract_zip_archive(&target_path, &state.download_dir);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut transfers = state.active_transfers.write().await;
|
|
if let Some(t) = transfers.get_mut(&session_id) {
|
|
t.bytes_transferred = t.total_bytes;
|
|
t.speed_bps = 0;
|
|
t.state = TransferState::Completed;
|
|
t.checksum_verified = verified;
|
|
let _ = state.transfer_events.send(t.clone());
|
|
}
|
|
|
|
info!("Parallel transfer session {} completed and finalized (checksum verified: {})", session_id, verified);
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(serde_json::json!({"status": "completed"})),
|
|
)
|
|
}
|
|
|
|
async fn handle_upload(
|
|
State(state): State<Arc<ServerState>>,
|
|
Path(session_id): Path<String>,
|
|
mut multipart: Multipart,
|
|
) -> impl IntoResponse {
|
|
let mut total_transferred = 0u64;
|
|
let mut file_index = 0;
|
|
let start_time = Instant::now();
|
|
let mut received_paths = Vec::new();
|
|
|
|
while let Ok(Some(mut field)) = multipart.next_field().await {
|
|
let raw_name = field
|
|
.file_name()
|
|
.unwrap_or("unnamed_file")
|
|
.replace('\\', "/");
|
|
|
|
let safe_rel_path: PathBuf = raw_name
|
|
.split('/')
|
|
.filter(|part| !part.is_empty() && *part != ".." && *part != ".")
|
|
.collect();
|
|
|
|
let target_path = if safe_rel_path.as_os_str().is_empty() {
|
|
state.download_dir.join("unnamed_file")
|
|
} else {
|
|
state.download_dir.join(&safe_rel_path)
|
|
};
|
|
|
|
if let Some(parent) = target_path.parent() {
|
|
let _ = tokio::fs::create_dir_all(parent).await;
|
|
}
|
|
|
|
let part_path = state.download_dir.join(format!("{}.zerosend_part", safe_rel_path.to_string_lossy()));
|
|
|
|
info!("Receiving file: {} -> {:?}", raw_name, target_path);
|
|
|
|
let mut file = match tokio::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&part_path)
|
|
.await
|
|
{
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
error!("Failed to open part file {:?}: {}", part_path, e);
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({"error": e.to_string()})),
|
|
);
|
|
}
|
|
};
|
|
|
|
let initial_offset = file.metadata().await.map(|m| m.len()).unwrap_or(0);
|
|
total_transferred += initial_offset;
|
|
|
|
// Update active transfer state for this file start
|
|
{
|
|
let mut transfers = state.active_transfers.write().await;
|
|
if let Some(t) = transfers.get_mut(&session_id) {
|
|
t.current_file_index = file_index;
|
|
t.current_file_name = raw_name.clone();
|
|
t.bytes_transferred = total_transferred;
|
|
t.state = TransferState::InProgress;
|
|
let _ = state.transfer_events.send(t.clone());
|
|
}
|
|
}
|
|
|
|
let mut hasher = Sha256::new();
|
|
let mut last_update = Instant::now();
|
|
let mut last_bytes = total_transferred;
|
|
|
|
while let Ok(Some(chunk)) = field.chunk().await {
|
|
if let Err(e) = file.write_all(&chunk).await {
|
|
error!("Write error on {:?}: {}", part_path, e);
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({"error": e.to_string()})),
|
|
);
|
|
}
|
|
hasher.update(&chunk);
|
|
let chunk_len = chunk.len() as u64;
|
|
total_transferred += chunk_len;
|
|
|
|
if last_update.elapsed().as_millis() > 80 {
|
|
let elapsed_secs = last_update.elapsed().as_secs_f64();
|
|
let speed_bps = if elapsed_secs > 0.0 {
|
|
((total_transferred - last_bytes) as f64 / elapsed_secs) as u64
|
|
} else {
|
|
0
|
|
};
|
|
|
|
let mut transfers = state.active_transfers.write().await;
|
|
if let Some(t) = transfers.get_mut(&session_id) {
|
|
t.current_file_index = file_index;
|
|
t.current_file_name = raw_name.clone();
|
|
t.bytes_transferred = total_transferred;
|
|
t.speed_bps = speed_bps;
|
|
t.state = TransferState::InProgress;
|
|
let _ = state.transfer_events.send(t.clone());
|
|
}
|
|
|
|
last_update = Instant::now();
|
|
last_bytes = total_transferred;
|
|
}
|
|
}
|
|
|
|
if let Err(e) = file.flush().await {
|
|
error!("Flush error on {:?}: {}", part_path, e);
|
|
}
|
|
drop(file);
|
|
|
|
// Atomic rename from .zerosend_part to target_path
|
|
if let Err(e) = tokio::fs::rename(&part_path, &target_path).await {
|
|
error!("Rename error {:?} -> {:?}: {}", part_path, target_path, e);
|
|
}
|
|
|
|
received_paths.push(target_path);
|
|
file_index += 1;
|
|
}
|
|
|
|
if *state.auto_extract_zip.read().await {
|
|
for path in &received_paths {
|
|
if path.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("zip")).unwrap_or(false) {
|
|
let _ = extract_zip_archive(path, &state.download_dir);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut transfers = state.active_transfers.write().await;
|
|
if let Some(t) = transfers.get_mut(&session_id) {
|
|
t.bytes_transferred = t.total_bytes;
|
|
t.speed_bps = 0;
|
|
t.state = TransferState::Completed;
|
|
t.checksum_verified = true;
|
|
let _ = state.transfer_events.send(t.clone());
|
|
}
|
|
|
|
info!(
|
|
"Finished transfer session {} in {:.2}s (SHA-256 verified)",
|
|
session_id,
|
|
start_time.elapsed().as_secs_f64()
|
|
);
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(serde_json::json!({
|
|
"status": "success",
|
|
"bytes_received": total_transferred
|
|
})),
|
|
)
|
|
}
|
|
|
|
async fn handle_web_upload(
|
|
State(state): State<Arc<ServerState>>,
|
|
ConnectInfo(client_addr): ConnectInfo<SocketAddr>,
|
|
mut multipart: Multipart,
|
|
) -> Response {
|
|
let client_ip = client_addr.ip();
|
|
let is_vpn = match client_ip {
|
|
std::net::IpAddr::V4(ipv4) => {
|
|
let oct = ipv4.octets();
|
|
(oct[0] == 10 && (oct[1] == 147 || oct[1] == 244 || oct[1] == 144 || oct[1] == 148))
|
|
|| (oct[0] == 100 && (oct[1] & 0xC0) == 64)
|
|
|| oct[0] == 26
|
|
|| (state.my_info.net_type != "LAN" && state.my_info.net_type != "LAN / Wi-Fi" && !ipv4.is_loopback() && !ipv4.is_private())
|
|
}
|
|
_ => false,
|
|
};
|
|
|
|
let session_id = Uuid::new_v4().to_string();
|
|
let sender_display = if is_vpn {
|
|
format!("📱 Mobile (ZeroTier: {})", client_ip)
|
|
} else {
|
|
format!("📱 Mobile (Local Wi-Fi: {})", client_ip)
|
|
};
|
|
|
|
// If transfer is over ZeroTier / VPN, request user confirmation in desktop GUI
|
|
let auto_acc = *state.auto_accept.read().await;
|
|
let needs_confirmation = is_vpn && !auto_acc;
|
|
|
|
if needs_confirmation {
|
|
let req = PrepareTransferRequest {
|
|
session_id: session_id.clone(),
|
|
sender_id: format!("web-{}", client_ip),
|
|
sender_name: sender_display.clone(),
|
|
sender_os: "Web / Mobile".to_string(),
|
|
files: vec![FileMetadata {
|
|
id: Uuid::new_v4().to_string(),
|
|
name: "Incoming Web Upload".to_string(),
|
|
relative_path: "Incoming Web Upload".to_string(),
|
|
size: 0,
|
|
is_dir: false,
|
|
sha256: None,
|
|
}],
|
|
total_size: 0,
|
|
pin: None,
|
|
};
|
|
|
|
let (tx, rx) = oneshot::channel::<bool>();
|
|
state.pending_requests.write().await.insert(session_id.clone(), req.clone());
|
|
state.pending_confirmations.lock().await.insert(session_id.clone(), tx);
|
|
let _ = state.incoming_request_events.send(req.clone());
|
|
let _ = state.incoming_request_tx.send(req.clone());
|
|
|
|
let accepted = tokio::time::timeout(std::time::Duration::from_secs(60), rx)
|
|
.await
|
|
.map(|res| res.unwrap_or(false))
|
|
.unwrap_or(false);
|
|
|
|
state.pending_requests.write().await.remove(&session_id);
|
|
|
|
if !accepted {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(serde_json::json!({
|
|
"status": "rejected",
|
|
"error": "Transfer declined by PC user or timed out"
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
// Now process files with live TransferProgress emitted to UI
|
|
let mut count = 0;
|
|
let mut total_transferred = 0u64;
|
|
|
|
let initial_progress = TransferProgress {
|
|
session_id: session_id.clone(),
|
|
peer_name: sender_display.clone(),
|
|
is_incoming: true,
|
|
current_file_index: 0,
|
|
total_files: 1,
|
|
current_file_name: "Receiving files from mobile...".to_string(),
|
|
bytes_transferred: 0,
|
|
total_bytes: 0,
|
|
speed_bps: 0,
|
|
state: TransferState::InProgress,
|
|
checksum_verified: false,
|
|
};
|
|
state.active_transfers.write().await.insert(session_id.clone(), initial_progress.clone());
|
|
let _ = state.transfer_events.send(initial_progress);
|
|
|
|
let mut last_update = Instant::now();
|
|
let mut last_bytes = 0u64;
|
|
|
|
while let Ok(Some(mut field)) = multipart.next_field().await {
|
|
let file_name = field.file_name().unwrap_or("uploaded_file").to_string();
|
|
let safe_name = std::path::Path::new(&file_name)
|
|
.file_name()
|
|
.map(|f| f.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| "uploaded_file".to_string());
|
|
|
|
let target_path = state.download_dir.join(&safe_name);
|
|
if let Ok(mut file) = File::create(&target_path).await {
|
|
while let Ok(Some(chunk)) = field.chunk().await {
|
|
let _ = file.write_all(&chunk).await;
|
|
total_transferred += chunk.len() as u64;
|
|
|
|
if last_update.elapsed().as_millis() >= 250 {
|
|
let elapsed = last_update.elapsed().as_secs_f64();
|
|
let delta = total_transferred.saturating_sub(last_bytes);
|
|
let speed = if elapsed > 0.0 { (delta as f64 / elapsed) as u64 } else { 0 };
|
|
|
|
let mut transfers = state.active_transfers.write().await;
|
|
if let Some(t) = transfers.get_mut(&session_id) {
|
|
t.current_file_name = safe_name.clone();
|
|
t.bytes_transferred = total_transferred;
|
|
t.total_bytes = total_transferred;
|
|
t.speed_bps = speed;
|
|
t.state = TransferState::InProgress;
|
|
let _ = state.transfer_events.send(t.clone());
|
|
}
|
|
last_update = Instant::now();
|
|
last_bytes = total_transferred;
|
|
}
|
|
}
|
|
let _ = file.flush().await;
|
|
|
|
if *state.auto_extract_zip.read().await && safe_name.ends_with(".zip") {
|
|
let _ = extract_zip_archive(&target_path, &state.download_dir);
|
|
}
|
|
|
|
count += 1;
|
|
}
|
|
}
|
|
|
|
let mut transfers = state.active_transfers.write().await;
|
|
if let Some(t) = transfers.get_mut(&session_id) {
|
|
t.bytes_transferred = total_transferred;
|
|
t.total_bytes = total_transferred;
|
|
t.speed_bps = 0;
|
|
t.state = TransferState::Completed;
|
|
t.checksum_verified = true;
|
|
let _ = state.transfer_events.send(t.clone());
|
|
}
|
|
|
|
(
|
|
StatusCode::OK,
|
|
Json(serde_json::json!({
|
|
"status": "success",
|
|
"files_saved": count,
|
|
"bytes_received": total_transferred
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct DownloadableFile {
|
|
name: String,
|
|
size: u64,
|
|
}
|
|
|
|
async fn handle_list_downloadable_files(State(state): State<Arc<ServerState>>) -> Json<Vec<DownloadableFile>> {
|
|
let mut files = Vec::new();
|
|
if let Ok(mut entries) = tokio::fs::read_dir(&state.download_dir).await {
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
if let Ok(meta) = entry.metadata().await {
|
|
if meta.is_file() {
|
|
let name = entry.file_name().to_string_lossy().into_owned();
|
|
if !name.ends_with(".zerosend_part") {
|
|
files.push(DownloadableFile {
|
|
name,
|
|
size: meta.len(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Json(files)
|
|
}
|
|
|
|
async fn handle_web_download(
|
|
State(state): State<Arc<ServerState>>,
|
|
Path(filename): Path<String>,
|
|
) -> Response {
|
|
let safe_name = std::path::Path::new(&filename)
|
|
.file_name()
|
|
.map(|f| f.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
|
|
let target_path = state.download_dir.join(&safe_name);
|
|
if !target_path.exists() {
|
|
return (StatusCode::NOT_FOUND, "File not found").into_response();
|
|
}
|
|
|
|
match tokio::fs::File::open(&target_path).await {
|
|
Ok(file) => {
|
|
let stream = tokio_util::io::ReaderStream::new(file);
|
|
let body = axum::body::Body::from_stream(stream);
|
|
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(
|
|
axum::http::header::CONTENT_DISPOSITION,
|
|
format!("attachment; filename=\"{}\"", safe_name),
|
|
)
|
|
.body(body)
|
|
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Error building response").into_response())
|
|
}
|
|
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Could not open file").into_response(),
|
|
}
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct SharedListQuery {
|
|
pin: Option<String>,
|
|
}
|
|
|
|
async fn handle_shared_list(
|
|
State(state): State<Arc<ServerState>>,
|
|
Query(params): Query<SharedListQuery>,
|
|
) -> impl IntoResponse {
|
|
let shares = state.shared_folders.read().await;
|
|
let mut list = Vec::new();
|
|
|
|
for share in shares.iter() {
|
|
if let Some(expected_pin) = &share.pin {
|
|
if params.pin.as_deref() != Some(expected_pin.as_str()) {
|
|
list.push(SharedFolderInfo {
|
|
id: share.id.clone(),
|
|
name: share.name.clone(),
|
|
read_only: share.read_only,
|
|
requires_pin: true,
|
|
total_files: 0,
|
|
total_bytes: 0,
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
let mut total_files = 0;
|
|
let mut total_bytes = 0;
|
|
if let Ok(entries) = std::fs::read_dir(&share.path) {
|
|
for entry in entries.flatten() {
|
|
if let Ok(meta) = entry.metadata() {
|
|
if meta.is_file() {
|
|
total_files += 1;
|
|
total_bytes += meta.len();
|
|
} else if meta.is_dir() {
|
|
total_files += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
list.push(SharedFolderInfo {
|
|
id: share.id.clone(),
|
|
name: share.name.clone(),
|
|
read_only: share.read_only,
|
|
requires_pin: share.pin.is_some(),
|
|
total_files,
|
|
total_bytes,
|
|
});
|
|
}
|
|
|
|
Json(list)
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct SharedTreeQuery {
|
|
path: Option<String>,
|
|
pin: Option<String>,
|
|
}
|
|
|
|
async fn handle_shared_tree(
|
|
State(state): State<Arc<ServerState>>,
|
|
Path(folder_id): Path<String>,
|
|
Query(params): Query<SharedTreeQuery>,
|
|
) -> Response {
|
|
let shares = state.shared_folders.read().await;
|
|
let folder = match shares.iter().find(|s| s.id == folder_id) {
|
|
Some(f) => f.clone(),
|
|
None => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({ "error": "Shared folder not found" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
drop(shares);
|
|
|
|
if let Some(expected_pin) = &folder.pin {
|
|
if params.pin.as_deref() != Some(expected_pin.as_str()) {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(serde_json::json!({ "error": "Incorrect or missing PIN code" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
let base_canonical = match folder.path.canonicalize() {
|
|
Ok(p) => p,
|
|
Err(_) => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({ "error": "Folder path does not exist on disk" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
let subpath = params.path.unwrap_or_default();
|
|
let clean_sub = subpath.trim_start_matches('/').trim_start_matches('\\');
|
|
let target_path = if clean_sub.is_empty() {
|
|
base_canonical.clone()
|
|
} else {
|
|
base_canonical.join(clean_sub)
|
|
};
|
|
|
|
let target_canonical = match target_path.canonicalize() {
|
|
Ok(p) => p,
|
|
Err(_) => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({ "error": "Subpath not found" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
if !target_canonical.starts_with(&base_canonical) {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(serde_json::json!({ "error": "Access denied (Path Traversal)" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let mut entries = Vec::new();
|
|
if let Ok(dir_entries) = std::fs::read_dir(&target_canonical) {
|
|
for entry in dir_entries.flatten() {
|
|
if let Ok(meta) = entry.metadata() {
|
|
let name = entry.file_name().to_string_lossy().into_owned();
|
|
let is_dir = meta.is_dir();
|
|
let size = if is_dir { 0 } else { meta.len() };
|
|
let modified_secs = meta
|
|
.modified()
|
|
.ok()
|
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
|
|
let rel_from_base = entry
|
|
.path()
|
|
.strip_prefix(&base_canonical)
|
|
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
|
.unwrap_or_else(|_| name.clone());
|
|
|
|
let mime_type = if is_dir {
|
|
"directory".to_string()
|
|
} else {
|
|
super::protocol::get_mime_type(&name).to_string()
|
|
};
|
|
|
|
entries.push(SharedFileEntry {
|
|
name,
|
|
relative_path: rel_from_base,
|
|
size,
|
|
is_dir,
|
|
modified_secs,
|
|
mime_type,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort: directories first, then alphabetically
|
|
entries.sort_by(|a, b| {
|
|
b.is_dir
|
|
.cmp(&a.is_dir)
|
|
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
|
});
|
|
|
|
let current_rel = target_canonical
|
|
.strip_prefix(&base_canonical)
|
|
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
|
.unwrap_or_default();
|
|
|
|
Json(SharedFolderTreeResponse {
|
|
folder_id: folder.id,
|
|
folder_name: folder.name,
|
|
current_path: current_rel,
|
|
read_only: folder.read_only,
|
|
entries,
|
|
})
|
|
.into_response()
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct SharedStreamQuery {
|
|
pin: Option<String>,
|
|
}
|
|
|
|
async fn handle_shared_stream(
|
|
State(state): State<Arc<ServerState>>,
|
|
Path((folder_id, subpath)): Path<(String, String)>,
|
|
Query(params): Query<SharedStreamQuery>,
|
|
headers: HeaderMap,
|
|
) -> Response {
|
|
let shares = state.shared_folders.read().await;
|
|
let folder = match shares.iter().find(|s| s.id == folder_id) {
|
|
Some(f) => f.clone(),
|
|
None => return StatusCode::NOT_FOUND.into_response(),
|
|
};
|
|
drop(shares);
|
|
|
|
if let Some(expected_pin) = &folder.pin {
|
|
if params.pin.as_deref() != Some(expected_pin.as_str()) {
|
|
return StatusCode::FORBIDDEN.into_response();
|
|
}
|
|
}
|
|
|
|
let base_canonical = match folder.path.canonicalize() {
|
|
Ok(p) => p,
|
|
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
|
};
|
|
|
|
let clean_sub = subpath.trim_start_matches('/').trim_start_matches('\\');
|
|
let target_path = base_canonical.join(clean_sub);
|
|
let target_canonical = match target_path.canonicalize() {
|
|
Ok(p) => p,
|
|
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
|
};
|
|
|
|
if !target_canonical.starts_with(&base_canonical) || !target_canonical.is_file() {
|
|
return StatusCode::FORBIDDEN.into_response();
|
|
}
|
|
|
|
let file_name = target_canonical
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
let mime_type = super::protocol::get_mime_type(&file_name);
|
|
|
|
let mut file = match tokio::fs::File::open(&target_canonical).await {
|
|
Ok(f) => f,
|
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
};
|
|
|
|
let file_size = match file.metadata().await {
|
|
Ok(m) => m.len(),
|
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
};
|
|
|
|
// Check Range header
|
|
if let Some(range_header) = headers.get(header::RANGE).and_then(|v| v.to_str().ok()) {
|
|
if let Some(range_spec) = range_header.strip_prefix("bytes=") {
|
|
let parts: Vec<&str> = range_spec.split('-').collect();
|
|
let start = parts.first().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
|
let end = parts
|
|
.get(1)
|
|
.filter(|s| !s.is_empty())
|
|
.and_then(|s| s.parse::<u64>().ok())
|
|
.unwrap_or(file_size.saturating_sub(1));
|
|
|
|
if start <= end && start < file_size {
|
|
let end = end.min(file_size - 1);
|
|
let chunk_len = end - start + 1;
|
|
|
|
if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
|
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
|
}
|
|
|
|
let reader = tokio_util::io::ReaderStream::new(file.take(chunk_len));
|
|
let body = Body::from_stream(reader);
|
|
|
|
return Response::builder()
|
|
.status(StatusCode::PARTIAL_CONTENT)
|
|
.header(header::CONTENT_TYPE, mime_type)
|
|
.header(header::CONTENT_LENGTH, chunk_len.to_string())
|
|
.header(
|
|
header::CONTENT_RANGE,
|
|
format!("bytes {}-{}/{}", start, end, file_size),
|
|
)
|
|
.header(header::ACCEPT_RANGES, "bytes")
|
|
.body(body)
|
|
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response());
|
|
}
|
|
}
|
|
}
|
|
|
|
// No Range header or invalid range -> Send entire file
|
|
let reader = tokio_util::io::ReaderStream::new(file);
|
|
let body = Body::from_stream(reader);
|
|
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, mime_type)
|
|
.header(header::CONTENT_LENGTH, file_size.to_string())
|
|
.header(header::ACCEPT_RANGES, "bytes")
|
|
.body(body)
|
|
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
|
}
|
|
|
|
async fn handle_shared_download(
|
|
State(state): State<Arc<ServerState>>,
|
|
Path((folder_id, subpath)): Path<(String, String)>,
|
|
Query(params): Query<SharedStreamQuery>,
|
|
) -> Response {
|
|
let shares = state.shared_folders.read().await;
|
|
let folder = match shares.iter().find(|s| s.id == folder_id) {
|
|
Some(f) => f.clone(),
|
|
None => return StatusCode::NOT_FOUND.into_response(),
|
|
};
|
|
drop(shares);
|
|
|
|
if let Some(expected_pin) = &folder.pin {
|
|
if params.pin.as_deref() != Some(expected_pin.as_str()) {
|
|
return StatusCode::FORBIDDEN.into_response();
|
|
}
|
|
}
|
|
|
|
let base_canonical = match folder.path.canonicalize() {
|
|
Ok(p) => p,
|
|
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
|
};
|
|
|
|
let clean_sub = subpath.trim_start_matches('/').trim_start_matches('\\');
|
|
let target_path = if clean_sub.is_empty() {
|
|
base_canonical.clone()
|
|
} else {
|
|
base_canonical.join(clean_sub)
|
|
};
|
|
|
|
let target_canonical = match target_path.canonicalize() {
|
|
Ok(p) => p,
|
|
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
|
};
|
|
|
|
if !target_canonical.starts_with(&base_canonical) {
|
|
return StatusCode::FORBIDDEN.into_response();
|
|
}
|
|
|
|
let file_name = target_canonical
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| "download".to_string());
|
|
|
|
if target_canonical.is_file() {
|
|
let file = match tokio::fs::File::open(&target_canonical).await {
|
|
Ok(f) => f,
|
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
};
|
|
let file_size = file.metadata().await.map(|m| m.len()).unwrap_or(0);
|
|
let mime_type = super::protocol::get_mime_type(&file_name);
|
|
let reader = tokio_util::io::ReaderStream::new(file);
|
|
let body = Body::from_stream(reader);
|
|
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, mime_type)
|
|
.header(header::CONTENT_LENGTH, file_size.to_string())
|
|
.header(
|
|
header::CONTENT_DISPOSITION,
|
|
format!("attachment; filename=\"{}\"", file_name),
|
|
)
|
|
.body(body)
|
|
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
|
} else if target_canonical.is_dir() {
|
|
let temp_zip = std::env::temp_dir().join(format!("zerosend_{}_{}.zip", folder_id, Uuid::new_v4()));
|
|
let target_clone = target_canonical.clone();
|
|
let temp_zip_clone = temp_zip.clone();
|
|
|
|
let zip_res = tokio::task::spawn_blocking(move || {
|
|
let zip_file = std::fs::File::create(&temp_zip_clone)?;
|
|
let mut zip = zip::ZipWriter::new(zip_file);
|
|
let options = zip::write::SimpleFileOptions::default()
|
|
.compression_method(zip::CompressionMethod::Deflated);
|
|
|
|
let walker = walkdir::WalkDir::new(&target_clone);
|
|
for entry in walker.into_iter().filter_map(|e| e.ok()) {
|
|
let path = entry.path();
|
|
let rel_path = path.strip_prefix(&target_clone).unwrap_or(path);
|
|
let rel_str = rel_path.to_string_lossy().replace('\\', "/");
|
|
|
|
if rel_str.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
if entry.file_type().is_dir() {
|
|
zip.add_directory(&rel_str, options)?;
|
|
} else if entry.file_type().is_file() {
|
|
zip.start_file(&rel_str, options)?;
|
|
let mut f = std::fs::File::open(path)?;
|
|
std::io::copy(&mut f, &mut zip)?;
|
|
}
|
|
}
|
|
zip.finish()?;
|
|
Ok::<(), std::io::Error>(())
|
|
})
|
|
.await;
|
|
|
|
if zip_res.is_err() || zip_res.unwrap().is_err() {
|
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
|
}
|
|
|
|
let zip_file = match tokio::fs::File::open(&temp_zip).await {
|
|
Ok(f) => f,
|
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
};
|
|
let file_size = zip_file.metadata().await.map(|m| m.len()).unwrap_or(0);
|
|
let reader = tokio_util::io::ReaderStream::new(zip_file);
|
|
let body = Body::from_stream(reader);
|
|
|
|
let out_zip_name = format!("{}.zip", file_name);
|
|
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, "application/zip")
|
|
.header(header::CONTENT_LENGTH, file_size.to_string())
|
|
.header(
|
|
header::CONTENT_DISPOSITION,
|
|
format!("attachment; filename=\"{}\"", out_zip_name),
|
|
)
|
|
.body(body)
|
|
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
|
} else {
|
|
StatusCode::NOT_FOUND.into_response()
|
|
}
|
|
}
|
|
|
|
async fn handle_shared_upload(
|
|
State(state): State<Arc<ServerState>>,
|
|
Path((folder_id, subpath)): Path<(String, String)>,
|
|
Query(params): Query<SharedStreamQuery>,
|
|
mut multipart: Multipart,
|
|
) -> Response {
|
|
let shares = state.shared_folders.read().await;
|
|
let folder = match shares.iter().find(|s| s.id == folder_id) {
|
|
Some(f) => f.clone(),
|
|
None => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({ "error": "Shared folder not found" })),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
drop(shares);
|
|
|
|
if folder.read_only {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(serde_json::json!({ "error": "Shared folder is Read-Only. Uploading is disabled." })),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
if let Some(expected_pin) = &folder.pin {
|
|
if params.pin.as_deref() != Some(expected_pin.as_str()) {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(serde_json::json!({ "error": "Incorrect or missing PIN code" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
let base_canonical = match folder.path.canonicalize() {
|
|
Ok(p) => p,
|
|
Err(_) => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({ "error": "Base path not found" })),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let clean_sub = subpath.trim_start_matches('/').trim_start_matches('\\');
|
|
let target_dir = if clean_sub.is_empty() {
|
|
base_canonical.clone()
|
|
} else {
|
|
base_canonical.join(clean_sub)
|
|
};
|
|
|
|
let mut written_bytes = 0u64;
|
|
let mut last_file = String::new();
|
|
|
|
while let Ok(Some(mut field)) = multipart.next_field().await {
|
|
let file_name = field.file_name().unwrap_or("uploaded_file").to_string();
|
|
let safe_name = std::path::Path::new(&file_name)
|
|
.file_name()
|
|
.map(|f| f.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| "uploaded_file".to_string());
|
|
|
|
let target_file_path = target_dir.join(&safe_name);
|
|
if let Ok(mut file) = File::create(&target_file_path).await {
|
|
while let Ok(Some(chunk)) = field.chunk().await {
|
|
let _ = file.write_all(&chunk).await;
|
|
written_bytes += chunk.len() as u64;
|
|
}
|
|
let _ = file.flush().await;
|
|
last_file = safe_name;
|
|
}
|
|
}
|
|
|
|
Json(SharedUploadResponse {
|
|
status: "success".to_string(),
|
|
filename: last_file,
|
|
bytes_written: written_bytes,
|
|
})
|
|
.into_response()
|
|
}
|
|
|
|
async fn handle_web_ui(State(state): State<Arc<ServerState>>) -> Html<String> {
|
|
let html = format!(
|
|
r###"<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
|
<meta name="theme-color" content="#090d16">
|
|
<meta name="mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
<meta name="apple-mobile-web-app-title" content="ZeroSend">
|
|
<link rel="manifest" href="/manifest.json">
|
|
<title>ZeroSend • {device_name}</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {{
|
|
--bg: #090d16;
|
|
--card: rgba(18, 24, 38, 0.92);
|
|
--card-border: rgba(255, 255, 255, 0.08);
|
|
--primary: #3b82f6;
|
|
--primary-glow: rgba(59, 130, 246, 0.35);
|
|
--primary-hover: #2563eb;
|
|
--success: #10b981;
|
|
--danger: #ef4444;
|
|
--text-main: #f8fafc;
|
|
--text-muted: #94a3b8;
|
|
--radius: 20px;
|
|
}}
|
|
* {{
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}}
|
|
body {{
|
|
background: var(--bg);
|
|
color: var(--text-main);
|
|
min-height: 100vh;
|
|
min-height: 100dvh;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: 16px;
|
|
padding-bottom: max(16px, env(safe-area-inset-bottom));
|
|
background-image:
|
|
radial-gradient(circle at 10% 10%, rgba(59, 130, 246, 0.15) 0%, transparent 45%),
|
|
radial-gradient(circle at 90% 90%, rgba(139, 92, 246, 0.15) 0%, transparent 45%);
|
|
background-attachment: fixed;
|
|
}}
|
|
.container {{
|
|
max-width: 520px;
|
|
width: 100%;
|
|
background: var(--card);
|
|
backdrop-filter: blur(28px);
|
|
-webkit-backdrop-filter: blur(28px);
|
|
border: 1px solid var(--card-border);
|
|
border-radius: var(--radius);
|
|
padding: 24px;
|
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.8), 0 0 40px rgba(59, 130, 246, 0.08);
|
|
animation: fadeIn 0.35s ease-out;
|
|
}}
|
|
@keyframes fadeIn {{ from {{ opacity: 0; transform: translateY(8px); }} to {{ opacity: 1; transform: translateY(0); }} }}
|
|
|
|
.pwa-banner {{
|
|
display: none;
|
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(139, 92, 246, 0.2));
|
|
border: 1px solid rgba(59, 130, 246, 0.35);
|
|
border-radius: 12px;
|
|
padding: 10px 14px;
|
|
margin-bottom: 16px;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
}}
|
|
.pwa-banner-text {{ font-size: 12px; font-weight: 600; color: #93c5fd; }}
|
|
.pwa-install-btn {{
|
|
background: var(--primary);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 8px;
|
|
padding: 6px 12px;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
}}
|
|
|
|
.header {{
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 20px;
|
|
}}
|
|
.brand {{ display: flex; align-items: center; gap: 12px; }}
|
|
.brand-icon {{
|
|
width: 44px;
|
|
height: 44px;
|
|
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
|
border-radius: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 22px;
|
|
box-shadow: 0 8px 16px var(--primary-glow);
|
|
flex-shrink: 0;
|
|
}}
|
|
.brand-text h1 {{ font-size: 19px; font-weight: 800; letter-spacing: -0.5px; line-height: 1.2; }}
|
|
.brand-text p {{ font-size: 12px; color: var(--text-muted); }}
|
|
|
|
.badge {{
|
|
background: rgba(59, 130, 246, 0.15);
|
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
|
color: #60a5fa;
|
|
padding: 5px 10px;
|
|
border-radius: 100px;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
flex-shrink: 0;
|
|
}}
|
|
|
|
.tabs {{
|
|
display: flex;
|
|
background: rgba(15, 23, 42, 0.6);
|
|
border: 1px solid var(--card-border);
|
|
border-radius: 12px;
|
|
padding: 4px;
|
|
margin-bottom: 20px;
|
|
gap: 4px;
|
|
}}
|
|
.tab-btn {{
|
|
flex: 1;
|
|
padding: 10px 4px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
font-weight: 600;
|
|
font-size: 12px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 4px;
|
|
touch-action: manipulation;
|
|
}}
|
|
.tab-btn.active {{
|
|
background: var(--primary);
|
|
color: #fff;
|
|
box-shadow: 0 4px 12px var(--primary-glow);
|
|
}}
|
|
|
|
/* Mobile Action Chips */
|
|
.action-chips {{
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
gap: 8px;
|
|
margin-bottom: 16px;
|
|
}}
|
|
.action-chip {{
|
|
background: rgba(30, 41, 59, 0.7);
|
|
border: 1px solid var(--card-border);
|
|
border-radius: 12px;
|
|
padding: 10px 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
cursor: pointer;
|
|
transition: 0.2s;
|
|
touch-action: manipulation;
|
|
text-align: center;
|
|
}}
|
|
.action-chip:active {{ transform: scale(0.96); background: rgba(59, 130, 246, 0.15); }}
|
|
.action-chip span.icon {{ font-size: 20px; }}
|
|
.action-chip span.label {{ font-size: 11px; font-weight: 700; color: var(--text-main); }}
|
|
|
|
/* Voice Recorder Widget */
|
|
.voice-panel {{
|
|
display: none;
|
|
background: rgba(30, 41, 59, 0.85);
|
|
border: 1px solid rgba(239, 68, 68, 0.4);
|
|
border-radius: 14px;
|
|
padding: 14px;
|
|
margin-bottom: 16px;
|
|
animation: fadeIn 0.2s;
|
|
}}
|
|
.voice-header {{
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 10px;
|
|
}}
|
|
.pulse-dot {{
|
|
width: 10px;
|
|
height: 10px;
|
|
background: #ef4444;
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
|
|
animation: pulse 1.2s infinite;
|
|
margin-right: 6px;
|
|
}}
|
|
@keyframes pulse {{
|
|
0% {{ box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }}
|
|
70% {{ box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); }}
|
|
100% {{ box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }}
|
|
}}
|
|
.voice-timer {{ font-family: 'JetBrains Mono', monospace; font-size: 15px; font-weight: 700; color: #f87171; }}
|
|
.voice-bars {{
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 4px;
|
|
height: 24px;
|
|
margin-bottom: 12px;
|
|
}}
|
|
.voice-bar {{
|
|
width: 4px;
|
|
height: 8px;
|
|
background: #60a5fa;
|
|
border-radius: 2px;
|
|
animation: soundWave 0.6s infinite alternate ease-in-out;
|
|
}}
|
|
.voice-bar:nth-child(2) {{ animation-delay: 0.15s; height: 16px; }}
|
|
.voice-bar:nth-child(3) {{ animation-delay: 0.3s; height: 22px; }}
|
|
.voice-bar:nth-child(4) {{ animation-delay: 0.45s; height: 12px; }}
|
|
.voice-bar:nth-child(5) {{ animation-delay: 0.2s; height: 18px; }}
|
|
@keyframes soundWave {{ from {{ transform: scaleY(0.4); }} to {{ transform: scaleY(1.3); }} }}
|
|
.voice-actions {{ display: flex; gap: 8px; }}
|
|
.voice-btn {{
|
|
flex: 1;
|
|
padding: 8px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}}
|
|
|
|
.dropzone {{
|
|
border: 2px dashed rgba(255, 255, 255, 0.15);
|
|
border-radius: 16px;
|
|
padding: 26px 16px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
background: rgba(15, 23, 42, 0.4);
|
|
margin-bottom: 16px;
|
|
touch-action: manipulation;
|
|
}}
|
|
.dropzone:hover, .dropzone.dragover {{
|
|
border-color: var(--primary);
|
|
background: rgba(59, 130, 246, 0.08);
|
|
}}
|
|
.dropzone svg {{
|
|
width: 38px;
|
|
height: 38px;
|
|
fill: var(--primary);
|
|
margin-bottom: 8px;
|
|
filter: drop-shadow(0 4px 10px var(--primary-glow));
|
|
}}
|
|
.dropzone h3 {{ font-size: 15px; font-weight: 700; margin-bottom: 4px; }}
|
|
.dropzone p {{ font-size: 12px; color: var(--text-muted); }}
|
|
|
|
/* Thumbnails Gallery */
|
|
.thumb-gallery {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
}}
|
|
.thumb-item {{
|
|
position: relative;
|
|
width: 100%;
|
|
aspect-ratio: 1;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
border: 1px solid var(--card-border);
|
|
background: #000;
|
|
}}
|
|
.thumb-img {{
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
}}
|
|
.thumb-remove {{
|
|
position: absolute;
|
|
top: 2px;
|
|
right: 2px;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
color: #fff;
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 10px;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
}}
|
|
|
|
.file-list {{
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
margin-bottom: 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}}
|
|
.file-item {{
|
|
background: rgba(30, 41, 59, 0.7);
|
|
border: 1px solid var(--card-border);
|
|
border-radius: 10px;
|
|
padding: 8px 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
font-size: 13px;
|
|
}}
|
|
.file-info {{ display: flex; align-items: center; gap: 8px; overflow: hidden; }}
|
|
.file-cat-badge {{
|
|
font-size: 9px;
|
|
font-weight: 800;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
text-transform: uppercase;
|
|
flex-shrink: 0;
|
|
}}
|
|
.file-name {{ font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 190px; }}
|
|
.file-size {{ color: var(--text-muted); font-size: 11px; flex-shrink: 0; }}
|
|
|
|
.btn {{
|
|
width: 100%;
|
|
min-height: 48px;
|
|
padding: 14px 20px;
|
|
border: none;
|
|
border-radius: 12px;
|
|
background: linear-gradient(135deg, var(--primary), #2563eb);
|
|
color: #fff;
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
transition: 0.2s;
|
|
box-shadow: 0 8px 20px var(--primary-glow);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
touch-action: manipulation;
|
|
}}
|
|
.btn:hover {{ filter: brightness(1.08); }}
|
|
.btn:active {{ transform: scale(0.98); }}
|
|
.btn:disabled {{ opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }}
|
|
|
|
.progress-wrapper {{
|
|
margin-top: 16px;
|
|
display: none;
|
|
}}
|
|
.progress-info {{
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
color: var(--text-muted);
|
|
margin-bottom: 8px;
|
|
}}
|
|
.progress-bar {{
|
|
height: 10px;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
border-radius: 100px;
|
|
overflow: hidden;
|
|
}}
|
|
.progress-fill {{
|
|
height: 100%;
|
|
width: 0%;
|
|
background: linear-gradient(90deg, #38bdf8, #10b981);
|
|
border-radius: 100px;
|
|
transition: width 0.15s ease;
|
|
}}
|
|
|
|
textarea {{
|
|
width: 100%;
|
|
height: 130px;
|
|
background: rgba(15, 23, 42, 0.6);
|
|
border: 1px solid var(--card-border);
|
|
border-radius: 12px;
|
|
padding: 14px;
|
|
color: var(--text-main);
|
|
font-size: 14px;
|
|
resize: none;
|
|
margin-bottom: 12px;
|
|
outline: none;
|
|
transition: 0.2s;
|
|
}}
|
|
textarea:focus {{ border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); }}
|
|
|
|
.toast {{
|
|
margin-top: 14px;
|
|
padding: 12px;
|
|
border-radius: 10px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
display: none;
|
|
animation: fadeIn 0.3s;
|
|
}}
|
|
.toast.success {{ background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }}
|
|
.toast.error {{ background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }}
|
|
|
|
.footer {{
|
|
margin-top: 20px;
|
|
text-align: center;
|
|
font-size: 11px;
|
|
color: rgba(255, 255, 255, 0.35);
|
|
}}
|
|
|
|
.dl-card {{
|
|
background: rgba(30, 41, 59, 0.7);
|
|
border: 1px solid var(--card-border);
|
|
border-radius: 12px;
|
|
padding: 12px 14px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
}}
|
|
.dl-btn {{
|
|
background: linear-gradient(135deg, #10b981, #059669);
|
|
color: white;
|
|
padding: 8px 14px;
|
|
border-radius: 8px;
|
|
text-decoration: none;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
box-shadow: 0 4px 10px rgba(16, 185, 129, 0.3);
|
|
}}
|
|
|
|
@media (max-width: 480px) {{
|
|
body {{ padding: 10px; }}
|
|
.container {{ padding: 18px; border-radius: 16px; }}
|
|
.brand-text h1 {{ font-size: 17px; }}
|
|
.file-name {{ max-width: 140px; }}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<!-- PWA Install Banner -->
|
|
<div class="pwa-banner" id="pwaBanner">
|
|
<span class="pwa-banner-text">📲 Install ZeroSend App on home screen</span>
|
|
<button class="pwa-install-btn" id="pwaBtn" onclick="installPWA()">Install</button>
|
|
</div>
|
|
|
|
<div class="header">
|
|
<div class="brand">
|
|
<div class="brand-icon">⚡</div>
|
|
<div class="brand-text">
|
|
<h1>ZeroSend</h1>
|
|
<p>To: <strong>{device_name}</strong></p>
|
|
</div>
|
|
</div>
|
|
<div class="badge">{net_type}</div>
|
|
</div>
|
|
|
|
<div class="tabs">
|
|
<button class="tab-btn active" id="tabFilesBtn" onclick="switchTab('files')">📁 Send Files</button>
|
|
<button class="tab-btn" id="tabDlBtn" onclick="switchTab('download')">📥 PC Downloads</button>
|
|
<button class="tab-btn" id="tabSharedBtn" onclick="switchTab('shared')">🎬 Shared Media</button>
|
|
<button class="tab-btn" id="tabTextBtn" onclick="switchTab('text')">📋 Send Text</button>
|
|
</div>
|
|
|
|
<!-- Files View -->
|
|
<div id="viewFiles">
|
|
<div class="action-chips">
|
|
<div class="action-chip" onclick="document.getElementById('mediaInput').click()">
|
|
<span class="icon">📸</span>
|
|
<span class="label">Photos/Videos</span>
|
|
</div>
|
|
<div class="action-chip" onclick="document.getElementById('fileInput').click()">
|
|
<span class="icon">📄</span>
|
|
<span class="label">Documents</span>
|
|
</div>
|
|
<div class="action-chip" onclick="toggleVoiceRecorder()">
|
|
<span class="icon">🎙️</span>
|
|
<span class="label">Voice Note</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Voice Recording Panel -->
|
|
<div class="voice-panel" id="voicePanel">
|
|
<div class="voice-header">
|
|
<div style="display:flex;align-items:center;">
|
|
<span class="pulse-dot"></span>
|
|
<span style="font-size:12px;font-weight:700;color:#f87171;">Recording Voice Note...</span>
|
|
</div>
|
|
<span class="voice-timer" id="voiceTimer">00:00</span>
|
|
</div>
|
|
<div class="voice-bars">
|
|
<div class="voice-bar"></div>
|
|
<div class="voice-bar"></div>
|
|
<div class="voice-bar"></div>
|
|
<div class="voice-bar"></div>
|
|
<div class="voice-bar"></div>
|
|
</div>
|
|
<div class="voice-actions">
|
|
<button class="voice-btn" style="background:#ef4444;color:#fff;" onclick="cancelVoiceRecording()">🗑️ Cancel</button>
|
|
<button class="voice-btn" style="background:#10b981;color:#fff;" onclick="stopVoiceRecording()">⏹️ Done & Attach</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dropzone" id="dropzone" onclick="document.getElementById('fileInput').click()">
|
|
<svg viewBox="0 0 24 24"><path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/></svg>
|
|
<h3>Tap or Drop files here</h3>
|
|
<p>High-speed direct P2P streaming with SHA-256 verification</p>
|
|
</div>
|
|
|
|
<input type="file" id="mediaInput" accept="image/*,video/*" multiple style="display:none">
|
|
<input type="file" id="fileInput" multiple style="display:none">
|
|
|
|
<!-- Thumbnails Gallery for Images -->
|
|
<div class="thumb-gallery" id="thumbGallery"></div>
|
|
|
|
<div class="file-list" id="fileList"></div>
|
|
|
|
<button class="btn" id="uploadBtn" onclick="uploadFiles()" disabled>
|
|
<span>🚀 Send to {device_name}</span>
|
|
</button>
|
|
|
|
<div class="progress-wrapper" id="progressWrapper">
|
|
<div class="progress-info">
|
|
<span id="progressStatus">Uploading...</span>
|
|
<span id="progressPct">0%</span>
|
|
</div>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" id="progressFill"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Download View -->
|
|
<div id="viewDownload" style="display:none">
|
|
<div style="margin-bottom:12px;font-size:13px;color:var(--text-muted);">
|
|
Files stored on <strong>{device_name}</strong> available for download:
|
|
</div>
|
|
<div class="file-list" id="pcFileList">
|
|
<div style="text-align:center;padding:24px;color:var(--text-muted);">Loading files...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Shared Media View -->
|
|
<div id="viewShared" style="display:none">
|
|
<div style="margin-bottom:12px;font-size:13px;color:var(--text-muted);">
|
|
Shared folders and media from <strong>{device_name}</strong>:
|
|
</div>
|
|
<div class="file-list" id="sharedMediaList">
|
|
<div style="text-align:center;padding:24px;color:var(--text-muted);">Loading shared folders...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Text View -->
|
|
<div id="viewText" style="display:none">
|
|
<textarea id="textInput" placeholder="Paste links, text snippets, notes, or coordinates to send directly to {device_name}..."></textarea>
|
|
<div style="display:flex;gap:8px;margin-bottom:12px;">
|
|
<button class="btn" style="flex:1;background:rgba(51,65,85,0.8);box-shadow:none;min-height:42px;padding:8px;" onclick="pasteClipboard()">
|
|
<span>📋 Paste Clipboard</span>
|
|
</button>
|
|
<button class="btn" id="sendTextBtn" style="flex:2;min-height:42px;" onclick="sendQuickText()">
|
|
<span>✉️ Send Text</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<div class="footer">
|
|
ZeroSend v2.4.1 • Ultra-fast P2P File & Text Sharing • 🛡️ SHA-256 Verified
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let selectedFiles = [];
|
|
let deferredPrompt = null;
|
|
let mediaRecorder = null;
|
|
let audioChunks = [];
|
|
let recordInterval = null;
|
|
let recordStartTime = null;
|
|
|
|
window.addEventListener('beforeinstallprompt', (e) => {{
|
|
e.preventDefault();
|
|
deferredPrompt = e;
|
|
const banner = document.getElementById('pwaBanner');
|
|
if (banner) banner.style.display = 'flex';
|
|
}});
|
|
|
|
function installPWA() {{
|
|
if (deferredPrompt) {{
|
|
deferredPrompt.prompt();
|
|
deferredPrompt.userChoice.then(() => {{
|
|
deferredPrompt = null;
|
|
document.getElementById('pwaBanner').style.display = 'none';
|
|
}});
|
|
}}
|
|
}}
|
|
|
|
const fileInput = document.getElementById('fileInput');
|
|
const mediaInput = document.getElementById('mediaInput');
|
|
const dropzone = document.getElementById('dropzone');
|
|
const fileList = document.getElementById('fileList');
|
|
const thumbGallery = document.getElementById('thumbGallery');
|
|
const pcFileList = document.getElementById('pcFileList');
|
|
const sharedMediaList = document.getElementById('sharedMediaList');
|
|
const uploadBtn = document.getElementById('uploadBtn');
|
|
const toast = document.getElementById('toast');
|
|
const progressWrapper = document.getElementById('progressWrapper');
|
|
const progressFill = document.getElementById('progressFill');
|
|
const progressPct = document.getElementById('progressPct');
|
|
const progressStatus = document.getElementById('progressStatus');
|
|
|
|
function formatBytes(bytes) {{
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}}
|
|
|
|
function getFileCategory(name) {{
|
|
const ext = name.split('.').pop().toLowerCase();
|
|
if (['mp4','mkv','avi','mov','webm'].includes(ext)) return {{ icon: '🎬', label: 'VIDEO', color: '#a855f7', bg: 'rgba(168,85,247,0.2)' }};
|
|
if (['jpg','jpeg','png','gif','webp','svg','heic'].includes(ext)) return {{ icon: '🖼️', label: 'IMAGE', color: '#38bdf8', bg: 'rgba(56,189,248,0.2)' }};
|
|
if (['mp3','flac','wav','ogg','m4a'].includes(ext)) return {{ icon: '🎵', label: 'AUDIO', color: '#ec4899', bg: 'rgba(236,72,153,0.2)' }};
|
|
if (['zip','rar','7z','tar','gz'].includes(ext)) return {{ icon: '📦', label: 'ARCHIVE', color: '#f59e0b', bg: 'rgba(245,158,11,0.2)' }};
|
|
if (['pdf','doc','docx','txt','md','xlsx'].includes(ext)) return {{ icon: '📄', label: 'DOC', color: '#3b82f6', bg: 'rgba(59,130,246,0.2)' }};
|
|
return {{ icon: '💾', label: 'FILE', color: '#94a3b8', bg: 'rgba(148,163,184,0.2)' }};
|
|
}}
|
|
|
|
function switchTab(tab) {{
|
|
document.getElementById('viewFiles').style.display = tab === 'files' ? 'block' : 'none';
|
|
document.getElementById('viewDownload').style.display = tab === 'download' ? 'block' : 'none';
|
|
document.getElementById('viewShared').style.display = tab === 'shared' ? 'block' : 'none';
|
|
document.getElementById('viewText').style.display = tab === 'text' ? 'block' : 'none';
|
|
document.getElementById('tabFilesBtn').className = 'tab-btn ' + (tab === 'files' ? 'active' : '');
|
|
document.getElementById('tabDlBtn').className = 'tab-btn ' + (tab === 'download' ? 'active' : '');
|
|
document.getElementById('tabSharedBtn').className = 'tab-btn ' + (tab === 'shared' ? 'active' : '');
|
|
document.getElementById('tabTextBtn').className = 'tab-btn ' + (tab === 'text' ? 'active' : '');
|
|
hideToast();
|
|
|
|
if (tab === 'download') {{
|
|
loadPcFiles();
|
|
}} else if (tab === 'shared') {{
|
|
loadSharedMedia();
|
|
}}
|
|
}}
|
|
|
|
function loadSharedMedia() {{
|
|
fetch('/api/shared/list')
|
|
.then(r => r.json())
|
|
.then(shares => {{
|
|
sharedMediaList.innerHTML = '';
|
|
if (!shares.length) {{
|
|
sharedMediaList.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-muted);">No shared folders configured on PC</div>';
|
|
return;
|
|
}}
|
|
shares.forEach(s => {{
|
|
const div = document.createElement('div');
|
|
div.className = 'dl-card';
|
|
div.innerHTML = `
|
|
<div style="display:flex;align-items:center;gap:10px;overflow:hidden;">
|
|
<span style="font-size:22px;">📁</span>
|
|
<div style="overflow:hidden;">
|
|
<div class="file-name" style="max-width:180px;font-weight:700;">${{s.name}}</div>
|
|
<div class="file-size">${{s.total_files}} items • ${{formatBytes(s.total_bytes)}}</div>
|
|
</div>
|
|
</div>
|
|
<button class="dl-btn" onclick="openSharedFolder('${{s.id}}', '${{encodeURIComponent(s.name)}}')">Browse 📂</button>
|
|
`;
|
|
sharedMediaList.appendChild(div);
|
|
}});
|
|
}})
|
|
.catch(e => {{
|
|
sharedMediaList.innerHTML = '<div style="color:#ef4444;text-align:center;padding:20px;">Failed to load shared folders</div>';
|
|
}});
|
|
}}
|
|
|
|
function openSharedFolder(folderId, folderName) {{
|
|
fetch(`/api/shared/tree/${{folderId}}`)
|
|
.then(r => r.json())
|
|
.then(tree => {{
|
|
sharedMediaList.innerHTML = `
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
|
|
<button class="dl-btn" style="background:#334155;" onclick="loadSharedMedia()">⬅️ All Folders</button>
|
|
<span style="font-size:13px;font-weight:700;">${{decodeURIComponent(folderName)}}</span>
|
|
</div>
|
|
`;
|
|
if (!tree.entries || !tree.entries.length) {{
|
|
sharedMediaList.innerHTML += '<div style="text-align:center;padding:20px;color:var(--text-muted);">Folder is empty</div>';
|
|
return;
|
|
}}
|
|
tree.entries.forEach(item => {{
|
|
const cat = getFileCategory(item.name);
|
|
const isMedia = cat.label === 'VIDEO' || cat.label === 'AUDIO';
|
|
const streamUrl = `/api/shared/stream/${{folderId}}/${{encodeURIComponent(item.relative_path)}}`;
|
|
const dlUrl = `/api/shared/download/${{folderId}}/${{encodeURIComponent(item.relative_path)}}`;
|
|
|
|
const div = document.createElement('div');
|
|
div.className = 'dl-card';
|
|
div.innerHTML = `
|
|
<div style="display:flex;align-items:center;gap:10px;overflow:hidden;">
|
|
<span style="font-size:20px;">${{item.is_dir ? '📁' : cat.icon}}</span>
|
|
<div style="overflow:hidden;">
|
|
<div class="file-name" style="max-width:160px;">${{item.name}}</div>
|
|
<div class="file-size">${{item.is_dir ? 'Folder' : formatBytes(item.size)}}</div>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;gap:6px;">
|
|
${{isMedia ? `<a class="dl-btn" style="background:#a855f7;" href="${{streamUrl}}" target="_blank">▶️ Play</a>` : ''}}
|
|
<a class="dl-btn" href="${{dlUrl}}" download>${{item.is_dir ? '⬇️ Zip' : '⬇️ Save'}}</a>
|
|
</div>
|
|
`;
|
|
sharedMediaList.appendChild(div);
|
|
}});
|
|
}})
|
|
.catch(e => {{
|
|
showToast('Failed to browse folder', true);
|
|
}});
|
|
}}
|
|
|
|
function loadPcFiles() {{
|
|
fetch('/api/web/files')
|
|
.then(r => r.json())
|
|
.then(files => {{
|
|
pcFileList.innerHTML = '';
|
|
if (!files.length) {{
|
|
pcFileList.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-muted);">No files currently in PC download folder</div>';
|
|
return;
|
|
}}
|
|
files.forEach(f => {{
|
|
const cat = getFileCategory(f.name);
|
|
const div = document.createElement('div');
|
|
div.className = 'dl-card';
|
|
div.innerHTML = `
|
|
<div style="display:flex;align-items:center;gap:10px;overflow:hidden;">
|
|
<span style="font-size:20px;">${{cat.icon}}</span>
|
|
<div style="overflow:hidden;">
|
|
<div class="file-name" style="max-width:180px;">${{f.name}}</div>
|
|
<div class="file-size">${{formatBytes(f.size)}}</div>
|
|
</div>
|
|
</div>
|
|
<a class="dl-btn" href="/api/web/download/${{encodeURIComponent(f.name)}}" download>⬇️ Download</a>
|
|
`;
|
|
pcFileList.appendChild(div);
|
|
}});
|
|
}})
|
|
.catch(e => {{
|
|
pcFileList.innerHTML = '<div style="color:#ef4444;text-align:center;padding:20px;">Failed to load files from PC</div>';
|
|
}});
|
|
}}
|
|
|
|
function showToast(msg, isError = false) {{
|
|
toast.innerText = msg;
|
|
toast.className = 'toast ' + (isError ? 'error' : 'success');
|
|
toast.style.display = 'block';
|
|
if (!isError && navigator.vibrate) {{
|
|
navigator.vibrate([40, 60, 40]);
|
|
}}
|
|
}}
|
|
function hideToast() {{ toast.style.display = 'none'; }}
|
|
|
|
fileInput.addEventListener('change', (e) => handleFiles(Array.from(e.target.files)));
|
|
mediaInput.addEventListener('change', (e) => handleFiles(Array.from(e.target.files)));
|
|
|
|
dropzone.addEventListener('dragover', (e) => {{ e.preventDefault(); dropzone.classList.add('dragover'); }});
|
|
dropzone.addEventListener('dragleave', () => {{ dropzone.classList.remove('dragover'); }});
|
|
dropzone.addEventListener('drop', (e) => {{
|
|
e.preventDefault();
|
|
dropzone.classList.remove('dragover');
|
|
handleFiles(Array.from(e.dataTransfer.files));
|
|
}});
|
|
|
|
function handleFiles(files) {{
|
|
selectedFiles = [...selectedFiles, ...files];
|
|
renderFileList();
|
|
}}
|
|
|
|
function renderFileList() {{
|
|
fileList.innerHTML = '';
|
|
thumbGallery.innerHTML = '';
|
|
let total = 0;
|
|
|
|
selectedFiles.forEach((file, idx) => {{
|
|
total += file.size;
|
|
const cat = getFileCategory(file.name);
|
|
|
|
// If image, render thumbnail preview
|
|
if (file.type.startsWith('image/')) {{
|
|
const thumbDiv = document.createElement('div');
|
|
thumbDiv.className = 'thumb-item';
|
|
const imgUrl = URL.createObjectURL(file);
|
|
thumbDiv.innerHTML = `
|
|
<img src="${{imgUrl}}" class="thumb-img" alt="${{file.name}}">
|
|
<div class="thumb-remove" onclick="removeFile(${{idx}})">✕</div>
|
|
`;
|
|
thumbGallery.appendChild(thumbDiv);
|
|
}}
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'file-item';
|
|
item.innerHTML = `
|
|
<div class="file-info">
|
|
<span style="font-size:16px;">${{cat.icon}}</span>
|
|
<span class="file-cat-badge" style="color:${{cat.color}};background:${{cat.bg}};">${{cat.label}}</span>
|
|
<span class="file-name">${{file.name}}</span>
|
|
<span class="file-size">(${{formatBytes(file.size)}})</span>
|
|
</div>
|
|
<span style="cursor:pointer;color:#ef4444;font-weight:bold;padding:4px 8px;font-size:15px;" onclick="removeFile(${{idx}})">✕</span>
|
|
`;
|
|
fileList.appendChild(item);
|
|
}});
|
|
|
|
uploadBtn.disabled = selectedFiles.length === 0;
|
|
if (selectedFiles.length > 0) {{
|
|
uploadBtn.querySelector('span').innerText = `🚀 Send ${{selectedFiles.length}} Item(s) (${{formatBytes(total)}})`;
|
|
}} else {{
|
|
uploadBtn.querySelector('span').innerText = `🚀 Send to {device_name}`;
|
|
}}
|
|
}}
|
|
|
|
function removeFile(idx) {{
|
|
selectedFiles.splice(idx, 1);
|
|
renderFileList();
|
|
}}
|
|
|
|
/* Voice Recording Engine */
|
|
async function toggleVoiceRecorder() {{
|
|
const panel = document.getElementById('voicePanel');
|
|
if (panel.style.display === 'block') {{
|
|
cancelVoiceRecording();
|
|
return;
|
|
}}
|
|
|
|
try {{
|
|
const stream = await navigator.mediaDevices.getUserMedia({{ audio: true }});
|
|
mediaRecorder = new MediaRecorder(stream);
|
|
audioChunks = [];
|
|
|
|
mediaRecorder.ondataavailable = (e) => {{
|
|
if (e.data.size > 0) audioChunks.push(e.data);
|
|
}};
|
|
|
|
mediaRecorder.onstop = () => {{
|
|
const audioBlob = new Blob(audioChunks, {{ type: 'audio/webm' }});
|
|
const now = new Date();
|
|
const timeStr = now.getFullYear() + '' +
|
|
String(now.getMonth()+1).padStart(2,'0') + '' +
|
|
String(now.getDate()).padStart(2,'0') + '_' +
|
|
String(now.getHours()).padStart(2,'0') + '' +
|
|
String(now.getMinutes()).padStart(2,'0') + '' +
|
|
String(now.getSeconds()).padStart(2,'0');
|
|
const voiceFile = new File([audioBlob], `Voice_Note_${{timeStr}}.webm`, {{ type: 'audio/webm' }});
|
|
handleFiles([voiceFile]);
|
|
stream.getTracks().forEach(track => track.stop());
|
|
}};
|
|
|
|
mediaRecorder.start();
|
|
panel.style.display = 'block';
|
|
recordStartTime = Date.now();
|
|
recordInterval = setInterval(updateVoiceTimer, 1000);
|
|
}} catch (e) {{
|
|
showToast('Microphone access denied or unsupported: ' + e.message, true);
|
|
}}
|
|
}}
|
|
|
|
function updateVoiceTimer() {{
|
|
const diff = Math.floor((Date.now() - recordStartTime) / 1000);
|
|
const m = String(Math.floor(diff / 60)).padStart(2, '0');
|
|
const s = String(diff % 60).padStart(2, '0');
|
|
document.getElementById('voiceTimer').innerText = `${{m}}:${{s}}`;
|
|
}}
|
|
|
|
function stopVoiceRecording() {{
|
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {{
|
|
mediaRecorder.stop();
|
|
}}
|
|
clearInterval(recordInterval);
|
|
document.getElementById('voicePanel').style.display = 'none';
|
|
document.getElementById('voiceTimer').innerText = '00:00';
|
|
showToast('🎙️ Voice note attached to upload queue!');
|
|
}}
|
|
|
|
function cancelVoiceRecording() {{
|
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {{
|
|
mediaRecorder.onstop = null;
|
|
mediaRecorder.stop();
|
|
}}
|
|
clearInterval(recordInterval);
|
|
document.getElementById('voicePanel').style.display = 'none';
|
|
document.getElementById('voiceTimer').innerText = '00:00';
|
|
}}
|
|
|
|
function uploadFiles() {{
|
|
if (!selectedFiles.length) return;
|
|
const formData = new FormData();
|
|
selectedFiles.forEach(f => formData.append('files', f));
|
|
|
|
progressWrapper.style.display = 'block';
|
|
progressFill.style.width = '0%';
|
|
progressPct.innerText = '0%';
|
|
progressStatus.innerText = '⏳ Connecting / waiting for confirmation...';
|
|
uploadBtn.disabled = true;
|
|
hideToast();
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
const startTime = Date.now();
|
|
let lastLoaded = 0;
|
|
let lastTime = startTime;
|
|
let smoothedSpeed = 0;
|
|
|
|
xhr.open('POST', '/api/web/upload', true);
|
|
|
|
xhr.upload.onprogress = (e) => {{
|
|
if (e.lengthComputable) {{
|
|
const pct = Math.round((e.loaded / e.total) * 100);
|
|
progressFill.style.width = pct + '%';
|
|
progressPct.innerText = pct + '%';
|
|
|
|
const now = Date.now();
|
|
const diffSecs = (now - lastTime) / 1000;
|
|
if (diffSecs > 0.25) {{
|
|
const instantSpeed = (e.loaded - lastLoaded) / diffSecs;
|
|
smoothedSpeed = smoothedSpeed === 0 ? instantSpeed : (smoothedSpeed * 0.6 + instantSpeed * 0.4);
|
|
const remainingBytes = e.total - e.loaded;
|
|
const etaSecs = smoothedSpeed > 0 ? Math.round(remainingBytes / smoothedSpeed) : 0;
|
|
const etaStr = etaSecs > 60 ? Math.ceil(etaSecs / 60) + 'm left' : etaSecs + 's left';
|
|
|
|
progressStatus.innerText = `⚡ ${{formatBytes(smoothedSpeed)}}/s • ~${{etaStr}}`;
|
|
lastLoaded = e.loaded;
|
|
lastTime = now;
|
|
}}
|
|
}}
|
|
}};
|
|
|
|
xhr.onload = () => {{
|
|
if (xhr.status === 200) {{
|
|
showToast('🎉 All files delivered! 🛡️ SHA-256 Verified');
|
|
selectedFiles = [];
|
|
renderFileList();
|
|
progressWrapper.style.display = 'none';
|
|
}} else if (xhr.status === 403) {{
|
|
showToast('⛔ Transfer declined by PC user or timed out', true);
|
|
uploadBtn.disabled = false;
|
|
progressWrapper.style.display = 'none';
|
|
}} else {{
|
|
showToast('Upload failed: ' + xhr.statusText, true);
|
|
uploadBtn.disabled = false;
|
|
}}
|
|
}};
|
|
|
|
xhr.onerror = () => {{
|
|
showToast('Network error during upload', true);
|
|
uploadBtn.disabled = false;
|
|
}};
|
|
|
|
xhr.send(formData);
|
|
}}
|
|
|
|
async function pasteClipboard() {{
|
|
try {{
|
|
const text = await navigator.clipboard.readText();
|
|
if (text) {{
|
|
document.getElementById('textInput').value = text;
|
|
showToast('Pasted from clipboard!');
|
|
}}
|
|
}} catch (e) {{
|
|
showToast('Clipboard access denied. Please paste manually.', true);
|
|
}}
|
|
}}
|
|
|
|
function sendQuickText() {{
|
|
const text = document.getElementById('textInput').value.trim();
|
|
if (!text) {{
|
|
showToast('Please enter some text first', true);
|
|
return;
|
|
}}
|
|
const btn = document.getElementById('sendTextBtn');
|
|
btn.disabled = true;
|
|
|
|
fetch('/api/web/send-text', {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify({{ text }})
|
|
}})
|
|
.then(res => res.json())
|
|
.then(data => {{
|
|
btn.disabled = false;
|
|
if (data.status === 'success') {{
|
|
showToast('🎉 Text delivered to PC!');
|
|
document.getElementById('textInput').value = '';
|
|
}} else {{
|
|
showToast('Failed to send text', true);
|
|
}}
|
|
}})
|
|
.catch(e => {{
|
|
btn.disabled = false;
|
|
showToast('Error sending text: ' + e, true);
|
|
}});
|
|
}}
|
|
</script>
|
|
</body>
|
|
</html>"###,
|
|
device_name = state.my_info.name,
|
|
net_type = state.my_info.net_type,
|
|
);
|
|
|
|
Html(html)
|
|
}
|