Handle Embedded Tomcat Exception in Spring Boot - java

We have an issue where embedded Tomcat is throwing IllegalArgumentException from the LegacyCookieProcessor. It throws a 500 HTTP response code.
We need to handle the exception and do something with it (specifically, send it as a 400 instead).
The typical #ExceptionHandler(IllegalArgumentException.class) doesn't seem to get triggered and Google only seems to give results for dealing with Spring Boot specific exceptions.
Example:
Here is an example to reproduce the behavior. You can execute the example by downloading the initial project including spring-web (https://start.spring.io/) in version 2.1.5.RELEASE. Then add the following two classes to your project.
DemoControllerAdvice.java
package com.example.demo;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
#RestControllerAdvice
public class DemoControllerAdvice {
#ExceptionHandler(IllegalArgumentException.class)
#ResponseStatus(HttpStatus.FORBIDDEN)
public Map<String, String> forbiddenHandler() {
Map<String, String> map = new HashMap<>();
map.put("error", "An error occurred.");
map.put("status", HttpStatus.FORBIDDEN.value() + " " + HttpStatus.FORBIDDEN.name());
return map;
}
}
DemoRestController.java
package com.example.demo;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
#RestController
public class DemoRestController {
#GetMapping(value = "/working")
public void working() {
throw new java.lang.IllegalArgumentException();
}
#GetMapping(value = "/not-working")
public String notWorking(#RequestParam String demo) {
return "You need to pass e.g. the character ^ as a request param to test this.";
}
}
Then, start the server and request the following URLs in the browser:
http://localhost:8080/working An IllegalArgumentException is thrown manually in the controller. It is then caught by the ControllerAdvice and will therefore produce a JSON string containing the information defined in the DemoControllerAdvice
http://localhost:8080/not-working?demo=test^123 An IllegalArgumentException is thrown by the Tomcat, because the request param cannot be parsed (because of the invalid character ^). The exception however is not caught by the ControllerAdvice. It shows the default HTML page provided by Tomcat. It also provides a different error code than defined in the DemoControllerAdvice.
In the logs the following message is shown:
o.apache.coyote.http11.Http11Processor : Error parsing HTTP request header
Note: further occurrences of HTTP request parsing errors will be logged at DEBUG level.
java.lang.IllegalArgumentException: Invalid character found in the request target. The valid characters are defined in RFC 7230 and RFC 3986
at org.apache.coyote.http11.Http11InputBuffer.parseRequestLine(Http11InputBuffer.java:467) ~[tomcat-embed-core-9.0.19.jar:9.0.19]

This is a feature of Tomcat itself as mentioned in this answer.
However, you can do something like this by allowing the special characters that you are expecting as part of your request and handle them yourself.
First, you need to allow the special characters that you would need to handle by setting up the relaxedQueryChars like this.
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.stereotype.Component;
#Component
public class TomcatCustomizer implements
WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
#Override
public void customize(TomcatServletWebServerFactory factory) {
factory.addConnectorCustomizers((connector) -> {
connector.setAttribute("relaxedQueryChars", "^");
});
}
}
and later handle the special characters in each of your requests or create an interceptor and handle it in a common place.
To handle it in the request individually you can do something like this.
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
#RestController
public class DemoRestController {
#GetMapping(value = "/working")
public void working() {
throw new java.lang.IllegalArgumentException();
}
#GetMapping(value = "/not-working")
public String notWorking(#RequestParam String demo) {
if (demo.contains("^")) {
throw new java.lang.IllegalArgumentException("^");
}
return "You need to pass e.g. the character ^ as a request param to test this.";
}
}
You might also want to refer this answer to decide if you really need this fix.

Try to catch the IllegalArgumentException in your filter, then call HttpServletResponse.sendError(int sc, String msg);. This may catch the IllegalArgumentExceptions that do not come from Tomcat though. But I suppose you already handle them properly.

Related

spring boot upload file with the other parameter by using #RequestPart cause Excption

