summaryrefslogtreecommitdiff
path: root/src/main.rs
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-09-08 14:06:30 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-09-08 14:06:30 -0500
commitbd3c7a4344a184ac5734cdebb647d386a263d04b (patch)
tree8ada84bf944da4a2d3f0f2821b66a805d8f5c423 /src/main.rs
Initial commitmain
Diffstat (limited to 'src/main.rs')
-rw-r--r--src/main.rs374
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}");
+ }
+}