From 9cea03c0aa6b764d8f3bf4c90d7ad1b9a5a6a9fd Mon Sep 17 00:00:00 2001 From: Marc Giffing Date: Sun, 10 Mar 2024 22:41:11 +0100 Subject: [PATCH] Support for Method level @RateLimiting annoation #250 --- README.adoc | 692 +++++++++--------- .../RateLimitConditionMatchingStrategy.java | 25 +- .../post_execution_condition.plantuml | 30 + .../doc/plantuml/post_execution_condition.png | Bin 0 -> 30956 bytes 4 files changed, 394 insertions(+), 353 deletions(-) create mode 100644 src/main/doc/plantuml/post_execution_condition.plantuml create mode 100644 src/main/doc/plantuml/post_execution_condition.png diff --git a/README.adoc b/README.adoc index 1cf73756..882db897 100644 --- a/README.adoc +++ b/README.adoc @@ -9,317 +9,177 @@ image:{url-repo}/actions/workflows/maven.yml/badge.svg[Build Status,link={url-re image:{url-repo}/actions/workflows/codeql.yml/badge.svg[Build Status,link={url-repo}/actions/worklows/codeql.yml] image:{url-repo}/actions/workflows/pmd.yml/badge.svg[Build Status,link={url-repo}/actions/worklows/pmd.yml] -= Spring Boot Starter for Bucket4j - -https://github.com/vladimir-bukhtoyarov/bucket4j - Project version overview: * 0.11.x - Bucket4j 8.8.0 - Spring Boot 3.2.x * 0.10.x - Bucket4j 8.7.0 - Spring Boot 3.1.x - -*Examples:* - -* {url-examples}/ehcache[Ehcache] -* {url-examples}/hazelcast[Hazelcast] -* {url-examples}/caffeine[Caffeine] -* {url-examples}/webflux[Webflux (Async)] -* {url-examples}/gateway[Spring Cloud Gateway (Async)] - -= Contents +== Contents * <> -* <> -* <> -* <> -* <> -* <> -* <> -* <> -* <> -* <> -* <> -* <> - - -[[introduction]] -== Introduction - -This project is a http://projects.spring.io/spring-boot/[Spring Boot Starter] for Bucket4j. -It can be used limit the rate of access to your REST APIs or method calls. - -* Prevention of DoS Attacks, brute-force logins attempts -* Request throttling for specific regions, unauthenticated users, authenticated users, not paying users. - -The benefit of this project is the configuration of Bucket4j via Spring Boots *properties* or *yaml* files. You don't -have to write a single line of code. - -[[migration_guide]] -== Migration Guide - -This section is meant to help you migrate your application to new version of this starter project. - -=== Spring Boot Starter Bucket4j 0.12 - -* Removed deprecated expression property - -=== Spring Boot Starter Bucket4j 0.9 - -* Upgrade to Spring Boot 3 -* Spring Boot 3 requires Java 17 so use at least Java 17 -* Replaced Java 8 compatible Bucket4j dependencies -* Exclude example webflux-infinispan due to startup problems +* <> +** <> +*** <> +*** <> +*** <> +*** <> +*** <> -=== Spring Boot Starter Bucket4j 0.8 +* <> +** <> +** <> -==== Compatibility to Java 8 +* <> +** <> +** <> +** <> +** <> -The version 0.8 tries to be compatible with Java 8 as long as Bucket4j is supporting Java 8. With the release -of Bucket4j 8.0.0 Bucket4j decided to migrate to Java 11 but provides dedicated artifacts for Java 8. -The project is switching to the dedicated artifacts which supports Java 8. You can read more about -it https://github.com/bucket4j/bucket4j#java-compatibility-matrix[here]. - -==== Rename property expression to cache-key - -The property *..rate-limits[0].expression* is renamed to *..rate-limits[0].cache-key*. -An Exception is thrown on startup if the *expression* property is configured. -To ensure that the property is not filled falsely the property is marked with *@Null*. This change requires -a Bean Validation implementation. - -==== JSR 380 - Bean Validation implementation required - -To ensure that the Bucket4j property configuration is correct an Validation API implementation is required. -You can add the Spring Boot Starter Validation which will automatically configures one. +[[introduction]] +== Spring Boot Starter for Bucket4j -[source, xml] ----- - - org.springframework.boot - spring-boot-starter-validation - ----- +This project is a http://projects.spring.io/spring-boot/[Spring Boot Starter] for https://github.com/vladimir-bukhtoyarov/bucket4j[Bucket4j]. You can use it to set access limits on your API. The benefit of this project is the configuration via *properties* or *yaml* files. You don't have to write a single line of code. -==== Explicit Configuration of the Refill Speed - API Break +The following bullets are example use cases. -The refill speed of the Buckets can now configured explicitly with the Enum RefillSpeed. You can choose between -a greedy or interval refill see the https://bucket4j.com/8.1.1/toc.html#refill[official documentation]. +* Prevention of DoS Attacks +* Brute-force logins attempts +* Request throttling for specific regions, unauthenticated users, authenticated users +* Rate limit for not paying users or users with different permissions -Before 0.8 the refill speed was configured implicitly by setting the fixed-refill-interval property explicit. +The following features are provided. Some of them use Springs Expression Language to dynamically interpret conditions. -[source, properties] ----- -bucket4j.filters[0].rate-limits[0].bandwidths[0].fixed-refill-interval=0 -bucket4j.filters[0].rate-limits[0].bandwidths[0].fixed-refill-interval-unit=minutes ----- - -These properties are removed and replaced by the following configuration: +* Cache key for differentiate the by username, IP address, ...) +* Execute by a specific condition +* Skip by a specific condition +* <> +* Post token consumption based on the filter/method result -[source, properties] ----- -bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=interval ----- +You have two choices for rate limit configuration. The first is to add a filter for incoming web requests or a fine-grained on method level. -You can read more about the refill speed configuration here <> +=== Filter -[[getting_started]] -== Getting started +Filters are pluggable components that intercepts the incoming web requests and can reject it to stop the further processing. You can add multiple filters for different urls or skip the rate limit at all for authenticated users. If the limit exceeds, the web requests is aborted and the request is declined with the HTTP Status 429 Too Many Requests. -To use the rate limit in your project you have to add the Bucket4j Spring Boot Starter dependency in -your project. Additionally you have to choose a caching provider <>. +This projects supports the following filters: -The next example uses https://www.jcp.org/en/jsr/detail?id=107[JSR 107] Ehcache which will be auto configured with the https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-caching.html[Spring Boot Starter Cache]. +* https://docs.oracle.com/javaee%2F6%2Fapi%2F%2F/javax/servlet/Filter.html[Servlet Filter] (Default) +* https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/server/WebFilter.html[Webflux Webfilter] (reactive) +* https://docs.spring.io/spring-cloud-gateway/reference/spring-cloud-gateway/global-filters.html[Spring Cloud Gateway Global Filter] (reactive) -[source, xml] +[source,properties] ---- - - com.giffing.bucket4j.spring.boot.starter - bucket4j-spring-boot-starter - - - org.springframework.boot - spring-boot-starter-cache - - - org.ehcache - ehcache - +bucket4j.filters[0].cache-name=buckets # the name of the cache +bucket4j.filters[0].url=^(/hello).* # regular expression for the url +bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5 # refills 5 tokens every 10 seconds (intervall) +bucket4j.filters[0].rate-limits[0].bandwidths[0].time=10 +bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=seconds +bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=intervall ---- -> Don't forget to enable the caching feature by adding the @EnableCaching annotation to any of the configuration classes. +=== Method -The configuration can be done in the application.properties / application.yml. -The following configuration limits all requests independently from the user. It allows a maximum of 5 requests within 10 seconds independently from the user. +By annotating your method with @RateLimiting, AOP is used to intercept the method. You have full access to the method parameters to define the rate limit key or skip the rate limit on your conditions. - -[source,yml] +[source,properties] ---- -bucket4j: - enabled: true - filters: - - cache-name: buckets - url: .* - rate-limits: - - bandwidths: - - capacity: 5 - time: 10 - unit: seconds +bucket4j.methods[0].name=not_an_admin # the name of the configuration for annotation reference +bucket4j.methods[0].cache-name=buckets # the name of the cache +bucket4j.methods[0].rate-limits[0].bandwidths[0].capacity=5 # refills 5 tokens every 10 seconds (intervall) +bucket4j.methods[0].rate-limits[0].bandwidths[0].time=10 +bucket4j.methods[0].rate-limits[0].bandwidths[0].unit=seconds +bucket4j.methods[0].rate-limits[0].bandwidths[0].refill-speed=intervall ---- -For Ehcache 3 you also need a *ehcache.xml* which can be placed in the classpath. -The configured cache name *buckets* must be defined in the configuration file. - -[source,yml] ----- -spring: - cache: - jcache: - config: classpath:ehcache.xml ----- - -[source,xml] ----- - - - - 3600 - - 1000000 - - - +[source,java] ---- +@RateLimiting( + // reference to the property file + name = "not_an_admin", + // only when the parameter is not admin + executeCondition = "#myParamName != 'admin'", + // the method name is added to the cache key to prevent conflicts with other methods + ratePerMethod = true, + // if the limit is exceeded the fallback method is called. If not provided an exception is thrown + fallbackMethodName = "myFallbackMethod") + public String execute(String myParamName) { + log.info("Method with Param {} executed", myParamName); + return myParamName; + } -[[overview_cache_autoconfiguration]] -== Overview Cache Autoconfiguration - -The following list contains the Caching implementation which will be autoconfigured by this starter. - -[cols="1,1,1"] -|=== -|*Reactive* -|*Name* -|*cache-to-use* - -|N -|{url-config-cache}/jcache/JCacheBucket4jConfiguration.java[JSR 107 -JCache] -|jcache - -|Yes -|{url-config-cache}/ignite/IgniteBucket4jCacheConfiguration.java[Ignite] -|jcache-ignite - -|no -|{url-config-cache}/hazelcast/HazelcastSpringBucket4jCacheConfiguration.java[Hazelcast] -|hazelcast-spring - -|yes -|{url-config-cache}/hazelcast/HazelcastReactiveBucket4jCacheConfiguration.java[Hazelcast] -|hazelcast-reactive - -|Yes -|{url-config-cache}/infinispan/InfinispanBucket4jCacheConfiguration.java[Infinispan] -|infinispan - -|No -|{url-config-cache}/redis/jedis/JedisBucket4jConfiguration.java[Redis-Jedis] -|redis-jedis - -|Yes -|{url-config-cache}/redis/lettuce/LettuceBucket4jConfiguration.java[Redis-Lettuce] -|redis-lettuce - -|Yes -|{url-config-cache}/redis/redission/RedissonBucket4jConfiguration.java[Redis-Redisson] -|redis-redisson - -|=== - -Instead of determine the Caching Provider by the Bucket4j Spring Boot Starter project you can implement the SynchCacheResolver -or the AsynchCacheResolver by yourself. - -You can enable the cache auto configuration explicitly by using the *cache-to-use* property name or setting -it to an invalid value to disable all auto configurations. - -[source, properties] ----- -bucket4j.cache-to-use=jcache # + public String myFallbackMethod(String myParamName) { + log.info("Fallback-Method with Param {} executed", myParamName); + return myParamName; + } ---- -[[filters]] -== Filter -=== Filter strategy +[[project_configuration]] +== Project Configuration -The filter strategy defines how the execution of the rate limits will be performed. +[[bucket4j_complete_properties]] +=== General Bucket4j properties [source, properties] ---- -bucket4j.filters[0].strategy=first # [first, all] ----- - -==== first - -The *first* is the default strategy. This the default strategy which only executes one rate limit configuration. - -==== all - -The *all* strategy executes all rate limit independently. - -[[cache_key]] -== Cache Key - -To differentiate incoming request you can provide an expression which is used as a key resolver for the underlying cache. - -The expression uses the https://docs.spring.io/spring/docs/current/spring-framework-reference/html/expressions.html[Spring Expression Language] (SpEL) which -provides the most flexible solution to determine the cache key written in one line of code. https://docs.spring.io/spring/docs/current/spring-framework-reference/html/expressions.html#expressions-spel-compilation[The expression compiles to a Java class which will be used]. - -Depending on the filter method [servlet, webflux, gateway] different SpEL root objects can be used in the expression so that you have a direct access to the method of these request objects: - -* servlet: jakarta.servlet.http.HttpServletRequest (e.g. getRemoteAddr() or getRequestURI()) -* webflux: org.springframework.http.server.reactive.ServerHttpRequest -* gateway: org.springframework.http.server.reactive.ServerHttpRequest - -The configured URL which is used for filtering is added to the cache-key to provide a unique cache-key for multiple URL. -You can read more about it https://github.com/MarcGiffing/bucket4j-spring-boot-starter/issues/19[here]. +bucket4j.enabled=true # enable/disable bucket4j support +bucket4j.cache-to-use= # If you use multiple caching implementation in your project and you want to choose a specific one you can set the cache here (jcache, hazelcast, ignite, redis) -*Limiting based on IP-Address*: -[source] ----- -getRemoteAddress() +# Optional default metric tags for all filters +bucket4j.default-metric-tags[0].key=IP +bucket4j.default-metric-tags[0].expression=getRemoteAddr() +bucket4j.default-metric-tags[0].types=REJECTED_COUNTER ---- +==== Filter Bucket4j properties -*Limiting based on Username - If not logged in use IP-Address*: -[source] ----- -@securityService.username()?: getRemoteAddr() ----- -[source,java] +[source, properties] ---- -/** -* You can define custom beans like the SecurityService which can be used in the SpEl expressions. -**/ -@Service -public class SecurityService { - - public String username() { - String name = SecurityContextHolder.getContext().getAuthentication().getName(); - if(name == "anonymousUser") { - return null; - } - return name; - } - -} +bucket4j.filter-config-caching-enabled=true #Enable/disable caching of filter configurations. +bucket4j.filter-config-cache-name=filterConfigCache #The name of the cache where the configurations are stored. Defaults to 'filterConfigCache'. +bucket4j.filters[0].id=filter1 # The id of the filter. This field is mandatory when configuration caching is enabled and should always be a unique string. +bucket4j.filters[0].major-version=1 # [min = 1, max = 92 million] Major version number of the configuration. +bucket4j.filters[0].minor-version=1 # [min = 1, max = 99 billion] Minor version number of the configuration. (intended for internal updates, for example based on CPU-usage, but can also be used for regular updates) +bucket4j.filters[0].cache-name=buckets # the name of the cache key +bucket4j.filters[0].filter-method=servlet # [servlet,webflux,gateway] +bucket4j.filters[0].filter-order= # Per default the lowest integer plus 10. Set it to a number higher then zero to execute it after e.g. Spring Security. +bucket4j.filters[0].http-content-type=application/json +bucket4j.filters[0].http-status-code=TOO_MANY_REQUESTS # Enum value of org.springframework.http.HttpStatus +bucket4j.filters[0].http-response-body={ "message": "Too many requests" } # the json response which should be added to the body +bucket4j.filters[0].http-response-headers.=MY_CUSTOM_HEADER_VALUE # You can add any numbers of custom headers +bucket4j.filters[0].hide-http-response-headers=true # Hides response headers like x-rate-limit-remaining or x-rate-limit-retry-after-seconds on rate limiting +bucket4j.filters[0].url=.* # a regular expression +bucket4j.filters[0].strategy=first # [first, all] if multiple rate limits configured the 'first' strategy stops the processing after the first matching +bucket4j.filters[0].rate-limits[0].cache-key=getRemoteAddr() # defines the cache key. It will be evaluated with the Spring Expression Language +bucket4j.filters[0].rate-limits[0].num-tokens=1 # The number of tokens to consume +bucket4j.filters[0].rate-limits[0].execute-condition=1==1 # an optional SpEl expression to decide to execute the rate limit or not +bucket4j.filters[1].rate-limits[0].post-execute-condition= # an optional SpEl expression to decide if the token consumption should only estimated for the incoming request and the returning response used to check if the token must be consumed: getStatus() eq 401 +bucket4j.filters[0].rate-limits[0].execute-predicates[0]=PATH=/hello,/world # On the HTTP Path as a list +bucket4j.filters[0].rate-limits[0].execute-predicates[1]=METHOD=GET,POST # On the HTTP Method +bucket4j.filters[0].rate-limits[0].execute-predicates[2]=QUERY=HELLO # Checks for the existence of a Query Parameter +bucket4j.filters[0].rate-limits[0].skip-condition=1==1 # an optional SpEl expression to skip the rate limit +bucket4j.filters[0].rate-limits[0].tokens-inheritance-strategy=RESET # [RESET, AS_IS, ADDITIVE, PROPORTIONALLY], defaults to RESET and is only used for dynamically updating configurations +bucket4j.filters[0].rate-limits[0].bandwidths[0].id=bandwidthId # Optional when using tokensInheritanceStrategy.RESET or if the rate-limit only contains 1 bandwidth. The id should be unique within the rate-limit. +bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=10 +bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-capacity= # default is capacity +bucket4j.filters[0].rate-limits[0].bandwidths[0].time=1 +bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=minutes +bucket4j.filters[0].rate-limits[0].bandwidths[0].initial-capacity= # Optional initial tokens +bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=greedy # [greedy,interval] +bucket4j.filters[0].metrics.enabled=true +bucket4j.filters[0].metrics.types=CONSUMED_COUNTER,REJECTED_COUNTER # (optional) if your not interested in the consumed counter you can specify only the rejected counter +bucket4j.filters[0].metrics.tags[0].key=IP +bucket4j.filters[0].metrics.tags[0].expression=getRemoteAddr() +bucket4j.filters[0].metrics.tags[0].types=REJECTED_COUNTER # (optionial) this tag should for example only be applied for the rejected counter +bucket4j.filters[0].metrics.tags[1].key=URL +bucket4j.filters[0].metrics.tags[1].expression=getRequestURI() +bucket4j.filters[0].metrics.tags[2].key=USERNAME +bucket4j.filters[0].metrics.tags[2].expression=@securityService.username() != null ? @securityService.username() : 'anonym' ---- -[[cache_overview]] - - [[refill_speed]] -== Refill Speed +==== Refill Speed The refill speed defines the period of the regeneration of consumed tokens. This starter supports two types of token regeneration. The refill speed can be set with the following @@ -335,14 +195,31 @@ bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=greedy # [greedy,i You can read more about the refill speed in the https://bucket4j.com/8.1.1/toc.html#refill[official documentation]. +[[rate_limit_strategy]] +==== Rate Limit Strategy + +If multiple rate limits are defined the strategy defines how many of them should be executed. + +[source, properties] +---- +bucket4j.filters[0].strategy=first # [first, all] +---- + +===== first + +The *first* is the default strategy. This the default strategy which only executes one rate limit configuration. If a rate limit configuration is skipped due to the provided condition. It does not count as an executed rate limit. + +===== all + +The *all* strategy executes all rate limit independently. [[skip_execution_predicates]] -== Skip and Execution Predicates (experimental) +==== Skip and Execution Predicates (experimental) Skip and Execution Predicates can be used to conditionally skip or execute the rate limiting. Each predicate has a unique name and a self-contained configuration. The following section describes the build in Execution Predicates and how to use them. -=== Path Predicates +===== Path Predicates The Path Predicate takes a list of path parameters where any of the paths must match. See https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/util/pattern/PathPattern.java[PathPattern] for the available configuration options. Segments are not evaluated further. @@ -355,7 +232,7 @@ bucket4j.filters[0].rate-limits[0].execute-predicates[0]=PATH=/hello,/world,/adm Matches the paths '/hello', '/world' or '/admin'. -=== Method Predicate +===== Method Predicate The Method Predicate takes a list of method parameters where any of the methods must match the used HTTP method. @@ -365,7 +242,7 @@ bucket4j.filters[0].rate-limits[0].execute-predicates[0]=METHOD=GET,POST ---- Matches if the HTTP method is 'GET' or 'POST'. -=== Query Predicate +===== Query Predicate The Query Predicate takes a single parameter to check for the existence of the query parameter. @@ -375,7 +252,7 @@ bucket4j.filters[0].rate-limits[0].execute-predicates[0]=QUERY=PARAM_1 ---- Matches if the query parameter 'PARAM_1' exists. -=== Header Predicate +===== Header Predicate The Header Predicate takes to parameters. @@ -387,7 +264,7 @@ bucket4j.filters[0].rate-limits[0].execute-predicates[0]=Content-Type,.*PDF.* ---- Matches if the query parameter 'PARAM_1' exists. -=== Custom Predicate +===== Custom Predicate You can also define you own Execution Predicate: @@ -421,15 +298,71 @@ public class MyQueryExecutePredicate extends ExecutePredicate BAD_REQUEST - The configCacheManager currently does *not* contain validation in the setValue method. The configuration should be validated before calling the this method. -[[bucket4j_complete_properties]] -== Bucket4j properties - - -[source, properties] ----- -bucket4j.enabled=true # enable/disable bucket4j support -bucket4j.cache-to-use= # If you use multiple caching implementation in your project and you want to choose a specific one you can set the cache here (jcache, hazelcast, ignite, redis) -bucket4j.filter-config-caching-enabled=true #Enable/disable caching of filter configurations. -bucket4j.filter-config-cache-name=filterConfigCache #The name of the cache where the configurations are stored. Defaults to 'filterConfigCache'. -bucket4j.filters[0].id=filter1 # The id of the filter. This field is mandatory when configuration caching is enabled and should always be a unique string. -bucket4j.filters[0].major-version=1 # [min = 1, max = 92 million] Major version number of the configuration. -bucket4j.filters[0].minor-version=1 # [min = 1, max = 99 billion] Minor version number of the configuration. (intended for internal updates, for example based on CPU-usage, but can also be used for regular updates) -bucket4j.filters[0].cache-name=buckets # the name of the cache key -bucket4j.filters[0].filter-method=servlet # [servlet,webflux,gateway] -bucket4j.filters[0].filter-order= # Per default the lowest integer plus 10. Set it to a number higher then zero to execute it after e.g. Spring Security. -bucket4j.filters[0].http-content-type=application/json -bucket4j.filters[0].http-status-code=TOO_MANY_REQUESTS # Enum value of org.springframework.http.HttpStatus -bucket4j.filters[0].http-response-body={ "message": "Too many requests" } # the json response which should be added to the body -bucket4j.filters[0].http-response-headers.=MY_CUSTOM_HEADER_VALUE # You can add any numbers of custom headers -bucket4j.filters[0].hide-http-response-headers=true # Hides response headers like x-rate-limit-remaining or x-rate-limit-retry-after-seconds on rate limiting -bucket4j.filters[0].url=.* # a regular expression -bucket4j.filters[0].strategy=first # [first, all] if multiple rate limits configured the 'first' strategy stops the processing after the first matching -bucket4j.filters[0].rate-limits[0].cache-key=getRemoteAddr() # defines the cache key. It will be evaluated with the Spring Expression Language -bucket4j.filters[0].rate-limits[0].num-tokens=1 # The number of tokens to consume -bucket4j.filters[0].rate-limits[0].execute-condition=1==1 # an optional SpEl expression to decide to execute the rate limit or not -bucket4j.filters[1].rate-limits[0].post-execute-condition= # an optional SpEl expression to decide if the token consumption should only estimated for the incoming request and the returning response used to check if the token must be consumed: getStatus() eq 401 -bucket4j.filters[0].rate-limits[0].execute-predicates[0]=PATH=/hello,/world # On the HTTP Path as a list -bucket4j.filters[0].rate-limits[0].execute-predicates[1]=METHOD=GET,POST # On the HTTP Method -bucket4j.filters[0].rate-limits[0].execute-predicates[2]=QUERY=HELLO # Checks for the existence of a Query Parameter -bucket4j.filters[0].rate-limits[0].skip-condition=1==1 # an optional SpEl expression to skip the rate limit -bucket4j.filters[0].rate-limits[0].tokens-inheritance-strategy=RESET # [RESET, AS_IS, ADDITIVE, PROPORTIONALLY], defaults to RESET and is only used for dynamically updating configurations -bucket4j.filters[0].rate-limits[0].bandwidths[0].id=bandwidthId # Optional when using tokensInheritanceStrategy.RESET or if the rate-limit only contains 1 bandwidth. The id should be unique within the rate-limit. -bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=10 -bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-capacity= # default is capacity -bucket4j.filters[0].rate-limits[0].bandwidths[0].time=1 -bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=minutes -bucket4j.filters[0].rate-limits[0].bandwidths[0].initial-capacity= # Optional initial tokens -bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=greedy # [greedy,interval] -bucket4j.filters[0].metrics.enabled=true -bucket4j.filters[0].metrics.types=CONSUMED_COUNTER,REJECTED_COUNTER # (optional) if your not interested in the consumed counter you can specify only the rejected counter -bucket4j.filters[0].metrics.tags[0].key=IP -bucket4j.filters[0].metrics.tags[0].expression=getRemoteAddr() -bucket4j.filters[0].metrics.tags[0].types=REJECTED_COUNTER # (optionial) this tag should for example only be applied for the rejected counter -bucket4j.filters[0].metrics.tags[1].key=URL -bucket4j.filters[0].metrics.tags[1].expression=getRequestURI() -bucket4j.filters[0].metrics.tags[2].key=USERNAME -bucket4j.filters[0].metrics.tags[2].expression=@securityService.username() != null ? @securityService.username() : 'anonym' - -# Optional default metric tags for all filters -bucket4j.default-metric-tags[0].key=IP -bucket4j.default-metric-tags[0].expression=getRemoteAddr() -bucket4j.default-metric-tags[0].types=REJECTED_COUNTER - -# Hide HTTP response headers ----- - - [[monitoring]] -== Monitoring - Spring Boot Actuator +=== Monitoring - Spring Boot Actuator Spring Boot ships with a great support for collecting metrics. This project automatically provides metric information about the consumed and rejected buckets. You can extend these information with configurable https://micrometer.io/docs/concepts#_tag_naming[custom tags] like the username or the IP-Address which can then be evaluated in a monitoring system like prometheus/grafana. @@ -577,9 +452,148 @@ bucket4j: unit: minutes ---- +[[appendix]] +== Appendix -[[configuration_examples]] -== Configuration via properties +[[migration_guide]] +=== Migration Guide + +This section is meant to help you migrate your application to new version of this starter project. + +==== Spring Boot Starter Bucket4j 0.12 + +* Removed deprecated expression property + +==== Spring Boot Starter Bucket4j 0.9 + +* Upgrade to Spring Boot 3 +* Spring Boot 3 requires Java 17 so use at least Java 17 +* Replaced Java 8 compatible Bucket4j dependencies +* Exclude example webflux-infinispan due to startup problems + +==== Spring Boot Starter Bucket4j 0.8 + +===== Compatibility to Java 8 + +The version 0.8 tries to be compatible with Java 8 as long as Bucket4j is supporting Java 8. With the release +of Bucket4j 8.0.0 Bucket4j decided to migrate to Java 11 but provides dedicated artifacts for Java 8. +The project is switching to the dedicated artifacts which supports Java 8. You can read more about +it https://github.com/bucket4j/bucket4j#java-compatibility-matrix[here]. + +===== Rename property expression to cache-key + +The property *..rate-limits[0].expression* is renamed to *..rate-limits[0].cache-key*. +An Exception is thrown on startup if the *expression* property is configured. + +To ensure that the property is not filled falsely the property is marked with *@Null*. This change requires +a Bean Validation implementation. + +===== JSR 380 - Bean Validation implementation required + +To ensure that the Bucket4j property configuration is correct an Validation API implementation is required. +You can add the Spring Boot Starter Validation which will automatically configures one. + +[source, xml] +---- + + org.springframework.boot + spring-boot-starter-validation + +---- + +===== Explicit Configuration of the Refill Speed - API Break + +The refill speed of the Buckets can now configured explicitly with the Enum RefillSpeed. You can choose between +a greedy or interval refill see the https://bucket4j.com/8.1.1/toc.html#refill[official documentation]. + +Before 0.8 the refill speed was configured implicitly by setting the fixed-refill-interval property explicit. + +[source, properties] +---- +bucket4j.filters[0].rate-limits[0].bandwidths[0].fixed-refill-interval=0 +bucket4j.filters[0].rate-limits[0].bandwidths[0].fixed-refill-interval-unit=minutes +---- + +These properties are removed and replaced by the following configuration: + +[source, properties] +---- +bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=interval +---- + +You can read more about the refill speed configuration here <> + +[[overview_cache_autoconfiguration]] +=== Overview Cache Autoconfiguration + +The following list contains the Caching implementation which will be autoconfigured by this starter. + +[cols="1,1,1"] +|=== +|*Reactive* +|*Name* +|*cache-to-use* + +|N +|{url-config-cache}/jcache/JCacheBucket4jConfiguration.java[JSR 107 -JCache] +|jcache + +|Yes +|{url-config-cache}/ignite/IgniteBucket4jCacheConfiguration.java[Ignite] +|jcache-ignite + +|no +|{url-config-cache}/hazelcast/HazelcastSpringBucket4jCacheConfiguration.java[Hazelcast] +|hazelcast-spring + +|yes +|{url-config-cache}/hazelcast/HazelcastReactiveBucket4jCacheConfiguration.java[Hazelcast] +|hazelcast-reactive + +|Yes +|{url-config-cache}/infinispan/InfinispanBucket4jCacheConfiguration.java[Infinispan] +|infinispan + +|No +|{url-config-cache}/redis/jedis/JedisBucket4jConfiguration.java[Redis-Jedis] +|redis-jedis + +|Yes +|{url-config-cache}/redis/lettuce/LettuceBucket4jConfiguration.java[Redis-Lettuce] +|redis-lettuce + +|Yes +|{url-config-cache}/redis/redission/RedissonBucket4jConfiguration.java[Redis-Redisson] +|redis-redisson + +|=== + +Instead of determine the Caching Provider by the Bucket4j Spring Boot Starter project you can implement the SynchCacheResolver +or the AsynchCacheResolver by yourself. + +You can enable the cache auto configuration explicitly by using the *cache-to-use* property name or setting +it to an invalid value to disable all auto configurations. + +[source, properties] +---- +bucket4j.cache-to-use=jcache # +---- + +[[examples]] +=== Examples + +* {url-examples}/ehcache[Ehcache] +* {url-examples}/hazelcast[Hazelcast] +* {url-examples}/caffeine[Caffeine] +* {url-examples}/redis-jedis[Redis Jedis] +* {url-examples}/redis-lettuce[Redis Lettuce] +* {url-examples}/redis-redisson[Redis Redisson] +* {url-examples}/webflux[Webflux (Async)] +* {url-examples}/gateway[Spring Cloud Gateway (Async)] +* {url-examples}/webflux-infinispan[Infinispan] + +[[property_configuration_examples]] +=== Property Configuration Examples Simple configuration to allow a maximum of 5 requests within 10 seconds independently from the user. @@ -663,4 +677,4 @@ bucket4j: - capacity: 10000 time: 1 unit: minutes ----- +---- \ No newline at end of file diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitConditionMatchingStrategy.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitConditionMatchingStrategy.java index 1a42f1b7..698e241a 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitConditionMatchingStrategy.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitConditionMatchingStrategy.java @@ -2,21 +2,18 @@ /** * Bad name :-) - * - * If multiple rate limits configured this strategy decides when to stop - * the evaluation. - * - * + *

