1<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Component\HttpFoundation;
13
14/**
15 * Response represents an HTTP response in JSON format.
16 *
17 * Note that this class does not force the returned JSON content to be an
18 * object. It is however recommended that you do return an object as it
19 * protects yourself against XSSI and JSON-JavaScript Hijacking.
20 *
21 * @see https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/AJAX_Security_Cheat_Sheet.md#always-return-json-with-an-object-on-the-outside
22 *
23 * @author Igor Wiedler <igor@wiedler.ch>
24 */
25class JsonResponse extends Response
26{
27    protected $data;
28    protected $callback;
29
30    // Encode <, >, ', &, and " characters in the JSON, making it also safe to be embedded into HTML.
31    // 15 === JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT
32    const DEFAULT_ENCODING_OPTIONS = 15;
33
34    protected $encodingOptions = self::DEFAULT_ENCODING_OPTIONS;
35
36    /**
37     * @param mixed $data    The response data
38     * @param int   $status  The response status code
39     * @param array $headers An array of response headers
40     * @param bool  $json    If the data is already a JSON string
41     */
42    public function __construct($data = null, $status = 200, $headers = [], $json = false)
43    {
44        parent::__construct('', $status, $headers);
45
46        if (null === $data) {
47            $data = new \ArrayObject();
48        }
49
50        $json ? $this->setJson($data) : $this->setData($data);
51    }
52
53    /**
54     * Factory method for chainability.
55     *
56     * Example:
57     *
58     *     return JsonResponse::create(['key' => 'value'])
59     *         ->setSharedMaxAge(300);
60     *
61     * @param mixed $data    The JSON response data
62     * @param int   $status  The response status code
63     * @param array $headers An array of response headers
64     *
65     * @return static
66     */
67    public static function create($data = null, $status = 200, $headers = [])
68    {
69        return new static($data, $status, $headers);
70    }
71
72    /**
73     * Factory method for chainability.
74     *
75     * Example:
76     *
77     *     return JsonResponse::fromJsonString('{"key": "value"}')
78     *         ->setSharedMaxAge(300);
79     *
80     * @param string|null $data    The JSON response string
81     * @param int         $status  The response status code
82     * @param array       $headers An array of response headers
83     *
84     * @return static
85     */
86    public static function fromJsonString($data = null, $status = 200, $headers = [])
87    {
88        return new static($data, $status, $headers, true);
89    }
90
91    /**
92     * Sets the JSONP callback.
93     *
94     * @param string|null $callback The JSONP callback or null to use none
95     *
96     * @return $this
97     *
98     * @throws \InvalidArgumentException When the callback name is not valid
99     */
100    public function setCallback($callback = null)
101    {
102        if (null !== $callback) {
103            // partially taken from https://geekality.net/2011/08/03/valid-javascript-identifier/
104            // partially taken from https://github.com/willdurand/JsonpCallbackValidator
105            //      JsonpCallbackValidator is released under the MIT License. See https://github.com/willdurand/JsonpCallbackValidator/blob/v1.1.0/LICENSE for details.
106            //      (c) William Durand <william.durand1@gmail.com>
107            $pattern = '/^[$_\p{L}][$_\p{L}\p{Mn}\p{Mc}\p{Nd}\p{Pc}\x{200C}\x{200D}]*(?:\[(?:"(?:\\\.|[^"\\\])*"|\'(?:\\\.|[^\'\\\])*\'|\d+)\])*?$/u';
108            $reserved = [
109                'break', 'do', 'instanceof', 'typeof', 'case', 'else', 'new', 'var', 'catch', 'finally', 'return', 'void', 'continue', 'for', 'switch', 'while',
110                'debugger', 'function', 'this', 'with', 'default', 'if', 'throw', 'delete', 'in', 'try', 'class', 'enum', 'extends', 'super',  'const', 'export',
111                'import', 'implements', 'let', 'private', 'public', 'yield', 'interface', 'package', 'protected', 'static', 'null', 'true', 'false',
112            ];
113            $parts = explode('.', $callback);
114            foreach ($parts as $part) {
115                if (!preg_match($pattern, $part) || \in_array($part, $reserved, true)) {
116                    throw new \InvalidArgumentException('The callback name is not valid.');
117                }
118            }
119        }
120
121        $this->callback = $callback;
122
123        return $this->update();
124    }
125
126    /**
127     * Sets a raw string containing a JSON document to be sent.
128     *
129     * @param string $json
130     *
131     * @return $this
132     *
133     * @throws \InvalidArgumentException
134     */
135    public function setJson($json)
136    {
137        $this->data = $json;
138
139        return $this->update();
140    }
141
142    /**
143     * Sets the data to be sent as JSON.
144     *
145     * @param mixed $data
146     *
147     * @return $this
148     *
149     * @throws \InvalidArgumentException
150     */
151    public function setData($data = [])
152    {
153        if (\defined('HHVM_VERSION')) {
154            // HHVM does not trigger any warnings and let exceptions
155            // thrown from a JsonSerializable object pass through.
156            // If only PHP did the same...
157            $data = json_encode($data, $this->encodingOptions);
158        } else {
159            if (!interface_exists('JsonSerializable', false)) {
160                set_error_handler(function () { return false; });
161                try {
162                    $data = @json_encode($data, $this->encodingOptions);
163                } finally {
164                    restore_error_handler();
165                }
166            } else {
167                try {
168                    $data = json_encode($data, $this->encodingOptions);
169                } catch (\Exception $e) {
170                    if ('Exception' === \get_class($e) && 0 === strpos($e->getMessage(), 'Failed calling ')) {
171                        throw $e->getPrevious() ?: $e;
172                    }
173                    throw $e;
174                }
175
176                if (\PHP_VERSION_ID >= 70300 && (JSON_THROW_ON_ERROR & $this->encodingOptions)) {
177                    return $this->setJson($data);
178                }
179            }
180        }
181
182        if (JSON_ERROR_NONE !== json_last_error()) {
183            throw new \InvalidArgumentException(json_last_error_msg());
184        }
185
186        return $this->setJson($data);
187    }
188
189    /**
190     * Returns options used while encoding data to JSON.
191     *
192     * @return int
193     */
194    public function getEncodingOptions()
195    {
196        return $this->encodingOptions;
197    }
198
199    /**
200     * Sets options used while encoding data to JSON.
201     *
202     * @param int $encodingOptions
203     *
204     * @return $this
205     */
206    public function setEncodingOptions($encodingOptions)
207    {
208        $this->encodingOptions = (int) $encodingOptions;
209
210        return $this->setData(json_decode($this->data));
211    }
212
213    /**
214     * Updates the content and headers according to the JSON data and callback.
215     *
216     * @return $this
217     */
218    protected function update()
219    {
220        if (null !== $this->callback) {
221            // Not using application/javascript for compatibility reasons with older browsers.
222            $this->headers->set('Content-Type', 'text/javascript');
223
224            return $this->setContent(sprintf('/**/%s(%s);', $this->callback, $this->data));
225        }
226
227        // Only set the header when there is none or when it equals 'text/javascript' (from a previous update with callback)
228        // in order to not overwrite a custom definition.
229        if (!$this->headers->has('Content-Type') || 'text/javascript' === $this->headers->get('Content-Type')) {
230            $this->headers->set('Content-Type', 'application/json');
231        }
232
233        return $this->setContent($this->data);
234    }
235}
236