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.URL;
023import java.net.UnknownHostException;
024import java.util.ArrayList;
025import java.util.Collections;
026import java.util.HashMap;
027import java.util.List;
028import java.util.Map;
029import java.util.Map.Entry;
030import java.util.Optional;
031import java.util.regex.Pattern;
032
033import javax.net.ssl.SSLHandshakeException;
034
035import org.apache.avalon.framework.component.Component;
036import org.apache.commons.lang3.StringUtils;
037import org.slf4j.Logger;
038import org.slf4j.LoggerFactory;
039
040import org.ametys.core.ui.Callable;
041
042/**
043 * Utility class for HTTP urls.
044 */
045public class HttpUrlUtils implements Component
046{
047    /** The Avalon role */
048    public static final String ROLE = HttpUrlUtils.class.getName();
049    
050    /** Regexp for HTTP url */
051    public static final Pattern HTTP_URL_VALIDATOR = Pattern.compile("^(https?:\\/\\/.+)?$");
052    
053    /** The status of HTTP check */
054    public static enum HttpCheck
055    {
056        /** All right */
057        SUCCESS,
058        /** Server error. */
059        SERVER_ERROR,
060        /** URL not found.*/
061        NOT_FOUND,
062        /** Unauthorized. */
063        UNAUTHORIZED,
064        /** Timeout (too long) */
065        TIMEOUT,
066        /** A redirect occurs */
067        REDIRECT,
068        /** Security level error */
069        SECURITY_LEVEL_ERROR,
070        /** No HTTP url */
071        NOT_HTTP
072        
073    }
074    
075    /**
076     * HTTP Status-Code 307: Temporay Redirect
077     */
078    public static final int HTTP_REDIRECT_TEMP = 307;
079    
080    /**
081     * HTTP Status-Code 308: Temporay Redirect
082     */
083    public static final int HTTP_REDIRECT_PERM = 308;
084    
085    
086    private static Logger __logger = LoggerFactory.getLogger(HttpUrlUtils.class);
087    
088    /**
089     * Prepare the connection to the remote url
090     * @param url The url 
091     * @param userAgent The user agent. Can be null.
092     * @param method The method for teh URL request. Can be null.
093     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
094     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
095     * @param followRedirects Sets to true to follow HTTP redirects 
096     * @return The URL connection
097     * @throws MalformedURLException If the given url is a malformed URL
098     * @throws IOException if an I/O exception occurs
099     */
100    public static HttpURLConnection prepareConnection(String url, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects) throws MalformedURLException, IOException
101    {
102        return prepareConnection(url, userAgent, method, timeout, readTimeOut, followRedirects, Collections.EMPTY_MAP);
103    }
104    
105    /**
106     * Prepare the connection to the remote url
107     * @param httpUrl The HTTP url 
108     * @param userAgent The user agent. Can be null.
109     * @param method The method for teh URL request. Can be null.
110     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
111     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
112     * @param followRedirects Sets to true to follow HTTP redirects 
113     * @param requestHeaders The request headers.
114     * @return The URL connection
115     * @throws MalformedURLException If the given url is a malformed URL
116     * @throws IOException if an I/O exception occurs
117     */
118    public static HttpURLConnection prepareConnection(String httpUrl, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects, Map<String, String> requestHeaders) throws MalformedURLException, IOException
119    {
120        return prepareConnection(new URL(httpUrl), userAgent, method, timeout, readTimeOut, followRedirects, requestHeaders);
121    }
122    
123    /**
124     * Prepare the connection to the remote url
125     * @param url The url
126     * @param userAgent The user agent. Can be null.
127     * @param method The method for teh URL request. Can be null.
128     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
129     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
130     * @param followRedirects Sets to true to follow HTTP redirects 
131     * @param requestHeaders The request headers.
132     * @return The URL connection
133     * @throws MalformedURLException If the given url is a malformed URL
134     * @throws IOException if an I/O exception occurs
135     */
136    public static HttpURLConnection prepareConnection(URL url, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects, Map<String, String> requestHeaders) throws MalformedURLException, IOException
137    {
138        HttpURLConnection httpUrlConnection = (HttpURLConnection) url.openConnection();
139        
140        if (userAgent != null)
141        {
142            httpUrlConnection.setRequestProperty("User-Agent", userAgent);
143        }
144        if (method != null)
145        {
146            httpUrlConnection.setRequestMethod(method);
147        }
148        if (timeout != -1)
149        {
150            httpUrlConnection.setConnectTimeout(timeout);
151        }
152        
153        if (readTimeOut != -1)
154        {
155            httpUrlConnection.setReadTimeout(readTimeOut);
156        }
157        
158        if (followRedirects)
159        {
160            httpUrlConnection.setInstanceFollowRedirects(true);
161        }
162        
163        for (Entry<String, String> headers : requestHeaders.entrySet())
164        {
165            httpUrlConnection.setRequestProperty(headers.getKey(), headers.getValue());
166        }
167        
168        return httpUrlConnection;
169    }
170    
171    /**
172     * Check the HTTP url
173     * @param httpUrl The HTTP url to test 
174     * @param userAgent The user agent. Can be null.
175     * @param method The method for teh URL request. Can be null.
176     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
177     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
178     * @param followRedirects Sets to true to follow HTTP redirects 
179     * @return The URL connection
180     */
181    public static HttpCheckReport checkHttpUrl(String httpUrl, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects)
182    {
183        return checkHttpUrl(httpUrl, userAgent, method, timeout, readTimeOut, followRedirects, Collections.EMPTY_MAP);
184    }
185    
186    /**
187     * Check the HTTP url
188     * @param httpUrl The url to test 
189     * @param userAgent The user agent. Can be null.
190     * @param method The method for teh URL request. Can be null.
191     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
192     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
193     * @param followRedirects Sets to true to follow HTTP redirects 
194     * @param requestHeaders The request headers.
195     * @return The URL connection
196     */
197    public static HttpCheckReport checkHttpUrl(String httpUrl, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects, Map<String, String> requestHeaders)
198    {
199        if (!HTTP_URL_VALIDATOR.matcher(StringUtils.defaultIfEmpty(httpUrl, "")).matches())
200        {
201            __logger.debug("Url '{}' is not a valid HTTP url", httpUrl);
202            return new HttpCheckReport(HttpCheck.NOT_HTTP);
203        }
204        
205        try
206        {
207            return checkHttpUrl(new URL(httpUrl), userAgent, method, timeout, readTimeOut, followRedirects, requestHeaders);
208        }
209        catch (MalformedURLException e) 
210        {
211            __logger.debug("Unable to parse '{}' as a HTTP url", httpUrl, e);
212            return new HttpCheckReport(HttpCheck.NOT_HTTP);
213        }
214    }
215    
216    /**
217     * Check the HTTP url
218     * @param url The url to test 
219     * @param userAgent The user agent. Can be null.
220     * @param method The method for teh URL request. Can be null.
221     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
222     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
223     * @param followRedirects Sets to true to follow HTTP redirects 
224     * @param requestHeaders The request headers.
225     * @return The URL connection
226     */
227    public static HttpCheckReport checkHttpUrl(URL url, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects, Map<String, String> requestHeaders)
228    {
229        return _checkHttpUrl(url, userAgent, method, timeout, readTimeOut, followRedirects, requestHeaders, new ArrayList<>(), 5);
230    }
231    
232    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)
233    {
234        try
235        {
236            HttpURLConnection connection = prepareConnection(url, userAgent, method, timeout, readTimeOut, followRedirects, requestHeaders);
237                     
238            if (connection.getResponseCode() == HttpURLConnection.HTTP_OK)
239            {
240                __logger.debug("Check of URL '{}' successed", url);
241                return new HttpCheckReport(HttpCheck.SUCCESS);
242            }
243            else if (connection.getResponseCode() == HttpURLConnection.HTTP_MOVED_TEMP /* 302 */ 
244                    || connection.getResponseCode() == HttpURLConnection.HTTP_MOVED_PERM /* 301 */ 
245                    || connection.getResponseCode() == HttpURLConnection.HTTP_SEE_OTHER /* 303 */
246                    || connection.getResponseCode() == HTTP_REDIRECT_TEMP /* 307 */
247                    || connection.getResponseCode() == HTTP_REDIRECT_PERM /* 308 */)
248            {
249                if (followRedirects)
250                {
251                    // Redirection between HTTP and HTTPS URLs is not followed (do it manually)
252                    if (maxRedirects < 0 || visitedUrl.contains(url.toExternalForm()))
253                    {
254                        // The maximum number of redirects has been reached or the url was already been visited when following redirects
255                        throw new IOException("Exceed max number of redirects");
256                    }
257                    
258                    visitedUrl.add(url.toExternalForm());
259                    
260                    URL base = connection.getURL();
261                    String location = connection.getHeaderField("Location");
262                    URL redirect = new URL(base, location);  // Deal with relative URLs
263                    
264                    return _checkHttpUrl(redirect, userAgent, method, timeout, readTimeOut, true, requestHeaders, visitedUrl, maxRedirects - 1);
265                }
266                else
267                {
268                    return new HttpCheckReport(HttpCheck.REDIRECT);
269                }
270            }
271            else
272            {
273                __logger.debug("Check of URL '{}' returns the status code {}", url, connection.getResponseCode());
274                
275                int responseCode = connection.getResponseCode();
276                
277                switch (responseCode)
278                {
279                    case HttpURLConnection.HTTP_NOT_FOUND:
280                        return new HttpCheckReport(HttpCheck.NOT_FOUND);
281                    case HttpURLConnection.HTTP_FORBIDDEN:
282                    case HttpURLConnection.HTTP_UNAUTHORIZED:
283                        return new HttpCheckReport(HttpCheck.UNAUTHORIZED);
284                    case HttpURLConnection.HTTP_INTERNAL_ERROR:
285                    default:
286                        return new HttpCheckReport(HttpCheck.SERVER_ERROR, Optional.ofNullable(connection.getResponseMessage()));
287                }
288            }
289        }
290        catch (SSLHandshakeException e)
291        {
292            __logger.debug("Certificate error for URL '{}'", url, e);
293            return new HttpCheckReport(HttpCheck.SECURITY_LEVEL_ERROR, Optional.ofNullable(e.getMessage()));
294        }
295        catch (SocketTimeoutException e)
296        {
297            __logger.debug("Aborting test for URL '{}' because too long", url, e);
298            return new HttpCheckReport(HttpCheck.TIMEOUT);
299        }
300        catch (UnknownHostException e)
301        {
302            __logger.debug("Unknown host for URL '{}'", url, e);
303            return new HttpCheckReport(HttpCheck.NOT_FOUND);
304        }
305        catch (IOException e)
306        {
307            __logger.debug("Cannot test URL '{}'", url, e);
308            return new HttpCheckReport(HttpCheck.SERVER_ERROR, Optional.ofNullable(e.getMessage()));
309        }
310    }
311    
312    /**
313     * Method to check a HTTP url from client side. 
314     * The HTTP redirects will be followed.
315     * @param httpUrl the http url to check
316     * @return the result of the check
317     */
318    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
319    public Map<String, Object> checkHttpUrl(String httpUrl)
320    {
321        Map<String, Object> result = new HashMap<>();
322        
323        HttpCheckReport report = checkHttpUrl(httpUrl, null, null, 2000, 2000, true);
324        result.put("success", HttpCheck.SUCCESS.equals(report.status()));
325        if (report.message().isPresent())
326        {
327            result.put("message", report.message().get());
328        }
329        result.put("checkResult", report.status().name());
330        
331        return result;
332    }
333    
334    /**
335     * Represent the result of a check and a potential message
336     * @param status the result status
337     * @param message the message if needed
338     */
339    public record HttpCheckReport(HttpCheck status, Optional<String> message)
340    {
341        /**
342         * Create a report with the provided status and no message.
343         * @param result the result status
344         */
345        public HttpCheckReport(HttpCheck result)
346        {
347            this(result, Optional.empty());
348        }
349    }
350}