200)); * * @param array $config Setup the response object * @return Response */ public static function factory(array $config = []) { return new Response($config); } // HTTP status codes and messages public static $messages = [ // Informational 1xx 100 => 'Continue', 101 => 'Switching Protocols', 102 => 'Processing', // Success 2xx 200 => 'OK', 201 => 'Created', 202 => 'Accepted', 203 => 'Non-Authoritative Information', 204 => 'No Content', 205 => 'Reset Content', 206 => 'Partial Content', 207 => 'Multi-Status', 208 => 'Already Reported', // 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', 422 => 'Unprocessable Entity', 423 => 'Locked', 424 => 'Failed Dependency', 429 => 'Too Many Requests', // Server Error 5xx 500 => 'Internal Server Error', 501 => 'Not Implemented', 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Timeout', 505 => 'HTTP Version Not Supported', 507 => 'Insufficient Storage', 508 => 'Loop Detected', 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 = []; /** * @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 = []) { $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 * * @param mixed $content Content of body * * @return mixed */ public function body($content = NULL) { if ($content === NULL) { return $this->_body; } // Cast scalar types or objects (with __toString method) to string if (is_scalar($content) || (is_object($content) && method_exists($content, '__toString'))) { $content = (string)$content; } $this->_body = $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 KO7_Response|int|boolean acting as setter \ acting as getter \ false on invalid status code */ public function status($status = NULL) { if ($status === NULL) { return $this->_status; } if (array_key_exists($status, Response::$messages)) { $this->_status = (int) $status; return $this; } return false; } /** * 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); foreach ($key as $_key => $_value) { $this->cookie($_key, $_value); } } else { if ( ! is_array($value)) { $value = [ '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 = []; 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/ko7.zip'); * * Download a generated file: * * $csv = tmpfile(); * fputcsv($csv, ['label1', 'label2']); * $request->send_file($csv, $filename); * * 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|resource|bool $filename filename with path, file stream, or TRUE for the current response * @param string $download downloaded file name * @param array $options additional options * @return void * @throws KO7_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 KO7_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 if (is_resource($filename) && get_resource_type($filename) === 'stream') { if (empty($download)) { throw new KO7_Exception('Download name must be provided for streaming files'); } // Make sure this is a file handle $file_meta = stream_get_meta_data($filename); if ($file_meta['seekable'] === FALSE) { throw new KO7_Exception('Resource must be a file handle'); } // Handle file streams passed in as resources $file = $filename; $size = fstat($file)['size']; } 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 KO7_Exception('Could not read file to send: :file', [ ':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); // 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 = KO7_Exception::text($e); if (is_object(KO7::$log)) { // Add this exception to the log KO7::$log->add(Log::ERROR, $error); // Make sure the logs are written KO7::$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'] = KO7::$content_type.'; charset='.KO7::$charset; } // Set the content length $this->headers('content-length', (string) $this->content_length()); // If KO7 expose, set the user-agent if (KO7::$expose) { $this->headers('user-agent', KO7::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 = []; // 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 [$start, $end]; } }