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