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