diff --git a/src/Control/HTTPResponse.php b/src/Control/HTTPResponse.php index 4060509c8..ee7eb427b 100644 --- a/src/Control/HTTPResponse.php +++ b/src/Control/HTTPResponse.php @@ -3,7 +3,6 @@ namespace SilverStripe\Control; use InvalidArgumentException; -use Monolog\Formatter\FormatterInterface; use Monolog\Handler\HandlerInterface; use SilverStripe\Core\Convert; use SilverStripe\Core\Injector\Injectable; @@ -176,7 +175,8 @@ class HTTPResponse */ public function isError() { - return $this->statusCode && ($this->statusCode < 200 || $this->statusCode > 399); + $statusCode = $this->getStatusCode(); + return $statusCode && ($statusCode < 200 || $statusCode > 399); } /** @@ -257,12 +257,12 @@ class HTTPResponse $code = 302; } $this->setStatusCode($code); - $this->headers['Location'] = $dest; + $this->addHeader('Location', $dest); return $this; } /** - * Send this HTTPReponse to the browser + * Send this HTTPResponse to the browser */ public function output() { @@ -271,55 +271,86 @@ class HTTPResponse Requirements::include_in_response($this); } - if (in_array($this->statusCode, self::$redirect_codes) && headers_sent($file, $line)) { - $url = Director::absoluteURL($this->headers['Location'], true); - $urlATT = Convert::raw2htmlatt($url); - $urlJS = Convert::raw2js($url); - $title = Director::isDev() - ? "{$urlATT}... (output started on {$file}, line {$line})" - : "{$urlATT}..."; - echo <<isRedirect() && headers_sent()) { + $this->htmlRedirect(); + } else { + $this->outputHeaders(); + $this->outputBody(); + } + } + + /** + * Generate a browser redirect without setting headers + */ + protected function htmlRedirect() + { + $headersSent = headers_sent($file, $line); + $location = $this->getHeader('Location'); + $url = Director::absoluteURL($location); + $urlATT = Convert::raw2htmlatt($url); + $urlJS = Convert::raw2js($url); + $title = (Director::isDev() && $headersSent) + ? "{$urlATT}... (output started on {$file}, line {$line})" + : "{$urlATT}..."; + echo <<Redirecting to {$title}

EOT - ; - } else { - $line = $file = null; - if (!headers_sent($file, $line)) { - header($_SERVER['SERVER_PROTOCOL'] . " $this->statusCode " . $this->getStatusDescription()); - foreach ($this->headers as $header => $value) { - //etags need to be quoted - if (strcasecmp('etag', $header) === 0 && 0 !== strpos($value, '"')) { - $value = sprintf('"%s"', $value); - } - header("$header: $value", true, $this->statusCode); - } - } else { - // It's critical that these status codes are sent; we need to report a failure if not. - if ($this->statusCode >= 300) { - user_error( - "Couldn't set response type to $this->statusCode because " . - "of output on line $line of $file", - E_USER_WARNING - ); - } - } + ; + } - // If this is an error but no error body has yet been generated, - // delegate formatting to current error handler. - if ($this->isError() && !$this->body) { - /** @var HandlerInterface $handler */ - $handler = Injector::inst()->get(HandlerInterface::class); - $formatter = $handler->getFormatter(); - echo $formatter->format(array( - 'code' => $this->statusCode - )); - } else { - echo $this->body; + /** + * Output HTTP headers to the browser + */ + protected function outputHeaders() + { + $headersSent = headers_sent($file, $line); + if (!$headersSent) { + $method = sprintf( + "%s %d %s", + $_SERVER['SERVER_PROTOCOL'], + $this->getStatusCode(), + $this->getStatusDescription() + ); + header($method); + foreach ($this->getHeaders() as $header => $value) { + header("{$header}: {$value}", true, $this->getStatusCode()); } + } elseif ($this->getStatusCode() >= 300) { + // It's critical that these status codes are sent; we need to report a failure if not. + user_error( + sprintf( + "Couldn't set response type to %d because of output on line %s of %s", + $this->getStatusCode(), + $line, + $file + ), + E_USER_WARNING + ); + } + } + + /** + * Output body of this response to the browser + */ + protected function outputBody() + { + // Only show error pages or generic "friendly" errors if the status code signifies + // an error, and the response doesn't have any body yet that might contain + // a more specific error description. + $body = $this->getBody(); + if ($this->isError() && empty($body)) { + /** @var HandlerInterface $handler */ + $handler = Injector::inst()->get(HandlerInterface::class); + $formatter = $handler->getFormatter(); + echo $formatter->format(array( + 'code' => $this->statusCode + )); + } else { + echo $this->body; } } @@ -331,6 +362,16 @@ EOT */ public function isFinished() { - return in_array($this->statusCode, array(301, 302, 303, 304, 305, 307, 401, 403)); + return $this->isRedirect() || $this->isError(); + } + + /** + * Determine if this response is a redirect + * + * @return bool + */ + public function isRedirect() + { + return in_array($this->getStatusCode(), self::$redirect_codes); } } diff --git a/src/Control/HTTPStreamResponse.php b/src/Control/HTTPStreamResponse.php new file mode 100644 index 000000000..c547c8ce8 --- /dev/null +++ b/src/Control/HTTPStreamResponse.php @@ -0,0 +1,166 @@ +setStream($stream); + if ($contentLength) { + $this->addHeader('Content-Length', $contentLength); + } + } + + /** + * Determine if a stream is seekable + * + * @return bool + */ + protected function isSeekable() + { + $stream = $this->getStream(); + if (!$stream) { + return false; + } + $metadata = stream_get_meta_data($stream); + return $metadata['seekable']; + } + + /** + * @return resource + */ + public function getStream() + { + return $this->stream; + } + + /** + * @param resource $stream + * @return $this + */ + public function setStream($stream) + { + $this->setBody(null); + $this->stream = $stream; + return $this; + } + + /** + * Get body prior to stream traversal + * + * @return string + */ + public function getSavedBody() + { + return parent::getBody(); + } + + public function getBody() + { + $body = $this->getSavedBody(); + if (isset($body)) { + return $body; + } + + // Consume stream into string + $body = $this->consumeStream(function ($stream) { + $body = stream_get_contents($stream); + + // If this stream isn't seekable, we'll need to save the body + // in case of subsequent requests. + if (!$this->isSeekable()) { + $this->setBody($body); + } + return $body; + }); + return $body; + } + + /** + * Safely consume the stream + * + * @param callable $callback Callback which will perform the consumable action on the stream + * @return mixed Result of $callback($stream) or null if no stream available + * @throws BadMethodCallException Throws exception if stream can't be re-consumed + */ + protected function consumeStream($callback) + { + // Load from stream + $stream = $this->getStream(); + if (!$stream) { + return null; + } + + // Check if stream must be rewound + if ($this->consumed) { + if (!$this->isSeekable()) { + throw new BadMethodCallException( + "Unseekable stream has already been consumed" + ); + } + rewind($stream); + } + + // Consume + $this->consumed = true; + return $callback($stream); + } + + /** + * Output body of this response to the browser + */ + protected function outputBody() + { + // If the output has been overwritten, or the stream is irreversable and has + // already been consumed, return the cached body. + $body = $this->getSavedBody(); + if ($body) { + echo $body; + return; + } + + // Stream to output + if ($this->getStream()) { + $this->consumeStream(function ($stream) { + fpassthru($stream); + }); + return; + } + + // Fail over + parent::outputBody(); + } +} diff --git a/tests/php/Control/HTTPStreamResponseTest.php b/tests/php/Control/HTTPStreamResponseTest.php new file mode 100644 index 000000000..9ef389d41 --- /dev/null +++ b/tests/php/Control/HTTPStreamResponseTest.php @@ -0,0 +1,63 @@ +assertEquals("Test output\n", $response->getBody()); + + // Test stream output + ob_start(); + $response->output(); + $result = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals(12, $response->getHeader('Content-Length')); + $this->assertEquals("Test output\n", $result); + } + + /** + * Test stream directly without loading into memory + */ + public function testDirectStream() + { + $path = __DIR__ . '/HTTPStreamResponseTest/testfile.txt'; + $stream = fopen($path, 'r'); + $metadata = stream_get_meta_data($stream); + $this->assertTrue($metadata['seekable']); + $response = new HTTPStreamResponse($stream, filesize($path)); + + // Test stream output + ob_start(); + $response->output(); + $result = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals(12, $response->getHeader('Content-Length')); + $this->assertEquals("Test output\n", $result); + $this->assertEmpty($response->getSavedBody(), 'Body of seekable stream is un-cached'); + + // Seekable stream can be repeated + ob_start(); + $response->output(); + $result = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals(12, $response->getHeader('Content-Length')); + $this->assertEquals("Test output\n", $result); + $this->assertEmpty($response->getSavedBody(), 'Body of seekable stream is un-cached'); + } +} diff --git a/tests/php/Control/HTTPStreamResponseTest/testfile.txt b/tests/php/Control/HTTPStreamResponseTest/testfile.txt new file mode 100644 index 000000000..400627996 --- /dev/null +++ b/tests/php/Control/HTTPStreamResponseTest/testfile.txt @@ -0,0 +1 @@ +Test output