3
declare(strict_types=1);
6
* This file is part of CodeIgniter 4 framework.
8
* (c) CodeIgniter Foundation <admin@codeigniter.com>
10
* For the full copyright and license information, please view
11
* the LICENSE file that was distributed with this source code.
14
namespace CodeIgniter\HTTP;
16
use CodeIgniter\Cookie\Cookie;
17
use CodeIgniter\Cookie\CookieStore;
18
use CodeIgniter\Cookie\Exceptions\CookieException;
19
use CodeIgniter\HTTP\Exceptions\HTTPException;
20
use CodeIgniter\I18n\Time;
21
use CodeIgniter\Pager\PagerInterface;
22
use CodeIgniter\Security\Exceptions\SecurityException;
23
use Config\Cookie as CookieConfig;
26
use InvalidArgumentException;
31
* Additional methods to make a PSR-7 Response class
32
* compliant with the framework's own ResponseInterface.
34
* @see https://github.com/php-fig/http-message/blob/master/src/ResponseInterface.php
39
* Content security policy handler
41
* @var ContentSecurityPolicy
46
* CookieStore instance.
50
protected $cookieStore;
53
* Type of format the body is in.
54
* Valid: html, json, xml
58
protected $bodyFormat = 'html';
61
* Return an instance with the specified status code and, optionally, reason phrase.
63
* If no reason phrase is specified, will default recommended reason phrase for
64
* the response's status code.
66
* @see http://tools.ietf.org/html/rfc7231#section-6
67
* @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
69
* @param int $code The 3-digit integer result code to set.
70
* @param string $reason The reason phrase to use with the
71
* provided status code; if none is provided, will
72
* default to the IANA name.
76
* @throws HTTPException For invalid status code arguments.
78
public function setStatusCode(int $code, string $reason = '')
81
if ($code < 100 || $code > 599) {
82
throw HTTPException::forInvalidStatusCode($code);
85
// Unknown and no message?
86
if (! array_key_exists($code, static::$statusCodes) && ($reason === '')) {
87
throw HTTPException::forUnkownStatusCode($code);
90
$this->statusCode = $code;
92
$this->reason = ($reason !== '') ? $reason : static::$statusCodes[$code];
97
// --------------------------------------------------------------------
98
// Convenience Methods
99
// --------------------------------------------------------------------
102
* Sets the date header
106
public function setDate(DateTime $date)
108
$date->setTimezone(new DateTimeZone('UTC'));
110
$this->setHeader('Date', $date->format('D, d M Y H:i:s') . ' GMT');
116
* Set the Link Header
118
* @see http://tools.ietf.org/html/rfc5988
122
* @todo Recommend moving to Pager
124
public function setLink(PagerInterface $pager)
128
if ($previous = $pager->getPreviousPageURI()) {
129
$links .= '<' . $pager->getPageURI($pager->getFirstPage()) . '>; rel="first",';
130
$links .= '<' . $previous . '>; rel="prev"';
133
if (($next = $pager->getNextPageURI()) && $previous) {
138
$links .= '<' . $next . '>; rel="next",';
139
$links .= '<' . $pager->getPageURI($pager->getLastPage()) . '>; rel="last"';
142
$this->setHeader('Link', $links);
148
* Sets the Content Type header for this response with the mime type
149
* and, optionally, the charset.
153
public function setContentType(string $mime, string $charset = 'UTF-8')
155
// add charset attribute if not already there and provided as parm
156
if ((strpos($mime, 'charset=') < 1) && ($charset !== '')) {
157
$mime .= '; charset=' . $charset;
160
$this->removeHeader('Content-Type'); // replace existing content type
161
$this->setHeader('Content-Type', $mime);
167
* Converts the $body into JSON and sets the Content Type header.
169
* @param array|object|string $body
173
public function setJSON($body, bool $unencoded = false)
175
$this->body = $this->formatBody($body, 'json' . ($unencoded ? '-unencoded' : ''));
181
* Returns the current body, converted to JSON is it isn't already.
183
* @return string|null
185
* @throws InvalidArgumentException If the body property is not array.
187
public function getJSON()
191
if ($this->bodyFormat !== 'json') {
192
$body = service('format')->getFormatter('application/json')->format($body);
195
return $body ?: null;
199
* Converts $body into XML, and sets the correct Content-Type.
201
* @param array|string $body
205
public function setXML($body)
207
$this->body = $this->formatBody($body, 'xml');
213
* Retrieves the current body into XML and returns it.
215
* @return bool|string|null
217
* @throws InvalidArgumentException If the body property is not array.
219
public function getXML()
223
if ($this->bodyFormat !== 'xml') {
224
$body = service('format')->getFormatter('application/xml')->format($body);
231
* Handles conversion of the data into the appropriate format,
232
* and sets the correct Content-Type header for our response.
234
* @param array|object|string $body
235
* @param string $format Valid: json, xml
237
* @return false|string
239
* @throws InvalidArgumentException If the body property is not string or array.
241
protected function formatBody($body, string $format)
243
$this->bodyFormat = ($format === 'json-unencoded' ? 'json' : $format);
244
$mime = "application/{$this->bodyFormat}";
245
$this->setContentType($mime);
247
// Nothing much to do for a string...
248
if (! is_string($body) || $format === 'json-unencoded') {
249
$body = service('format')->getFormatter($mime)->format($body);
255
// --------------------------------------------------------------------
256
// Cache Control Methods
258
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9
259
// --------------------------------------------------------------------
262
* Sets the appropriate headers to ensure this response
263
* is not cached by the browsers.
267
* @todo Recommend researching these directives, might need: 'private', 'no-transform', 'no-store', 'must-revalidate'
269
* @see DownloadResponse::noCache()
271
public function noCache()
273
$this->removeHeader('Cache-Control');
274
$this->setHeader('Cache-Control', ['no-store', 'max-age=0', 'no-cache']);
280
* A shortcut method that allows the developer to set all of the
281
* cache-control headers in one method call.
283
* The options array is used to provide the cache-control directives
284
* for the header. It might look something like:
292
* Typical options are:
305
public function setCache(array $options = [])
307
if ($options === []) {
311
$this->removeHeader('Cache-Control');
312
$this->removeHeader('ETag');
315
if (isset($options['etag'])) {
316
$this->setHeader('ETag', $options['etag']);
317
unset($options['etag']);
321
if (isset($options['last-modified'])) {
322
$this->setLastModified($options['last-modified']);
324
unset($options['last-modified']);
327
$this->setHeader('Cache-Control', $options);
333
* Sets the Last-Modified date header.
335
* $date can be either a string representation of the date or,
336
* preferably, an instance of DateTime.
338
* @param DateTime|string $date
342
public function setLastModified($date)
344
if ($date instanceof DateTime) {
345
$date->setTimezone(new DateTimeZone('UTC'));
346
$this->setHeader('Last-Modified', $date->format('D, d M Y H:i:s') . ' GMT');
347
} elseif (is_string($date)) {
348
$this->setHeader('Last-Modified', $date);
354
// --------------------------------------------------------------------
356
// --------------------------------------------------------------------
359
* Sends the output to the browser.
363
public function send()
365
// If we're enforcing a Content Security Policy,
366
// we need to give it a chance to build out it's headers.
367
if ($this->CSP->enabled()) {
368
$this->CSP->finalize($this);
370
$this->body = str_replace(['{csp-style-nonce}', '{csp-script-nonce}'], '', $this->body ?? '');
373
$this->sendHeaders();
374
$this->sendCookies();
381
* Sends the headers of this HTTP response to the browser.
385
public function sendHeaders()
387
// Have the headers already been sent?
388
if ($this->pretend || headers_sent()) {
392
// Per spec, MUST be sent with each request, if possible.
393
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html
394
if (! isset($this->headers['Date']) && PHP_SAPI !== 'cli-server') {
395
$this->setDate(DateTime::createFromFormat('U', (string) Time::now()->getTimestamp()));
399
header(sprintf('HTTP/%s %s %s', $this->getProtocolVersion(), $this->getStatusCode(), $this->getReasonPhrase()), true, $this->getStatusCode());
401
// Send all of our headers
402
foreach ($this->headers() as $name => $value) {
403
if ($value instanceof Header) {
405
$name . ': ' . $value->getValueLine(),
407
$this->getStatusCode()
410
foreach ($value as $header) {
412
$name . ': ' . $header->getValueLine(),
414
$this->getStatusCode()
424
* Sends the Body of the message to the browser.
428
public function sendBody()
436
* Perform a redirect to a new URL, in two flavors: header or location.
438
* @param string $uri The URI to redirect to
439
* @param int|null $code The type of redirection, defaults to 302
443
* @throws HTTPException For invalid status code.
445
public function redirect(string $uri, string $method = 'auto', ?int $code = null)
447
// IIS environment likely? Use 'refresh' for better compatibility
450
&& isset($_SERVER['SERVER_SOFTWARE'])
451
&& str_contains($_SERVER['SERVER_SOFTWARE'], 'Microsoft-IIS')
454
} elseif ($method !== 'refresh' && $code === null) {
455
// override status code for HTTP/1.1 & higher
457
isset($_SERVER['SERVER_PROTOCOL'], $_SERVER['REQUEST_METHOD'])
458
&& $this->getProtocolVersion() >= 1.1
460
if ($_SERVER['REQUEST_METHOD'] === Method::GET) {
462
} elseif (in_array($_SERVER['REQUEST_METHOD'], [Method::POST, Method::PUT, Method::DELETE], true)) {
463
// reference: https://en.wikipedia.org/wiki/Post/Redirect/Get
471
if ($code === null) {
476
'refresh' => $this->setHeader('Refresh', '0;url=' . $uri),
477
default => $this->setHeader('Location', $uri),
480
$this->setStatusCode($code);
488
* Accepts an arbitrary number of binds (up to 7) or an associative
489
* array in the first parameter containing all the values.
491
* @param array|Cookie|string $name Cookie name / array containing binds / Cookie object
492
* @param string $value Cookie value
493
* @param int $expire Cookie expiration time in seconds
494
* @param string $domain Cookie domain (e.g.: '.yourdomain.com')
495
* @param string $path Cookie path (default: '/')
496
* @param string $prefix Cookie name prefix ('': the default prefix)
497
* @param bool|null $secure Whether to only transfer cookies via SSL
498
* @param bool|null $httponly Whether only make the cookie accessible via HTTP (no javascript)
499
* @param string|null $samesite
503
public function setCookie(
514
if ($name instanceof Cookie) {
515
$this->cookieStore = $this->cookieStore->put($name);
520
$cookieConfig = config(CookieConfig::class);
522
$secure ??= $cookieConfig->secure;
523
$httponly ??= $cookieConfig->httponly;
524
$samesite ??= $cookieConfig->samesite;
526
if (is_array($name)) {
527
// always leave 'name' in last place, as the loop will break otherwise, due to ${$item}
528
foreach (['samesite', 'value', 'expire', 'domain', 'path', 'prefix', 'secure', 'httponly', 'name'] as $item) {
529
if (isset($name[$item])) {
530
${$item} = $name[$item];
535
if (is_numeric($expire)) {
536
$expire = $expire > 0 ? Time::now()->getTimestamp() + $expire : 0;
539
$cookie = new Cookie($name, $value, [
540
'expires' => $expire ?: 0,
545
'httponly' => $httponly,
546
'samesite' => $samesite ?? '',
549
$this->cookieStore = $this->cookieStore->put($cookie);
555
* Returns the `CookieStore` instance.
557
* @return CookieStore
559
public function getCookieStore()
561
return $this->cookieStore;
565
* Checks to see if the Response has a specified cookie or not.
567
public function hasCookie(string $name, ?string $value = null, string $prefix = ''): bool
569
$prefix = $prefix ?: Cookie::setDefaults()['prefix']; // to retain BC
571
return $this->cookieStore->has($name, $prefix, $value);
577
* @param string $prefix Cookie prefix.
578
* '': the default prefix
580
* @return array<string, Cookie>|Cookie|null
582
public function getCookie(?string $name = null, string $prefix = '')
584
if ((string) $name === '') {
585
return $this->cookieStore->display();
589
$prefix = $prefix ?: Cookie::setDefaults()['prefix']; // to retain BC
591
return $this->cookieStore->get($name, $prefix);
592
} catch (CookieException $e) {
593
log_message('error', (string) $e);
600
* Sets a cookie to be deleted when the response is sent.
604
public function deleteCookie(string $name = '', string $domain = '', string $path = '/', string $prefix = '')
610
$prefix = $prefix ?: Cookie::setDefaults()['prefix']; // to retain BC
612
$prefixed = $prefix . $name;
613
$store = $this->cookieStore;
616
/** @var Cookie $cookie */
617
foreach ($store as $cookie) {
618
if ($cookie->getPrefixedName() === $prefixed) {
619
if ($domain !== $cookie->getDomain()) {
623
if ($path !== $cookie->getPath()) {
627
$cookie = $cookie->withValue('')->withExpired();
630
$this->cookieStore = $store->put($cookie);
636
$this->setCookie($name, '', 0, $domain, $path, $prefix);
643
* Returns all cookies currently set.
645
* @return array<string, Cookie>
647
public function getCookies()
649
return $this->cookieStore->display();
653
* Actually sets the cookies.
657
protected function sendCookies()
659
if ($this->pretend) {
663
$this->dispatchCookies();
666
private function dispatchCookies(): void
668
/** @var IncomingRequest $request */
669
$request = service('request');
671
foreach ($this->cookieStore->display() as $cookie) {
672
if ($cookie->isSecure() && ! $request->isSecure()) {
673
throw SecurityException::forInsecureCookie();
676
$name = $cookie->getPrefixedName();
677
$value = $cookie->getValue();
678
$options = $cookie->getOptions();
680
if ($cookie->isRaw()) {
681
$this->doSetRawCookie($name, $value, $options);
683
$this->doSetCookie($name, $value, $options);
687
$this->cookieStore->clear();
691
* Extracted call to `setrawcookie()` in order to run unit tests on it.
693
* @codeCoverageIgnore
695
private function doSetRawCookie(string $name, string $value, array $options): void
697
setrawcookie($name, $value, $options);
701
* Extracted call to `setcookie()` in order to run unit tests on it.
703
* @codeCoverageIgnore
705
private function doSetCookie(string $name, string $value, array $options): void
707
setcookie($name, $value, $options);
713
* Generates the headers that force a download to happen. And
714
* sends the file to the browser.
716
* @param string $filename The name you want the downloaded file to be named
717
* or the path to the file to send
718
* @param string|null $data The data to be downloaded. Set null if the $filename is the file path
719
* @param bool $setMime Whether to try and send the actual MIME type
721
* @return DownloadResponse|null
723
public function download(string $filename = '', $data = '', bool $setMime = false)
725
if ($filename === '' || $data === '') {
730
if ($data === null) {
731
$filepath = $filename;
732
$filename = explode('/', str_replace(DIRECTORY_SEPARATOR, '/', $filename));
733
$filename = end($filename);
736
$response = new DownloadResponse($filename, $setMime);
738
if ($filepath !== '') {
739
$response->setFilePath($filepath);
740
} elseif ($data !== null) {
741
$response->setBinary($data);
747
public function getCSP(): ContentSecurityPolicy