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}