proof of concept for private browser cache based on etag and if-none-match
parent
1df0f0f677
commit
d9669a7073
|
@ -123,7 +123,7 @@ async fn serve_file (
|
||||||
mut f: File,
|
mut f: File,
|
||||||
client_wants_body: bool,
|
client_wants_body: bool,
|
||||||
range: range::ValidParsed,
|
range: range::ValidParsed,
|
||||||
if_none_match: Option <&Vec <u8>>,
|
if_none_match: Option <&[u8]>,
|
||||||
)
|
)
|
||||||
-> Result <Response, FileServerError>
|
-> Result <Response, FileServerError>
|
||||||
{
|
{
|
||||||
|
@ -131,8 +131,18 @@ async fn serve_file (
|
||||||
// be valid ASCII, but if I make it binary I might accidentally pass the
|
// be valid ASCII, but if I make it binary I might accidentally pass the
|
||||||
// hash binary as a header, which is not valid.
|
// hash binary as a header, which is not valid.
|
||||||
|
|
||||||
let etag = get_file_etag (&f).await.map (String::into_bytes);
|
let actual_etag = get_file_etag (&f).await.map (String::into_bytes);
|
||||||
let client_cache_hit = match &etag {
|
|
||||||
|
let input = ServeFileInput {
|
||||||
|
if_none_match,
|
||||||
|
actual_etag,
|
||||||
|
client_wants_body,
|
||||||
|
range_requested: range.range_requested,
|
||||||
|
};
|
||||||
|
|
||||||
|
let decision = serve_file_decision (&input);
|
||||||
|
|
||||||
|
let client_cache_hit = match &input.actual_etag {
|
||||||
None => false,
|
None => false,
|
||||||
Some (actual) => match &if_none_match {
|
Some (actual) => match &if_none_match {
|
||||||
None => false,
|
None => false,
|
||||||
|
@ -140,31 +150,31 @@ async fn serve_file (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (tx, rx) = channel (1);
|
|
||||||
let body = if client_wants_body && ! client_cache_hit {
|
|
||||||
Some (rx)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let (range, range_requested) = (range.range, range.range_requested);
|
let (range, range_requested) = (range.range, range.range_requested);
|
||||||
|
|
||||||
info! ("Serving range {}-{}", range.start, range.end);
|
info! ("Serving range {}-{}", range.start, range.end);
|
||||||
|
|
||||||
let content_length = range.end - range.start;
|
let content_length = range.end - range.start;
|
||||||
|
|
||||||
let seek = SeekFrom::Start (range.start);
|
let body = if decision.should_send_body {
|
||||||
f.seek (seek).await?;
|
let seek = SeekFrom::Start (range.start);
|
||||||
|
f.seek (seek).await?;
|
||||||
|
|
||||||
if body.is_some () {
|
let (tx, rx) = channel (1);
|
||||||
tokio::spawn (async move {
|
tokio::spawn (async move {
|
||||||
stream_file (f, content_length, tx).await;
|
stream_file (f, content_length, tx).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Some (rx)
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let mut response = Response::default ();
|
let mut response = Response::default ();
|
||||||
|
|
||||||
|
response.status_code (decision.status_code);
|
||||||
|
|
||||||
// The cache-related headers in HTTP have bad names. See here:
|
// The cache-related headers in HTTP have bad names. See here:
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
|
||||||
// The intended semantics I'm using are:
|
// The intended semantics I'm using are:
|
||||||
|
@ -179,29 +189,16 @@ async fn serve_file (
|
||||||
// consider it stale.
|
// consider it stale.
|
||||||
|
|
||||||
response.header ("cache-control".to_string (), b"no-cache,max-age=0".to_vec ());
|
response.header ("cache-control".to_string (), b"no-cache,max-age=0".to_vec ());
|
||||||
etag.map (|etag| {
|
input.actual_etag.map (|etag| {
|
||||||
response.header ("etag".to_string (), etag);
|
response.header ("etag".to_string (), etag);
|
||||||
});
|
});
|
||||||
response.header (String::from ("accept-ranges"), b"bytes".to_vec ());
|
response.header (String::from ("accept-ranges"), b"bytes".to_vec ());
|
||||||
|
|
||||||
if range_requested {
|
if range_requested {
|
||||||
response.status_code (StatusCode::PartialContent);
|
|
||||||
response.header (String::from ("content-range"), format! ("bytes {}-{}/{}", range.start, range.end - 1, range.end).into_bytes ());
|
response.header (String::from ("content-range"), format! ("bytes {}-{}/{}", range.start, range.end - 1, range.end).into_bytes ());
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
response.status_code (StatusCode::Ok);
|
|
||||||
response.header (String::from ("content-length"), range.end.to_string ().into_bytes ());
|
|
||||||
}
|
|
||||||
|
|
||||||
if client_cache_hit {
|
response.content_length = Some (content_length);
|
||||||
response.status_code (StatusCode::NotModified);
|
|
||||||
}
|
|
||||||
else if ! client_wants_body {
|
|
||||||
response.status_code (StatusCode::NoContent);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
response.content_length = Some (content_length);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some (body) = body {
|
if let Some (body) = body {
|
||||||
response.body (body);
|
response.body (body);
|
||||||
|
@ -210,10 +207,70 @@ async fn serve_file (
|
||||||
Ok (response)
|
Ok (response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive (Debug)]
|
||||||
|
struct ServeFileInput <'a> {
|
||||||
|
if_none_match: Option <&'a [u8]>,
|
||||||
|
actual_etag: Option <Vec <u8>>,
|
||||||
|
client_wants_body: bool,
|
||||||
|
range_requested: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive (Debug, PartialEq)]
|
||||||
|
struct ServeFileOutput {
|
||||||
|
status_code: StatusCode,
|
||||||
|
should_send_body: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve_file_decision (input: &ServeFileInput) -> ServeFileOutput
|
||||||
|
{
|
||||||
|
match (&input.if_none_match, &input.actual_etag) {
|
||||||
|
(Some (if_none_match), Some (actual_etag)) => if &actual_etag == if_none_match {
|
||||||
|
return ServeFileOutput {
|
||||||
|
status_code: StatusCode::NotModified,
|
||||||
|
should_send_body: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! input.client_wants_body {
|
||||||
|
return ServeFileOutput {
|
||||||
|
status_code: StatusCode::NoContent,
|
||||||
|
should_send_body: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.range_requested {
|
||||||
|
return ServeFileOutput {
|
||||||
|
status_code: StatusCode::PartialContent,
|
||||||
|
should_send_body: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::Ok,
|
||||||
|
should_send_body: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_file_etag (f: &File) -> Option <String>
|
async fn get_file_etag (f: &File) -> Option <String>
|
||||||
{
|
{
|
||||||
let md = f.metadata ().await;
|
let md = f.metadata ().await.ok ()?;
|
||||||
None
|
|
||||||
|
#[derive (Serialize)]
|
||||||
|
struct CacheBreaker {
|
||||||
|
len: u64,
|
||||||
|
mtime: std::time::SystemTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
let buf = rmp_serde::to_vec (&CacheBreaker {
|
||||||
|
len: md.len (),
|
||||||
|
mtime: md.modified ().ok ()?,
|
||||||
|
}).ok ()?;
|
||||||
|
|
||||||
|
let hash = blake3::hash (&buf);
|
||||||
|
|
||||||
|
Some (hash.to_hex ().to_string ())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stream_file (
|
async fn stream_file (
|
||||||
|
@ -326,7 +383,7 @@ pub async fn serve_all (
|
||||||
file,
|
file,
|
||||||
send_body,
|
send_body,
|
||||||
range,
|
range,
|
||||||
}) => serve_file (file.into_inner (), send_body, range, headers.get ("if-none-match")).await?,
|
}) => serve_file (file.into_inner (), send_body, range, headers.get ("if-none-match").map (|v| &v[..])).await?,
|
||||||
MarkdownErr (e) => {
|
MarkdownErr (e) => {
|
||||||
#[cfg (feature = "markdown")]
|
#[cfg (feature = "markdown")]
|
||||||
{
|
{
|
||||||
|
|
|
@ -163,3 +163,273 @@ fn file_server () {
|
||||||
fn parse_uri () {
|
fn parse_uri () {
|
||||||
assert! (http::Uri::from_maybe_shared ("/").is_ok ());
|
assert! (http::Uri::from_maybe_shared ("/").is_ok ());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serve_file_decision () {
|
||||||
|
use ptth_core::http_serde::StatusCode;
|
||||||
|
use super::{
|
||||||
|
ServeFileInput,
|
||||||
|
ServeFileOutput,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (input, expected) in vec! [
|
||||||
|
// Regular HEAD requests
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: None,
|
||||||
|
actual_etag: None,
|
||||||
|
client_wants_body: false,
|
||||||
|
range_requested: false,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::NoContent,
|
||||||
|
should_send_body: false,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: None,
|
||||||
|
actual_etag: None,
|
||||||
|
client_wants_body: false,
|
||||||
|
range_requested: true,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::NoContent,
|
||||||
|
should_send_body: false,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
// Regular GET requests
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: None,
|
||||||
|
actual_etag: None,
|
||||||
|
client_wants_body: true,
|
||||||
|
range_requested: false,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::Ok,
|
||||||
|
should_send_body: true,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: None,
|
||||||
|
actual_etag: None,
|
||||||
|
client_wants_body: true,
|
||||||
|
range_requested: true,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::PartialContent,
|
||||||
|
should_send_body: true,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
// HEAD requests where we pull a valid etag from the FS
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: None,
|
||||||
|
actual_etag: Some (b"bogus_2".to_vec ()),
|
||||||
|
client_wants_body: false,
|
||||||
|
range_requested: false,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::NoContent,
|
||||||
|
should_send_body: false,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: None,
|
||||||
|
actual_etag: Some (b"bogus_2".to_vec ()),
|
||||||
|
client_wants_body: false,
|
||||||
|
range_requested: true,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::NoContent,
|
||||||
|
should_send_body: false,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: None,
|
||||||
|
actual_etag: Some (b"bogus_2".to_vec ()),
|
||||||
|
client_wants_body: true,
|
||||||
|
range_requested: false,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::Ok,
|
||||||
|
should_send_body: true,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: None,
|
||||||
|
actual_etag: Some (b"bogus_2".to_vec ()),
|
||||||
|
client_wants_body: true,
|
||||||
|
range_requested: true,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::PartialContent,
|
||||||
|
should_send_body: true,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
// Client has an expected ETag but we can't pull the real one for
|
||||||
|
// some reason
|
||||||
|
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: Some (b"bogus_1"),
|
||||||
|
actual_etag: None,
|
||||||
|
client_wants_body: false,
|
||||||
|
range_requested: false,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::NoContent,
|
||||||
|
should_send_body: false,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: Some (b"bogus_1"),
|
||||||
|
actual_etag: None,
|
||||||
|
client_wants_body: false,
|
||||||
|
range_requested: true,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::NoContent,
|
||||||
|
should_send_body: false,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: Some (b"bogus_1"),
|
||||||
|
actual_etag: None,
|
||||||
|
client_wants_body: true,
|
||||||
|
range_requested: false,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::Ok,
|
||||||
|
should_send_body: true,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: Some (b"bogus_1"),
|
||||||
|
actual_etag: None,
|
||||||
|
client_wants_body: true,
|
||||||
|
range_requested: true,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::PartialContent,
|
||||||
|
should_send_body: true,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
// File changed on disk since the client last saw it
|
||||||
|
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: Some (b"bogus_1"),
|
||||||
|
actual_etag: Some (b"bogus_2".to_vec ()),
|
||||||
|
client_wants_body: false,
|
||||||
|
range_requested: false,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::NoContent,
|
||||||
|
should_send_body: false,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: Some (b"bogus_1"),
|
||||||
|
actual_etag: Some (b"bogus_2".to_vec ()),
|
||||||
|
client_wants_body: false,
|
||||||
|
range_requested: true,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::NoContent,
|
||||||
|
should_send_body: false,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: Some (b"bogus_1"),
|
||||||
|
actual_etag: Some (b"bogus_2".to_vec ()),
|
||||||
|
client_wants_body: true,
|
||||||
|
range_requested: false,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::Ok,
|
||||||
|
should_send_body: true,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: Some (b"bogus_1"),
|
||||||
|
actual_etag: Some (b"bogus_2".to_vec ()),
|
||||||
|
client_wants_body: true,
|
||||||
|
range_requested: true,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::PartialContent,
|
||||||
|
should_send_body: true,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
// The ETags match, and we can tell the client to use their cache
|
||||||
|
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: Some (b"bogus_3"),
|
||||||
|
actual_etag: Some (b"bogus_3".to_vec ()),
|
||||||
|
client_wants_body: false,
|
||||||
|
range_requested: false,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::NotModified,
|
||||||
|
should_send_body: false,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: Some (b"bogus_3"),
|
||||||
|
actual_etag: Some (b"bogus_3".to_vec ()),
|
||||||
|
client_wants_body: false,
|
||||||
|
range_requested: true,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::NotModified,
|
||||||
|
should_send_body: false,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: Some (b"bogus_3"),
|
||||||
|
actual_etag: Some (b"bogus_3".to_vec ()),
|
||||||
|
client_wants_body: true,
|
||||||
|
range_requested: false,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::NotModified,
|
||||||
|
should_send_body: false,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ServeFileInput {
|
||||||
|
if_none_match: Some (b"bogus_3"),
|
||||||
|
actual_etag: Some (b"bogus_3".to_vec ()),
|
||||||
|
client_wants_body: true,
|
||||||
|
range_requested: true,
|
||||||
|
},
|
||||||
|
ServeFileOutput {
|
||||||
|
status_code: StatusCode::NotModified,
|
||||||
|
should_send_body: false,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
].into_iter () {
|
||||||
|
let actual = super::serve_file_decision (&input);
|
||||||
|
assert_eq! (actual, expected, "{:?}", input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue