@@ -253,6 +253,17 @@ impl AdminApp {
253253 header_value ( headers, "x-fluxheim-cache-limit" )
254254 . or_else ( || query_param ( query, "limit" ) ) ,
255255 ) ,
256+ ( "POST" , "/_fluxheim/cache/purge-prefix" ) => self . cache_purge_prefix_response (
257+ header_value ( headers, "x-fluxheim-cache-vhost" )
258+ . or_else ( || query_param ( query, "vhost" ) ) ,
259+ header_value ( headers, "x-fluxheim-cache-route" )
260+ . or_else ( || query_param ( query, "route" ) ) ,
261+ header_value ( headers, "x-fluxheim-cache-path-prefix" )
262+ . or_else ( || query_param ( query, "path_prefix" ) )
263+ . or_else ( || query_param ( query, "prefix" ) ) ,
264+ header_value ( headers, "x-fluxheim-cache-limit" )
265+ . or_else ( || query_param ( query, "limit" ) ) ,
266+ ) ,
256267 ( "POST" , "/_fluxheim/snapshot" ) => {
257268 self . create_snapshot_response ( header_value ( headers, "x-fluxheim-message" ) )
258269 }
@@ -276,6 +287,7 @@ impl AdminApp {
276287 | "/_fluxheim/cache/purge"
277288 | "/_fluxheim/cache/purge-bulk"
278289 | "/_fluxheim/cache/purge-index"
290+ | "/_fluxheim/cache/purge-prefix"
279291 | "/_fluxheim/snapshot"
280292 | "/_fluxheim/rollback"
281293 | "/_fluxheim/reload" ,
@@ -664,6 +676,65 @@ impl AdminApp {
664676 }
665677 }
666678
679+ #[ cfg( feature = "cache" ) ]
680+ fn cache_purge_prefix_response (
681+ & self ,
682+ vhost : Option < & str > ,
683+ route : Option < & str > ,
684+ path_prefix : Option < & str > ,
685+ limit : Option < & str > ,
686+ ) -> AdminResponse {
687+ let Some ( vhost) = vhost. map ( str:: trim) . filter ( |vhost| !vhost. is_empty ( ) ) else {
688+ return error_response (
689+ StatusCode :: BAD_REQUEST ,
690+ "cache path-prefix purge vhost is required" ,
691+ ) ;
692+ } ;
693+ let path_prefix = match validated_cache_purge_path_prefix ( path_prefix) {
694+ Ok ( path_prefix) => path_prefix,
695+ Err ( message) => return error_response ( StatusCode :: BAD_REQUEST , message) ,
696+ } ;
697+ let limit = match validated_cache_indexed_purge_limit ( limit) {
698+ Ok ( limit) => limit,
699+ Err ( message) => return error_response ( StatusCode :: BAD_REQUEST , message) ,
700+ } ;
701+
702+ match self . proxy . purge_indexed_image_cache_path_prefix (
703+ crate :: proxy:: CacheIndexedPathPrefixPurgeRequest {
704+ vhost,
705+ route : route. filter ( |route| !route. trim ( ) . is_empty ( ) ) ,
706+ path_prefix,
707+ limit,
708+ } ,
709+ ) {
710+ Ok ( result) => {
711+ let body = format ! (
712+ r#"{{"status":"ok","matched":{},"purged":{},"truncated":{},"vhost":"{}","route":{},"path_prefix":"{}","memory_matched":{},"memory_purged":{},"memory_truncated":{},"disk_matched":{},"disk_purged":{},"disk_truncated":{}}}"# ,
713+ result. matched( ) ,
714+ result. purged( ) ,
715+ result. truncated( ) ,
716+ json_escape( & result. vhost) ,
717+ cache_route_json( result. route. as_deref( ) ) ,
718+ json_escape( path_prefix) ,
719+ result. memory_matched,
720+ result. memory_purged,
721+ result. memory_truncated,
722+ result. disk_matched,
723+ result. disk_purged,
724+ result. disk_truncated
725+ ) ;
726+ json_response ( StatusCode :: OK , body. as_bytes ( ) )
727+ }
728+ Err ( error) if error. kind ( ) == std:: io:: ErrorKind :: NotFound => {
729+ error_response ( StatusCode :: NOT_FOUND , & error. to_string ( ) )
730+ }
731+ Err ( error) if error. kind ( ) == std:: io:: ErrorKind :: InvalidInput => {
732+ error_response ( StatusCode :: BAD_REQUEST , & error. to_string ( ) )
733+ }
734+ Err ( error) => error_response ( StatusCode :: INTERNAL_SERVER_ERROR , & error. to_string ( ) ) ,
735+ }
736+ }
737+
667738 #[ cfg( not( feature = "cache" ) ) ]
668739 fn cache_purge_response (
669740 & self ,
@@ -700,6 +771,17 @@ impl AdminApp {
700771 error_response ( StatusCode :: BAD_REQUEST , "cache support is not compiled in" )
701772 }
702773
774+ #[ cfg( not( feature = "cache" ) ) ]
775+ fn cache_purge_prefix_response (
776+ & self ,
777+ _vhost : Option < & str > ,
778+ _route : Option < & str > ,
779+ _path_prefix : Option < & str > ,
780+ _limit : Option < & str > ,
781+ ) -> AdminResponse {
782+ error_response ( StatusCode :: BAD_REQUEST , "cache support is not compiled in" )
783+ }
784+
703785 fn apply_snapshot (
704786 & self ,
705787 snapshot : & ConfigSnapshot ,
@@ -1619,6 +1701,18 @@ fn validate_cache_purge_path_value(path: &str) -> Result<(), &'static str> {
16191701 Ok ( ( ) )
16201702}
16211703
1704+ #[ cfg( feature = "cache" ) ]
1705+ fn validated_cache_purge_path_prefix ( prefix : Option < & str > ) -> Result < & str , & ' static str > {
1706+ let Some ( prefix) = prefix. map ( str:: trim) . filter ( |prefix| !prefix. is_empty ( ) ) else {
1707+ return Err ( "cache path-prefix purge prefix is required and must start with /" ) ;
1708+ } ;
1709+ validate_cache_purge_path_value ( prefix) ?;
1710+ if prefix == "/" {
1711+ return Err ( "cache path-prefix purge prefix must not be /; use scope purge instead" ) ;
1712+ }
1713+ Ok ( prefix)
1714+ }
1715+
16221716#[ cfg( feature = "cache" ) ]
16231717fn path_contains_traversal_segment ( path : & str ) -> bool {
16241718 path. split ( '/' ) . any ( |segment| matches ! ( segment, "." | ".." ) )
@@ -2007,6 +2101,62 @@ mod tests {
20072101 assert ! ( body. contains( r#""truncated":false"# ) ) ;
20082102 }
20092103
2104+ #[ cfg( feature = "cache" ) ]
2105+ #[ test]
2106+ fn cache_purge_prefix_endpoint_accepts_path_prefix ( ) {
2107+ let config = Config {
2108+ vhosts : vec ! [ VhostConfig {
2109+ name: "cached" . to_owned( ) ,
2110+ hosts: vec![ "cached.example" . to_owned( ) ] ,
2111+ max_request_body_bytes: None ,
2112+ acme_challenge: crate :: config:: VhostAcmeChallengeConfig :: default ( ) ,
2113+ redirect: crate :: config:: VhostRedirectConfig :: default ( ) ,
2114+ tls: crate :: config:: VhostTlsConfig :: default ( ) ,
2115+ proxy: ProxyConfig :: default ( ) ,
2116+ cache: CacheConfig {
2117+ enabled: true ,
2118+ memory: crate :: config:: CacheMemoryConfig {
2119+ enabled: true ,
2120+ max_size_bytes: ByteSize :: from_bytes( 2048 ) ,
2121+ } ,
2122+ max_object_bytes: ByteSize :: from_bytes( 512 ) ,
2123+ ..CacheConfig :: default ( )
2124+ } ,
2125+ headers: crate :: config:: VhostHeaderPolicyConfig :: default ( ) ,
2126+ web: WebConfig :: default ( ) ,
2127+ routes: Vec :: new( ) ,
2128+ } ] ,
2129+ ..Config :: default ( )
2130+ } ;
2131+ let app = app_with_config ( config) ;
2132+
2133+ let response = app. handle (
2134+ "POST" ,
2135+ "/_fluxheim/cache/purge-prefix" ,
2136+ Some ( "vhost=cached&path_prefix=/assets/&limit=16" ) ,
2137+ & auth_headers ( ) ,
2138+ ) ;
2139+
2140+ assert_eq ! ( response. status, StatusCode :: OK ) ;
2141+ let body = String :: from_utf8 ( response. body ) . unwrap ( ) ;
2142+ assert ! ( body. contains( r#""vhost":"cached""# ) ) ;
2143+ assert ! ( body. contains( r#""path_prefix":"/assets/""# ) ) ;
2144+ assert ! ( body. contains( r#""matched":0"# ) ) ;
2145+ }
2146+
2147+ #[ cfg( feature = "cache" ) ]
2148+ #[ test]
2149+ fn cache_purge_prefix_endpoint_rejects_root_prefix ( ) {
2150+ let response = app ( ) . handle (
2151+ "POST" ,
2152+ "/_fluxheim/cache/purge-prefix" ,
2153+ Some ( "vhost=cached&path_prefix=/" ) ,
2154+ & auth_headers ( ) ,
2155+ ) ;
2156+
2157+ assert_eq ! ( response. status, StatusCode :: BAD_REQUEST ) ;
2158+ }
2159+
20102160 #[ cfg( feature = "cache" ) ]
20112161 #[ test]
20122162 fn cache_purge_endpoint_rejects_missing_identity ( ) {
0 commit comments