@@ -264,6 +264,18 @@ impl AdminApp {
264264 header_value ( headers, "x-fluxheim-cache-limit" )
265265 . or_else ( || query_param ( query, "limit" ) ) ,
266266 ) ,
267+ ( "POST" , "/_fluxheim/cache/purge-wildcard" ) => self . cache_purge_wildcard_response (
268+ header_value ( headers, "x-fluxheim-cache-vhost" )
269+ . or_else ( || query_param ( query, "vhost" ) ) ,
270+ header_value ( headers, "x-fluxheim-cache-route" )
271+ . or_else ( || query_param ( query, "route" ) ) ,
272+ header_value ( headers, "x-fluxheim-cache-path-pattern" )
273+ . or_else ( || query_param ( query, "path_pattern" ) )
274+ . or_else ( || query_param ( query, "pattern" ) )
275+ . or_else ( || query_param ( query, "wildcard" ) ) ,
276+ header_value ( headers, "x-fluxheim-cache-limit" )
277+ . or_else ( || query_param ( query, "limit" ) ) ,
278+ ) ,
267279 ( "POST" , "/_fluxheim/snapshot" ) => {
268280 self . create_snapshot_response ( header_value ( headers, "x-fluxheim-message" ) )
269281 }
@@ -288,6 +300,7 @@ impl AdminApp {
288300 | "/_fluxheim/cache/purge-bulk"
289301 | "/_fluxheim/cache/purge-index"
290302 | "/_fluxheim/cache/purge-prefix"
303+ | "/_fluxheim/cache/purge-wildcard"
291304 | "/_fluxheim/snapshot"
292305 | "/_fluxheim/rollback"
293306 | "/_fluxheim/reload" ,
@@ -735,6 +748,65 @@ impl AdminApp {
735748 }
736749 }
737750
751+ #[ cfg( feature = "cache" ) ]
752+ fn cache_purge_wildcard_response (
753+ & self ,
754+ vhost : Option < & str > ,
755+ route : Option < & str > ,
756+ path_pattern : Option < & str > ,
757+ limit : Option < & str > ,
758+ ) -> AdminResponse {
759+ let Some ( vhost) = vhost. map ( str:: trim) . filter ( |vhost| !vhost. is_empty ( ) ) else {
760+ return error_response (
761+ StatusCode :: BAD_REQUEST ,
762+ "cache wildcard purge vhost is required" ,
763+ ) ;
764+ } ;
765+ let path_pattern = match validated_cache_purge_path_pattern ( path_pattern) {
766+ Ok ( path_pattern) => path_pattern,
767+ Err ( message) => return error_response ( StatusCode :: BAD_REQUEST , message) ,
768+ } ;
769+ let limit = match validated_cache_indexed_purge_limit ( limit) {
770+ Ok ( limit) => limit,
771+ Err ( message) => return error_response ( StatusCode :: BAD_REQUEST , message) ,
772+ } ;
773+
774+ match self . proxy . purge_indexed_image_cache_path_pattern (
775+ crate :: proxy:: CacheIndexedPathPatternPurgeRequest {
776+ vhost,
777+ route : route. filter ( |route| !route. trim ( ) . is_empty ( ) ) ,
778+ path_pattern,
779+ limit,
780+ } ,
781+ ) {
782+ Ok ( result) => {
783+ let body = format ! (
784+ r#"{{"status":"ok","matched":{},"purged":{},"truncated":{},"vhost":"{}","route":{},"path_pattern":"{}","memory_matched":{},"memory_purged":{},"memory_truncated":{},"disk_matched":{},"disk_purged":{},"disk_truncated":{}}}"# ,
785+ result. matched( ) ,
786+ result. purged( ) ,
787+ result. truncated( ) ,
788+ json_escape( & result. vhost) ,
789+ cache_route_json( result. route. as_deref( ) ) ,
790+ json_escape( path_pattern) ,
791+ result. memory_matched,
792+ result. memory_purged,
793+ result. memory_truncated,
794+ result. disk_matched,
795+ result. disk_purged,
796+ result. disk_truncated
797+ ) ;
798+ json_response ( StatusCode :: OK , body. as_bytes ( ) )
799+ }
800+ Err ( error) if error. kind ( ) == std:: io:: ErrorKind :: NotFound => {
801+ error_response ( StatusCode :: NOT_FOUND , & error. to_string ( ) )
802+ }
803+ Err ( error) if error. kind ( ) == std:: io:: ErrorKind :: InvalidInput => {
804+ error_response ( StatusCode :: BAD_REQUEST , & error. to_string ( ) )
805+ }
806+ Err ( error) => error_response ( StatusCode :: INTERNAL_SERVER_ERROR , & error. to_string ( ) ) ,
807+ }
808+ }
809+
738810 #[ cfg( not( feature = "cache" ) ) ]
739811 fn cache_purge_response (
740812 & self ,
@@ -782,6 +854,17 @@ impl AdminApp {
782854 error_response ( StatusCode :: BAD_REQUEST , "cache support is not compiled in" )
783855 }
784856
857+ #[ cfg( not( feature = "cache" ) ) ]
858+ fn cache_purge_wildcard_response (
859+ & self ,
860+ _vhost : Option < & str > ,
861+ _route : Option < & str > ,
862+ _path_pattern : Option < & str > ,
863+ _limit : Option < & str > ,
864+ ) -> AdminResponse {
865+ error_response ( StatusCode :: BAD_REQUEST , "cache support is not compiled in" )
866+ }
867+
785868 fn apply_snapshot (
786869 & self ,
787870 snapshot : & ConfigSnapshot ,
@@ -1713,6 +1796,28 @@ fn validated_cache_purge_path_prefix(prefix: Option<&str>) -> Result<&str, &'sta
17131796 Ok ( prefix)
17141797}
17151798
1799+ #[ cfg( feature = "cache" ) ]
1800+ fn validated_cache_purge_path_pattern ( pattern : Option < & str > ) -> Result < & str , & ' static str > {
1801+ let Some ( pattern) = pattern. map ( str:: trim) . filter ( |pattern| !pattern. is_empty ( ) ) else {
1802+ return Err ( "cache wildcard purge pattern is required and must start with /" ) ;
1803+ } ;
1804+ validate_cache_purge_path_value ( pattern) ?;
1805+ if !pattern. contains ( '*' ) {
1806+ return Err ( "cache wildcard purge pattern must contain *" ) ;
1807+ }
1808+ if pattern
1809+ . chars ( )
1810+ . filter ( |character| * character != '*' )
1811+ . collect :: < String > ( )
1812+ == "/"
1813+ {
1814+ return Err (
1815+ "cache wildcard purge pattern must not target the whole cache; use scope purge instead" ,
1816+ ) ;
1817+ }
1818+ Ok ( pattern)
1819+ }
1820+
17161821#[ cfg( feature = "cache" ) ]
17171822fn path_contains_traversal_segment ( path : & str ) -> bool {
17181823 path. split ( '/' ) . any ( |segment| matches ! ( segment, "." | ".." ) )
@@ -2157,6 +2262,67 @@ mod tests {
21572262 assert_eq ! ( response. status, StatusCode :: BAD_REQUEST ) ;
21582263 }
21592264
2265+ #[ cfg( feature = "cache" ) ]
2266+ #[ test]
2267+ fn cache_purge_wildcard_endpoint_accepts_path_pattern ( ) {
2268+ let config = Config {
2269+ vhosts : vec ! [ VhostConfig {
2270+ name: "cached" . to_owned( ) ,
2271+ hosts: vec![ "cached.example" . to_owned( ) ] ,
2272+ max_request_body_bytes: None ,
2273+ acme_challenge: crate :: config:: VhostAcmeChallengeConfig :: default ( ) ,
2274+ redirect: crate :: config:: VhostRedirectConfig :: default ( ) ,
2275+ tls: crate :: config:: VhostTlsConfig :: default ( ) ,
2276+ proxy: ProxyConfig :: default ( ) ,
2277+ cache: CacheConfig {
2278+ enabled: true ,
2279+ memory: crate :: config:: CacheMemoryConfig {
2280+ enabled: true ,
2281+ max_size_bytes: ByteSize :: from_bytes( 2048 ) ,
2282+ } ,
2283+ max_object_bytes: ByteSize :: from_bytes( 512 ) ,
2284+ ..CacheConfig :: default ( )
2285+ } ,
2286+ headers: crate :: config:: VhostHeaderPolicyConfig :: default ( ) ,
2287+ web: WebConfig :: default ( ) ,
2288+ routes: Vec :: new( ) ,
2289+ } ] ,
2290+ ..Config :: default ( )
2291+ } ;
2292+ let app = app_with_config ( config) ;
2293+
2294+ let response = app. handle (
2295+ "POST" ,
2296+ "/_fluxheim/cache/purge-wildcard" ,
2297+ Some ( "vhost=cached&pattern=/assets/*.png&limit=16" ) ,
2298+ & auth_headers ( ) ,
2299+ ) ;
2300+
2301+ assert_eq ! ( response. status, StatusCode :: OK ) ;
2302+ let body = String :: from_utf8 ( response. body ) . unwrap ( ) ;
2303+ assert ! ( body. contains( r#""vhost":"cached""# ) ) ;
2304+ assert ! ( body. contains( r#""path_pattern":"/assets/*.png""# ) ) ;
2305+ assert ! ( body. contains( r#""matched":0"# ) ) ;
2306+ }
2307+
2308+ #[ cfg( feature = "cache" ) ]
2309+ #[ test]
2310+ fn cache_purge_wildcard_endpoint_rejects_root_pattern ( ) {
2311+ for query in [
2312+ Some ( "vhost=cached&pattern=/*" ) ,
2313+ Some ( "vhost=cached&pattern=/***" ) ,
2314+ ] {
2315+ let response = app ( ) . handle (
2316+ "POST" ,
2317+ "/_fluxheim/cache/purge-wildcard" ,
2318+ query,
2319+ & auth_headers ( ) ,
2320+ ) ;
2321+
2322+ assert_eq ! ( response. status, StatusCode :: BAD_REQUEST ) ;
2323+ }
2324+ }
2325+
21602326 #[ cfg( feature = "cache" ) ]
21612327 #[ test]
21622328 fn cache_purge_endpoint_rejects_missing_identity ( ) {
0 commit comments