Completed
Push — master ( 015661...534cad )
by Carl
04:29
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 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<integer,string> 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<integer,string> 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->set('Content-Type', $request->getMimeType('js'));
234
        $response->setStatusCode(200);
235
        $response->setContent($body);
236
    }
237
238
    /**
239
     * @param Request  $request
240
     * @param Response $response
241
     */
242
    private function setTurbolinksLocationHeaderFromSession(Request $request, Response $response)
243
    {
244
        $session = $request->getSession();
245
246
        // set 'Turbolinks-Location' header
247
        if ($session && $session->has(self::LOCATION_SESSION_ATTR_NAME)) {
248
            $location = $session->remove(self::LOCATION_SESSION_ATTR_NAME);
249
            $response->headers->add(
250
                array(self::REDIRECT_RESPONSE_HEADER => $location)
251
            );
252
        }
253
    }
254
255
    /**
256
     * @param  ResponseHeaderBag $headers
257
     *
258
     * @return array Turbolinks options
259
     */
260
    public function extractTurbolinksHeaders($headers)
261
    {
262
        $options = array();
263
        $optionsMap = self::$turbolinksOptionsMap;
264
265
        foreach ($headers as $key => $value) {
266
            if ($result = array_search($key, array_map('strtolower', $optionsMap))) {
267
                if (is_array($value) && count($value) === 1 && array_key_exists(0, $value)) {
268
                    $value = $value[0];
269
                }
270
                $options[$result] = $value;
271
                if (is_array($headers)) {
272
                    unset($headers[$key]);
273
                } elseif ($headers instanceof ResponseHeaderBag) {
274
                    $headers->remove($key);
275
                }
276
            }
277
        }
278
279
        return $options;
280
    }
281
282
    /**
283
     * Return HTTP headers equivalent of the given Turbolinks options.
284
     * E.G. `['turbolinks'  => 'advance']` becomes `['X-Turbolinks' => 'advance']`
285
     *
286
     * @param  array $options
287
     *
288
     * @return array
289
     */
290
    public function convertTurbolinksOptions($options = array())
291
    {
292
        $headers = array();
293
        $optionsMap = self::$turbolinksOptionsMap;
294
295
        foreach ($options as $key => $value) {
296
            if (in_array($key, array_keys($optionsMap))) {
297
                $headers[$optionsMap[$key]] = $value;
298
            }
299
        }
300
301
        return $headers;
302
    }
303
}
304