Completed
Push — master ( 534cad...c9dd2f )
by Carl
01:01
created

Turbolinks.php (3 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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));
0 ignored issues
show
It seems like $request->headers->get(s...:ORIGIN_REQUEST_HEADER) targeting Symfony\Component\HttpFoundation\HeaderBag::get() can also be of type array or null; however, Symfony\Component\HttpFoundation\HeaderBag::set() does only seem to accept string|array<integer,string>, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
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));
0 ignored issues
show
It seems like $request->headers->get(s...:ORIGIN_REQUEST_HEADER) targeting Symfony\Component\HttpFoundation\HeaderBag::get() can also be of type array or null; however, Helthe\Component\Turboli...bolinks::getUrlOrigin() does only seem to accept string, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
153
        $responseOrigin = $this->getUrlOrigin($response->headers->get(self::ORIGIN_RESPONSE_HEADER));
0 ignored issues
show
It seems like $response->headers->get(...ORIGIN_RESPONSE_HEADER) targeting Symfony\Component\HttpFoundation\HeaderBag::get() can also be of type array or null; however, Helthe\Component\Turboli...bolinks::getUrlOrigin() does only seem to accept string, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
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