when using spring boot upload file with parameter, I used #RequestPart for all parameter.
Here the code:
spring boot: 2.7.8
when one of them is String and the other is Integer and both of them annotated by #RequestPart will be cause Exception
package com.interfaces.anti.mage.api;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.interfaces.anti.mage.model.Address;
import com.interfaces.anti.mage.model.Order;
import com.interfaces.anti.mage.service.OrderService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
* #author dengbojing
*/
#RestController
#RequestMapping("/order")
public class OrderApi {
#PostMapping("/upload")
public String upload(#RequestPart("file")MultipartFile file, #RequestPart("id") String id, #RequestPart("number") Integer number) {
System.out.println(id);
System.out.println(number);
return "success";
}
}
Exception info:
2023-02-13 22:51:40,319 WARN [http-nio-8099-exec-2] org.springframework.web.servlet.handler.AbstractHandlerExceptionResolver: Resolved [org.springframework.web.HttpMediaTypeNotSupportedException: Content type 'application/octet-stream' not supported]
when all the other parameter type is String and annotated by #RequestPart, the method will be worked and print the info.
#PostMapping("/upload")
public String upload(#RequestPart("file")MultipartFile file, #RequestPart("id") String id, #RequestPart("number") String number) {
System.out.println(id);
System.out.println(number);
return "success";
}
So why this? the exception means the program treat the String and Integer paramter as Stream? but why all String will be fine?
ps: even change to this #PostMapping(value = "/upload", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE}) also got the same problem
ps: this is the request info:
I take the chance of this post because it pointed me to the right direction anyway so, for sake of anyone using Postman, receiving the error above here is my hint, learnt with pain and solved thanks to the hints above.
In Postman, when you fill a form-data body, the table misses, by default I believe, the CONTENT TYPE column.
You can add the column the using the button on the right side, but then it will show Auto consequently, depending from your parameters, you should change it to the proper type.
I hope it may help others :)
Regards

Spring Cloud Gateway - modify response body in global Post filter

