diff --git a/crates/ptth_server/src/file_server/mod.rs b/crates/ptth_server/src/file_server/mod.rs index a94cb37..bfe998d 100644 --- a/crates/ptth_server/src/file_server/mod.rs +++ b/crates/ptth_server/src/file_server/mod.rs @@ -123,7 +123,7 @@ async fn serve_file ( mut f: File, client_wants_body: bool, range: range::ValidParsed, - if_none_match: Option <&Vec >, + if_none_match: Option <&[u8]>, ) -> Result { @@ -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?; - - if body.is_some () { + let body = if decision.should_send_body { + let seek = SeekFrom::Start (range.start); + f.seek (seek).await?; + + 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 >, + 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 { - 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")] { diff --git a/crates/ptth_server/src/file_server/tests.rs b/crates/ptth_server/src/file_server/tests.rs index 503a725..90352a3 100644 --- a/crates/ptth_server/src/file_server/tests.rs +++ b/crates/ptth_server/src/file_server/tests.rs @@ -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); + } +}