+ * If multiple rate limits configured this strategy decides when to stop the evaluation. */ public enum RateLimitConditionMatchingStrategy { - /** - * All rate limits should be evaluated - */ - ALL, - /** - * Only the first matching rate limit will be evaluated - */ - FIRST, - + /** + * All rate limits should be evaluated + */ + ALL, + /** + * Only the first matching rate limit will be evaluated + */ + FIRST, + } diff --git a/src/main/doc/plantuml/post_execution_condition.plantuml b/src/main/doc/plantuml/post_execution_condition.plantuml new file mode 100644 index 00000000..7f35233f --- /dev/null +++ b/src/main/doc/plantuml/post_execution_condition.plantuml @@ -0,0 +1,30 @@ +@startuml + +== First Request - 1 Token available == + +User -> Bucket4jFilter: webRequest +box "Webserver" #LightBlue +participant Bucket4jFilter +participant SpringSecurityFilter + +Bucket4jFilter -> Bucket4jFilter : check remaining tokens +Bucket4jFilter -> SpringSecurityFilter : tokens available proceed +SpringSecurityFilter -> SpringSecurityFilter : authenticate +SpringSecurityFilter -> Bucket4jFilter : authentication Failed 401 +Bucket4jFilter -> Bucket4jFilter : if response status 401 consume token +Bucket4jFilter -> User : response 401 +end box + +== Second Request - 0 Token available == + + +User -> Bucket4jFilter: webRequest +box "Webserver" #LightBlue +participant Bucket4jFilter +participant SpringSecurityFilter + +Bucket4jFilter -> Bucket4jFilter : check remaining tokens -> no token available +Bucket4jFilter -> User : reject request 429 +end box + +@enduml \ No newline at end of file diff --git a/src/main/doc/plantuml/post_execution_condition.png b/src/main/doc/plantuml/post_execution_condition.png new file mode 100644 index 0000000000000000000000000000000000000000..8490255a071f57d27cd9cc8fb1639852e6f2eb32 GIT binary patch literal 30956 zcmdSBcT`i~_bnO=C>^N^C>BtfQUs(Upp+mT1O!6|fzTlcq1fmmHS`Xl_uc|XQF`xP zq)3HFzgw5jgCh^fp80|QSnQu_*c(_`JHd^N?IBV|mPWRE_C|&`^__0MvbVRk6JTez zhU;0{J6OOU7+6^#I$P->kaHMQRSo;Ujzi9Y^Sq83Q28wXor4r}#42fRO7hf#GLcdF z3X9_NbQG!SNYQt~98-OhRU6HdIGYQhmKR2s<6;V?q9Y$|*?yA|z*l3Iyc6+i0dH-? z@v#V!<4wiOs;H7D;zZ~YFETbTbv`hxr^r|_ZZ*I!;FZSHG{|m;*0M> zP?U3ii}u&OmXad;0@9rQyshTPtlZgBWNt0OKTgPHwJwlbo+pq#eD5BVORrg$m$PxX z`Z>M`m)OSB^n9hkV3VpYdnx~cZi+ftiXO`G(m87rKew#=C$c(E(hKDB`?e+m;}oyZ zcd!M28~K|4VE@jE)J*QIBy|yU{uBB#KE7+aUM(`gcUt;7>$Ck`d8@-R=U2}!;#Vcv z**(IsdarWXV|Uj6MiNftOxW1FBi-8%Jgz#-7dS`W9j;2qUeDX?i=ZOA_NSwU9Q z!jwQn&e@=&VeHfO8^$yE^hQiR8Ld=8pD3udWY)bRphbkXEgr)x;b8>j)NXB!tml}m zclKgM+#nD{vCI=O)z`3)Yht2>(uh#^h=9rjFWNPrPsRiLR$yb zlX+?w#IEKF*m@o)Zq8m~L+@>UH!X9{5!fu$pN7?S zzWMXngE7Rz@0wfRa7G!A^s0!oggdzU;lm#gRfJCRu;t;E>x$@VS zRutCxs>*{mKI#<5AmZ0Xrh0lfxiiDe8TVq<*DY(eJyZ_x?d_L*iR|r|<5}wwG7eE+)Y3HyB%fing{6iQ`{nqRc4(g4+axNxz5VPz zR&20HWxl*s|G{}9hlNjYk5-UBLx&5^V88g8i*3X;0+BIY`JLKNl|=SZ-R-ma7$hIN z{nm58)HM!maXM|L*Hd!zDNdT%l>f-YsyT^hv#9;y5JJ0>QdRgJqQU&D{c8l%0l9_L zfK@1Rl|J>Wh!_=W7zt^crzeZDnF$2J;QfzaYyI#)+C4I90@hLbI|zs+Mk z!+p3OI=5W-U@BL!KtDNcv@ifRYZB^z9ktvv6drlwsBvqHDiU9mz2Idbl}e(L8XmqL z^QXDi9`09tU)6ImEL6>+_~b{?jg=i*oiEB_d0$0IlV|JqUwFe)hbg`?PM{I+bFr zKdjfWogwV@{>ZMZ&P7iT72mRW_c4*KyF0WLFKanF(50@MkuBqn>`_()>#(&rm>l_- zBY0|=<_fI}USHT=_eG7)=1QQ7B;XiNlv2y`xxbdo(|4|iw}ZashdM>uY5Go^q}Hpw zTRkG#CAyqaK`i&ae!1V(c%Oh@o#0u3>$@C&Hl4)t*IyJKaj;Sj)%dwv^FSHeHc0SR z`OVl^^$U0_mQ#$@4ZNaCmS4H5ZaPEqFOSR8e#Sr`2@Ge`27y4&|Gzvm`~_2Uw049; zK!r|y5bOPxw?^bZqQ>yk})(RYS%6 z1U$9K-LJkxf-qRrE&Bd9SEOH-n125Pcglij+s#2gM`&(&q`_(TqJ)ol4kBA4svO-l z%B>R_I3}PgAnJ95>ku>DDqD9E-A0R^t~b=Z`WVltKFRRr`eTLXuUkuP@-z>xx>K{m z*nVFyTz)O?_-%lnr&0Y!r_Ei+3dFgvUd0m|pwzEH_PD@SLgK8UG1D+3$aq_R9cG2H zJbd>Z5_Qg+z2W>`p*mRg1Z!%zbr?c|@irKUg6#kK#nwqoA;D?c+wcdjw?qwDv-v&v z0(cH&&x4bN`Xf<@3lD_hH=H7Ombq-%4|wuJS=HB?AC6rVv|~q9Ma0CujN&o3nXcn> z;$;mIB{o-dGIVk(yKw$|lY`qvlPJ4(bFJNiU}AzsgO{ikZ$Nodw6IdaTjVt=$~^(M zU;5z>gk4-*Vq;=h)OSrMh1|C0wVim)NgvJ6=r!p2eyFB>F|xzGKD0XxE3-V7je8Zo z<}lm*wV#dny1+!LTvcG8;y>4o6?9As3|*qQ)K!jHr8nLhPC-Rmkbqf8nuW!?B1Abj zek`ViOuFv<94Q(iq2m*Av>e@>Tb|{=uV30iiARg8!oUzjov~~>ndk5gMnGM2je_Et z6`fiZZhIW&akE4h`|aC>E(iGAFj2Q(ZDm%Xq~RUy&@37fL!t zYnA2X{2hTHW!=HzOXcBuO160uRU}G9{0=M%Zt2rD$)h!{ zj^Dp@uspXXCMJ%2WEC`u%cWBLFYySA;IApm$gH@Dar7-NUp5QyCx>`M3<842T3#?kq9b~L zf3PF^t4a}ZH>HS{xaVG1UY#aL3eO}~FwP(UXS6ZZAGzmt zvx2>|^FgTX@u6_yN`>8{*?VO9vU^EmwkyN;wl@n+RS>GG_bJB`Xl3(ci$9NsjEtVY z@IE+Lz-MYFyJB_bi`2TDq1%jr+PEK1m+n2(f|A+X;KkI*8`Ch)r%f1qe4;nbeX{Hw z($cRa);;Xz*$fx6m3*p5IsuzaMG3BkV8%(zx!If0;wx0hq2j+#xHnz}zJX6(bY_vm>VCdyt}KH`e6O{vg+=!gekO|0%6~DDQBi&ZOB~mn?I@ zuP1{S@8;b8}-#6vD*&^qRN|0hqc^1u^ zW;Otk;f+G_Q@o&YVvqb;Xkd)W;3>wMN9iLVPghoQS-qrXIX z_vYcxj5eD#;-i;?iMO_dgp*ts=+LNs&1mkuo!yic2=`NJy(1drtP9=o z!v^x@3BSA7vK1(WELFJ;38Zvh>%E%|mvA5<{e+*)D_7eZo|kVshnv_R!5K1q>(2QvFA9rVS%vD~GnpSmG@7$<;|dN`V&Z=Hy)`C0$E(#w81^tlBE zMQK|r%9Z$r&}ZU|afEYIvU0!L1K2NZx2=v{4JU%ke#f4jPQgo6 zj_Yz(Z~J4?-@`{kUnWbc>gyvBp*)#Cf6|1?OHI8t!0|8gqq6R6#ROb!)7}r2r5&Yw zeBNjQ#yv&H&D1nhhClIcdfJSe-8jBnWPh5lqOz(=5QE}|4?ocu7f(TB)T$8AJn$(e z#>Z*6U$u*DQD7GUVhQ$aTXKG3;dG@);zLmI6H!F_=9gRz-8UZcm3{Fdzet*#`#Uw&8vCrJ^ue7#C$3P8?tFmQ-^9bor zg#3qcb8{`m5Gje{i9d&=cXaVXX=|0~)oDO>Vpsg(e9^ymc5a5k3Cx2zi+UwD`VgnJ z6VrMXfRMG}8rMf9LN`A^YQyvcI>hUA&j#4(Lpw==olyJUf}2Fm+z~5 z@9fL!epGmG--;~{tq0Qg;)BceL?Z{Xh60~uxg2Z`WJv`&6zn)Sd#=3?2*~?6pe=g# zt#D$nO~Pg>sJlh2*;jG)ST2BAhqGps2i~S0&mw#SHc@#v=KNkS%sP(?0>(5A+x4BO z9HOdpgtd5ca(S#R@9uC5rp<@MFSfI+T zEZ^CQLjW7{Et`VvoSsabhI~mekP&HVhgGgAb{f2*Kp6iyv^@jUDEW-zc`QQs z#*!1B*YAhaYZ9Wirs0JkKI$PE0XeZ5o#rE=g$RDh$B=o5&Y9b=9?sI+thzF1)4i&A)t z2hx-;;uQfHsOj)@1X5|i-Vn#vRkGs<_{W^&YwSDX}_p1r&g~*YWXSfc88h#V2pTtA(t%* z&f=9#JubTdc3e>0L>oMwnqGg59kbo5q98oCveKS8ia|OiOZsbTYqy58)02_)*0>^V z#0nY*@b$ekj?1j3jH{Lha}1=mwze*j+<5j~L=9GAHV_&bs#RuLATb+j-`vr0pNZ*J zM-y;xeidRh4!Kq#A1iRMzn}kZGo#^qk?^g= z6RX-|1h{OmT=Kw3k;!7Wx{j+_vLq4I$KM~_*K_mfm_O~KlcCy^r&6Xd=$ACr!66~0 z>6A-Xh7Nwp>?!Bvf1uV!Hx_7yD$kcqI=3bMl-&8zldPTxD;BE?ZT^IhhPiA!9lcCM zB=UUoCs{9nn&s=n^do zPv5;e-^y#;esy$pv?L1}sYq0Ww2fqn<$Ti*e6n zb_O(1dH&~5YiQd3$b%|Us9{w$h28dx60?_82!C{zj>y;z9y1DyKDh*osJ8|0Y{3mI zqts#7gq^whg;^saBV`tMmImxz|9T+acYTx!_CC}%-{RnXk;aE!dfywua|IlJbJP>q}2#m04S z&T$7Nt-2i_7DxaI)=-KO42tr)gRO1S>LV~+df)QAR{~u~wM*gG#!562IE;9&M&rtKpEbP2o)~gCVG;8-3fpD}#_0 zfkKHRI}oQQu7~VI+n0#xe(vm`3G*S2cwaPxNT29r(BEVvj z8=F;D&(*%NxiB|Zpm`-4>3>~tHxa(}^gEwL?aARX3H_X{VsCW~pWUE??FXF^Z)F*xv=_BJN|)M|QviDYe}>Z{1h{iCCM_wJE^b^fWBd2JFS z%ReG@y=V$U!wZ)lyK?2qtOcCZo{7$TfR*i=hem&qhyG`7tI3#E=tE)Vji)|MseFcH z6ZlQ-Ji@}U*D0Y!Mn;@>bc$c~fGzd=o_hQpn7G?QLn&Hf?-9{(d!zGrXsG;{EVS5e zpR7F%F>Y~W$LW*Pw8P{V}sX!z&ri5 zAUcwGT@caj-Q_l8r+5{g(P3?*$P1m%P6w!TgC|k4DV3Ty`wm#ccO>+8`dImiU?3H| zefxIhj+bIr&>g9yq@-8VmV?=muh-BpJv{ctBn?fUl*l-WHiO-Y>=z}OV()R`9k(pJ z-AT#AN6T&Q5Jbbs=`&$9<)M|x>jL&nckgBTMG+{L{to{g>%bm%a=bT{mXgx=zhzIsxX^tx8;v@zr1SNKMc3E0wYlTK_>9_(V zDJ-Q~WMs_3!oqxf!vg~Y9TNsW`bV@nC(?h+h@)d(PIw0uQ8&j2=5oMA&pj>!V0CE% zt4a&nh6DK0jnLMnnMgxgxph+>HKI7#!0XL8`Q#G!q zTk)qZ$2&t{??ekZ6$W;)Eqs1|Ny3xRCm?`Y-lUX8SzlkjD^55g^Js7Vk;Q0#5;8Jp zVRv;b^xMyG4aLHSGsWAE0TCQ~=OLklzmafwYbKGcAHZQDUUwH16x6{uqdvhZ?R_e)grudV`T4a0nDM3hgRyLOa9^52NU{)F2gdF6crQTo zWO!)Ead%~eEkGHBCs?Q|8hc?OmS49*UBW;1iSijv&TErf{RZ}LFYF1}&Poqts(t-< zt;n#2Y%~Wv!mPX+8VZWWPm{Io@sjR(Ck)bDd##?ds~9m=G2a5D*qV?g=p`W>w1!jfr7{`7yy{YAwciTkBF4jk9ipC-#VwQ>k!1 zuy=1|g#5-0`9(uhZh!LLj=_-lc!d!mA))4vu35PGI>bJoE_hWqy(UYZXA2cgzv3Lb@MX6^ZScPiUMZs-54=(vHqQ#sO zF_gH{i}A)8bG+pB;UX^Xpm{HR`|=i2(+VrX|AS3}OtP%7jE|ps z@LbW_dim2!>baE=lM4_?2bK|CucX#gmwv@`5cawd1jWOPATMms!&at{!UGkwceQ}g z0$VVCSwVd=hzofm_&?#D-=>f@=l(w;zaOVzezqihe0+v4Ywn903iD>9%(A&HxSFV! zWmmJ$d1bfjO4!>fK)U~ACb6Izk}<=;5*fy7-MOY0kwjNN+}?pA#o5<9D7Ul~Q}Qji znyE5^K`p$QoWH)+uY`-1fIZgJ9Y>M0-ukt>Sa<|!!$N6 zTDmKmJxz>B^6{?$NxOS zChJKPC`rF?p9xlssaLFI^1J5ELl5)zmrW?@laXqVQ5a!*@lCumrb_I9IVd;wDnIh= zVt{Buj1*`2bKAY2x#Oma_(djYWRmSSJzK^PDB)vpUUl#=326QE$GQ-E;- z%|f0LPGe~P$W;CUw?&@Isp^-WE%-Qmpk!gb=uzQjjmuJ`NY4&!QM=7*6}S12&GLZ@ zjkSRmgT4HXTo|5zf6>tT&)u!~O8xh<)oc=Nof8?Q*TXHD)jA6q?Vav!tvKL&;_k7C z-O8!jAw!t+n6#@vrNt|G9;Gk%5VA=M!fYl9lc$n;>bmr8A-;t#+ds= zIuY_C4qm%|a2OKaU^ckMZ~KuidNwXyyId$vO{!DTdFcz?XzK07^%GOmH-f6p=-+i` zAaS7cAH^Mpde$>mk@%61ltlbYgZ%P|#}EEPL65=wH>FMNDeB6dzq2 zTQ?n)c4#Y*&PtlpWzf)?t)-Vf{4L7`5qcZB5;IwMhOS(?*~u1)qR@=Mg#VIhzFTpx zsoc7<7h9C~=b`0}E8|zE!<)X0hMDdGwdUhp`3YG(UW?#ghbi2axuQ-ZV%+Nmsbo>^ z`(7V*-Q22EyaT`G9r%iaYOyq82!+*VOXes9jpYk%p?ml$(&>D$Ytf#=AVFbNhCeq(aL*)-W+4 zOI|tnRHk_saCAKLFxZYE8lhs7<>O@A7AgSN~D<@%#T`x zM%Zj?KkK7nn@HlK<=P)|dmXeb4At%3niP%8&*gvYMTlJNA$0))&XLfCE zPR?uiqvB#5#bUiF)m63HD+BFe+B4x2b=QH)Waz$z2J&Vm5vzI;J>Xc` z(~HixKb2Zh{|t7)ho?hp+)Pj7F1<<82Y?fN#@Fe=G&ejp|E77KyL5VyUshF`x_;hC z@Bj)zK>{EbK>IVrpKbnAGdy1$Cd2?CBu;xT(}K+q^0YCfdJPyzV6sJCKj|O9UpnRW z4F+lMDOES~hK;wqn(4;?N`V6*{p~JN=X`@a2f6rLJ5(dYLf~`12@BYg5WX(HthqWzO< zS*E;9Sq76VknMF4CIf72!wR6_(dJ)8WSry>LEg~$VS2HS3Gk@s{`c}QP_45^r_VRI z(c_-6fU{sg0D;*5KiXP;OT6z12#;K^LMHxMWK4R)|H{fK5>&NYHAPukz;s5{@Vqr4 zw3C4CBqj@IQ{u5`XukR;mQ@0A8dPy@BTBiVC-bkCD&wNUsxiZh-eP8}fv zz{vC``_UqrlAS=7KKVT0S<4krS_UhR9L}n(3NMTHGKs!%T&i;@-YjG^GCJ)oohh0m z&qk?dLieAEQYb|!!&J4kDTho=S}$AF#)m9EdgsN?3x^LvS;X!u8oA>D^h>(__u<3 z{2Z?<^XDlajv7!eH50bdGzO9%EM1M$5{Q}z{Ux(c*`Fz9_70PUjgno$>CLM zQ62Y9TaAuUVX047OrkMvUS;v8{gkvC^X0oQfpw{T$3=uFLp9%S)B2WOR<7`)cuWv# zZ%(Ca+b4G_qPNA0N!Y=F*g_>}+wruRo3l^dB-iCMG^6`TM*Urddp(Hxk!4Dj=Y|FX zJ&x@u!{rxIzlZ7J4Pc+l*L zl!GV|_HJGrka4S9olw1tahFk2UwGdj7wP{a`}iPxre&*S_uLy1_t1HF^T-N&=)C8j znT{L{_kdu|Cm`@Z^!2;dPK~00qQZbc{_j8d!$jPdAKXjZEG}5vZ?ct_SHNPvqD)iyOX!}zLE8@yb(vJ z?ihgI*;z4kaw{WbwJ^vYq-M^JnU~`%1Mu$Gfa=L`ut}&MO9BgoSJHfn3^me3q zSr11na3so2{LqH8B!^CABQs`ID`70@_Wm;w_n+7IqSo#UdIWYV3gjtQR@;7yyO8h* z?1tva@iu!!p?av!w<#BW8nMNbTZx!>`csqnkZ@+3)dIg>0n~&ac(_Kj1tG%Ktg`rp zEh8XXFkM(pwQRkDAKbUEN_6v**#yS%=;ZytR@;QE@B;bJvQ`sA0riZVYO0=z^_-fH2QF>ok!&xM8HI%l<#4dqII5{muM>CNc?1CMSWnniPOQM%sZ!} zZqrtoKDN6jK5adyhZ+WbVlN>)VFmE=552xG9Of|D9zG zp9+!oY2T`rpJ<%C(EOn^u1jI*Q8QRwep={+r7I1oXebv!6?ZUp;Yq4xRwVfm4CwBL zH_Wy?6moURC~Y3bO;+w}l=$ge8=o>^7Q}OZJ{-7H~4UH zb~$s*Z3eC=*9$7(xgLUz5h$qNkvI?Mb8_mPK@GZ$t1Y5-{lU;#=PTaycEi7TdaHx3`|VvV%ook5BxghoBmyH)Q0Sx zH;ybZmQ_@=R$Ue7h=XRs?kI~;K7{9e_&ZnB@c{mL{=B%jL`#D{4L6wr_7tE7iLEW3 zBfYrzIH+W~+uf99cWGYxPbKfHJjsL{-UO~5bG$Xm7Z~TgTX%Tl+CxluiO(Ff5RD5( zxl24|98;N7Y{0&@vkAw=GD|EJ@C$VC@KhtYOxS2BOZ;2bgg}-89iBdU`p9AeH{^cN zzmm)wE?jr>hTGV56|r#suX^P_!~fr?xBg#Ab;?joVd#lthToLa)X2bn)^*u~#H2r< z1t2U?%GKI|`n()z#lV#VMxfX&`0tBXr@b+sq) z^}{{|QJG7cI9L}t3OCK1TrZ33I&Bbt@tgkzPr*yz;q4iSka=^F>c?XgCV)IX z|I)7So%wRXi{5R2<177GsfD2BIN##Uteaz1*s`}6vnxF=go2h8IXB)O%l)3SCFb!2 zhU-(DLDg2-nyoF10xhkAj*Y#&w%gB(vhfARStdsGAe?8RC;Zby(Efnc71l^9?ihaPmK_C3flIcnVF5&EfnSOl|9VPHmcl^QY$X5lG$rS20)Bru|DdsRn$QAY4No_u_E3(lW6#|0xZ?P z8A)vFEjK`Oiet}cPCH)n zg;$;2y5q9kX)S!tHOBA1u(=KdJuEJ;pYqBs52o!w^fm70RPMG5+4+|a#g}t~zzcOl znSBoy_{!~PBH3JnWNu>1_|T-720I3Y;cn~0ogp210efkIW|qrqYRXt^Gg=O`7{rOx z0M2vUfc7I)j(o-BF!R}IprQ%&Q2L=i?6pHd;H1qo$1ZX@ou2?T?p+ENz**=d8K6@Y z)*mibz4d=k=1R_gd0gR%nW z1Eduz0RKV)YeWX}ztEBY0}BJ=WAhdX7A$`LW2FGi5}xFMDd=7T-o$%=3)B$$kA==%=2aVB(Wte}9X9#Q;y z&NC!@&I?LBPO#Q=>9@TixtG->XJoWmZVVuQAR}%j^Pa;}ewPHmcK}v*%4`3%Zw}m+&mQ_1X7tzo_6sT#`3+FdO&(lxzX~^X6Ykd4lL7aw>!Jv zW+a+}k^mncpBOK)idhTR3g*xBj???^e}^&I^YbI&ax36K7-n_jr_kVjtGag)+1%N zW@osvOb}_OM*mp-=7RS9K>G9A$ufEU7tUNctkL<5-))t=Oc_BKmtzU4TVhZklb*7B z64wD|UcRH`mhg#&7d=c-T7KfFz>XSytf|b4 zQT94xo4N3M-c%%INoP`+vSS{=95kiQFSPXlKGBbA0&ORD9bVMU!(XbyI(vt%*Y5wS ze)NU?MXoX}lWc3M`qCf9EmlxV5&)GW_~dO51i|%9z;ALyZCV&;-ADeUjVtrF@0hUb zE~h87=;*a!<(t z8cw^K;Hm0r7uVOT5jdJi4}50YSjSFJ(NkWh*6j)&THY(vN_*mpVH8#Bo1gyZ1il^H z1Cgv#qDCxqGeA7#b`k)0QQ~o~2k!jeNXm6z^C`4u)hDg4g}$G10|+2Ao+X;bw| zdVm1xlv!R+=`JcWMyuf3x|w3xdg~#1IM`#qMe*c{@Q8{?K~pOIVE@sjHPk<qFn<ybZjGO;0|5a2XyS0RfTmY-!S-uEeW9*W2yK`eZWB!biB!*7G z1y&24U%gXai7<7}T&Tqd^SCg}{pNUe`cRL(8>+aydbO za7fl$FY?nHCHQang)LCH?Lj9Z8VZj$CbB-0i@uJ+kV0C?&cr6MjT(|#*B{*e_78_F zlUF|`FX%^=>RzDtujbY#zPd*y#|#id+C~sMPr!CQK+h4I6MfHd|4UbV&!c6O5UPV7 z1==HEX_4ph*I`i3ELAevaMGcgHXzl*`68)dg7cAAlGWbnRc%5qhMo_s3YYmNLTaxh zF3s&967LN9sfJu$zvaARKwy;6W+HkO0ou-92Dx@-ylHZTcbP~FB8&th;%rY~jvXPW9|r%;Gwh5RZMSZ3=h&Hqa#Qj3p}Tx`3#ta=rm=R3V2% zo7pQ8JeSl0uJYbjYu4qhRW9L{B-r`zM6$?A7s|=1CSKZ;ZE*?gQ?Ro`y*eUQ-(BHY zhC$CoIItJ(y}kMU)}_Mo`EK}CkJHI4_Lp~?x420|pgzia76VoAke_!QwMy41u29B?Vbt9yAd1lRZRip1nclLQZ(7Lnx|f?P z=S^LmS)yM414!#tr#$Y+%3)())JBftHt;82Dh%{k@Rh z{yNGBX^as6Hp%<9=M=wytR~id=9^6a(2=UolVq-cfD1A5f#wxkorzE;obX>jVr#wn zzn0kku%>^z2d>EhTPP?mUIc@~=I_TcXU&%oNa#O@up{^{?n`WKDuH76@5B|nN*u%2 zG0_&SJa5s7GQmw?(+ow{eg1rFdDG(H*3sh(^-^ik>2KNZ~u>>AOA0SkO@&mYE&5~QN`Q=5V zzfSfd3W0~6`_buayd!_d6HA-?W{?qtf&~AZ)dzEl8@^Lq-U*7UP27G{&fn&-+_u2q z>3%FaPta}Sz{v`9m(r~nanjam^s%mzVINZQXJ(K4grq9!aI7g|tTWegvPNyz>7Uxz zD-LuIZ-KhIBfC`;cX$8WyQFLjKynvRyO4f^{xnk}t&^(tQ(SEdRMFc{i?NlBP!gmq z9)+dZ7cCPFRkmqj%QK_=91eMR$8qJw)&=v zQXKfJkwiZgU)Utc<^6?ng6?E#CtE5lp%4N8=zFiA zrr}?LhNs1!D`}~^XKoA7NoXLQ)7)}0(w5U^L zFY*)Joms2@TE=4qH6@>`ML2Wn0Tc0;v9QJ+xu|MzM*HSBWf%=1!BLwNUwaO{y#U&nz&U75g$jz~4_w zzi?b;Tp2X$52Y;FSDf9`VT8BxB9Do!MBSY~D`)5|((HW1O1^)vI{L$Fx}X5&86mj! zJwa$H9F^tu+W!()!T?9QtNxh;R0>(AW_T4|1R%2p<7Y6 zuNagj^rE=tjn`7b9i4cKs<^YGg*`q<=8%Wn>k3pY+>hNe)ryEpnOjMl7+>D|pj^*9 zOBuY-llZ$_XkiN;s>+L&(uv}#-QlNt;RB`}5~GlJ&FQ^AXCBtgtS>Vc+p3{>x#ePBPo~ENl1Qx-UJXAul!py>~0vmo-Oc;6O_E1yy&H~`^>2j62&w6IB5sTXGsghrcan%KPW%croe}n|m zD80lD&=t*!%Fz)PN?`p{!GTVB`7OWzhP}5#I}5s|Q;Ed}?s&?{q8sNfDLfgl0)ZTUwE&gqV5v91jGB(je3RZMw(DdE&yCC6+}L@eft{*3@Dha3mOOWr8R^E zt3Qpqr;U_0h5Q+T`P` z{JG9c5ZzY;tUQ0pFE2cZK#>kF#~F=9sn{Z2Rz9c1~ z7`^UlB^!4H#-@5f>paoBS}&0ou`VH;D+A{JOlnxN4}sjq z;tNm>ysLFrCM+Y%|NN#B|1X@Y@RPO z-OVpRZhqCzD^~xruK#aAoin-RzxT%c=kmd}wD{1Dmnw*4y|~|g27bZ{k9(;toVV?^ zer|8>EI&dN2HLGk#dDj*{#aXl8flkH)l6?!<$4Pmi5?W} zz46S6qpDK>UyyMW^4hKz>;$6kzi1*;HNFr^bB3JGv|fhb~6Sg*jIBv zU{4@l`HVT-TR#PEx?fYL$MooJVEB^?xKS)S-vfNeHs8M}&8&`B7}IFtmo+Y$ zOk<#V1Z$TDH{yVC-T{%NJrn(I78dFTEbdUyYL^-44%!{2T=yorL{EiY|C}x_=lh*w z8^_I$ePjVT>)oT%^8~M&rWAU2zo^6)-U|h)X#2BY?O54gmt<`RxGW1MfQ51p{M3l3 zX#8r;-Xs#pg2fvH>N@=V{5cv0Ge_&S;h`ABD$Q6&Yb*Zc%LzobTt$m)r(ni44#AHl zw1%^vN+;T)r7v>sne_{B0>4F{frS2A2{xA_kI?MHMg8jp$Zj-2i@`cqA%0+Q`j(kV%rNb(Po+(OCJo`p zW%gsx$zV6%zOl38$`Fk-GBHuld9f1&jQ)!=72%qD`ef>soMg6nUo1xXtH7^=sNWqI z5@Hs$!2T4A>u(|)*G&&h_1sjV4~Zt){uMCuW^y9J`S3HqbuL^Yb^&hAWO1P5{vfsZ z$9H*Lz43+iZZC;d7%8xhA8JkIx?yr^aC@v&!rO*?1c^YhJ#+ zH@dws0DNV-?ajmDB{?pq6i1))8;T?hzLU+G6XQk2-ybgU;GfjNe@cXff5<_a$_FVN2qjP^~nbcH-#C>AxSygXGH zPVk7(R{O)ysjpv6wtw_+X9UN~TC*3SM3udt1g3oUap&hgK3K;b_I_yZ`&tHmV`e6X zSOI$SM3%B)m;Cp286U0fOgK(iOjPcGyX?Ktgc?6+RR&T2uOiWG9i}se|DntNPtZ6L zE8qZi&c65#NqOO~_d&y4i@HU(p*ip`gF+TQAz}9OoG6Q&Bdz4 zhlg{Ma;si7J9LoFj8%TZ@2|$fon_cN=;9R8l=WY}58yI+#(SzoJL>zdp2P9-@K~RJ zy#`eMU}`kq3Q)U$TRFI*{@fNw64(^>CKy`-200PJg!lKM1Ykb-<2^cC0DqS)|99FQ z|1+eYAp&??bw|bV7x_~D)k7gxg^P9h?PL1Edo^6RS-idsV^!fjs-tU9k6X{ZOHh7( zS`adaAqgb{<5A^Z1FbF`l;E&pTiAWjXNgVxM&DumUvG$$s^TYgf*w6=IsY9}jCAN7 z(iA9=z(fHj0KeG;RXfC5VE(iwT4{k+L)FaQ03>QxHwnBLO)sXU`~5L%cB~?p+-qs= zQlb8B)W8e$mq?R5M&GHiEa!W{dPlt_)izxkoE%%qL?Pa**yeSK9Kk`T1iuAgV{5zl z^QX6uPb8;-CxgO>tkGjVuL$jzFF`9@d{EG>j+7GB8TR1a^{IH!vok+0sSc$Dtwe$I zuh}*%f~2DcVl+B~FKP2S*C=|C(7U~Im}MDJC8h*jARRqe)xgsN{NM&+byTS`FV6~G zYx?thT3+~YsvNCMTw!?Yn|flU!2i~Rh}zE?0*x&!s_Bn+>_N962qnczpra48^Fg;L zeTjZBd^@?!+?zdb81jZE2o;oNsKT$xu=o|*+$mj%L0*%4Q9(`Q9eOfqo`=T_dU6@Z zn!CF0i4t7C%%oG}0$lAN4O&iAG8&E*8p(om2->}`6A=-Cj{P2m&Gv!XO|53MhyODBUrn zgmgF3NH;@wBi)@N(j_pobPh-e($5~fuIu)>pZ8wt{_uWsjj+x+_hz4a|Ks=_r+)xq zC?Kyd@u}=MI8sZeDfk=*OvehhBB|%K)4b-(=YOG(CjhF~<>8iLne% zE)DQaTm}Qp!^352HI|$Rv+>fUMwb%?m(_t(IU#^RC|Bxq1P`(%t^&|XOqhcIZh`%z zb{Jg4HC8aQJKLCEA|Z4v%d&(hlM>+e2y5s^?9S!cNB_$?Q4xp;(P?PahT?(Xbp-R`1)9-;${GZ1_em64fQGVP5`JXDyRJR4Dy zp`9H)%WFh|6Xggn6+=&$NMEwR#lR4`W@Zn+lpmMb%o`ZH?w4C}k7VO57*0pfs3T`n zhKb{41_p){W1Y`|7WSJ1V4|4n;D`s-X@HR`VZMQleX<;H){X`Og#lq?&6;Z$<|LX- zlfh9^NNZPQH|qiqdg`$nl^jS#P2HCIU4&=-Gw1n`IK$IgyKNW~g16>-*PG)NfeXi>#iPrWkxiy=mX-UK`xvwTyFvI5+P2?v}q9H401KxDNER;`Z>2C_6v`ULEz?g ze6qZ;Q;BT&%A8^6>?>$@^vx3wO|d;eSp@nMJ&69^ofO^xA`xj?KuqY@mCs3WLmk-R za$?z)%Y$sPMe_Nh6-Hx9wa4U|lX{dn06#&cDJBf2i$si<#PJ0f0hlmAW?eh9kmd&~&~{}GRTzBzc`|!}ewrSeOhnp@ zO-)V3a_EUHURpje4Fsb|x zPyIs3!|_`1C=~^V<1j8?8+hqodGceUe zbghlC9h&XUrzq=UjJf+4DQUkXMH1uajSjmm!gYX6h}&5gy)%Etp&jO6d9MBX*IWYm zp>z?v%I-!do3){(PBj_7BJqbt6hYqw@1LsWx?;Ak$k;*AJIH=3HZ;^qt;tng?K{{$ zOifL#JMi)G@4cMKm3e76seSuL4IGT@jw{cipv*Q?kUW{vBQsUt#37{(qK8i3SAirg z(cf!oYi0VI?6xV&7(RTsSX^AB67u0R9P-j50h^Tz18JU0xxvnqRX;5)ZOIGY29e>{ zUkjv+Z=ux%eM>29$dZ1R&2%|k-3;#Rm*GNpqJ-vr{Xw%nr{f{N_hOwQ2%-$1K6v0! z9~9(<>+PPaxQar(y(R>o&6cD*xGP%B9QS;HRsBnjbb6#KNqLTtFK)^8h~ni?wSzBV z)I-jaV$SN?%0~vJrKK@&s7m>2YUb1XXpD5Q;VCm^#%2QS_AEOhINT-oZ$ z`b&Ng5~6j3-5JHte`Nl@<^GgCDA1ITfc_kF*Dt*%PuS|T!KmVicjo9Qi0_Ok6k*D6GmCu z+E9ZY#QhI~e+Bjo4;WG=`1 zsqI`Q6A#sEBe@#S4pf1q#p$VY^ijj%4Dgz8+DD2Ps@HuUDl07oYCiaTWlfKL!RZ-QdQ zQ`^ngujUsP_5hVTIX->_;z8?ufKK-W#XRHVQ&&@SJwZKLrsY)Cjsnhgarc(O47PrN z(!qz*osP!F#xnhWJ%E7Lf+RkJ$>vZdZw1hcsf%^G3Fj?Tdt57VpdjHEu7!jFz=52{ zGUNS79*9|#OLecK0C0NH?e)dr-?=9@UpamwVFIeV}-Ovsmg=Y zOL>vYFi@TW<7A0$G@Ip*E|d#NN&awNqtz-43hiqc5827RKt<0#2>7vJZVBr&{dHjYy?Gf%`@Kn`qgCjb&o%Zqzr)~Dvrl$tv-Rbz!NDiPkf1BiN$Oy&_Q zU}7@ya|y0tKryrV+*vQui@%${ zK|mn2Xb66P4VKk$jYukImG{w=ENuvc9Oyc=+h{X{2IT_JRr%6B=f_*&`~f`3C_gfT z522=*!L8WqMzWm&SybL_b*t4yMdXxt6)*u(`YpqKos1K#tZ4rFb*kCbj;f7n^A3U{ zn?LDKUSo4z^%23gOP4MqB3Q2f3mqML|D4H&+h^})@Vf^1@{a^VzNcsPr_$by+!u}8E!9POd-v&YdCb(Tk8~u1< z`|rEh{~@&c)Ik>&nSj`?-`-r0jylXd4UM{M{83`d&u8@j7pu2f+e_?j9)a4P!zpk)DN7f2vnvE*nn( z!pklKlYl6=-a9Uw5|UIjJaQl1kJ#*Q00y$kz8FIF&S!uI5&R{CZ9krid}riI{eZc) z`sG)@x3gAW?`9n5^nNR&!1@CP>pxE`dRb884*cmSo9ol*uCoG4KI?SLhE3= zlq=!Ug{kj>a>Jp}e1JA~k?diCi-@u}Q1vos0xnOk-a#h&2R-X`1mn$2 zG|s5BH|Gub3EroP*6lq2 zQmCb8A~xkp(i)a=$-5?%7V=>P93R+W3@@ZdLd^JBQ7pV+r#Wt2cY9&M&&Ovm9_b~Y zSe3!@Vfn$K`Xh58RL_P*pCw=N{uL620ky2>*Q|#O^*h zhu@5V+^%>WM%}Hb1kf=*voNG*9n7?mbbzUOiq2WO{fo2t4l39(a8&?^aEJY91EM43 z)@eaqLPFO?*wH>AIR8NJu6X~hQKh58fpn1<%-zs6G`d03`E`PO5H3@cI0z`?ZO151 z_OjU~{sKp5M!n}-b7-0X!E4}^W=+Kwca>Mk5~`pJlw!y&pcLbj!5*hMY@fjXaIgQj z21}ZD^fcLmW`N<%RU-eR}EVuI!`%$_aG zDy4>N4Bc$!GUm!2%9MVQ*&UYtl^I3xTan~0gVg2uy0pq7lfbanYz+Tw6g}SC7BRQJ zCEYU4b2uR&$F%dikG#9r)t@F;Bg3{k&DcFA47!w{qw>x${KXnjoyn6%d3g|V44l;c zTsP<;{DV66;h+Lw?wy6!H8Gty#PH*Ayxz$K=3uWD*A{A;HO}A&Otf2fVh$*y|22-N zQZZp;8_f0?m#s*CD->60Py(}^L6X===~07>YgzAe+P}x)Wg@c`Ws8P1f6-%l!*rcR zl49wf0TfLBa*x7@DOFMKB6MJ;KLmc~HanKSbePd%t=%vo7VUytj9pQjSw6ChpHI}X!z>E{T$8zJ5PHsOB2|AS{nG~X( zY5ioE1HP52{*_H9iJwv@5>OVUhj#Me#`d!}jyC5<`1~8JNXU{wg~tT+Oj^=mnoV~a zA|smQszc9V_9ES~+^VPqhhlsa=xY##p+oi{qK=ng)^}CnxROvcLybuVP zd&Af3$ZoF;z3n}t(NBJp$#V>*T(|YE_(mQ!^xYFS({H&f^pR#F` z3Vrh!Nby9rzy^cT$F#H!vLP@u-`g8UtFLlF|MA`>a4WY4z`c+9u-+1&)QczxRnSsT zJNEMOzIqiC`sK^=6N~%k5!o9pxVP>cw36-By4AA0shqH0Mb_w>#aV{QC8QKOgS$0a z-PhI@Ku^@L7uoUY&vk+5e3kF&mzo#<_(8g<8MM&(eQQRjLIKElVx4!%{<_Xu7I&a z4pM$!QFCIY4Prnv94v-jNIe^u5^c#KmvttjuRG-u@V@2#?>^@K--~q{gN1_uaeKw) z-O!iBx3RW%wAU{nu9!m%rWJzpWZaZ5@Zb;$_Y`bXo1SW@C;M?W8F9CxTlr`LPr~}+`o{9ycy;bY&YIQFEKqxp zCFy{HbRvpqoS17OU+=^wxFJpC*<{Uj`uuxI$)p4T*;Nv8jz&!}eXh2`UWU>-80Rg~L9;I*dNxiP}+DT@$ z!=B!oH-WysBD%VZz(US|Ij@I{II#6uq^efx(kI}M;~SFU7bfEAbLPueR<_BZu;N{V z?R&T$f70mM=$8!aLY3bBbl(Yt{|y%H<*Ug0#+v;wX8R+p57)VW{YB zzE4u9-0C$g6z@Q5-kHcn@mLAFLQ+f^YHJNK*8gOuuIBfGq(%nlw(+}yNe9)EM*E!y zC)FP=Ts1T_Qb`?N7q$Y&Mq$FPt}Z2>fRl-h#*?%80BDZNyLYtA{k^^Nm1Co6dtdw3 zJ#;=j(~?za`^;ZnTvHrLCwdXn1d7(Jv9bgBgP9upF^c$i@(^ao<>k2=8Z!Zw?YvXP z=K-Pa{eceon|nPGgkgM+vQKQ)t2>_IJxm-+Si~Z{e_TEoua2>VPOVQ-ro9pFRE4~ z%#32|tIgbjdaFWx^vCOdxp;Q2UM2<=6A6#+!f+ZQ*e$)nqqbeYHtJLM`{1a0Rk=Fu zzJYGPaxOcfqoDsiNkZfKd3pZ)GxZL8{T1h`ZwoKQJAp=mgql4NCXk_M$39I><=59I z5E*14`daF7CSw~a?+J)to84ns>L;MCJDuQ=g&7XyJYs?NLcdkCuTBVj9+~#cyRG{o z?-llNbfKP6HY-fjQp~~XNfXt=k#dnu$lV_JO1ymn^`AidfX(qIQ0d1xOn1s--_1PE$EFc=K@6N0u5T&_%I?r`1i zw)ZX&!<6c40)H!QfonjSBZ2hJctK>@I|FR|czIQJ`>3MuoY#4p^jN@V)%MiZ??k^= zv*^<>F(yApt(Feu6TJSEPz7j@`g8Am>|p7~c9KQMrlzST)ZhJBwS+(= zWiU)zg^5k^D_j(2T&Nw*f0)w8_#tW6H{C~kpjIF zbKgKs1Y{{&SXiW{rUE0w{aMm{qfKN95QKon))1it9ysG5_q-K0XF<%Y6Nu4m{fm+qI)(9m18YS%C^ zx2NkARa7s)2|H|9g~7%KzKXOIUJQI~d$P1a`7$dvhU#k8A3s$oA-<^M^2$|`l9Jk6 z?gc0s4WTtTIL|z1nfpjmi9F#7oqF}Aj}C$x$IO@ykcfzzy=}?NHOL# zvRe}aeS-1*d1mgInUFlSeh>ZgI*(7r7=MuQR=_3daNOL%2I2KP1p4njf==TzozM3^UV-&8vi~wbvA#Xs;XpUqG@O|`;wFAo$(2XS(J)b7eYeB z95a`0O){_aWY$=(;uG&^;Cyu(Th8u@gV%2s@Hy9B-YaI+*KlqXEbFj zx)#vNXdBo-#G;}ityQ9QUhu%Iv4)wRUS#M|uMZNoBWjpO$+u9#wNNn5{A8E-vF5e# zF|+QTp1HD1tE9CO60uFMxx0f)6t%T)G||&rEqrbo9vY${A2$Ao60RaV9mBA-oVGNY z`gG;I_F7MKxdZl5B;=Y~-zW2Eavf-p+6S9`fuI(c1O$)xZTJ2aWOvL!EMXt}uK-j6 za=^|~43`EGB_6W-dNVBR>H#}bG5i?Ai3bDOUEU0Pb@f0-Kod$2af&5EcISZU!rWZC zq6reIF2tKPB-FxSWT?@LWBgLJ@V zxd*W^W_F#-U+46vpTfpRj+yn~_cu4+sodF=S5i7|CunnajsvePsj|>Xrr=AfG@C^z zmR+@AAG?WZyX^kWH#JB|@`r`V6(^VTql+DHpYpM}HhM-DT#AS5vjSPlWkk-8$>t<| zSr`&ZJ18l|)WeNrryO3rTWb0=YVkT+wHFi2!I$xk-KD(d<`fZ1@@oD5)bMa@Ox!zk z^iWuXBZtObqte?N%vSeAhx;^qA5v1YkE=($8it0>pD-}QY;m8SUB)Mn_2#ucSE+FP zg2<$p9+QmcI$0kvYo9coj2Fl_$rKOjMRgFoRS%QZ@ zwybm}IB-)5ys`tlBZrd5(%%HU0ESp2F#URTUEN8_3(3&r_wpeMOMOa<5wq(giq&)s zhY3O4R5@OxfgcT6wnWDY`LuO3mJc5^Q{JfgeAiMVXgg0=WPLI~LJ8PnuJpZSVme-& zwhPhKJPWj=7mvrl$(%s z7UA=kz6lD5Uq(#>r)(B_NrizDAd+m*C8$;O_g^1N7m<6ngi|RtS8O&_?OA!enm)YA zA3#V%6wPI_4!mGuM@+xpefX*{c!ZqMx+mJ%t>H~TLIZ>RY8L@zlUq}6)s%Q%XsCj< zFcTARlWr%IrIO>Z%8rUf*G}gfMF-2FFlt)$T5HhI!J9PiiP>3<>FH5>A7Ogp>~e-Q zklzXv+*KuCP{2$_!Yuh#d?`?2%Y;sc6nbyNd~#b)wkcQsniCfPf952(+MN3ectb>M zZ!ap*j{la}DqW^@`saC*tNqG|(1YC^+^$`^w{go$DB_}dj;NlO3#cDsW1STTbG``F zq@BOt-JWH0qDkC3`*}&~0;?4f15Ior+UZ;RY#Yz8No&Eg0!KV8&D7~SLO;1YPRe=P z$A-9zYir{Ls{L?SoZ#34Ekf};LS`;3+C{(OVys`Iy`GTR5K;gdYxw~I*f>PBA{jha zVqYQO4`tjH%a?)1u}KfdCN{k>nZ)~pv56W(Xd(c&doW#pZJCyXgZ^24 zg$LJa^AHN3rP#T2LPb+^pxIrh{m`-Uo~_01pbJ5#l?4)p5DQ1!gD0Px-ySy_0%6FCdDr=76v`aS+1=^2Y2Ql%~@tW2rY z+D8jS=Nkp8WgS8B&t<>8@Jvd^Wc|?n(B34dBM)98qxD}jPUoI zhk5b^oR&~g`;ojSKeHZkIWq@b->k4$z~#;0j0Uf%5z7z0$EdZ`*%_eh|K%+2zG=`K zYND83t=WnVh>1MRRi1&z^>nJmQ#f#czvAKTHMIacDrF*SMc>&l%Z|kf;>%ziPNIY( zZgOQkJPh||9dDheu3ih*z>Olm|B0}0g7eJMBDc!c#HV-!__~ZUrM`T=3LdQPAd4C! z5ATim3@=K@o9p^=avE_efh0ShMsN^c$Zs@Sco)%)k1|P=62F)|P4%Vd>zx*a80QA`Vg;u&kjg&V32(dc7*(0EU}l>-CD= zs-nBsEE=Kr@hY7i(hsDMz5P(Pv)2q;6th`s*Sbv}~j z+RQ9>A>!oaUe(5*+6jyLk4TlRrPFTwe&I{ylB@((?6?RQO(*k0hh)Y^1vre{Fg({} zVEM;muYa9J8m58W>I9KareSv-uc&8sF3saNGB!>{e_m>gQ#-lTR|b!G6w_ec(U(}q zxYyn$2?}1r5A>n$ww%_iu|Q`!Th*aa&2vDFjo%eRqwoO;e?Y}6DjMb&hnWABK>w6${!IJE)xV)L^O41J_y#hk9$7RF= z{Qq_M)iz-?UivOP*pLeZXlxV*?99U}w0lR8n{ErJsF)b-egU@iTU}kOz_D5McdThe zK$PdYAK0-Ijx8%QbhJUrcG*e>4&0WLKZIN+xdwvGV2$syWt+EvQTo2Lo*ruxN4CXs zPv&^p3eYo_D_l1^wke(uIG5b})(ms;7oU){=nOK1jJ- sH+R4Fy-+g@>&hkf0geCk@2NmH_k($sn~zUHB@ZGhEb+2XP}Ado02yU|