Response::offsetSet()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 2
b 0
f 0
nc 1
nop 2
dl 0
loc 3
rs 10
1
<?php
2
3
namespace Helix\Site;
4
5
use ArrayAccess;
6
use DateTimeInterface;
7
use Helix\Site;
8
use Throwable;
9
10
/**
11
 * The response.
12
 */
13
class Response implements ArrayAccess
14
{
15
16
    /**
17
     * @var int
18
     */
19
    protected $code = 404;
20
21
    /**
22
     * Associative values and/or enumerated literal headers.
23
     *
24
     * @var array
25
     */
26
    protected $headers = [
27
        'Accept-Ranges' => 'none',
28
        'Cache-Control' => 'no-store'
29
    ];
30
31
    /**
32
     * `X-Response-Id`, also used in logging errors.
33
     *
34
     * @var string
35
     */
36
    protected $id;
37
38
    /**
39
     * @var Request
40
     */
41
    protected $request;
42
43
    /**
44
     * @var Site
45
     */
46
    protected $site;
47
48
    /**
49
     * Last modification time. Ignored when zero.
50
     *
51
     * @var int
52
     */
53
    protected $timestamp = 0;
54
55
    /**
56
     * @param Site $site
57
     */
58
    public function __construct(Site $site)
59
    {
60
        $this->site = $site;
61
        $this->request = $site->getRequest();
62
        $this->id = uniqid();
63
        if (isset($this->request['X-Request-Id'])) {
64
            $this['X-Request-Id'] = $this->request['X-Request-Id'];
65
        }
66
        ob_end_clean();
67
        ob_implicit_flush(); // flush() non-empty output.
68
        header_register_callback(fn() => $this->_onRender());
69
        register_shutdown_function(fn() => $this->_onShutdown());
70
    }
71
72
    /**
73
     * Injects headers before content is put in PHP's SAPI write buffer.
74
     *
75
     * @internal
76
     */
77
    protected function _onRender(): void
78
    {
79
        header_remove('X-Powered-By');
80
        $this['X-Response-Id'] = $this->id;
81
        ksort($this->headers);
82
        foreach ($this->headers as $key => $value) {
83
            if (is_string($key)) {
84
                $value = "{$key}: {$value}";
85
            }
86
            header($value);
87
        }
88
        if (!$this->isModified()) {
89
            $this->setCode(304);
90
        }
91
        http_response_code($this->code);
92
        if ($this->isEmpty()) {
93
            exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
94
        }
95
    }
96
97
    /**
98
     * Renders an error if nothing was written to the SAPI yet.
99
     * Logs the response to the SAPI handler if the site is in dev mode.
100
     *
101
     * @internal
102
     */
103
    protected function _onShutdown(): void
104
    {
105
        if ($this->site->isDev()) {
106
            $ip = $this->request->getClient();
107
            $method = $this->request->getMethod();
108
            $path = $this->request->getPath();
109
            $line = "{$ip} [{$this->code}]: {$method} {$path}";
110
            error_log($line, 4);
111
        }
112
        if (!headers_sent()) {
113
            $this->error_exit();
114
        }
115
    }
116
117
    /**
118
     * Renders an error and exits.
119
     *
120
     * @param null|Throwable $error Defaults to an {@link HttpError} with the current response code.
121
     */
122
    public function error_exit(Throwable $error = null): void
123
    {
124
        $error = $error ?? new HttpError($this->code);
125
        $code = $error instanceof HttpError ? $error->getCode() : 500;
126
        $this->setCode($code);
127
        $template = __DIR__ . '/error.phtml';
128
        if (file_exists("view/{$code}.phtml")) {
129
            $template = "view/{$code}.phtml";
130
        } elseif (file_exists('view/error.phtml')) {
131
            $template = 'view/error.phtml';
132
        }
133
        $this->view_exit(new View($template, [
134
            'site' => $this->site,
135
            'error' => $error
136
        ]));
137
        exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
138
    }
139
140
    /**
141
     * Outputs a file (or requested range) and exits.
142
     *
143
     * @param string $path
144
     * @param bool $download
145
     */
146
    public function file_exit(string $path, bool $download = false): void
147
    {
148
        clearstatcache(true, $path);
149
        if (!file_exists($path)) {
150
            $this->setCode(404)->error_exit();
151
        }
152
        if (!is_file($path) or !is_readable($path)) {
153
            $this->setCode(403)->error_exit();
154
        }
155
        $fh = fopen($path, 'rb');
156
        flock($fh, LOCK_SH);
157
        $size = filesize($path);
158
        $this->setTimestamp(filemtime($path));
159
        $this['ETag'] = $eTag = (string)filemtime($path);
160
        $this['Accept-Ranges'] = 'bytes';
161
        if ($download) {
162
            $this['Content-Disposition'] = sprintf('attachment; filename="%s"', basename($path));
163
        }
164
        $range = $this->request['Range'];
165
        $ifRange = trim($this->request['If-Range'], '"');
0 ignored issues
show
Bug introduced by
It seems like $this->request['If-Range'] can also be of type null; however, parameter $string of trim() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

165
        $ifRange = trim(/** @scrutinizer ignore-type */ $this->request['If-Range'], '"');
Loading history...
166
        if (!$range or ($ifRange and $ifRange !== $eTag and strtotime($ifRange) !== $this->timestamp)) {
167
            $this->setCode(200);
168
            $this['Content-Length'] = $size;
169
            $this['Content-Type'] = mime_content_type($path);
170
            fpassthru($fh);
171
            flush();
172
            exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
173
        }
174
        if (preg_match('/^bytes=(?<start>\d+)?-(?<stop>\d+)?$/', $range, $bytes, PREG_UNMATCHED_AS_NULL)) {
175
            // maximum byte offset = file length - 1
176
            $max = $size - 1;
177
            // explicit start byte, or convert a negative offset. "-0" is illegal.
178
            $start = $bytes['start'] ?? (isset($bytes['stop']) ? $size - $bytes['stop'] : 0);
179
            // explicit stop byte, or maximum due to negative offset
180
            $stop = isset($bytes['start']) ? $bytes['stop'] ?? $max : $max;
181
            if (0 <= $start and $start <= $stop and $stop <= $max) { // the range is valid
182
                $this->setCode(206); // partial content
183
                $length = ($stop - $start) + 1;
184
                $this['Content-Length'] = $length;
185
                $this['Content-Range'] = "bytes {$start}-{$stop}/{$size}";
186
                fseek($fh, $start);
187
                if ($stop === $max) {
188
                    fpassthru($fh);
189
                } else {
190
                    echo fread($fh, $length);
191
                }
192
                flush();
193
                exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
194
            }
195
        }
196
        $this['Content-Range'] = "bytes */{$size}";
197
        $this->setCode(416)->error_exit();
198
    }
199
200
    /**
201
     * @return int
202
     */
203
    final public function getCode(): int
204
    {
205
        return $this->code;
206
    }
207
208
    /**
209
     * @return string
210
     */
211
    final public function getId(): string
212
    {
213
        return $this->id;
214
    }
215
216
    /**
217
     * @return int
218
     */
219
    public function getTimestamp(): int
220
    {
221
        return $this->timestamp;
222
    }
223
224
    /**
225
     * Whether the response will only consist of headers.
226
     *
227
     * @return bool
228
     */
229
    public function isEmpty(): bool
230
    {
231
        return $this->request->isHead() or in_array($this->code, [204, 205, 304]);
232
    }
233
234
    /**
235
     * Whether the response body would be considered fresh.
236
     *
237
     * @return bool
238
     */
239
    public function isModified(): bool
240
    {
241
        // Explicit 304 takes precedence over all.
242
        if ($this->code === 304) {
243
            return false;
244
        }
245
        // If-None-Match takes precedence over If-Modified-Since
246
        if ($this->request['If-None-Match']) {
247
            return !in_array($this['ETag'], str_getcsv($this->request['If-None-Match']), true);
0 ignored issues
show
Bug introduced by
It seems like $this->request['If-None-Match'] can also be of type null; however, parameter $string of str_getcsv() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

247
            return !in_array($this['ETag'], str_getcsv(/** @scrutinizer ignore-type */ $this->request['If-None-Match']), true);
Loading history...
248
        }
249
        if ($this->timestamp and $this->request['If-Modified-Since']) {
250
            return $this->timestamp > strtotime($this->request['If-Modified-Since']);
0 ignored issues
show
Bug introduced by
It seems like $this->request['If-Modified-Since'] can also be of type null; however, parameter $datetime of strtotime() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

250
            return $this->timestamp > strtotime(/** @scrutinizer ignore-type */ $this->request['If-Modified-Since']);
Loading history...
251
        }
252
        return true;
253
    }
254
255
    /**
256
     * Renders mixed content and exits.
257
     *
258
     * @param mixed $content
259
     */
260
    public function mixed_exit($content): void
261
    {
262
        if ($content instanceof View) {
263
            $this->view_exit($content);
264
        } elseif ($content instanceof Throwable) {
265
            $this->error_exit($content);
266
        } else {
267
            echo $content;
268
            flush();
269
            exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
270
        }
271
    }
272
273
    /**
274
     * @param mixed $key
275
     * @return bool
276
     */
277
    public function offsetExists($key)
278
    {
279
        return isset($this->headers[$key]);
280
    }
281
282
    /**
283
     * @param mixed $key
284
     * @return null|string
285
     */
286
    public function offsetGet($key)
287
    {
288
        return $this->headers[$key] ?? null;
289
    }
290
291
    /**
292
     * @param mixed $key
293
     * @param string $value
294
     */
295
    public function offsetSet($key, $value)
296
    {
297
        $this->headers[$key] = $value;
298
    }
299
300
    /**
301
     * @param mixed $key
302
     */
303
    public function offsetUnset($key)
304
    {
305
        unset($this->headers[$key]);
306
    }
307
308
    /**
309
     * Issues a redirect and exits.
310
     *
311
     * @param string $location
312
     * @param int $code
313
     */
314
    public function redirect_exit(string $location, int $code = 302): void
315
    {
316
        $this->setCode($code);
317
        $this['Location'] = $location;
318
        flush();
319
        exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
320
    }
321
322
    /**
323
     * Specifies how long the response can be cached by the client.
324
     *
325
     * @param int|DateTimeInterface $ttl Zero or negative time means "don't cache"
326
     * @return $this
327
     */
328
    public function setCacheTtl($ttl)
329
    {
330
        if ($ttl instanceof DateTimeInterface) {
331
            $ttl = $ttl->getTimestamp() - time();
332
        }
333
        $this['Cache-Control'] = $ttl > 0 ? "must-revalidate, max-age={$ttl}" : 'no-store';
334
        return $this;
335
    }
336
337
    /**
338
     * @param int $code
339
     * @return $this
340
     */
341
    public function setCode(int $code)
342
    {
343
        $this->code = $code;
344
        return $this;
345
    }
346
347
    /**
348
     * Sets or unsets the timestamp and `Last-Modified` header.
349
     *
350
     * @param int $timestamp
351
     * @return $this
352
     */
353
    public function setTimestamp(int $timestamp)
354
    {
355
        if ($timestamp) {
356
            $this['Last-Modified'] = gmdate('D, d M Y H:i:s T', $timestamp);
357
        } else {
358
            unset($this['Last-Modified']);
359
        }
360
        $this->timestamp = $timestamp;
361
        return $this;
362
    }
363
364
    /**
365
     * Updates the modification time if the given one is greater.
366
     *
367
     * @param int $timestamp
368
     * @return $this
369
     */
370
    public function touch(int $timestamp)
371
    {
372
        if ($timestamp > $this->timestamp) {
373
            $this->setTimestamp($timestamp);
374
        }
375
        return $this;
376
    }
377
378
    /**
379
     * Renders a view and exits.
380
     *
381
     * If the request is `HEAD` this skips rendering content and only outputs headers.
382
     *
383
     * @param View $view
384
     */
385
    public function view_exit(View $view): void
386
    {
387
        $this->setCacheTtl($view->getCacheTtl());
388
        if (!$this->request->isHead()) {
389
            $view->render();
390
        }
391
        flush();
392
        exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
393
    }
394
}
395