I have spent the last 2 days trying every possible way of modifying the response body of a request before it hits the client, and nothing seems to work for me. So far I have tried the implementations mentioned here, here, here, here, here and a few others that I can't find right now, but nothing has worked. It doesn't matter if I define the filter as pre, post, global, gateway or route-specific - the actual response modification doesn't seem to work for me.
My situation is the following:
I have a YAML-configured API gateway running and have configured one of its routes to lead to an ADF service in the background. The issue I have with this ADF application is that the response it returns to the client is in the form of an HTML template that is automatically generated by its backend. In this template, some of the URLs are hardcoded and point to the address of the application itself. To justify the use of an API Gateway in this case, I want to replace those ADF URLs with those of the API Gateway.
For simplicity's sake, let's say the IP address of my ADF service is 1.2.3.4:1234, and the IP address of my API Gateway is localhost:8080. When I hit the ADF route in my gateway, the response contains some auto-generated javascript inserts, such as this one:
AdfPage.PAGE.__initializeSessionTimeoutTimer(1800000, 120000, "http://1.2.3.4:1234/entry/dynamic/index.jspx");
As you can see, it contains a hardcoded URL. I want to access the response body and find all those hardcoded URLs and replace them with the gateway URL, so the above example becomes:
AdfPage.PAGE.__initializeSessionTimeoutTimer(1800000, 120000, "http://localhost:8080/entry/dynamic/index.jspx");
To do this, it seems sensible to me to have a global POST filter that kicks in only when the request matches the route for my ADF application, so that's what I've settled on doing.
Here is my post filter so far:
#Bean
public GlobalFilter globalADFUrlReplacementFilter() {
return (exchange, chain) -> chain.filter(exchange).then(Mono.just(exchange)).map(serverWebExchange -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
if (requestIsTowardsADF(request)) {
logger.info("EXECUTING GLOBAL POST FILTER FOR ADF TEMPLATE URL REPLACEMENT");
ServerHttpResponseDecorator responseDecorator = new ServerHttpResponseDecorator(response) {
#Override
#SuppressWarnings("unchecked")
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
logger.info("OVERRIDING writeWith METHOD TO MODIFY THE BODY");
Flux<? extends DataBuffer> flux = (Flux<? extends DataBuffer>) body;
return super.writeWith(flux.buffer().map(buffer -> {
DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory();
DataBuffer join = dataBufferFactory.join(buffer);
byte[] content = new byte[join.readableByteCount()];
join.read(content);
DataBufferUtils.release(join);
String bodyStr = new String(content, StandardCharsets.UTF_8);
bodyStr = bodyStr.replace(ADF_URL, API_GATEWAY_URL);
getDelegate().getHeaders().setContentLength(bodyStr.getBytes().length);
return bufferFactory().wrap(bodyStr.getBytes());
}));
}
};
logger.info("ADF URL REPLACEMENT FILTER DONE");
return chain.filter(serverWebExchange.mutate().request(request).response(responseDecorator).build());
}
return serverWebExchange;
})
.then();
}
And the config:
spring:
cloud:
gateway:
routes:
- id: adf-test-2
uri: http://1.2.3.4:1234
predicates:
- Path=/entry/**
You can see that I'm using a org.slf4j.Logger object to log messages in the console. When I run my API Gateway and hit the ADF route, I can see the following:
EXECUTING GLOBAL POST FILTER FOR ADF TEMPLATE URL REPLACEMENT
ADF URL REPLACEMENT FILTER DONE
And when I check the response I got back from the API Gateway, I can see that the response body is still identical and the ADF URLs have not been replaced at all. I tried debugging the application and as soon as it reaches ServerHttpResponseDecorator responseDecorator = new ServerHttpResponseDecorator(response) { it skips over the entire anonymous class implementation within those curly braces. A testament to that is the absence of the OVERRIDING writeWith METHOD TO MODIFY THE BODY log in the console - it never got executed!
It seems that for some reason the actual body modification doesn't get executed and I can't figure out why. I tried several different implementations of this filter, as mentioned in the above links, and neither of them worked.
Can someone please share with me a working POST filter that modifies the response body, or point out the flaw in my solution?
Thanks a bunch in advance!
Thanks for sharing this sample filter cdan. I provided the most straightforward solution to my issue using it as a template. Here's how it looks:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.rewrite.ModifyResponseBodyGatewayFilterFactory;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
#Component
public class TestFilter2 extends AbstractGatewayFilterFactory<TestFilter2.Config> {
public static final String ADF_URL = "1.2.3.4:1234";
public static final String AG_URL = "localhost:8080";
final Logger logger = LoggerFactory.getLogger(TestFilter2.class);
public static class Config {
private String param1;
public Config() {
}
public void setParam1(String param1) {
this.param1 = param1;
}
public String getParam1() {
return param1;
}
}
#Override
public List<String> shortcutFieldOrder() {
return List.of("param1");
}
private final ModifyResponseBodyGatewayFilterFactory modifyResponseBodyFilterFactory;
public TestFilter2() {
super(Config.class);
this.modifyResponseBodyFilterFactory = new ModifyResponseBodyGatewayFilterFactory(new ArrayList<>(), new HashSet<>(), new HashSet<>());
}
#Override
public GatewayFilter apply(Config config) {
final ModifyResponseBodyGatewayFilterFactory.Config modifyResponseBodyFilterFactoryConfig = new ModifyResponseBodyGatewayFilterFactory.Config();
modifyResponseBodyFilterFactoryConfig.setRewriteFunction(String.class, String.class, (exchange, bodyAsString) -> Mono.just(bodyAsString.replace(ADF_URL, AG_URL)));
return modifyResponseBodyFilterFactory.apply(modifyResponseBodyFilterFactoryConfig);
}
}
I have added this filter to my route definition like so:
spring:
cloud:
gateway:
httpclient:
wiretap: true
httpserver:
wiretap: true
routes:
- id: adf-test-2
uri: http://1.2.3.4:1234
predicates:
- Path=/entry/**
filters:
- TestFilter2
I'm simply trying to modify the response body and replace the ADF URL in it with the AG URL, but whenever I try to hit the ADF route I get the below exception:
2022-05-08 17:35:19.492 ERROR 87216 --- [ctor-http-nio-3] a.w.r.e.AbstractErrorWebExceptionHandler : [284b180d-1] 500 Server Error for HTTP GET "/entry/dynamic/index.jspx"
org.springframework.web.reactive.function.UnsupportedMediaTypeException: Content type 'text/html' not supported for bodyType=java.lang.String
at org.springframework.web.reactive.function.BodyExtractors.lambda$readWithMessageReaders$12(BodyExtractors.java:201) ~[spring-webflux-5.3.18.jar:5.3.18]
Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
Error has been observed at the following site(s):
*__checkpoint ? Body from UNKNOWN [DefaultClientResponse]
*__checkpoint ? org.springframework.cloud.gateway.filter.WeightCalculatorWebFilter [DefaultWebFilterChain]
*__checkpoint ? HTTP GET "/entry/dynamic/index.jspx" [ExceptionHandlingWebHandler]
I searched the web for some time but wasn't able to find any clear answer on why this UnsupportedMediaTypeException: Content type 'text/html' not supported for bodyType=java.lang.String exception gets thrown when I try to work with the bodyAsString field that is supposed to contain the response body as String. Debugging the entire filter didn't work either, as the exception seems to be thrown immediately after I hit the route and I can't even get in the body of that class. Am I missing something obvious?
UPDATE (09.05.2022):
After looking into this further, I refactored the filter structure a bit by removing the unnecessary parameter in the config, and Autowiring the dependency towards ModifyResponseBodyGatewayFilterFactory, and now it seems the filter works properly and does the replacement I needed it to do. I will test it a bit longer to make sure it does indeed work as expected, and if it does, I'll mark this as the solution. Thanks for all of your input cdan!
Here's the entire filter:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.rewrite.ModifyResponseBodyGatewayFilterFactory;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
#Component
public class TestFilter2 extends AbstractGatewayFilterFactory<TestFilter2.Config> {
public static final String ADF_URL = "1.2.3.4:1234";
public static final String AG_URL = "localhost:8080";
#Autowired
private final ModifyResponseBodyGatewayFilterFactory modifyResponseBodyFilterFactory;
public static class Config {
public Config() {
}
}
public TestFilter2(ModifyResponseBodyGatewayFilterFactory modifyResponseBodyFilterFactory) {
super(Config.class);
this.modifyResponseBodyFilterFactory = modifyResponseBodyFilterFactory;
}
#Override
public GatewayFilter apply(Config config) {
final ModifyResponseBodyGatewayFilterFactory.Config modifyResponseBodyFilterFactoryConfig = new ModifyResponseBodyGatewayFilterFactory.Config();
modifyResponseBodyFilterFactoryConfig.setRewriteFunction(String.class, String.class, (exchange, bodyAsString) -> Mono.just(bodyAsString.replace(ADF_URL, AG_URL)));
return modifyResponseBodyFilterFactory.apply(modifyResponseBodyFilterFactoryConfig);
}
}
Try with the built-in ModifyResponseBody Filter with Java DSL. If you still need more advanced response processing, your next option is to extend the ModifyResponseBodyGatewayFilterFactory class.
(Update 2022-05-08)
For example, using the Delegation design pattern (wrapping the built-in ModifyResponseBodyFilter in a new custom filter taking one custom parameter):
package test;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.rewrite.ModifyResponseBodyGatewayFilterFactory;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.util.*;
#Component
public class MyFilterFactory extends AbstractGatewayFilterFactory<MyFilterFactory.Config>
{
public static class Config
{
private String param1;
// Add other parameters if necessary
public Config() {}
public void setParam1(String param1) {
this.param1 = param1;
}
public String getParam1() {
return param1;
}
// Add getters and setters for other parameters if any
}
#Override
public List<String> shortcutFieldOrder()
{
return Arrays.asList("param1" /*, other parameters */ );
}
private final ModifyResponseBodyGatewayFilterFactory modifyResponseBodyFilterFactory;
public MyFilterFactory()
{
super(Config.class);
this.modifyResponseBodyFilterFactory = new ModifyResponseBodyGatewayFilterFactory(new ArrayList<>(), new HashSet<>(), new HashSet<>());
}
#Override
public GatewayFilter apply(Config config)
{
final ModifyResponseBodyGatewayFilterFactory.Config modifyResponseBodyFilterFactoryConfig = new ModifyResponseBodyGatewayFilterFactory.Config();
modifyResponseBodyFilterFactoryConfig.setNewContentType(MediaType.TEXT_HTML_VALUE);
modifyResponseBodyFilterFactoryConfig.setRewriteFunction(String.class, String.class, (exchange, bodyAsString) -> {
final String output;
/*
Do whatever transformation of bodyAsString (response body as String) and assign the result to output...
*/
return Mono.just(output);
});
return modifyResponseBodyFilterFactory.apply(modifyResponseBodyFilterFactoryConfig);
}
}

