proof of concept for private browser cache based on etag and if-none-match

main
_ 2021-04-03 17:26:53 +00:00
parent 1df0f0f677
commit d9669a7073
2 changed files with 360 additions and 33 deletions

View File

@ -123,7 +123,7 @@ async fn serve_file (
mut f: File,
client_wants_body: bool,
range: range::ValidParsed,
if_none_match: Option <&Vec <u8>>,
if_none_match: Option <&[u8]>,
)
-> 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
// hash binary as a header, which is not valid.
let etag = get_file_etag (&f).await.map (String::into_bytes);
let client_cache_hit = match &etag {
let actual_etag = get_file_etag (&f).await.map (String::into_bytes);
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,
Some (actual) => match &if_none_match {
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);
info! ("Serving range {}-{}", range.start, range.end);
let content_length = range.end - range.start;
let seek = SeekFrom::Start (range.start);
f.seek (seek).await?;
let body = if decision.should_send_body {
let seek = SeekFrom::Start (range.start);
f.seek (seek).await?;
if body.is_some () {
let (tx, rx) = channel (1);
tokio::spawn (async move {
stream_file (f, content_length, tx).await;
});
Some (rx)
}
else {
None
};
let mut response = Response::default ();
response.status_code (decision.status_code);
// The cache-related headers in HTTP have bad names. See here:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
// The intended semantics I'm using are:
@ -179,29 +189,16 @@ async fn serve_file (
// consider it stale.
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 (String::from ("accept-ranges"), b"bytes".to_vec ());
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 ());
}
else {
response.status_code (StatusCode::Ok);
response.header (String::from ("content-length"), range.end.to_string ().into_bytes ());
}
if client_cache_hit {
response.status_code (StatusCode::NotModified);
}
else if ! client_wants_body {
response.status_code (StatusCode::NoContent);
}
else {
response.content_length = Some (content_length);
}
response.content_length = Some (content_length);
if let Some (body) = body {
response.body (body);
@ -210,10 +207,70 @@ async fn serve_file (
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>
{
let md = f.metadata ().await;
None
let md = f.metadata ().await.ok ()?;
#[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 (
@ -326,7 +383,7 @@ pub async fn serve_all (
file,
send_body,
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) => {
#[cfg (feature = "markdown")]
{

View File

@ -163,3 +163,273 @@ fn file_server () {
fn parse_uri () {
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);
}
}