From dc00234dffc9bf10fef06a88163da2f594e9f867 Mon Sep 17 00:00:00 2001 From: Benjamin Franzke Date: Wed, 2 Aug 2017 16:06:54 +0200 Subject: [PATCH] [TASK] Adapt FAL dumpFile to use PSR-7 response objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new driver method streamFile() is added (specified in a new, internal StreamableDriverInterface). streamFile() returns a PSR-7 response which serves the contents of the file. Once this interface will be marked as public, third party drivers will be allowed to return an own response (e.g. containing a redirect to a CDN), providing full controls to headers. It also opens possibilties for optimizations like X-SendFile (apache) or X-Accell-Redirect (nginx) to be used by drivers. We also add SelfEmittableStreamInterface (marked as internal) to support the same fast file sending using readfile() – the interface provides a hook which is called by the AbstractApplication in sendResponse. That means that file contents do not need to be read into memory, stored into a stream, and then read again, but can be piped to stdout by php directly. For all existing drivers backward compatibility is provided by wrapping their dumpFileContents() method into a decorator stream which calls dumpFileContents *when* the response is sent. That means middlewares are able to prevent/stop/enhance the response, but the driver method dumpFileContents is still used – it's delayed until Application::sendResponse. The dumpFileContents method of the ResourceStorage class is now deprecated. ResourceStorage->streamFile() should be used instead. Change-Id: I64e707c1f8350e409ff2505b98531b92b2936e02 Releases: master Resolves: #83793 Reviewed-on: https://review.typo3.org/55585 Reviewed-by: Benni Mack Tested-by: Benni Mack Tested-by: TYPO3com Reviewed-by: Susanne Moog Tested-by: Susanne Moog --- .../Classes/Controller/FileDumpController.php | 15 +-- .../core/Classes/Http/AbstractApplication.php | 10 +- .../FalDumpFileContentsDecoratorStream.php | 108 ++++++++++++++++++ .../Http/SelfEmittableLazyOpenStream.php | 70 ++++++++++++ .../Http/SelfEmittableStreamInterface.php | 32 ++++++ .../Classes/Resource/Driver/LocalDriver.php | 36 +++++- .../Driver/StreamableDriverInterface.php | 37 ++++++ .../Hook/FileDumpEIDHookInterface.php | 3 + .../core/Classes/Resource/ResourceStorage.php | 66 +++++++++++ ...93-FALResourceStorage-dumpFileContents.rst | 32 ++++++ .../Php/MethodCallMatcher.php | 7 ++ 11 files changed, 406 insertions(+), 10 deletions(-) create mode 100644 typo3/sysext/core/Classes/Http/FalDumpFileContentsDecoratorStream.php create mode 100644 typo3/sysext/core/Classes/Http/SelfEmittableLazyOpenStream.php create mode 100644 typo3/sysext/core/Classes/Http/SelfEmittableStreamInterface.php create mode 100644 typo3/sysext/core/Classes/Resource/Driver/StreamableDriverInterface.php create mode 100644 typo3/sysext/core/Documentation/Changelog/master/Deprecation-83793-FALResourceStorage-dumpFileContents.rst diff --git a/typo3/sysext/core/Classes/Controller/FileDumpController.php b/typo3/sysext/core/Classes/Controller/FileDumpController.php index cb60a1984a17..a2ab3d4c300f 100644 --- a/typo3/sysext/core/Classes/Controller/FileDumpController.php +++ b/typo3/sysext/core/Classes/Controller/FileDumpController.php @@ -21,7 +21,6 @@ use TYPO3\CMS\Core\Resource\ProcessedFileRepository; use TYPO3\CMS\Core\Resource\ResourceFactory; use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Core\Utility\HttpUtility; /** * Class FileDumpController @@ -73,20 +72,22 @@ public function dumpAction(ServerRequestInterface $request) } if ($file === null) { - HttpUtility::setResponseCodeAndExit(HttpUtility::HTTP_STATUS_404); + return (new Response)->withStatus(404); } - // Hook: allow some other process to do some security/access checks. Hook should issue 403 if access is rejected + // Hook: allow some other process to do some security/access checks. Hook should return 403 response if access is rejected, void otherwise foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['FileDumpEID.php']['checkFileAccess'] ?? [] as $className) { $hookObject = GeneralUtility::makeInstance($className); if (!$hookObject instanceof FileDumpEIDHookInterface) { throw new \UnexpectedValueException($className . ' must implement interface ' . FileDumpEIDHookInterface::class, 1394442417); } - $hookObject->checkFileAccess($file); + $response = $hookObject->checkFileAccess($file); + if ($response instanceof ResponseInterface) { + return $response; + } } - $file->getStorage()->dumpFileContents($file); - // @todo Refactor FAL to not echo directly, but to implement a stream for output here and use response - return null; + + return $file->getStorage()->streamFile($file); } return (new Response)->withStatus(403); } diff --git a/typo3/sysext/core/Classes/Http/AbstractApplication.php b/typo3/sysext/core/Classes/Http/AbstractApplication.php index 181cff632107..909df57fc5da 100644 --- a/typo3/sysext/core/Classes/Http/AbstractApplication.php +++ b/typo3/sysext/core/Classes/Http/AbstractApplication.php @@ -59,7 +59,7 @@ protected function createMiddlewareDispatcher(RequestHandlerInterface $requestHa */ protected function sendResponse(ResponseInterface $response) { - if ($response instanceof \TYPO3\CMS\Core\Http\NullResponse) { + if ($response instanceof NullResponse) { return; } @@ -77,7 +77,13 @@ protected function sendResponse(ResponseInterface $response) header($name . ': ' . implode(', ', $values)); } } - echo $response->getBody()->__toString(); + $body = $response->getBody(); + if ($body instanceof SelfEmittableStreamInterface) { + // Optimization for streams that use php functions like readfile() as fastpath for serving files. + $body->emit(); + } else { + echo $body->__toString(); + } } /** diff --git a/typo3/sysext/core/Classes/Http/FalDumpFileContentsDecoratorStream.php b/typo3/sysext/core/Classes/Http/FalDumpFileContentsDecoratorStream.php new file mode 100644 index 000000000000..8e0c5483da24 --- /dev/null +++ b/typo3/sysext/core/Classes/Http/FalDumpFileContentsDecoratorStream.php @@ -0,0 +1,108 @@ +identifier = $identifier; + $this->driver = $driver; + $this->size = $size; + } + + /** + * Emit the response to stdout, as specified in SelfEmittableStreamInterface. + * Offload to the driver method dumpFileContents. + */ + public function emit() + { + $this->driver->dumpFileContents($this->identifier); + } + + /** + * Creates a stream (on demand). This method is consumed by the guzzle StreamDecoratorTrait + * and is used when this stream is used without the emit() fastpath. + * + * @return StreamInterface + */ + protected function createStream(): StreamInterface + { + $stream = new Stream('php://temp', 'rw'); + $stream->write($this->driver->getFileContents($this->identifier)); + return $stream; + } + + /** + * @return int + */ + public function getSize(): int + { + return $this->size; + } + + /** + * @return bool + */ + public function isWritable(): bool + { + return false; + } + + /** + * @param string $string + * @throws \RuntimeException on failure. + */ + public function write($string) + { + throw new \RuntimeException('Cannot write to a ' . self::class, 1538331852); + } +} diff --git a/typo3/sysext/core/Classes/Http/SelfEmittableLazyOpenStream.php b/typo3/sysext/core/Classes/Http/SelfEmittableLazyOpenStream.php new file mode 100644 index 000000000000..6542e62dd2a4 --- /dev/null +++ b/typo3/sysext/core/Classes/Http/SelfEmittableLazyOpenStream.php @@ -0,0 +1,70 @@ +filename = $filename; + } + + /** + * Output the contents of the file to the output buffer + */ + public function emit() + { + readfile($this->filename, false); + } + + /** + * @return bool + */ + public function isWritable(): bool + { + return false; + } + + /** + * @param string $string + * @throws \RuntimeException on failure. + */ + public function write($string) + { + throw new \RuntimeException('Cannot write to a ' . self::class, 1538331833); + } +} diff --git a/typo3/sysext/core/Classes/Http/SelfEmittableStreamInterface.php b/typo3/sysext/core/Classes/Http/SelfEmittableStreamInterface.php new file mode 100644 index 000000000000..b1c41542e843 --- /dev/null +++ b/typo3/sysext/core/Classes/Http/SelfEmittableStreamInterface.php @@ -0,0 +1,32 @@ +getAbsolutePath($this->canonicalizeAndCheckFileIdentifier($identifier)), 0); } + /** + * Stream file using a PSR-7 Response object. + * + * @param string $identifier + * @param array $properties + * @return ResponseInterface + */ + public function streamFile(string $identifier, array $properties): ResponseInterface + { + $fileInfo = $this->getFileInfoByIdentifier($identifier, ['name', 'mimetype', 'mtime', 'size']); + $downloadName = $properties['filename_overwrite'] ?? $fileInfo['name'] ?? ''; + $mimeType = $properties['mimetype_overwrite'] ?? $fileInfo['mimetype'] ?? ''; + $contentDisposition = ($properties['as_download'] ?? false) ? 'attachment' : 'inline'; + + $filePath = $this->getAbsolutePath($this->canonicalizeAndCheckFileIdentifier($identifier)); + + return new Response( + new SelfEmittableLazyOpenStream($filePath), + 200, + [ + 'Content-Disposition' => $contentDisposition . '; filename="' . $downloadName . '"', + 'Content-Type' => $mimeType, + 'Content-Length' => (string)$fileInfo['size'], + 'Last-Modified' => gmdate('D, d M Y H:i:s', $fileInfo['mtime']) . ' GMT', + // Cache-Control header is needed here to solve an issue with browser IE8 and lower + // See for more information: http://support.microsoft.com/kb/323308 + 'Cache-Control' => '', + ] + ); + } + /** * Get the path of the nearest recycler folder of a given $path. * Return an empty string if there is no recycler folder available. diff --git a/typo3/sysext/core/Classes/Resource/Driver/StreamableDriverInterface.php b/typo3/sysext/core/Classes/Resource/Driver/StreamableDriverInterface.php new file mode 100644 index 000000000000..3506a4290ebf --- /dev/null +++ b/typo3/sysext/core/Classes/Resource/Driver/StreamableDriverInterface.php @@ -0,0 +1,37 @@ +dumpFileContents() will be removed in TYPO3 v10.0. Use streamFile() instead.', E_USER_DEPRECATED); + $downloadName = $alternativeFilename ?: $file->getName(); $contentDisposition = $asDownload ? 'attachment' : 'inline'; header('Content-Disposition: ' . $contentDisposition . '; filename="' . $downloadName . '"'); @@ -1665,6 +1672,65 @@ public function dumpFileContents(FileInterface $file, $asDownload = false, $alte $this->driver->dumpFileContents($file->getIdentifier()); } + /** + * Returns a PSR-7 Response which can be used to stream the requested file + * + * @param FileInterface $file + * @param bool $asDownload If set Content-Disposition attachment is sent, inline otherwise + * @param string $alternativeFilename the filename for the download (if $asDownload is set) + * @param string $overrideMimeType If set this will be used as Content-Type header instead of the automatically detected mime type. + * @return ResponseInterface + */ + public function streamFile( + FileInterface $file, + bool $asDownload = false, + string $alternativeFilename = null, + string $overrideMimeType = null + ): ResponseInterface { + if (!$this->driver instanceof StreamableDriverInterface) { + return $this->getPseudoStream($file, $asDownload, $alternativeFilename, $overrideMimeType); + } + + $properties = [ + 'as_download' => $asDownload, + 'filename_overwrite' => $alternativeFilename, + 'mimetype_overwrite' => $overrideMimeType, + ]; + return $this->driver->streamFile($file->getIdentifier(), $properties); + } + + /** + * Wrap DriverInterface::dumpFileContents into a SelfEmittableStreamInterface + * + * @param FileInterface $file + * @param bool $asDownload If set Content-Disposition attachment is sent, inline otherwise + * @param string $alternativeFilename the filename for the download (if $asDownload is set) + * @param string $overrideMimeType If set this will be used as Content-Type header instead of the automatically detected mime type. + * @return ResponseInterface + */ + protected function getPseudoStream( + FileInterface $file, + bool $asDownload = false, + string $alternativeFilename = null, + string $overrideMimeType = null + ) { + $downloadName = $alternativeFilename ?: $file->getName(); + $contentDisposition = $asDownload ? 'attachment' : 'inline'; + + $stream = new FalDumpFileContentsDecoratorStream($file->getIdentifier(), $this->driver, $file->getSize()); + $headers = [ + 'Content-Disposition' => $contentDisposition . '; filename="' . $downloadName . '"', + 'Content-Type' => $overrideMimeType ?: $file->getMimeType(), + 'Content-Length' => (string)$file->getSize(), + 'Last-Modified' => gmdate('D, d M Y H:i:s', array_pop($this->driver->getFileInfoByIdentifier($file->getIdentifier(), ['mtime']))) . ' GMT', + // Cache-Control header is needed here to solve an issue with browser IE8 and lower + // See for more information: http://support.microsoft.com/kb/323308 + 'Cache-Control' => '', + ]; + + return new Response($stream, 200, $headers); + } + /** * Set contents of a file object. * diff --git a/typo3/sysext/core/Documentation/Changelog/master/Deprecation-83793-FALResourceStorage-dumpFileContents.rst b/typo3/sysext/core/Documentation/Changelog/master/Deprecation-83793-FALResourceStorage-dumpFileContents.rst new file mode 100644 index 000000000000..6bdaa7e08704 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/master/Deprecation-83793-FALResourceStorage-dumpFileContents.rst @@ -0,0 +1,32 @@ +.. include:: ../../Includes.txt + +============================================================= +Deprecation: #83793 - FAL ResourceStorage->dumpFileContents() +============================================================= + +See :issue:`83793` + +Description +=========== + +The method :php:`ResourceStorage->dumpFileContents()` has been marked as deprecated. + + +Impact +====== + +Calling this method will trigger a PHP deprecation notice. + + +Affected Installations +====================== + +TYPO3 installations with extensions, which use the method. + + +Migration +========= + +Use :php:`ResourceStorage->streamFile()` instead. + +.. index:: FAL, PHP-API, FullyScanned diff --git a/typo3/sysext/install/Configuration/ExtensionScanner/Php/MethodCallMatcher.php b/typo3/sysext/install/Configuration/ExtensionScanner/Php/MethodCallMatcher.php index b2264fb59406..cf796c078959 100644 --- a/typo3/sysext/install/Configuration/ExtensionScanner/Php/MethodCallMatcher.php +++ b/typo3/sysext/install/Configuration/ExtensionScanner/Php/MethodCallMatcher.php @@ -3824,4 +3824,11 @@ 'Deprecation-86461-MarkVariousTypoScriptParsingFunctionalityAsInternal.rst' ], ], + 'TYPO3\CMS\Core\Resource\ResourceStorage->dumpFileContents' => [ + 'numberOfMandatoryArguments' => 1, + 'maximumNumberOfArguments' => 4, + 'restFiles' => [ + 'Deprecation-83793-FALResourceStorage-dumpFileContents.rst' + ], + ], ];