001package org.jsoup.helper; 002 003import org.jsoup.Connection; 004import org.jsoup.HttpStatusException; 005import org.jsoup.Progress; 006import org.jsoup.UnsupportedMimeTypeException; 007import org.jsoup.internal.ControllableInputStream; 008import org.jsoup.internal.StringUtil; 009import org.jsoup.nodes.Document; 010import org.jsoup.parser.Parser; 011import org.jsoup.parser.StreamParser; 012import org.jspecify.annotations.Nullable; 013 014import javax.net.ssl.SSLContext; 015import javax.net.ssl.SSLSocketFactory; 016import java.io.BufferedInputStream; 017import java.io.BufferedReader; 018import java.io.BufferedWriter; 019import java.io.ByteArrayInputStream; 020import java.io.IOException; 021import java.io.InputStream; 022import java.io.InputStreamReader; 023import java.io.OutputStream; 024import java.io.OutputStreamWriter; 025import java.io.UncheckedIOException; 026import java.net.CookieManager; 027import java.net.CookieStore; 028import java.net.InetSocketAddress; 029import java.net.MalformedURLException; 030import java.net.Proxy; 031import java.net.URL; 032import java.net.URLEncoder; 033import java.nio.Buffer; 034import java.nio.ByteBuffer; 035import java.nio.charset.Charset; 036import java.nio.charset.IllegalCharsetNameException; 037import java.nio.charset.StandardCharsets; 038import java.util.ArrayList; 039import java.util.Collection; 040import java.util.Collections; 041import java.util.LinkedHashMap; 042import java.util.List; 043import java.util.Map; 044import java.util.concurrent.locks.ReentrantLock; 045import java.util.regex.Pattern; 046import java.util.zip.GZIPInputStream; 047import java.util.zip.Inflater; 048import java.util.zip.InflaterInputStream; 049 050import static org.jsoup.Connection.Method.HEAD; 051import static org.jsoup.helper.DataUtil.UTF_8; 052import static org.jsoup.internal.Normalizer.lowerCase; 053import static org.jsoup.internal.SharedConstants.DefaultBufferSize; 054 055/** 056 * Implementation of {@link Connection}. 057 * @see org.jsoup.Jsoup#connect(String) 058 */ 059@SuppressWarnings("CharsetObjectCanBeUsed") 060public class HttpConnection implements Connection { 061 public static final String CONTENT_ENCODING = "Content-Encoding"; 062 /** 063 * Many users would get caught by not setting a user-agent and therefore getting different responses on their desktop 064 * vs in jsoup, which would otherwise default to {@code Java}. So by default, use a desktop UA. 065 */ 066 public static final String DEFAULT_UA = 067 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"; 068 private static final String USER_AGENT = "User-Agent"; 069 public static final String CONTENT_TYPE = "Content-Type"; 070 public static final String MULTIPART_FORM_DATA = "multipart/form-data"; 071 public static final String FORM_URL_ENCODED = "application/x-www-form-urlencoded"; 072 static final String DefaultUploadType = "application/octet-stream"; 073 private static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1"); 074 075 private HttpConnection.Request req; 076 private Connection.@Nullable Response res; 077 volatile @Nullable Object clientState; // Java 11 HttpClient state, held as Object for Java 8 compatibility 078 079 /** 080 Create a new Connection, with the request URL specified. 081 @param url the URL to fetch from 082 @return a new Connection object 083 */ 084 public static Connection connect(String url) { 085 Connection con = new HttpConnection(); 086 con.url(url); 087 return con; 088 } 089 090 /** 091 Create a new Connection, with the request URL specified. 092 @param url the URL to fetch from 093 @return a new Connection object 094 */ 095 public static Connection connect(URL url) { 096 Connection con = new HttpConnection(); 097 con.url(url); 098 return con; 099 } 100 101 /** 102 Create a new, empty HttpConnection. 103 */ 104 public HttpConnection() { 105 req = new Request(); 106 req.connection = this; 107 } 108 109 /** 110 Create a new Request by deep-copying an existing Request. Note that the data and body of the original are not 111 copied. All other settings (proxy, parser, cookies, etc) are copied. 112 @param copy the request to copy 113 */ 114 HttpConnection(Request copy) { 115 req = new Request(copy); 116 } 117 118 /** Encodes a multipart field name or filename so it stays within its quoted header value. */ 119 static String encodeMimeName(String val) { 120 return val.replace("\"", "%22").replace("\r", "%0D").replace("\n", "%0A"); 121 } 122 123 /** Validates that a multipart content-type cannot introduce another header line. */ 124 private static void validateMimeContentType(String contentType) { 125 Validate.notEmptyParam(contentType, "contentType"); 126 Validate.isFalse(contentType.indexOf('\r') != -1 || contentType.indexOf('\n') != -1, 127 "The 'contentType' parameter must not contain CR or LF."); 128 } 129 130 @Override 131 public Connection newRequest() { 132 // copy the prototype request for the different settings, cookie manager, etc 133 return new HttpConnection(req); 134 } 135 136 /** Create a new Connection that just wraps the provided Request and Response */ 137 private HttpConnection(Request req, Response res) { 138 this.req = req; 139 this.res = res; 140 } 141 142 @Override 143 public Connection url(URL url) { 144 req.url(url); 145 return this; 146 } 147 148 @Override 149 public Connection url(String url) { 150 Validate.notEmptyParam(url, "url"); 151 try { 152 req.url(new URL(url)); 153 } catch (MalformedURLException e) { 154 throw new IllegalArgumentException(String.format("The supplied URL, '%s', is malformed. Make sure it is an absolute URL, and starts with 'http://' or 'https://'. See https://jsoup.org/cookbook/extracting-data/working-with-urls", url), e); 155 } 156 return this; 157 } 158 159 @Override 160 public Connection proxy(@Nullable Proxy proxy) { 161 req.proxy(proxy); 162 return this; 163 } 164 165 @Override 166 public Connection proxy(String host, int port) { 167 req.proxy(host, port); 168 return this; 169 } 170 171 @Override 172 public Connection userAgent(String userAgent) { 173 Validate.notNullParam(userAgent, "userAgent"); 174 req.header(USER_AGENT, userAgent); 175 return this; 176 } 177 178 @Override 179 public Connection timeout(int millis) { 180 req.timeout(millis); 181 return this; 182 } 183 184 @Override 185 public Connection maxBodySize(int bytes) { 186 req.maxBodySize(bytes); 187 return this; 188 } 189 190 @Override 191 public Connection followRedirects(boolean followRedirects) { 192 req.followRedirects(followRedirects); 193 return this; 194 } 195 196 @Override 197 public Connection referrer(String referrer) { 198 Validate.notNullParam(referrer, "referrer"); 199 req.header("Referer", referrer); 200 return this; 201 } 202 203 @Override 204 public Connection method(Method method) { 205 req.method(method); 206 return this; 207 } 208 209 @Override 210 public Connection ignoreHttpErrors(boolean ignoreHttpErrors) { 211 req.ignoreHttpErrors(ignoreHttpErrors); 212 return this; 213 } 214 215 @Override 216 public Connection ignoreContentType(boolean ignoreContentType) { 217 req.ignoreContentType(ignoreContentType); 218 return this; 219 } 220 221 @Override 222 public Connection data(String key, String value) { 223 req.data(KeyVal.create(key, value)); 224 return this; 225 } 226 227 @Override 228 @Deprecated 229 public Connection sslSocketFactory(SSLSocketFactory sslSocketFactory) { 230 req.sslSocketFactory(sslSocketFactory); 231 return this; 232 } 233 234 @Override 235 public Connection sslContext(SSLContext sslContext) { 236 req.sslContext(sslContext); 237 return this; 238 } 239 240 @Override 241 public Connection data(String key, String filename, InputStream inputStream) { 242 req.data(KeyVal.create(key, filename, inputStream)); 243 return this; 244 } 245 246 @Override 247 public Connection data(String key, String filename, InputStream inputStream, String contentType) { 248 req.data(KeyVal.create(key, filename, inputStream).contentType(contentType)); 249 return this; 250 } 251 252 @Override 253 public Connection data(Map<String, String> data) { 254 Validate.notNullParam(data, "data"); 255 for (Map.Entry<String, String> entry : data.entrySet()) { 256 req.data(KeyVal.create(entry.getKey(), entry.getValue())); 257 } 258 return this; 259 } 260 261 @Override 262 public Connection data(String... keyvals) { 263 Validate.notNullParam(keyvals, "keyvals"); 264 Validate.isTrue(keyvals.length %2 == 0, "Must supply an even number of key value pairs"); 265 for (int i = 0; i < keyvals.length; i += 2) { 266 String key = keyvals[i]; 267 String value = keyvals[i+1]; 268 Validate.notEmpty(key, "Data key must not be empty"); 269 Validate.notNull(value, "Data value must not be null"); 270 req.data(KeyVal.create(key, value)); 271 } 272 return this; 273 } 274 275 @Override 276 public Connection data(Collection<Connection.KeyVal> data) { 277 Validate.notNullParam(data, "data"); 278 for (Connection.KeyVal entry: data) { 279 req.data(entry); 280 } 281 return this; 282 } 283 284 @Override 285 public Connection.@Nullable KeyVal data(String key) { 286 Validate.notEmptyParam(key, "key"); 287 for (Connection.KeyVal keyVal : request().data()) { 288 if (keyVal.key().equals(key)) 289 return keyVal; 290 } 291 return null; 292 } 293 294 @Override 295 public Connection requestBody(String body) { 296 req.requestBody(body); 297 return this; 298 } 299 300 @Override 301 public Connection requestBodyStream(InputStream stream) { 302 req.requestBodyStream(stream); 303 return this; 304 } 305 306 @Override 307 public Connection header(String name, String value) { 308 req.header(name, value); 309 return this; 310 } 311 312 @Override 313 public Connection headers(Map<String,String> headers) { 314 Validate.notNullParam(headers, "headers"); 315 for (Map.Entry<String,String> entry : headers.entrySet()) { 316 req.header(entry.getKey(),entry.getValue()); 317 } 318 return this; 319 } 320 321 @Override 322 public Connection cookie(String name, String value) { 323 req.cookie(name, value); 324 return this; 325 } 326 327 @Override 328 public Connection cookies(Map<String, String> cookies) { 329 Validate.notNullParam(cookies, "cookies"); 330 for (Map.Entry<String, String> entry : cookies.entrySet()) { 331 req.cookie(entry.getKey(), entry.getValue()); 332 } 333 return this; 334 } 335 336 @Override 337 public Connection cookieStore(CookieStore cookieStore) { 338 // create a new cookie manager using the new store 339 req.cookieManager = new CookieManager(cookieStore, null); 340 return this; 341 } 342 343 @Override 344 public CookieStore cookieStore() { 345 return req.cookieManager.getCookieStore(); 346 } 347 348 @Override 349 public Connection parser(Parser parser) { 350 req.parser(parser); 351 return this; 352 } 353 354 @Override 355 public Document get() throws IOException { 356 req.method(Method.GET); 357 execute(); 358 Validate.notNull(res); 359 return res.parse(); 360 } 361 362 @Override 363 public Document post() throws IOException { 364 req.method(Method.POST); 365 execute(); 366 Validate.notNull(res); 367 return res.parse(); 368 } 369 370 @Override 371 public Connection.Response execute() throws IOException { 372 res = Response.execute(req); 373 return res; 374 } 375 376 @Override 377 public Connection.Request request() { 378 return req; 379 } 380 381 @Override 382 public Connection request(Connection.Request request) { 383 req = (HttpConnection.Request) request; // will throw a class-cast exception if the user has extended some but not all of Connection; that's desired 384 return this; 385 } 386 387 @Override 388 public Connection.Response response() { 389 if (res == null) { 390 throw new IllegalArgumentException("You must execute the request before getting a response."); 391 } 392 return res; 393 } 394 395 @Override 396 public Connection response(Connection.Response response) { 397 res = response; 398 return this; 399 } 400 401 @Override 402 public Connection postDataCharset(String charset) { 403 req.postDataCharset(charset); 404 return this; 405 } 406 407 @Override public Connection auth(@Nullable RequestAuthenticator authenticator) { 408 req.auth(authenticator); 409 return this; 410 } 411 412 @Override public Connection onResponseProgress(Progress<Connection.Response> handler) { 413 req.responseProgress = handler; 414 return this; 415 } 416 417 @SuppressWarnings("unchecked") 418 private static abstract class Base<T extends Connection.Base<T>> implements Connection.Base<T> { 419 private static final URL UnsetUrl; // only used if you created a new Request() 420 static { 421 try { 422 UnsetUrl = new URL("http://undefined/"); 423 } catch (MalformedURLException e) { 424 throw new IllegalStateException(e); 425 } 426 } 427 428 URL url = UnsetUrl; 429 Method method = Method.GET; 430 Map<String, List<String>> headers; 431 Map<String, String> cookies; 432 433 private Base() { 434 headers = new LinkedHashMap<>(); 435 cookies = new LinkedHashMap<>(); 436 } 437 438 private Base(Base<T> copy) { 439 url = copy.url; // unmodifiable object 440 method = copy.method; 441 headers = new LinkedHashMap<>(); 442 for (Map.Entry<String, List<String>> entry : copy.headers.entrySet()) { 443 headers.put(entry.getKey(), new ArrayList<>(entry.getValue())); 444 } 445 cookies = new LinkedHashMap<>(); cookies.putAll(copy.cookies); // just holds strings 446 } 447 448 @Override 449 public URL url() { 450 if (url == UnsetUrl) 451 throw new IllegalArgumentException("URL not set. Make sure to call #url(...) before executing the request."); 452 return url; 453 } 454 455 @Override 456 public T url(URL url) { 457 Validate.notNullParam(url, "url"); 458 this.url = new UrlBuilder(url).build(); 459 return (T) this; 460 } 461 462 @Override 463 public Method method() { 464 return method; 465 } 466 467 @Override 468 public T method(Method method) { 469 Validate.notNullParam(method, "method"); 470 this.method = method; 471 return (T) this; 472 } 473 474 @Override @Nullable 475 public String header(String name) { 476 Validate.notNullParam(name, "name"); 477 List<String> vals = getHeadersCaseInsensitive(name); 478 if (!vals.isEmpty()) { 479 // https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 480 return StringUtil.join(vals, ", "); 481 } 482 483 return null; 484 } 485 486 @Override 487 public T addHeader(String name, @Nullable String value) { 488 Validate.notEmptyParam(name, "name"); 489 value = value == null ? "" : value; 490 491 List<String> values = headers(name); 492 if (values.isEmpty()) { 493 values = new ArrayList<>(); 494 headers.put(name, values); 495 } 496 values.add(value); 497 498 return (T) this; 499 } 500 501 @Override 502 public List<String> headers(String name) { 503 Validate.notEmptyParam(name, "name"); 504 return getHeadersCaseInsensitive(name); 505 } 506 507 @Override 508 public T header(String name, String value) { 509 Validate.notEmptyParam(name, "name"); 510 removeHeader(name); // ensures we don't get an "accept-encoding" and an "Accept-Encoding" 511 addHeader(name, value); 512 return (T) this; 513 } 514 515 @Override 516 public boolean hasHeader(String name) { 517 Validate.notEmptyParam(name, "name"); 518 return !getHeadersCaseInsensitive(name).isEmpty(); 519 } 520 521 /** 522 * Test if the request has a header with this value (case-insensitive). 523 */ 524 @Override 525 public boolean hasHeaderWithValue(String name, String value) { 526 Validate.notEmpty(name); 527 Validate.notEmpty(value); 528 List<String> values = headers(name); 529 for (String candidate : values) { 530 if (value.equalsIgnoreCase(candidate)) 531 return true; 532 } 533 return false; 534 } 535 536 @Override 537 public T removeHeader(String name) { 538 Validate.notEmptyParam(name, "name"); 539 Map.Entry<String, List<String>> entry = scanHeaders(name); // remove is case-insensitive too 540 if (entry != null) 541 headers.remove(entry.getKey()); // ensures correct case 542 return (T) this; 543 } 544 545 @Override 546 public Map<String, String> headers() { 547 LinkedHashMap<String, String> map = new LinkedHashMap<>(headers.size()); 548 for (Map.Entry<String, List<String>> entry : headers.entrySet()) { 549 String header = entry.getKey(); 550 List<String> values = entry.getValue(); 551 if (!values.isEmpty()) 552 map.put(header, values.get(0)); 553 } 554 return map; 555 } 556 557 @Override 558 public Map<String, List<String>> multiHeaders() { 559 return headers; 560 } 561 562 private List<String> getHeadersCaseInsensitive(String name) { 563 Validate.notNull(name); 564 565 for (Map.Entry<String, List<String>> entry : headers.entrySet()) { 566 if (name.equalsIgnoreCase(entry.getKey())) 567 return entry.getValue(); 568 } 569 570 return Collections.emptyList(); 571 } 572 573 private Map.@Nullable Entry<String, List<String>> scanHeaders(String name) { 574 String lc = lowerCase(name); 575 for (Map.Entry<String, List<String>> entry : headers.entrySet()) { 576 if (lowerCase(entry.getKey()).equals(lc)) 577 return entry; 578 } 579 return null; 580 } 581 582 @Override 583 public String cookie(String name) { 584 Validate.notEmptyParam(name, "name"); 585 return cookies.get(name); 586 } 587 588 @Override 589 public T cookie(String name, String value) { 590 Validate.notEmptyParam(name, "name"); 591 Validate.notNullParam(value, "value"); 592 cookies.put(name, value); 593 return (T) this; 594 } 595 596 @Override 597 public boolean hasCookie(String name) { 598 Validate.notEmptyParam(name, "name"); 599 return cookies.containsKey(name); 600 } 601 602 @Override 603 public T removeCookie(String name) { 604 Validate.notEmptyParam(name, "name"); 605 cookies.remove(name); 606 return (T) this; 607 } 608 609 @Override 610 public Map<String, String> cookies() { 611 return cookies; 612 } 613 } 614 615 public static class Request extends HttpConnection.Base<Connection.Request> implements Connection.Request { 616 static { 617 System.setProperty("sun.net.http.allowRestrictedHeaders", "true"); 618 // make sure that we can send Sec-Fetch-Site headers etc. 619 } 620 621 HttpConnection connection; 622 private @Nullable Proxy proxy; 623 private int timeoutMilliseconds; 624 private int maxBodySizeBytes; 625 private boolean followRedirects; 626 private final Collection<Connection.KeyVal> data; 627 private @Nullable Object body = null; // String or InputStream 628 @Nullable String mimeBoundary; 629 private boolean ignoreHttpErrors = false; 630 private boolean ignoreContentType = false; 631 private Parser parser; 632 private boolean parserDefined = false; // called parser(...) vs initialized in ctor 633 private String postDataCharset = DataUtil.defaultCharsetName; 634 private @Nullable SSLSocketFactory sslSocketFactory; 635 @Nullable SSLContext sslContext; 636 private CookieManager cookieManager; 637 @Nullable RequestAuthenticator authenticator; 638 private @Nullable Progress<Connection.Response> responseProgress; 639 640 private final ReentrantLock executing = new ReentrantLock(); // detects and warns if same request used concurrently 641 642 Request() { 643 super(); 644 timeoutMilliseconds = 30000; // 30 seconds 645 maxBodySizeBytes = 1024 * 1024 * 2; // 2MB 646 followRedirects = true; 647 data = new ArrayList<>(); 648 method = Method.GET; 649 addHeader("Accept-Encoding", "gzip"); 650 addHeader(USER_AGENT, DEFAULT_UA); 651 parser = Parser.htmlParser(); 652 cookieManager = new CookieManager(); // creates a default InMemoryCookieStore 653 } 654 655 Request(Request copy) { 656 super(copy); 657 connection = copy.connection; 658 proxy = copy.proxy; 659 postDataCharset = copy.postDataCharset; 660 timeoutMilliseconds = copy.timeoutMilliseconds; 661 maxBodySizeBytes = copy.maxBodySizeBytes; 662 followRedirects = copy.followRedirects; 663 data = new ArrayList<>(); // data not copied 664 //body not copied 665 ignoreHttpErrors = copy.ignoreHttpErrors; 666 ignoreContentType = copy.ignoreContentType; 667 parser = copy.parser.newInstance(); // parsers and their tree-builders maintain state, so need a fresh copy 668 parserDefined = copy.parserDefined; 669 sslSocketFactory = copy.sslSocketFactory; // these are all synchronized so safe to share 670 sslContext = copy.sslContext; 671 cookieManager = copy.cookieManager; 672 authenticator = copy.authenticator; 673 responseProgress = copy.responseProgress; 674 } 675 676 @Override @Nullable 677 public Proxy proxy() { 678 return proxy; 679 } 680 681 @Override 682 public Request proxy(@Nullable Proxy proxy) { 683 this.proxy = proxy; 684 return this; 685 } 686 687 @Override 688 public Request proxy(String host, int port) { 689 this.proxy = new Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port)); 690 return this; 691 } 692 693 @Override 694 public int timeout() { 695 return timeoutMilliseconds; 696 } 697 698 @Override 699 public Request timeout(int millis) { 700 Validate.isTrue(millis >= 0, "Timeout milliseconds must be 0 (infinite) or greater"); 701 timeoutMilliseconds = millis; 702 return this; 703 } 704 705 @Override 706 public int maxBodySize() { 707 return maxBodySizeBytes; 708 } 709 710 @Override 711 public Connection.Request maxBodySize(int bytes) { 712 Validate.isTrue(bytes >= 0, "maxSize must be 0 (unlimited) or larger"); 713 maxBodySizeBytes = bytes; 714 return this; 715 } 716 717 @Override 718 public boolean followRedirects() { 719 return followRedirects; 720 } 721 722 @Override 723 public Connection.Request followRedirects(boolean followRedirects) { 724 this.followRedirects = followRedirects; 725 return this; 726 } 727 728 @Override 729 public boolean ignoreHttpErrors() { 730 return ignoreHttpErrors; 731 } 732 733 @Override @Nullable 734 public SSLSocketFactory sslSocketFactory() { 735 return sslSocketFactory; 736 } 737 738 @Override 739 @Deprecated 740 public void sslSocketFactory(SSLSocketFactory sslSocketFactory) { 741 this.sslSocketFactory = sslSocketFactory; 742 } 743 744 @Override @Nullable 745 public SSLContext sslContext() { 746 return sslContext; 747 } 748 749 @Override 750 public Connection.Request sslContext(SSLContext sslContext) { 751 this.sslContext = sslContext; 752 return this; 753 } 754 755 @Override 756 public Connection.Request ignoreHttpErrors(boolean ignoreHttpErrors) { 757 this.ignoreHttpErrors = ignoreHttpErrors; 758 return this; 759 } 760 761 @Override 762 public boolean ignoreContentType() { 763 return ignoreContentType; 764 } 765 766 @Override 767 public Connection.Request ignoreContentType(boolean ignoreContentType) { 768 this.ignoreContentType = ignoreContentType; 769 return this; 770 } 771 772 @Override 773 public Request data(Connection.KeyVal keyval) { 774 Validate.notNullParam(keyval, "keyval"); 775 data.add(keyval); 776 return this; 777 } 778 779 @Override 780 public Collection<Connection.KeyVal> data() { 781 return data; 782 } 783 784 @Override 785 public Connection.Request requestBody(@Nullable String body) { 786 this.body = body; 787 return this; 788 } 789 790 @Override @Nullable 791 public String requestBody() { 792 return body instanceof String ? (String) body : null; 793 } 794 795 @Override 796 public Connection.Request requestBodyStream(InputStream stream) { 797 body = stream; 798 return this; 799 } 800 801 /** Get the request body as an InputStream, or null if it is not a stream. */ 802 @Nullable InputStream requestBodyStream() { 803 return body instanceof InputStream ? (InputStream) body : null; 804 } 805 806 @Override 807 public Request parser(Parser parser) { 808 this.parser = parser; 809 parserDefined = true; 810 return this; 811 } 812 813 @Override 814 public Parser parser() { 815 return parser; 816 } 817 818 @Override 819 public Connection.Request postDataCharset(String charset) { 820 Validate.notNullParam(charset, "charset"); 821 if (!Charset.isSupported(charset)) throw new IllegalCharsetNameException(charset); 822 this.postDataCharset = charset; 823 return this; 824 } 825 826 @Override 827 public String postDataCharset() { 828 return postDataCharset; 829 } 830 831 CookieManager cookieManager() { 832 return cookieManager; 833 } 834 835 @Override public Connection.Request auth(@Nullable RequestAuthenticator authenticator) { 836 this.authenticator = authenticator; 837 return this; 838 } 839 840 @Override @Nullable public RequestAuthenticator auth() { 841 return authenticator; 842 } 843 } 844 845 public static class Response extends HttpConnection.Base<Connection.Response> implements Connection.Response { 846 private static final int MAX_REDIRECTS = 20; 847 private static final String LOCATION = "Location"; 848 private static final String[] REDIRECT_CONTENT_HEADERS = { 849 CONTENT_ENCODING, "Content-Language", "Content-Location", CONTENT_TYPE, "Content-Length", "Digest", "Last-Modified" 850 }; 851 int statusCode; 852 String statusMessage = ""; 853 private @Nullable ByteBuffer byteData; 854 private @Nullable ControllableInputStream bodyStream; 855 @Nullable RequestExecutor executor; 856 private @Nullable String charset; 857 @Nullable String contentType; 858 int contentLength; 859 private boolean executed = false; 860 private boolean inputStreamRead = false; 861 private int numRedirects = 0; 862 private final HttpConnection.Request req; 863 864 // matches XML content types: */xml, */xml-*, and */*+xml 865 private static final Pattern xmlContentTypeRxp = Pattern.compile( 866 "^[^/;]+/(?:xml(?:-[^/;]+)?|[^/;]+\\+xml)(?:\\s*;.*)?$", Pattern.CASE_INSENSITIVE); 867 868 /** 869 <b>Internal only! </b>Creates a dummy HttpConnection.Response, useful for testing. All actual responses 870 are created from the HttpURLConnection and fields defined. 871 */ 872 Response() { 873 super(); 874 statusCode = 400; 875 statusMessage = "Request not made"; 876 req = new Request(); 877 contentType = null; 878 } 879 880 static Response execute(HttpConnection.Request req) throws IOException { 881 return execute(req, null); 882 } 883 884 /** 885 Determines the request method for an automatic redirect, or null if the status is not automatically redirected. 886 See <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-15.4">RFC 9110, Section 15.4</a>. 887 */ 888 static @Nullable Method redirectMethod(int statusCode, Method method) { 889 switch (statusCode) { 890 case 301: // Moved Permanently 891 case 302: // Found 892 return method == Method.POST ? Method.GET : method; 893 case 303: // See Other 894 return method == Method.HEAD ? Method.HEAD : Method.GET; 895 case 307: // Temporary Redirect 896 case 308: // Permanent Redirect 897 return method; 898 default: 899 return null; 900 } 901 } 902 903 /** 904 Tests if two URLs share an HTTP origin, as defined by scheme, host, and effective port. 905 See <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-4.3.1">RFC 9110, Section 4.3.1</a>. 906 */ 907 static boolean sameOrigin(URL first, URL second) { 908 int firstPort = first.getPort() != -1 ? first.getPort() : first.getDefaultPort(); 909 int secondPort = second.getPort() != -1 ? second.getPort() : second.getDefaultPort(); 910 return first.getProtocol().equalsIgnoreCase(second.getProtocol()) 911 && first.getHost().equalsIgnoreCase(second.getHost()) 912 && firstPort == secondPort; 913 } 914 915 static Response execute(HttpConnection.Request req, @Nullable Response prevRes) throws IOException { 916 Validate.isTrue(req.executing.tryLock(), "Multiple threads were detected trying to execute the same request concurrently. Make sure to use Connection#newRequest() and do not share an executing request between threads."); 917 Validate.notNullParam(req, "req"); 918 URL url = req.url(); 919 Validate.notNull(url, "URL must be specified to connect"); 920 String protocol = url.getProtocol(); 921 if (!protocol.equals("http") && !protocol.equals("https")) 922 throw new MalformedURLException("Only http & https protocols supported"); 923 final boolean supportsBody = req.method().hasBody(); 924 final boolean hasBody = req.body != null; 925 if (!supportsBody) 926 Validate.isFalse(hasBody, "Cannot set a request body for HTTP method " + req.method()); 927 928 // set up the request for execution 929 if (!req.data().isEmpty() && (!supportsBody || hasBody)) 930 serialiseRequestUrl(req); 931 else if (supportsBody) 932 setOutputContentType(req); 933 934 long startTime = System.nanoTime(); 935 RequestExecutor executor = RequestDispatch.get(req, prevRes); 936 Response res = null; 937 try { 938 res = executor.execute(); 939 940 Method nextMethod = redirectMethod(res.statusCode, req.method()); 941 if (nextMethod != null && res.hasHeader(LOCATION) && req.followRedirects()) { 942 if (nextMethod == req.method() && (req.body instanceof InputStream || needsMultipart(req))) 943 throw new IOException("Cannot follow redirect with a streamed request body; disable followRedirects and resend with a fresh stream"); 944 945 if (nextMethod != req.method()) { 946 req.method(nextMethod); 947 req.data().clear(); 948 req.requestBody(null); 949 for (String header : REDIRECT_CONTENT_HEADERS) 950 req.removeHeader(header); 951 } 952 953 String location = res.header(LOCATION); 954 Validate.notNull(location); 955 URL redir = StringUtil.resolve(req.url(), location); 956 if (!sameOrigin(req.url(), redir)) { 957 // remove sensitive headers; defense-in-depth against open redirects 958 req.removeHeader("Authorization"); 959 req.removeHeader("Cookie"); 960 req.removeHeader("Cookie2"); 961 req.cookies().clear(); 962 } 963 req.url(redir); 964 965 return execute(req, res); 966 } 967 if ((res.statusCode < 200 || res.statusCode >= 400) && !req.ignoreHttpErrors()) 968 throw new HttpStatusException("HTTP error fetching URL", res.statusCode, req.url().toString()); 969 970 // check that we can handle the returned content type; if not, abort before fetching it 971 String contentType = res.contentType(); 972 boolean isText = contentType != null && contentType.regionMatches(true, 0, "text/", 0, 5); 973 boolean isXml = contentType != null && xmlContentTypeRxp.matcher(contentType).matches(); 974 975 if (contentType != null 976 && !req.ignoreContentType() 977 && !isText 978 && !isXml 979 ) 980 throw new UnsupportedMimeTypeException("Unhandled content type. Must be a text or XML media type", 981 contentType, req.url().toString()); 982 983 // switch to the XML parser if content type is xml and not parser not explicitly set 984 if (isXml) { 985 if (!req.parserDefined) req.parser(Parser.xmlParser()); 986 } 987 988 res.charset = DataUtil.getCharsetFromContentType(res.contentType); // may be null, readInputStream deals with it 989 if (res.contentLength != 0 && req.method() != HEAD) { // -1 means unknown, chunked. sun throws an IO exception on 500 response with no content when trying to read body 990 InputStream stream = executor.responseBody(); 991 if (res.hasHeaderWithValue(CONTENT_ENCODING, "gzip")) 992 stream = new GZIPInputStream(stream); 993 else if (res.hasHeaderWithValue(CONTENT_ENCODING, "deflate")) 994 stream = new InflaterInputStream(stream, new Inflater(true)); 995 996 res.bodyStream = ControllableInputStream.wrap( 997 stream, DefaultBufferSize, req.maxBodySize()) 998 .timeout(startTime, req.timeout()); 999 1000 if (req.responseProgress != null) // set response progress listener 1001 res.bodyStream.onProgress(res.contentLength, req.responseProgress, res); 1002 } else { 1003 res.byteData = DataUtil.emptyByteBuffer(); 1004 } 1005 } catch (IOException e) { 1006 if (res != null) res.safeClose(); // will be non-null if got to conn 1007 throw e; 1008 } finally { 1009 req.executing.unlock(); 1010 1011 // detach any thread local auth delegate 1012 if (req.authenticator != null) 1013 AuthenticationHandler.handler.remove(); 1014 } 1015 1016 res.executed = true; 1017 return res; 1018 } 1019 1020 @Override 1021 public int statusCode() { 1022 return statusCode; 1023 } 1024 1025 @Override 1026 public String statusMessage() { 1027 return statusMessage; 1028 } 1029 1030 @Override @Nullable 1031 public String charset() { 1032 return charset; 1033 } 1034 1035 @Override 1036 public Response charset(String charset) { 1037 this.charset = charset; 1038 return this; 1039 } 1040 1041 @Override @Nullable 1042 public String contentType() { 1043 return contentType; 1044 } 1045 1046 /** Called from parse() or streamParser(), validates and prepares the input stream, and aligns common settings. */ 1047 private ControllableInputStream prepareParse() { 1048 Validate.isTrue(executed, "Request must be executed (with .execute(), .get(), or .post() before parsing response"); 1049 ControllableInputStream stream = bodyStream; 1050 if (byteData != null) { // bytes have been read in to the buffer, parse that 1051 ByteArrayInputStream bytes = new ByteArrayInputStream(byteData.array(), 0, byteData.limit()); 1052 stream = ControllableInputStream.wrap(bytes, 0); // no max 1053 inputStreamRead = false; // ok to reparse if in bytes 1054 } 1055 Validate.isFalse(inputStreamRead, "Input stream already read and parsed, cannot re-read."); 1056 Validate.notNull(stream); 1057 inputStreamRead = true; 1058 return stream; 1059 } 1060 1061 @Override public Document parse() throws IOException { 1062 ControllableInputStream stream = prepareParse(); 1063 Document doc = DataUtil.parseInputStream(stream, charset, url.toExternalForm(), req.parser()); 1064 doc.connection(new HttpConnection(req, this)); // because we're static, don't have the connection obj. // todo - maybe hold in the req? 1065 charset = doc.outputSettings().charset().name(); // update charset from meta-equiv, possibly 1066 safeClose(); 1067 return doc; 1068 } 1069 1070 @Override public StreamParser streamParser() throws IOException { 1071 ControllableInputStream stream = prepareParse(); 1072 String baseUri = url.toExternalForm(); 1073 DataUtil.CharsetDoc charsetDoc = DataUtil.detectCharsetForStreamParser(stream, charset, baseUri, req.parser()); 1074 1075 // set up the stream parser and rig this connection up to the parsed doc: 1076 StreamParser streamer = new StreamParser(req.parser()); 1077 BufferedReader reader = new BufferedReader(new InputStreamReader(charsetDoc.input, charsetDoc.charset)); 1078 streamer.parse(reader, baseUri); // initializes the parse and the document, but does not step() it 1079 streamer.document().connection(new HttpConnection(req, this)); 1080 charset = charsetDoc.charset.name(); 1081 1082 // we don't safeClose() as in parse(); caller must close streamParser to close InputStream stream 1083 return streamer; 1084 } 1085 1086 /** 1087 Reads the bodyStream into byteData. A no-op if already executed. 1088 */ 1089 @Override 1090 public Connection.Response readFully() throws IOException { 1091 Validate.isTrue(executed, "Request must be executed (with .execute(), .get(), or .post() before getting response body"); 1092 if (bodyStream != null && byteData == null) { 1093 Validate.isFalse(inputStreamRead, "Request has already been read (with .parse())"); 1094 try { 1095 byteData = DataUtil.readToByteBuffer(bodyStream, req.maxBodySize()); 1096 } finally { 1097 inputStreamRead = true; 1098 safeClose(); 1099 } 1100 } 1101 return this; 1102 } 1103 1104 /** 1105 Reads the body, but throws an UncheckedIOException if an IOException occurs. 1106 @throws UncheckedIOException if an IOException occurs 1107 */ 1108 private void readByteDataUnchecked() { 1109 try { 1110 readFully(); 1111 } catch (IOException e) { 1112 throw new UncheckedIOException(e); 1113 } 1114 } 1115 1116 @Override 1117 public String readBody() throws IOException { 1118 readFully(); 1119 return body(); 1120 } 1121 1122 @Override 1123 public String body() { 1124 readByteDataUnchecked(); 1125 Validate.notNull(byteData); 1126 // charset gets set from header on execute, and from meta-equiv on parse. parse may not have happened yet 1127 String body = (charset == null ? UTF_8 : Charset.forName(charset)) 1128 .decode(byteData).toString(); 1129 ((Buffer)byteData).rewind(); // cast to avoid covariant return type change in jdk9 1130 return body; 1131 } 1132 1133 @Override 1134 public byte[] bodyAsBytes() { 1135 readByteDataUnchecked(); 1136 Validate.notNull(byteData); 1137 Validate.isTrue(byteData.hasArray()); // we made it, so it should 1138 1139 byte[] array = byteData.array(); 1140 int offset = byteData.arrayOffset(); 1141 int length = byteData.limit(); 1142 1143 if (offset == 0 && length == array.length) { // exact, just return it 1144 return array; 1145 } else { // trim to size 1146 byte[] exactArray = new byte[length]; 1147 System.arraycopy(array, offset, exactArray, 0, length); 1148 return exactArray; 1149 } 1150 } 1151 1152 @Override 1153 @Deprecated 1154 public Connection.Response bufferUp() { 1155 readByteDataUnchecked(); 1156 return this; 1157 } 1158 1159 @Override 1160 public BufferedInputStream bodyStream() { 1161 Validate.isTrue(executed, "Request must be executed (with .execute(), .get(), or .post() before getting response body"); 1162 1163 // if we have read to bytes (via readFully), return those as a stream. 1164 if (byteData != null) { 1165 return new BufferedInputStream( 1166 new ByteArrayInputStream(byteData.array(), 0, byteData.limit()), 1167 DefaultBufferSize); 1168 } 1169 1170 Validate.isFalse(inputStreamRead, "Request has already been read"); 1171 Validate.notNull(bodyStream); 1172 inputStreamRead = true; 1173 return bodyStream.inputStream(); 1174 } 1175 1176 /** 1177 * Call on completion of stream read, to close the body (or error) stream. The connection.disconnect allows 1178 * keep-alives to work (as the underlying connection is actually held open, despite the name). 1179 */ 1180 private void safeClose() { 1181 if (bodyStream != null) { 1182 try { 1183 bodyStream.close(); 1184 } catch (IOException e) { 1185 // no-op 1186 } finally { 1187 bodyStream = null; 1188 } 1189 } 1190 1191 if (executor != null) executor.safeClose(); // disconnect 1192 } 1193 1194 Response(HttpConnection.Request request) { 1195 this.req = request; 1196 } 1197 1198 // set up url, method, header, cookies 1199 void prepareResponse(Map<String, List<String>> resHeaders, HttpConnection.@Nullable Response previousResponse) throws IOException { 1200 processResponseHeaders(resHeaders); // includes cookie key/val read during header scan 1201 CookieUtil.storeCookies(req, this, url, resHeaders); // add set cookies to cookie store 1202 1203 if (previousResponse != null) { // was redirected 1204 // map previous response cookies into this response cookies() object 1205 for (Map.Entry<String, String> prevCookie : previousResponse.cookies().entrySet()) { 1206 if (!hasCookie(prevCookie.getKey())) 1207 cookie(prevCookie.getKey(), prevCookie.getValue()); 1208 } 1209 previousResponse.safeClose(); 1210 1211 // enforce too many redirects: 1212 numRedirects = previousResponse.numRedirects + 1; 1213 if (numRedirects >= MAX_REDIRECTS) 1214 throw new IOException(String.format("Too many redirects occurred trying to load URL %s", previousResponse.url())); 1215 } 1216 } 1217 1218 void processResponseHeaders(Map<String, List<String>> resHeaders) { 1219 for (Map.Entry<String, List<String>> entry : resHeaders.entrySet()) { 1220 String name = entry.getKey(); 1221 if (name == null) 1222 continue; // http/1.1 line 1223 1224 List<String> values = entry.getValue(); 1225 for (String value : values) { 1226 addHeader(name, fixHeaderEncoding(value)); 1227 } 1228 } 1229 } 1230 1231 /** 1232 Servers may encode response headers in UTF-8 instead of RFC defined 8859. The JVM decodes the headers (before we see them) as 8859, which can lead to mojibake data. 1233 <p>This method attempts to detect that and re-decode the string as UTF-8.</p> 1234 <p>However on Android, the headers will be decoded as UTF8, so we can detect and pass those directly.</p> 1235 * @param val a header value string that may have been incorrectly decoded as 8859. 1236 * @return a potentially re-decoded string. 1237 */ 1238 @Nullable 1239 static String fixHeaderEncoding(@Nullable String val) { 1240 if (val == null) return val; 1241 // If we can't encode the string as 8859, then it couldn't have been decoded as 8859 1242 if (!StandardCharsets.ISO_8859_1.newEncoder().canEncode(val)) 1243 return val; 1244 byte[] bytes = val.getBytes(ISO_8859_1); 1245 if (looksLikeUtf8(bytes)) 1246 return new String(bytes, UTF_8); 1247 else 1248 return val; 1249 } 1250 1251 private static boolean looksLikeUtf8(byte[] input) { 1252 int i = 0; 1253 // BOM: 1254 if (input.length >= 3 1255 && (input[0] & 0xFF) == 0xEF 1256 && (input[1] & 0xFF) == 0xBB 1257 && (input[2] & 0xFF) == 0xBF) { 1258 i = 3; 1259 } 1260 1261 int end; 1262 boolean foundNonAscii = false; 1263 for (int j = input.length; i < j; ++i) { 1264 int o = input[i]; 1265 if ((o & 0x80) == 0) { 1266 continue; // ASCII 1267 } 1268 foundNonAscii = true; 1269 1270 // UTF-8 leading: 1271 if ((o & 0xE0) == 0xC0) { 1272 end = i + 1; 1273 } else if ((o & 0xF0) == 0xE0) { 1274 end = i + 2; 1275 } else if ((o & 0xF8) == 0xF0) { 1276 end = i + 3; 1277 } else { 1278 return false; 1279 } 1280 1281 if (end >= input.length) 1282 return false; 1283 1284 while (i < end) { 1285 i++; 1286 o = input[i]; 1287 if ((o & 0xC0) != 0x80) { 1288 return false; 1289 } 1290 } 1291 } 1292 return foundNonAscii; 1293 } 1294 1295 private static void setOutputContentType(final HttpConnection.Request req) { 1296 final String contentType = req.header(CONTENT_TYPE); 1297 String bound = contentType != null && contentType.contains(MULTIPART_FORM_DATA) ? req.mimeBoundary : null; 1298 if (contentType != null) { 1299 // no-op; don't add content type as already set (e.g. for requestBody()) 1300 // todo - if content type already set, we could add charset 1301 1302 // if user has set content type to multipart/form-data, auto add boundary. 1303 if(contentType.contains(MULTIPART_FORM_DATA) && !contentType.contains("boundary")) { 1304 bound = DataUtil.mimeBoundary(); 1305 req.header(CONTENT_TYPE, MULTIPART_FORM_DATA + "; boundary=" + bound); 1306 } 1307 1308 } 1309 else if (needsMultipart(req)) { 1310 bound = DataUtil.mimeBoundary(); 1311 req.header(CONTENT_TYPE, MULTIPART_FORM_DATA + "; boundary=" + bound); 1312 } else { 1313 req.header(CONTENT_TYPE, FORM_URL_ENCODED + "; charset=" + req.postDataCharset()); 1314 } 1315 req.mimeBoundary = bound; 1316 } 1317 1318 static void writePost(final HttpConnection.Request req, final OutputStream outputStream) throws IOException { 1319 try (OutputStreamWriter osw = new OutputStreamWriter(outputStream, req.postDataCharset()); 1320 BufferedWriter w = new BufferedWriter(osw)) { 1321 implWritePost(req, w, outputStream); 1322 } 1323 } 1324 1325 private static void implWritePost(final HttpConnection.Request req, final BufferedWriter w, final OutputStream outputStream) throws IOException { 1326 final Collection<Connection.KeyVal> data = req.data(); 1327 final String boundary = req.mimeBoundary; 1328 1329 if (boundary != null) { // a multipart post 1330 for (Connection.KeyVal keyVal : data) { 1331 w.write("--"); 1332 w.write(boundary); 1333 w.write("\r\n"); 1334 w.write("Content-Disposition: form-data; name=\""); 1335 w.write(encodeMimeName(keyVal.key())); // encodes " to %22 1336 w.write("\""); 1337 final InputStream input = keyVal.inputStream(); 1338 if (input != null) { 1339 w.write("; filename=\""); 1340 w.write(encodeMimeName(keyVal.value())); 1341 w.write("\"\r\nContent-Type: "); 1342 String contentType = keyVal.contentType(); 1343 if (contentType != null) 1344 validateMimeContentType(contentType); 1345 w.write(contentType != null ? contentType : DefaultUploadType); 1346 w.write("\r\n\r\n"); 1347 w.flush(); 1348 DataUtil.crossStreams(input, outputStream); 1349 outputStream.flush(); 1350 } else { 1351 w.write("\r\n\r\n"); 1352 w.write(keyVal.value()); 1353 } 1354 w.write("\r\n"); 1355 } 1356 w.write("--"); 1357 w.write(boundary); 1358 w.write("--"); 1359 } else if (req.body != null) { // a single body (bytes or plain text); data will be in query string 1360 if (req.body instanceof String) { 1361 w.write((String) req.body); 1362 } else if (req.body instanceof InputStream) { 1363 DataUtil.crossStreams((InputStream) req.body, outputStream); 1364 outputStream.flush(); 1365 } else { 1366 throw new IllegalStateException(); 1367 } 1368 } else { // regular form data (application/x-www-form-urlencoded) 1369 boolean first = true; 1370 for (Connection.KeyVal keyVal : data) { 1371 if (!first) w.append('&'); 1372 else first = false; 1373 1374 w.write(URLEncoder.encode(keyVal.key(), req.postDataCharset())); 1375 w.write('='); 1376 w.write(URLEncoder.encode(keyVal.value(), req.postDataCharset())); 1377 } 1378 } 1379 } 1380 1381 // for get url reqs, serialise the data map into the url 1382 private static void serialiseRequestUrl(Connection.Request req) throws IOException { 1383 UrlBuilder in = new UrlBuilder(req.url()); 1384 1385 for (Connection.KeyVal keyVal : req.data()) { 1386 Validate.isFalse(keyVal.hasInputStream(), "InputStream data not supported in URL query string."); 1387 in.appendKeyVal(keyVal); 1388 } 1389 req.url(in.build()); 1390 req.data().clear(); // moved into url as get params 1391 } 1392 } 1393 1394 private static boolean needsMultipart(Connection.Request req) { 1395 // multipart mode, for files. add the header if we see something with an inputstream, and return a non-null boundary 1396 for (Connection.KeyVal keyVal : req.data()) { 1397 if (keyVal.hasInputStream()) 1398 return true; 1399 } 1400 return false; 1401 } 1402 1403 public static class KeyVal implements Connection.KeyVal { 1404 private String key; 1405 private String value; 1406 private @Nullable InputStream stream; 1407 private @Nullable String contentType; 1408 1409 public static KeyVal create(String key, String value) { 1410 return new KeyVal(key, value); 1411 } 1412 1413 public static KeyVal create(String key, String filename, InputStream stream) { 1414 return new KeyVal(key, filename) 1415 .inputStream(stream); 1416 } 1417 1418 private KeyVal(String key, String value) { 1419 Validate.notEmptyParam(key, "key"); 1420 Validate.notNullParam(value, "value"); 1421 this.key = key; 1422 this.value = value; 1423 } 1424 1425 @Override 1426 public KeyVal key(String key) { 1427 Validate.notEmptyParam(key, "key"); 1428 this.key = key; 1429 return this; 1430 } 1431 1432 @Override 1433 public String key() { 1434 return key; 1435 } 1436 1437 @Override 1438 public KeyVal value(String value) { 1439 Validate.notNullParam(value, "value"); 1440 this.value = value; 1441 return this; 1442 } 1443 1444 @Override 1445 public String value() { 1446 return value; 1447 } 1448 1449 @Override 1450 public KeyVal inputStream(InputStream inputStream) { 1451 Validate.notNullParam(inputStream, "inputStream"); 1452 this.stream = inputStream; 1453 return this; 1454 } 1455 1456 @Override @Nullable 1457 public InputStream inputStream() { 1458 return stream; 1459 } 1460 1461 @Override 1462 public boolean hasInputStream() { 1463 return stream != null; 1464 } 1465 1466 @Override 1467 public Connection.KeyVal contentType(String contentType) { 1468 validateMimeContentType(contentType); 1469 this.contentType = contentType; 1470 return this; 1471 } 1472 1473 @Override @Nullable 1474 public String contentType() { 1475 return contentType; 1476 } 1477 1478 @Override 1479 public String toString() { 1480 return key + "=" + value; 1481 } 1482 } 1483}