Response.php 18 KB

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