Javax validation does not include field name

We have built an API in which in our model We have a field
#Min(1) #Max(16)
private Long demoField;
When we provide 17 to demoField
it will throw us an error on the client-side
"must be less than or equal to 16"
But when we see the violation message it includes the field name and the message looks like
"demoField: must be less than or equal to 16"
So the question of why we are not getting field name in the client-side error message.
Am I missing something?
API built on spring boot
It's not passed by default. You could implement your own error handler to customize the message passed back, by using #ControllerAdvice for example.
One way is to just specify the message:
#Min(value = 5, message="Age must be at least 5")
In which case in the #ControllerAdvice, you would just need to read getDefaultMessage()
If you don't want to manually add default messages, the approach would be to implement something along the lines of (with appropriate null checks etc):
package com.example.demo;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
#ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
#Override
protected ResponseEntity<Object> handleBindException(
BindException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
return new ResponseEntity<>(
ex.getFieldError().getField() + ": " + ex.getFieldError().getDefaultMessage(),
headers,
status);
}
}

RestOperation exchange rejects cookie

I have a class which checks the health status of a REST-Interface. It works nice but my log files are full of these warnings:
2017-08-23 03:59:58.707 WARN 1849 --- [io-13811-exec-5] o.a.h.c.protocol.ResponseProcessCookies : Cookie rejected [JSESSIONID="14747303A2F23D4BE6DBAE0F282DEA94", version:0, domain:dealersearch.....com, path:/DCRMBroker/, expiry:null] Illegal 'path' attribute "/DCRMBroker/". Path of origin: "/system/healthcheck.jsp"
It seems the requests do not accept any cookies. I do not understand what is going on and how to accept them to get around the masses of log messages.
My health check class:
package com......commons.health;
import com.....commons.health.HealthResult.Health;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.web.client.RestOperations;
public abstract class RestHealthCheck extends HealthCheck {
private static final Logger LOGGER = LoggerFactory.getLogger(RestHealthCheck.class);
public RestHealthCheck() {}
public abstract String getUrl();
public abstract RestOperations getRestOperations();
public HealthResult getHealthResult() {
HealthResult result = new HealthResult();
result.setName(this.getName());
result.setHealth(Health.HEALTHY);
result.setMessage((String)null);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.TEXT_PLAIN);
HttpEntity entity = new HttpEntity(headers);
try {
this.getRestOperations().exchange(this.getUrl(), HttpMethod.HEAD, entity, String.class, new Object[0]);
} catch (Exception var5) {
LOGGER.trace("Health check failed", var5);
result.setHealth(Health.UNHEALTHY);
result.setMessage(var5.getMessage());
}
return result;
}
}
An URL http://server.example.com/system/healthcheck.jsp is requested and the response contained a cookie that is defined for the URL http://server.example.com/DCRMBroker/.
The URLs are different and a client worth its name should reject that which happens here leading to the logs. The information you gave is not enough to say for sure who the culprit is. It can be the JSP-page setting a wrong cookie, it might be a forwarding rule on the server, so the JSP-page is internally forwarded to a different resource without changing the path within the response. It might also be something on your client where some kind of redirection takes place leading to this effect.
Maybe my explanations help you to track the reason down. If it's something on your side, you can fix that. If it's something on the other side, you might get in contact with them in order to let that fix that. Alternatively you can check your client if you can switch off the warning for this particular case so your log becomes calm again.

How to specify all routes except one?

I'm trying to overwrite my old project from native JS to React with MobX. My application has several pages and I want to use react-router to handle them. For that I need to return index.html page for all routes from my Spring-boot controller:
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
#Controller
public class RoutesController {
#RequestMapping(value = { "", "/**" }, method = RequestMethod.GET)
public String index() {
return "index";
}
}
Also I have special folder for static components on embedded Apache Tomcat server. This folder contains build.js for my client-side application.
Controller presented above can serve all requests to server with index.html page, but I have also client-side application on /static/** route.
How can I specify all routes in my controller except /static/**?
I solved my problem. To get only one page for all requests your controller must implement ErrorController and two methods: error and getErrorPath.
Here is an example:
import org.springframework.boot.autoconfigure.web.ErrorController;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
#Controller
public class RoutesController implements ErrorController {
private static final String PATH = "/error";
#RequestMapping(value = PATH)
public String error() {
return "index";
}
#Override
public String getErrorPath() {
return PATH;
}
}
Server get the request and will not find any routes, so it will return result of error method.
Maybe that's not the best solution, but after three days of searching the Internet I have not found anything better.

Categories