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.jakarta; 018 019import com.soklet.MarshaledResponse; 020import com.soklet.Request; 021import com.soklet.Response; 022import com.soklet.ResponseCookie; 023import com.soklet.StatusCode; 024import jakarta.servlet.ServletContext; 025import jakarta.servlet.ServletOutputStream; 026import jakarta.servlet.http.Cookie; 027import jakarta.servlet.http.HttpServletRequest; 028import jakarta.servlet.http.HttpServletResponse; 029import org.jspecify.annotations.NonNull; 030import org.jspecify.annotations.Nullable; 031 032import javax.annotation.concurrent.NotThreadSafe; 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 public void sendError(int sc, 511 @Nullable String msg) throws IOException { 512 ensureResponseIsUncommitted(); 513 resetBuffer(); 514 setStatus(sc); 515 setErrorMessage(msg); 516 writeDefaultErrorBody(sc, msg); 517 setResponseCommitted(true); 518 } 519 520 @Override 521 public void sendError(int sc) throws IOException { 522 ensureResponseIsUncommitted(); 523 resetBuffer(); 524 setStatus(sc); 525 setErrorMessage(null); 526 writeDefaultErrorBody(sc, null); 527 setResponseCommitted(true); 528 } 529 530 @NonNull 531 private String getRedirectBaseUrl() { 532 HttpServletRequest httpServletRequest = getHttpServletRequest().orElse(null); 533 534 if (httpServletRequest == null) 535 return "http://localhost"; 536 537 String scheme = httpServletRequest.getScheme(); 538 if (scheme == null || scheme.isBlank()) 539 scheme = "http"; 540 String host = httpServletRequest.getServerName(); 541 if (host == null || host.isBlank()) 542 host = "localhost"; 543 host = normalizeHostForLocation(host); 544 int port = httpServletRequest.getServerPort(); 545 boolean defaultPort = port <= 0 || ("https".equalsIgnoreCase(scheme) && port == 443) || ("http".equalsIgnoreCase(scheme) && port == 80); 546 String authorityHost = host; 547 548 if (host != null && host.indexOf(':') >= 0 && !host.startsWith("[") && !host.endsWith("]")) 549 authorityHost = "[" + host + "]"; 550 551 String authority = defaultPort ? authorityHost : format("%s:%d", authorityHost, port); 552 validateAuthority(scheme, authority); 553 return format("%s://%s", scheme, authority); 554 } 555 556 @Nullable 557 private String getRawQuery() { 558 HttpServletRequest httpServletRequest = getHttpServletRequest().orElse(null); 559 560 if (httpServletRequest == null) 561 return null; 562 563 String rawQuery = httpServletRequest.getQueryString(); 564 return rawQuery == null || rawQuery.isEmpty() ? null : rawQuery; 565 } 566 567 private static final class ParsedLocation { 568 @Nullable 569 private final String scheme; 570 @Nullable 571 private final String rawAuthority; 572 @NonNull 573 private final String rawPath; 574 @Nullable 575 private final String rawQuery; 576 @Nullable 577 private final String rawFragment; 578 private final boolean opaque; 579 580 private ParsedLocation(@Nullable String scheme, 581 @Nullable String rawAuthority, 582 @NonNull String rawPath, 583 @Nullable String rawQuery, 584 @Nullable String rawFragment, 585 boolean opaque) { 586 this.scheme = scheme; 587 this.rawAuthority = rawAuthority; 588 this.rawPath = rawPath; 589 this.rawQuery = rawQuery; 590 this.rawFragment = rawFragment; 591 this.opaque = opaque; 592 } 593 } 594 595 private static final class ParsedPath { 596 @NonNull 597 private final String rawPath; 598 @Nullable 599 private final String rawQuery; 600 @Nullable 601 private final String rawFragment; 602 603 private ParsedPath(@NonNull String rawPath, 604 @Nullable String rawQuery, 605 @Nullable String rawFragment) { 606 this.rawPath = rawPath; 607 this.rawQuery = rawQuery; 608 this.rawFragment = rawFragment; 609 } 610 } 611 612 @NonNull 613 private ParsedPath parsePathAndSuffix(@NonNull String rawPath) { 614 String path = rawPath; 615 String rawQuery = null; 616 String rawFragment = null; 617 618 int hash = path.indexOf('#'); 619 if (hash >= 0) { 620 rawFragment = path.substring(hash + 1); 621 path = path.substring(0, hash); 622 } 623 624 int question = path.indexOf('?'); 625 if (question >= 0) { 626 rawQuery = path.substring(question + 1); 627 path = path.substring(0, question); 628 } 629 630 return new ParsedPath(path, rawQuery, rawFragment); 631 } 632 633 private boolean isAsciiAlpha(char c) { 634 return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); 635 } 636 637 private boolean isAsciiDigit(char c) { 638 return c >= '0' && c <= '9'; 639 } 640 641 private boolean isSchemeChar(char c) { 642 return isAsciiAlpha(c) || isAsciiDigit(c) || c == '+' || c == '-' || c == '.'; 643 } 644 645 private boolean isValidScheme(@NonNull String scheme) { 646 if (scheme.isEmpty()) 647 return false; 648 649 if (!isAsciiAlpha(scheme.charAt(0))) 650 return false; 651 652 for (int i = 1; i < scheme.length(); i++) { 653 if (!isSchemeChar(scheme.charAt(i))) 654 return false; 655 } 656 657 return true; 658 } 659 660 private boolean containsNonAscii(@NonNull String value) { 661 for (int i = 0; i < value.length(); i++) { 662 if (value.charAt(i) > 0x7F) 663 return true; 664 } 665 666 return false; 667 } 668 669 @NonNull 670 private String normalizeHostForLocation(@NonNull String host) { 671 requireNonNull(host); 672 String normalized = host.trim(); 673 674 if (normalized.isEmpty()) 675 throw new IllegalArgumentException("Redirect host is invalid"); 676 677 if (normalized.startsWith("[") && normalized.endsWith("]")) 678 return normalized; 679 680 if (normalized.indexOf(':') >= 0) 681 return normalized; 682 683 if (containsNonAscii(normalized)) { 684 try { 685 normalized = IDN.toASCII(normalized); 686 } catch (IllegalArgumentException e) { 687 throw new IllegalArgumentException("Redirect host is invalid", e); 688 } 689 } 690 691 return normalized; 692 } 693 694 private int countColons(@NonNull String value) { 695 int count = 0; 696 697 for (int i = 0; i < value.length(); i++) { 698 if (value.charAt(i) == ':') 699 count++; 700 } 701 702 return count; 703 } 704 705 @Nullable 706 private String normalizeAuthority(@NonNull String scheme, 707 @Nullable String rawAuthority) { 708 requireNonNull(scheme); 709 710 if (rawAuthority == null || rawAuthority.isBlank()) 711 return null; 712 713 String authority = rawAuthority.trim(); 714 String userInfo = null; 715 String hostPort = authority; 716 int at = authority.lastIndexOf('@'); 717 718 if (at >= 0) { 719 userInfo = authority.substring(0, at); 720 hostPort = authority.substring(at + 1); 721 } 722 723 String normalizedHostPort; 724 725 if (hostPort.startsWith("[")) { 726 int close = hostPort.indexOf(']'); 727 if (close < 0) 728 throw new IllegalArgumentException("Redirect location is invalid"); 729 730 normalizedHostPort = hostPort; 731 } else { 732 int colonCount = countColons(hostPort); 733 String host = hostPort; 734 String port = null; 735 736 if (colonCount > 1) { 737 host = hostPort; 738 } else if (colonCount == 1) { 739 int colon = hostPort.lastIndexOf(':'); 740 741 if (colon <= 0 || colon == hostPort.length() - 1) 742 throw new IllegalArgumentException("Redirect location is invalid"); 743 744 String portCandidate = hostPort.substring(colon + 1); 745 boolean allDigits = true; 746 747 for (int i = 0; i < portCandidate.length(); i++) { 748 if (!isAsciiDigit(portCandidate.charAt(i))) { 749 allDigits = false; 750 break; 751 } 752 } 753 754 if (!allDigits) 755 throw new IllegalArgumentException("Redirect location is invalid"); 756 757 host = hostPort.substring(0, colon); 758 port = portCandidate; 759 } 760 761 String normalizedHost = normalizeHostForLocation(host); 762 763 if (normalizedHost.indexOf(':') >= 0 && !normalizedHost.startsWith("[")) 764 normalizedHost = "[" + normalizedHost + "]"; 765 766 normalizedHostPort = port == null ? normalizedHost : normalizedHost + ":" + port; 767 } 768 769 String normalized = userInfo == null ? normalizedHostPort : userInfo + "@" + normalizedHostPort; 770 validateAuthority(scheme, normalized); 771 return normalized; 772 } 773 774 private void validateAuthority(@NonNull String scheme, 775 @Nullable String authority) { 776 requireNonNull(scheme); 777 778 try { 779 new URI(scheme, authority, null, null, null); 780 } catch (URISyntaxException e) { 781 throw new IllegalArgumentException("Redirect location is invalid", e); 782 } 783 } 784 785 private boolean isUnreserved(char c) { 786 return isAsciiAlpha(c) || isAsciiDigit(c) || c == '-' || c == '.' || c == '_' || c == '~'; 787 } 788 789 private boolean isSubDelim(char c) { 790 return c == '!' || c == '$' || c == '&' || c == '\'' || c == '(' || c == ')' 791 || c == '*' || c == '+' || c == ',' || c == ';' || c == '='; 792 } 793 794 private boolean isPchar(char c) { 795 return isUnreserved(c) || isSubDelim(c) || c == ':' || c == '@'; 796 } 797 798 private boolean isAllowedInPath(char c) { 799 return isPchar(c) || c == '/'; 800 } 801 802 private boolean isAllowedInQueryOrFragment(char c) { 803 return isPchar(c) || c == '/' || c == '?'; 804 } 805 806 private boolean isHexDigit(char c) { 807 return (c >= '0' && c <= '9') 808 || (c >= 'A' && c <= 'F') 809 || (c >= 'a' && c <= 'f'); 810 } 811 812 @NonNull 813 private String encodePreservingEscapes(@NonNull String input, 814 boolean allowQueryOrFragmentChars) { 815 requireNonNull(input); 816 817 StringBuilder out = new StringBuilder(input.length()); 818 int length = input.length(); 819 820 for (int i = 0; i < length; ) { 821 char c = input.charAt(i); 822 823 if (c == '%' && i + 2 < length 824 && isHexDigit(input.charAt(i + 1)) && isHexDigit(input.charAt(i + 2))) { 825 out.append('%').append(input.charAt(i + 1)).append(input.charAt(i + 2)); 826 i += 3; 827 continue; 828 } 829 830 boolean allowed = allowQueryOrFragmentChars ? isAllowedInQueryOrFragment(c) : isAllowedInPath(c); 831 832 if (allowed) { 833 out.append(c); 834 i++; 835 continue; 836 } 837 838 int codePoint = input.codePointAt(i); 839 byte[] bytes = new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8); 840 841 for (byte b : bytes) { 842 out.append('%'); 843 int v = b & 0xFF; 844 out.append(Character.toUpperCase(Character.forDigit((v >> 4) & 0xF, 16))); 845 out.append(Character.toUpperCase(Character.forDigit(v & 0xF, 16))); 846 } 847 848 i += Character.charCount(codePoint); 849 } 850 851 return out.toString(); 852 } 853 854 private int firstDelimiterIndex(@NonNull String value) { 855 int slash = value.indexOf('/'); 856 int question = value.indexOf('?'); 857 int hash = value.indexOf('#'); 858 int index = -1; 859 860 if (slash >= 0) 861 index = slash; 862 if (question >= 0 && (index == -1 || question < index)) 863 index = question; 864 if (hash >= 0 && (index == -1 || hash < index)) 865 index = hash; 866 867 return index; 868 } 869 870 @Nullable 871 private ParsedLocation parseLocationFallback(@NonNull String location) { 872 int colon = location.indexOf(':'); 873 if (colon <= 0) 874 return null; 875 876 String scheme = location.substring(0, colon); 877 if (!isValidScheme(scheme)) 878 return null; 879 880 String rest = location.substring(colon + 1); 881 882 if (rest.startsWith("//")) { 883 String authorityAndPath = rest.substring(2); 884 int delimiterIndex = firstDelimiterIndex(authorityAndPath); 885 String rawAuthority = delimiterIndex == -1 ? authorityAndPath : authorityAndPath.substring(0, delimiterIndex); 886 String remainder = delimiterIndex == -1 ? "" : authorityAndPath.substring(delimiterIndex); 887 ParsedPath parsedPath = parsePathAndSuffix(remainder); 888 return new ParsedLocation(scheme, rawAuthority.isEmpty() ? null : rawAuthority, 889 parsedPath.rawPath, parsedPath.rawQuery, parsedPath.rawFragment, false); 890 } 891 892 if (rest.startsWith("/")) { 893 ParsedPath parsedPath = parsePathAndSuffix(rest); 894 return new ParsedLocation(scheme, null, parsedPath.rawPath, parsedPath.rawQuery, parsedPath.rawFragment, false); 895 } 896 897 return new ParsedLocation(scheme, null, rest, null, null, true); 898 } 899 900 @NonNull 901 private ParsedLocation parseLocation(@NonNull String location) { 902 requireNonNull(location); 903 904 try { 905 URI uri = URI.create(location); 906 String rawPath = uri.getRawPath() == null ? "" : uri.getRawPath(); 907 return new ParsedLocation(uri.getScheme(), uri.getRawAuthority(), rawPath, uri.getRawQuery(), uri.getRawFragment(), uri.isOpaque()); 908 } catch (Exception ignored) { 909 ParsedLocation fallback = parseLocationFallback(location); 910 if (fallback != null) 911 return fallback; 912 913 ParsedPath parsedPath = parsePathAndSuffix(location); 914 return new ParsedLocation(null, null, parsedPath.rawPath, parsedPath.rawQuery, parsedPath.rawFragment, false); 915 } 916 } 917 918 @NonNull 919 private String normalizePath(@NonNull String path) { 920 requireNonNull(path); 921 922 if (path.isEmpty()) 923 return path; 924 925 String input = path; 926 StringBuilder output = new StringBuilder(); 927 928 while (!input.isEmpty()) { 929 if (input.startsWith("../")) { 930 input = input.substring(3); 931 } else if (input.startsWith("./")) { 932 input = input.substring(2); 933 } else if (input.startsWith("/./")) { 934 input = input.substring(2); 935 } else if (input.equals("/.")) { 936 input = "/"; 937 } else if (input.startsWith("/../")) { 938 input = input.substring(3); 939 removeLastSegment(output); 940 } else if (input.equals("/..")) { 941 input = "/"; 942 removeLastSegment(output); 943 } else if (input.equals(".") || input.equals("..")) { 944 input = ""; 945 } else { 946 int start = input.startsWith("/") ? 1 : 0; 947 int nextSlash = input.indexOf('/', start); 948 949 if (nextSlash == -1) { 950 output.append(input); 951 input = ""; 952 } else { 953 output.append(input, 0, nextSlash); 954 input = input.substring(nextSlash); 955 } 956 } 957 } 958 959 return output.toString(); 960 } 961 962 private void removeLastSegment(@NonNull StringBuilder output) { 963 requireNonNull(output); 964 965 int length = output.length(); 966 967 if (length == 0) 968 return; 969 970 int end = length; 971 972 if (end > 0 && output.charAt(end - 1) == '/') 973 end--; 974 975 if (end <= 0) { 976 output.setLength(0); 977 return; 978 } 979 980 int lastSlash = output.lastIndexOf("/", end - 1); 981 982 if (lastSlash >= 0) 983 output.delete(lastSlash, output.length()); 984 else 985 output.setLength(0); 986 } 987 988 @NonNull 989 private String buildAbsoluteLocation(@NonNull String scheme, 990 @Nullable String rawAuthority, 991 @NonNull String rawPath, 992 @Nullable String rawQuery, 993 @Nullable String rawFragment) { 994 requireNonNull(scheme); 995 requireNonNull(rawPath); 996 997 String encodedPath = encodePreservingEscapes(rawPath, false); 998 String encodedQuery = rawQuery == null ? null : encodePreservingEscapes(rawQuery, true); 999 String encodedFragment = rawFragment == null ? null : encodePreservingEscapes(rawFragment, true); 1000 1001 StringBuilder out = new StringBuilder(); 1002 out.append(scheme).append(':'); 1003 1004 if (rawAuthority != null) { 1005 out.append("//").append(rawAuthority); 1006 } 1007 1008 out.append(encodedPath); 1009 1010 if (encodedQuery != null) 1011 out.append('?').append(encodedQuery); 1012 1013 if (encodedFragment != null) 1014 out.append('#').append(encodedFragment); 1015 1016 return out.toString(); 1017 } 1018 1019 @NonNull 1020 private String buildOpaqueLocation(@NonNull String location) { 1021 requireNonNull(location); 1022 1023 try { 1024 return URI.create(location).toASCIIString(); 1025 } catch (Exception e) { 1026 throw new IllegalArgumentException("Redirect location is invalid", e); 1027 } 1028 } 1029 1030 @Override 1031 public void sendRedirect(@Nullable String location) throws IOException { 1032 sendRedirect(location, HttpServletResponse.SC_FOUND, true); 1033 } 1034 1035 @Override 1036 public void sendRedirect(@Nullable String location, 1037 int sc, 1038 boolean clearBuffer) throws IOException { 1039 ensureResponseIsUncommitted(); 1040 1041 if (location == null) 1042 throw new IllegalArgumentException("Redirect location must not be null"); 1043 1044 setStatus(sc); 1045 1046 if (clearBuffer) 1047 resetBuffer(); 1048 1049 // This method can accept relative URLs; the servlet container must convert the relative URL to an absolute URL 1050 // before sending the response to the client. If the location is relative without a leading '/' the container 1051 // interprets it as relative to the current request URI. If the location is relative with a leading '/' 1052 // the container interprets it as relative to the servlet container root. If the location is relative with two 1053 // leading '/' the container interprets it as a network-path reference (see RFC 3986: Uniform Resource 1054 // Identifier (URI): Generic Syntax, section 4.2 "Relative Reference"). 1055 String baseUrl = getRedirectBaseUrl(); 1056 URI baseUri = URI.create(baseUrl); 1057 String scheme = baseUri.getScheme(); 1058 String baseAuthority = baseUri.getRawAuthority(); 1059 String finalLocation; 1060 ParsedLocation parsed = parseLocation(location); 1061 1062 if (parsed.opaque) { 1063 finalLocation = buildOpaqueLocation(location); 1064 } else if (location.startsWith("//")) { 1065 // Network-path reference: keep host from location but inherit scheme 1066 String normalizedAuthority = normalizeAuthority(scheme, parsed.rawAuthority); 1067 1068 if (normalizedAuthority == null || normalizedAuthority.isBlank()) 1069 throw new IllegalArgumentException("Redirect location is invalid"); 1070 1071 String normalized = normalizePath(parsed.rawPath); 1072 finalLocation = buildAbsoluteLocation(scheme, normalizedAuthority, normalized, parsed.rawQuery, parsed.rawFragment); 1073 } else if (parsed.scheme != null) { 1074 // URL is already absolute 1075 String normalizedAuthority = normalizeAuthority(parsed.scheme, parsed.rawAuthority); 1076 finalLocation = buildAbsoluteLocation(parsed.scheme, normalizedAuthority, parsed.rawPath, parsed.rawQuery, parsed.rawFragment); 1077 } else if (location.startsWith("/")) { 1078 // URL is relative with leading / 1079 String normalized = normalizePath(parsed.rawPath); 1080 finalLocation = buildAbsoluteLocation(scheme, baseAuthority, normalized, parsed.rawQuery, parsed.rawFragment); 1081 } else { 1082 // URL is relative but does not have leading '/', resolve against the parent of the current path 1083 String base = getRawPath(); 1084 String path = parsed.rawPath; 1085 String query = parsed.rawQuery; 1086 1087 if (path.isEmpty() && query == null) 1088 query = getRawQuery(); 1089 1090 if (path.isEmpty()) { 1091 String normalized = normalizePath(base); 1092 finalLocation = buildAbsoluteLocation(scheme, baseAuthority, normalized, query, parsed.rawFragment); 1093 } else { 1094 int idx = base.lastIndexOf('/'); 1095 String parent = (idx <= 0) ? "/" : base.substring(0, idx); 1096 String resolvedPath = parent.endsWith("/") ? parent + path : parent + "/" + path; 1097 String normalized = normalizePath(resolvedPath); 1098 finalLocation = buildAbsoluteLocation(scheme, baseAuthority, normalized, query, parsed.rawFragment); 1099 } 1100 } 1101 1102 setRedirectUrl(finalLocation); 1103 setHeader("Location", finalLocation); 1104 1105 flushBuffer(); 1106 setResponseCommitted(true); 1107 } 1108 1109 @Override 1110 public void setDateHeader(@Nullable String name, 1111 long date) { 1112 if (isCommitted()) 1113 return; 1114 1115 setHeader(name, dateHeaderRepresentation(date)); 1116 } 1117 1118 @Override 1119 public void addDateHeader(@Nullable String name, 1120 long date) { 1121 if (isCommitted()) 1122 return; 1123 1124 addHeader(name, dateHeaderRepresentation(date)); 1125 } 1126 1127 @Override 1128 public void setHeader(@Nullable String name, 1129 @Nullable String value) { 1130 if (isCommitted()) 1131 return; 1132 1133 if (name != null && !name.isBlank() && value != null) { 1134 if ("Content-Type".equalsIgnoreCase(name)) { 1135 setContentType(value); 1136 return; 1137 } 1138 1139 putHeaderValue(name, value, true); 1140 } 1141 } 1142 1143 @Override 1144 public void addHeader(@Nullable String name, 1145 @Nullable String value) { 1146 if (isCommitted()) 1147 return; 1148 1149 if (name != null && !name.isBlank() && value != null) { 1150 if ("Content-Type".equalsIgnoreCase(name)) { 1151 setContentType(value); 1152 return; 1153 } 1154 1155 putHeaderValue(name, value, false); 1156 } 1157 } 1158 1159 @Override 1160 public void setIntHeader(@Nullable String name, 1161 int value) { 1162 setHeader(name, String.valueOf(value)); 1163 } 1164 1165 @Override 1166 public void addIntHeader(@Nullable String name, 1167 int value) { 1168 addHeader(name, String.valueOf(value)); 1169 } 1170 1171 @Override 1172 public void setStatus(int sc) { 1173 if (isCommitted()) 1174 return; 1175 1176 this.statusCode = sc; 1177 } 1178 1179 @Override 1180 public int getStatus() { 1181 return getStatusCode(); 1182 } 1183 1184 @Override 1185 @Nullable 1186 public String getHeader(@Nullable String name) { 1187 if (name == null) 1188 return null; 1189 1190 if ("Set-Cookie".equalsIgnoreCase(name)) { 1191 List<@NonNull String> values = getHeaders().get(name); 1192 1193 if (values != null && !values.isEmpty()) 1194 return values.get(0); 1195 1196 List<@NonNull String> cookieValues = getSetCookieHeaderValues(); 1197 return cookieValues.isEmpty() ? null : cookieValues.get(0); 1198 } 1199 1200 List<@NonNull String> values = getHeaders().get(name); 1201 return values == null || values.size() == 0 ? null : values.get(0); 1202 } 1203 1204 @Override 1205 @NonNull 1206 public Collection<@NonNull String> getHeaders(@Nullable String name) { 1207 if (name == null) 1208 return List.of(); 1209 1210 if ("Set-Cookie".equalsIgnoreCase(name)) { 1211 List<@NonNull String> values = getHeaders().get(name); 1212 List<@NonNull String> cookieValues = getSetCookieHeaderValues(); 1213 1214 if ((values == null || values.isEmpty()) && cookieValues.isEmpty()) 1215 return List.of(); 1216 1217 List<@NonNull String> combined = new ArrayList<>(); 1218 1219 if (values != null) 1220 combined.addAll(values); 1221 1222 combined.addAll(cookieValues); 1223 return Collections.unmodifiableList(combined); 1224 } 1225 1226 List<@NonNull String> values = getHeaders().get(name); 1227 return values == null ? List.of() : Collections.unmodifiableList(values); 1228 } 1229 1230 @Override 1231 @NonNull 1232 public Collection<@NonNull String> getHeaderNames() { 1233 Set<@NonNull String> names = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER); 1234 names.addAll(getHeaders().keySet()); 1235 1236 if (!getCookies().isEmpty()) 1237 names.add("Set-Cookie"); 1238 1239 return Collections.unmodifiableSet(names); 1240 } 1241 1242 @Override 1243 @NonNull 1244 public String getCharacterEncoding() { 1245 return getEffectiveCharset().name(); 1246 } 1247 1248 @Override 1249 @Nullable 1250 public String getContentType() { 1251 String headerValue = getHeader("Content-Type"); 1252 return headerValue != null ? headerValue : this.contentType; 1253 } 1254 1255 @Override 1256 @NonNull 1257 public ServletOutputStream getOutputStream() throws IOException { 1258 // Returns a ServletOutputStream suitable for writing binary data in the response. 1259 // The servlet container does not encode the binary data. 1260 // Calling flush() on the ServletOutputStream commits the response. 1261 // Either this method or getWriter() may be called to write the body, not both, except when reset() has been called. 1262 ResponseWriteMethod currentResponseWriteMethod = getResponseWriteMethod(); 1263 1264 if (currentResponseWriteMethod == ResponseWriteMethod.UNSPECIFIED) { 1265 setResponseWriteMethod(ResponseWriteMethod.SERVLET_OUTPUT_STREAM); 1266 this.servletOutputStream = SokletServletOutputStream.withOutputStream(getResponseOutputStream()) 1267 .onWriteOccurred((ignored1, ignored2) -> maybeCommitOnWrite()) 1268 .onWriteFinalized((ignored) -> { 1269 setResponseCommitted(true); 1270 setResponseFinalized(true); 1271 }).build(); 1272 return getServletOutputStream().get(); 1273 } else if (currentResponseWriteMethod == ResponseWriteMethod.SERVLET_OUTPUT_STREAM) { 1274 return getServletOutputStream().get(); 1275 } else { 1276 throw new IllegalStateException(format("Cannot use %s for writing response; already using %s", 1277 ServletOutputStream.class.getSimpleName(), PrintWriter.class.getSimpleName())); 1278 } 1279 } 1280 1281 @NonNull 1282 private Boolean writerObtained() { 1283 return getResponseWriteMethod() == ResponseWriteMethod.PRINT_WRITER; 1284 } 1285 1286 @NonNull 1287 private Optional<String> extractCharsetFromContentType(@Nullable String type) { 1288 if (type == null) 1289 return Optional.empty(); 1290 1291 String[] parts = type.split(";"); 1292 1293 for (int i = 1; i < parts.length; i++) { 1294 String p = parts[i].trim(); 1295 if (p.toLowerCase(Locale.ROOT).startsWith("charset=")) { 1296 String cs = p.substring("charset=".length()).trim(); 1297 1298 if (cs.startsWith("\"") && cs.endsWith("\"") && cs.length() >= 2) 1299 cs = cs.substring(1, cs.length() - 1); 1300 1301 return Optional.of(cs); 1302 } 1303 } 1304 1305 return Optional.empty(); 1306 } 1307 1308 // Helper: remove any charset=... from Content-Type (preserve other params) 1309 @NonNull 1310 private Optional<String> stripCharsetParam(@Nullable String type) { 1311 if (type == null) 1312 return Optional.empty(); 1313 1314 String[] parts = type.split(";"); 1315 String base = parts[0].trim(); 1316 List<@NonNull String> kept = new ArrayList<>(); 1317 1318 for (int i = 1; i < parts.length; i++) { 1319 String p = parts[i].trim(); 1320 1321 if (!p.toLowerCase(Locale.ROOT).startsWith("charset=") && !p.isEmpty()) 1322 kept.add(p); 1323 } 1324 1325 return Optional.ofNullable(kept.isEmpty() ? base : base + "; " + String.join("; ", kept)); 1326 } 1327 1328 // Helper: ensure Content-Type includes the given charset (replacing any existing one) 1329 @NonNull 1330 private Optional<String> withCharset(@Nullable String type, 1331 @NonNull String charsetName) { 1332 requireNonNull(charsetName); 1333 1334 if (type == null) 1335 return Optional.empty(); 1336 1337 String baseNoCs = stripCharsetParam(type).orElse("text/plain"); 1338 return Optional.of(baseNoCs + "; charset=" + charsetName); 1339 } 1340 1341 @Override 1342 public PrintWriter getWriter() throws IOException { 1343 // Returns a PrintWriter object that can send character text to the client. 1344 // The PrintWriter uses the character encoding returned by getCharacterEncoding(). 1345 // If the response's character encoding has not been specified as described in getCharacterEncoding 1346 // (i.e., the method just returns the default value), getWriter updates it to the effective default. 1347 // Calling flush() on the PrintWriter commits the response. 1348 // 1349 // Either this method or getOutputStream() may be called to write the body, not both, except when reset() has been called. 1350 // Returns a PrintWriter that uses the character encoding returned by getCharacterEncoding(). 1351 // If not specified yet, calling getWriter() fixes the encoding to the effective default. 1352 ResponseWriteMethod currentResponseWriteMethod = getResponseWriteMethod(); 1353 1354 if (currentResponseWriteMethod == ResponseWriteMethod.UNSPECIFIED) { 1355 // Freeze encoding now 1356 Charset enc = getEffectiveCharset(); 1357 setCharset(enc); // record the chosen encoding explicitly 1358 1359 // If a content type is already present and lacks charset, append the frozen charset to header 1360 String currentContentType = getContentType(); 1361 1362 if (currentContentType != null) { 1363 Optional<String> csInHeader = extractCharsetFromContentType(currentContentType); 1364 if (csInHeader.isEmpty() || !csInHeader.get().equalsIgnoreCase(enc.name())) { 1365 String updated = withCharset(currentContentType, enc.name()).orElse(null); 1366 1367 if (updated != null) { 1368 this.contentType = updated; 1369 putHeaderValue("Content-Type", updated, true); 1370 } else { 1371 this.contentType = currentContentType; 1372 putHeaderValue("Content-Type", currentContentType, true); 1373 } 1374 } 1375 } 1376 1377 setResponseWriteMethod(ResponseWriteMethod.PRINT_WRITER); 1378 1379 this.printWriter = 1380 SokletServletPrintWriter.withWriter( 1381 new OutputStreamWriter(getResponseOutputStream(), enc)) 1382 .onWriteOccurred((ignored1, ignored2) -> maybeCommitOnWrite()) 1383 .onWriteFinalized((ignored) -> { 1384 setResponseCommitted(true); 1385 setResponseFinalized(true); 1386 }) 1387 .build(); 1388 1389 return getPrintWriter().get(); 1390 } else if (currentResponseWriteMethod == ResponseWriteMethod.PRINT_WRITER) { 1391 return getPrintWriter().get(); 1392 } else { 1393 throw new IllegalStateException(format("Cannot use %s for writing response; already using %s", 1394 PrintWriter.class.getSimpleName(), ServletOutputStream.class.getSimpleName())); 1395 } 1396 } 1397 1398 @Override 1399 public void setCharacterEncoding(@Nullable String charset) { 1400 if (isCommitted()) 1401 return; 1402 1403 // Spec: no effect after getWriter() or after commit 1404 if (writerObtained()) 1405 return; 1406 1407 if (charset == null || charset.isBlank()) { 1408 // Clear explicit charset; default will be chosen at writer time if needed 1409 setCharset(null); 1410 1411 // If a Content-Type is set, remove its charset=... parameter 1412 String currentContentType = getContentType(); 1413 1414 if (currentContentType != null) { 1415 String updated = stripCharsetParam(currentContentType).orElse(null); 1416 this.contentType = updated; 1417 if (updated == null || updated.isBlank()) { 1418 getHeaders().remove("Content-Type"); 1419 } else { 1420 putHeaderValue("Content-Type", updated, true); 1421 } 1422 } 1423 1424 return; 1425 } 1426 1427 Charset cs; 1428 1429 try { 1430 cs = Charset.forName(charset); 1431 } catch (IllegalCharsetNameException | UnsupportedCharsetException e) { 1432 return; 1433 } 1434 setCharset(cs); 1435 1436 // If a Content-Type is set, reflect/replace the charset=... in the header 1437 String currentContentType = getContentType(); 1438 1439 if (currentContentType != null) { 1440 String updated = withCharset(currentContentType, cs.name()).orElse(null); 1441 1442 if (updated != null) { 1443 this.contentType = updated; 1444 putHeaderValue("Content-Type", updated, true); 1445 } else { 1446 this.contentType = currentContentType; 1447 putHeaderValue("Content-Type", currentContentType, true); 1448 } 1449 } 1450 } 1451 1452 @Override 1453 public void setContentLength(int len) { 1454 if (isCommitted()) 1455 return; 1456 1457 setHeader("Content-Length", String.valueOf(len)); 1458 } 1459 1460 @Override 1461 public void setContentLengthLong(long len) { 1462 if (isCommitted()) 1463 return; 1464 1465 setHeader("Content-Length", String.valueOf(len)); 1466 } 1467 1468 @Override 1469 public void setContentType(@Nullable String type) { 1470 // This method may be called repeatedly to change content type and character encoding. 1471 // This method has no effect if called after the response has been committed. 1472 // It does not set the response's character encoding if it is called after getWriter has been called 1473 // or after the response has been committed. 1474 if (isCommitted()) 1475 return; 1476 1477 if (!writerObtained()) { 1478 // Before writer: charset can still be established/overridden 1479 this.contentType = type; 1480 1481 if (type == null || type.isBlank()) { 1482 getHeaders().remove("Content-Type"); 1483 return; 1484 } 1485 1486 // If caller specified charset=..., adopt it as the current explicit charset 1487 Optional<String> cs = extractCharsetFromContentType(type); 1488 if (cs.isPresent()) { 1489 try { 1490 setCharset(Charset.forName(cs.get())); 1491 } catch (IllegalCharsetNameException | UnsupportedCharsetException ignored) { 1492 // Ignore invalid charset token; leave current charset unchanged. 1493 } 1494 putHeaderValue("Content-Type", type, true); 1495 } else { 1496 // No charset in type. If an explicit charset already exists (via setCharacterEncoding), 1497 // reflect it in the header; otherwise just set the type as-is. 1498 if (getCharset().isPresent()) { 1499 String updated = withCharset(type, getCharset().get().name()).orElse(null); 1500 1501 if (updated != null) { 1502 this.contentType = updated; 1503 putHeaderValue("Content-Type", updated, true); 1504 } else { 1505 putHeaderValue("Content-Type", type, true); 1506 } 1507 } else { 1508 putHeaderValue("Content-Type", type, true); 1509 } 1510 } 1511 } else { 1512 // After writer: charset is frozen. We can change the MIME type, but we must NOT change encoding. 1513 // If caller supplies a charset, normalize the header back to the locked encoding. 1514 this.contentType = type; 1515 1516 if (type == null || type.isBlank()) { 1517 // Allowed: clear header; does not change actual encoding used by writer 1518 getHeaders().remove("Content-Type"); 1519 return; 1520 } 1521 1522 String locked = getCharacterEncoding(); // the frozen encoding name 1523 String normalized = withCharset(type, locked).orElse(null); 1524 1525 if (normalized != null) { 1526 this.contentType = normalized; 1527 putHeaderValue("Content-Type", normalized, true); 1528 } else { 1529 this.contentType = type; 1530 putHeaderValue("Content-Type", type, true); 1531 } 1532 } 1533 } 1534 1535 @Override 1536 public void setBufferSize(int size) { 1537 ensureResponseIsUncommitted(); 1538 1539 if (size <= 0) 1540 throw new IllegalArgumentException("Buffer size must be greater than 0"); 1541 1542 // Per Servlet spec, setBufferSize must be called before any content is written 1543 if (getResponseOutputStream().size() > 0) 1544 throw new IllegalStateException("setBufferSize must be called before any content is written"); 1545 1546 setResponseBufferSizeInBytes(size); 1547 1548 if (!writerObtained() && getServletOutputStream().isEmpty()) 1549 setResponseOutputStream(new ByteArrayOutputStream(getResponseBufferSizeInBytes())); 1550 } 1551 1552 @Override 1553 public int getBufferSize() { 1554 return getResponseBufferSizeInBytes(); 1555 } 1556 1557 @Override 1558 public void flushBuffer() throws IOException { 1559 if (!isCommitted()) 1560 setResponseCommitted(true); 1561 1562 SokletServletPrintWriter currentWriter = getPrintWriter().orElse(null); 1563 SokletServletOutputStream currentOutputStream = getServletOutputStream().orElse(null); 1564 1565 if (currentWriter != null) { 1566 currentWriter.flush(); 1567 } else if (currentOutputStream != null) { 1568 currentOutputStream.flush(); 1569 } else { 1570 getResponseOutputStream().flush(); 1571 } 1572 } 1573 1574 @Override 1575 public void resetBuffer() { 1576 ensureResponseIsUncommitted(); 1577 getResponseOutputStream().reset(); 1578 } 1579 1580 @Override 1581 public boolean isCommitted() { 1582 return getResponseCommitted(); 1583 } 1584 1585 @Override 1586 public void reset() { 1587 // Clears any data that exists in the buffer as well as the status code, headers. 1588 // The state of calling getWriter() or getOutputStream() is also cleared. 1589 // It is legal, for instance, to call getWriter(), reset() and then getOutputStream(). 1590 // If getWriter() or getOutputStream() have been called before this method, then the corresponding returned 1591 // Writer or OutputStream will be staled and the behavior of using the stale object is undefined. 1592 // If the response has been committed, this method throws an IllegalStateException. 1593 1594 ensureResponseIsUncommitted(); 1595 1596 setStatusCode(HttpServletResponse.SC_OK); 1597 setServletOutputStream(null); 1598 setPrintWriter(null); 1599 setResponseWriteMethod(ResponseWriteMethod.UNSPECIFIED); 1600 setResponseOutputStream(new ByteArrayOutputStream(getResponseBufferSizeInBytes())); 1601 getHeaders().clear(); 1602 getCookies().clear(); 1603 1604 // Clear content-type/charset & locale to a pristine state 1605 this.contentType = null; 1606 setCharset(null); 1607 this.locale = null; 1608 this.errorMessage = null; 1609 this.redirectUrl = null; 1610 } 1611 1612 @Override 1613 public void setLocale(@Nullable Locale locale) { 1614 if (isCommitted()) 1615 return; 1616 1617 this.locale = locale; 1618 1619 if (locale != null && !writerObtained() && getCharset().isEmpty()) { 1620 Charset contextCharset = getContextResponseCharset(); 1621 Charset selectedCharset = contextCharset == null ? DEFAULT_CHARSET : contextCharset; 1622 setCharacterEncoding(selectedCharset.name()); 1623 } 1624 1625 if (locale == null) { 1626 getHeaders().remove("Content-Language"); 1627 return; 1628 } 1629 1630 String tag = locale.toLanguageTag(); 1631 1632 if (tag.isBlank()) 1633 getHeaders().remove("Content-Language"); 1634 else 1635 putHeaderValue("Content-Language", tag, true); 1636 } 1637 1638 @Override 1639 public Locale getLocale() { 1640 return this.locale == null ? Locale.getDefault() : this.locale; 1641 } 1642}