Response.php 17 KB

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