001/*
002 * Copyright 2022-2025 Revetware LLC.
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 */
016
017package com.soklet.core;
018
019import com.soklet.internal.spring.LinkedCaseInsensitiveMap;
020
021import javax.annotation.Nonnull;
022import javax.annotation.Nullable;
023import javax.annotation.concurrent.ThreadSafe;
024import java.lang.Thread.UncaughtExceptionHandler;
025import java.lang.invoke.MethodHandle;
026import java.lang.invoke.MethodHandles;
027import java.lang.invoke.MethodHandles.Lookup;
028import java.lang.invoke.MethodType;
029import java.net.MalformedURLException;
030import java.net.URI;
031import java.net.URISyntaxException;
032import java.net.URL;
033import java.nio.charset.Charset;
034import java.nio.charset.IllegalCharsetNameException;
035import java.nio.charset.UnsupportedCharsetException;
036import java.util.Collections;
037import java.util.LinkedHashMap;
038import java.util.LinkedHashSet;
039import java.util.List;
040import java.util.Locale;
041import java.util.Locale.LanguageRange;
042import java.util.Map;
043import java.util.Optional;
044import java.util.Set;
045import java.util.concurrent.ExecutorService;
046import java.util.concurrent.Executors;
047import java.util.concurrent.ThreadFactory;
048import java.util.regex.Pattern;
049import java.util.stream.Collectors;
050
051import static java.lang.String.format;
052import static java.util.Objects.requireNonNull;
053
054/**
055 * A non-instantiable collection of utility methods.
056 *
057 * @author <a href="https://www.revetkn.com">Mark Allen</a>
058 */
059@ThreadSafe
060public final class Utilities {
061        @Nonnull
062        private static final boolean VIRTUAL_THREADS_AVAILABLE;
063        @Nonnull
064        private static final byte[] EMPTY_BYTE_ARRAY;
065        @Nonnull
066        private static final Map<String, Locale> LOCALES_BY_LANGUAGE_RANGE_RANGE;
067        @Nonnull
068        private static final Pattern HEAD_WHITESPACE_PATTERN;
069        @Nonnull
070        private static final Pattern TAIL_WHITESPACE_PATTERN;
071
072        static {
073                EMPTY_BYTE_ARRAY = new byte[0];
074
075                Locale[] locales = Locale.getAvailableLocales();
076                Map<String, Locale> localesByLanguageRangeRange = new LinkedHashMap<>(locales.length);
077
078                for (Locale locale : locales) {
079                        LanguageRange languageRange = new LanguageRange(locale.toLanguageTag());
080                        localesByLanguageRangeRange.put(languageRange.getRange(), locale);
081                }
082
083                LOCALES_BY_LANGUAGE_RANGE_RANGE = Collections.unmodifiableMap(localesByLanguageRangeRange);
084
085                boolean virtualThreadsAvailable = false;
086
087                try {
088                        // Detect if Virtual Threads are usable by feature testing via reflection.
089                        // Hat tip to https://github.com/javalin/javalin for this technique
090                        Class.forName("java.lang.Thread$Builder$OfVirtual");
091                        virtualThreadsAvailable = true;
092                } catch (Exception ignored) {
093                        // We don't care why this failed, but if we're here we know JVM does not support virtual threads
094                }
095
096                VIRTUAL_THREADS_AVAILABLE = virtualThreadsAvailable;
097
098                // See https://www.regular-expressions.info/unicode.html
099                // \p{Z} or \p{Separator}: any kind of whitespace or invisible separator.
100                //
101                // First pattern matches all whitespace at the head of a string, second matches the same for tail.
102                // Useful for a "stronger" trim() function, which is almost always what we want in a web context
103                // with user-supplied input.
104                HEAD_WHITESPACE_PATTERN = Pattern.compile("^(\\p{Z})+");
105                TAIL_WHITESPACE_PATTERN = Pattern.compile("(\\p{Z})+$");
106        }
107
108        private Utilities() {
109                // Non-instantiable
110        }
111
112        /**
113         * Does the platform runtime support virtual threads (either Java 19 and 20 w/preview enabled or Java 21+)?
114         *
115         * @return {@code true} if the runtime supports virtual threads, {@code false} otherwise
116         */
117        @Nonnull
118        public static Boolean virtualThreadsAvailable() {
119                return VIRTUAL_THREADS_AVAILABLE;
120        }
121
122        /**
123         * Provides a virtual-thread-per-task executor service if supported by the runtime.
124         * <p>
125         * In order to support Soklet users who are not yet ready to enable virtual threads (those <strong>not</strong> running either Java 19 and 20 w/preview enabled or Java 21+),
126         * we compile Soklet with a source level &lt; 19 and avoid any hard references to virtual threads by dynamically creating our executor service via {@link MethodHandle} references.
127         * <p>
128         * <strong>You should not call this method if {@link Utilities#virtualThreadsAvailable()} is {@code false}.</strong>
129         * <pre>{@code  // This method is effectively equivalent to this code
130         * return Executors.newThreadPerTaskExecutor(
131         *   Thread.ofVirtual()
132         *    .name(threadNamePrefix)
133         *    .uncaughtExceptionHandler(uncaughtExceptionHandler)
134         *    .factory()
135         * );}</pre>
136         *
137         * @param threadNamePrefix         thread name prefix for the virtual thread factory builder
138         * @param uncaughtExceptionHandler uncaught exception handler for the virtual thread factory builder
139         * @return a virtual-thread-per-task executor service
140         * @throws IllegalStateException if the runtime environment does not support virtual threads
141         */
142        @Nonnull
143        public static ExecutorService createVirtualThreadsNewThreadPerTaskExecutor(@Nonnull String threadNamePrefix,
144                                                                                                                                                                                                                                                                                                                 @Nonnull UncaughtExceptionHandler uncaughtExceptionHandler) {
145                requireNonNull(threadNamePrefix);
146                requireNonNull(uncaughtExceptionHandler);
147
148                if (!virtualThreadsAvailable())
149                        throw new IllegalStateException("Virtual threads are not available. Please confirm you are using Java 19-20 with the '--enable-preview' javac parameter specified or Java 21+");
150
151                // Hat tip to https://github.com/javalin/javalin for this technique
152                Class<?> threadBuilderOfVirtualClass;
153
154                try {
155                        threadBuilderOfVirtualClass = Class.forName("java.lang.Thread$Builder$OfVirtual");
156                } catch (ClassNotFoundException e) {
157                        throw new IllegalStateException("Unable to load virtual thread builder class", e);
158                }
159
160                Lookup lookup = MethodHandles.publicLookup();
161
162                MethodHandle methodHandleThreadOfVirtual;
163                MethodHandle methodHandleThreadBuilderOfVirtualName;
164                MethodHandle methodHandleThreadBuilderOfVirtualUncaughtExceptionHandler;
165                MethodHandle methodHandleThreadBuilderOfVirtualFactory;
166                MethodHandle methodHandleExecutorsNewThreadPerTaskExecutor;
167
168                try {
169                        methodHandleThreadOfVirtual = lookup.findStatic(Thread.class, "ofVirtual", MethodType.methodType(threadBuilderOfVirtualClass));
170                        methodHandleThreadBuilderOfVirtualName = lookup.findVirtual(threadBuilderOfVirtualClass, "name", MethodType.methodType(threadBuilderOfVirtualClass, String.class, long.class));
171                        methodHandleThreadBuilderOfVirtualUncaughtExceptionHandler = lookup.findVirtual(threadBuilderOfVirtualClass, "uncaughtExceptionHandler", MethodType.methodType(threadBuilderOfVirtualClass, UncaughtExceptionHandler.class));
172                        methodHandleThreadBuilderOfVirtualFactory = lookup.findVirtual(threadBuilderOfVirtualClass, "factory", MethodType.methodType(ThreadFactory.class));
173                        methodHandleExecutorsNewThreadPerTaskExecutor = lookup.findStatic(Executors.class, "newThreadPerTaskExecutor", MethodType.methodType(ExecutorService.class, ThreadFactory.class));
174                } catch (NoSuchMethodException | IllegalAccessException e) {
175                        throw new IllegalStateException("Unable to load method handle for virtual thread factory", e);
176                }
177
178                try {
179                        // Thread.ofVirtual()
180                        Object virtualThreadBuilder = methodHandleThreadOfVirtual.invoke();
181                        // .name(threadNamePrefix, start)
182                        methodHandleThreadBuilderOfVirtualName.invoke(virtualThreadBuilder, threadNamePrefix, 1);
183                        // .uncaughtExceptionHandler(uncaughtExceptionHandler)
184                        methodHandleThreadBuilderOfVirtualUncaughtExceptionHandler.invoke(virtualThreadBuilder, uncaughtExceptionHandler);
185                        // .factory();
186                        ThreadFactory threadFactory = (ThreadFactory) methodHandleThreadBuilderOfVirtualFactory.invoke(virtualThreadBuilder);
187
188                        // return Executors.newThreadPerTaskExecutor(threadFactory);
189                        return (ExecutorService) methodHandleExecutorsNewThreadPerTaskExecutor.invoke(threadFactory);
190                } catch (Throwable t) {
191                        throw new IllegalStateException("Unable to create virtual thread executor service", t);
192                }
193        }
194
195        @Nonnull
196        public static byte[] emptyByteArray() {
197                return EMPTY_BYTE_ARRAY;
198        }
199
200        @Nonnull
201        public static Map<String, Set<String>> extractQueryParametersFromQuery(@Nonnull String query) {
202                requireNonNull(query);
203
204                // For form parameters, body will look like "One=Two&Three=Four" ...a query string.
205                String syntheticUrl = format("https://www.soklet.com?%s", query);
206                return extractQueryParametersFromUrl(syntheticUrl);
207        }
208
209        @Nonnull
210        public static Map<String, Set<String>> extractQueryParametersFromUrl(@Nonnull String url) {
211                requireNonNull(url);
212
213                URI uri;
214
215                try {
216                        uri = new URI(url);
217                } catch (URISyntaxException e) {
218                        return Map.of();
219                }
220
221                String query = trimAggressivelyToNull(uri.getQuery());
222
223                if (query == null)
224                        return Map.of();
225
226                String[] queryParameterComponents = query.split("&");
227                Map<String, Set<String>> queryParameters = new LinkedHashMap<>();
228
229                for (String queryParameterComponent : queryParameterComponents) {
230                        String[] queryParameterNameAndValue = queryParameterComponent.split("=");
231                        String name = queryParameterNameAndValue.length > 0 ? trimAggressivelyToNull(queryParameterNameAndValue[0]) : null;
232
233                        if (name == null)
234                                continue;
235
236                        String value = queryParameterNameAndValue.length > 1 ? trimAggressivelyToNull(queryParameterNameAndValue[1]) : null;
237
238                        if (value == null)
239                                continue;
240
241                        Set<String> values = queryParameters.computeIfAbsent(name, k -> new LinkedHashSet<>());
242
243                        values.add(value);
244                }
245
246                return queryParameters;
247        }
248
249        @Nonnull
250        public static Map<String, Set<String>> extractCookiesFromHeaders(@Nonnull Map<String, Set<String>> headers) {
251                requireNonNull(headers);
252
253                Map<String, Set<String>> cookies = new LinkedCaseInsensitiveMap<>();
254
255                for (Map.Entry<String, Set<String>> entry : headers.entrySet()) {
256                        if (entry.getKey().equals("Cookie")) {
257                                Set<String> values = entry.getValue();
258
259                                for (String value : values) {
260                                        value = trimAggressivelyToNull(value);
261
262                                        if (value == null)
263                                                continue;
264
265                                        String[] cookieComponents = value.split(";");
266
267                                        for (String cookieComponent : cookieComponents) {
268                                                cookieComponent = trimAggressivelyToNull(cookieComponent);
269
270                                                if (cookieComponent == null)
271                                                        continue;
272
273                                                String[] cookiePair = cookieComponent.split("=");
274
275                                                if (cookiePair.length != 1 && cookiePair.length != 2)
276                                                        continue;
277
278                                                String cookieName = trimAggressivelyToNull(cookiePair[0]);
279                                                String cookieValue = cookiePair.length == 1 ? null : trimAggressivelyToNull(cookiePair[1]);
280
281                                                if (cookieName == null)
282                                                        continue;
283
284                                                Set<String> cookieValues = cookies.get(cookieName);
285
286                                                if (cookieValues == null) {
287                                                        cookieValues = new LinkedHashSet<>();
288                                                        cookies.put(cookieName, cookieValues);
289                                                }
290
291                                                if (cookieValue != null)
292                                                        cookieValues.add(cookieValue);
293                                        }
294                                }
295                        }
296                }
297
298                return cookies;
299        }
300
301        @Nonnull
302        public static String normalizedPathForUrl(@Nonnull String url) {
303                requireNonNull(url);
304
305                url = trimAggressively(url);
306
307                if (url.length() == 0)
308                        return "/";
309
310                if (url.startsWith("http://") || url.startsWith("https://")) {
311                        try {
312                                URL absoluteUrl = new URL(url);
313                                url = absoluteUrl.getPath();
314                        } catch (MalformedURLException e) {
315                                throw new RuntimeException(format("Malformed URL: %s", url), e);
316                        }
317                }
318
319                if (!url.startsWith("/"))
320                        url = format("/%s", url);
321
322                if ("/".equals(url))
323                        return url;
324
325                while (url.endsWith("/"))
326                        url = url.substring(0, url.length() - 1);
327
328                int queryIndex = url.indexOf("?");
329
330                if (queryIndex != -1)
331                        url = url.substring(0, queryIndex);
332
333                return url;
334        }
335
336        @Nonnull
337        public static List<Locale> localesFromAcceptLanguageHeaderValue(@Nonnull String acceptLanguageHeaderValue) {
338                requireNonNull(acceptLanguageHeaderValue);
339
340                try {
341                        List<LanguageRange> languageRanges = LanguageRange.parse(acceptLanguageHeaderValue);
342
343                        return languageRanges.stream()
344                                        .map(languageRange -> LOCALES_BY_LANGUAGE_RANGE_RANGE.get(languageRange.getRange()))
345                                        .filter(locale -> locale != null)
346                                        .collect(Collectors.toList());
347                } catch (Exception ignored) {
348                        return List.of();
349                }
350        }
351
352        /**
353         * Best-effort attempt to determine a client's URL prefix by examining request headers.
354         * <p>
355         * A URL prefix in this context is defined as {@code <scheme>://host<:optional port>}, but no path or query components.
356         * <p>
357         * Soklet is generally the "last hop" behind a load balancer/reverse proxy and does get accessed directly by clients.
358         * <p>
359         * Normally a load balancer/reverse proxy/other upstream proxies will provide information about the true source of the
360         * request through headers like the following:
361         * <ul>
362         *   <li>{@code Host}</li>
363         *   <li>{@code Forwarded}</li>
364         *   <li>{@code Origin}</li>
365         *   <li>{@code X-Forwarded-Proto}</li>
366         *   <li>{@code X-Forwarded-Protocol}</li>
367         *   <li>{@code X-Url-Scheme}</li>
368         *   <li>{@code Front-End-Https}</li>
369         *   <li>{@code X-Forwarded-Ssl}</li>
370         *   <li>{@code X-Forwarded-Host}</li>
371         *   <li>{@code X-Forwarded-Port}</li>
372         * </ul>
373         * <p>
374         * This method may take these and other headers into account when determining URL prefix.
375         * <p>
376         * For example, the following would be legal URL prefixes returned from this method:
377         * <ul>
378         *   <li>{@code https://www.soklet.com}</li>
379         *   <li>{@code http://www.fake.com:1234}</li>
380         * </ul>
381         * <p>
382         * The following would NOT be legal URL prefixes:
383         * <ul>
384         *   <li>{@code www.soklet.com} (missing protocol) </li>
385         *   <li>{@code https://www.soklet.com/} (trailing slash)</li>
386         *   <li>{@code https://www.soklet.com/test} (trailing slash, path)</li>
387         *   <li>{@code https://www.soklet.com/test?abc=1234} (trailing slash, path, query)</li>
388         * </ul>
389         *
390         * @param headers HTTP request headers
391         * @return the URL prefix, or {@link Optional#empty()} if it could not be determined
392         */
393        @Nonnull
394        public static Optional<String> extractClientUrlPrefixFromHeaders(@Nonnull Map<String, Set<String>> headers) {
395                requireNonNull(headers);
396
397                // Host                   developer.mozilla.org OR developer.mozilla.org:443
398                // Forwarded              by=<identifier>;for=<identifier>;host=<host>;proto=<http|https> (can be repeated if comma-separated, e.g. for=12.34.56.78;host=example.com;proto=https, for=23.45.67.89)
399                // Origin                 null OR <scheme>://<hostname> OR <scheme>://<hostname>:<port>
400                // X-Forwarded-Proto      https
401                // X-Forwarded-Protocol   https (Microsoft's alternate name)
402                // X-Url-Scheme           https (Microsoft's alternate name)
403                // Front-End-Https        on (Microsoft's alternate name)
404                // X-Forwarded-Ssl        on (Microsoft's alternate name)
405                // X-Forwarded-Host       id42.example-cdn.com
406                // X-Forwarded-Port       443
407
408                String protocol = null;
409                String host = null;
410                String portAsString = null;
411
412                // Host: developer.mozilla.org OR developer.mozilla.org:443
413                Set<String> hostHeaders = headers.get("Host");
414
415                if (hostHeaders != null && hostHeaders.size() > 0) {
416                        String hostHeader = trimAggressivelyToNull(hostHeaders.stream().findFirst().get());
417
418                        if (hostHeader != null) {
419                                if (hostHeader.contains(":")) {
420                                        String[] hostHeaderComponents = hostHeader.split(":");
421                                        if (hostHeaderComponents.length == 2) {
422                                                host = trimAggressivelyToNull(hostHeaderComponents[0]);
423                                                portAsString = trimAggressivelyToNull(hostHeaderComponents[1]);
424                                        }
425                                } else {
426                                        host = hostHeader;
427                                }
428                        }
429                }
430
431                // Forwarded: by=<identifier>;for=<identifier>;host=<host>;proto=<http|https> (can be repeated if comma-separated, e.g. for=12.34.56.78;host=example.com;proto=https, for=23.45.67.89)
432                Set<String> forwardedHeaders = headers.get("Forwarded");
433
434                if (forwardedHeaders != null && forwardedHeaders.size() > 0) {
435                        String forwardedHeader = trimAggressivelyToNull(forwardedHeaders.stream().findFirst().get());
436
437                        // If there are multiple comma-separated components, pick the first one
438                        String[] forwardedHeaderComponents = forwardedHeader.split(",");
439                        forwardedHeader = trimAggressivelyToNull(forwardedHeaderComponents[0]);
440
441                        if (forwardedHeader != null) {
442                                // Each field component might look like "by=<identifier>"
443                                String[] forwardedHeaderFieldComponents = forwardedHeader.split(";");
444
445                                for (String forwardedHeaderFieldComponent : forwardedHeaderFieldComponents) {
446                                        forwardedHeaderFieldComponent = trimAggressivelyToNull(forwardedHeaderFieldComponent);
447
448                                        if (forwardedHeaderFieldComponent == null)
449                                                continue;
450
451                                        // Break "by=<identifier>" into "by" and "<identifier>" pieces
452                                        String[] forwardedHeaderFieldNameAndValue = forwardedHeaderFieldComponent.split(Pattern.quote("=" /* escape special Regex char */));
453                                        if (forwardedHeaderFieldNameAndValue.length != 2)
454                                                continue;
455
456                                        // e.g. "by"
457                                        String name = trimAggressivelyToNull(forwardedHeaderFieldNameAndValue[0]);
458                                        // e.g. "<identifier>"
459                                        String value = trimAggressivelyToNull(forwardedHeaderFieldNameAndValue[1]);
460
461                                        if (name == null || value == null)
462                                                continue;
463
464                                        // We only care about the "Host" and "Proto" components here.
465                                        if ("host".equalsIgnoreCase(name)) {
466                                                if (host == null)
467                                                        host = value;
468                                        } else if ("proto".equalsIgnoreCase(name)) {
469                                                if (protocol == null)
470                                                        protocol = value;
471                                        }
472                                }
473                        }
474                }
475
476                // Origin: null OR <scheme>://<hostname> OR <scheme>://<hostname>:<port>
477                if (protocol == null || host == null || portAsString == null) {
478                        Set<String> originHeaders = headers.get("Origin");
479
480                        if (originHeaders != null && originHeaders.size() > 0) {
481                                String originHeader = trimAggressivelyToNull(originHeaders.stream().findFirst().get());
482                                String[] originHeaderComponents = originHeader.split("://");
483
484                                if (originHeaderComponents.length == 2) {
485                                        protocol = trimAggressivelyToNull(originHeaderComponents[0]);
486                                        String originHostAndMaybePort = trimAggressivelyToNull(originHeaderComponents[1]);
487
488                                        if (originHostAndMaybePort != null) {
489                                                if (originHostAndMaybePort.contains(":")) {
490                                                        String[] originHostAndPortComponents = originHostAndMaybePort.split(":");
491
492                                                        if (originHostAndPortComponents.length == 2) {
493                                                                host = trimAggressivelyToNull(originHostAndPortComponents[0]);
494                                                                portAsString = trimAggressivelyToNull(originHostAndPortComponents[1]);
495                                                        }
496                                                } else {
497                                                        host = originHostAndMaybePort;
498                                                }
499                                        }
500                                }
501                        }
502                }
503
504                // X-Forwarded-Proto: https
505                if (protocol == null) {
506                        Set<String> xForwardedProtoHeaders = headers.get("X-Forwarded-Proto");
507                        if (xForwardedProtoHeaders != null && xForwardedProtoHeaders.size() > 0) {
508                                String xForwardedProtoHeader = trimAggressivelyToNull(xForwardedProtoHeaders.stream().findFirst().get());
509                                protocol = xForwardedProtoHeader;
510                        }
511                }
512
513                // X-Forwarded-Protocol: https (Microsoft's alternate name)
514                if (protocol == null) {
515                        Set<String> xForwardedProtocolHeaders = headers.get("X-Forwarded-Protocol");
516                        if (xForwardedProtocolHeaders != null && xForwardedProtocolHeaders.size() > 0) {
517                                String xForwardedProtocolHeader = trimAggressivelyToNull(xForwardedProtocolHeaders.stream().findFirst().get());
518                                protocol = xForwardedProtocolHeader;
519                        }
520                }
521
522                // X-Url-Scheme: https (Microsoft's alternate name)
523                if (protocol == null) {
524                        Set<String> xUrlSchemeHeaders = headers.get("X-Url-Scheme");
525                        if (xUrlSchemeHeaders != null && xUrlSchemeHeaders.size() > 0) {
526                                String xUrlSchemeHeader = trimAggressivelyToNull(xUrlSchemeHeaders.stream().findFirst().get());
527                                protocol = xUrlSchemeHeader;
528                        }
529                }
530
531                // Front-End-Https: on (Microsoft's alternate name)
532                if (protocol == null) {
533                        Set<String> frontEndHttpsHeaders = headers.get("Front-End-Https");
534                        if (frontEndHttpsHeaders != null && frontEndHttpsHeaders.size() > 0) {
535                                String frontEndHttpsHeader = trimAggressivelyToNull(frontEndHttpsHeaders.stream().findFirst().get());
536
537                                if (frontEndHttpsHeader != null)
538                                        protocol = "on".equalsIgnoreCase(frontEndHttpsHeader) ? "https" : "http";
539                        }
540                }
541
542                // X-Forwarded-Ssl: on (Microsoft's alternate name)
543                if (protocol == null) {
544                        Set<String> xForwardedSslHeaders = headers.get("X-Forwarded-Ssl");
545                        if (xForwardedSslHeaders != null && xForwardedSslHeaders.size() > 0) {
546                                String xForwardedSslHeader = trimAggressivelyToNull(xForwardedSslHeaders.stream().findFirst().get());
547
548                                if (xForwardedSslHeader != null)
549                                        protocol = "on".equalsIgnoreCase(xForwardedSslHeader) ? "https" : "http";
550                        }
551                }
552
553                // X-Forwarded-Host: id42.example-cdn.com
554                if (host == null) {
555                        Set<String> xForwardedHostHeaders = headers.get("X-Forwarded-Host");
556                        if (xForwardedHostHeaders != null && xForwardedHostHeaders.size() > 0) {
557                                String xForwardedHostHeader = trimAggressivelyToNull(xForwardedHostHeaders.stream().findFirst().get());
558                                host = xForwardedHostHeader;
559                        }
560                }
561
562                // X-Forwarded-Port: 443
563                if (portAsString == null) {
564                        Set<String> xForwardedPortHeaders = headers.get("X-Forwarded-Port");
565                        if (xForwardedPortHeaders != null && xForwardedPortHeaders.size() > 0) {
566                                String xForwardedPortHeader = trimAggressivelyToNull(xForwardedPortHeaders.stream().findFirst().get());
567                                portAsString = xForwardedPortHeader;
568                        }
569                }
570
571                Integer port = null;
572
573                if (portAsString != null) {
574                        try {
575                                port = Integer.parseInt(portAsString, 10);
576                        } catch (Exception ignored) {
577                                // Not an integer; ignore it
578                        }
579                }
580
581                if (protocol != null && host != null && port == null)
582                        return Optional.of(format("%s://%s", protocol, host));
583
584                if (protocol != null && host != null && port != null) {
585                        boolean usingDefaultPort = ("http".equalsIgnoreCase(protocol) && port.equals(80))
586                                        || ("https".equalsIgnoreCase(protocol) && port.equals(443));
587
588                        // Only include the port number if it's nonstandard for the protocol
589                        String clientUrlPrefix = usingDefaultPort
590                                        ? format("%s://%s", protocol, host)
591                                        : format("%s://%s:%s", protocol, host, port);
592
593                        return Optional.of(clientUrlPrefix);
594                }
595
596                return Optional.empty();
597        }
598
599        @Nonnull
600        public static Optional<String> extractContentTypeFromHeaders(@Nonnull Map<String, Set<String>> headers) {
601                requireNonNull(headers);
602
603                Set<String> contentTypeHeaderValues = headers.get("Content-Type");
604
605                if (contentTypeHeaderValues == null || contentTypeHeaderValues.size() == 0)
606                        return Optional.empty();
607
608                return extractContentTypeFromHeaderValue(contentTypeHeaderValues.stream().findFirst().get());
609        }
610
611        @Nonnull
612        public static Optional<String> extractContentTypeFromHeaderValue(@Nullable String contentTypeHeaderValue) {
613                contentTypeHeaderValue = trimAggressivelyToNull(contentTypeHeaderValue);
614
615                if (contentTypeHeaderValue == null)
616                        return Optional.empty();
617
618                // Examples
619                // Content-Type: text/html; charset=utf-8
620                // Content-Type: multipart/form-data; boundary=something
621
622                int indexOfSemicolon = contentTypeHeaderValue.indexOf(";");
623
624                // Simple case, e.g. "text/html"
625                if (indexOfSemicolon == -1)
626                        return Optional.ofNullable(trimAggressivelyToNull(contentTypeHeaderValue));
627
628                // More complex case, e.g. "text/html; charset=utf-8"
629                return Optional.ofNullable(trimAggressivelyToNull(contentTypeHeaderValue.substring(0, indexOfSemicolon)));
630        }
631
632        @Nonnull
633        public static Optional<Charset> extractCharsetFromHeaders(@Nonnull Map<String, Set<String>> headers) {
634                requireNonNull(headers);
635
636                Set<String> contentTypeHeaderValues = headers.get("Content-Type");
637
638                if (contentTypeHeaderValues == null || contentTypeHeaderValues.size() == 0)
639                        return Optional.empty();
640
641                return extractCharsetFromHeaderValue(contentTypeHeaderValues.stream().findFirst().get());
642        }
643
644        @Nonnull
645        public static Optional<Charset> extractCharsetFromHeaderValue(@Nullable String contentTypeHeaderValue) {
646                contentTypeHeaderValue = trimAggressivelyToNull(contentTypeHeaderValue);
647
648                if (contentTypeHeaderValue == null)
649                        return Optional.empty();
650
651                // Examples
652                // Content-Type: text/html; charset=utf-8
653                // Content-Type: multipart/form-data; boundary=something
654
655                int indexOfSemicolon = contentTypeHeaderValue.indexOf(";");
656
657                // Simple case, e.g. "text/html"
658                if (indexOfSemicolon == -1)
659                        return Optional.empty();
660
661                // More complex case, e.g. "text/html; charset=utf-8" or "multipart/form-data; charset=utf-8; boundary=something"
662                boolean finishedContentType = false;
663                boolean finishedCharsetName = false;
664                StringBuilder buffer = new StringBuilder();
665                String charsetName = null;
666
667                for (int i = 0; i < contentTypeHeaderValue.length(); i++) {
668                        char c = contentTypeHeaderValue.charAt(i);
669
670                        if (Character.isWhitespace(c))
671                                continue;
672
673                        if (c == ';') {
674                                // No content type yet?  This just be it...
675                                if (!finishedContentType) {
676                                        finishedContentType = true;
677                                        buffer = new StringBuilder();
678                                } else if (!finishedCharsetName) {
679                                        if (buffer.indexOf("charset=") == 0) {
680                                                charsetName = buffer.toString();
681                                                finishedCharsetName = true;
682                                                break;
683                                        }
684                                }
685                        } else {
686                                buffer.append(Character.toLowerCase(c));
687                        }
688                }
689
690                // Handle case where charset is the end of the string, e.g. "whatever;charset=utf-8"
691                if (!finishedCharsetName) {
692                        String potentialCharset = trimAggressivelyToNull(buffer.toString());
693                        if (potentialCharset != null && potentialCharset.startsWith("charset=")) {
694                                finishedCharsetName = true;
695                                charsetName = potentialCharset;
696                        }
697                }
698
699                if (finishedCharsetName) {
700                        // e.g. "charset=utf-8" -> "utf-8"
701                        charsetName = trimAggressivelyToNull(charsetName.replace("charset=", ""));
702
703                        if (charsetName != null) {
704                                try {
705                                        return Optional.of(Charset.forName(charsetName));
706                                } catch (IllegalCharsetNameException | UnsupportedCharsetException ignored) {
707                                        return Optional.empty();
708                                }
709                        }
710                }
711
712                return Optional.empty();
713        }
714
715        /**
716         * A "stronger" version of {@link String#trim()} which discards any kind of whitespace or invisible separator.
717         * <p>
718         * In a web environment with user-supplied inputs, this is the behavior we want the vast majority of the time.
719         * For example, users copy-paste URLs from Microsoft Word or Outlook and it's easy to accidentally include a {@code U+202F
720         * "Narrow No-Break Space (NNBSP)"} character at the end, which might break parsing.
721         * <p>
722         * See <a href="https://www.compart.com/en/unicode/U+202F">https://www.compart.com/en/unicode/U+202F</a> for details.
723         *
724         * @param string the string to trim
725         * @return the trimmed string, or {@code null} if the input string is {@code null} or the trimmed representation is of length {@code 0}
726         */
727        @Nullable
728        public static String trimAggressively(@Nullable String string) {
729                if (string == null)
730                        return null;
731
732                string = HEAD_WHITESPACE_PATTERN.matcher(string).replaceAll("");
733
734                if (string.length() == 0)
735                        return string;
736
737                string = TAIL_WHITESPACE_PATTERN.matcher(string).replaceAll("");
738
739                return string;
740        }
741
742        @Nullable
743        public static String trimAggressivelyToNull(@Nullable String string) {
744                if (string == null)
745                        return null;
746
747                string = trimAggressively(string);
748                return string.length() == 0 ? null : string;
749        }
750
751        @Nonnull
752        public static String trimAggressivelyToEmpty(@Nullable String string) {
753                if (string == null)
754                        return "";
755
756                return trimAggressively(string);
757        }
758}