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