1<?php
2
3namespace GuzzleHttp;
4
5use GuzzleHttp\Exception\BadResponseException;
6use GuzzleHttp\Exception\TooManyRedirectsException;
7use GuzzleHttp\Promise\PromiseInterface;
8use Psr\Http\Message\RequestInterface;
9use Psr\Http\Message\ResponseInterface;
10use Psr\Http\Message\UriInterface;
11
12/**
13 * Request redirect middleware.
14 *
15 * Apply this middleware like other middleware using
16 * {@see \GuzzleHttp\Middleware::redirect()}.
17 *
18 * @final
19 */
20class RedirectMiddleware
21{
22    public const HISTORY_HEADER = 'X-Guzzle-Redirect-History';
23
24    public const STATUS_HISTORY_HEADER = 'X-Guzzle-Redirect-Status-History';
25
26    /**
27     * @var array
28     */
29    public static $defaultSettings = [
30        'max' => 5,
31        'protocols' => ['http', 'https'],
32        'strict' => false,
33        'referer' => false,
34        'track_redirects' => false,
35    ];
36
37    /**
38     * @var callable(RequestInterface, array): PromiseInterface
39     */
40    private $nextHandler;
41
42    /**
43     * @param callable(RequestInterface, array): PromiseInterface $nextHandler Next handler to invoke.
44     */
45    public function __construct(callable $nextHandler)
46    {
47        $this->nextHandler = $nextHandler;
48    }
49
50    public function __invoke(RequestInterface $request, array $options): PromiseInterface
51    {
52        $fn = $this->nextHandler;
53
54        if (empty($options['allow_redirects'])) {
55            return $fn($request, $options);
56        }
57
58        if ($options['allow_redirects'] === true) {
59            $options['allow_redirects'] = self::$defaultSettings;
60        } elseif (!\is_array($options['allow_redirects'])) {
61            throw new \InvalidArgumentException('allow_redirects must be true, false, or array');
62        } else {
63            // Merge the default settings with the provided settings
64            $options['allow_redirects'] += self::$defaultSettings;
65        }
66
67        if (empty($options['allow_redirects']['max'])) {
68            return $fn($request, $options);
69        }
70
71        return $fn($request, $options)
72            ->then(function (ResponseInterface $response) use ($request, $options) {
73                return $this->checkRedirect($request, $options, $response);
74            });
75    }
76
77    /**
78     * @return ResponseInterface|PromiseInterface
79     */
80    public function checkRedirect(RequestInterface $request, array $options, ResponseInterface $response)
81    {
82        if (\strpos((string) $response->getStatusCode(), '3') !== 0
83            || !$response->hasHeader('Location')
84        ) {
85            return $response;
86        }
87
88        $this->guardMax($request, $response, $options);
89        $nextRequest = $this->modifyRequest($request, $options, $response);
90
91        // If authorization is handled by curl, unset it if URI is cross-origin.
92        if (Psr7\UriComparator::isCrossOrigin($request->getUri(), $nextRequest->getUri()) && defined('\CURLOPT_HTTPAUTH')) {
93            unset(
94                $options['curl'][\CURLOPT_HTTPAUTH],
95                $options['curl'][\CURLOPT_USERPWD]
96            );
97        }
98
99        if (isset($options['allow_redirects']['on_redirect'])) {
100            ($options['allow_redirects']['on_redirect'])(
101                $request,
102                $response,
103                $nextRequest->getUri()
104            );
105        }
106
107        $promise = $this($nextRequest, $options);
108
109        // Add headers to be able to track history of redirects.
110        if (!empty($options['allow_redirects']['track_redirects'])) {
111            return $this->withTracking(
112                $promise,
113                (string) $nextRequest->getUri(),
114                $response->getStatusCode()
115            );
116        }
117
118        return $promise;
119    }
120
121    /**
122     * Enable tracking on promise.
123     */
124    private function withTracking(PromiseInterface $promise, string $uri, int $statusCode): PromiseInterface
125    {
126        return $promise->then(
127            static function (ResponseInterface $response) use ($uri, $statusCode) {
128                // Note that we are pushing to the front of the list as this
129                // would be an earlier response than what is currently present
130                // in the history header.
131                $historyHeader = $response->getHeader(self::HISTORY_HEADER);
132                $statusHeader = $response->getHeader(self::STATUS_HISTORY_HEADER);
133                \array_unshift($historyHeader, $uri);
134                \array_unshift($statusHeader, (string) $statusCode);
135
136                return $response->withHeader(self::HISTORY_HEADER, $historyHeader)
137                                ->withHeader(self::STATUS_HISTORY_HEADER, $statusHeader);
138            }
139        );
140    }
141
142    /**
143     * Check for too many redirects.
144     *
145     * @throws TooManyRedirectsException Too many redirects.
146     */
147    private function guardMax(RequestInterface $request, ResponseInterface $response, array &$options): void
148    {
149        $current = $options['__redirect_count']
150            ?? 0;
151        $options['__redirect_count'] = $current + 1;
152        $max = $options['allow_redirects']['max'];
153
154        if ($options['__redirect_count'] > $max) {
155            throw new TooManyRedirectsException("Will not follow more than {$max} redirects", $request, $response);
156        }
157    }
158
159    public function modifyRequest(RequestInterface $request, array $options, ResponseInterface $response): RequestInterface
160    {
161        // Request modifications to apply.
162        $modify = [];
163        $protocols = $options['allow_redirects']['protocols'];
164
165        // Use a GET request if this is an entity enclosing request and we are
166        // not forcing RFC compliance, but rather emulating what all browsers
167        // would do.
168        $statusCode = $response->getStatusCode();
169        if ($statusCode == 303
170            || ($statusCode <= 302 && !$options['allow_redirects']['strict'])
171        ) {
172            $safeMethods = ['GET', 'HEAD', 'OPTIONS'];
173            $requestMethod = $request->getMethod();
174
175            $modify['method'] = in_array($requestMethod, $safeMethods) ? $requestMethod : 'GET';
176            $modify['body'] = '';
177        }
178
179        $uri = self::redirectUri($request, $response, $protocols);
180        if (isset($options['idn_conversion']) && ($options['idn_conversion'] !== false)) {
181            $idnOptions = ($options['idn_conversion'] === true) ? \IDNA_DEFAULT : $options['idn_conversion'];
182            $uri = Utils::idnUriConvert($uri, $idnOptions);
183        }
184
185        $modify['uri'] = $uri;
186        Psr7\Message::rewindBody($request);
187
188        // Add the Referer header if it is told to do so and only
189        // add the header if we are not redirecting from https to http.
190        if ($options['allow_redirects']['referer']
191            && $modify['uri']->getScheme() === $request->getUri()->getScheme()
192        ) {
193            $uri = $request->getUri()->withUserInfo('');
194            $modify['set_headers']['Referer'] = (string) $uri;
195        } else {
196            $modify['remove_headers'][] = 'Referer';
197        }
198
199        // Remove Authorization and Cookie headers if URI is cross-origin.
200        if (Psr7\UriComparator::isCrossOrigin($request->getUri(), $modify['uri'])) {
201            $modify['remove_headers'][] = 'Authorization';
202            $modify['remove_headers'][] = 'Cookie';
203        }
204
205        return Psr7\Utils::modifyRequest($request, $modify);
206    }
207
208    /**
209     * Set the appropriate URL on the request based on the location header.
210     */
211    private static function redirectUri(
212        RequestInterface $request,
213        ResponseInterface $response,
214        array $protocols
215    ): UriInterface {
216        $location = Psr7\UriResolver::resolve(
217            $request->getUri(),
218            new Psr7\Uri($response->getHeaderLine('Location'))
219        );
220
221        // Ensure that the redirect URI is allowed based on the protocols.
222        if (!\in_array($location->getScheme(), $protocols)) {
223            throw new BadResponseException(\sprintf('Redirect URI, %s, does not use one of the allowed redirect protocols: %s', $location, \implode(', ', $protocols)), $request, $response);
224        }
225
226        return $location;
227    }
228}
229