001/*
002 *  Copyright 2019 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.core.util;
017
018import java.io.IOException;
019import java.net.HttpURLConnection;
020import java.net.MalformedURLException;
021import java.net.SocketTimeoutException;
022import java.net.URI;
023import java.net.URISyntaxException;
024import java.net.URL;
025import java.net.UnknownHostException;
026import java.util.ArrayList;
027import java.util.Collections;
028import java.util.HashMap;
029import java.util.List;
030import java.util.Map;
031import java.util.Map.Entry;
032import java.util.Optional;
033import java.util.regex.Pattern;
034
035import javax.net.ssl.SSLHandshakeException;
036
037import org.apache.avalon.framework.component.Component;
038import org.apache.commons.lang3.RegExUtils;
039import org.apache.commons.lang3.StringUtils;
040import org.apache.hc.client5.http.classic.HttpClient;
041import org.apache.hc.client5.http.config.ConnectionConfig;
042import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
043import org.apache.hc.client5.http.impl.classic.HttpClients;
044import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
045import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
046import org.apache.hc.core5.util.Timeout;
047import org.slf4j.Logger;
048import org.slf4j.LoggerFactory;
049
050import org.ametys.core.ui.Callable;
051
052/**
053 * Utility class for HTTP urls.
054 */
055public class HttpUtils implements Component
056{
057    /** The Avalon role */
058    public static final String ROLE = HttpUtils.class.getName();
059    
060    /** Regexp for HTTP url */
061    public static final Pattern HTTP_URL_VALIDATOR = Pattern.compile("^(https?:\\/\\/.+)?$");
062    
063    /** The status of HTTP check */
064    public static enum HttpCheck
065    {
066        /** All right */
067        SUCCESS,
068        /** Server error. */
069        SERVER_ERROR,
070        /** URL not found.*/
071        NOT_FOUND,
072        /** Unauthorized. */
073        UNAUTHORIZED,
074        /** Timeout (too long) */
075        TIMEOUT,
076        /** A redirect occurs */
077        REDIRECT,
078        /** Security level error */
079        SECURITY_LEVEL_ERROR,
080        /** No HTTP url */
081        NOT_HTTP
082        
083    }
084    
085    /**
086     * HTTP Status-Code 307: Temporay Redirect
087     */
088    public static final int HTTP_REDIRECT_TEMP = 307;
089    
090    /**
091     * HTTP Status-Code 308: Temporay Redirect
092     */
093    public static final int HTTP_REDIRECT_PERM = 308;
094    
095    
096    private static Logger __logger = LoggerFactory.getLogger(HttpUtils.class);
097    
098    /**
099     * Prepare the connection to the remote url
100     * @param url The url
101     * @param userAgent The user agent. Can be null.
102     * @param method The method for teh URL request. Can be null.
103     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
104     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
105     * @param followRedirects Sets to true to follow HTTP redirects
106     * @return The URL connection
107     * @throws MalformedURLException If the given url is a malformed URL
108     * @throws IOException if an I/O exception occurs
109     */
110    public static HttpURLConnection prepareConnection(String url, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects) throws MalformedURLException, IOException
111    {
112        return prepareConnection(url, userAgent, method, timeout, readTimeOut, followRedirects, Collections.EMPTY_MAP);
113    }
114    
115    /**
116     * Prepare the connection to the remote url
117     * @param httpUrl The HTTP url
118     * @param userAgent The user agent. Can be null.
119     * @param method The method for teh URL request. Can be null.
120     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
121     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
122     * @param followRedirects Sets to true to follow HTTP redirects
123     * @param requestHeaders The request headers.
124     * @return The URL connection
125     * @throws MalformedURLException If the given url is a malformed URL
126     * @throws IOException if an I/O exception occurs
127     */
128    public static HttpURLConnection prepareConnection(String httpUrl, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects, Map<String, String> requestHeaders) throws MalformedURLException, IOException
129    {
130        try
131        {
132            return prepareConnection(new URI(httpUrl).toURL(), userAgent, method, timeout, readTimeOut, followRedirects, requestHeaders);
133        }
134        catch (URISyntaxException e)
135        {
136            throw new IOException(e);
137        }
138    }
139    
140    /**
141     * Prepare the connection to the remote url
142     * @param url The url
143     * @param userAgent The user agent. Can be null.
144     * @param method The method for teh URL request. Can be null.
145     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
146     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
147     * @param followRedirects Sets to true to follow HTTP redirects
148     * @param requestHeaders The request headers.
149     * @return The URL connection
150     * @throws MalformedURLException If the given url is a malformed URL
151     * @throws IOException if an I/O exception occurs
152     */
153    public static HttpURLConnection prepareConnection(URL url, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects, Map<String, String> requestHeaders) throws MalformedURLException, IOException
154    {
155        HttpURLConnection httpUrlConnection = (HttpURLConnection) url.openConnection();
156        
157        if (userAgent != null)
158        {
159            httpUrlConnection.setRequestProperty("User-Agent", userAgent);
160        }
161        if (method != null)
162        {
163            httpUrlConnection.setRequestMethod(method);
164        }
165        if (timeout != -1)
166        {
167            httpUrlConnection.setConnectTimeout(timeout);
168        }
169        
170        if (readTimeOut != -1)
171        {
172            httpUrlConnection.setReadTimeout(readTimeOut);
173        }
174        
175        if (followRedirects)
176        {
177            httpUrlConnection.setInstanceFollowRedirects(true);
178        }
179        
180        for (Entry<String, String> headers : requestHeaders.entrySet())
181        {
182            httpUrlConnection.setRequestProperty(headers.getKey(), headers.getValue());
183        }
184        
185        return httpUrlConnection;
186    }
187    
188    /**
189     * Check the HTTP url
190     * @param httpUrl The HTTP url to test
191     * @param userAgent The user agent. Can be null.
192     * @param method The method for teh URL request. Can be null.
193     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
194     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
195     * @param followRedirects Sets to true to follow HTTP redirects
196     * @return The URL connection
197     */
198    public static HttpCheckReport checkHttpUrl(String httpUrl, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects)
199    {
200        return checkHttpUrl(httpUrl, userAgent, method, timeout, readTimeOut, followRedirects, Collections.EMPTY_MAP);
201    }
202    
203    /**
204     * Check the HTTP url
205     * @param httpUrl The url to test
206     * @param userAgent The user agent. Can be null.
207     * @param method The method for teh URL request. Can be null.
208     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
209     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
210     * @param followRedirects Sets to true to follow HTTP redirects
211     * @param requestHeaders The request headers.
212     * @return The URL connection
213     */
214    public static HttpCheckReport checkHttpUrl(String httpUrl, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects, Map<String, String> requestHeaders)
215    {
216        if (!HTTP_URL_VALIDATOR.matcher(StringUtils.defaultIfEmpty(httpUrl, "")).matches())
217        {
218            __logger.debug("Url '{}' is not a valid HTTP url", httpUrl);
219            return new HttpCheckReport(HttpCheck.NOT_HTTP);
220        }
221        
222        try
223        {
224            return checkHttpUrl(new URI(httpUrl).toURL(), userAgent, method, timeout, readTimeOut, followRedirects, requestHeaders);
225        }
226        catch (MalformedURLException | URISyntaxException e)
227        {
228            __logger.debug("Unable to parse '{}' as a HTTP url", httpUrl, e);
229            return new HttpCheckReport(HttpCheck.NOT_HTTP);
230        }
231    }
232    
233    /**
234     * Check the HTTP url
235     * @param url The url to test
236     * @param userAgent The user agent. Can be null.
237     * @param method The method for teh URL request. Can be null.
238     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
239     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
240     * @param followRedirects Sets to true to follow HTTP redirects
241     * @param requestHeaders The request headers.
242     * @return The URL connection
243     */
244    public static HttpCheckReport checkHttpUrl(URL url, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects, Map<String, String> requestHeaders)
245    {
246        return _checkHttpUrl(url, userAgent, method, timeout, readTimeOut, followRedirects, requestHeaders, new ArrayList<>(), 5);
247    }
248    
249    private static HttpCheckReport _checkHttpUrl(URL url, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects, Map<String, String> requestHeaders, List<String> visitedUrl, int maxRedirects)
250    {
251        try
252        {
253            HttpURLConnection connection = prepareConnection(url, userAgent, method, timeout, readTimeOut, followRedirects, requestHeaders);
254                     
255            if (connection.getResponseCode() == HttpURLConnection.HTTP_OK)
256            {
257                __logger.debug("Check of URL '{}' successed", url);
258                return new HttpCheckReport(HttpCheck.SUCCESS);
259            }
260            else if (connection.getResponseCode() == HttpURLConnection.HTTP_MOVED_TEMP /* 302 */
261                    || connection.getResponseCode() == HttpURLConnection.HTTP_MOVED_PERM /* 301 */
262                    || connection.getResponseCode() == HttpURLConnection.HTTP_SEE_OTHER /* 303 */
263                    || connection.getResponseCode() == HTTP_REDIRECT_TEMP /* 307 */
264                    || connection.getResponseCode() == HTTP_REDIRECT_PERM /* 308 */)
265            {
266                if (followRedirects)
267                {
268                    // Redirection between HTTP and HTTPS URLs is not followed (do it manually)
269                    if (maxRedirects < 0 || visitedUrl.contains(url.toExternalForm()))
270                    {
271                        // The maximum number of redirects has been reached or the url was already been visited when following redirects
272                        throw new IOException("Exceed max number of redirects");
273                    }
274                    
275                    visitedUrl.add(url.toExternalForm());
276                    
277                    URL base = connection.getURL();
278                    String location = connection.getHeaderField("Location");
279                    URL redirect = base.toURI().resolve(location).toURL(); // Deal with relative URLs
280                    
281                    return _checkHttpUrl(redirect, userAgent, method, timeout, readTimeOut, true, requestHeaders, visitedUrl, maxRedirects - 1);
282                }
283                else
284                {
285                    return new HttpCheckReport(HttpCheck.REDIRECT);
286                }
287            }
288            else
289            {
290                __logger.debug("Check of URL '{}' returns the status code {}", url, connection.getResponseCode());
291                
292                int responseCode = connection.getResponseCode();
293                
294                switch (responseCode)
295                {
296                    case HttpURLConnection.HTTP_NOT_FOUND:
297                        return new HttpCheckReport(HttpCheck.NOT_FOUND);
298                    case HttpURLConnection.HTTP_FORBIDDEN:
299                    case HttpURLConnection.HTTP_UNAUTHORIZED:
300                        return new HttpCheckReport(HttpCheck.UNAUTHORIZED);
301                    case HttpURLConnection.HTTP_INTERNAL_ERROR:
302                    default:
303                        return new HttpCheckReport(HttpCheck.SERVER_ERROR, Optional.ofNullable(connection.getResponseMessage()));
304                }
305            }
306        }
307        catch (SSLHandshakeException e)
308        {
309            __logger.debug("Certificate error for URL '{}'", url, e);
310            return new HttpCheckReport(HttpCheck.SECURITY_LEVEL_ERROR, Optional.ofNullable(e.getMessage()));
311        }
312        catch (SocketTimeoutException e)
313        {
314            __logger.debug("Aborting test for URL '{}' because too long", url, e);
315            return new HttpCheckReport(HttpCheck.TIMEOUT);
316        }
317        catch (UnknownHostException e)
318        {
319            __logger.debug("Unknown host for URL '{}'", url, e);
320            return new HttpCheckReport(HttpCheck.NOT_FOUND);
321        }
322        catch (IOException | URISyntaxException e)
323        {
324            __logger.debug("Cannot test URL '{}'", url, e);
325            return new HttpCheckReport(HttpCheck.SERVER_ERROR, Optional.ofNullable(e.getMessage()));
326        }
327    }
328    
329    /**
330     * Method to check a HTTP url from client side.
331     * The HTTP redirects will be followed.
332     * @param httpUrl the http url to check
333     * @return the result of the check
334     */
335    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
336    public Map<String, Object> checkHttpUrl(String httpUrl)
337    {
338        Map<String, Object> result = new HashMap<>();
339        
340        HttpCheckReport report = checkHttpUrl(httpUrl, null, null, 2000, 2000, true);
341        result.put("success", HttpCheck.SUCCESS.equals(report.status()));
342        if (report.message().isPresent())
343        {
344            result.put("message", report.message().get());
345        }
346        result.put("checkResult", report.status().name());
347        
348        return result;
349    }
350    
351    /**
352     * Represent the result of a check and a potential message
353     * @param status the result status
354     * @param message the message if needed
355     */
356    public record HttpCheckReport(HttpCheck status, Optional<String> message)
357    {
358        /**
359         * Create a report with the provided status and no message.
360         * @param result the result status
361         */
362        public HttpCheckReport(HttpCheck result)
363        {
364            this(result, Optional.empty());
365        }
366    }
367    
368    /**
369     * Create ands return a configured, ready to use, {@link HttpClient}.<br>
370     * This method is well suited to create a client dedicated to a single route.
371     * @param maxConnections the maximum simultaneous connections. If zero or negative, defaults to HttpClient's default values.
372     * @param timeout the socket and connect timeout, in seconds. If zero or negative, defaults to HttpClient's default values.
373     * @return a configured {@link HttpClient}
374     */
375    public static CloseableHttpClient createHttpClient(int maxConnections, int timeout)
376    {
377        ConnectionConfig connectionConfig = ConnectionConfig.custom()
378                                                            .setConnectTimeout(timeout > 0 ? Timeout.ofSeconds(timeout) : null)
379                                                            .setSocketTimeout(timeout > 0 ? Timeout.ofSeconds(timeout) : null)
380                                                            .build();
381        
382        PoolingHttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
383                                                                                                        .setDefaultConnectionConfig(connectionConfig)
384                                                                                                        .setMaxConnTotal(maxConnections > 0 ? maxConnections : PoolingHttpClientConnectionManager.DEFAULT_MAX_TOTAL_CONNECTIONS)
385                                                                                                        .setMaxConnPerRoute(maxConnections > 0 ? maxConnections : PoolingHttpClientConnectionManager.DEFAULT_MAX_CONNECTIONS_PER_ROUTE)
386                                                                                                        .build();
387        
388        return HttpClients.custom()
389                          .setConnectionManager(connectionManager)
390                          .useSystemProperties()
391                          .build();
392    }
393    
394    /**
395     * Strips the end of an uri to remove "*.html" ends and "/" ending characters
396     * @param uri The uri to edit
397     * @return The edited uri
398     */
399    public static String sanitize(String uri)
400    {
401        return StringUtils.stripEnd(RegExUtils.removePattern(uri, "[^/]*.html$"), "/");
402    }
403}