diff options
Diffstat (limited to 'src/main.rs')
| -rw-r--r-- | src/main.rs | 374 |
1 files changed, 374 insertions, 0 deletions
diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ecffd46 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,374 @@ +//! A LAN file server for sharing files on a local network. + +// Copyright (c) 2026 Doan Luu Duc Tai +// +// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or +// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license +// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use axum::response::IntoResponse; +use clap::Parser; +use futures_util::StreamExt; +use std::fmt::Write as _; +use std::io::{self, Write}; + +#[derive(Parser, Debug)] +#[command(name = "fileshare", about = "LAN File Server")] +struct Args { + #[arg(long, help = "Server mode (upload or simple)")] + mode: Option<String>, + + #[arg(long, help = "Port number (auto if not set)")] + port: Option<u16>, + + #[arg(long, help = "Directory to expose")] + dir: std::path::PathBuf, +} + +#[derive(Clone, Debug)] +struct AppState { + dir: std::path::PathBuf, +} + +fn get_free_port() -> u16 { + std::net::TcpListener::bind("0.0.0.0:0") + .and_then(|listener| listener.local_addr()) + .map_or(8080, |addr| addr.port()) +} + +fn get_local_ip() -> String { + local_ip_address::local_ip() + .map_or_else(|_| "127.0.0.1".to_string(), |ip| ip.to_string()) +} + +fn safe_filename(name: &str) -> String { + let path = std::path::Path::new(name); + let filename = path.file_name().and_then(|s| s.to_str()).unwrap_or("file"); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + + format!("{now}_{filename}") +} + +fn get_safe_path(uri_path: &str, root_dir: &std::path::Path) -> Option<std::path::PathBuf> { + let decoded = percent_encoding::percent_decode_str(uri_path) + .decode_utf8() + .ok()?; + let canonical_root = root_dir.canonicalize().ok()?; + let mut safe_path = canonical_root.clone(); + + for segment in decoded.split('/') { + if segment.is_empty() || segment == "." { + continue; + } + if segment == ".." { + safe_path.pop(); + } else { + safe_path.push(segment); + } + } + + if safe_path.exists() { + safe_path = safe_path.canonicalize().ok()?; + } + + if safe_path.starts_with(&canonical_root) { + Some(safe_path) + } else { + None + } +} + +fn render_directory_index(dir_path: &std::path::Path, request_path: &str) -> io::Result<String> { + let mut html = String::new(); + html.push_str("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Directory listing</title></head><body>"); + let _ = write!( + html, + "<h1>Directory listing for {request_path}</h1><hr><ul>" + ); + + if request_path != "/" { + html.push_str("<li><a href=\"..\">..</a></li>"); + } + + let mut entries = Vec::new(); + for entry_res in std::fs::read_dir(dir_path)? { + let entry = entry_res?; + let name = entry.file_name().to_string_lossy().to_string(); + let metadata = entry.metadata()?; + let is_dir = metadata.is_dir(); + entries.push((name, is_dir)); + } + + entries.sort_by(|a, b| { + if a.1 == b.1 { + a.0.to_lowercase().cmp(&b.0.to_lowercase()) + } else if a.1 { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Greater + } + }); + + for (name, is_dir) in entries { + let display_name = if is_dir { + format!("{name}/") + } else { + name.clone() + }; + let href = if is_dir { format!("{name}/") } else { name }; + let _ = write!(html, "<li><a href=\"{href}\">{display_name}</a></li>"); + } + + html.push_str("</ul><hr></body></html>"); + Ok(html) +} + +async fn handle_get( + axum::extract::State(state): axum::extract::State<AppState>, + uri: axum::http::Uri, +) -> axum::response::Response { + let path_str = uri.path(); + let Some(safe_path) = get_safe_path(path_str, &state.dir) else { + return (axum::http::StatusCode::FORBIDDEN, "Forbidden").into_response(); + }; + + if !safe_path.exists() { + return (axum::http::StatusCode::NOT_FOUND, "Not Found").into_response(); + } + + if safe_path.is_dir() { + if !path_str.ends_with('/') { + let redirect_path = format!("{path_str}/"); + return axum::response::Response::builder() + .status(axum::http::StatusCode::MOVED_PERMANENTLY) + .header("location", redirect_path) + .body(axum::body::Body::empty()) + .unwrap_or_else(|_| { + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + ) + .into_response() + }); + } + + render_directory_index(&safe_path, path_str).map_or_else( + |_| { + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + ) + .into_response() + }, + |html| axum::response::Html(html).into_response(), + ) + } else { + tokio::fs::File::open(&safe_path).await.map_or_else( + |_| { + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + ) + .into_response() + }, + |file| { + let stream = + tokio_util::codec::FramedRead::new(file, tokio_util::codec::BytesCodec::new()) + .map(|r| r.map(tokio_util::bytes::BytesMut::freeze)); + let body = axum::body::Body::from_stream(stream); + let mime = mime_guess::from_path(&safe_path).first_or_octet_stream(); + axum::response::Response::builder() + .header("content-type", mime.as_ref()) + .body(body) + .unwrap_or_else(|_| { + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error", + ) + .into_response() + }) + }, + ) + } +} + +async fn upload_form() -> axum::response::Html<&'static str> { + axum::response::Html( + r#" + <!DOCTYPE html> + <html> + <body> + <h1>Upload File</h1> + <form action="/" method="POST" enctype="multipart/form-data"> + <input type="file" name="file"> + <input type="submit" value="Upload"> + </form> + </body> + </html> + "#, + ) +} + +async fn upload_file( + axum::extract::State(state): axum::extract::State<AppState>, + mut multipart: axum::extract::Multipart, +) -> axum::response::Response { + let mut uploaded = false; + + while let Some(field) = match multipart.next_field().await { + Ok(Some(f)) => Some(f), + Ok(None) => None, + Err(err) => { + return ( + axum::http::StatusCode::BAD_REQUEST, + format!("Multipart error: {err}"), + ) + .into_response(); + } + } { + let filename = match field.file_name() { + Some(name) if !name.is_empty() => name.to_string(), + _ => continue, + }; + + let data = match field.bytes().await { + Ok(bytes) => bytes, + Err(err) => { + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to read data: {err}"), + ) + .into_response(); + } + }; + + let safe_name = safe_filename(&filename); + let filepath = state.dir.join(safe_name); + + if let Err(err) = tokio::fs::write(&filepath, data).await { + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to write file: {err}"), + ) + .into_response(); + } + + uploaded = true; + break; + } + + if uploaded { + (axum::http::StatusCode::OK, "File uploaded successfully.").into_response() + } else { + (axum::http::StatusCode::BAD_REQUEST, "Upload failed.").into_response() + } +} + +fn print_server_info(port: u16, target_dir: &std::path::Path) { + let local_ip = get_local_ip(); + + println!("\nServing at:"); + println!(" Local: http://127.0.0.1:{port}"); + println!(" Network: http://{local_ip}:{port}"); + println!(" Directory: {}", target_dir.display()); +} + +#[tokio::main] +async fn main() { + let args = Args::parse(); + let port = args.port.unwrap_or_else(get_free_port); + + let target_dir = match args.dir.canonicalize() { + Ok(path) => { + if !path.is_dir() { + eprintln!("[-] Error: '{}' is not a directory.", args.dir.display()); + std::process::exit(1); + } + path + } + Err(e) => { + eprintln!( + "[-] Error: Directory '{}' does not exist or is not accessible: {}", + args.dir.display(), + e + ); + std::process::exit(1); + } + }; + + let mode = match args.mode.as_deref() { + Some("upload") => "upload".to_string(), + Some("simple") => "simple".to_string(), + Some(other) => { + eprintln!("Invalid mode '{other}'. Use 'upload' or 'simple'."); + std::process::exit(1); + } + None => { + println!("\nChoose server mode:"); + println!("1) Upload server"); + println!("2) Simple HTTP server"); + + print!("\nEnter choice (1 or 2): "); + if io::stdout().flush().is_err() { + eprintln!("[-] Error: Failed to flush stdout"); + std::process::exit(1); + } + + let mut choice = String::new(); + if io::stdin().read_line(&mut choice).is_err() { + eprintln!("[-] Error: Failed to read line"); + std::process::exit(1); + } + match choice.trim() { + "1" => "upload".to_string(), + "2" => "simple".to_string(), + _ => { + eprintln!("Invalid choice."); + std::process::exit(1); + } + } + } + }; + + let state = AppState { + dir: target_dir.clone(), + }; + + let app = if mode == "upload" { + axum::Router::new() + .route("/", axum::routing::get(upload_form).post(upload_file)) + .fallback(handle_get) + .with_state(state) + } else { + axum::Router::new().fallback(handle_get).with_state(state) + }; + + println!( + "\n[+] {} started", + if mode == "upload" { + "Upload server" + } else { + "Simple HTTP server" + } + ); + print_server_info(port, &target_dir); + + let listener = match tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await { + Ok(l) => l, + Err(e) => { + eprintln!("[-] Error: Failed to bind to port {port}: {e}"); + std::process::exit(1); + } + }; + + if let Err(e) = axum::serve(listener, app).await { + eprintln!("[-] Server error: {e}"); + } +} |
