Response.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767
  1. <?php
  2. /**
  3. * Response wrapper. Created as the result of any [Request] execution
  4. * or utility method (i.e. Redirect). Implements standard HTTP
  5. * response format.
  6. *
  7. * @package KO7
  8. * @category Base
  9. *
  10. * @copyright (c) 2007-2016 Kohana Team
  11. * @copyright (c) since 2016 Koseven Team
  12. * @license https://koseven.dev/LICENSE
  13. * @since 3.1.0
  14. */
  15. class KO7_Response implements HTTP_Response {
  16. /**
  17. * Factory method to create a new [Response]. Pass properties
  18. * in using an associative array.
  19. *
  20. * // Create a new response
  21. * $response = Response::factory();
  22. *
  23. * // Create a new response with headers
  24. * $response = Response::factory(array('status' => 200));
  25. *
  26. * @param array $config Setup the response object
  27. * @return Response
  28. */
  29. public static function factory(array $config = [])
  30. {
  31. return new Response($config);
  32. }
  33. // HTTP status codes and messages
  34. public static $messages = [
  35. // Informational 1xx
  36. 100 => 'Continue',
  37. 101 => 'Switching Protocols',
  38. 102 => 'Processing',
  39. 103 => 'Early Hints',
  40. // Success 2xx
  41. 200 => 'OK',
  42. 201 => 'Created',
  43. 202 => 'Accepted',
  44. 203 => 'Non-Authoritative Information',
  45. 204 => 'No Content',
  46. 205 => 'Reset Content',
  47. 206 => 'Partial Content',
  48. 207 => 'Multi-Status',
  49. 208 => 'Already Reported',
  50. 226 => 'IM Used',
  51. // Redirection 3xx
  52. 300 => 'Multiple Choices',
  53. 301 => 'Moved Permanently',
  54. 302 => 'Found', // 1.1
  55. 303 => 'See Other',
  56. 304 => 'Not Modified',
  57. 305 => 'Use Proxy',
  58. // 306 is deprecated but reserved
  59. 307 => 'Temporary Redirect',
  60. 308 => 'Permanent Redirect',
  61. // Client Error 4xx
  62. 400 => 'Bad Request',
  63. 401 => 'Unauthorized',
  64. 402 => 'Payment Required',
  65. 403 => 'Forbidden',
  66. 404 => 'Not Found',
  67. 405 => 'Method Not Allowed',
  68. 406 => 'Not Acceptable',
  69. 407 => 'Proxy Authentication Required',
  70. 408 => 'Request Timeout',
  71. 409 => 'Conflict',
  72. 410 => 'Gone',
  73. 411 => 'Length Required',
  74. 412 => 'Precondition Failed',
  75. 413 => 'Request Entity Too Large',
  76. 414 => 'Request-URI Too Long',
  77. 415 => 'Unsupported Media Type',
  78. 416 => 'Requested Range Not Satisfiable',
  79. 417 => 'Expectation Failed',
  80. 418 => 'I\'m a teapot',
  81. 421 => 'Misdirected Request',
  82. 422 => 'Unprocessable Entity',
  83. 423 => 'Locked',
  84. 424 => 'Failed Dependency',
  85. 425 => 'Too Early',
  86. 426 => 'Upgrade Required',
  87. 428 => 'Precondition Required',
  88. 429 => 'Too Many Requests',
  89. 431 => 'Request Header Fields Too Large',
  90. 451 => 'Unavailable For Legal Reasons',
  91. // Server Error 5xx
  92. 500 => 'Internal Server Error',
  93. 501 => 'Not Implemented',
  94. 502 => 'Bad Gateway',
  95. 503 => 'Service Unavailable',
  96. 504 => 'Gateway Timeout',
  97. 505 => 'HTTP Version Not Supported',
  98. 506 => 'Variant Also Negotiates',
  99. 507 => 'Insufficient Storage',
  100. 508 => 'Loop Detected',
  101. 509 => 'Bandwidth Limit Exceeded',
  102. 510 => 'Not Extended',
  103. 511 => 'Network Authentication Required'
  104. ];
  105. /**
  106. * @var integer The response http status
  107. */
  108. protected $_status = 200;
  109. /**
  110. * @var HTTP_Header Headers returned in the response
  111. */
  112. protected $_header;
  113. /**
  114. * @var string The response body
  115. */
  116. protected $_body = '';
  117. /**
  118. * @var array Cookies to be returned in the response
  119. */
  120. protected $_cookies = [];
  121. /**
  122. * @var string The response protocol
  123. */
  124. protected $_protocol;
  125. /**
  126. * Sets up the response object
  127. *
  128. * @param array $config Setup the response object
  129. * @return void
  130. */
  131. public function __construct(array $config = [])
  132. {
  133. $this->_header = new HTTP_Header;
  134. foreach ($config as $key => $value)
  135. {
  136. if (property_exists($this, $key))
  137. {
  138. if ($key == '_header')
  139. {
  140. $this->headers($value);
  141. }
  142. else
  143. {
  144. $this->$key = $value;
  145. }
  146. }
  147. }
  148. }
  149. /**
  150. * Outputs the body when cast to string
  151. *
  152. * @return string
  153. */
  154. public function __toString()
  155. {
  156. return $this->_body;
  157. }
  158. /**
  159. * Gets or sets the body of the response
  160. *
  161. * @param mixed $content Content of body
  162. *
  163. * @return mixed
  164. */
  165. public function body($content = NULL)
  166. {
  167. if ($content === NULL)
  168. {
  169. return $this->_body;
  170. }
  171. // Cast scalar types or objects (with __toString method) to string
  172. if (is_scalar($content) || (is_object($content) && method_exists($content, '__toString')))
  173. {
  174. $content = (string)$content;
  175. }
  176. $this->_body = $content;
  177. return $this;
  178. }
  179. /**
  180. * Gets or sets the HTTP protocol. The standard protocol to use
  181. * is `HTTP/1.1`.
  182. *
  183. * @param string $protocol Protocol to set to the request/response
  184. * @return mixed
  185. */
  186. public function protocol($protocol = NULL)
  187. {
  188. if ($protocol)
  189. {
  190. $this->_protocol = strtoupper($protocol);
  191. return $this;
  192. }
  193. if ($this->_protocol === NULL)
  194. {
  195. $this->_protocol = HTTP::$protocol;
  196. }
  197. return $this->_protocol;
  198. }
  199. /**
  200. * Sets or gets the HTTP status from this response.
  201. *
  202. * // Set the HTTP status to 404 Not Found
  203. * $response = Response::factory()
  204. * ->status(404);
  205. *
  206. * // Get the current status
  207. * $status = $response->status();
  208. *
  209. * @param integer $status Status to set to this response
  210. *
  211. * @return KO7_Response|int|boolean acting as setter \ acting as getter \ false on invalid status code
  212. */
  213. public function status($status = NULL)
  214. {
  215. if ($status === NULL)
  216. {
  217. return $this->_status;
  218. }
  219. if (array_key_exists($status, Response::$messages))
  220. {
  221. $this->_status = (int) $status;
  222. return $this;
  223. }
  224. return false;
  225. }
  226. /**
  227. * Gets and sets headers to the [Response], allowing chaining
  228. * of response methods. If chaining isn't required, direct
  229. * access to the property should be used instead.
  230. *
  231. * // Get a header
  232. * $accept = $response->headers('Content-Type');
  233. *
  234. * // Set a header
  235. * $response->headers('Content-Type', 'text/html');
  236. *
  237. * // Get all headers
  238. * $headers = $response->headers();
  239. *
  240. * // Set multiple headers
  241. * $response->headers(array('Content-Type' => 'text/html', 'Cache-Control' => 'no-cache'));
  242. *
  243. * @param mixed $key
  244. * @param string $value
  245. * @return mixed
  246. */
  247. public function headers($key = NULL, $value = NULL)
  248. {
  249. if ($key === NULL)
  250. {
  251. return $this->_header;
  252. }
  253. elseif (is_array($key))
  254. {
  255. $this->_header->exchangeArray($key);
  256. return $this;
  257. }
  258. elseif ($value === NULL)
  259. {
  260. return Arr::get($this->_header, $key);
  261. }
  262. else
  263. {
  264. $this->_header[$key] = $value;
  265. return $this;
  266. }
  267. }
  268. /**
  269. * Returns the length of the body for use with
  270. * content header
  271. *
  272. * @return integer
  273. */
  274. public function content_length()
  275. {
  276. return strlen($this->body());
  277. }
  278. /**
  279. * Set and get cookies values for this response.
  280. *
  281. * // Get the cookies set to the response
  282. * $cookies = $response->cookie();
  283. *
  284. * // Set a cookie to the response
  285. * $response->cookie('session', array(
  286. * 'value' => $value,
  287. * 'expiration' => 12352234
  288. * ));
  289. *
  290. * @param mixed $key cookie name, or array of cookie values
  291. * @param string $value value to set to cookie
  292. * @return string
  293. * @return void
  294. * @return [Response]
  295. */
  296. public function cookie($key = NULL, $value = NULL)
  297. {
  298. // Handle the get cookie calls
  299. if ($key === NULL)
  300. return $this->_cookies;
  301. elseif ( ! is_array($key) AND ! $value)
  302. return Arr::get($this->_cookies, $key);
  303. // Handle the set cookie calls
  304. if (is_array($key))
  305. {
  306. reset($key);
  307. foreach ($key as $_key => $_value)
  308. {
  309. $this->cookie($_key, $_value);
  310. }
  311. }
  312. else
  313. {
  314. if ( ! is_array($value))
  315. {
  316. $value = [
  317. 'value' => $value,
  318. 'expiration' => Cookie::$expiration
  319. ];
  320. }
  321. elseif ( ! isset($value['expiration']))
  322. {
  323. $value['expiration'] = Cookie::$expiration;
  324. }
  325. $this->_cookies[$key] = $value;
  326. }
  327. return $this;
  328. }
  329. /**
  330. * Deletes a cookie set to the response
  331. *
  332. * @param string $name
  333. * @return Response
  334. */
  335. public function delete_cookie($name)
  336. {
  337. unset($this->_cookies[$name]);
  338. return $this;
  339. }
  340. /**
  341. * Deletes all cookies from this response
  342. *
  343. * @return Response
  344. */
  345. public function delete_cookies()
  346. {
  347. $this->_cookies = [];
  348. return $this;
  349. }
  350. /**
  351. * Sends the response status and all set headers.
  352. *
  353. * @param boolean $replace replace existing headers
  354. * @param callback $callback function to handle header output
  355. * @return mixed
  356. */
  357. public function send_headers($replace = FALSE, $callback = NULL)
  358. {
  359. return $this->_header->send_headers($this, $replace, $callback);
  360. }
  361. /**
  362. * Send file download as the response. All execution will be halted when
  363. * this method is called! Use TRUE for the filename to send the current
  364. * response as the file content. The third parameter allows the following
  365. * options to be set:
  366. *
  367. * Type | Option | Description | Default Value
  368. * ----------|-----------|------------------------------------|--------------
  369. * `boolean` | inline | Display inline instead of download | `FALSE`
  370. * `string` | mime_type | Manual mime type | Automatic
  371. * `boolean` | delete | Delete the file after sending | `FALSE`
  372. *
  373. * Download a file that already exists:
  374. *
  375. * $request->send_file('media/packages/ko7.zip');
  376. *
  377. * Download a generated file:
  378. *
  379. * $csv = tmpfile();
  380. * fputcsv($csv, ['label1', 'label2']);
  381. * $request->send_file($csv, $filename);
  382. *
  383. * Download generated content as a file:
  384. *
  385. * $request->response($content);
  386. * $request->send_file(TRUE, $filename);
  387. *
  388. * [!!] No further processing can be done after this method is called!
  389. *
  390. * @param string|resource|bool $filename filename with path, file stream, or TRUE for the current response
  391. * @param string $download downloaded file name
  392. * @param array $options additional options
  393. * @return void
  394. * @throws KO7_Exception
  395. * @uses File::mime_by_ext
  396. * @uses File::mime
  397. * @uses Request::send_headers
  398. */
  399. public function send_file($filename, $download = NULL, array $options = NULL)
  400. {
  401. if ( ! empty($options['mime_type']))
  402. {
  403. // The mime-type has been manually set
  404. $mime = $options['mime_type'];
  405. }
  406. if ($filename === TRUE)
  407. {
  408. if (empty($download))
  409. {
  410. throw new KO7_Exception('Download name must be provided for streaming files');
  411. }
  412. // Temporary files will automatically be deleted
  413. $options['delete'] = FALSE;
  414. if ( ! isset($mime))
  415. {
  416. // Guess the mime using the file extension
  417. $mime = File::mime_by_ext(strtolower(pathinfo($download, PATHINFO_EXTENSION)));
  418. }
  419. // Force the data to be rendered if
  420. $file_data = (string) $this->_body;
  421. // Get the content size
  422. $size = strlen($file_data);
  423. // Create a temporary file to hold the current response
  424. $file = tmpfile();
  425. // Write the current response into the file
  426. fwrite($file, $file_data);
  427. // File data is no longer needed
  428. unset($file_data);
  429. }
  430. else if (is_resource($filename) && get_resource_type($filename) === 'stream')
  431. {
  432. if (empty($download))
  433. {
  434. throw new KO7_Exception('Download name must be provided for streaming files');
  435. }
  436. // Make sure this is a file handle
  437. $file_meta = stream_get_meta_data($filename);
  438. if ($file_meta['seekable'] === FALSE)
  439. {
  440. throw new KO7_Exception('Resource must be a file handle');
  441. }
  442. // Handle file streams passed in as resources
  443. $file = $filename;
  444. $size = fstat($file)['size'];
  445. }
  446. else
  447. {
  448. // Get the complete file path
  449. $filename = realpath($filename);
  450. if (empty($download))
  451. {
  452. // Use the file name as the download file name
  453. $download = pathinfo($filename, PATHINFO_BASENAME);
  454. }
  455. // Get the file size
  456. $size = filesize($filename);
  457. if ( ! isset($mime))
  458. {
  459. // Get the mime type from the extension of the download file
  460. $mime = File::mime_by_ext(pathinfo($download, PATHINFO_EXTENSION));
  461. }
  462. // Open the file for reading
  463. $file = fopen($filename, 'rb');
  464. }
  465. if ( ! is_resource($file))
  466. {
  467. throw new KO7_Exception('Could not read file to send: :file', [
  468. ':file' => $download,
  469. ]);
  470. }
  471. // Inline or download?
  472. $disposition = empty($options['inline']) ? 'attachment' : 'inline';
  473. // Calculate byte range to download.
  474. list($start, $end) = $this->_calculate_byte_range($size);
  475. if ( ! empty($options['resumable']))
  476. {
  477. if ($start > 0 OR $end < ($size - 1))
  478. {
  479. // Partial Content
  480. $this->_status = 206;
  481. }
  482. // Range of bytes being sent
  483. $this->_header['content-range'] = 'bytes '.$start.'-'.$end.'/'.$size;
  484. $this->_header['accept-ranges'] = 'bytes';
  485. }
  486. // Set the headers for a download
  487. $this->_header['content-disposition'] = $disposition.'; filename="'.$download.'"';
  488. $this->_header['content-type'] = $mime;
  489. $this->_header['content-length'] = (string) (($end - $start) + 1);
  490. if (Request::user_agent('browser') === 'Internet Explorer')
  491. {
  492. // Naturally, IE does not act like a real browser...
  493. if (Request::$initial->secure())
  494. {
  495. // http://support.microsoft.com/kb/316431
  496. $this->_header['pragma'] = $this->_header['cache-control'] = 'public';
  497. }
  498. if (version_compare(Request::user_agent('version'), '8.0', '>='))
  499. {
  500. // http://ajaxian.com/archives/ie-8-security
  501. $this->_header['x-content-type-options'] = 'nosniff';
  502. }
  503. }
  504. // Send all headers now
  505. $this->send_headers();
  506. while (ob_get_level())
  507. {
  508. // Flush all output buffers
  509. ob_end_flush();
  510. }
  511. // Manually stop execution
  512. ignore_user_abort(TRUE);
  513. // Send data in 16kb blocks
  514. $block = 1024 * 16;
  515. fseek($file, $start);
  516. while ( ! feof($file) AND ($pos = ftell($file)) <= $end)
  517. {
  518. if (connection_aborted())
  519. break;
  520. if ($pos + $block > $end)
  521. {
  522. // Don't read past the buffer.
  523. $block = $end - $pos + 1;
  524. }
  525. // Output a block of the file
  526. echo fread($file, $block);
  527. // Send the data now
  528. flush();
  529. }
  530. // Close the file
  531. fclose($file);
  532. if ( ! empty($options['delete']))
  533. {
  534. try
  535. {
  536. // Attempt to remove the file
  537. unlink($filename);
  538. }
  539. catch (Exception $e)
  540. {
  541. // Create a text version of the exception
  542. $error = KO7_Exception::text($e);
  543. if (is_object(KO7::$log))
  544. {
  545. // Add this exception to the log
  546. KO7::$log->add(Log::ERROR, $error);
  547. // Make sure the logs are written
  548. KO7::$log->write();
  549. }
  550. // Do NOT display the exception, it will corrupt the output!
  551. }
  552. }
  553. // Stop execution
  554. exit;
  555. }
  556. /**
  557. * Renders the HTTP_Interaction to a string, producing
  558. *
  559. * - Protocol
  560. * - Headers
  561. * - Body
  562. *
  563. * @return string
  564. */
  565. public function render()
  566. {
  567. if ( ! $this->_header->offsetExists('content-type'))
  568. {
  569. // Add the default Content-Type header if required
  570. $this->_header['content-type'] = KO7::$content_type.'; charset='.KO7::$charset;
  571. }
  572. // Set the content length
  573. $this->headers('content-length', (string) $this->content_length());
  574. // If KO7 expose, set the user-agent
  575. if (KO7::$expose)
  576. {
  577. $this->headers('user-agent', KO7::version());
  578. }
  579. // Prepare cookies
  580. if ($this->_cookies)
  581. {
  582. if (extension_loaded('http'))
  583. {
  584. $cookies = version_compare(phpversion('http'), '2.0.0', '>=') ?
  585. (string) new \http\Cookie($this->_cookies) :
  586. http_build_cookie($this->_cookies);
  587. $this->_header['set-cookie'] = $cookies;
  588. }
  589. else
  590. {
  591. $cookies = [];
  592. // Parse each
  593. foreach ($this->_cookies as $key => $value)
  594. {
  595. $string = $key.'='.$value['value'].'; expires='.date('l, d M Y H:i:s T', $value['expiration']);
  596. $cookies[] = $string;
  597. }
  598. // Create the cookie string
  599. $this->_header['set-cookie'] = $cookies;
  600. }
  601. }
  602. $output = $this->_protocol.' '.$this->_status.' '.Response::$messages[$this->_status]."\r\n";
  603. $output .= (string) $this->_header;
  604. $output .= $this->_body;
  605. return $output;
  606. }
  607. /**
  608. * Generate ETag
  609. * Generates an ETag from the response ready to be returned
  610. *
  611. * @throws Request_Exception
  612. * @return String Generated ETag
  613. */
  614. public function generate_etag()
  615. {
  616. if ($this->_body === '')
  617. {
  618. throw new Request_Exception('No response yet associated with request - cannot auto generate resource ETag');
  619. }
  620. // Generate a unique hash for the response
  621. return '"'.sha1($this->render()).'"';
  622. }
  623. /**
  624. * Parse the byte ranges from the HTTP_RANGE header used for
  625. * resumable downloads.
  626. *
  627. * @link http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35
  628. * @return array|FALSE
  629. */
  630. protected function _parse_byte_range()
  631. {
  632. if ( ! isset($_SERVER['HTTP_RANGE']))
  633. {
  634. return FALSE;
  635. }
  636. // TODO, speed this up with the use of string functions.
  637. preg_match_all('/(-?[0-9]++(?:-(?![0-9]++))?)(?:-?([0-9]++))?/', $_SERVER['HTTP_RANGE'], $matches, PREG_SET_ORDER);
  638. return $matches[0];
  639. }
  640. /**
  641. * Calculates the byte range to use with send_file. If HTTP_RANGE doesn't
  642. * exist then the complete byte range is returned
  643. *
  644. * @param integer $size
  645. * @return array
  646. */
  647. protected function _calculate_byte_range($size)
  648. {
  649. // Defaults to start with when the HTTP_RANGE header doesn't exist.
  650. $start = 0;
  651. $end = $size - 1;
  652. if ($range = $this->_parse_byte_range())
  653. {
  654. // We have a byte range from HTTP_RANGE
  655. $start = $range[1];
  656. if ($start[0] === '-')
  657. {
  658. // A negative value means we start from the end, so -500 would be the
  659. // last 500 bytes.
  660. $start = $size - abs($start);
  661. }
  662. if (isset($range[2]))
  663. {
  664. // Set the end range
  665. $end = $range[2];
  666. }
  667. }
  668. // Normalize values.
  669. $start = abs(intval($start));
  670. // Keep the the end value in bounds and normalize it.
  671. $end = min(abs(intval($end)), $size - 1);
  672. // Keep the start in bounds.
  673. $start = ($end < $start) ? 0 : max($start, 0);
  674. return [$start, $end];
  675. }
  676. }