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