View Javadoc
1   package org.metricshub.http;
2   
3   /*-
4    * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲
5    * HTTP Java Client
6    * ჻჻჻჻჻჻
7    * Copyright (C) 2023 MetricsHub
8    * ჻჻჻჻჻჻
9    * Licensed under the Apache License, Version 2.0 (the "License");
10   * you may not use this file except in compliance with the License.
11   * You may obtain a copy of the License at
12   *
13   *      http://www.apache.org/licenses/LICENSE-2.0
14   *
15   * Unless required by applicable law or agreed to in writing, software
16   * distributed under the License is distributed on an "AS IS" BASIS,
17   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18   * See the License for the specific language governing permissions and
19   * limitations under the License.
20   * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱
21   */
22  
23  import java.io.ByteArrayOutputStream;
24  import java.io.File;
25  import java.io.FileNotFoundException;
26  import java.io.FileOutputStream;
27  import java.io.IOException;
28  import java.io.InputStream;
29  import java.io.OutputStream;
30  import java.net.Authenticator;
31  import java.net.HttpURLConnection;
32  import java.net.InetSocketAddress;
33  import java.net.MalformedURLException;
34  import java.net.Proxy;
35  import java.net.URL;
36  import java.nio.charset.Charset;
37  import java.nio.charset.StandardCharsets;
38  import java.security.KeyManagementException;
39  import java.security.NoSuchAlgorithmException;
40  import java.util.Arrays;
41  import java.util.Map;
42  import java.util.regex.Matcher;
43  import java.util.regex.Pattern;
44  import java.util.zip.GZIPInputStream;
45  import java.util.zip.InflaterInputStream;
46  import javax.net.ssl.HostnameVerifier;
47  import javax.net.ssl.HttpsURLConnection;
48  import javax.net.ssl.SSLContext;
49  import javax.net.ssl.SSLSession;
50  import javax.net.ssl.SSLSocketFactory;
51  import javax.net.ssl.TrustManager;
52  import javax.net.ssl.X509TrustManager;
53  
54  /**
55   * Simple HTTP Client implementation for Java's HttpURLConnection.<br>
56   * It has no external dependencies and facilitates the execution of HTTP requests.
57   */
58  public class HttpClient {
59  	static {
60  		// Fix JDK-8208526 issue
61  		System.setProperty("jdk.tls.acknowledgeCloseNotify", "true");
62  	}
63  
64  	/**
65  	 * The default User-Agent to use if the <code>userAgent</code> value is not provided to the HttpClient.
66  	 */
67  	public static final String DEFAULT_USER_AGENT =
68  		"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.79 Safari/537.36 Edge/14.14393 org.metricshub.http";
69  	private static final int MAX_CONTENT_LENGTH = 50 * 1024 * 1024; // 50 MB max
70  	private static final int BUFFER_SIZE = 64 * 1024; // 64 KB chunks
71  	private static final Charset UTF8_CHARSET = StandardCharsets.UTF_8;
72  	private static final Pattern CHARSET_REGEX = Pattern.compile("charset=\\s*\"?([^; \"]+)", Pattern.CASE_INSENSITIVE);
73  
74  	/**
75  	 * Hostname verifier that doesn't verify sh*t
76  	 */
77  	private static final HostnameVerifier LOUSY_HOSTNAME_VERIFIER = (String urlHostName, SSLSession session) -> true;
78  
79  	/**
80  	 * Trust manager that welcomes any certificate from anywhere
81  	 */
82  	private static final TrustManager[] LOUSY_TRUST_MANAGER = new TrustManager[] {
83  		new X509TrustManager() {
84  			@Override
85  			public java.security.cert.X509Certificate[] getAcceptedIssuers() {
86  				return null;
87  			}
88  
89  			@Override
90  			public void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) {}
91  
92  			@Override
93  			public void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) {}
94  		}
95  	};
96  
97  	/**
98  	 * The lousy SSL Socket Factory, that accepts any certificate
99  	 */
100 	private static final SSLSocketFactory BASE_SOCKET_FACTORY;
101 
102 	/**
103 	 * The default SSL protocols available in this JRE
104 	 */
105 	private static final String[] DEFAULT_SSL_PROTOCOLS;
106 
107 	static {
108 		SSLContext sc = null;
109 		try {
110 			sc = SSLContext.getInstance("SSL");
111 			sc.init(null, LOUSY_TRUST_MANAGER, new java.security.SecureRandom());
112 		} catch (NoSuchAlgorithmException | KeyManagementException e) {}
113 		BASE_SOCKET_FACTORY = sc.getSocketFactory();
114 		DEFAULT_SSL_PROTOCOLS = sc.getDefaultSSLParameters().getProtocols();
115 	}
116 
117 	/**
118 	 * Returns the InputStream that will be properly decoded, according to the
119 	 * content encoding of the HTTP response.
120 	 *
121 	 * @param httpURL HttpURLConnection instance
122 	 * @return the input stream to read from
123 	 */
124 	private static InputStream getDecodedStream(HttpURLConnection httpURL) {
125 		String contentEncoding = httpURL.getContentEncoding();
126 
127 		try {
128 			// In case of a GZIP-encoded content, well, unzip it!
129 			if ("gzip".equalsIgnoreCase(contentEncoding)) {
130 				return new GZIPInputStream(httpURL.getInputStream());
131 			} else if ("deflate".equalsIgnoreCase(contentEncoding)) {
132 				return new InflaterInputStream(httpURL.getInputStream());
133 			} else {
134 				return httpURL.getInputStream();
135 			}
136 		} catch (IOException e) {
137 			// If getInputStream() failed, then use the error stream, if available
138 			return httpURL.getErrorStream();
139 		}
140 	}
141 
142 	/**
143 	 * @param url The URL to be requested (e.g. https://w3.test.org/site/list.jsp)
144 	 * @param method GET|POST|PUT|DELETE or whatever HTTP verb is supported
145 	 * @param specifiedSslProtocolArray Array of string of the SSL protocols to use (e.g.: "SSLv3", "TLSv1", etc.)
146 	 * @param username Username to access the specified URL
147 	 * @param password Password associated to username
148 	 * @param proxyServer Host name of IP address of the proxy. Leave empty or null if no proxy is required.
149 	 * @param proxyPort Port of the proxy (e.g. 3128)
150 	 * @param proxyUsername Username to connect to the proxy (if any)
151 	 * @param proxyPassword Password associated to the proxy username
152 	 * @param userAgent String of the user agent to specify in the request (if null, will use a default one)
153 	 * @param addHeaderMap Additional headers to be added to the HTTP request (pairs of key and value)
154 	 * @param body Body of the HTTP request to be sent
155 	 * @param timeout Timeout in seconds before the operation is canceled
156 	 * @param downloadToPath A path where to download the content of the HTTP response to
157 	 * @return an HttpResponse, which itself contains the HTTP status code, the headers and the body of the response
158 	 * @throws MalformedURLException when the specified URL is invalid
159 	 * @throws IOException when anything wrong happens during the connection and while downloading information from the Web server
160 	 * @throws FileNotFoundException when the specified downloadToPath is not correct (not a file, not accessible, etc.)
161 	 */
162 	public static HttpResponse sendRequest(
163 		String url,
164 		String method,
165 		String[] specifiedSslProtocolArray,
166 		String username,
167 		char[] password,
168 		String proxyServer,
169 		int proxyPort,
170 		String proxyUsername,
171 		char[] proxyPassword,
172 		String userAgent,
173 		Map<String, String> addHeaderMap,
174 		String body,
175 		int timeout,
176 		String downloadToPath
177 	) throws IOException {
178 		// Connect through a proxy?
179 		boolean useProxy = proxyServer != null && !proxyServer.isEmpty();
180 
181 		// Connect directly (no proxy)
182 		HttpURLConnection httpURL;
183 		if (!useProxy) {
184 			httpURL = (HttpURLConnection) new URL(url).openConnection();
185 		} else {
186 			Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyServer, proxyPort));
187 			httpURL = (HttpURLConnection) new URL(url).openConnection(proxy);
188 		}
189 
190 		// Download to a file?
191 		// Perform some verifications on the specified downloadToPath
192 		File downloadToFile = null;
193 		if (downloadToPath != null && !downloadToPath.isEmpty()) {
194 			// So, the user specified a file path to download to
195 			downloadToFile = new File(downloadToPath);
196 
197 			// Check whether the user specified a path whose path doesn't exist
198 			File parentDirectory = downloadToFile.getParentFile();
199 			if (parentDirectory != null && !parentDirectory.exists()) {
200 				// So, the parent directory of the specified filed path does not exist
201 				try {
202 					// We therefore need to create the directory (and maybe the grand-parent directories, and so on...)
203 					parentDirectory.mkdirs();
204 				} catch (SecurityException e) {
205 					throw new IOException("Couldn't create the necessary directories for " + downloadToPath);
206 				}
207 			}
208 		}
209 
210 		/////////////////////////////////////////
211 		//      H T T P S   C a s e            //
212 		/////////////////////////////////////////
213 
214 		// For HTTPS connections, we need to setup more things
215 		if (httpURL instanceof HttpsURLConnection) {
216 			// In order to accept to connect to all invalid HTTPS servers, we need to setup
217 			// our own lousy -- very untight -- verifiers
218 			((HttpsURLConnection) httpURL).setHostnameVerifier(LOUSY_HOSTNAME_VERIFIER);
219 
220 			// If no protocols were specified (as normal), use the default ones
221 			if (specifiedSslProtocolArray == null || specifiedSslProtocolArray.length == 0) {
222 				// So, simply use the base socket factory
223 				((HttpsURLConnection) httpURL).setSSLSocketFactory(BASE_SOCKET_FACTORY);
224 			} else {
225 				// Clean-up the list of specified protocols (remove non supported ones, incl. SSLv2Hello)
226 				String[] protocolsToEnable = Arrays
227 					.stream(specifiedSslProtocolArray)
228 					.filter(p -> p != null && !"SSLv2Hello".equalsIgnoreCase(p))
229 					.filter(p -> Arrays.stream(DEFAULT_SSL_PROTOCOLS).anyMatch(d -> d.equalsIgnoreCase(p)))
230 					.toArray(String[]::new);
231 
232 				// Create a new SSL socket factory with these settings
233 				SSLSocketFactory overridenSocketFactory = new ProtocolOverridingSSLSocketFactory(
234 					BASE_SOCKET_FACTORY,
235 					protocolsToEnable
236 				);
237 				((HttpsURLConnection) httpURL).setSSLSocketFactory(overridenSocketFactory);
238 			}
239 		}
240 
241 		// Setup the HTTP connection
242 		httpURL.setRequestMethod(method);
243 		httpURL.setDefaultUseCaches(false);
244 		httpURL.setDoOutput(true);
245 		httpURL.setDoInput(true);
246 		httpURL.setConnectTimeout(timeout * 1000);
247 		httpURL.setReadTimeout(timeout * 1000);
248 		httpURL.setAllowUserInteraction(false);
249 		httpURL.setInstanceFollowRedirects(true);
250 
251 		// User agent
252 		if (userAgent == null || userAgent.isEmpty()) {
253 			userAgent = DEFAULT_USER_AGENT;
254 		}
255 		httpURL.addRequestProperty("User-Agent", userAgent);
256 
257 		// Add the additional specified headers
258 		if (addHeaderMap != null) {
259 			addHeaderMap.forEach((header, value) -> {
260 				if (header != null && value != null && !header.isEmpty() && !value.isEmpty()) {
261 					httpURL.addRequestProperty(header, value);
262 				}
263 			});
264 		}
265 
266 		// Authentication
267 		ThreadSafeNoCacheAuthenticator.setCredentials(username, password, proxyUsername, proxyPassword);
268 		Authenticator.setDefault(ThreadSafeNoCacheAuthenticator.getInstance());
269 
270 		// Go!
271 		try {
272 			httpURL.connect();
273 
274 			// Send our request
275 			if (body != null && !body.isEmpty()) {
276 				try (OutputStream os = httpURL.getOutputStream()) {
277 					os.write(body.getBytes(UTF8_CHARSET));
278 				}
279 			}
280 
281 			// New HttpResponse
282 			HttpResponse response = new HttpResponse();
283 
284 			// Get the HTTP response code
285 			// Note: this may fail and trigger an IOException with JRE1.6 on some 401 (Unauthorized) responses
286 			response.setStatusCode(httpURL.getResponseCode());
287 
288 			// Read the response headers
289 			httpURL
290 				.getHeaderFields()
291 				.forEach((header, valueList) -> valueList.forEach(value -> response.appendHeader(header, value)));
292 
293 			// Do we have a file path to write to?
294 			if (downloadToFile != null) {
295 				// If the specified downloadToPath is a directory, we will have to make up a file name
296 				// by retrieving the name of the resource that we're downloading, basically
297 				// We can do this only at the very last second because the user may have specified a
298 				// URL which doesn't not specify a file (like .../download.php?file=Avatar1080p.mkv)
299 				// which will be then redirected to the real URL (..../A09230E9FB58A0459284/Avatar1080p.mkv)
300 				// So, only now we can retrieve the URL of the httpURL connection, which should be the
301 				// latest one that we've queried after we've been redirected
302 				if (downloadToFile.isDirectory()) {
303 					String tempFilename = httpURL.getURL().getPath();
304 					String filename = tempFilename.substring(tempFilename.lastIndexOf('/'));
305 					downloadToPath = new File(downloadToFile, filename).getPath();
306 				}
307 
308 				// Download the content directly to the file
309 				try (
310 					FileOutputStream fileStream = new FileOutputStream(downloadToPath);
311 					InputStream httpStream = getDecodedStream(httpURL)
312 				) {
313 					byte[] tempBuf = new byte[BUFFER_SIZE];
314 					int readBytes;
315 					while ((readBytes = httpStream.read(tempBuf)) != -1) {
316 						fileStream.write(tempBuf, 0, readBytes);
317 					}
318 				}
319 
320 				// As we have successfully created the file, we will put in the returned "body" of the
321 				// HTTP response the path to the file that we just created, so that the client can reference it
322 				response.appendBody(downloadToPath);
323 
324 				// Return
325 				return response;
326 			}
327 
328 			// Read the content (expecting a text string, as it's going to be returned as a String, and not a byte[])
329 
330 			// First, what is the content length?
331 			int contentLength = httpURL.getContentLength();
332 
333 			// If content is too large, then discard it
334 			if (contentLength > MAX_CONTENT_LENGTH) {
335 				throw new IOException("Content is too large (" + contentLength + " bytes > " + MAX_CONTENT_LENGTH + " bytes)");
336 			}
337 
338 			// What is the encoding (so we can build the String accordingly)
339 			Charset charset = UTF8_CHARSET;
340 			String contentType = httpURL.getContentType();
341 			if (contentType != null) {
342 				Matcher charsetMatcher = CHARSET_REGEX.matcher(contentType);
343 				if (charsetMatcher.find()) {
344 					charset = Charset.forName(charsetMatcher.group(1));
345 				}
346 			}
347 
348 			// Read body by chunks
349 			ByteArrayOutputStream bodyBytes = contentLength > 0
350 				? new ByteArrayOutputStream(contentLength)
351 				: new ByteArrayOutputStream();
352 
353 			byte[] buffer = new byte[BUFFER_SIZE];
354 			int totalBytesCount = 0;
355 			int bytesCount;
356 
357 			try (InputStream httpStream = getDecodedStream(httpURL)) {
358 				while (httpStream != null && (bytesCount = httpStream.read(buffer)) != -1) {
359 					bodyBytes.write(buffer, 0, bytesCount);
360 					totalBytesCount += bytesCount;
361 					if (totalBytesCount > MAX_CONTENT_LENGTH) {
362 						throw new IOException("Content is too large (maximum " + MAX_CONTENT_LENGTH + " bytes)");
363 					}
364 				}
365 			}
366 			response.appendBody(new String(bodyBytes.toByteArray(), charset));
367 
368 			// Return
369 			return response;
370 		} finally {
371 			// Disconnect
372 			httpURL.disconnect();
373 
374 			// Clear the credentials
375 			ThreadSafeNoCacheAuthenticator.clearCredentials();
376 		}
377 	}
378 }