🐛 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 {
display: inline-block;
padding: 10px;
min-width: 50%;
width: 100%;
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;
}
</style>
@ -24,21 +30,30 @@
<p>{{path}}</p>
<div class="entry_list">
<table class="entry_list">
<thead>
<tr>
<th>Name</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<div>
<a class="entry" href="../">📁 ../</a>
</div>
<tr>
<td><a class="entry" href="../">📁 ../</a></td>
<td></td>
</tr>
{{#each entries}}
<div>
<a class="entry" href="{{this.encoded_file_name}}{{this.trailing_slash}}">
{{this.icon}} {{this.file_name}}{{this.trailing_slash}}
</a>
</div>
<tr>
<td><a class="entry" href="{{this.encoded_file_name}}{{this.trailing_slash}}">
{{this.icon}} {{this.file_name}}{{this.trailing_slash}}</a></td>
<td><span class="grey">{{this.size}}</span></td>
</tr>
{{/each}}
</div>
</tbody>
</table>
</body>
</html>

View File

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

View File

@ -32,6 +32,7 @@ pub struct Config {
struct ServerState <'a> {
config: Config,
handlebars: handlebars::Handlebars <'a>,
hidden_path: Option <PathBuf>,
}
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.uri,
&ptth_req.headers,
None
state.hidden_path.as_ref ().map (|p| p.as_path ())
).await;
let mut resp = Response::builder ()
@ -97,7 +98,9 @@ pub struct ConfigFile {
#[tokio::main]
async fn main () -> Result <(), Box <dyn Error>> {
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);
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,
},
handlebars,
hidden_path: Some (path),
});
let make_svc = make_service_fn (|_conn| {

View File

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

View File

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

View File

@ -1,6 +1,7 @@
// Static file server that can plug into the PTTH reverse server
use std::{
borrow::Cow,
cmp::{min, max},
collections::*,
convert::{Infallible, TryInto},
@ -28,7 +29,11 @@ use tracing::instrument;
use regex::Regex;
use crate::{
http_serde,
http_serde::{
Method,
Response,
StatusCode,
},
prelude::*,
prefix_match,
};
@ -54,6 +59,8 @@ struct TemplateDirEntry {
encoded_file_name: String,
size: Cow <'static, str>,
error: bool,
}
@ -97,13 +104,28 @@ async fn read_dir_entry (entry: DirEntry) -> TemplateDirEntry
trailing_slash: "",
file_name: "File / directory name is not UTF-8".into (),
encoded_file_name: "".into (),
size: "".into (),
error: true,
},
};
let (trailing_slash, icon) = match entry.file_type ().await {
Ok (t) => if t.is_dir () {
("/", "📁")
let metadata = match entry.metadata ().await {
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 {
let icon = if file_name.ends_with (".mp4") {
@ -131,9 +153,8 @@ async fn read_dir_entry (entry: DirEntry) -> TemplateDirEntry
"📄"
};
("", icon)
},
Err (_) => ("", "⚠️"),
("", icon, pretty_print_bytes (metadata.len ()).into ())
}
};
use percent_encoding::*;
@ -145,13 +166,14 @@ async fn read_dir_entry (entry: DirEntry) -> TemplateDirEntry
trailing_slash: &trailing_slash,
file_name,
encoded_file_name,
size,
error: false,
}
}
async fn serve_root (
handlebars: &Handlebars <'static>,
) -> http_serde::Response
) -> Response
{
let server_info = ServerInfo {
server_name: "PTTH file server",
@ -160,7 +182,7 @@ async fn serve_root (
let s = handlebars.render ("file_server_root", &server_info).unwrap ();
let body = s.into_bytes ();
let mut resp = http_serde::Response::default ();
let mut resp = Response::default ();
resp
.header ("content-type".to_string (), "text/html".to_string ().into_bytes ())
.body_bytes (body)
@ -168,14 +190,12 @@ async fn serve_root (
resp
}
use std::borrow::Cow;
#[instrument (level = "debug", skip (handlebars, dir))]
async fn serve_dir (
handlebars: &Handlebars <'static>,
path: Cow <'_, str>,
mut dir: ReadDir
) -> http_serde::Response
) -> Response
{
let server_info = ServerInfo {
server_name: "PTTH file server",
@ -196,7 +216,7 @@ async fn serve_dir (
}).unwrap ();
let body = s.into_bytes ();
let mut resp = http_serde::Response::default ();
let mut resp = Response::default ();
resp
.header ("content-type".to_string (), "text/html".to_string ().into_bytes ())
.body_bytes (body)
@ -210,7 +230,9 @@ async fn serve_file (
should_send_body: bool,
range_start: Option <u64>,
range_end: Option <u64>
) -> http_serde::Response {
)
-> Response
{
let (tx, rx) = channel (1);
let body = if should_send_body {
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 ());
if should_send_body {
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 ());
}
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 ());
}
@ -295,15 +317,23 @@ async fn serve_file (
response
}
async fn serve_error (
status_code: http_serde::StatusCode,
msg: String
fn serve_error (
status_code: StatusCode,
msg: &str
)
-> http_serde::Response
-> Response
{
let mut resp = http_serde::Response::default ();
let mut resp = Response::default ();
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
}
@ -311,18 +341,22 @@ async fn serve_error (
pub async fn serve_all (
handlebars: &Handlebars <'static>,
root: &Path,
method: http_serde::Method,
method: Method,
uri: &str,
headers: &HashMap <String, Vec <u8>>,
hidden_path: Option <&Path>
)
-> http_serde::Response
-> Response
{
info! ("Client requested {}", uri);
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,
None => {
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
let encoded_path = &uri [..];
let encoded_path = &uri [1..];
let path_s = percent_decode (encoded_path.as_bytes ()).decode_utf8 ().unwrap ();
let path = Path::new (&*path_s);
@ -343,14 +377,17 @@ pub async fn serve_all (
if let Some (hidden_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 == "/" {
serve_root (handlebars).await
let has_trailing_slash = path_s.is_empty () || path_s.ends_with ("/");
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 (
handlebars,
full_path.to_string_lossy (),
@ -370,11 +407,11 @@ pub async fn serve_all (
}
let should_send_body = match &method {
http_serde::Method::Get => true,
http_serde::Method::Head => false,
Method::Get => true,
Method::Head => false,
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
}
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)
}
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)]
mod tests {
#[test]
fn i_hate_paths () {
use std::{
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 ();
assert_eq! (components.next (), Some (Component::RootDir));
@ -434,4 +516,38 @@ mod tests {
assert_eq! (components.next (), Some (Component::CurDir));
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)
- 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
- Deny unused HTTP methods for endpoints
- ETag cache based on mtime
@ -17,11 +13,13 @@
- Reverse proxy to other local servers
Off-project stuff:
# Off-project stuff:
- 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
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
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.
## 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.