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