🐛 Redirect to add trailing slashes for directories
parent
435232bf6c
commit
02da0ff0fc
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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| {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
else if let Ok (dir) = read_dir (&full_path).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 ()));
|
||||
}
|
||||
|
||||
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 {
|
||||
use std::{
|
||||
ffi::OsStr,
|
||||
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 () {
|
||||
use std::{
|
||||
ffi::OsStr,
|
||||
path::{Component, Path}
|
||||
};
|
||||
|
||||
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
24
todo.md
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue