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 < 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}