001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.tools;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.io.BufferedOutputStream;
007import java.io.BufferedReader;
008import java.io.ByteArrayInputStream;
009import java.io.IOException;
010import java.io.InputStream;
011import java.io.OutputStream;
012import java.net.CookieHandler;
013import java.net.CookieManager;
014import java.net.HttpURLConnection;
015import java.net.URL;
016import java.nio.charset.StandardCharsets;
017import java.util.Collections;
018import java.util.List;
019import java.util.Locale;
020import java.util.Map;
021import java.util.Map.Entry;
022import java.util.NoSuchElementException;
023import java.util.Optional;
024import java.util.Scanner;
025import java.util.TreeMap;
026import java.util.concurrent.TimeUnit;
027import java.util.regex.Matcher;
028import java.util.regex.Pattern;
029import java.util.zip.GZIPInputStream;
030
031import org.openstreetmap.josm.Main;
032import org.openstreetmap.josm.data.Version;
033import org.openstreetmap.josm.gui.progress.NullProgressMonitor;
034import org.openstreetmap.josm.gui.progress.ProgressMonitor;
035import org.openstreetmap.josm.io.Compression;
036import org.openstreetmap.josm.io.ProgressInputStream;
037import org.openstreetmap.josm.io.ProgressOutputStream;
038import org.openstreetmap.josm.io.UTFInputStreamReader;
039import org.openstreetmap.josm.io.auth.DefaultAuthenticator;
040import org.openstreetmap.josm.spi.preferences.Config;
041
042/**
043 * Provides a uniform access for a HTTP/HTTPS server. This class should be used in favour of {@link HttpURLConnection}.
044 * @since 9168
045 */
046public final class HttpClient {
047
048    private URL url;
049    private final String requestMethod;
050    private int connectTimeout = (int) TimeUnit.SECONDS.toMillis(Config.getPref().getInt("socket.timeout.connect", 15));
051    private int readTimeout = (int) TimeUnit.SECONDS.toMillis(Config.getPref().getInt("socket.timeout.read", 30));
052    private byte[] requestBody;
053    private long ifModifiedSince;
054    private final Map<String, String> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
055    private int maxRedirects = Config.getPref().getInt("socket.maxredirects", 5);
056    private boolean useCache;
057    private String reasonForRequest;
058    private String outputMessage = tr("Uploading data ...");
059    private HttpURLConnection connection; // to allow disconnecting before `response` is set
060    private Response response;
061    private boolean finishOnCloseOutput = true;
062
063    // Pattern to detect Tomcat error message. Be careful with change of format:
064    // CHECKSTYLE.OFF: LineLength
065    // https://svn.apache.org/viewvc/tomcat/trunk/java/org/apache/catalina/valves/ErrorReportValve.java?r1=1740707&r2=1779641&pathrev=1779641&diff_format=h
066    // CHECKSTYLE.ON: LineLength
067    private static final Pattern TOMCAT_ERR_MESSAGE = Pattern.compile(
068        ".*<p><b>[^<]+</b>[^<]+</p><p><b>[^<]+</b> (?:<u>)?([^<]*)(?:</u>)?</p><p><b>[^<]+</b> (?:<u>)?[^<]*(?:</u>)?</p>.*",
069        Pattern.CASE_INSENSITIVE);
070
071    static {
072        try {
073            CookieHandler.setDefault(new CookieManager());
074        } catch (SecurityException e) {
075            Logging.log(Logging.LEVEL_ERROR, "Unable to set default cookie handler", e);
076        }
077    }
078
079    private HttpClient(URL url, String requestMethod) {
080        this.url = url;
081        this.requestMethod = requestMethod;
082        this.headers.put("Accept-Encoding", "gzip");
083    }
084
085    /**
086     * Opens the HTTP connection.
087     * @return HTTP response
088     * @throws IOException if any I/O error occurs
089     */
090    public Response connect() throws IOException {
091        return connect(null);
092    }
093
094    /**
095     * Opens the HTTP connection.
096     * @param progressMonitor progress monitor
097     * @return HTTP response
098     * @throws IOException if any I/O error occurs
099     * @since 9179
100     */
101    public Response connect(ProgressMonitor progressMonitor) throws IOException {
102        if (progressMonitor == null) {
103            progressMonitor = NullProgressMonitor.INSTANCE;
104        }
105        final HttpURLConnection connection = (HttpURLConnection) url.openConnection();
106        this.connection = connection;
107        connection.setRequestMethod(requestMethod);
108        connection.setRequestProperty("User-Agent", Version.getInstance().getFullAgentString());
109        connection.setConnectTimeout(connectTimeout);
110        connection.setReadTimeout(readTimeout);
111        connection.setInstanceFollowRedirects(false); // we do that ourselves
112        if (ifModifiedSince > 0) {
113            connection.setIfModifiedSince(ifModifiedSince);
114        }
115        connection.setUseCaches(useCache);
116        if (!useCache) {
117            connection.setRequestProperty("Cache-Control", "no-cache");
118        }
119        for (Map.Entry<String, String> header : headers.entrySet()) {
120            if (header.getValue() != null) {
121                connection.setRequestProperty(header.getKey(), header.getValue());
122            }
123        }
124
125        progressMonitor.beginTask(tr("Contacting Server..."), 1);
126        progressMonitor.indeterminateSubTask(null);
127
128        if ("PUT".equals(requestMethod) || "POST".equals(requestMethod) || "DELETE".equals(requestMethod)) {
129            Logging.info("{0} {1} ({2}) ...", requestMethod, url, Utils.getSizeString(requestBody.length, Locale.getDefault()));
130            if (Logging.isTraceEnabled() && requestBody.length > 0) {
131                Logging.trace("BODY: {0}", new String(requestBody, StandardCharsets.UTF_8));
132            }
133            connection.setFixedLengthStreamingMode(requestBody.length);
134            connection.setDoOutput(true);
135            try (OutputStream out = new BufferedOutputStream(
136                    new ProgressOutputStream(connection.getOutputStream(), requestBody.length,
137                            progressMonitor, outputMessage, finishOnCloseOutput))) {
138                out.write(requestBody);
139            }
140        }
141
142        boolean successfulConnection = false;
143        try {
144            try {
145                connection.connect();
146                final boolean hasReason = reasonForRequest != null && !reasonForRequest.isEmpty();
147                Logging.info("{0} {1}{2} -> {3}{4}",
148                        requestMethod, url, hasReason ? (" (" + reasonForRequest + ')') : "",
149                        connection.getResponseCode(),
150                        connection.getContentLengthLong() > 0
151                                ? (" (" + Utils.getSizeString(connection.getContentLengthLong(), Locale.getDefault()) + ')')
152                                : ""
153                );
154                if (Logging.isDebugEnabled()) {
155                    Logging.debug("RESPONSE: {0}", connection.getHeaderFields());
156                }
157                if (DefaultAuthenticator.getInstance().isEnabled() && connection.getResponseCode() == HttpURLConnection.HTTP_UNAUTHORIZED) {
158                    DefaultAuthenticator.getInstance().addFailedCredentialHost(url.getHost());
159                }
160            } catch (IOException | IllegalArgumentException | NoSuchElementException e) {
161                Logging.info("{0} {1} -> !!!", requestMethod, url);
162                Logging.warn(e);
163                //noinspection ThrowableResultOfMethodCallIgnored
164                Main.addNetworkError(url, Utils.getRootCause(e));
165                throw e;
166            }
167            if (isRedirect(connection.getResponseCode())) {
168                final String redirectLocation = connection.getHeaderField("Location");
169                if (redirectLocation == null) {
170                    /* I18n: argument is HTTP response code */
171                    throw new IOException(tr("Unexpected response from HTTP server. Got {0} response without ''Location'' header." +
172                            " Can''t redirect. Aborting.", connection.getResponseCode()));
173                } else if (maxRedirects > 0) {
174                    url = new URL(url, redirectLocation);
175                    maxRedirects--;
176                    Logging.info(tr("Download redirected to ''{0}''", redirectLocation));
177                    return connect();
178                } else if (maxRedirects == 0) {
179                    String msg = tr("Too many redirects to the download URL detected. Aborting.");
180                    throw new IOException(msg);
181                }
182            }
183            response = new Response(connection, progressMonitor);
184            successfulConnection = true;
185            return response;
186        } finally {
187            if (!successfulConnection) {
188                connection.disconnect();
189            }
190        }
191    }
192
193    /**
194     * Returns the HTTP response which is set only after calling {@link #connect()}.
195     * Calling this method again, returns the identical object (unless another {@link #connect()} is performed).
196     *
197     * @return the HTTP response
198     * @since 9309
199     */
200    public Response getResponse() {
201        return response;
202    }
203
204    /**
205     * A wrapper for the HTTP response.
206     */
207    public static final class Response {
208        private final HttpURLConnection connection;
209        private final ProgressMonitor monitor;
210        private final int responseCode;
211        private final String responseMessage;
212        private boolean uncompress;
213        private boolean uncompressAccordingToContentDisposition;
214        private String responseData;
215
216        private Response(HttpURLConnection connection, ProgressMonitor monitor) throws IOException {
217            CheckParameterUtil.ensureParameterNotNull(connection, "connection");
218            CheckParameterUtil.ensureParameterNotNull(monitor, "monitor");
219            this.connection = connection;
220            this.monitor = monitor;
221            this.responseCode = connection.getResponseCode();
222            this.responseMessage = connection.getResponseMessage();
223            if (this.responseCode >= 300) {
224                String contentType = getContentType();
225                if (contentType == null || (
226                        contentType.contains("text") ||
227                        contentType.contains("html") ||
228                        contentType.contains("xml"))
229                        ) {
230                    String content = this.fetchContent();
231                    if (content.isEmpty()) {
232                        Logging.debug("Server did not return any body");
233                    } else {
234                        Logging.debug("Response body: ");
235                        Logging.debug(this.fetchContent());
236                    }
237                } else {
238                    Logging.debug("Server returned content: {0} of length: {1}. Not printing.", contentType, this.getContentLength());
239                }
240            }
241        }
242
243        /**
244         * Sets whether {@link #getContent()} should uncompress the input stream if necessary.
245         *
246         * @param uncompress whether the input stream should be uncompressed if necessary
247         * @return {@code this}
248         */
249        public Response uncompress(boolean uncompress) {
250            this.uncompress = uncompress;
251            return this;
252        }
253
254        /**
255         * Sets whether {@link #getContent()} should uncompress the input stream according to {@code Content-Disposition}
256         * HTTP header.
257         * @param uncompressAccordingToContentDisposition whether the input stream should be uncompressed according to
258         * {@code Content-Disposition}
259         * @return {@code this}
260         * @since 9172
261         */
262        public Response uncompressAccordingToContentDisposition(boolean uncompressAccordingToContentDisposition) {
263            this.uncompressAccordingToContentDisposition = uncompressAccordingToContentDisposition;
264            return this;
265        }
266
267        /**
268         * Returns the URL.
269         * @return the URL
270         * @see HttpURLConnection#getURL()
271         * @since 9172
272         */
273        public URL getURL() {
274            return connection.getURL();
275        }
276
277        /**
278         * Returns the request method.
279         * @return the HTTP request method
280         * @see HttpURLConnection#getRequestMethod()
281         * @since 9172
282         */
283        public String getRequestMethod() {
284            return connection.getRequestMethod();
285        }
286
287        /**
288         * Returns an input stream that reads from this HTTP connection, or,
289         * error stream if the connection failed but the server sent useful data.
290         * <p>
291         * Note: the return value can be null, if both the input and the error stream are null.
292         * Seems to be the case if the OSM server replies a 401 Unauthorized, see #3887
293         * @return input or error stream
294         * @throws IOException if any I/O error occurs
295         *
296         * @see HttpURLConnection#getInputStream()
297         * @see HttpURLConnection#getErrorStream()
298         */
299        @SuppressWarnings("resource")
300        public InputStream getContent() throws IOException {
301            InputStream in;
302            try {
303                in = connection.getInputStream();
304            } catch (IOException ioe) {
305                Logging.debug(ioe);
306                in = Optional.ofNullable(connection.getErrorStream()).orElseGet(() -> new ByteArrayInputStream(new byte[]{}));
307            }
308            in = new ProgressInputStream(in, getContentLength(), monitor);
309            in = "gzip".equalsIgnoreCase(getContentEncoding()) ? new GZIPInputStream(in) : in;
310            Compression compression = Compression.NONE;
311            if (uncompress) {
312                final String contentType = getContentType();
313                Logging.debug("Uncompressing input stream according to Content-Type header: {0}", contentType);
314                compression = Compression.forContentType(contentType);
315            }
316            if (uncompressAccordingToContentDisposition && Compression.NONE.equals(compression)) {
317                final String contentDisposition = getHeaderField("Content-Disposition");
318                final Matcher matcher = Pattern.compile("filename=\"([^\"]+)\"").matcher(
319                        contentDisposition != null ? contentDisposition : "");
320                if (matcher.find()) {
321                    Logging.debug("Uncompressing input stream according to Content-Disposition header: {0}", contentDisposition);
322                    compression = Compression.byExtension(matcher.group(1));
323                }
324            }
325            in = compression.getUncompressedInputStream(in);
326            return in;
327        }
328
329        /**
330         * Returns {@link #getContent()} wrapped in a buffered reader.
331         *
332         * Detects Unicode charset in use utilizing {@link UTFInputStreamReader}.
333         * @return buffered reader
334         * @throws IOException if any I/O error occurs
335         */
336        public BufferedReader getContentReader() throws IOException {
337            return new BufferedReader(
338                    UTFInputStreamReader.create(getContent())
339            );
340        }
341
342        /**
343         * Fetches the HTTP response as String.
344         * @return the response
345         * @throws IOException if any I/O error occurs
346         */
347        public synchronized String fetchContent() throws IOException {
348            if (responseData == null) {
349                try (Scanner scanner = new Scanner(getContentReader()).useDelimiter("\\A")) { // \A - beginning of input
350                    responseData = scanner.hasNext() ? scanner.next() : "";
351                }
352            }
353            return responseData;
354        }
355
356        /**
357         * Gets the response code from this HTTP connection.
358         * @return HTTP response code
359         *
360         * @see HttpURLConnection#getResponseCode()
361         */
362        public int getResponseCode() {
363            return responseCode;
364        }
365
366        /**
367         * Gets the response message from this HTTP connection.
368         * @return HTTP response message
369         *
370         * @see HttpURLConnection#getResponseMessage()
371         * @since 9172
372         */
373        public String getResponseMessage() {
374            return responseMessage;
375        }
376
377        /**
378         * Returns the {@code Content-Encoding} header.
379         * @return {@code Content-Encoding} HTTP header
380         * @see HttpURLConnection#getContentEncoding()
381         */
382        public String getContentEncoding() {
383            return connection.getContentEncoding();
384        }
385
386        /**
387         * Returns the {@code Content-Type} header.
388         * @return {@code Content-Type} HTTP header
389         */
390        public String getContentType() {
391            return connection.getHeaderField("Content-Type");
392        }
393
394        /**
395         * Returns the {@code Expire} header.
396         * @return {@code Expire} HTTP header
397         * @see HttpURLConnection#getExpiration()
398         * @since 9232
399         */
400        public long getExpiration() {
401            return connection.getExpiration();
402        }
403
404        /**
405         * Returns the {@code Last-Modified} header.
406         * @return {@code Last-Modified} HTTP header
407         * @see HttpURLConnection#getLastModified()
408         * @since 9232
409         */
410        public long getLastModified() {
411            return connection.getLastModified();
412        }
413
414        /**
415         * Returns the {@code Content-Length} header.
416         * @return {@code Content-Length} HTTP header
417         * @see HttpURLConnection#getContentLengthLong()
418         */
419        public long getContentLength() {
420            return connection.getContentLengthLong();
421        }
422
423        /**
424         * Returns the value of the named header field.
425         * @param name the name of a header field
426         * @return the value of the named header field, or {@code null} if there is no such field in the header
427         * @see HttpURLConnection#getHeaderField(String)
428         * @since 9172
429         */
430        public String getHeaderField(String name) {
431            return connection.getHeaderField(name);
432        }
433
434        /**
435         * Returns an unmodifiable Map mapping header keys to a List of header values.
436         * As per RFC 2616, section 4.2 header names are case insensitive, so returned map is also case insensitive
437         * @return unmodifiable Map mapping header keys to a List of header values
438         * @see HttpURLConnection#getHeaderFields()
439         * @since 9232
440         */
441        public Map<String, List<String>> getHeaderFields() {
442            // returned map from HttpUrlConnection is case sensitive, use case insensitive TreeMap to conform to RFC 2616
443            Map<String, List<String>> ret = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
444            for (Entry<String, List<String>> e: connection.getHeaderFields().entrySet()) {
445                if (e.getKey() != null) {
446                    ret.put(e.getKey(), e.getValue());
447                }
448            }
449            return Collections.unmodifiableMap(ret);
450        }
451
452        /**
453         * @see HttpURLConnection#disconnect()
454         */
455        public void disconnect() {
456            HttpClient.disconnect(connection);
457        }
458    }
459
460    /**
461     * Creates a new instance for the given URL and a {@code GET} request
462     *
463     * @param url the URL
464     * @return a new instance
465     */
466    public static HttpClient create(URL url) {
467        return create(url, "GET");
468    }
469
470    /**
471     * Creates a new instance for the given URL and a {@code GET} request
472     *
473     * @param url the URL
474     * @param requestMethod the HTTP request method to perform when calling
475     * @return a new instance
476     */
477    public static HttpClient create(URL url, String requestMethod) {
478        return new HttpClient(url, requestMethod);
479    }
480
481    /**
482     * Returns the URL set for this connection.
483     * @return the URL
484     * @see #create(URL)
485     * @see #create(URL, String)
486     * @since 9172
487     */
488    public URL getURL() {
489        return url;
490    }
491
492    /**
493     * Returns the request method set for this connection.
494     * @return the HTTP request method
495     * @see #create(URL, String)
496     * @since 9172
497     */
498    public String getRequestMethod() {
499        return requestMethod;
500    }
501
502    /**
503     * Returns the set value for the given {@code header}.
504     * @param header HTTP header name
505     * @return HTTP header value
506     * @since 9172
507     */
508    public String getRequestHeader(String header) {
509        return headers.get(header);
510    }
511
512    /**
513     * Sets whether not to set header {@code Cache-Control=no-cache}
514     *
515     * @param useCache whether not to set header {@code Cache-Control=no-cache}
516     * @return {@code this}
517     * @see HttpURLConnection#setUseCaches(boolean)
518     */
519    public HttpClient useCache(boolean useCache) {
520        this.useCache = useCache;
521        return this;
522    }
523
524    /**
525     * Sets whether not to set header {@code Connection=close}
526     * <p>
527     * This might fix #7640, see
528     * <a href='https://web.archive.org/web/20140118201501/http://www.tikalk.com/java/forums/httpurlconnection-disable-keep-alive'>here</a>.
529     *
530     * @param keepAlive whether not to set header {@code Connection=close}
531     * @return {@code this}
532     */
533    public HttpClient keepAlive(boolean keepAlive) {
534        return setHeader("Connection", keepAlive ? null : "close");
535    }
536
537    /**
538     * Sets a specified timeout value, in milliseconds, to be used when opening a communications link to the resource referenced
539     * by this URLConnection. If the timeout expires before the connection can be established, a
540     * {@link java.net.SocketTimeoutException} is raised. A timeout of zero is interpreted as an infinite timeout.
541     * @param connectTimeout an {@code int} that specifies the connect timeout value in milliseconds
542     * @return {@code this}
543     * @see HttpURLConnection#setConnectTimeout(int)
544     */
545    public HttpClient setConnectTimeout(int connectTimeout) {
546        this.connectTimeout = connectTimeout;
547        return this;
548    }
549
550    /**
551     * Sets the read timeout to a specified timeout, in milliseconds. A non-zero value specifies the timeout when reading from
552     * input stream when a connection is established to a resource. If the timeout expires before there is data available for
553     * read, a {@link java.net.SocketTimeoutException} is raised. A timeout of zero is interpreted as an infinite timeout.
554     * @param readTimeout an {@code int} that specifies the read timeout value in milliseconds
555     * @return {@code this}
556     * @see HttpURLConnection#setReadTimeout(int)
557     */
558    public HttpClient setReadTimeout(int readTimeout) {
559        this.readTimeout = readTimeout;
560        return this;
561    }
562
563    /**
564     * Sets the {@code Accept} header.
565     * @param accept header value
566     *
567     * @return {@code this}
568     */
569    public HttpClient setAccept(String accept) {
570        return setHeader("Accept", accept);
571    }
572
573    /**
574     * Sets the request body for {@code PUT}/{@code POST} requests.
575     * @param requestBody request body
576     *
577     * @return {@code this}
578     */
579    public HttpClient setRequestBody(byte[] requestBody) {
580        this.requestBody = Utils.copyArray(requestBody);
581        return this;
582    }
583
584    /**
585     * Sets the {@code If-Modified-Since} header.
586     * @param ifModifiedSince header value
587     *
588     * @return {@code this}
589     */
590    public HttpClient setIfModifiedSince(long ifModifiedSince) {
591        this.ifModifiedSince = ifModifiedSince;
592        return this;
593    }
594
595    /**
596     * Sets the maximum number of redirections to follow.
597     *
598     * Set {@code maxRedirects} to {@code -1} in order to ignore redirects, i.e.,
599     * to not throw an {@link IOException} in {@link #connect()}.
600     * @param maxRedirects header value
601     *
602     * @return {@code this}
603     */
604    public HttpClient setMaxRedirects(int maxRedirects) {
605        this.maxRedirects = maxRedirects;
606        return this;
607    }
608
609    /**
610     * Sets an arbitrary HTTP header.
611     * @param key header name
612     * @param value header value
613     *
614     * @return {@code this}
615     */
616    public HttpClient setHeader(String key, String value) {
617        this.headers.put(key, value);
618        return this;
619    }
620
621    /**
622     * Sets arbitrary HTTP headers.
623     * @param headers HTTP headers
624     *
625     * @return {@code this}
626     */
627    public HttpClient setHeaders(Map<String, String> headers) {
628        this.headers.putAll(headers);
629        return this;
630    }
631
632    /**
633     * Sets a reason to show on console. Can be {@code null} if no reason is given.
634     * @param reasonForRequest Reason to show
635     * @return {@code this}
636     * @since 9172
637     */
638    public HttpClient setReasonForRequest(String reasonForRequest) {
639        this.reasonForRequest = reasonForRequest;
640        return this;
641    }
642
643    /**
644     * Sets the output message to be displayed in progress monitor for {@code PUT}, {@code POST} and {@code DELETE} methods.
645     * Defaults to "Uploading data ..." (translated). Has no effect for {@code GET} or any other method.
646     * @param outputMessage message to be displayed in progress monitor
647     * @return {@code this}
648     * @since 12711
649     */
650    public HttpClient setOutputMessage(String outputMessage) {
651        this.outputMessage = outputMessage;
652        return this;
653    }
654
655    /**
656     * Sets whether the progress monitor task will be finished when the output stream is closed. This is {@code true} by default.
657     * @param finishOnCloseOutput whether the progress monitor task will be finished when the output stream is closed
658     * @return {@code this}
659     * @since 10302
660     */
661    public HttpClient setFinishOnCloseOutput(boolean finishOnCloseOutput) {
662        this.finishOnCloseOutput = finishOnCloseOutput;
663        return this;
664    }
665
666    private static boolean isRedirect(final int statusCode) {
667        switch (statusCode) {
668            case HttpURLConnection.HTTP_MOVED_PERM: // 301
669            case HttpURLConnection.HTTP_MOVED_TEMP: // 302
670            case HttpURLConnection.HTTP_SEE_OTHER: // 303
671            case 307: // TEMPORARY_REDIRECT:
672            case 308: // PERMANENT_REDIRECT:
673                return true;
674            default:
675                return false;
676        }
677    }
678
679    /**
680     * @see HttpURLConnection#disconnect()
681     * @since 9309
682     */
683    public void disconnect() {
684        HttpClient.disconnect(connection);
685    }
686
687    private static void disconnect(final HttpURLConnection connection) {
688        if (connection != null) {
689            // Fix upload aborts - see #263
690            connection.setConnectTimeout(100);
691            connection.setReadTimeout(100);
692            try {
693                Thread.sleep(100);
694            } catch (InterruptedException ex) {
695                Logging.warn("InterruptedException in " + HttpClient.class + " during cancel");
696                Thread.currentThread().interrupt();
697            }
698            connection.disconnect();
699        }
700    }
701
702    /**
703     * Returns a {@link Matcher} against predefined Tomcat error messages.
704     * If it matches, error message can be extracted from {@code group(1)}.
705     * @param data HTML contents to check
706     * @return a {@link Matcher} against predefined Tomcat error messages
707     * @since 13358
708     */
709    public static Matcher getTomcatErrorMatcher(String data) {
710        return data != null ? TOMCAT_ERR_MESSAGE.matcher(data) : null;
711    }
712}