//! A LAN file server for sharing files on a local network. // Copyright (c) 2026 HalcyonForelsket // // Licensed under the Apache License, Version 2.0 or the MIT license // , 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, #[arg(long, help = "Port number (auto if not set)")] port: Option, #[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 { 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 { let mut html = String::new(); html.push_str("Directory listing"); let _ = write!( html, "

Directory listing for {request_path}


    " ); if request_path != "/" { html.push_str("
  • ..
  • "); } 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, "
  • {display_name}
  • "); } html.push_str("

"); Ok(html) } async fn handle_get( axum::extract::State(state): axum::extract::State, 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#"

Upload File

"#, ) } async fn upload_file( axum::extract::State(state): axum::extract::State, 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}"); } }