001/* 002 * Copyright 2024-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.servlet.jakarta; 018 019import com.soklet.Request; 020import com.soklet.Utilities; 021import jakarta.servlet.AsyncContext; 022import jakarta.servlet.DispatcherType; 023import jakarta.servlet.RequestDispatcher; 024import jakarta.servlet.ServletConnection; 025import jakarta.servlet.ServletContext; 026import jakarta.servlet.ServletException; 027import jakarta.servlet.ServletInputStream; 028import jakarta.servlet.ServletRequest; 029import jakarta.servlet.ServletResponse; 030import jakarta.servlet.http.Cookie; 031import jakarta.servlet.http.HttpServletMapping; 032import jakarta.servlet.http.HttpServletRequest; 033import jakarta.servlet.http.HttpServletResponse; 034import jakarta.servlet.http.HttpSession; 035import jakarta.servlet.http.HttpUpgradeHandler; 036import jakarta.servlet.http.MappingMatch; 037import jakarta.servlet.http.Part; 038 039import javax.annotation.Nonnull; 040import javax.annotation.Nullable; 041import javax.annotation.concurrent.NotThreadSafe; 042import java.io.BufferedReader; 043import java.io.ByteArrayInputStream; 044import java.io.IOException; 045import java.io.InputStream; 046import java.io.InputStreamReader; 047import java.io.UnsupportedEncodingException; 048import java.net.InetAddress; 049import java.net.URI; 050import java.nio.charset.Charset; 051import java.nio.charset.IllegalCharsetNameException; 052import java.nio.charset.StandardCharsets; 053import java.nio.charset.UnsupportedCharsetException; 054import java.security.Principal; 055import java.time.Instant; 056import java.time.ZoneOffset; 057import java.time.format.DateTimeFormatter; 058import java.time.format.DateTimeFormatterBuilder; 059import java.time.format.SignStyle; 060import java.time.temporal.ChronoField; 061import java.util.ArrayList; 062import java.util.Collection; 063import java.util.Collections; 064import java.util.Enumeration; 065import java.util.HashMap; 066import java.util.HashSet; 067import java.util.List; 068import java.util.Locale; 069import java.util.Map; 070import java.util.Map.Entry; 071import java.util.Optional; 072import java.util.Set; 073import java.util.TreeMap; 074import java.util.UUID; 075 076import static java.lang.String.format; 077import static java.util.Locale.ROOT; 078import static java.util.Locale.US; 079import static java.util.Locale.getDefault; 080import static java.util.Objects.requireNonNull; 081 082/** 083 * Soklet integration implementation of {@link HttpServletRequest}. 084 * 085 * @author <a href="https://www.revetkn.com">Mark Allen</a> 086 */ 087@NotThreadSafe 088public final class SokletHttpServletRequest implements HttpServletRequest { 089 @Nonnull 090 private static final Charset DEFAULT_CHARSET; 091 @Nonnull 092 private static final DateTimeFormatter RFC_1123_PARSER; 093 @Nonnull 094 private static final DateTimeFormatter RFC_1036_PARSER; 095 @Nonnull 096 private static final DateTimeFormatter ASCTIME_PARSER; 097 098 static { 099 DEFAULT_CHARSET = StandardCharsets.ISO_8859_1; // Per Servlet spec 100 RFC_1123_PARSER = DateTimeFormatter.RFC_1123_DATE_TIME; 101 // RFC 1036: spaces between day/month/year + 2-digit year reduced to 19xx baseline. 102 RFC_1036_PARSER = new DateTimeFormatterBuilder() 103 .parseCaseInsensitive() 104 .appendPattern("EEE, dd MMM ") 105 .appendValueReduced(ChronoField.YEAR, 2, 2, 1900) // 94 -> 1994 106 .appendPattern(" HH:mm:ss zzz") 107 .toFormatter(US) 108 .withZone(ZoneOffset.UTC); 109 110 // asctime: "EEE MMM d HH:mm:ss yyyy" — allow 1 or 2 spaces before day, no zone in text → default GMT. 111 ASCTIME_PARSER = new DateTimeFormatterBuilder() 112 .parseCaseInsensitive() 113 .appendPattern("EEE MMM") 114 .appendLiteral(' ') 115 .optionalStart().appendLiteral(' ').optionalEnd() // tolerate double space before single-digit day 116 .appendValue(ChronoField.DAY_OF_MONTH, 1, 2, SignStyle.NOT_NEGATIVE) 117 .appendPattern(" HH:mm:ss yyyy") 118 .toFormatter(US) 119 .withZone(ZoneOffset.UTC); 120 } 121 122 @Nonnull 123 private final Request request; 124 @Nullable 125 private final String host; 126 @Nullable 127 private final Integer port; 128 @Nonnull 129 private final ServletContext servletContext; 130 @Nullable 131 private HttpSession httpSession; 132 @Nonnull 133 private final Map<String, Object> attributes; 134 @Nonnull 135 private final List<Cookie> cookies; 136 @Nullable 137 private Charset charset; 138 @Nullable 139 private String contentType; 140 141 @Nonnull 142 public static Builder withRequest(@Nonnull Request request) { 143 return new Builder(request); 144 } 145 146 private SokletHttpServletRequest(@Nonnull Builder builder) { 147 requireNonNull(builder); 148 requireNonNull(builder.request); 149 150 this.request = builder.request; 151 this.attributes = new HashMap<>(); 152 this.cookies = parseCookies(request); 153 this.charset = parseCharacterEncoding(request).orElse(null); 154 this.contentType = parseContentType(request).orElse(null); 155 this.host = builder.host; 156 this.port = builder.port; 157 this.servletContext = builder.servletContext == null ? SokletServletContext.withDefaults() : builder.servletContext; 158 this.httpSession = builder.httpSession; 159 } 160 161 @Nonnull 162 protected Request getRequest() { 163 return this.request; 164 } 165 166 @Nonnull 167 protected Map<String, Object> getAttributes() { 168 return this.attributes; 169 } 170 171 @Nonnull 172 protected List<Cookie> parseCookies(@Nonnull Request request) { 173 requireNonNull(request); 174 175 Map<String, Set<String>> cookies = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); 176 cookies.putAll(request.getCookies()); 177 178 List<Cookie> convertedCookies = new ArrayList<>(cookies.size()); 179 180 for (Entry<String, Set<String>> entry : cookies.entrySet()) { 181 String name = entry.getKey(); 182 Set<String> values = entry.getValue(); 183 184 // Should never occur... 185 if (name == null) 186 continue; 187 188 for (String value : values) 189 convertedCookies.add(new Cookie(name, value)); 190 } 191 192 return convertedCookies; 193 } 194 195 @Nonnull 196 protected Optional<Charset> parseCharacterEncoding(@Nonnull Request request) { 197 requireNonNull(request); 198 return Utilities.extractCharsetFromHeaders(request.getHeaders()); 199 } 200 201 @Nonnull 202 protected Optional<String> parseContentType(@Nonnull Request request) { 203 requireNonNull(request); 204 return Utilities.extractContentTypeFromHeaders(request.getHeaders()); 205 } 206 207 @Nonnull 208 protected Optional<HttpSession> getHttpSession() { 209 return Optional.ofNullable(this.httpSession); 210 } 211 212 protected void setHttpSession(@Nullable HttpSession httpSession) { 213 this.httpSession = httpSession; 214 } 215 216 @Nonnull 217 protected Optional<Charset> getCharset() { 218 return Optional.ofNullable(this.charset); 219 } 220 221 protected void setCharset(@Nullable Charset charset) { 222 this.charset = charset; 223 } 224 225 @Nonnull 226 protected Optional<String> getHost() { 227 return Optional.ofNullable(this.host); 228 } 229 230 @Nonnull 231 protected Optional<Integer> getPort() { 232 return Optional.ofNullable(this.port); 233 } 234 235 /** 236 * Builder used to construct instances of {@link SokletHttpServletRequest}. 237 * <p> 238 * This class is intended for use by a single thread. 239 * 240 * @author <a href="https://www.revetkn.com">Mark Allen</a> 241 */ 242 @NotThreadSafe 243 public static class Builder { 244 @Nonnull 245 private Request request; 246 @Nullable 247 private Integer port; 248 @Nullable 249 private String host; 250 @Nullable 251 private ServletContext servletContext; 252 @Nullable 253 private HttpSession httpSession; 254 255 @Nonnull 256 private Builder(@Nonnull Request request) { 257 requireNonNull(request); 258 this.request = request; 259 } 260 261 @Nonnull 262 public Builder request(@Nonnull Request request) { 263 requireNonNull(request); 264 this.request = request; 265 return this; 266 } 267 268 @Nonnull 269 public Builder host(@Nullable String host) { 270 this.host = host; 271 return this; 272 } 273 274 @Nonnull 275 public Builder port(@Nullable Integer port) { 276 this.port = port; 277 return this; 278 } 279 280 @Nonnull 281 public Builder servletContext(@Nullable ServletContext servletContext) { 282 this.servletContext = servletContext; 283 return this; 284 } 285 286 @Nonnull 287 public Builder httpSession(@Nullable HttpSession httpSession) { 288 this.httpSession = httpSession; 289 return this; 290 } 291 292 @Nonnull 293 public SokletHttpServletRequest build() { 294 return new SokletHttpServletRequest(this); 295 } 296 } 297 298 // Implementation of HttpServletRequest methods below: 299 300 // Helpful reference at https://stackoverflow.com/a/21046620 by Victor Stafusa - BozoNaCadeia 301 // 302 // Method URL-Decoded Result 303 // ---------------------------------------------------- 304 // getContextPath() no /app 305 // getLocalAddr() 127.0.0.1 306 // getLocalName() 30thh.loc 307 // getLocalPort() 8480 308 // getMethod() GET 309 // getPathInfo() yes /a?+b 310 // getProtocol() HTTP/1.1 311 // getQueryString() no p+1=c+d&p+2=e+f 312 // getRequestedSessionId() no S%3F+ID 313 // getRequestURI() no /app/test%3F/a%3F+b;jsessionid=S+ID 314 // getRequestURL() no http://30thh.loc:8480/app/test%3F/a%3F+b;jsessionid=S+ID 315 // getScheme() http 316 // getServerName() 30thh.loc 317 // getServerPort() 8480 318 // getServletPath() yes /test? 319 // getParameterNames() yes [p 2, p 1] 320 // getParameter("p 1") yes c d 321 322 @Override 323 @Nullable 324 public String getAuthType() { 325 // This is legal according to spec 326 return null; 327 } 328 329 @Override 330 @Nonnull 331 public Cookie[] getCookies() { 332 return this.cookies.toArray(new Cookie[0]); 333 } 334 335 @Override 336 public long getDateHeader(@Nullable String name) { 337 if (name == null) 338 return -1; 339 340 String value = getHeader(name); 341 342 if (value == null) 343 return -1; 344 345 // Try HTTP-date formats (RFC 1123 → RFC 1036 → asctime) 346 for (DateTimeFormatter fmt : List.of(RFC_1123_PARSER, RFC_1036_PARSER, ASCTIME_PARSER)) { 347 try { 348 return Instant.from(fmt.parse(value)).toEpochMilli(); 349 } catch (Exception ignored) { 350 // try next 351 } 352 } 353 354 // Fallback: epoch millis 355 try { 356 return Long.parseLong(value); 357 } catch (NumberFormatException e) { 358 throw new IllegalArgumentException( 359 String.format("Header with name '%s' and value '%s' cannot be converted to a date", name, value), 360 e 361 ); 362 } 363 } 364 365 @Override 366 @Nullable 367 public String getHeader(@Nullable String name) { 368 if (name == null) 369 return null; 370 371 return getRequest().getHeader(name).orElse(null); 372 } 373 374 @Override 375 @Nonnull 376 public Enumeration<String> getHeaders(@Nullable String name) { 377 if (name == null) 378 return Collections.emptyEnumeration(); 379 380 Set<String> values = request.getHeaders().get(name); 381 return values == null ? Collections.emptyEnumeration() : Collections.enumeration(values); 382 } 383 384 @Override 385 @Nonnull 386 public Enumeration<String> getHeaderNames() { 387 return Collections.enumeration(getRequest().getHeaders().keySet()); 388 } 389 390 @Override 391 public int getIntHeader(@Nullable String name) { 392 if (name == null) 393 return -1; 394 395 String value = getHeader(name); 396 397 if (value == null) 398 return -1; 399 400 // Throws NumberFormatException if parsing fails, per spec 401 return Integer.valueOf(value, 10); 402 } 403 404 @Override 405 @Nonnull 406 public String getMethod() { 407 return getRequest().getHttpMethod().name(); 408 } 409 410 @Override 411 @Nullable 412 public String getPathInfo() { 413 return getRequest().getPath(); 414 } 415 416 @Override 417 @Nullable 418 public String getPathTranslated() { 419 return getRequest().getPath(); 420 } 421 422 @Override 423 @Nonnull 424 public String getContextPath() { 425 return ""; 426 } 427 428 @Override 429 @Nullable 430 public String getQueryString() { 431 try { 432 URI uri = new URI(request.getUri()); 433 return uri.getQuery(); 434 } catch (Exception ignored) { 435 return null; 436 } 437 } 438 439 @Override 440 @Nullable 441 public String getRemoteUser() { 442 // This is legal according to spec 443 return null; 444 } 445 446 @Override 447 public boolean isUserInRole(@Nullable String role) { 448 // This is legal according to spec 449 return false; 450 } 451 452 @Override 453 @Nullable 454 public Principal getUserPrincipal() { 455 // This is legal according to spec 456 return null; 457 } 458 459 @Override 460 @Nullable 461 public String getRequestedSessionId() { 462 // This is legal according to spec 463 return null; 464 } 465 466 @Override 467 @Nonnull 468 public String getRequestURI() { 469 return getRequest().getPath(); 470 } 471 472 @Override 473 @Nonnull 474 public StringBuffer getRequestURL() { 475 // Try forwarded/synthesized absolute prefix first 476 String clientUrlPrefix = Utilities.extractClientUrlPrefixFromHeaders(getRequest().getHeaders()).orElse(null); 477 478 if (clientUrlPrefix != null) 479 return new StringBuffer(format("%s%s", clientUrlPrefix, getRequest().getPath())); 480 481 // Fall back to builder-provided host/port when available 482 String scheme = getScheme(); // Soklet returns "http" by design 483 String host = getServerName(); 484 int port = getServerPort(); // may throw if not initialized by builder 485 boolean defaultPort = ("https".equalsIgnoreCase(scheme) && port == 443) || ("http".equalsIgnoreCase(scheme) && port == 80); 486 String authority = defaultPort ? host : format("%s:%d", host, port); 487 return new StringBuffer(format("%s://%s%s", scheme, authority, getRequest().getPath())); 488 } 489 490 @Override 491 @Nonnull 492 public String getServletPath() { 493 // This is legal according to spec 494 return ""; 495 } 496 497 @Override 498 @Nullable 499 public HttpSession getSession(boolean create) { 500 HttpSession currentHttpSession = getHttpSession().orElse(null); 501 502 if (create && currentHttpSession == null) { 503 currentHttpSession = SokletHttpSession.withServletContext(getServletContext()); 504 setHttpSession(currentHttpSession); 505 } 506 507 return currentHttpSession; 508 } 509 510 @Override 511 @Nonnull 512 public HttpSession getSession() { 513 HttpSession currentHttpSession = getHttpSession().orElse(null); 514 515 if (currentHttpSession == null) { 516 currentHttpSession = SokletHttpSession.withServletContext(getServletContext()); 517 setHttpSession(currentHttpSession); 518 } 519 520 return currentHttpSession; 521 } 522 523 @Override 524 @Nonnull 525 public String changeSessionId() { 526 HttpSession currentHttpSession = getHttpSession().orElse(null); 527 528 if (currentHttpSession == null) 529 throw new IllegalStateException("No session is present"); 530 531 if (!(currentHttpSession instanceof SokletHttpSession)) 532 throw new IllegalStateException(format("Cannot change session IDs. Session must be of type %s; instead it is of type %s", 533 SokletHttpSession.class.getSimpleName(), currentHttpSession.getClass().getSimpleName())); 534 535 UUID newSessionId = UUID.randomUUID(); 536 ((SokletHttpSession) currentHttpSession).setSessionId(newSessionId); 537 return String.valueOf(newSessionId); 538 } 539 540 @Override 541 public boolean isRequestedSessionIdValid() { 542 // This is legal according to spec 543 return false; 544 } 545 546 @Override 547 public boolean isRequestedSessionIdFromCookie() { 548 // This is legal according to spec 549 return false; 550 } 551 552 @Override 553 public boolean isRequestedSessionIdFromURL() { 554 // This is legal according to spec 555 return false; 556 } 557 558 @Override 559 public boolean authenticate(@Nonnull HttpServletResponse httpServletResponse) throws IOException, ServletException { 560 requireNonNull(httpServletResponse); 561 // TODO: perhaps revisit this in the future 562 throw new ServletException("Authentication is not supported"); 563 } 564 565 @Override 566 public void login(@Nullable String username, 567 @Nullable String password) throws ServletException { 568 // This is legal according to spec 569 throw new ServletException("Authentication login is not supported"); 570 } 571 572 @Override 573 public void logout() throws ServletException { 574 // This is legal according to spec 575 throw new ServletException("Authentication logout is not supported"); 576 } 577 578 @Override 579 @Nonnull 580 public Collection<Part> getParts() throws IOException, ServletException { 581 // Legal if the request body is larger than maxRequestSize, or any Part in the request is larger than maxFileSize, 582 // or there is no @MultipartConfig or multipart-config in deployment descriptors 583 throw new IllegalStateException("Servlet multipart configuration is not supported"); 584 } 585 586 @Override 587 @Nullable 588 public Part getPart(@Nullable String name) throws IOException, ServletException { 589 // Legal if the request body is larger than maxRequestSize, or any Part in the request is larger than maxFileSize, 590 // or there is no @MultipartConfig or multipart-config in deployment descriptors 591 throw new IllegalStateException("Servlet multipart configuration is not supported"); 592 } 593 594 @Override 595 @Nonnull 596 public <T extends HttpUpgradeHandler> T upgrade(@Nullable Class<T> handlerClass) throws IOException, ServletException { 597 // Legal if the given handlerClass fails to be instantiated 598 throw new ServletException("HTTP upgrade is not supported"); 599 } 600 601 @Override 602 @Nullable 603 public Object getAttribute(@Nullable String name) { 604 if (name == null) 605 return null; 606 607 return getAttributes().get(name); 608 } 609 610 @Override 611 @Nonnull 612 public Enumeration<String> getAttributeNames() { 613 return Collections.enumeration(getAttributes().keySet()); 614 } 615 616 @Override 617 @Nonnull 618 public String getCharacterEncoding() { 619 Charset charset = getCharset().orElse(null); 620 return charset == null ? null : charset.name(); 621 } 622 623 @Override 624 public void setCharacterEncoding(@Nullable String env) throws UnsupportedEncodingException { 625 // Note that spec says: "This method must be called prior to reading request parameters or 626 // reading input using getReader(). Otherwise, it has no effect." 627 // ...but we don't need to care about this because Soklet requests are byte arrays of finite size, not streams 628 if (env == null) { 629 setCharset(null); 630 } else { 631 try { 632 setCharset(Charset.forName(env)); 633 } catch (IllegalCharsetNameException | UnsupportedCharsetException e) { 634 throw new UnsupportedEncodingException(format("Not sure how to handle character encoding '%s'", env)); 635 } 636 } 637 } 638 639 @Override 640 public int getContentLength() { 641 byte[] body = request.getBody().orElse(null); 642 return body == null ? 0 : body.length; 643 } 644 645 @Override 646 public long getContentLengthLong() { 647 byte[] body = request.getBody().orElse(null); 648 return body == null ? 0 : body.length; 649 } 650 651 @Override 652 @Nullable 653 public String getContentType() { 654 return this.contentType; 655 } 656 657 @Override 658 @Nonnull 659 public ServletInputStream getInputStream() throws IOException { 660 byte[] body = getRequest().getBody().orElse(new byte[]{}); 661 return SokletServletInputStream.withInputStream(new ByteArrayInputStream(body)); 662 } 663 664 @Override 665 @Nullable 666 public String getParameter(@Nullable String name) { 667 String value = null; 668 669 // First, check query parameters. 670 if (getRequest().getQueryParameters().keySet().contains(name)) { 671 // If there is a query parameter with the given name, return it 672 value = getRequest().getQueryParameter(name).orElse(null); 673 } else if (getRequest().getFormParameters().keySet().contains(name)) { 674 // Otherwise, check form parameters in request body 675 value = getRequest().getFormParameter(name).orElse(null); 676 } 677 678 return value; 679 } 680 681 @Override 682 @Nonnull 683 public Enumeration<String> getParameterNames() { 684 Set<String> queryParameterNames = getRequest().getQueryParameters().keySet(); 685 Set<String> formParameterNames = getRequest().getFormParameters().keySet(); 686 687 Set<String> parameterNames = new HashSet<>(queryParameterNames.size() + formParameterNames.size()); 688 parameterNames.addAll(queryParameterNames); 689 parameterNames.addAll(formParameterNames); 690 691 return Collections.enumeration(parameterNames); 692 } 693 694 @Override 695 @Nullable 696 public String[] getParameterValues(@Nullable String name) { 697 if (name == null) 698 return null; 699 700 List<String> parameterValues = new ArrayList<>(); 701 702 Set<String> queryValues = getRequest().getQueryParameters().get(name); 703 704 if (queryValues != null) 705 parameterValues.addAll(queryValues); 706 707 Set<String> formValues = getRequest().getFormParameters().get(name); 708 709 if (formValues != null) 710 parameterValues.addAll(formValues); 711 712 return parameterValues.isEmpty() ? null : parameterValues.toArray(new String[0]); 713 } 714 715 @Override 716 @Nonnull 717 public Map<String, String[]> getParameterMap() { 718 Map<String, Set<String>> parameterMap = new HashMap<>(); 719 720 // Mutable copy of entries 721 for (Entry<String, Set<String>> entry : getRequest().getQueryParameters().entrySet()) 722 parameterMap.put(entry.getKey(), new HashSet<>(entry.getValue())); 723 724 // Add form parameters to entries 725 for (Entry<String, Set<String>> entry : getRequest().getFormParameters().entrySet()) { 726 Set<String> existingEntries = parameterMap.get(entry.getKey()); 727 728 if (existingEntries != null) 729 existingEntries.addAll(entry.getValue()); 730 else 731 parameterMap.put(entry.getKey(), entry.getValue()); 732 } 733 734 Map<String, String[]> finalParameterMap = new HashMap<>(); 735 736 for (Entry<String, Set<String>> entry : parameterMap.entrySet()) 737 finalParameterMap.put(entry.getKey(), entry.getValue().toArray(new String[0])); 738 739 return Collections.unmodifiableMap(finalParameterMap); 740 } 741 742 @Override 743 @Nonnull 744 public String getProtocol() { 745 return "HTTP/1.1"; 746 } 747 748 @Override 749 @Nonnull 750 public String getScheme() { 751 // Honor common reverse-proxy header; fall back to http 752 String proto = getRequest().getHeader("X-Forwarded-Proto").orElse(null); 753 754 if (proto != null) { 755 proto = proto.trim().toLowerCase(ROOT); 756 if (proto.equals("https") || proto.equals("http")) 757 return proto; 758 } 759 760 return "http"; 761 } 762 763 @Override 764 @Nonnull 765 public String getServerName() { 766 // Path only (no query parameters) preceded by remote protocol, host, and port (if available) 767 // e.g. https://www.soklet.com/test/abc 768 String clientUrlPrefix = Utilities.extractClientUrlPrefixFromHeaders(getRequest().getHeaders()).orElse(null); 769 770 if (clientUrlPrefix == null) 771 return getLocalName(); 772 773 clientUrlPrefix = clientUrlPrefix.toLowerCase(ROOT); 774 775 // Remove protocol prefix 776 if (clientUrlPrefix.startsWith("https://")) 777 clientUrlPrefix = clientUrlPrefix.replace("https://", ""); 778 else if (clientUrlPrefix.startsWith("http://")) 779 clientUrlPrefix = clientUrlPrefix.replace("http://", ""); 780 781 // Remove "/" and anything after it 782 int indexOfFirstSlash = clientUrlPrefix.indexOf("/"); 783 784 if (indexOfFirstSlash != -1) 785 clientUrlPrefix = clientUrlPrefix.substring(0, indexOfFirstSlash); 786 787 // Remove ":" and anything after it (port) 788 int indexOfColon = clientUrlPrefix.indexOf(":"); 789 790 if (indexOfColon != -1) 791 clientUrlPrefix = clientUrlPrefix.substring(0, indexOfColon); 792 793 return clientUrlPrefix; 794 } 795 796 @Override 797 public int getServerPort() { 798 // Path only (no query parameters) preceded by remote protocol, host, and port (if available) 799 // e.g. https://www.soklet.com/test/abc 800 String clientUrlPrefix = Utilities.extractClientUrlPrefixFromHeaders(getRequest().getHeaders()).orElse(null); 801 802 if (clientUrlPrefix == null) 803 return getLocalPort(); 804 805 clientUrlPrefix = clientUrlPrefix.toLowerCase(ROOT); 806 807 boolean https = false; 808 809 // Remove protocol prefix 810 if (clientUrlPrefix.startsWith("https://")) { 811 clientUrlPrefix = clientUrlPrefix.replace("https://", ""); 812 https = true; 813 } else if (clientUrlPrefix.startsWith("http://")) { 814 clientUrlPrefix = clientUrlPrefix.replace("http://", ""); 815 } 816 817 // Remove "/" and anything after it 818 int indexOfFirstSlash = clientUrlPrefix.indexOf("/"); 819 820 if (indexOfFirstSlash != -1) 821 clientUrlPrefix = clientUrlPrefix.substring(0, indexOfFirstSlash); 822 823 String[] hostAndPortComponents = clientUrlPrefix.split(":"); 824 825 // No explicit port? Look at protocol for guidance 826 if (hostAndPortComponents.length == 1) 827 return https ? 443 : 80; 828 829 try { 830 return Integer.parseInt(hostAndPortComponents[1], 10); 831 } catch (Exception ignored) { 832 return getLocalPort(); 833 } 834 } 835 836 @Override 837 @Nonnull 838 public BufferedReader getReader() throws IOException { 839 Charset charset = getCharset().orElse(DEFAULT_CHARSET); 840 InputStream inputStream = new ByteArrayInputStream(getRequest().getBody().orElse(new byte[0])); 841 return new BufferedReader(new InputStreamReader(inputStream, charset)); 842 } 843 844 @Override 845 @Nullable 846 public String getRemoteAddr() { 847 String xForwardedForHeader = getRequest().getHeader("X-Forwarded-For").orElse(null); 848 849 if (xForwardedForHeader == null) 850 return null; 851 852 // Example value: 203.0.113.195,2001:db8:85a3:8d3:1319:8a2e:370:7348,198.51.100.178 853 String[] components = xForwardedForHeader.split(","); 854 855 if (components.length == 0 || components[0] == null) 856 return null; 857 858 String value = components[0].trim(); 859 return value.length() > 0 ? value : "127.0.0.1"; 860 } 861 862 @Override 863 @Nullable 864 public String getRemoteHost() { 865 // This is X-Forwarded-For and is generally what we want (if present) 866 String remoteAddr = getRemoteAddr(); 867 868 if (remoteAddr != null) 869 return remoteAddr; 870 871 // Path only (no query parameters) preceded by remote protocol, host, and port (if available) 872 // e.g. https://www.soklet.com/test/abc 873 String clientUrlPrefix = Utilities.extractClientUrlPrefixFromHeaders(getRequest().getHeaders()).orElse(null); 874 875 if (clientUrlPrefix != null) { 876 clientUrlPrefix = clientUrlPrefix.toLowerCase(ROOT); 877 878 // Remove protocol prefix 879 if (clientUrlPrefix.startsWith("https://")) 880 clientUrlPrefix = clientUrlPrefix.replace("https://", ""); 881 else if (clientUrlPrefix.startsWith("http://")) 882 clientUrlPrefix = clientUrlPrefix.replace("http://", ""); 883 884 // Remove "/" and anything after it 885 int indexOfFirstSlash = clientUrlPrefix.indexOf("/"); 886 887 if (indexOfFirstSlash != -1) 888 clientUrlPrefix = clientUrlPrefix.substring(0, indexOfFirstSlash); 889 890 String[] hostAndPortComponents = clientUrlPrefix.split(":"); 891 892 String host = null; 893 894 if (hostAndPortComponents != null && hostAndPortComponents.length > 0 && hostAndPortComponents[0] != null) 895 host = hostAndPortComponents[0].trim(); 896 897 if (host != null && host.length() > 0) 898 return host; 899 } 900 901 // "If the engine cannot or chooses not to resolve the hostname (to improve performance), 902 // this method returns the dotted-string form of the IP address." 903 return getRemoteAddr(); 904 } 905 906 @Override 907 public void setAttribute(@Nullable String name, 908 @Nullable Object o) { 909 if (name == null) 910 return; 911 912 if (o == null) 913 removeAttribute(name); 914 else 915 getAttributes().put(name, o); 916 } 917 918 @Override 919 public void removeAttribute(@Nullable String name) { 920 if (name == null) 921 return; 922 923 getAttributes().remove(name); 924 } 925 926 @Override 927 @Nonnull 928 public Locale getLocale() { 929 List<Locale> locales = getRequest().getLocales(); 930 return locales.size() == 0 ? getDefault() : locales.get(0); 931 } 932 933 @Override 934 @Nonnull 935 public Enumeration<Locale> getLocales() { 936 List<Locale> locales = getRequest().getLocales(); 937 return Collections.enumeration(locales.size() == 0 ? List.of(getDefault()) : locales); 938 } 939 940 @Override 941 public boolean isSecure() { 942 return getScheme().equals("https"); 943 } 944 945 @Override 946 @Nullable 947 public RequestDispatcher getRequestDispatcher(@Nullable String path) { 948 // "This method returns null if the servlet container cannot return a RequestDispatcher." 949 return null; 950 } 951 952 @Override 953 public int getRemotePort() { 954 // Not reliably knowable without a socket; return 0 to indicate "unknown" 955 return 0; 956 } 957 958 @Override 959 @Nonnull 960 public String getLocalName() { 961 if (getHost().isPresent()) 962 return getHost().get(); 963 964 try { 965 String hostName = InetAddress.getLocalHost().getHostName(); 966 967 if (hostName != null) { 968 hostName = hostName.trim(); 969 970 if (hostName.length() > 0) 971 return hostName; 972 } 973 } catch (Exception e) { 974 // Ignored 975 } 976 977 return "localhost"; 978 } 979 980 @Override 981 @Nonnull 982 public String getLocalAddr() { 983 try { 984 String hostAddress = InetAddress.getLocalHost().getHostAddress(); 985 986 if (hostAddress != null) { 987 hostAddress = hostAddress.trim(); 988 989 if (hostAddress.length() > 0) 990 return hostAddress; 991 } 992 } catch (Exception e) { 993 // Ignored 994 } 995 996 return "127.0.0.1"; 997 } 998 999 @Override 1000 public int getLocalPort() { 1001 return getPort().orElseThrow(() -> new IllegalStateException(format("%s must be initialized with a port in order to call this method", 1002 getClass().getSimpleName()))); 1003 } 1004 1005 @Override 1006 @Nonnull 1007 public ServletContext getServletContext() { 1008 return this.servletContext; 1009 } 1010 1011 @Override 1012 @Nonnull 1013 public AsyncContext startAsync() throws IllegalStateException { 1014 throw new IllegalStateException("Soklet does not support async servlet operations"); 1015 } 1016 1017 @Override 1018 @Nonnull 1019 public AsyncContext startAsync(@Nonnull ServletRequest servletRequest, 1020 @Nonnull ServletResponse servletResponse) throws IllegalStateException { 1021 requireNonNull(servletResponse); 1022 requireNonNull(servletResponse); 1023 1024 throw new IllegalStateException("Soklet does not support async servlet operations"); 1025 } 1026 1027 @Override 1028 public boolean isAsyncStarted() { 1029 return false; 1030 } 1031 1032 @Override 1033 public boolean isAsyncSupported() { 1034 return false; 1035 } 1036 1037 @Override 1038 @Nonnull 1039 public AsyncContext getAsyncContext() { 1040 throw new IllegalStateException("Soklet does not support async servlet operations"); 1041 } 1042 1043 @Override 1044 @Nonnull 1045 public DispatcherType getDispatcherType() { 1046 // Currently Soklet does not support RequestDispatcher, so this is safe to hardcode 1047 return DispatcherType.REQUEST; 1048 } 1049 1050 // *** Jakarta-specific below 1051 1052 @Nullable 1053 private String requestId; 1054 @Nullable 1055 private ServletConnection servletConnection; 1056 1057 @Override 1058 @Nonnull 1059 public HttpServletMapping getHttpServletMapping() { 1060 // Soklet does not use Servlet mappings. Return a default mapping consistent with the container's default servlet handling ("/"). 1061 return new HttpServletMapping() { 1062 @Override 1063 @Nonnull 1064 public String getMatchValue() { 1065 return ""; // empty for DEFAULT 1066 } 1067 1068 @Override 1069 @Nonnull 1070 public String getPattern() { 1071 return "/"; 1072 } 1073 1074 @Override 1075 @Nonnull 1076 public String getServletName() { 1077 return "Soklet"; 1078 } 1079 1080 @Override 1081 @Nonnull 1082 public MappingMatch getMappingMatch() { 1083 return MappingMatch.DEFAULT; 1084 } 1085 }; 1086 } 1087 1088 @Override 1089 @Nonnull 1090 public Map<String, String> getTrailerFields() { 1091 // Soklet requests are backed by an in-memory byte array and do not support protocol trailers. 1092 return Map.of(); 1093 } 1094 1095 @Override 1096 public boolean isTrailerFieldsReady() { 1097 // There will never be trailers to read for Soklet-backed requests. 1098 return true; 1099 } 1100 1101 @Override 1102 public void setCharacterEncoding(@Nullable Charset encoding) { 1103 // Prefer the new 6.1 overload. Behaves like setCharacterEncoding(String) but without checked exception. 1104 setCharset(encoding); 1105 } 1106 1107 @Override 1108 @Nonnull 1109 public String getRequestId() { 1110 if (this.requestId == null) 1111 this.requestId = UUID.randomUUID().toString(); 1112 1113 return this.requestId; 1114 } 1115 1116 @Override 1117 @Nonnull 1118 public String getProtocolRequestId() { 1119 // Per Servlet 6.1 specification, for HTTP/1.x there is no protocol-defined request ID. 1120 // Return the empty string in that case. 1121 return ""; 1122 } 1123 1124 @Override 1125 @Nonnull 1126 public ServletConnection getServletConnection() { 1127 if (this.servletConnection == null) { 1128 String protocol = getProtocol(); // e.g. "HTTP/1.1" 1129 boolean secure = "https".equalsIgnoreCase(getScheme()); 1130 String alpn = protocol.toUpperCase(Locale.ROOT).startsWith("HTTP/1") ? "http/1.1" : "unknown"; 1131 String connectionId = UUID.randomUUID().toString(); 1132 1133 this.servletConnection = new ServletConnection() { 1134 @Override 1135 @Nonnull 1136 public String getConnectionId() { 1137 return connectionId; 1138 } 1139 1140 @Override 1141 @Nonnull 1142 public String getProtocol() { 1143 return alpn; 1144 } 1145 1146 1147 @Override 1148 @Nonnull 1149 public String getProtocolConnectionId() { 1150 // HTTP/1.x and HTTP/2 do not define a protocol connection ID per spec. 1151 return ""; 1152 } 1153 1154 @Override 1155 public boolean isSecure() { 1156 return secure; 1157 } 1158 }; 1159 } 1160 1161 return this.servletConnection; 1162 } 1163}