Skip to content

Commit e06802f

Browse files
committed
feat: impl static file serving addon
Introduce the Addon concept to write application logic out of the server implementation itself and aim to a modularized architecture. Implements a static file serving addon to support sending files to the client. The static file serving addon also provides a scoped file system abstraction which securely resolve files in the file system tree.
1 parent eb1a5f0 commit e06802f

File tree

9 files changed

+526
-163
lines changed

9 files changed

+526
-163
lines changed

Cargo.lock

Lines changed: 1 addition & 96 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ chrono = "0.4"
2727
futures = "0.3"
2828
http = "0.2"
2929
handlebars = "3"
30-
hyper = { version = "0.14", features = ["http1", "server", "tcp"] }
31-
hyper-staticfile = "0.6"
30+
hyper = { version = "0.14", features = ["http1", "server", "stream", "tcp"] }
31+
mime_guess = "2"
3232
rustls = "0.19"
33-
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
33+
tokio = { version = "1", features = ["fs", "rt-multi-thread", "macros"] }
3434
tokio-rustls = "0.22"
3535
toml = "0.5"
3636
serde = { version = "1", features = ["derive"] }

src/addon/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub mod static_file;

src/addon/static_file/file.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
use anyhow::{Context, Result};
2+
use chrono::{DateTime, Local};
3+
use mime_guess::{from_path, Mime};
4+
use std::fs::Metadata;
5+
use std::path::PathBuf;
6+
7+
/// Wrapper around `tokio::fs::File` built from a OS ScopedFileSystem file
8+
/// providing `std::fs::Metadata` and the path to such file
9+
#[derive(Debug)]
10+
pub struct File {
11+
pub path: PathBuf,
12+
pub file: tokio::fs::File,
13+
pub metadata: Metadata,
14+
}
15+
16+
impl File {
17+
pub fn new(path: PathBuf, file: tokio::fs::File, metadata: Metadata) -> Self {
18+
File {
19+
path,
20+
file,
21+
metadata,
22+
}
23+
}
24+
25+
pub fn mime(&self) -> Mime {
26+
from_path(self.path.clone()).first_or_octet_stream()
27+
}
28+
29+
pub fn size(&self) -> u64 {
30+
self.metadata.len()
31+
}
32+
33+
pub fn last_modified(&self) -> Result<DateTime<Local>> {
34+
let modified = self
35+
.metadata
36+
.modified()
37+
.context("Failed to read last modified time for file")?;
38+
let modified: DateTime<Local> = modified.into();
39+
40+
Ok(modified)
41+
}
42+
}

