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,
|
||||
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 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);
|
||||
}
|
||||
|
||||
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")]
|
||||
{
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue