overhaul; generate directory index

This commit is contained in:
Crispy 2024-03-23 22:40:09 +01:00
parent ecadce6730
commit c408a56830
2 changed files with 302 additions and 46 deletions

206
src/http.rs Normal file
View file

@ -0,0 +1,206 @@
#[derive(Debug)]
pub struct Request {
pub method: Method,
pub path: String,
pub host: String,
pub range: Option<ContentRange>,
}
#[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<Content>,
}
#[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<u8>,
}
impl Request {
pub fn parse(source: &str) -> Option<Self> {
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<u8> {
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<u8>) -> 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<Self> {
match source {
"GET" => Some(Self::Get),
"HEAD" => Some(Self::Head),
_ => None,
}
}
}
impl ContentRange {
fn parse(source: &str) -> Option<Self> {
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()?)),
}
}
}

View file

@ -1,6 +1,13 @@
use std::io::prelude::*; use std::{
use std::net::{TcpListener, TcpStream}; env,
use std::{env, fs}; fs::{self, File},
io::{Read, Write},
net::{TcpListener, TcpStream},
path::{Path, PathBuf},
};
mod http;
use http::{Content, Method, Request, Response, Status};
fn main() { fn main() {
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
@ -35,9 +42,8 @@ fn handle_connection(mut stream: TcpStream) {
let request = String::from_utf8_lossy(&buffer); let request = String::from_utf8_lossy(&buffer);
let peer_addr = stream.peer_addr().ok(); let peer_addr = stream.peer_addr().ok();
println!( println!(
"Received {} bytes from {:?}\n\n[{}]", "Received {} bytes from {:?}\n=======\n{}=======\n\n",
size, size,
peer_addr, peer_addr,
request request
@ -47,51 +53,95 @@ fn handle_connection(mut stream: TcpStream) {
.replace("\\n", "\n") .replace("\\n", "\n")
); );
let mut request = request.into_owned(); let request = Request::parse(&request);
while request.contains("..") {
request = request.replace("..", ""); let response;
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 {
response = Response::new(Status::BadRequest).format(false);
} }
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 stream
.write_all(response.as_bytes()) .write_all(&response)
.unwrap_or_else(|_| println!("failed to respond")); .unwrap_or_else(|_| println!("failed to respond"));
stream stream
.flush() .flush()
.unwrap_or_else(|_| println!("failed to respond")); .unwrap_or_else(|_| println!("failed to respond"));
} }
fn send_file(stream: &mut TcpStream, path: &str) { fn get_file(request: Request) -> Option<Content> {
if let Ok(text) = fs::read_to_string(path) { let path = PathBuf::from(format!("./{}", &request.path))
let contents = text + "\n\n"; .canonicalize()
let response = format!( .ok()?;
"HTTP/1.1 200 OK\r\nContent-Type: {}; charset=UTF-8\r\nContent-Length: {}\r\n\r\n{}", if path.strip_prefix(env::current_dir().unwrap()).is_err() {
if path.ends_with(".html") { return None;
"text/html" }
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 { } else {
"text/plain" generate_index(&request.path, &path)
}, }
contents.len(), } else if path.is_file() {
contents 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<Content> {
let list = path
.read_dir()
.ok()?
.flatten()
.filter_map(|e| {
let target = e.file_name().to_str()?.to_string();
let mut s = format!(
" <li><a href=\"{}\"> {}",
PathBuf::from(relative_path).join(&target).display(),
target
); );
stream if e.file_type().ok()?.is_dir() {
.write_all(response.as_bytes()) s.push('/');
.unwrap_or_else(|_| println!("failed to respond")); }
} else { s.push_str("</a></li>\n");
eprintln!("File does not exist: {}", path); Some(s)
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 .fold(String::new(), |mut content, entry| {
.write_all(response.as_bytes()) content.push_str(&entry);
.unwrap_or_else(|_| println!("failed to respond with 404")); content
}; });
stream let page = format!(
.flush() r#"<!DOCTYPE html>
.unwrap_or_else(|_| println!("failed to respond")); <html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Index of {relative_path}</title>
</head>
<body>
<h3>Index of {relative_path}</h3>
<ul>
<li><a href="..">../</a></li>
{list}
</ul>
</body>
</html>"#,
);
Some(Content::html(page))
} }