@@ -38,6 +38,10 @@ const MAX_CACHE_PURGE_PATH_BYTES: usize = 4096;
3838const MAX_CACHE_PURGE_QUERY_BYTES : usize = 8192 ;
3939#[ cfg( feature = "cache" ) ]
4040const MAX_CACHE_PURGE_BULK_PATHS : usize = 256 ;
41+ #[ cfg( feature = "cache" ) ]
42+ const DEFAULT_CACHE_INDEXED_PURGE_LIMIT : usize = 1024 ;
43+ #[ cfg( feature = "cache" ) ]
44+ const MAX_CACHE_INDEXED_PURGE_LIMIT : usize = 10_000 ;
4145
4246#[ derive( Clone ) ]
4347pub struct AdminApp {
@@ -241,6 +245,14 @@ impl AdminApp {
241245 . or_else ( || query_param ( query, "url_query" ) )
242246 . or_else ( || query_param ( query, "cache_query" ) ) ,
243247 ) ,
248+ ( "POST" , "/_fluxheim/cache/purge-index" ) => self . cache_purge_index_response (
249+ header_value ( headers, "x-fluxheim-cache-vhost" )
250+ . or_else ( || query_param ( query, "vhost" ) ) ,
251+ header_value ( headers, "x-fluxheim-cache-route" )
252+ . or_else ( || query_param ( query, "route" ) ) ,
253+ header_value ( headers, "x-fluxheim-cache-limit" )
254+ . or_else ( || query_param ( query, "limit" ) ) ,
255+ ) ,
244256 ( "POST" , "/_fluxheim/snapshot" ) => {
245257 self . create_snapshot_response ( header_value ( headers, "x-fluxheim-message" ) )
246258 }
@@ -263,6 +275,7 @@ impl AdminApp {
263275 | "/_fluxheim/self-heal/report"
264276 | "/_fluxheim/cache/purge"
265277 | "/_fluxheim/cache/purge-bulk"
278+ | "/_fluxheim/cache/purge-index"
266279 | "/_fluxheim/snapshot"
267280 | "/_fluxheim/rollback"
268281 | "/_fluxheim/reload" ,
@@ -599,6 +612,58 @@ impl AdminApp {
599612 }
600613 }
601614
615+ #[ cfg( feature = "cache" ) ]
616+ fn cache_purge_index_response (
617+ & self ,
618+ vhost : Option < & str > ,
619+ route : Option < & str > ,
620+ limit : Option < & str > ,
621+ ) -> AdminResponse {
622+ let Some ( vhost) = vhost. map ( str:: trim) . filter ( |vhost| !vhost. is_empty ( ) ) else {
623+ return error_response (
624+ StatusCode :: BAD_REQUEST ,
625+ "cache indexed purge vhost is required" ,
626+ ) ;
627+ } ;
628+ let limit = match validated_cache_indexed_purge_limit ( limit) {
629+ Ok ( limit) => limit,
630+ Err ( message) => return error_response ( StatusCode :: BAD_REQUEST , message) ,
631+ } ;
632+
633+ match self
634+ . proxy
635+ . purge_indexed_image_cache ( crate :: proxy:: CacheIndexedPurgeRequest {
636+ vhost,
637+ route : route. filter ( |route| !route. trim ( ) . is_empty ( ) ) ,
638+ limit,
639+ } ) {
640+ Ok ( result) => {
641+ let body = format ! (
642+ r#"{{"status":"ok","matched":{},"purged":{},"truncated":{},"vhost":"{}","route":{},"memory_matched":{},"memory_purged":{},"memory_truncated":{},"disk_matched":{},"disk_purged":{},"disk_truncated":{}}}"# ,
643+ result. matched( ) ,
644+ result. purged( ) ,
645+ result. truncated( ) ,
646+ json_escape( & result. vhost) ,
647+ cache_route_json( result. route. as_deref( ) ) ,
648+ result. memory_matched,
649+ result. memory_purged,
650+ result. memory_truncated,
651+ result. disk_matched,
652+ result. disk_purged,
653+ result. disk_truncated
654+ ) ;
655+ json_response ( StatusCode :: OK , body. as_bytes ( ) )
656+ }
657+ Err ( error) if error. kind ( ) == std:: io:: ErrorKind :: NotFound => {
658+ error_response ( StatusCode :: NOT_FOUND , & error. to_string ( ) )
659+ }
660+ Err ( error) if error. kind ( ) == std:: io:: ErrorKind :: InvalidInput => {
661+ error_response ( StatusCode :: BAD_REQUEST , & error. to_string ( ) )
662+ }
663+ Err ( error) => error_response ( StatusCode :: INTERNAL_SERVER_ERROR , & error. to_string ( ) ) ,
664+ }
665+ }
666+
602667 #[ cfg( not( feature = "cache" ) ) ]
603668 fn cache_purge_response (
604669 & self ,
@@ -625,6 +690,16 @@ impl AdminApp {
625690 error_response ( StatusCode :: BAD_REQUEST , "cache support is not compiled in" )
626691 }
627692
693+ #[ cfg( not( feature = "cache" ) ) ]
694+ fn cache_purge_index_response (
695+ & self ,
696+ _vhost : Option < & str > ,
697+ _route : Option < & str > ,
698+ _limit : Option < & str > ,
699+ ) -> AdminResponse {
700+ error_response ( StatusCode :: BAD_REQUEST , "cache support is not compiled in" )
701+ }
702+
628703 fn apply_snapshot (
629704 & self ,
630705 snapshot : & ConfigSnapshot ,
@@ -1570,6 +1645,20 @@ fn validated_cache_purge_query(query: Option<&str>) -> Result<Option<&str>, &'st
15701645 Ok ( Some ( query) )
15711646}
15721647
1648+ #[ cfg( feature = "cache" ) ]
1649+ fn validated_cache_indexed_purge_limit ( limit : Option < & str > ) -> Result < usize , & ' static str > {
1650+ let Some ( limit) = limit. map ( str:: trim) . filter ( |limit| !limit. is_empty ( ) ) else {
1651+ return Ok ( DEFAULT_CACHE_INDEXED_PURGE_LIMIT ) ;
1652+ } ;
1653+ let limit = limit
1654+ . parse :: < usize > ( )
1655+ . map_err ( |_| "cache indexed purge limit is invalid" ) ?;
1656+ if limit == 0 || limit > MAX_CACHE_INDEXED_PURGE_LIMIT {
1657+ return Err ( "cache indexed purge limit is out of range" ) ;
1658+ }
1659+ Ok ( limit)
1660+ }
1661+
15731662fn truthy_header ( headers : & HeaderMap , name : & str ) -> bool {
15741663 header_value ( headers, name) . is_some_and ( truthy)
15751664}
@@ -1874,6 +1963,50 @@ mod tests {
18741963 assert ! ( body. contains( r#""route":"assets""# ) ) ;
18751964 }
18761965
1966+ #[ cfg( feature = "cache" ) ]
1967+ #[ test]
1968+ fn cache_purge_index_endpoint_accepts_vhost_scope ( ) {
1969+ let config = Config {
1970+ vhosts : vec ! [ VhostConfig {
1971+ name: "cached" . to_owned( ) ,
1972+ hosts: vec![ "cached.example" . to_owned( ) ] ,
1973+ max_request_body_bytes: None ,
1974+ acme_challenge: crate :: config:: VhostAcmeChallengeConfig :: default ( ) ,
1975+ redirect: crate :: config:: VhostRedirectConfig :: default ( ) ,
1976+ tls: crate :: config:: VhostTlsConfig :: default ( ) ,
1977+ proxy: ProxyConfig :: default ( ) ,
1978+ cache: CacheConfig {
1979+ enabled: true ,
1980+ memory: crate :: config:: CacheMemoryConfig {
1981+ enabled: true ,
1982+ max_size_bytes: ByteSize :: from_bytes( 2048 ) ,
1983+ } ,
1984+ max_object_bytes: ByteSize :: from_bytes( 512 ) ,
1985+ ..CacheConfig :: default ( )
1986+ } ,
1987+ headers: crate :: config:: VhostHeaderPolicyConfig :: default ( ) ,
1988+ web: WebConfig :: default ( ) ,
1989+ routes: Vec :: new( ) ,
1990+ } ] ,
1991+ ..Config :: default ( )
1992+ } ;
1993+ let app = app_with_config ( config) ;
1994+
1995+ let response = app. handle (
1996+ "POST" ,
1997+ "/_fluxheim/cache/purge-index" ,
1998+ Some ( "vhost=cached&limit=16" ) ,
1999+ & auth_headers ( ) ,
2000+ ) ;
2001+
2002+ assert_eq ! ( response. status, StatusCode :: OK ) ;
2003+ let body = String :: from_utf8 ( response. body ) . unwrap ( ) ;
2004+ assert ! ( body. contains( r#""vhost":"cached""# ) ) ;
2005+ assert ! ( body. contains( r#""matched":0"# ) ) ;
2006+ assert ! ( body. contains( r#""purged":0"# ) ) ;
2007+ assert ! ( body. contains( r#""truncated":false"# ) ) ;
2008+ }
2009+
18772010 #[ cfg( feature = "cache" ) ]
18782011 #[ test]
18792012 fn cache_purge_endpoint_rejects_missing_identity ( ) {
0 commit comments