@@ -5,16 +5,66 @@ use std::hash::{Hash, Hasher};
55use std:: io:: { self , Write as _} ;
66use std:: path:: { self , PathBuf } ;
77use std:: string:: String ;
8- use std:: sync:: LazyLock ;
8+ use std:: sync:: { Arc , LazyLock } ;
9+ use std:: task:: { Context , Poll } ;
910
11+ use axum:: body:: Body ;
12+ use axum:: http:: header:: { ACCEPT_ENCODING , CONTENT_ENCODING , CONTENT_TYPE } ;
13+ use axum:: http:: { HeaderMap , HeaderValue , Request , Response , StatusCode } ;
14+ use axum:: response:: IntoResponse ;
1015use bytes:: Bytes ;
16+ use futures:: future:: BoxFuture ;
1117use futures:: stream:: { BoxStream , StreamExt } ;
12- use rostra_util_error:: WhateverResult ;
13- use snafu:: { OptionExt as _, ResultExt as _} ;
18+ use snafu:: { OptionExt as _, ResultExt as _, Snafu } ;
1419use tokio_stream:: wrappers:: ReadDirStream ;
20+ use tower_service:: Service ;
1521use tracing:: { debug, info} ;
1622
17- use crate :: LOG_TARGET ;
23+ const LOG_TARGET : & str = "axum::dpc" ;
24+
25+ pub type BoxedError = Box < dyn std:: error:: Error + Send + Sync + ' static > ;
26+ pub type BoxedErrorResult < T > = std:: result:: Result < T , BoxedError > ;
27+
28+ /// Handles ETag-based conditional requests
29+ ///
30+ /// Takes the request headers, the ETag value, and response headers to modify.
31+ /// If the client already has the current version (based on If-None-Match
32+ /// header), returns a 304 Not Modified response.
33+ ///
34+ /// Returns:
35+ /// - Some(Response) if a 304 Not Modified should be returned
36+ /// - None if processing should continue normally
37+ pub fn handle_etag (
38+ req_headers : & axum:: http:: HeaderMap ,
39+ etag : & str ,
40+ resp_headers : & mut axum:: http:: HeaderMap ,
41+ ) -> Option < axum:: response:: Response > {
42+ use axum:: http:: StatusCode ;
43+ use axum:: http:: header:: { ETAG , IF_NONE_MATCH } ;
44+ use axum:: response:: IntoResponse ;
45+
46+ // Add ETag header to response
47+ if let Ok ( etag_value) = axum:: http:: HeaderValue :: from_str ( etag) {
48+ resp_headers. insert ( ETAG , etag_value) ;
49+ }
50+
51+ // Check if client already has this version
52+ if let Some ( if_none_match) = req_headers. get ( IF_NONE_MATCH ) {
53+ if if_none_match. as_bytes ( ) == etag. as_bytes ( ) {
54+ return Some ( ( StatusCode :: NOT_MODIFIED , resp_headers. clone ( ) ) . into_response ( ) ) ;
55+ }
56+ }
57+
58+ None
59+ }
60+
61+ #[ derive( Debug , Snafu ) ]
62+ pub enum LoadError {
63+ #[ snafu( display( "IO error for {}" , path. display( ) ) ) ]
64+ IO { source : io:: Error , path : PathBuf } ,
65+ #[ snafu( display( "Invalid path: {}" , path. display( ) ) ) ]
66+ InvalidPath { path : PathBuf } ,
67+ }
1868
1969/// Pre-loaded and pre-compressed static assets. This
2070/// is used to serve static assets from the build directory without reading from
@@ -23,17 +73,15 @@ use crate::LOG_TARGET;
2373pub struct StaticAssets ( HashMap < String , StaticAsset > ) ;
2474
2575impl StaticAssets {
26- pub async fn load ( root_dir : & path:: Path ) -> WhateverResult < Self > {
76+ pub async fn load ( root_dir : & path:: Path ) -> Result < Self , LoadError > {
2777 info ! ( target: LOG_TARGET , dir=%root_dir. display( ) , "Loading assets" ) ;
2878 let mut cache = HashMap :: default ( ) ;
2979
30- let assets: Vec < WhateverResult < ( String , StaticAsset ) > > =
80+ let assets: Vec < Result < ( String , StaticAsset ) , LoadError > > =
3181 read_dir_stream ( root_dir. to_owned ( ) )
3282 . map ( |file| async move {
33- let path = file. whatever_context ( "Failed to read file metadata" ) ?;
34- // let filename = path.file_name().and_then(|n| n.to_str());
83+ let path = file. with_context ( |_e| IOSnafu { path : root_dir. to_owned ( ) } ) ?;
3584 let filename = path. strip_prefix ( root_dir) . expect ( "Can't fail" ) . to_str ( ) ;
36- // .and_then(|n| n.to_str());
3785 let ext = path. extension ( ) . and_then ( |p| p. to_str ( ) ) ;
3886
3987 let ( filename, ext) = match ( filename, ext) {
@@ -46,12 +94,12 @@ impl StaticAssets {
4694 . into_os_string ( )
4795 . into_string ( )
4896 . ok ( )
49- . whatever_context ( "Invalid path" ) ?;
97+ . with_context ( || InvalidPathSnafu { path : path . to_owned ( ) } ) ?;
5098 tracing:: debug!( path = %stored_path, "Loading asset" ) ;
5199
52100 let raw = tokio:: fs:: read ( & path)
53101 . await
54- . whatever_context ( "Could not read file" ) ?;
102+ . with_context ( |_e| IOSnafu { path : path . to_owned ( ) } ) ?;
55103
56104 let compressed = match ext {
57105 "css" | "js" | "svg" | "json" => Some ( compress_data ( & raw ) ) ,
@@ -87,7 +135,7 @@ impl StaticAssets {
87135 } )
88136 . buffered ( 32 )
89137 . filter_map (
90- |res_opt : WhateverResult < std:: option:: Option < ( String , StaticAsset ) > > | {
138+ |res_opt : Result < std:: option:: Option < ( String , StaticAsset ) > , LoadError > | {
91139 ready ( res_opt. transpose ( ) )
92140 } ,
93141 )
@@ -102,7 +150,7 @@ impl StaticAssets {
102150 for ( key, asset) in & cache {
103151 tracing:: debug!( %key, path = %asset. path, "Asset loaded" ) ;
104152 }
105- tracing:: debug!( len = cache. len( ) , "Loaded assets" ) ;
153+ tracing:: debug!( target : LOG_TARGET , len = cache. len( ) , "Loaded assets" ) ;
106154
107155 Ok ( Self ( cache) )
108156 }
@@ -203,3 +251,86 @@ fn read_dir_stream(dir: PathBuf) -> BoxStream<'static, io::Result<PathBuf>> {
203251 }
204252 . boxed ( )
205253}
254+
255+ #[ derive( Clone ) ]
256+ pub struct StaticAssetService {
257+ assets : Arc < StaticAssets > ,
258+ }
259+
260+ impl StaticAssetService {
261+ pub fn new ( assets : Arc < StaticAssets > ) -> Self {
262+ Self { assets }
263+ }
264+
265+ fn handle_request ( & self , req : Request < Body > ) -> Response < Body > {
266+ let path = req. uri ( ) . path ( ) . trim_start_matches ( '/' ) ;
267+ let Some ( asset) = self . assets . get ( path) else {
268+ dbg ! ( "NOT FOUND" , path) ;
269+ return ( StatusCode :: NOT_FOUND , Body :: empty ( ) ) . into_response ( ) ;
270+ } ;
271+
272+ let req_headers = req. headers ( ) ;
273+ let mut resp_headers = HeaderMap :: new ( ) ;
274+
275+ // Set content type
276+ resp_headers. insert (
277+ CONTENT_TYPE ,
278+ HeaderValue :: from_static ( asset. content_type ( ) . unwrap_or ( "application/octet-stream" ) ) ,
279+ ) ;
280+
281+ // Handle ETag and conditional request
282+ let etag = asset. etag . clone ( ) ;
283+ if let Some ( response) = crate :: handle_etag ( req_headers, & etag, & mut resp_headers) {
284+ return response;
285+ }
286+
287+ let accepts_brotli = req_headers
288+ . get_all ( ACCEPT_ENCODING )
289+ . into_iter ( )
290+ . any ( |encodings| {
291+ let Ok ( str) = encodings. to_str ( ) else {
292+ return false ;
293+ } ;
294+
295+ str. split ( ',' ) . any ( |s| s. trim ( ) == "br" )
296+ } ) ;
297+
298+ let content = match ( accepts_brotli, asset. compressed . as_ref ( ) ) {
299+ ( true , Some ( compressed) ) => {
300+ resp_headers. insert ( CONTENT_ENCODING , HeaderValue :: from_static ( "br" ) ) ;
301+ compressed. clone ( )
302+ }
303+ _ => asset. raw . clone ( ) ,
304+ } ;
305+
306+ ( resp_headers, content) . into_response ( )
307+ }
308+ }
309+
310+ impl < B > Service < Request < B > > for StaticAssetService
311+ where
312+ B : Send + ' static ,
313+ {
314+ type Response = Response < Body > ;
315+ type Error = std:: convert:: Infallible ;
316+ type Future = BoxFuture < ' static , Result < Self :: Response , Self :: Error > > ;
317+
318+ fn poll_ready ( & mut self , _cx : & mut Context < ' _ > ) -> Poll < Result < ( ) , std:: convert:: Infallible > > {
319+ Poll :: Ready ( Ok ( ( ) ) )
320+ }
321+
322+ fn call (
323+ & mut self ,
324+ req : Request < B > ,
325+ ) -> BoxFuture < ' static , Result < Response < Body > , std:: convert:: Infallible > > {
326+ let service = self . clone ( ) ;
327+ let uri = req. uri ( ) . clone ( ) ;
328+
329+ Box :: pin ( async move {
330+ // Convert to a Request<Body> by extracting just the URI
331+ let new_req = Request :: builder ( ) . uri ( uri) . body ( Body :: empty ( ) ) . unwrap ( ) ;
332+
333+ Ok ( service. handle_request ( new_req) )
334+ } )
335+ }
336+ }
0 commit comments