001/* 002 * Copyright 2024-2026 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.javax; 018 019import com.soklet.MarshaledResponse; 020import com.soklet.Request; 021import com.soklet.Response; 022import com.soklet.ResponseCookie; 023import com.soklet.StatusCode; 024import org.jspecify.annotations.NonNull; 025import org.jspecify.annotations.Nullable; 026 027import javax.annotation.concurrent.NotThreadSafe; 028import javax.servlet.ServletContext; 029import javax.servlet.ServletOutputStream; 030import javax.servlet.http.Cookie; 031import javax.servlet.http.HttpServletRequest; 032import javax.servlet.http.HttpServletResponse; 033import java.io.ByteArrayOutputStream; 034import java.io.IOException; 035import java.io.OutputStreamWriter; 036import java.io.PrintWriter; 037import java.net.IDN; 038import java.net.URI; 039import java.net.URISyntaxException; 040import java.nio.charset.Charset; 041import java.nio.charset.IllegalCharsetNameException; 042import java.nio.charset.StandardCharsets; 043import java.nio.charset.UnsupportedCharsetException; 044import java.time.Duration; 045import java.time.Instant; 046import java.time.ZoneId; 047import java.time.format.DateTimeFormatter; 048import java.util.ArrayList; 049import java.util.Collection; 050import java.util.Collections; 051import java.util.LinkedHashMap; 052import java.util.LinkedHashSet; 053import java.util.List; 054import java.util.Locale; 055import java.util.Map; 056import java.util.Optional; 057import java.util.Set; 058import java.util.TreeMap; 059import java.util.stream.Collectors; 060 061import static java.lang.String.format; 062import static java.util.Objects.requireNonNull; 063 064/** 065 * Soklet integration implementation of {@link HttpServletResponse}. 066 * 067 * @author <a href="https://www.revetkn.com">Mark Allen</a> 068 */ 069@NotThreadSafe 070public final class SokletHttpServletResponse implements HttpServletResponse { 071 @NonNull 072 private static final Integer DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES; 073 @NonNull 074 private static final Charset DEFAULT_CHARSET; 075 @NonNull 076 private static final DateTimeFormatter DATE_TIME_FORMATTER; 077 078 static { 079 DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES = 1_024; 080 DEFAULT_CHARSET = StandardCharsets.ISO_8859_1; // Per Servlet spec 081 DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz") 082 .withLocale(Locale.US) 083 .withZone(ZoneId.of("GMT")); 084 } 085 086 @NonNull 087 private final String rawPath; // Raw path (no query), e.g. "/test/abc" or "*" 088 @Nullable 089 private final HttpServletRequest httpServletRequest; 090 @NonNull 091 private final ServletContext servletContext; 092 @NonNull 093 private final List<@NonNull Cookie> cookies; 094 @NonNull 095 private final Map<@NonNull String, @NonNull List<@NonNull String>> headers; 096 @NonNull 097 private ByteArrayOutputStream responseOutputStream; 098 @NonNull 099 private ResponseWriteMethod responseWriteMethod; 100 @NonNull 101 private Integer statusCode; 102 @NonNull 103 private Boolean responseCommitted; 104 @NonNull 105 private Boolean responseFinalized; 106 @Nullable 107 private Locale locale; 108 @Nullable 109 private String errorMessage; 110 @Nullable 111 private String redirectUrl; 112 @Nullable 113 private Charset charset; 114 @Nullable 115 private String contentType; 116 @NonNull 117 private Integer responseBufferSizeInBytes; 118 @Nullable 119 private SokletServletOutputStream servletOutputStream; 120 @Nullable 121 private SokletServletPrintWriter printWriter; 122 123 @NonNull 124 public static SokletHttpServletResponse fromRequest(@NonNull HttpServletRequest request) { 125 requireNonNull(request); 126 String rawPath = request.getRequestURI(); 127 if (rawPath == null || rawPath.isEmpty()) 128 rawPath = "/"; 129 ServletContext servletContext = requireNonNull(request.getServletContext()); 130 return new SokletHttpServletResponse(request, rawPath, servletContext); 131 } 132 133 @NonNull 134 public static SokletHttpServletResponse fromRequest(@NonNull Request request, 135 @NonNull ServletContext servletContext) { 136 requireNonNull(request); 137 requireNonNull(servletContext); 138 HttpServletRequest httpServletRequest = SokletHttpServletRequest.withRequest(request) 139 .servletContext(servletContext) 140 .build(); 141 return fromRequest(httpServletRequest); 142 } 143 144 /** 145 * Creates a response bound to Soklet's raw path construct. 146 * <p> 147 * This is the exact path component sent by the client, without URL decoding and without a query string 148 * (for example, {@code "/a%20b/c"}). It corresponds to {@link Request#getRawPath()}. 149 * 150 * @param rawPath raw path component of the request (no query string) 151 * @param servletContext servlet context for this response 152 * @return a response bound to the raw request path 153 */ 154 @NonNull 155 public static SokletHttpServletResponse fromRawPath(@NonNull String rawPath, 156 @NonNull ServletContext servletContext) { 157 requireNonNull(rawPath); 158 requireNonNull(servletContext); 159 return new SokletHttpServletResponse(null, rawPath, servletContext); 160 } 161 162 private SokletHttpServletResponse(@Nullable HttpServletRequest httpServletRequest, 163 @NonNull String rawPath, 164 @NonNull ServletContext servletContext) { 165 requireNonNull(rawPath); 166 requireNonNull(servletContext); 167 168 this.httpServletRequest = httpServletRequest; 169 this.rawPath = rawPath; 170 this.servletContext = servletContext; 171 this.statusCode = HttpServletResponse.SC_OK; 172 this.responseWriteMethod = ResponseWriteMethod.UNSPECIFIED; 173 this.responseBufferSizeInBytes = DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES; 174 this.responseOutputStream = new ByteArrayOutputStream(DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES); 175 this.cookies = new ArrayList<>(); 176 this.headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); 177 this.responseCommitted = false; 178 this.responseFinalized = false; 179 } 180 181 @NonNull 182 public Response toResponse() { 183 MarshaledResponse marshaledResponse = toMarshaledResponse(); 184 185 return Response.withStatusCode(marshaledResponse.getStatusCode()) 186 .body(getResponseOutputStream().toByteArray()) 187 .headers(marshaledResponse.getHeaders()) 188 .cookies(marshaledResponse.getCookies()) 189 .build(); 190 } 191 192 @NonNull 193 public MarshaledResponse toMarshaledResponse() { 194 byte[] body = getResponseOutputStream().toByteArray(); 195 196 Map<@NonNull String, @NonNull Set<@NonNull String>> headers = getHeaders().entrySet().stream() 197 .collect(Collectors.toMap( 198 Map.Entry::getKey, 199 entry -> new LinkedHashSet<>(entry.getValue()), 200 (left, right) -> { 201 left.addAll(right); 202 return left; 203 }, 204 LinkedHashMap::new 205 )); 206 207 Set<@NonNull ResponseCookie> cookies = getCookies().stream() 208 .map(cookie -> { 209 ResponseCookie.Builder builder = ResponseCookie.with(cookie.getName(), cookie.getValue()) 210 .path(cookie.getPath()) 211 .secure(cookie.getSecure()) 212 .httpOnly(cookie.isHttpOnly()) 213 .domain(cookie.getDomain()); 214 215 if (cookie.getMaxAge() >= 0) 216 builder.maxAge(Duration.ofSeconds(cookie.getMaxAge())); 217 218 return builder.build(); 219 }) 220 .collect(Collectors.toSet()); 221 222 return MarshaledResponse.withStatusCode(getStatus()) 223 .body(body) 224 .headers(headers) 225 .cookies(cookies) 226 .build(); 227 } 228 229 @NonNull 230 private String getRawPath() { 231 return this.rawPath; 232 } 233 234 @NonNull 235 private Optional<HttpServletRequest> getHttpServletRequest() { 236 return Optional.ofNullable(this.httpServletRequest); 237 } 238 239 @NonNull 240 private ServletContext getServletContext() { 241 return this.servletContext; 242 } 243 244 @NonNull 245 private List<@NonNull Cookie> getCookies() { 246 return this.cookies; 247 } 248 249 @NonNull 250 private Map<@NonNull String, @NonNull List<@NonNull String>> getHeaders() { 251 return this.headers; 252 } 253 254 @NonNull 255 private List<@NonNull String> getSetCookieHeaderValues() { 256 if (getCookies().isEmpty()) 257 return List.of(); 258 259 List<@NonNull String> values = new ArrayList<>(getCookies().size()); 260 261 for (Cookie cookie : getCookies()) 262 values.add(toSetCookieHeaderValue(cookie)); 263 264 return values; 265 } 266 267 @NonNull 268 private String toSetCookieHeaderValue(@NonNull Cookie cookie) { 269 requireNonNull(cookie); 270 271 ResponseCookie.Builder builder = ResponseCookie.with(cookie.getName(), cookie.getValue()) 272 .path(cookie.getPath()) 273 .secure(cookie.getSecure()) 274 .httpOnly(cookie.isHttpOnly()) 275 .domain(cookie.getDomain()); 276 277 if (cookie.getMaxAge() >= 0) 278 builder.maxAge(Duration.ofSeconds(cookie.getMaxAge())); 279 280 return builder.build().toSetCookieHeaderRepresentation(); 281 } 282 283 private void putHeaderValue(@NonNull String name, 284 @NonNull String value, 285 boolean replace) { 286 requireNonNull(name); 287 requireNonNull(value); 288 289 if (replace) { 290 List<@NonNull String> values = new ArrayList<>(); 291 values.add(value); 292 getHeaders().put(name, values); 293 } else { 294 getHeaders().computeIfAbsent(name, k -> new ArrayList<>()).add(value); 295 } 296 } 297 298 @NonNull 299 private Integer getStatusCode() { 300 return this.statusCode; 301 } 302 303 private void setStatusCode(@NonNull Integer statusCode) { 304 requireNonNull(statusCode); 305 this.statusCode = statusCode; 306 } 307 308 @NonNull 309 private Optional<String> getErrorMessage() { 310 return Optional.ofNullable(this.errorMessage); 311 } 312 313 private void setErrorMessage(@Nullable String errorMessage) { 314 this.errorMessage = errorMessage; 315 } 316 317 @NonNull 318 private Optional<String> getRedirectUrl() { 319 return Optional.ofNullable(this.redirectUrl); 320 } 321 322 private void setRedirectUrl(@Nullable String redirectUrl) { 323 this.redirectUrl = redirectUrl; 324 } 325 326 @NonNull 327 private Optional<Charset> getCharset() { 328 return Optional.ofNullable(this.charset); 329 } 330 331 @Nullable 332 private Charset getContextResponseCharset() { 333 String encoding = getServletContext().getResponseCharacterEncoding(); 334 335 if (encoding == null || encoding.isBlank()) 336 return null; 337 338 try { 339 return Charset.forName(encoding); 340 } catch (IllegalCharsetNameException | UnsupportedCharsetException e) { 341 return null; 342 } 343 } 344 345 @NonNull 346 private Charset getEffectiveCharset() { 347 Charset explicit = this.charset; 348 349 if (explicit != null) 350 return explicit; 351 352 Charset context = getContextResponseCharset(); 353 return context == null ? DEFAULT_CHARSET : context; 354 } 355 356 private void setCharset(@Nullable Charset charset) { 357 this.charset = charset; 358 } 359 360 @NonNull 361 private Boolean getResponseCommitted() { 362 return this.responseCommitted; 363 } 364 365 private void setResponseCommitted(@NonNull Boolean responseCommitted) { 366 requireNonNull(responseCommitted); 367 this.responseCommitted = responseCommitted; 368 } 369 370 @NonNull 371 private Boolean getResponseFinalized() { 372 return this.responseFinalized; 373 } 374 375 private void setResponseFinalized(@NonNull Boolean responseFinalized) { 376 requireNonNull(responseFinalized); 377 this.responseFinalized = responseFinalized; 378 } 379 380 private void writeDefaultErrorBody(int statusCode, 381 @Nullable String message) { 382 if (getResponseOutputStream().size() > 0) 383 return; 384 385 String payload = message; 386 387 if (payload == null || payload.isBlank()) 388 payload = StatusCode.fromStatusCode(statusCode) 389 .map(StatusCode::getReasonPhrase) 390 .orElse("Error"); 391 392 if (payload.isBlank()) 393 return; 394 395 Charset charset = getEffectiveCharset(); 396 byte[] bytes = payload.getBytes(charset); 397 getResponseOutputStream().write(bytes, 0, bytes.length); 398 399 String currentContentType = getContentType(); 400 401 if (currentContentType == null || currentContentType.isBlank()) 402 setContentType("text/plain; charset=" + charset.name()); 403 } 404 405 private void maybeCommitOnWrite() { 406 if (!getResponseCommitted() && getResponseOutputStream().size() >= getResponseBufferSizeInBytes()) 407 setResponseCommitted(true); 408 } 409 410 private void ensureResponseIsUncommitted() { 411 if (getResponseCommitted()) 412 throw new IllegalStateException("Response has already been committed."); 413 } 414 415 @NonNull 416 private String dateHeaderRepresentation(@NonNull Long millisSinceEpoch) { 417 requireNonNull(millisSinceEpoch); 418 return DATE_TIME_FORMATTER.format(Instant.ofEpochMilli(millisSinceEpoch)); 419 } 420 421 @NonNull 422 private Optional<SokletServletOutputStream> getServletOutputStream() { 423 return Optional.ofNullable(this.servletOutputStream); 424 } 425 426 private void setServletOutputStream(@Nullable SokletServletOutputStream servletOutputStream) { 427 this.servletOutputStream = servletOutputStream; 428 } 429 430 @NonNull 431 private Optional<SokletServletPrintWriter> getPrintWriter() { 432 return Optional.ofNullable(this.printWriter); 433 } 434 435 public void setPrintWriter(@Nullable SokletServletPrintWriter printWriter) { 436 this.printWriter = printWriter; 437 } 438 439 @NonNull 440 private ByteArrayOutputStream getResponseOutputStream() { 441 return this.responseOutputStream; 442 } 443 444 private void setResponseOutputStream(@NonNull ByteArrayOutputStream responseOutputStream) { 445 requireNonNull(responseOutputStream); 446 this.responseOutputStream = responseOutputStream; 447 } 448 449 @NonNull 450 private Integer getResponseBufferSizeInBytes() { 451 return this.responseBufferSizeInBytes; 452 } 453 454 private void setResponseBufferSizeInBytes(@NonNull Integer responseBufferSizeInBytes) { 455 requireNonNull(responseBufferSizeInBytes); 456 this.responseBufferSizeInBytes = responseBufferSizeInBytes; 457 } 458 459 @NonNull 460 private ResponseWriteMethod getResponseWriteMethod() { 461 return this.responseWriteMethod; 462 } 463 464 private void setResponseWriteMethod(@NonNull ResponseWriteMethod responseWriteMethod) { 465 requireNonNull(responseWriteMethod); 466 this.responseWriteMethod = responseWriteMethod; 467 } 468 469 private enum ResponseWriteMethod { 470 UNSPECIFIED, 471 SERVLET_OUTPUT_STREAM, 472 PRINT_WRITER 473 } 474 475 // Implementation of HttpServletResponse methods below: 476 477 @Override 478 public void addCookie(@Nullable Cookie cookie) { 479 if (isCommitted()) 480 return; 481 482 if (cookie != null) 483 getCookies().add(cookie); 484 } 485 486 @Override 487 public boolean containsHeader(@Nullable String name) { 488 if (name == null) 489 return false; 490 491 if ("Set-Cookie".equalsIgnoreCase(name)) 492 return !getCookies().isEmpty() || getHeaders().containsKey(name); 493 494 return getHeaders().containsKey(name); 495 } 496 497 @Override 498 @Nullable 499 public String encodeURL(@Nullable String url) { 500 return url; 501 } 502 503 @Override 504 @Nullable 505 public String encodeRedirectURL(@Nullable String url) { 506 return url; 507 } 508 509 @Override 510 @Deprecated 511 public String encodeUrl(@Nullable String url) { 512 return url; 513 } 514 515 @Override 516 @Deprecated 517 public String encodeRedirectUrl(@Nullable String url) { 518 return url; 519 } 520 521 @Override 522 public void sendError(int sc, 523 @Nullable String msg) throws IOException { 524 ensureResponseIsUncommitted(); 525 resetBuffer(); 526 setStatus(sc); 527 setErrorMessage(msg); 528 writeDefaultErrorBody(sc, msg); 529 setResponseCommitted(true); 530 } 531 532 @Override 533 public void sendError(int sc) throws IOException { 534 ensureResponseIsUncommitted(); 535 resetBuffer(); 536 setStatus(sc); 537 setErrorMessage(null); 538 writeDefaultErrorBody(sc, null); 539 setResponseCommitted(true); 540 } 541 542 @NonNull 543 private String getRedirectBaseUrl() { 544 HttpServletRequest httpServletRequest = getHttpServletRequest().orElse(null); 545 546 if (httpServletRequest == null) 547 return "http://localhost"; 548 549 String scheme = httpServletRequest.getScheme(); 550 if (scheme == null || scheme.isBlank()) 551 scheme = "http"; 552 String host = httpServletRequest.getServerName(); 553 if (host == null || host.isBlank()) 554 host = "localhost"; 555 host = normalizeHostForLocation(host); 556 int port = httpServletRequest.getServerPort(); 557 boolean defaultPort = port <= 0 || ("https".equalsIgnoreCase(scheme) && port == 443) || ("http".equalsIgnoreCase(scheme) && port == 80); 558 String authorityHost = host; 559 560 if (host != null && host.indexOf(':') >= 0 && !host.startsWith("[") && !host.endsWith("]")) 561 authorityHost = "[" + host + "]"; 562 563 String authority = defaultPort ? authorityHost : format("%s:%d", authorityHost, port); 564 validateAuthority(scheme, authority); 565 return format("%s://%s", scheme, authority); 566 } 567 568 @Nullable 569 private String getRawQuery() { 570 HttpServletRequest httpServletRequest = getHttpServletRequest().orElse(null); 571 572 if (httpServletRequest == null) 573 return null; 574 575 String rawQuery = httpServletRequest.getQueryString(); 576 return rawQuery == null || rawQuery.isEmpty() ? null : rawQuery; 577 } 578 579 private static final class ParsedLocation { 580 @Nullable 581 private final String scheme; 582 @Nullable 583 private final String rawAuthority; 584 @NonNull 585 private final String rawPath; 586 @Nullable 587 private final String rawQuery; 588 @Nullable 589 private final String rawFragment; 590 private final boolean opaque; 591 592 private ParsedLocation(@Nullable String scheme, 593 @Nullable String rawAuthority, 594 @NonNull String rawPath, 595 @Nullable String rawQuery, 596 @Nullable String rawFragment, 597 boolean opaque) { 598 this.scheme = scheme; 599 this.rawAuthority = rawAuthority; 600 this.rawPath = rawPath; 601 this.rawQuery = rawQuery; 602 this.rawFragment = rawFragment; 603 this.opaque = opaque; 604 } 605 } 606 607 private static final class ParsedPath { 608 @NonNull 609 private final String rawPath; 610 @Nullable 611 private final String rawQuery; 612 @Nullable 613 private final String rawFragment; 614 615 private ParsedPath(@NonNull String rawPath, 616 @Nullable String rawQuery, 617 @Nullable String rawFragment) { 618 this.rawPath = rawPath; 619 this.rawQuery = rawQuery; 620 this.rawFragment = rawFragment; 621 } 622 } 623 624 @NonNull 625 private ParsedPath parsePathAndSuffix(@NonNull String rawPath) { 626 String path = rawPath; 627 String rawQuery = null; 628 String rawFragment = null; 629 630 int hash = path.indexOf('#'); 631 if (hash >= 0) { 632 rawFragment = path.substring(hash + 1); 633 path = path.substring(0, hash); 634 } 635 636 int question = path.indexOf('?'); 637 if (question >= 0) { 638 rawQuery = path.substring(question + 1); 639 path = path.substring(0, question); 640 } 641 642 return new ParsedPath(path, rawQuery, rawFragment); 643 } 644 645 private boolean isAsciiAlpha(char c) { 646 return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); 647 } 648 649 private boolean isAsciiDigit(char c) { 650 return c >= '0' && c <= '9'; 651 } 652 653 private boolean isSchemeChar(char c) { 654 return isAsciiAlpha(c) || isAsciiDigit(c) || c == '+' || c == '-' || c == '.'; 655 } 656 657 private boolean isValidScheme(@NonNull String scheme) { 658 if (scheme.isEmpty()) 659 return false; 660 661 if (!isAsciiAlpha(scheme.charAt(0))) 662 return false; 663 664 for (int i = 1; i < scheme.length(); i++) { 665 if (!isSchemeChar(scheme.charAt(i))) 666 return false; 667 } 668 669 return true; 670 } 671 672 private boolean containsNonAscii(@NonNull String value) { 673 for (int i = 0; i < value.length(); i++) { 674 if (value.charAt(i) > 0x7F) 675 return true; 676 } 677 678 return false; 679 } 680 681 @NonNull 682 private String normalizeHostForLocation(@NonNull String host) { 683 requireNonNull(host); 684 String normalized = host.trim(); 685 686 if (normalized.isEmpty()) 687 throw new IllegalArgumentException("Redirect host is invalid"); 688 689 if (normalized.startsWith("[") && normalized.endsWith("]")) 690 return normalized; 691 692 if (normalized.indexOf(':') >= 0) 693 return normalized; 694 695 if (containsNonAscii(normalized)) { 696 try { 697 normalized = IDN.toASCII(normalized); 698 } catch (IllegalArgumentException e) { 699 throw new IllegalArgumentException("Redirect host is invalid", e); 700 } 701 } 702 703 return normalized; 704 } 705 706 private int countColons(@NonNull String value) { 707 int count = 0; 708 709 for (int i = 0; i < value.length(); i++) { 710 if (value.charAt(i) == ':') 711 count++; 712 } 713 714 return count; 715 } 716 717 @Nullable 718 private String normalizeAuthority(@NonNull String scheme, 719 @Nullable String rawAuthority) { 720 requireNonNull(scheme); 721 722 if (rawAuthority == null || rawAuthority.isBlank()) 723 return null; 724 725 String authority = rawAuthority.trim(); 726 String userInfo = null; 727 String hostPort = authority; 728 int at = authority.lastIndexOf('@'); 729 730 if (at >= 0) { 731 userInfo = authority.substring(0, at); 732 hostPort = authority.substring(at + 1); 733 } 734 735 String normalizedHostPort; 736 737 if (hostPort.startsWith("[")) { 738 int close = hostPort.indexOf(']'); 739 if (close < 0) 740 throw new IllegalArgumentException("Redirect location is invalid"); 741 742 normalizedHostPort = hostPort; 743 } else { 744 int colonCount = countColons(hostPort); 745 String host = hostPort; 746 String port = null; 747 748 if (colonCount > 1) { 749 host = hostPort; 750 } else if (colonCount == 1) { 751 int colon = hostPort.lastIndexOf(':'); 752 753 if (colon <= 0 || colon == hostPort.length() - 1) 754 throw new IllegalArgumentException("Redirect location is invalid"); 755 756 String portCandidate = hostPort.substring(colon + 1); 757 boolean allDigits = true; 758 759 for (int i = 0; i < portCandidate.length(); i++) { 760 if (!isAsciiDigit(portCandidate.charAt(i))) { 761 allDigits = false; 762 break; 763 } 764 } 765 766 if (!allDigits) 767 throw new IllegalArgumentException("Redirect location is invalid"); 768 769 host = hostPort.substring(0, colon); 770 port = portCandidate; 771 } 772 773 String normalizedHost = normalizeHostForLocation(host); 774 775 if (normalizedHost.indexOf(':') >= 0 && !normalizedHost.startsWith("[")) 776 normalizedHost = "[" + normalizedHost + "]"; 777 778 normalizedHostPort = port == null ? normalizedHost : normalizedHost + ":" + port; 779 } 780 781 String normalized = userInfo == null ? normalizedHostPort : userInfo + "@" + normalizedHostPort; 782 validateAuthority(scheme, normalized); 783 return normalized; 784 } 785 786 private void validateAuthority(@NonNull String scheme, 787 @Nullable String authority) { 788 requireNonNull(scheme); 789 790 try { 791 new URI(scheme, authority, null, null, null); 792 } catch (URISyntaxException e) { 793 throw new IllegalArgumentException("Redirect location is invalid", e); 794 } 795 } 796 797 private boolean isUnreserved(char c) { 798 return isAsciiAlpha(c) || isAsciiDigit(c) || c == '-' || c == '.' || c == '_' || c == '~'; 799 } 800 801 private boolean isSubDelim(char c) { 802 return c == '!' || c == '$' || c == '&' || c == '\'' || c == '(' || c == ')' 803 || c == '*' || c == '+' || c == ',' || c == ';' || c == '='; 804 } 805 806 private boolean isPchar(char c) { 807 return isUnreserved(c) || isSubDelim(c) || c == ':' || c == '@'; 808 } 809 810 private boolean isAllowedInPath(char c) { 811 return isPchar(c) || c == '/'; 812 } 813 814 private boolean isAllowedInQueryOrFragment(char c) { 815 return isPchar(c) || c == '/' || c == '?'; 816 } 817 818 private boolean isHexDigit(char c) { 819 return (c >= '0' && c <= '9') 820 || (c >= 'A' && c <= 'F') 821 || (c >= 'a' && c <= 'f'); 822 } 823 824 @NonNull 825 private String encodePreservingEscapes(@NonNull String input, 826 boolean allowQueryOrFragmentChars) { 827 requireNonNull(input); 828 829 StringBuilder out = new StringBuilder(input.length()); 830 int length = input.length(); 831 832 for (int i = 0; i < length; ) { 833 char c = input.charAt(i); 834 835 if (c == '%' && i + 2 < length 836 && isHexDigit(input.charAt(i + 1)) && isHexDigit(input.charAt(i + 2))) { 837 out.append('%').append(input.charAt(i + 1)).append(input.charAt(i + 2)); 838 i += 3; 839 continue; 840 } 841 842 boolean allowed = allowQueryOrFragmentChars ? isAllowedInQueryOrFragment(c) : isAllowedInPath(c); 843 844 if (allowed) { 845 out.append(c); 846 i++; 847 continue; 848 } 849 850 int codePoint = input.codePointAt(i); 851 byte[] bytes = new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8); 852 853 for (byte b : bytes) { 854 out.append('%'); 855 int v = b & 0xFF; 856 out.append(Character.toUpperCase(Character.forDigit((v >> 4) & 0xF, 16))); 857 out.append(Character.toUpperCase(Character.forDigit(v & 0xF, 16))); 858 } 859 860 i += Character.charCount(codePoint); 861 } 862 863 return out.toString(); 864 } 865 866 private int firstDelimiterIndex(@NonNull String value) { 867 int slash = value.indexOf('/'); 868 int question = value.indexOf('?'); 869 int hash = value.indexOf('#'); 870 int index = -1; 871 872 if (slash >= 0) 873 index = slash; 874 if (question >= 0 && (index == -1 || question < index)) 875 index = question; 876 if (hash >= 0 && (index == -1 || hash < index)) 877 index = hash; 878 879 return index; 880 } 881 882 @Nullable 883 private ParsedLocation parseLocationFallback(@NonNull String location) { 884 int colon = location.indexOf(':'); 885 if (colon <= 0) 886 return null; 887 888 String scheme = location.substring(0, colon); 889 if (!isValidScheme(scheme)) 890 return null; 891 892 String rest = location.substring(colon + 1); 893 894 if (rest.startsWith("//")) { 895 String authorityAndPath = rest.substring(2); 896 int delimiterIndex = firstDelimiterIndex(authorityAndPath); 897 String rawAuthority = delimiterIndex == -1 ? authorityAndPath : authorityAndPath.substring(0, delimiterIndex); 898 String remainder = delimiterIndex == -1 ? "" : authorityAndPath.substring(delimiterIndex); 899 ParsedPath parsedPath = parsePathAndSuffix(remainder); 900 return new ParsedLocation(scheme, rawAuthority.isEmpty() ? null : rawAuthority, 901 parsedPath.rawPath, parsedPath.rawQuery, parsedPath.rawFragment, false); 902 } 903 904 if (rest.startsWith("/")) { 905 ParsedPath parsedPath = parsePathAndSuffix(rest); 906 return new ParsedLocation(scheme, null, parsedPath.rawPath, parsedPath.rawQuery, parsedPath.rawFragment, false); 907 } 908 909 return new ParsedLocation(scheme, null, rest, null, null, true); 910 } 911 912 @NonNull 913 private ParsedLocation parseLocation(@NonNull String location) { 914 requireNonNull(location); 915 916 try { 917 URI uri = URI.create(location); 918 String rawPath = uri.getRawPath() == null ? "" : uri.getRawPath(); 919 return new ParsedLocation(uri.getScheme(), uri.getRawAuthority(), rawPath, uri.getRawQuery(), uri.getRawFragment(), uri.isOpaque()); 920 } catch (Exception ignored) { 921 ParsedLocation fallback = parseLocationFallback(location); 922 if (fallback != null) 923 return fallback; 924 925 ParsedPath parsedPath = parsePathAndSuffix(location); 926 return new ParsedLocation(null, null, parsedPath.rawPath, parsedPath.rawQuery, parsedPath.rawFragment, false); 927 } 928 } 929 930 @NonNull 931 private String normalizePath(@NonNull String path) { 932 requireNonNull(path); 933 934 if (path.isEmpty()) 935 return path; 936 937 String input = path; 938 StringBuilder output = new StringBuilder(); 939 940 while (!input.isEmpty()) { 941 if (input.startsWith("../")) { 942 input = input.substring(3); 943 } else if (input.startsWith("./")) { 944 input = input.substring(2); 945 } else if (input.startsWith("/./")) { 946 input = input.substring(2); 947 } else if (input.equals("/.")) { 948 input = "/"; 949 } else if (input.startsWith("/../")) { 950 input = input.substring(3); 951 removeLastSegment(output); 952 } else if (input.equals("/..")) { 953 input = "/"; 954 removeLastSegment(output); 955 } else if (input.equals(".") || input.equals("..")) { 956 input = ""; 957 } else { 958 int start = input.startsWith("/") ? 1 : 0; 959 int nextSlash = input.indexOf('/', start); 960 961 if (nextSlash == -1) { 962 output.append(input); 963 input = ""; 964 } else { 965 output.append(input, 0, nextSlash); 966 input = input.substring(nextSlash); 967 } 968 } 969 } 970 971 return output.toString(); 972 } 973 974 private void removeLastSegment(@NonNull StringBuilder output) { 975 requireNonNull(output); 976 977 int length = output.length(); 978 979 if (length == 0) 980 return; 981 982 int end = length; 983 984 if (end > 0 && output.charAt(end - 1) == '/') 985 end--; 986 987 if (end <= 0) { 988 output.setLength(0); 989 return; 990 } 991 992 int lastSlash = output.lastIndexOf("/", end - 1); 993 994 if (lastSlash >= 0) 995 output.delete(lastSlash, output.length()); 996 else 997 output.setLength(0); 998 } 999 1000 @NonNull 1001 private String buildAbsoluteLocation(@NonNull String scheme, 1002 @Nullable String rawAuthority, 1003 @NonNull String rawPath, 1004 @Nullable String rawQuery, 1005 @Nullable String rawFragment) { 1006 requireNonNull(scheme); 1007 requireNonNull(rawPath); 1008 1009 String encodedPath = encodePreservingEscapes(rawPath, false); 1010 String encodedQuery = rawQuery == null ? null : encodePreservingEscapes(rawQuery, true); 1011 String encodedFragment = rawFragment == null ? null : encodePreservingEscapes(rawFragment, true); 1012 1013 StringBuilder out = new StringBuilder(); 1014 out.append(scheme).append(':'); 1015 1016 if (rawAuthority != null) { 1017 out.append("//").append(rawAuthority); 1018 } 1019 1020 out.append(encodedPath); 1021 1022 if (encodedQuery != null) 1023 out.append('?').append(encodedQuery); 1024 1025 if (encodedFragment != null) 1026 out.append('#').append(encodedFragment); 1027 1028 return out.toString(); 1029 } 1030 1031 @NonNull 1032 private String buildOpaqueLocation(@NonNull String location) { 1033 requireNonNull(location); 1034 1035 try { 1036 return URI.create(location).toASCIIString(); 1037 } catch (Exception e) { 1038 throw new IllegalArgumentException("Redirect location is invalid", e); 1039 } 1040 } 1041 1042 @Override 1043 public void sendRedirect(@Nullable String location) throws IOException { 1044 ensureResponseIsUncommitted(); 1045 1046 if (location == null) 1047 throw new IllegalArgumentException("Redirect location must not be null"); 1048 1049 setStatus(HttpServletResponse.SC_FOUND); 1050 resetBuffer(); 1051 1052 // This method can accept relative URLs; the servlet container must convert the relative URL to an absolute URL 1053 // before sending the response to the client. If the location is relative without a leading '/' the container 1054 // interprets it as relative to the current request URI. If the location is relative with a leading '/' 1055 // the container interprets it as relative to the servlet container root. If the location is relative with two 1056 // leading '/' the container interprets it as a network-path reference (see RFC 3986: Uniform Resource 1057 // Identifier (URI): Generic Syntax, section 4.2 "Relative Reference"). 1058 String baseUrl = getRedirectBaseUrl(); 1059 URI baseUri = URI.create(baseUrl); 1060 String scheme = baseUri.getScheme(); 1061 String baseAuthority = baseUri.getRawAuthority(); 1062 String finalLocation; 1063 ParsedLocation parsed = parseLocation(location); 1064 1065 if (parsed.opaque) { 1066 finalLocation = buildOpaqueLocation(location); 1067 } else if (location.startsWith("//")) { 1068 // Network-path reference: keep host from location but inherit scheme 1069 String normalizedAuthority = normalizeAuthority(scheme, parsed.rawAuthority); 1070 1071 if (normalizedAuthority == null || normalizedAuthority.isBlank()) 1072 throw new IllegalArgumentException("Redirect location is invalid"); 1073 1074 String normalized = normalizePath(parsed.rawPath); 1075 finalLocation = buildAbsoluteLocation(scheme, normalizedAuthority, normalized, parsed.rawQuery, parsed.rawFragment); 1076 } else if (parsed.scheme != null) { 1077 // URL is already absolute 1078 String normalizedAuthority = normalizeAuthority(parsed.scheme, parsed.rawAuthority); 1079 finalLocation = buildAbsoluteLocation(parsed.scheme, normalizedAuthority, parsed.rawPath, parsed.rawQuery, parsed.rawFragment); 1080 } else if (location.startsWith("/")) { 1081 // URL is relative with leading / 1082 String normalized = normalizePath(parsed.rawPath); 1083 finalLocation = buildAbsoluteLocation(scheme, baseAuthority, normalized, parsed.rawQuery, parsed.rawFragment); 1084 } else { 1085 // URL is relative but does not have leading '/', resolve against the parent of the current path 1086 String base = getRawPath(); 1087 String path = parsed.rawPath; 1088 String query = parsed.rawQuery; 1089 1090 if (path.isEmpty() && query == null) 1091 query = getRawQuery(); 1092 1093 if (path.isEmpty()) { 1094 String normalized = normalizePath(base); 1095 finalLocation = buildAbsoluteLocation(scheme, baseAuthority, normalized, query, parsed.rawFragment); 1096 } else { 1097 int idx = base.lastIndexOf('/'); 1098 String parent = (idx <= 0) ? "/" : base.substring(0, idx); 1099 String resolvedPath = parent.endsWith("/") ? parent + path : parent + "/" + path; 1100 String normalized = normalizePath(resolvedPath); 1101 finalLocation = buildAbsoluteLocation(scheme, baseAuthority, normalized, query, parsed.rawFragment); 1102 } 1103 } 1104 1105 setRedirectUrl(finalLocation); 1106 setHeader("Location", finalLocation); 1107 1108 flushBuffer(); 1109 setResponseCommitted(true); 1110 } 1111 1112 @Override 1113 public void setDateHeader(@Nullable String name, 1114 long date) { 1115 if (isCommitted()) 1116 return; 1117 1118 setHeader(name, dateHeaderRepresentation(date)); 1119 } 1120 1121 @Override 1122 public void addDateHeader(@Nullable String name, 1123 long date) { 1124 if (isCommitted()) 1125 return; 1126 1127 addHeader(name, dateHeaderRepresentation(date)); 1128 } 1129 1130 @Override 1131 public void setHeader(@Nullable String name, 1132 @Nullable String value) { 1133 if (isCommitted()) 1134 return; 1135 1136 if (name != null && !name.isBlank() && value != null) { 1137 if ("Content-Type".equalsIgnoreCase(name)) { 1138 setContentType(value); 1139 return; 1140 } 1141 1142 putHeaderValue(name, value, true); 1143 } 1144 } 1145 1146 @Override 1147 public void addHeader(@Nullable String name, 1148 @Nullable String value) { 1149 if (isCommitted()) 1150 return; 1151 1152 if (name != null && !name.isBlank() && value != null) { 1153 if ("Content-Type".equalsIgnoreCase(name)) { 1154 setContentType(value); 1155 return; 1156 } 1157 1158 putHeaderValue(name, value, false); 1159 } 1160 } 1161 1162 @Override 1163 public void setIntHeader(@Nullable String name, 1164 int value) { 1165 setHeader(name, String.valueOf(value)); 1166 } 1167 1168 @Override 1169 public void addIntHeader(@Nullable String name, 1170 int value) { 1171 addHeader(name, String.valueOf(value)); 1172 } 1173 1174 @Override 1175 public void setStatus(int sc) { 1176 if (isCommitted()) 1177 return; 1178 1179 this.statusCode = sc; 1180 } 1181 1182 @Override 1183 @Deprecated 1184 public void setStatus(int sc, 1185 @Nullable String sm) { 1186 if (isCommitted()) 1187 return; 1188 1189 this.statusCode = sc; 1190 this.errorMessage = sm; 1191 } 1192 1193 @Override 1194 public int getStatus() { 1195 return getStatusCode(); 1196 } 1197 1198 @Override 1199 @Nullable 1200 public String getHeader(@Nullable String name) { 1201 if (name == null) 1202 return null; 1203 1204 if ("Set-Cookie".equalsIgnoreCase(name)) { 1205 List<@NonNull String> values = getHeaders().get(name); 1206 1207 if (values != null && !values.isEmpty()) 1208 return values.get(0); 1209 1210 List<@NonNull String> cookieValues = getSetCookieHeaderValues(); 1211 return cookieValues.isEmpty() ? null : cookieValues.get(0); 1212 } 1213 1214 List<@NonNull String> values = getHeaders().get(name); 1215 return values == null || values.size() == 0 ? null : values.get(0); 1216 } 1217 1218 @Override 1219 @NonNull 1220 public Collection<@NonNull String> getHeaders(@Nullable String name) { 1221 if (name == null) 1222 return List.of(); 1223 1224 if ("Set-Cookie".equalsIgnoreCase(name)) { 1225 List<@NonNull String> values = getHeaders().get(name); 1226 List<@NonNull String> cookieValues = getSetCookieHeaderValues(); 1227 1228 if ((values == null || values.isEmpty()) && cookieValues.isEmpty()) 1229 return List.of(); 1230 1231 List<@NonNull String> combined = new ArrayList<>(); 1232 1233 if (values != null) 1234 combined.addAll(values); 1235 1236 combined.addAll(cookieValues); 1237 return Collections.unmodifiableList(combined); 1238 } 1239 1240 List<@NonNull String> values = getHeaders().get(name); 1241 return values == null ? List.of() : Collections.unmodifiableList(values); 1242 } 1243 1244 @Override 1245 @NonNull 1246 public Collection<@NonNull String> getHeaderNames() { 1247 Set<@NonNull String> names = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER); 1248 names.addAll(getHeaders().keySet()); 1249 1250 if (!getCookies().isEmpty()) 1251 names.add("Set-Cookie"); 1252 1253 return Collections.unmodifiableSet(names); 1254 } 1255 1256 @Override 1257 @NonNull 1258 public String getCharacterEncoding() { 1259 return getEffectiveCharset().name(); 1260 } 1261 1262 @Override 1263 @Nullable 1264 public String getContentType() { 1265 String headerValue = getHeader("Content-Type"); 1266 return headerValue != null ? headerValue : this.contentType; 1267 } 1268 1269 @Override 1270 @NonNull 1271 public ServletOutputStream getOutputStream() throws IOException { 1272 // Returns a ServletOutputStream suitable for writing binary data in the response. 1273 // The servlet container does not encode the binary data. 1274 // Calling flush() on the ServletOutputStream commits the response. 1275 // Either this method or getWriter() may be called to write the body, not both, except when reset() has been called. 1276 ResponseWriteMethod currentResponseWriteMethod = getResponseWriteMethod(); 1277 1278 if (currentResponseWriteMethod == ResponseWriteMethod.UNSPECIFIED) { 1279 setResponseWriteMethod(ResponseWriteMethod.SERVLET_OUTPUT_STREAM); 1280 this.servletOutputStream = SokletServletOutputStream.withOutputStream(getResponseOutputStream()) 1281 .onWriteOccurred((ignored1, ignored2) -> maybeCommitOnWrite()) 1282 .onWriteFinalized((ignored) -> { 1283 setResponseCommitted(true); 1284 setResponseFinalized(true); 1285 }).build(); 1286 return getServletOutputStream().get(); 1287 } else if (currentResponseWriteMethod == ResponseWriteMethod.SERVLET_OUTPUT_STREAM) { 1288 return getServletOutputStream().get(); 1289 } else { 1290 throw new IllegalStateException(format("Cannot use %s for writing response; already using %s", 1291 ServletOutputStream.class.getSimpleName(), PrintWriter.class.getSimpleName())); 1292 } 1293 } 1294 1295 @NonNull 1296 private Boolean writerObtained() { 1297 return getResponseWriteMethod() == ResponseWriteMethod.PRINT_WRITER; 1298 } 1299 1300 @NonNull 1301 private Optional<String> extractCharsetFromContentType(@Nullable String type) { 1302 if (type == null) 1303 return Optional.empty(); 1304 1305 String[] parts = type.split(";"); 1306 1307 for (int i = 1; i < parts.length; i++) { 1308 String p = parts[i].trim(); 1309 if (p.toLowerCase(Locale.ROOT).startsWith("charset=")) { 1310 String cs = p.substring("charset=".length()).trim(); 1311 1312 if (cs.startsWith("\"") && cs.endsWith("\"") && cs.length() >= 2) 1313 cs = cs.substring(1, cs.length() - 1); 1314 1315 return Optional.of(cs); 1316 } 1317 } 1318 1319 return Optional.empty(); 1320 } 1321 1322 // Helper: remove any charset=... from Content-Type (preserve other params) 1323 @NonNull 1324 private Optional<String> stripCharsetParam(@Nullable String type) { 1325 if (type == null) 1326 return Optional.empty(); 1327 1328 String[] parts = type.split(";"); 1329 String base = parts[0].trim(); 1330 List<@NonNull String> kept = new ArrayList<>(); 1331 1332 for (int i = 1; i < parts.length; i++) { 1333 String p = parts[i].trim(); 1334 1335 if (!p.toLowerCase(Locale.ROOT).startsWith("charset=") && !p.isEmpty()) 1336 kept.add(p); 1337 } 1338 1339 return Optional.ofNullable(kept.isEmpty() ? base : base + "; " + String.join("; ", kept)); 1340 } 1341 1342 // Helper: ensure Content-Type includes the given charset (replacing any existing one) 1343 @NonNull 1344 private Optional<String> withCharset(@Nullable String type, 1345 @NonNull String charsetName) { 1346 requireNonNull(charsetName); 1347 1348 if (type == null) 1349 return Optional.empty(); 1350 1351 String baseNoCs = stripCharsetParam(type).orElse("text/plain"); 1352 return Optional.of(baseNoCs + "; charset=" + charsetName); 1353 } 1354 1355 @Override 1356 public PrintWriter getWriter() throws IOException { 1357 // Returns a PrintWriter object that can send character text to the client. 1358 // The PrintWriter uses the character encoding returned by getCharacterEncoding(). 1359 // If the response's character encoding has not been specified as described in getCharacterEncoding 1360 // (i.e., the method just returns the default value), getWriter updates it to the effective default. 1361 // Calling flush() on the PrintWriter commits the response. 1362 // 1363 // Either this method or getOutputStream() may be called to write the body, not both, except when reset() has been called. 1364 // Returns a PrintWriter that uses the character encoding returned by getCharacterEncoding(). 1365 // If not specified yet, calling getWriter() fixes the encoding to the effective default. 1366 ResponseWriteMethod currentResponseWriteMethod = getResponseWriteMethod(); 1367 1368 if (currentResponseWriteMethod == ResponseWriteMethod.UNSPECIFIED) { 1369 // Freeze encoding now 1370 Charset enc = getEffectiveCharset(); 1371 setCharset(enc); // record the chosen encoding explicitly 1372 1373 // If a content type is already present and lacks charset, append the frozen charset to header 1374 String currentContentType = getContentType(); 1375 1376 if (currentContentType != null) { 1377 Optional<String> csInHeader = extractCharsetFromContentType(currentContentType); 1378 if (csInHeader.isEmpty() || !csInHeader.get().equalsIgnoreCase(enc.name())) { 1379 String updated = withCharset(currentContentType, enc.name()).orElse(null); 1380 1381 if (updated != null) { 1382 this.contentType = updated; 1383 putHeaderValue("Content-Type", updated, true); 1384 } else { 1385 this.contentType = currentContentType; 1386 putHeaderValue("Content-Type", currentContentType, true); 1387 } 1388 } 1389 } 1390 1391 setResponseWriteMethod(ResponseWriteMethod.PRINT_WRITER); 1392 1393 this.printWriter = 1394 SokletServletPrintWriter.withWriter( 1395 new OutputStreamWriter(getResponseOutputStream(), enc)) 1396 .onWriteOccurred((ignored1, ignored2) -> maybeCommitOnWrite()) 1397 .onWriteFinalized((ignored) -> { 1398 setResponseCommitted(true); 1399 setResponseFinalized(true); 1400 }) 1401 .build(); 1402 1403 return getPrintWriter().get(); 1404 } else if (currentResponseWriteMethod == ResponseWriteMethod.PRINT_WRITER) { 1405 return getPrintWriter().get(); 1406 } else { 1407 throw new IllegalStateException(format("Cannot use %s for writing response; already using %s", 1408 PrintWriter.class.getSimpleName(), ServletOutputStream.class.getSimpleName())); 1409 } 1410 } 1411 1412 @Override 1413 public void setCharacterEncoding(@Nullable String charset) { 1414 if (isCommitted()) 1415 return; 1416 1417 // Spec: no effect after getWriter() or after commit 1418 if (writerObtained()) 1419 return; 1420 1421 if (charset == null || charset.isBlank()) { 1422 // Clear explicit charset; default will be chosen at writer time if needed 1423 setCharset(null); 1424 1425 // If a Content-Type is set, remove its charset=... parameter 1426 String currentContentType = getContentType(); 1427 1428 if (currentContentType != null) { 1429 String updated = stripCharsetParam(currentContentType).orElse(null); 1430 this.contentType = updated; 1431 if (updated == null || updated.isBlank()) { 1432 getHeaders().remove("Content-Type"); 1433 } else { 1434 putHeaderValue("Content-Type", updated, true); 1435 } 1436 } 1437 1438 return; 1439 } 1440 1441 Charset cs; 1442 1443 try { 1444 cs = Charset.forName(charset); 1445 } catch (IllegalCharsetNameException | UnsupportedCharsetException e) { 1446 return; 1447 } 1448 setCharset(cs); 1449 1450 // If a Content-Type is set, reflect/replace the charset=... in the header 1451 String currentContentType = getContentType(); 1452 1453 if (currentContentType != null) { 1454 String updated = withCharset(currentContentType, cs.name()).orElse(null); 1455 1456 if (updated != null) { 1457 this.contentType = updated; 1458 putHeaderValue("Content-Type", updated, true); 1459 } else { 1460 this.contentType = currentContentType; 1461 putHeaderValue("Content-Type", currentContentType, true); 1462 } 1463 } 1464 } 1465 1466 @Override 1467 public void setContentLength(int len) { 1468 if (isCommitted()) 1469 return; 1470 1471 setHeader("Content-Length", String.valueOf(len)); 1472 } 1473 1474 @Override 1475 public void setContentLengthLong(long len) { 1476 if (isCommitted()) 1477 return; 1478 1479 setHeader("Content-Length", String.valueOf(len)); 1480 } 1481 1482 @Override 1483 public void setContentType(@Nullable String type) { 1484 // This method may be called repeatedly to change content type and character encoding. 1485 // This method has no effect if called after the response has been committed. 1486 // It does not set the response's character encoding if it is called after getWriter has been called 1487 // or after the response has been committed. 1488 if (isCommitted()) 1489 return; 1490 1491 if (!writerObtained()) { 1492 // Before writer: charset can still be established/overridden 1493 this.contentType = type; 1494 1495 if (type == null || type.isBlank()) { 1496 getHeaders().remove("Content-Type"); 1497 return; 1498 } 1499 1500 // If caller specified charset=..., adopt it as the current explicit charset 1501 Optional<String> cs = extractCharsetFromContentType(type); 1502 if (cs.isPresent()) { 1503 try { 1504 setCharset(Charset.forName(cs.get())); 1505 } catch (IllegalCharsetNameException | UnsupportedCharsetException ignored) { 1506 // Ignore invalid charset token; leave current charset unchanged. 1507 } 1508 putHeaderValue("Content-Type", type, true); 1509 } else { 1510 // No charset in type. If an explicit charset already exists (via setCharacterEncoding), 1511 // reflect it in the header; otherwise just set the type as-is. 1512 if (getCharset().isPresent()) { 1513 String updated = withCharset(type, getCharset().get().name()).orElse(null); 1514 1515 if (updated != null) { 1516 this.contentType = updated; 1517 putHeaderValue("Content-Type", updated, true); 1518 } else { 1519 putHeaderValue("Content-Type", type, true); 1520 } 1521 } else { 1522 putHeaderValue("Content-Type", type, true); 1523 } 1524 } 1525 } else { 1526 // After writer: charset is frozen. We can change the MIME type, but we must NOT change encoding. 1527 // If caller supplies a charset, normalize the header back to the locked encoding. 1528 this.contentType = type; 1529 1530 if (type == null || type.isBlank()) { 1531 // Allowed: clear header; does not change actual encoding used by writer 1532 getHeaders().remove("Content-Type"); 1533 return; 1534 } 1535 1536 String locked = getCharacterEncoding(); // the frozen encoding name 1537 String normalized = withCharset(type, locked).orElse(null); 1538 1539 if (normalized != null) { 1540 this.contentType = normalized; 1541 putHeaderValue("Content-Type", normalized, true); 1542 } else { 1543 this.contentType = type; 1544 putHeaderValue("Content-Type", type, true); 1545 } 1546 } 1547 } 1548 1549 @Override 1550 public void setBufferSize(int size) { 1551 ensureResponseIsUncommitted(); 1552 1553 if (size <= 0) 1554 throw new IllegalArgumentException("Buffer size must be greater than 0"); 1555 1556 // Per Servlet spec, setBufferSize must be called before any content is written 1557 if (getResponseOutputStream().size() > 0) 1558 throw new IllegalStateException("setBufferSize must be called before any content is written"); 1559 1560 setResponseBufferSizeInBytes(size); 1561 1562 if (!writerObtained() && getServletOutputStream().isEmpty()) 1563 setResponseOutputStream(new ByteArrayOutputStream(getResponseBufferSizeInBytes())); 1564 } 1565 1566 @Override 1567 public int getBufferSize() { 1568 return getResponseBufferSizeInBytes(); 1569 } 1570 1571 @Override 1572 public void flushBuffer() throws IOException { 1573 if (!isCommitted()) 1574 setResponseCommitted(true); 1575 1576 SokletServletPrintWriter currentWriter = getPrintWriter().orElse(null); 1577 SokletServletOutputStream currentOutputStream = getServletOutputStream().orElse(null); 1578 1579 if (currentWriter != null) { 1580 currentWriter.flush(); 1581 } else if (currentOutputStream != null) { 1582 currentOutputStream.flush(); 1583 } else { 1584 getResponseOutputStream().flush(); 1585 } 1586 } 1587 1588 @Override 1589 public void resetBuffer() { 1590 ensureResponseIsUncommitted(); 1591 getResponseOutputStream().reset(); 1592 } 1593 1594 @Override 1595 public boolean isCommitted() { 1596 return getResponseCommitted(); 1597 } 1598 1599 @Override 1600 public void reset() { 1601 // Clears any data that exists in the buffer as well as the status code, headers. 1602 // The state of calling getWriter() or getOutputStream() is also cleared. 1603 // It is legal, for instance, to call getWriter(), reset() and then getOutputStream(). 1604 // If getWriter() or getOutputStream() have been called before this method, then the corresponding returned 1605 // Writer or OutputStream will be staled and the behavior of using the stale object is undefined. 1606 // If the response has been committed, this method throws an IllegalStateException. 1607 1608 ensureResponseIsUncommitted(); 1609 1610 setStatusCode(HttpServletResponse.SC_OK); 1611 setServletOutputStream(null); 1612 setPrintWriter(null); 1613 setResponseWriteMethod(ResponseWriteMethod.UNSPECIFIED); 1614 setResponseOutputStream(new ByteArrayOutputStream(getResponseBufferSizeInBytes())); 1615 getHeaders().clear(); 1616 getCookies().clear(); 1617 1618 // Clear content-type/charset & locale to a pristine state 1619 this.contentType = null; 1620 setCharset(null); 1621 this.locale = null; 1622 this.errorMessage = null; 1623 this.redirectUrl = null; 1624 } 1625 1626 @Override 1627 public void setLocale(@Nullable Locale locale) { 1628 if (isCommitted()) 1629 return; 1630 1631 this.locale = locale; 1632 1633 if (locale != null && !writerObtained() && getCharset().isEmpty()) { 1634 Charset contextCharset = getContextResponseCharset(); 1635 Charset selectedCharset = contextCharset == null ? DEFAULT_CHARSET : contextCharset; 1636 setCharacterEncoding(selectedCharset.name()); 1637 } 1638 1639 if (locale == null) { 1640 getHeaders().remove("Content-Language"); 1641 return; 1642 } 1643 1644 String tag = locale.toLanguageTag(); 1645 1646 if (tag.isBlank()) 1647 getHeaders().remove("Content-Language"); 1648 else 1649 putHeaderValue("Content-Language", tag, true); 1650 } 1651 1652 @Override 1653 public Locale getLocale() { 1654 return this.locale == null ? Locale.getDefault() : this.locale; 1655 } 1656}