src/addon/static_file/http.rs

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
use anyhow::{Context, Result};
2+
use chrono::{DateTime, Local, Utc};
3+
use futures::Stream;
4+
use http::response::Builder as HttpResponseBuilder;
5+
use hyper::body::Body;
6+
use hyper::body::Bytes;
7+
use std::mem::MaybeUninit;
8+
use std::pin::Pin;
9+
use std::task::{self, Poll};
10+
use tokio::io::{AsyncRead, ReadBuf};
11+
12+
use super::file::File;
13+
14+
const FILE_BUFFER_SIZE: usize = 8 * 1024;
15+
16+
pub type FileBuffer = Box<[MaybeUninit<u8>; FILE_BUFFER_SIZE]>;
17+
18+
/// HTTP Response `Cache-Control` directive
19+
///
20+
/// Allow dead code until we have support for cache control configuration
21+
#[allow(dead_code)]
22+
23+
pub enum CacheControlDirective {
24+
/// Cache-Control: must-revalidate
25+
MustRevalidate,
26+
/// Cache-Control: no-cache
27+
NoCache,
28+
/// Cache-Control: no-store
29+
NoStore,
30+
/// Cache-Control: no-transform
31+
NoTransform,
32+
/// Cache-Control: public
33+
Public,
34+
/// Cache-Control: private
35+
Private,
36+
/// Cache-Control: proxy-revalidate
37+
ProxyRavalidate,
38+
/// Cache-Control: max-age=<seconds>
39+
MaxAge(u64),
40+
/// Cache-Control: s-maxage=<seconds>
41+
SMaxAge(u64),
42+
}
43+
44+
impl ToString for CacheControlDirective {
45+
fn to_string(&self) -> String {
46+
match &self {
47+
Self::MustRevalidate => String::from("must-revalidate"),
48+
Self::NoCache => String::from("no-cache"),
49+
Self::NoStore => String::from("no-store"),
50+
Self::NoTransform => String::from("no-transform"),
51+
Self::Public => String::from("public"),
52+
Self::Private => String::from("private"),
53+
Self::ProxyRavalidate => String::from("proxy-revalidate"),
54+
Self::MaxAge(age) => format!("max-age={}", age),
55+
Self::SMaxAge(age) => format!("s-maxage={}", age),
56+
}
57+
}
58+
}
59+
60+
pub struct ResponseHeaders {
61+
cache_control: String,
62+
content_length: u64,
63+
content_type: String,
64+
etag: String,
65+
last_modified: String,
66+
}
67+
68+
impl ResponseHeaders {
69+
pub fn new(
70+
file: &File,
71+
cache_control_directive: CacheControlDirective,
72+
) -> Result<ResponseHeaders> {
73+
let last_modified = file.last_modified()?;
74+
75+
Ok(ResponseHeaders {
76+
cache_control: cache_control_directive.to_string(),
77+
content_length: ResponseHeaders::content_length(file),
78+
content_type: ResponseHeaders::content_type(file),
79+
etag: ResponseHeaders::etag(file, &last_modified),
80+
last_modified: ResponseHeaders::last_modified(&last_modified),
81+
})
82+
}
83+
84+
fn content_length(file: &File) -> u64 {
85+
file.size()
86+
}
87+
88+
fn content_type(file: &File) -> String {
89+
file.mime().to_string()
90+
}
91+
92+
fn etag(file: &File, last_modified: &DateTime<Local>) -> String {
93+
format!(
94+
"W/\"{0:x}-{1:x}.{2:x}\"",
95+
file.size(),
96+
last_modified.timestamp(),
97+
last_modified.timestamp_subsec_nanos(),
98+
)
99+
}
100+
101+
fn last_modified(last_modified: &DateTime<Local>) -> String {
102+
format!(
103+
"{} GMT",
104+
last_modified
105+
.with_timezone(&Utc)
106+
.format("%a, %e %b %Y %H:%M:%S")
107+
)
108+
}
109+
}
110+
111+
pub async fn make_http_file_response(
112+
file: File,
113+
cache_control_directive: CacheControlDirective,
114+
) -> Result<hyper::http::Response<Body>> {
115+
let headers = ResponseHeaders::new(&file, cache_control_directive)?;
116+
let builder = HttpResponseBuilder::new()
117+
.header(http::header::CONTENT_LENGTH, headers.content_length)
118+
.header(http::header::CACHE_CONTROL, headers.cache_control)
119+
.header(http::header::CONTENT_TYPE, headers.content_type)
120+
.header(http::header::ETAG, headers.etag)
121+
.header(http::header::LAST_MODIFIED, headers.last_modified);
122+
123+
let body = file_bytes_into_http_body(file).await;
124+
let response = builder
125+
.body(body)
126+
.context("Failed to build HTTP File Response")?;
127+
128+
Ok(response)
129+
}
130+
131+
pub async fn file_bytes_into_http_body(file: File) -> Body {
132+
let byte_stream = ByteStream {
133+
file: file.file,
134+
buffer: Box::new([MaybeUninit::uninit(); FILE_BUFFER_SIZE]),
135+
};
136+
137+
Body::wrap_stream(byte_stream)
138+
}
139+
140+
pub struct ByteStream {
141+
file: tokio::fs::File,
142+
buffer: FileBuffer,
143+
}
144+
145+
impl Stream for ByteStream {
146+
type Item = Result<Bytes>;
147+
148+
fn poll_next(mut self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> Poll<Option<Self::Item>> {
149+
let ByteStream {
150+
ref mut file,
151+
ref mut buffer,
152+
} = *self;
153+
let mut read_buffer = ReadBuf::uninit(&mut buffer[..]);
154+
155+
match Pin::new(file).poll_read(cx, &mut read_buffer) {
156+
Poll::Ready(Ok(())) => {
157+
let filled = read_buffer.filled();
158+
159+
if filled.is_empty() {
160+
Poll::Ready(None)
161+
} else {
162+
Poll::Ready(Some(Ok(Bytes::copy_from_slice(filled))))
163+
}
164+
}
165+
Poll::Ready(Err(error)) => Poll::Ready(Some(Err(error.into()))),
166+
Poll::Pending => Poll::Pending,
167+
}
168+
}
169+
}

src/addon/static_file/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
mod file;
2+
mod scoped_file_system;
3+
4+
pub mod http;
5+
6+
pub use file::File;
7+
pub use scoped_file_system::{Directory, Entry, ScopedFileSystem};

0 commit comments

Comments
 (0)