diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..4b3bca1 --- /dev/null +++ b/src/http.rs @@ -0,0 +1,206 @@ +#[derive(Debug)] +pub struct Request { + pub method: Method, + pub path: String, + pub host: String, + pub range: Option, +} + +#[derive(Debug)] +pub enum ContentRange { + From(usize), + Full(usize, usize), + Suffix(usize), +} + +#[derive(Debug, PartialEq)] +pub enum Method { + Get, + Head, +} + +pub struct Response { + pub status: Status, + pub content: Option, +} + +#[derive(Debug, Clone, Copy)] +#[repr(u16)] +pub enum Status { + Ok = 200, + PartialContent = 206, + BadRequest = 400, + NotFound = 404, +} + +#[derive(Debug, Clone)] +pub struct Content { + content_type: &'static str, + bytes: Vec, +} + +impl Request { + pub fn parse(source: &str) -> Option { + let mut lines = source.lines(); + let head = lines.next()?; + let (method, head) = head.split_once(' ')?; + let method = Method::parse(method)?; + let (path, version) = head.split_once(' ')?; + _ = version.strip_prefix("HTTP/1")?; + + let mut host = None; + let mut range = None; + for line in lines { + if line.is_empty() { + break; + } + let line = line.to_lowercase(); + let (key, value) = line.split_once(": ")?; + match key { + "host" => host = Some(value.to_owned()), + "range" => range = ContentRange::parse(value), + _ => (), + } + } + let host = host?; + + //todo parse path %hex + + Some(Self { + method, + path: path.to_owned(), + host, + range, + }) + } +} + +impl Response { + pub fn new(status: Status) -> Self { + Self { + status, + content: None, + } + } + + pub fn format(self, head_only: bool) -> Vec { + if let Some(content) = self.content { + let mut data = format!( + "{}\r\nContent-Type: {}\r\nContent-Length: {}\r\n\r\n", + self.status.header(), + content.content_type, + content.bytes.len(), + ) + .into_bytes(); + if !head_only { + data.extend_from_slice(&content.bytes); + } + data + } else { + format!("{}\r\n\r\n", self.status.header()).into_bytes() + } + } + + pub fn with_content(mut self, content: Content) -> Self { + self.content = Some(content); + self + } +} + +impl Content { + pub fn html(text: String) -> Self { + Self::file("html", text.into_bytes()) + } + + pub fn text(text: String) -> Self { + Self::file("txt", text.into_bytes()) + } + + pub fn file(ext: &str, bytes: Vec) -> Self { + let content_type = match ext { + "txt" | "md" | "toml" => "text/plain", + "html" | "htm" => "text/html", + "css" => "text/css", + + "apng" => "image/apng", + "bmp" => "image/bmp", + "gif" => "image/gif", + "jpeg" | "jpg" => "image/jpeg", + "png" => "image/png", + "svg" => "image/svg+xml", + "tif" | "tiff" => "image/tiff", + "webp" => "image/webp", + + "aac" => "audio/aac", + "mp3" => "audio/mpeg", + "oga" | "ogg" => "audio/ogg", + "opus" => "audio/opus", + "wav" => "audio/wav", + "weba" => "audio/webm", + + "3gp" => "video/3gpp", + "3gp2" => "video/3gpp2", + "avi" => "video/x-msvideo", + "mp4" => "video/mp4", + "mpeg" => "video/mpeg", + "ogv" => "video/ogv", + "webm" => "video/webm", + + "json" => "application/json", + "gz" => "application/gzip", + _ => { + if bytes.is_ascii() { + "text/plain" + } else { + "application/octet-stream" + } + } + }; + Self { + content_type, + bytes, + } + } +} + +impl Status { + pub fn header(self) -> String { + format!("HTTP/1.1 {} {}", self.code(), self.name()) + } + + pub fn code(self) -> u16 { + self as u16 + } + + pub fn name(self) -> &'static str { + match self { + Status::Ok => "OK", + Status::PartialContent => "", + Status::BadRequest => "", + Status::NotFound => "NOT FOUND", + } + } +} + +impl Method { + fn parse(source: &str) -> Option { + match source { + "GET" => Some(Self::Get), + "HEAD" => Some(Self::Head), + _ => None, + } + } +} + +impl ContentRange { + fn parse(source: &str) -> Option { + let source = source.strip_prefix("bytes=")?; + let (start, end) = source.split_once('-')?; + match (start.is_empty(), end.is_empty()) { + (true, true) => None, + (true, false) => Some(Self::Suffix(end.parse().ok()?)), + (false, true) => Some(Self::From(start.parse().ok()?)), + (false, false) => Some(Self::Full(start.parse().ok()?, end.parse().ok()?)), + } + } +} diff --git a/src/main.rs b/src/main.rs index 8af5c17..f5f09fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,13 @@ -use std::io::prelude::*; -use std::net::{TcpListener, TcpStream}; -use std::{env, fs}; +use std::{ + env, + fs::{self, File}, + io::{Read, Write}, + net::{TcpListener, TcpStream}, + path::{Path, PathBuf}, +}; + +mod http; +use http::{Content, Method, Request, Response, Status}; fn main() { let args: Vec = env::args().collect(); @@ -35,9 +42,8 @@ fn handle_connection(mut stream: TcpStream) { let request = String::from_utf8_lossy(&buffer); let peer_addr = stream.peer_addr().ok(); - println!( - "Received {} bytes from {:?}\n\n[{}]", + "Received {} bytes from {:?}\n=======\n{}=======\n\n", size, peer_addr, request @@ -47,51 +53,95 @@ fn handle_connection(mut stream: TcpStream) { .replace("\\n", "\n") ); - let mut request = request.into_owned(); - while request.contains("..") { - request = request.replace("..", ""); - } + let request = Request::parse(&request); - if request.starts_with("GET") { - if let Some(file) = request.split_whitespace().nth(1) { - let path = format!("./{}", file); - send_file(&mut stream, &path); - return; - } - } - let response = "HTTP/1.1 400 BAD REQUEST\r\n\r\n"; - stream - .write_all(response.as_bytes()) - .unwrap_or_else(|_| println!("failed to respond")); - stream - .flush() - .unwrap_or_else(|_| println!("failed to respond")); -} + let response; -fn send_file(stream: &mut TcpStream, path: &str) { - if let Ok(text) = fs::read_to_string(path) { - let contents = text + "\n\n"; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: {}; charset=UTF-8\r\nContent-Length: {}\r\n\r\n{}", - if path.ends_with(".html") { - "text/html" - } else { - "text/plain" - }, - contents.len(), - contents - ); - stream - .write_all(response.as_bytes()) - .unwrap_or_else(|_| println!("failed to respond")); + if let Some(request) = request { + let head_only = request.method == Method::Head; + let path = request.path.clone(); + response = get_file(request) + .map(|content| Response::new(Status::Ok).with_content(content)) + .unwrap_or_else(|| { + Response::new(Status::NotFound) + .with_content(Content::text(format!("FILE NOT FOUND - '{}'", path))) + }) + .format(head_only); } else { - eprintln!("File does not exist: {}", path); - let response = format!("HTTP/1.1 404 NOT FOUND\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Length: {}\r\n\r\n{}", path.len(), path); - stream - .write_all(response.as_bytes()) - .unwrap_or_else(|_| println!("failed to respond with 404")); - }; + response = Response::new(Status::BadRequest).format(false); + } + + stream + .write_all(&response) + .unwrap_or_else(|_| println!("failed to respond")); stream .flush() .unwrap_or_else(|_| println!("failed to respond")); } + +fn get_file(request: Request) -> Option { + let path = PathBuf::from(format!("./{}", &request.path)) + .canonicalize() + .ok()?; + if path.strip_prefix(env::current_dir().unwrap()).is_err() { + return None; + } + + if path.is_dir() { + let index_file = path.join("index.html"); + if index_file.is_file() { + Some(Content::html(fs::read_to_string(index_file).ok()?)) + } else { + generate_index(&request.path, &path) + } + } else if path.is_file() { + let ext = path.extension().unwrap_or_default().to_str()?; + let mut buf = Vec::new(); + File::open(&path).ok()?.read_to_end(&mut buf).ok()?; + Some(Content::file(ext, buf)) + } else { + None + } +} + +fn generate_index(relative_path: &str, path: &Path) -> Option { + let list = path + .read_dir() + .ok()? + .flatten() + .filter_map(|e| { + let target = e.file_name().to_str()?.to_string(); + let mut s = format!( + "
  • {}", + PathBuf::from(relative_path).join(&target).display(), + target + ); + if e.file_type().ok()?.is_dir() { + s.push('/'); + } + s.push_str("
  • \n"); + Some(s) + }) + .fold(String::new(), |mut content, entry| { + content.push_str(&entry); + content + }); + let page = format!( + r#" + + + + + Index of {relative_path} + + +

    Index of {relative_path}

    +
      +
    • ../
    • + {list} +
    + +"#, + ); + Some(Content::html(page)) +}