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
	
	 _
						_