🐛 Redirect to add trailing slashes for directories

main
_ 2020-11-08 17:58:14 +00:00
parent 435232bf6c
commit 02da0ff0fc
7 changed files with 238 additions and 73 deletions

View File

@ -9,10 +9,16 @@
.entry { .entry {
display: inline-block; display: inline-block;
padding: 10px; padding: 10px;
min-width: 50%; width: 100%;
text-decoration: none; text-decoration: none;
} }
.entry_list div:nth-child(odd) { .grey {
color: #888;
}
.entry_list {
width: 100%;
}
.entry_list tr:nth-child(even) {
background-color: #ddd; background-color: #ddd;
} }
</style> </style>
@ -24,21 +30,30 @@
<p>{{path}}</p> <p>{{path}}</p>
<div class="entry_list"> <table class="entry_list">
<thead>
<tr>
<th>Name</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<div> <tr>
<a class="entry" href="../">📁 ../</a> <td><a class="entry" href="../">📁 ../</a></td>
</div> <td></td>
</tr>
{{#each entries}} {{#each entries}}
<div> <tr>
<a class="entry" href="{{this.encoded_file_name}}{{this.trailing_slash}}"> <td><a class="entry" href="{{this.encoded_file_name}}{{this.trailing_slash}}">
{{this.icon}} {{this.file_name}}{{this.trailing_slash}} {{this.icon}} {{this.file_name}}{{this.trailing_slash}}</a></td>
</a> <td><span class="grey">{{this.size}}</span></td>
</div> </tr>
{{/each}} {{/each}}
</div> </tbody>
</table>
</body> </body>
</html> </html>

View File

@ -9,10 +9,10 @@
.entry { .entry {
display: inline-block; display: inline-block;
padding: 10px; padding: 10px;
min-width: 50%; width: 100%;
text-decoration: none; text-decoration: none;
} }
.entry_list div:nth-child(odd) { .entry_list div:nth-child(even) {
background-color: #ddd; background-color: #ddd;
} }
</style> </style>

View File

@ -32,6 +32,7 @@ pub struct Config {
struct ServerState <'a> { struct ServerState <'a> {
config: Config, config: Config,
handlebars: handlebars::Handlebars <'a>, handlebars: handlebars::Handlebars <'a>,
hidden_path: Option <PathBuf>,
} }
fn status_reply <B: Into <Body>> (status: StatusCode, b: B) fn status_reply <B: Into <Body>> (status: StatusCode, b: B)
@ -67,7 +68,7 @@ async fn handle_all (req: Request <Body>, state: Arc <ServerState <'static>>)
ptth_req.method, ptth_req.method,
&ptth_req.uri, &ptth_req.uri,
&ptth_req.headers, &ptth_req.headers,
None state.hidden_path.as_ref ().map (|p| p.as_path ())
).await; ).await;
let mut resp = Response::builder () let mut resp = Response::builder ()
@ -97,7 +98,9 @@ pub struct ConfigFile {
#[tokio::main] #[tokio::main]
async fn main () -> Result <(), Box <dyn Error>> { async fn main () -> Result <(), Box <dyn Error>> {
tracing_subscriber::fmt::init (); tracing_subscriber::fmt::init ();
let config_file: ConfigFile = ptth::load_toml::load ("config/ptth_server.toml");
let path = PathBuf::from ("./config/ptth_server.toml");
let config_file: ConfigFile = ptth::load_toml::load (&path);
info! ("file_server_root: {:?}", config_file.file_server_root); info! ("file_server_root: {:?}", config_file.file_server_root);
let addr = SocketAddr::from(([0, 0, 0, 0], 4000)); let addr = SocketAddr::from(([0, 0, 0, 0], 4000));
@ -109,6 +112,7 @@ async fn main () -> Result <(), Box <dyn Error>> {
file_server_root: config_file.file_server_root, file_server_root: config_file.file_server_root,
}, },
handlebars, handlebars,
hidden_path: Some (path),
}); });
let make_svc = make_service_fn (|_conn| { let make_svc = make_service_fn (|_conn| {

View File

@ -1,6 +1,6 @@
use std::{ use std::{
collections::*, collections::*,
convert::{TryFrom}, convert::{TryFrom, TryInto},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -85,11 +85,13 @@ pub struct WrappedRequest {
pub req: RequestParts, pub req: RequestParts,
} }
#[derive (Debug, Deserialize, Serialize)] #[derive (Debug, Deserialize, Serialize, PartialEq)]
pub enum StatusCode { pub enum StatusCode {
Ok, // 200 Ok, // 200
PartialContent, // 206 PartialContent, // 206
TemporaryRedirect, // 307
BadRequest, // 400 BadRequest, // 400
Forbidden, // 403 Forbidden, // 403
NotFound, // 404 NotFound, // 404
@ -108,6 +110,8 @@ impl From <StatusCode> for hyper::StatusCode {
StatusCode::Ok => Self::OK, StatusCode::Ok => Self::OK,
StatusCode::PartialContent => Self::PARTIAL_CONTENT, StatusCode::PartialContent => Self::PARTIAL_CONTENT,
StatusCode::TemporaryRedirect => Self::TEMPORARY_REDIRECT,
StatusCode::BadRequest => Self::BAD_REQUEST, StatusCode::BadRequest => Self::BAD_REQUEST,
StatusCode::Forbidden => Self::FORBIDDEN, StatusCode::Forbidden => Self::FORBIDDEN,
StatusCode::NotFound => Self::NOT_FOUND, StatusCode::NotFound => Self::NOT_FOUND,
@ -142,6 +146,24 @@ pub struct Response {
} }
impl Response { impl Response {
pub async fn into_bytes (self) -> Vec <u8> {
let mut body = match self.body {
None => return Vec::new (),
Some (x) => x,
};
let mut result = match self.content_length {
None => Vec::new (),
Some (x) => Vec::with_capacity (x.try_into ().unwrap ()),
};
while let Some (Ok (mut chunk)) = body.recv ().await {
result.append (&mut chunk);
}
result
}
pub fn status_code (&mut self, c: StatusCode) -> &mut Self { pub fn status_code (&mut self, c: StatusCode) -> &mut Self {
self.parts.status_code = c; self.parts.status_code = c;
self self
@ -158,14 +180,12 @@ impl Response {
} }
pub fn body_bytes (&mut self, b: Vec <u8>) -> &mut Self { pub fn body_bytes (&mut self, b: Vec <u8>) -> &mut Self {
use std::convert::TryInto;
self.content_length = b.len ().try_into ().ok (); self.content_length = b.len ().try_into ().ok ();
self.header ("content-length".to_string (), b.len ().to_string ().into_bytes ()); self.header ("content-length".to_string (), b.len ().to_string ().into_bytes ());
let (mut tx, rx) = tokio::sync::mpsc::channel (1); let (mut tx, rx) = tokio::sync::mpsc::channel (1);
tokio::spawn (async move { tokio::spawn (async move {
tx.send (Ok (b)).await.unwrap (); tx.send (Ok (b)).await.ok ();
}); });
self.body = Some (rx); self.body = Some (rx);

View File

@ -86,12 +86,10 @@ mod tests {
use reqwest::Client; use reqwest::Client;
use tracing::{info}; use tracing::{info};
// This should be the first line of the `tracing` // Prefer this form for tests, since all tests share one process
// crate documentation. Their docs are awful, but you // and we don't care if another test already installed a subscriber.
// didn't hear it from me.
tracing_subscriber::fmt::init ();
tracing_subscriber::fmt ().try_init ().ok ();
let mut rt = Runtime::new ().unwrap (); let mut rt = Runtime::new ().unwrap ();
// Spawn the root task // Spawn the root task

View File

@ -1,6 +1,7 @@
// Static file server that can plug into the PTTH reverse server // Static file server that can plug into the PTTH reverse server
use std::{ use std::{
borrow::Cow,
cmp::{min, max}, cmp::{min, max},
collections::*, collections::*,
convert::{Infallible, TryInto}, convert::{Infallible, TryInto},
@ -28,7 +29,11 @@ use tracing::instrument;
use regex::Regex; use regex::Regex;
use crate::{ use crate::{
http_serde, http_serde::{
Method,
Response,
StatusCode,
},
prelude::*, prelude::*,
prefix_match, prefix_match,
}; };
@ -54,6 +59,8 @@ struct TemplateDirEntry {
encoded_file_name: String, encoded_file_name: String,
size: Cow <'static, str>,
error: bool, error: bool,
} }
@ -97,13 +104,28 @@ async fn read_dir_entry (entry: DirEntry) -> TemplateDirEntry
trailing_slash: "", trailing_slash: "",
file_name: "File / directory name is not UTF-8".into (), file_name: "File / directory name is not UTF-8".into (),
encoded_file_name: "".into (), encoded_file_name: "".into (),
size: "".into (),
error: true, error: true,
}, },
}; };
let (trailing_slash, icon) = match entry.file_type ().await { let metadata = match entry.metadata ().await {
Ok (t) => if t.is_dir () { Ok (x) => x,
("/", "📁") Err (_) => return TemplateDirEntry {
icon: "⚠️",
trailing_slash: "",
file_name: "Could not fetch metadata".into (),
encoded_file_name: "".into (),
size: "".into (),
error: true,
},
};
let (trailing_slash, icon, size) = {
let t = metadata.file_type ();
if t.is_dir () {
("/", "📁", "".into ())
} }
else { else {
let icon = if file_name.ends_with (".mp4") { let icon = if file_name.ends_with (".mp4") {
@ -131,9 +153,8 @@ async fn read_dir_entry (entry: DirEntry) -> TemplateDirEntry
"📄" "📄"
}; };
("", icon) ("", icon, pretty_print_bytes (metadata.len ()).into ())
}, }
Err (_) => ("", "⚠️"),
}; };
use percent_encoding::*; use percent_encoding::*;
@ -145,13 +166,14 @@ async fn read_dir_entry (entry: DirEntry) -> TemplateDirEntry
trailing_slash: &trailing_slash, trailing_slash: &trailing_slash,
file_name, file_name,
encoded_file_name, encoded_file_name,
size,
error: false, error: false,
} }
} }
async fn serve_root ( async fn serve_root (
handlebars: &Handlebars <'static>, handlebars: &Handlebars <'static>,
) -> http_serde::Response ) -> Response
{ {
let server_info = ServerInfo { let server_info = ServerInfo {
server_name: "PTTH file server", server_name: "PTTH file server",
@ -160,7 +182,7 @@ async fn serve_root (
let s = handlebars.render ("file_server_root", &server_info).unwrap (); let s = handlebars.render ("file_server_root", &server_info).unwrap ();
let body = s.into_bytes (); let body = s.into_bytes ();
let mut resp = http_serde::Response::default (); let mut resp = Response::default ();
resp resp
.header ("content-type".to_string (), "text/html".to_string ().into_bytes ()) .header ("content-type".to_string (), "text/html".to_string ().into_bytes ())
.body_bytes (body) .body_bytes (body)
@ -168,14 +190,12 @@ async fn serve_root (
resp resp
} }
use std::borrow::Cow;
#[instrument (level = "debug", skip (handlebars, dir))] #[instrument (level = "debug", skip (handlebars, dir))]
async fn serve_dir ( async fn serve_dir (
handlebars: &Handlebars <'static>, handlebars: &Handlebars <'static>,
path: Cow <'_, str>, path: Cow <'_, str>,
mut dir: ReadDir mut dir: ReadDir
) -> http_serde::Response ) -> Response
{ {
let server_info = ServerInfo { let server_info = ServerInfo {
server_name: "PTTH file server", server_name: "PTTH file server",
@ -196,7 +216,7 @@ async fn serve_dir (
}).unwrap (); }).unwrap ();
let body = s.into_bytes (); let body = s.into_bytes ();
let mut resp = http_serde::Response::default (); let mut resp = Response::default ();
resp resp
.header ("content-type".to_string (), "text/html".to_string ().into_bytes ()) .header ("content-type".to_string (), "text/html".to_string ().into_bytes ())
.body_bytes (body) .body_bytes (body)
@ -210,7 +230,9 @@ async fn serve_file (
should_send_body: bool, should_send_body: bool,
range_start: Option <u64>, range_start: Option <u64>,
range_end: Option <u64> range_end: Option <u64>
) -> http_serde::Response { )
-> Response
{
let (tx, rx) = channel (1); let (tx, rx) = channel (1);
let body = if should_send_body { let body = if should_send_body {
Some (rx) Some (rx)
@ -271,17 +293,17 @@ async fn serve_file (
}); });
} }
let mut response = http_serde::Response::default (); let mut response = Response::default ();
response.header (String::from ("accept-ranges"), b"bytes".to_vec ()); response.header (String::from ("accept-ranges"), b"bytes".to_vec ());
if should_send_body { if should_send_body {
if range_start.is_none () && range_end.is_none () { if range_start.is_none () && range_end.is_none () {
response.status_code (http_serde::StatusCode::Ok); response.status_code (StatusCode::Ok);
response.header (String::from ("content-length"), end.to_string ().into_bytes ()); response.header (String::from ("content-length"), end.to_string ().into_bytes ());
} }
else { else {
response.status_code (http_serde::StatusCode::PartialContent); response.status_code (StatusCode::PartialContent);
response.header (String::from ("content-range"), format! ("bytes {}-{}/{}", start, end - 1, end).into_bytes ()); response.header (String::from ("content-range"), format! ("bytes {}-{}/{}", start, end - 1, end).into_bytes ());
} }
@ -295,15 +317,23 @@ async fn serve_file (
response response
} }
async fn serve_error ( fn serve_error (
status_code: http_serde::StatusCode, status_code: StatusCode,
msg: String msg: &str
) )
-> http_serde::Response -> Response
{ {
let mut resp = http_serde::Response::default (); let mut resp = Response::default ();
resp.status_code (status_code); resp.status_code (status_code);
resp.body_bytes (msg.into_bytes ()); resp.body_bytes (msg.as_bytes ().to_vec ());
resp
}
fn serve_307 (location: String) -> Response {
let mut resp = Response::default ();
resp.status_code (StatusCode::TemporaryRedirect);
resp.header ("location".to_string (), location.into_bytes ());
resp.body_bytes (b"Redirecting...".to_vec ());
resp resp
} }
@ -311,18 +341,22 @@ async fn serve_error (
pub async fn serve_all ( pub async fn serve_all (
handlebars: &Handlebars <'static>, handlebars: &Handlebars <'static>,
root: &Path, root: &Path,
method: http_serde::Method, method: Method,
uri: &str, uri: &str,
headers: &HashMap <String, Vec <u8>>, headers: &HashMap <String, Vec <u8>>,
hidden_path: Option <&Path> hidden_path: Option <&Path>
) )
-> http_serde::Response -> Response
{ {
info! ("Client requested {}", uri); info! ("Client requested {}", uri);
use percent_encoding::*; use percent_encoding::*;
let uri = match prefix_match (uri, "/files/") { if uri == "/favicon.ico" {
return serve_error (StatusCode::NotFound, "");
}
let uri = match prefix_match (uri, "/files") {
Some (x) => x, Some (x) => x,
None => { None => {
return serve_root (handlebars).await; return serve_root (handlebars).await;
@ -331,7 +365,7 @@ pub async fn serve_all (
// TODO: There is totally a dir traversal attack in here somewhere // TODO: There is totally a dir traversal attack in here somewhere
let encoded_path = &uri [..]; let encoded_path = &uri [1..];
let path_s = percent_decode (encoded_path.as_bytes ()).decode_utf8 ().unwrap (); let path_s = percent_decode (encoded_path.as_bytes ()).decode_utf8 ().unwrap ();
let path = Path::new (&*path_s); let path = Path::new (&*path_s);
@ -343,14 +377,17 @@ pub async fn serve_all (
if let Some (hidden_path) = hidden_path { if let Some (hidden_path) = hidden_path {
if full_path == hidden_path { if full_path == hidden_path {
return serve_error (http_serde::StatusCode::Forbidden, "403 Forbidden".into ()).await; return serve_error (StatusCode::Forbidden, "403 Forbidden");
} }
} }
if uri == "/" { let has_trailing_slash = path_s.is_empty () || path_s.ends_with ("/");
serve_root (handlebars).await
if let Ok (dir) = read_dir (&full_path).await {
if ! has_trailing_slash {
return serve_307 (format! ("{}/", path.file_name ().unwrap ().to_str ().unwrap ()));
} }
else if let Ok (dir) = read_dir (&full_path).await {
serve_dir ( serve_dir (
handlebars, handlebars,
full_path.to_string_lossy (), full_path.to_string_lossy (),
@ -370,11 +407,11 @@ pub async fn serve_all (
} }
let should_send_body = match &method { let should_send_body = match &method {
http_serde::Method::Get => true, Method::Get => true,
http_serde::Method::Head => false, Method::Head => false,
m => { m => {
debug! ("Unsupported method {:?}", m); debug! ("Unsupported method {:?}", m);
return serve_error (http_serde::StatusCode::MethodNotAllowed, "Unsupported method".into ()).await; return serve_error (StatusCode::MethodNotAllowed, "Unsupported method");
} }
}; };
@ -386,7 +423,7 @@ pub async fn serve_all (
).await ).await
} }
else { else {
serve_error (http_serde::StatusCode::NotFound, "404 Not Found".into ()).await serve_error (StatusCode::NotFound, "404 Not Found")
} }
} }
@ -406,15 +443,60 @@ pub fn load_templates ()
Ok (handlebars) Ok (handlebars)
} }
fn pretty_print_bytes (b: u64) -> String {
if b < 1024 {
format! ("{} B", b)
}
else if (b + 512) < 1024 * 1024 {
format! ("{} KiB", (b + 512) / 1024)
}
else if (b + 512 * 1024) < 1024 * 1024 * 1024 {
format! ("{} MiB", (b + 512 * 1024) / 1024 / 1024)
}
else {
format! ("{} GiB", (b + 512 * 1024 * 1024) / 1024 / 1024 / 1024)
}
}
#[cfg (test)] #[cfg (test)]
mod tests { mod tests {
#[test]
fn i_hate_paths () {
use std::{ use std::{
ffi::OsStr, ffi::OsStr,
path::{Component, Path} path::{
Component,
Path,
PathBuf
},
}; };
use tokio::runtime::Runtime;
use crate::http_serde::{
StatusCode,
};
#[test]
fn pretty_print_bytes () {
for (input_after, expected_before, expected_after) in vec! [
(1, "0 B", "1 B"),
(1024, "1023 B", "1 KiB"),
(1024 + 512, "1 KiB", "2 KiB"),
(1023 * 1024 + 512, "1023 KiB", "1 MiB"),
((1024 + 512) * 1024, "1 MiB", "2 MiB"),
(1023 * 1024 * 1024 + 512 * 1024, "1023 MiB", "1 GiB"),
((1024 + 512) * 1024 * 1024, "1 GiB", "2 GiB"),
].into_iter () {
let actual = super::pretty_print_bytes (input_after - 1);
assert_eq! (&actual, expected_before);
let actual = super::pretty_print_bytes (input_after);
assert_eq! (&actual, expected_after);
}
}
#[test]
fn i_hate_paths () {
let mut components = Path::new ("/home/user").components (); let mut components = Path::new ("/home/user").components ();
assert_eq! (components.next (), Some (Component::RootDir)); assert_eq! (components.next (), Some (Component::RootDir));
@ -434,4 +516,38 @@ mod tests {
assert_eq! (components.next (), Some (Component::CurDir)); assert_eq! (components.next (), Some (Component::CurDir));
assert_eq! (components.next (), None); assert_eq! (components.next (), None);
} }
#[test]
fn file_server () {
use crate::{
http_serde::Method,
prelude::*,
};
tracing_subscriber::fmt ().try_init ().ok ();
let mut rt = Runtime::new ().unwrap ();
rt.block_on (async {
let handlebars = super::load_templates ().unwrap ();
let file_server_root = PathBuf::from ("./");
let headers = Default::default ();
for (uri_path, expected_status) in vec! [
("/", StatusCode::Ok),
("/files/src", StatusCode::TemporaryRedirect),
("/files/src/", StatusCode::Ok),
].into_iter () {
let resp = super::serve_all (
&handlebars,
&file_server_root,
Method::Get,
uri_path,
&headers,
None
).await;
assert_eq! (resp.parts.status_code, expected_status);
}
});
}
} }

24
todo.md
View File

@ -1,10 +1,6 @@
- Not working behind Nginx (Works okay behind Caddy) - Not working behind Nginx (Works okay behind Caddy)
- Reduce idle memory use? - Reduce idle memory use?
- Compress bad passwords file
- Package templates into exe for release
- Redirect to add trailing slashes
- Add file size in directory listing
- Allow spaces in server names - Allow spaces in server names
- Deny unused HTTP methods for endpoints - Deny unused HTTP methods for endpoints
- ETag cache based on mtime - ETag cache based on mtime
@ -17,11 +13,13 @@
- Reverse proxy to other local servers - Reverse proxy to other local servers
Off-project stuff: # Off-project stuff:
- Benchmark directory entry sorting - Benchmark directory entry sorting
Known issues: # Known issues:
## Graceful shutdown
Relay can't shut down gracefully if Firefox is connected to it, e.g. if Firefox Relay can't shut down gracefully if Firefox is connected to it, e.g. if Firefox
kept a connection open while watching a video. kept a connection open while watching a video.
@ -31,3 +29,17 @@ forced shutdown timer.
Sometimes I get the turtle icon in Firefox's network debugger. But this happens Sometimes I get the turtle icon in Firefox's network debugger. But this happens
even with Caddy running a static file server, so I can't prove that it's on my even with Caddy running a static file server, so I can't prove that it's on my
side. The VPS is cheap, and the datacenter is far away. side. The VPS is cheap, and the datacenter is far away.
## Embedded asssets
The bad_passwords file is huge. Since it's static, it should only be in physical
RAM when the server first launches, and then the kernel will let it be paged
out.
Rust has some open issues with compiling assets into the exe, so I'm not
going to push on this for now, for neither bad_passwords nor the HTML assets:
https://github.com/rust-lang/rust/issues/65818
I also considered compressing the passwords file, but I couldn't even get
brotli to give it a decent ratio.