718 lines
16 KiB
PHP
718 lines
16 KiB
PHP
<?php defined('SYSPATH') OR die('No direct script access.');
|
|
/**
|
|
* Response wrapper. Created as the result of any [Request] execution
|
|
* or utility method (i.e. Redirect). Implements standard HTTP
|
|
* response format.
|
|
*
|
|
* @package Kohana
|
|
* @category Base
|
|
* @author Kohana Team
|
|
* @copyright (c) 2008-2014 Kohana Team
|
|
* @license http://kohanaframework.org/license
|
|
* @since 3.1.0
|
|
*/
|
|
class Kohana_Response implements HTTP_Response {
|
|
|
|
/**
|
|
* Factory method to create a new [Response]. Pass properties
|
|
* in using an associative array.
|
|
*
|
|
* // Create a new response
|
|
* $response = Response::factory();
|
|
*
|
|
* // Create a new response with headers
|
|
* $response = Response::factory(array('status' => 200));
|
|
*
|
|
* @param array $config Setup the response object
|
|
* @return Response
|
|
*/
|
|
public static function factory(array $config = array())
|
|
{
|
|
return new Response($config);
|
|
}
|
|
|
|
// HTTP status codes and messages
|
|
public static $messages = array(
|
|
// Informational 1xx
|
|
100 => 'Continue',
|
|
101 => 'Switching Protocols',
|
|
|
|
// Success 2xx
|
|
200 => 'OK',
|
|
201 => 'Created',
|
|
202 => 'Accepted',
|
|
203 => 'Non-Authoritative Information',
|
|
204 => 'No Content',
|
|
205 => 'Reset Content',
|
|
206 => 'Partial Content',
|
|
|
|
// Redirection 3xx
|
|
300 => 'Multiple Choices',
|
|
301 => 'Moved Permanently',
|
|
302 => 'Found', // 1.1
|
|
303 => 'See Other',
|
|
304 => 'Not Modified',
|
|
305 => 'Use Proxy',
|
|
// 306 is deprecated but reserved
|
|
307 => 'Temporary Redirect',
|
|
|
|
// Client Error 4xx
|
|
400 => 'Bad Request',
|
|
401 => 'Unauthorized',
|
|
402 => 'Payment Required',
|
|
403 => 'Forbidden',
|
|
404 => 'Not Found',
|
|
405 => 'Method Not Allowed',
|
|
406 => 'Not Acceptable',
|
|
407 => 'Proxy Authentication Required',
|
|
408 => 'Request Timeout',
|
|
409 => 'Conflict',
|
|
410 => 'Gone',
|
|
411 => 'Length Required',
|
|
412 => 'Precondition Failed',
|
|
413 => 'Request Entity Too Large',
|
|
414 => 'Request-URI Too Long',
|
|
415 => 'Unsupported Media Type',
|
|
416 => 'Requested Range Not Satisfiable',
|
|
417 => 'Expectation Failed',
|
|
|
|
// Server Error 5xx
|
|
500 => 'Internal Server Error',
|
|
501 => 'Not Implemented',
|
|
502 => 'Bad Gateway',
|
|
503 => 'Service Unavailable',
|
|
504 => 'Gateway Timeout',
|
|
505 => 'HTTP Version Not Supported',
|
|
509 => 'Bandwidth Limit Exceeded'
|
|
);
|
|
|
|
/**
|
|
* @var integer The response http status
|
|
*/
|
|
protected $_status = 200;
|
|
|
|
/**
|
|
* @var HTTP_Header Headers returned in the response
|
|
*/
|
|
protected $_header;
|
|
|
|
/**
|
|
* @var string The response body
|
|
*/
|
|
protected $_body = '';
|
|
|
|
/**
|
|
* @var array Cookies to be returned in the response
|
|
*/
|
|
protected $_cookies = array();
|
|
|
|
/**
|
|
* @var string The response protocol
|
|
*/
|
|
protected $_protocol;
|
|
|
|
/**
|
|
* Sets up the response object
|
|
*
|
|
* @param array $config Setup the response object
|
|
* @return void
|
|
*/
|
|
public function __construct(array $config = array())
|
|
{
|
|
$this->_header = new HTTP_Header;
|
|
|
|
foreach ($config as $key => $value)
|
|
{
|
|
if (property_exists($this, $key))
|
|
{
|
|
if ($key == '_header')
|
|
{
|
|
$this->headers($value);
|
|
}
|
|
else
|
|
{
|
|
$this->$key = $value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Outputs the body when cast to string
|
|
*
|
|
* @return string
|
|
*/
|
|
public function __toString()
|
|
{
|
|
return $this->_body;
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the body of the response
|
|
*
|
|
* @return mixed
|
|
*/
|
|
public function body($content = NULL)
|
|
{
|
|
if ($content === NULL)
|
|
return $this->_body;
|
|
|
|
$this->_body = (string) $content;
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the HTTP protocol. The standard protocol to use
|
|
* is `HTTP/1.1`.
|
|
*
|
|
* @param string $protocol Protocol to set to the request/response
|
|
* @return mixed
|
|
*/
|
|
public function protocol($protocol = NULL)
|
|
{
|
|
if ($protocol)
|
|
{
|
|
$this->_protocol = strtoupper($protocol);
|
|
return $this;
|
|
}
|
|
|
|
if ($this->_protocol === NULL)
|
|
{
|
|
$this->_protocol = HTTP::$protocol;
|
|
}
|
|
|
|
return $this->_protocol;
|
|
}
|
|
|
|
/**
|
|
* Sets or gets the HTTP status from this response.
|
|
*
|
|
* // Set the HTTP status to 404 Not Found
|
|
* $response = Response::factory()
|
|
* ->status(404);
|
|
*
|
|
* // Get the current status
|
|
* $status = $response->status();
|
|
*
|
|
* @param integer $status Status to set to this response
|
|
* @return mixed
|
|
*/
|
|
public function status($status = NULL)
|
|
{
|
|
if ($status === NULL)
|
|
{
|
|
return $this->_status;
|
|
}
|
|
elseif (array_key_exists($status, Response::$messages))
|
|
{
|
|
$this->_status = (int) $status;
|
|
return $this;
|
|
}
|
|
else
|
|
{
|
|
throw new Kohana_Exception(__METHOD__.' unknown status value : :value', array(':value' => $status));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets and sets headers to the [Response], allowing chaining
|
|
* of response methods. If chaining isn't required, direct
|
|
* access to the property should be used instead.
|
|
*
|
|
* // Get a header
|
|
* $accept = $response->headers('Content-Type');
|
|
*
|
|
* // Set a header
|
|
* $response->headers('Content-Type', 'text/html');
|
|
*
|
|
* // Get all headers
|
|
* $headers = $response->headers();
|
|
*
|
|
* // Set multiple headers
|
|
* $response->headers(array('Content-Type' => 'text/html', 'Cache-Control' => 'no-cache'));
|
|
*
|
|
* @param mixed $key
|
|
* @param string $value
|
|
* @return mixed
|
|
*/
|
|
public function headers($key = NULL, $value = NULL)
|
|
{
|
|
if ($key === NULL)
|
|
{
|
|
return $this->_header;
|
|
}
|
|
elseif (is_array($key))
|
|
{
|
|
$this->_header->exchangeArray($key);
|
|
return $this;
|
|
}
|
|
elseif ($value === NULL)
|
|
{
|
|
return Arr::get($this->_header, $key);
|
|
}
|
|
else
|
|
{
|
|
$this->_header[$key] = $value;
|
|
return $this;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the length of the body for use with
|
|
* content header
|
|
*
|
|
* @return integer
|
|
*/
|
|
public function content_length()
|
|
{
|
|
return strlen($this->body());
|
|
}
|
|
|
|
/**
|
|
* Set and get cookies values for this response.
|
|
*
|
|
* // Get the cookies set to the response
|
|
* $cookies = $response->cookie();
|
|
*
|
|
* // Set a cookie to the response
|
|
* $response->cookie('session', array(
|
|
* 'value' => $value,
|
|
* 'expiration' => 12352234
|
|
* ));
|
|
*
|
|
* @param mixed $key cookie name, or array of cookie values
|
|
* @param string $value value to set to cookie
|
|
* @return string
|
|
* @return void
|
|
* @return [Response]
|
|
*/
|
|
public function cookie($key = NULL, $value = NULL)
|
|
{
|
|
// Handle the get cookie calls
|
|
if ($key === NULL)
|
|
return $this->_cookies;
|
|
elseif ( ! is_array($key) AND ! $value)
|
|
return Arr::get($this->_cookies, $key);
|
|
|
|
// Handle the set cookie calls
|
|
if (is_array($key))
|
|
{
|
|
reset($key);
|
|
while (list($_key, $_value) = each($key))
|
|
{
|
|
$this->cookie($_key, $_value);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if ( ! is_array($value))
|
|
{
|
|
$value = array(
|
|
'value' => $value,
|
|
'expiration' => Cookie::$expiration
|
|
);
|
|
}
|
|
elseif ( ! isset($value['expiration']))
|
|
{
|
|
$value['expiration'] = Cookie::$expiration;
|
|
}
|
|
|
|
$this->_cookies[$key] = $value;
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Deletes a cookie set to the response
|
|
*
|
|
* @param string $name
|
|
* @return Response
|
|
*/
|
|
public function delete_cookie($name)
|
|
{
|
|
unset($this->_cookies[$name]);
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Deletes all cookies from this response
|
|
*
|
|
* @return Response
|
|
*/
|
|
public function delete_cookies()
|
|
{
|
|
$this->_cookies = array();
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Sends the response status and all set headers.
|
|
*
|
|
* @param boolean $replace replace existing headers
|
|
* @param callback $callback function to handle header output
|
|
* @return mixed
|
|
*/
|
|
public function send_headers($replace = FALSE, $callback = NULL)
|
|
{
|
|
return $this->_header->send_headers($this, $replace, $callback);
|
|
}
|
|
|
|
/**
|
|
* Send file download as the response. All execution will be halted when
|
|
* this method is called! Use TRUE for the filename to send the current
|
|
* response as the file content. The third parameter allows the following
|
|
* options to be set:
|
|
*
|
|
* Type | Option | Description | Default Value
|
|
* ----------|-----------|------------------------------------|--------------
|
|
* `boolean` | inline | Display inline instead of download | `FALSE`
|
|
* `string` | mime_type | Manual mime type | Automatic
|
|
* `boolean` | delete | Delete the file after sending | `FALSE`
|
|
*
|
|
* Download a file that already exists:
|
|
*
|
|
* $request->send_file('media/packages/kohana.zip');
|
|
*
|
|
* Download generated content as a file:
|
|
*
|
|
* $request->response($content);
|
|
* $request->send_file(TRUE, $filename);
|
|
*
|
|
* [!!] No further processing can be done after this method is called!
|
|
*
|
|
* @param string $filename filename with path, or TRUE for the current response
|
|
* @param string $download downloaded file name
|
|
* @param array $options additional options
|
|
* @return void
|
|
* @throws Kohana_Exception
|
|
* @uses File::mime_by_ext
|
|
* @uses File::mime
|
|
* @uses Request::send_headers
|
|
*/
|
|
public function send_file($filename, $download = NULL, array $options = NULL)
|
|
{
|
|
if ( ! empty($options['mime_type']))
|
|
{
|
|
// The mime-type has been manually set
|
|
$mime = $options['mime_type'];
|
|
}
|
|
|
|
if ($filename === TRUE)
|
|
{
|
|
if (empty($download))
|
|
{
|
|
throw new Kohana_Exception('Download name must be provided for streaming files');
|
|
}
|
|
|
|
// Temporary files will automatically be deleted
|
|
$options['delete'] = FALSE;
|
|
|
|
if ( ! isset($mime))
|
|
{
|
|
// Guess the mime using the file extension
|
|
$mime = File::mime_by_ext(strtolower(pathinfo($download, PATHINFO_EXTENSION)));
|
|
}
|
|
|
|
// Force the data to be rendered if
|
|
$file_data = (string) $this->_body;
|
|
|
|
// Get the content size
|
|
$size = strlen($file_data);
|
|
|
|
// Create a temporary file to hold the current response
|
|
$file = tmpfile();
|
|
|
|
// Write the current response into the file
|
|
fwrite($file, $file_data);
|
|
|
|
// File data is no longer needed
|
|
unset($file_data);
|
|
}
|
|
else
|
|
{
|
|
// Get the complete file path
|
|
$filename = realpath($filename);
|
|
|
|
if (empty($download))
|
|
{
|
|
// Use the file name as the download file name
|
|
$download = pathinfo($filename, PATHINFO_BASENAME);
|
|
}
|
|
|
|
// Get the file size
|
|
$size = filesize($filename);
|
|
|
|
if ( ! isset($mime))
|
|
{
|
|
// Get the mime type from the extension of the download file
|
|
$mime = File::mime_by_ext(pathinfo($download, PATHINFO_EXTENSION));
|
|
}
|
|
|
|
// Open the file for reading
|
|
$file = fopen($filename, 'rb');
|
|
}
|
|
|
|
if ( ! is_resource($file))
|
|
{
|
|
throw new Kohana_Exception('Could not read file to send: :file', array(
|
|
':file' => $download,
|
|
));
|
|
}
|
|
|
|
// Inline or download?
|
|
$disposition = empty($options['inline']) ? 'attachment' : 'inline';
|
|
|
|
// Calculate byte range to download.
|
|
list($start, $end) = $this->_calculate_byte_range($size);
|
|
|
|
if ( ! empty($options['resumable']))
|
|
{
|
|
if ($start > 0 OR $end < ($size - 1))
|
|
{
|
|
// Partial Content
|
|
$this->_status = 206;
|
|
}
|
|
|
|
// Range of bytes being sent
|
|
$this->_header['content-range'] = 'bytes '.$start.'-'.$end.'/'.$size;
|
|
$this->_header['accept-ranges'] = 'bytes';
|
|
}
|
|
|
|
// Set the headers for a download
|
|
$this->_header['content-disposition'] = $disposition.'; filename="'.$download.'"';
|
|
$this->_header['content-type'] = $mime;
|
|
$this->_header['content-length'] = (string) (($end - $start) + 1);
|
|
|
|
if (Request::user_agent('browser') === 'Internet Explorer')
|
|
{
|
|
// Naturally, IE does not act like a real browser...
|
|
if (Request::$initial->secure())
|
|
{
|
|
// http://support.microsoft.com/kb/316431
|
|
$this->_header['pragma'] = $this->_header['cache-control'] = 'public';
|
|
}
|
|
|
|
if (version_compare(Request::user_agent('version'), '8.0', '>='))
|
|
{
|
|
// http://ajaxian.com/archives/ie-8-security
|
|
$this->_header['x-content-type-options'] = 'nosniff';
|
|
}
|
|
}
|
|
|
|
// Send all headers now
|
|
$this->send_headers();
|
|
|
|
while (ob_get_level())
|
|
{
|
|
// Flush all output buffers
|
|
ob_end_flush();
|
|
}
|
|
|
|
// Manually stop execution
|
|
ignore_user_abort(TRUE);
|
|
|
|
if ( ! Kohana::$safe_mode)
|
|
{
|
|
// Keep the script running forever
|
|
set_time_limit(0);
|
|
}
|
|
|
|
// Send data in 16kb blocks
|
|
$block = 1024 * 16;
|
|
|
|
fseek($file, $start);
|
|
|
|
while ( ! feof($file) AND ($pos = ftell($file)) <= $end)
|
|
{
|
|
if (connection_aborted())
|
|
break;
|
|
|
|
if ($pos + $block > $end)
|
|
{
|
|
// Don't read past the buffer.
|
|
$block = $end - $pos + 1;
|
|
}
|
|
|
|
// Output a block of the file
|
|
echo fread($file, $block);
|
|
|
|
// Send the data now
|
|
flush();
|
|
}
|
|
|
|
// Close the file
|
|
fclose($file);
|
|
|
|
if ( ! empty($options['delete']))
|
|
{
|
|
try
|
|
{
|
|
// Attempt to remove the file
|
|
unlink($filename);
|
|
}
|
|
catch (Exception $e)
|
|
{
|
|
// Create a text version of the exception
|
|
$error = Kohana_Exception::text($e);
|
|
|
|
if (is_object(Kohana::$log))
|
|
{
|
|
// Add this exception to the log
|
|
Kohana::$log->add(Log::ERROR, $error);
|
|
|
|
// Make sure the logs are written
|
|
Kohana::$log->write();
|
|
}
|
|
|
|
// Do NOT display the exception, it will corrupt the output!
|
|
}
|
|
}
|
|
|
|
// Stop execution
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Renders the HTTP_Interaction to a string, producing
|
|
*
|
|
* - Protocol
|
|
* - Headers
|
|
* - Body
|
|
*
|
|
* @return string
|
|
*/
|
|
public function render()
|
|
{
|
|
if ( ! $this->_header->offsetExists('content-type'))
|
|
{
|
|
// Add the default Content-Type header if required
|
|
$this->_header['content-type'] = Kohana::$content_type.'; charset='.Kohana::$charset;
|
|
}
|
|
|
|
// Set the content length
|
|
$this->headers('content-length', (string) $this->content_length());
|
|
|
|
// If Kohana expose, set the user-agent
|
|
if (Kohana::$expose)
|
|
{
|
|
$this->headers('user-agent', Kohana::version());
|
|
}
|
|
|
|
// Prepare cookies
|
|
if ($this->_cookies)
|
|
{
|
|
if (extension_loaded('http'))
|
|
{
|
|
$cookies = version_compare(phpversion('http'), '2.0.0', '>=') ?
|
|
(string) new \http\Cookie($this->_cookies) :
|
|
http_build_cookie($this->_cookies);
|
|
$this->_header['set-cookie'] = $cookies;
|
|
}
|
|
else
|
|
{
|
|
$cookies = array();
|
|
|
|
// Parse each
|
|
foreach ($this->_cookies as $key => $value)
|
|
{
|
|
$string = $key.'='.$value['value'].'; expires='.date('l, d M Y H:i:s T', $value['expiration']);
|
|
$cookies[] = $string;
|
|
}
|
|
|
|
// Create the cookie string
|
|
$this->_header['set-cookie'] = $cookies;
|
|
}
|
|
}
|
|
|
|
$output = $this->_protocol.' '.$this->_status.' '.Response::$messages[$this->_status]."\r\n";
|
|
$output .= (string) $this->_header;
|
|
$output .= $this->_body;
|
|
|
|
return $output;
|
|
}
|
|
|
|
/**
|
|
* Generate ETag
|
|
* Generates an ETag from the response ready to be returned
|
|
*
|
|
* @throws Request_Exception
|
|
* @return String Generated ETag
|
|
*/
|
|
public function generate_etag()
|
|
{
|
|
if ($this->_body === '')
|
|
{
|
|
throw new Request_Exception('No response yet associated with request - cannot auto generate resource ETag');
|
|
}
|
|
|
|
// Generate a unique hash for the response
|
|
return '"'.sha1($this->render()).'"';
|
|
}
|
|
|
|
/**
|
|
* Parse the byte ranges from the HTTP_RANGE header used for
|
|
* resumable downloads.
|
|
*
|
|
* @link http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35
|
|
* @return array|FALSE
|
|
*/
|
|
protected function _parse_byte_range()
|
|
{
|
|
if ( ! isset($_SERVER['HTTP_RANGE']))
|
|
{
|
|
return FALSE;
|
|
}
|
|
|
|
// TODO, speed this up with the use of string functions.
|
|
preg_match_all('/(-?[0-9]++(?:-(?![0-9]++))?)(?:-?([0-9]++))?/', $_SERVER['HTTP_RANGE'], $matches, PREG_SET_ORDER);
|
|
|
|
return $matches[0];
|
|
}
|
|
|
|
/**
|
|
* Calculates the byte range to use with send_file. If HTTP_RANGE doesn't
|
|
* exist then the complete byte range is returned
|
|
*
|
|
* @param integer $size
|
|
* @return array
|
|
*/
|
|
protected function _calculate_byte_range($size)
|
|
{
|
|
// Defaults to start with when the HTTP_RANGE header doesn't exist.
|
|
$start = 0;
|
|
$end = $size - 1;
|
|
|
|
if ($range = $this->_parse_byte_range())
|
|
{
|
|
// We have a byte range from HTTP_RANGE
|
|
$start = $range[1];
|
|
|
|
if ($start[0] === '-')
|
|
{
|
|
// A negative value means we start from the end, so -500 would be the
|
|
// last 500 bytes.
|
|
$start = $size - abs($start);
|
|
}
|
|
|
|
if (isset($range[2]))
|
|
{
|
|
// Set the end range
|
|
$end = $range[2];
|
|
}
|
|
}
|
|
|
|
// Normalize values.
|
|
$start = abs(intval($start));
|
|
|
|
// Keep the the end value in bounds and normalize it.
|
|
$end = min(abs(intval($end)), $size - 1);
|
|
|
|
// Keep the start in bounds.
|
|
$start = ($end < $start) ? 0 : max($start, 0);
|
|
|
|
return array($start, $end);
|
|
}
|
|
|
|
}
|