Turbolinks::canHandleRedirect()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 6
rs 10
c 0
b 0
f 0
cc 3
nc 3
nop 1
1
<?php
2
3
/*
4
 * This file is part of the Helthe Turbolinks package.
5
 *
6
 * (c) Carl Alexander <[email protected]>
7
 * (c) Tortue Torche <[email protected]>
8
 *
9
 * For the full copyright and license information, please view the LICENSE
10
 * file that was distributed with this source code.
11
 */
12
13
namespace Helthe\Component\Turbolinks;
14
15
use Symfony\Component\HttpFoundation\Request;
16
use Symfony\Component\HttpFoundation\Response;
17
use Symfony\Component\HttpFoundation\RedirectResponse;
18
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
19
20
/**
21
 * Turbolinks implements the server-side logic expected by Turbolinks javascript.
22
 *
23
 * @link https://github.com/rails/turbolinks/blob/master/lib/turbolinks.rb
24
 *
25
 * @author Carl Alexander <[email protected]>
26
 */
27
class Turbolinks
28
{
29
    /**
30
     * Request header used for origin validation.
31
     *
32
     * @var string
33
     */
34
    const ORIGIN_REQUEST_HEADER = 'Turbolinks-Referrer';
35
36
    /**
37
     * Response header used for origin validation.
38
     *
39
     * @var string
40
     */
41
    const ORIGIN_RESPONSE_HEADER = 'Location';
42
43
    /**
44
     * Redirect header inserted in the response.
45
     *
46
     * @var string
47
     */
48
    const REDIRECT_RESPONSE_HEADER = 'Turbolinks-Location';
49
50
    /**
51
     * Session attribute name for the redirect location.
52
     *
53
     * @var string
54
     */
55
    const LOCATION_SESSION_ATTR_NAME = 'helthe_turbolinks_location';
56
57
    /**
58
     * @var array
59
     */
60
    public static $turbolinksOptionsMap = array(
61
        // Handles normal redirection with Turbolinks, if not set to `false`.
62
        // Possible values: `null`, `'replace'`, `'advance'` or `false`
63
        'turbolinks' => 'X-Turbolinks',
64
    );
65
66
    /**
67
     * Modifies the HTTP headers and status code of the Response so that it can be
68
     * properly handled by the Turbolinks javascript.
69
     *
70
     * @param Request  $request
71
     * @param Response $response
72
     */
73
    public function decorateResponse(Request $request, Response $response)
74
    {
75
        if ($request->headers->has(self::ORIGIN_REQUEST_HEADER)) {
76
            $request->headers->set('referer', $request->headers->get(self::ORIGIN_REQUEST_HEADER));
77
        }
78
79
        $this->setTurbolinksLocationHeaderFromSession($request, $response);
80
81
        if ($response->isRedirect() && $response->headers->has(self::ORIGIN_RESPONSE_HEADER)) {
82
            $this->redirectTo($request, $response);
83
        }
84
85
        $this->modifyStatusCode($request, $response);
86
    }
87
88
    /**
89
     * @param Request  $request
90
     * @param Response $response
91
     * @return \Symfony\Component\HttpFoundation\Response
92
     */
93
    public function redirectTo($request, $response)
94
    {
95
        $turbolinks = $this->extractTurbolinksOptions($response->headers);
96
97
        if (
98
            $turbolinks !== false &&
99
            $request->isXmlHttpRequest() && ! $request->isMethod('GET')
100
        ) {
101
            $location = $response->headers->get(self::ORIGIN_RESPONSE_HEADER);
102
            $turbolinksContent = $this->visitLocationWithTurbolinks($location, $turbolinks);
103
            $this->performTurbolinksResponse($request, $response, $turbolinksContent);
104
        } elseif ($this->canHandleRedirect($request)) {
105
            $this->storeTurbolinksLocationInSession($request, $response);
106
        }
107
108
        return $response;
109
    }
110
111
    /**
112
     * Checks if the request can handle a Turbolink redirect. You need to have a
113
     * session and a XHR request header to handle a redirect.
114
     *
115
     * @param  Request $request
116
     *
117
     * @return bool
118
     */
119
    private function canHandleRedirect(Request $request)
120
    {
121
        $session = $request->getSession();
122
        return (is_a($session, '\Symfony\Component\HttpFoundation\Session\SessionInterface') ||  is_a($session, '\Illuminate\Contracts\Session\Session')) &&
123
            $request->headers->has(self::ORIGIN_REQUEST_HEADER);
124
    }
125
126
    /**
127
     * Parse the given url into an origin array with the scheme, host and port.
128
     *
129
     * @param  string $url
130
     *
131
     * @return array
132
     */
133
    private function getUrlOrigin($url)
134
    {
135
        return array(
136
            parse_url($url, PHP_URL_SCHEME),
137
            parse_url($url, PHP_URL_HOST),
138
            parse_url($url, PHP_URL_PORT),
139
        );
140
    }
141
142
    /**
143
     * Checks if the request and the response have the same origin.
144
     *
145
     * @param  Request  $request
146
     * @param  Response $response
147
     *
148
     * @return bool
149
     */
150
    private function haveSameOrigin(Request $request, Response $response)
151
    {
152
        $requestOrigin = $this->getUrlOrigin($request->headers->get(self::ORIGIN_REQUEST_HEADER));
153
        $responseOrigin = $this->getUrlOrigin($response->headers->get(self::ORIGIN_RESPONSE_HEADER));
154
155
        return $requestOrigin == $responseOrigin;
156
    }
157
158
    /**
159
     * Modifies the response status code. Checks for cross domain redirects and
160
     * blocks them.
161
     *
162
     * @param Request  $request
163
     * @param Response $response
164
     */
165
    private function modifyStatusCode(Request $request, Response $response)
166
    {
167
        if ($request->headers->has(self::ORIGIN_REQUEST_HEADER)
168
            && $response->headers->has(self::ORIGIN_RESPONSE_HEADER)
169
            && !$this->haveSameOrigin($request, $response)
170
        ) {
171
            $response->setStatusCode(Response::HTTP_FORBIDDEN);
172
        }
173
    }
174
175
    /**
176
     * @param  ResponseHeaderBag $headers
177
     * @return mixed
178
     */
179
    private function extractTurbolinksOptions($headers)
180
    {
181
        $options = $this->extractTurbolinksHeaders($headers);
182
183
        // Equivalent of the `array_pull()` Laravel helper:
184
        //   $turbolinks = array_pull($options, 'turbolinks');
185
        // See: http://laravel.com/docs/5.1/helpers#method-array-pull
186
        $turbolinks = null;
187
        if (isset($options['turbolinks'])) {
188
            $turbolinks = $options['turbolinks'];
189
            unset($options['turbolinks']);
190
        }
191
192
        return $turbolinks;
193
    }
194
195
    private function visitLocationWithTurbolinks($location, $action)
196
    {
197
        $visitOptions = array(
198
          'action' => is_string($action) && $action === "advance" ? $action : "replace"
199
        );
200
201
        $script = array();
202
        $script[] = "Turbolinks.clearCache();";
203
        $script[] = "Turbolinks.visit(".json_encode($location, JSON_UNESCAPED_SLASHES).", ".json_encode($visitOptions).");";
204
205
        return implode(PHP_EOL, $script);
206
    }
207
208
    /**
209
     * @param Request  $request
210
     * @param Response $response
211
     */
212
    private function storeTurbolinksLocationInSession(Request $request, Response $response)
213
    {
214
        // Stores the return value (the redirect target url) to persist through to the redirect
215
        // request, where it will be used to set the Turbolinks-Location response header. The
216
        // Turbolinks script will detect the header and use replaceState to reflect the redirected
217
        // url.
218
        $session = $request->getSession();
219
        if ($session) {
220
            $location = $response->headers->get(self::ORIGIN_RESPONSE_HEADER);
221
            $setMethod = method_exists($session, 'put') ? 'put' : 'set';
222
            $session->$setMethod(self::LOCATION_SESSION_ATTR_NAME, $location);
223
        }
224
    }
225
226
    /**
227
     * @param Request  $request
228
     * @param Response $response
229
     * @param string   $body Content of the response
230
     */
231
    private function performTurbolinksResponse(Request $request, Response $response, $body)
232
    {
233
        $response->headers->remove('Location');
234
        $response->headers->set('Content-Type', $request->getMimeType('js'));
235
        $response->setStatusCode(200);
236
        $response->setContent($body);
237
    }
238
239
    /**
240
     * @param Request  $request
241
     * @param Response $response
242
     */
243
    private function setTurbolinksLocationHeaderFromSession(Request $request, Response $response)
244
    {
245
        $session = $request->getSession();
246
247
        // set 'Turbolinks-Location' header
248
        if ($session && $session->has(self::LOCATION_SESSION_ATTR_NAME)) {
249
            $location = $session->remove(self::LOCATION_SESSION_ATTR_NAME);
250
            $response->headers->add(
251
                array(self::REDIRECT_RESPONSE_HEADER => $location)
252
            );
253
        }
254
    }
255
256
    /**
257
     * @param  ResponseHeaderBag $headers
258
     *
259
     * @return array Turbolinks options
260
     */
261
    public function extractTurbolinksHeaders($headers)
262
    {
263
        $options = array();
264
        $optionsMap = self::$turbolinksOptionsMap;
265
266
        foreach ($headers as $key => $value) {
267
            if ($result = array_search($key, array_map('strtolower', $optionsMap))) {
268
                if (is_array($value) && count($value) === 1 && array_key_exists(0, $value)) {
269
                    $value = $value[0];
270
                }
271
                $options[$result] = $value;
272
                if (is_array($headers)) {
273
                    unset($headers[$key]);
274
                } elseif ($headers instanceof ResponseHeaderBag) {
275
                    $headers->remove($key);
276
                }
277
            }
278
        }
279
280
        return $options;
281
    }
282
283
    /**
284
     * Return HTTP headers equivalent of the given Turbolinks options.
285
     * E.G. `['turbolinks'  => 'advance']` becomes `['X-Turbolinks' => 'advance']`
286
     *
287
     * @param  array $options
288
     *
289
     * @return array
290
     */
291
    public function convertTurbolinksOptions($options = array())
292
    {
293
        $headers = array();
294
        $optionsMap = self::$turbolinksOptionsMap;
295
296
        foreach ($options as $key => $value) {
297
            if (in_array($key, array_keys($optionsMap))) {
298
                $headers[$optionsMap[$key]] = $value;
299
            }
300
        }
301
302
        return $headers;
303
    }
304
}
305