Files
ZeroSend/src/transfer/server.rs
T

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)
}