From 032170d278b153c49ca74323026d1fbad0a0f9a5 Mon Sep 17 00:00:00 2001 From: DigiLive Date: Thu, 17 Feb 2022 21:49:14 +0100 Subject: [PATCH] Initial Code commit --- CHANGELOG.md | 5 + README.md | 62 +++ composer.json | 40 ++ src/FileStreamer.php | 404 ++++++++++++++++++ tests/FileStreamerTest.php | 357 ++++++++++++++++ tests/supportFiles/DigiLive/Output/Output.php | 71 +++ .../DigiLive/Reflector/Reflector.php | 136 ++++++ tests/supportFiles/OutputFunctions.php | 88 ++++ updateChangelog.php | 67 +++ 9 files changed, 1230 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 src/FileStreamer.php create mode 100644 tests/FileStreamerTest.php create mode 100644 tests/supportFiles/DigiLive/Output/Output.php create mode 100644 tests/supportFiles/DigiLive/Reflector/Reflector.php create mode 100644 tests/supportFiles/OutputFunctions.php create mode 100644 updateChangelog.php diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e00dd35 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## First Release (Soon) + +* No changes. diff --git a/README.md b/README.md new file mode 100644 index 0000000..23a90d2 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# fileStreamer + +[![GitHub release](https://img.shields.io/github/v/release/DigiLive/fileStreamer?include_prereleases)](https://github.com/DigiLive/gitChangelog/releases) +[![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) + +This library serves a file according to the headers which are sent with a http +request. It supports resumable downloads or streaming the content of a file to a +client. + +If you have any questions, comments or ideas concerning this library, please +consult the code documentation at first. +Create a new [issue](https://github.com/DigiLive/fieStreamer/issues/new) if +your concerns remain unanswered. + +## Features + +* Inline disposition. +* Attachment disposition. +* Serve a complete file. +* Serve a single byte range of a file. +* Serve multiple byte ranges of a file. + +## Requirements + +* PHP ^7.4 +* ext-fileinfo * + +## Installation + +The preferred method is to install the library +with [Composer](http://getcomposer.org). + +```sh +> composer require digilive/file-streamer:^1 +``` + +Set the version constraint to a value which suits you best. +Alternatively you can download the latest release +from [Github](https://github.com/DigiLive/fileStreamer/releases). + +## Example use + +```php +setInline(); +$fileStreamer->start(); +// Execution of PHP will terminate when FileStreamer::start() is finished. +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3647485 --- /dev/null +++ b/composer.json @@ -0,0 +1,40 @@ +{ + "name": "digilive/file-streamer", + "description": "Stream a file or serve a file as (resumeable) download", + "keywords": [ + "php", + "download", + "resumeable", + "stream" + ], + "type": "library", + "minimum-stability": "stable", + "license": "BSD-3-Clause", + "authors": [ + { + "name": "Ferry Cools", + "email": "info@digilive.nl" + } + ], + "require": { + "php": ">=7.4", + "ext-fileinfo": "*" + }, + "require-dev": { + "digilive/git-changelog": "^v1.0.1", + "phpunit/phpunit": "^9", + "mikey179/vfsstream": "^1.6" + }, + "autoload": { + "psr-4": { + "DigiLive\\FileStreamer\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "DigiLive\\": "tests/supportFiles/DigiLive/", + "DigiLive\\FileStreamer\\Tests\\": "tests/", + "DigiLive\\FileStreamer\\": "tests/supportFiles/" + } + } +} diff --git a/src/FileStreamer.php b/src/FileStreamer.php new file mode 100644 index 0000000..e4a39ad --- /dev/null +++ b/src/FileStreamer.php @@ -0,0 +1,404 @@ + + * @copyright (c) 2022 Ferry Cools + * @license New BSD License http://www.opensource.org/licenses/bsd-license.php + * @version 1.0.0 + * @link https://github.com/DigiLive/fileStreamer + */ +class FileStreamer +{ + private const CONTENT_INLINE = 0; + private const CONTENT_ATTACHMENT = 1; + private const CONTENT_PARTIAL = 2; + private const RANGE_INVALID = 4; + + /** + * @var bool True for inline disposition. False for attachment disposition. + */ + private bool $inline = false; + /** + * @var SplFileInfo Details of the requested file. + * @see SplFileInfo + */ + private SplFileInfo $fileInfo; + /** + * @var int Delay in ms to slow down sending the file content to the client. + * Increase this value when serving the file hogs up the systems resources. + */ + private int $delay; + /** + * @var array The ranges in bytes which are extracted from the http request headers. + * Each element contains a range indicated by a start- and an end byte. + */ + private array $fileRanges; + + /** + * @var array Contains the formats to create content for a multipart file download. + * A multipart file download is identified by having multiple byte-ranges in the http request headers. + */ + private array $multipartFormats; + /** + * @var false|resource Handle to the file to serve. + */ + private $filePointer; + + /** + * Class Constructor. + * + * Any trailing slash of the given path is stripped from the string parameter. + * Optionally you can set a delay time to prevent hogging up the system resources. + * + * @param string $filePath Path to the file to serve. + * @param int $delay Delay in micro second. + */ + public function __construct(string $filePath, int $delay = 0) + { + $this->fileInfo = new SplFileInfo(trim(rtrim($filePath), '\/')); + $this->delay = $delay; + } + + /** + * Set the disposition of the file stream to inline. + * + * This way the library acts like a proxy server for the requested file. + * A value of false will set the disposition to attachment. + * + * @param bool $inline True to enable inline disposition. + * + * @return void + */ + public function setInline(bool $inline = true): void + { + $this->inline = $inline; + } + + /** + * Start serving the file. + * + * @return void + */ + public function start() + { + $fileName = $this->fileInfo->getFilename(); + + $this->filePointer = @fopen($this->fileInfo->getPathname(), 'rb'); + if (!$this->filePointer || !flock($this->filePointer, LOCK_SH | LOCK_NB)) { + throw new RuntimeException("File $fileName is currently not available!"); + } + + $this->disableCompression(); + $this->getRequestedRanges(); + + switch (count($this->fileRanges)) { + case 0: + $this->sendFile(); + break; + case 1: + $this->sendSingleRange(); + break; + default: + $this->sendMultipleRanges(); + } + + // Serving the file finished successfully. + if (@fclose($this->filePointer) === false) { + trigger_error("An error occurred while closing file $fileName!", E_USER_WARNING); + } + + $this->terminate('File served'); + } + + /** + * Disable output compression of the servers. + * + * Note: + * Browser compression can only be disabled when php runs as an apache module. For other php modes, you'll + * need to disable the compression in your server configuration or .htaccess file. + * + * @return void + */ + private function disableCompression(): void + { + if (PHP_SAPI == 'apache2handler' && @apache_setenv('no-gzip', '1') === false) { + trigger_error('An error occurred while disabling output compression of the webserver!', E_USER_WARNING); + } + if (@ini_set('zlib.output_compression', 'Off') === false) { + // PHP Unit test will always trigger this error. Suppress with @ while testing. + trigger_error('An error occurred while disabling output compression of the php server!', E_USER_WARNING); + } + } + + /** + * Get the requested ranges from the headers of the request. + * + * One or multiple ranges are extracted and sanitized from the request header. + * - A range start is always lte 0 and end. + * - A range end is always lt the filesize. + * + * If these conditions are not met after sanitation, the script sends a http 416 error and stops execution. + */ + private function getRequestedRanges(): void + { + /* + * Valid ranges are: + * bytes=0-500 // The first 500 bytes. + * bytes=-500 // The last 500 bytes, not 0-500! + * bytes=500- // From byte 500 tot the end. + * bytes=0-500,1000-1499,-200 // The first 500 bytes, From byte 1000 to 1499 and the last 200 bytes. + */ + + if (!isset($_SERVER['HTTP_RANGE'])) { + $this->fileRanges = []; + + return; + } + + $fileRanges = []; + $fileEnd = $this->fileInfo->getSize() - 1; + [$rangeUnit, $requestedRanges] = explode('=', $_SERVER['HTTP_RANGE'], 2); + + if ($rangeUnit != 'bytes') { + $this->sendHeaders(self::RANGE_INVALID); + } + + // Sanitize requested ranges. + $requestedRanges = explode(',', $requestedRanges); + foreach ($requestedRanges as $range) { + [$start, $end] = explode('-', $range); + + if ($start == '') { + // bytes=-500 The last 500 bytes, not 0-500! + $start = $fileEnd - $end + 1; + $end = $fileEnd; + } + + if ($end == '') { + // bytes=500- From byte 500 to the end. + $end = $fileEnd; + } + + $start = (int)max($start, 0); + $end = (int)min($end, $fileEnd); + + if ($start > $end) { + $this->sendHeaders(self::RANGE_INVALID); + } + + $fileRanges[] = [ + 'start' => $start, + 'end' => $end, + ]; + } + + $this->fileRanges = $fileRanges; + } + + /** + * Send appropriate raw HTTP headers. + * + * Remember that headers must be sent before any actual output is sent, either by normal HTML tags, blank lines in + * a file, or from PHP. + * + * @param int $type Type of headers to send. + * + * @return void + */ + private function sendHeaders(int $type): void + { + if ($type == self::RANGE_INVALID) { + header('HTTP/1.1 416 Requested Range Not Satisfiable'); + $this->terminate(); + } + + // Get file mimetype. + $filePath = $this->fileInfo->getPathname(); + $fileSize = $this->fileInfo->getSize(); + $fileInfo = new finfo(); + $mimeType = @$fileInfo->file($filePath, FILEINFO_MIME_TYPE); + + // Caching headers as IE6 workaround. + header('Pragma: public'); + header('Expires: -1'); + header('Cache-Control: public, must-revalidate, post-check=0, pre-check=0'); + + // Range header. + header('Accept-Ranges: bytes'); + + // Content headers. + $contentType = 'Content-Type: ' . $mimeType ?: 'application/octet-stream'; + header($contentType); + header('Content-Transfer-Encoding: binary'); + header("Content-Disposition: attachment; filename=\"{$this->fileInfo->getFilename()}\""); + header("Content-Length: $fileSize"); + + switch ($type) { + case self::CONTENT_INLINE: + header('Content-Disposition: inline'); + break; + case self::CONTENT_PARTIAL: + header('HTTP/1.1 206 Partial Content'); + + $rangeCount = count($this->fileRanges); + + // Single range. + if ($rangeCount == 1) { + header('Content-Length: ' . ($this->fileRanges[0]['end'] - $this->fileRanges[0]['start'] + 1)); + header( + sprintf( + 'Content-Range: bytes %d-%d/%d', + $this->fileRanges[0]['start'], + $this->fileRanges[0]['end'], + $fileSize + ) + ); + + return; + } + + // Multiple ranges. + $contentLength = $rangeCount * 38; // boundaryStart + $contentLength += $rangeCount * strlen($contentType . "\r\n"); // contentType + $contentLength += 40; // boundaryEnd + + // Calculate the content length of the parted download. + foreach ($this->fileRanges as $range) { + $contentLength += strlen( + sprintf( + $this->multipartFormats['rangeFormat'], + $range['start'], + $range['end'], + $fileSize + ) + ); + $contentLength += $range['end'] - $range['start'] + 1; + } + + header("Content-Length: $contentLength"); + header('Content-Type: multipart/byteranges; boundary=' . $this->multipartFormats['rangeBoundary']); + } + } + + /** + * Terminate the current script with exit message/code. + * + * Terminates execution of the script. Shutdown functions and object destructors will always be executed. + * If status is a string, this function prints the status just before exiting. + * If status is an int, that value will be used as the exit status and not printed. Exit statuses should be in the + * range 0 to 254, the exit status 255 is reserved by PHP and shall not be used. The status 0 is used to terminate + * the program successfully. + * + * @param $status + * + * @return void + */ + public function terminate($status = null): void + { + exit($status); + } + + /** + * Stream the complete file to the client. + * + * @return void + */ + private function sendFile() + { + $this->sendHeaders($this->inline ? self::CONTENT_INLINE : self::CONTENT_ATTACHMENT); + $this->flush(0, $this->fileInfo->getSize()); + } + + /** + * Flush buffered content of the file to the client. + * + * The start and end of the content are defined by the methods parameters. + * The values of the parameters are treated as number of bytes from the beginning of the file to serve. + * + * @param int $start Start of the content. + * @param int $end End of the content. + * + * @return void + */ + private function flush(int $start, int $end): void + { + $done = false; + + fseek($this->filePointer, $start); + + while (!$done && connection_status() == CONNECTION_NORMAL) { + ini_set('max_execution_time', '30'); + echo @fread($this->filePointer, min(1024, $end - $start + 1)); + ob_flush(); + flush(); + $done = feof($this->filePointer) || ftell($this->filePointer) >= $end; + usleep($this->delay); + } + } + + /** + * Send a single range of file content to the client. + * + * @return void + */ + private function sendSingleRange(): void + { + $this->sendHeaders(self::CONTENT_PARTIAL); + $this->flush($this->fileRanges[0]['start'], $this->fileRanges[0]['end']); + } + + /** + * Send multiple ranges of file content to the client. + * + * @return void + */ + private function sendMultipleRanges(): void + { + $fileInfo = new finfo(); + $mimeType = @$fileInfo->file($this->fileInfo->getPathname(), FILEINFO_MIME_TYPE); + $fileSize = $this->fileInfo->getSize(); + + $this->setMultipartFormats(); + $this->sendHeaders(self::CONTENT_PARTIAL); + + foreach ($this->fileRanges as $range) { + echo $this->multipartFormats['boundaryStart']; + echo 'Content-Type: ' . ($mimeType ?: 'application/octet-stream') . "\r\n"; + echo sprintf($this->multipartFormats['rangeFormat'], $range['start'], $range['end'], $fileSize); + $this->flush($range['start'], $range['end']); + } + echo $this->multipartFormats['boundaryEnd']; + } + + /** + * Create the boundary and range strings for multipart downloads. + * + * @return void + */ + private function setMultipartFormats(): void + { + $rangeBoundary = md5($this->fileInfo->getPathname()); + $this->multipartFormats = [ + 'rangeBoundary' => "$rangeBoundary", + 'boundaryStart' => "\r\n--$rangeBoundary\r\n", + 'boundaryEnd' => "\r\n--$rangeBoundary--\r\n", + 'rangeFormat' => "Content-range: bytes %d-%d/%d\r\n\r\n", + ]; + } +} diff --git a/tests/FileStreamerTest.php b/tests/FileStreamerTest.php new file mode 100644 index 0000000..e54318b --- /dev/null +++ b/tests/FileStreamerTest.php @@ -0,0 +1,357 @@ + + * @copyright (c) 2022 Ferry Cools + * @license New BSD License http://www.opensource.org/licenses/bsd-license.php + * @version 1.0.0 + * @link https://github.com/DigiLive/fileStreamer + */ +class FileStreamerTest extends TestCase +{ + /** + * Defines the headers which are always sent by the FileStreamer class. + * Some could be overridden at a real download, but the Output class will append instead of override. + */ + private const defaultHeaders = [ + 'Pragma: public', + 'Expires: -1', + 'Cache-Control: public, must-revalidate, post-check=0, pre-check=0', + 'Accept-Ranges: bytes', + 'Content-Type: text/plain', // Could be overridden at real download. + 'Content-Transfer-Encoding: binary', + 'Content-Disposition: attachment; filename="dummyFile.txt"', // Could be overridden at real download. + 'Content-Length: 10', // Could be overridden at real download. + ]; + + /** + * Instantiate mocks required for this test case. + * + * Mocks include a filesystem and an output buffer. + * + * @return void + */ + public static function setUpBeforeClass(): void + { + vfsStream::setup('source', null, ['dummyFile.txt' => '0123456789']); + Output::getOverrides('./supportFiles/OutputFunctions.php'); + Output::reset(); + } + + /** + * Clear mocks after the last test of this test class is run. + * + * @throws vfsStreamException When unregistering the mocked filesystem fails. + */ + public static function tearDownAfterClass(): void + { + vfsStreamWrapper::unregister(); + Output::reset(); + } + + /** + * Reset the collected output and headers after each test. + * + * @return void + */ + public function tearDown(): void + { + Output::reset(); + } + + /** + * Test if range values get extracted from the ranges as defined in the http request header. + * + * @dataProvider provideValidRanges + * + * @param string $RequestedRange A valid range. + * @param array $expectedRange Expected range extracted from the valid range. + * + * @return void + */ + public function testGetRequestedRanges(string $RequestedRange, array $expectedRange) + { + $_SERVER['HTTP_RANGE'] = $RequestedRange; + $fileStreamer = new FileStreamer(vfsStream::url('source/dummyFile.txt')); + $reflector = Reflector::create($fileStreamer); + + $reflector->getRequestedRanges(); + + $this->assertEquals($expectedRange, $reflector->fileRanges); + $reflector->unset(); + } + + /** + * Test if the correct headers are set when requesting an invalid range. + * + * @dataProvider provideInvalidRanges + * + * @param string $RequestedRange An invalid range. + * + * @return void + */ + public function testRequestedRangesInvalid(string $RequestedRange) + { + $_SERVER['HTTP_RANGE'] = $RequestedRange; + + // Mock the class to override the terminate method. + /** @var FileStreamer $mock */ + $mock = $this->mockFileStreamer(); + $reflector = Reflector::create($mock); + + try { + $reflector->getRequestedRanges(); + $this->fail(); + } catch (Exception $e) { + $this->assertCount(1, Output::$headers); + $this->assertContains('HTTP/1.1 416 Requested Range Not Satisfiable', Output::$headers); + } + + $reflector->unset(); + } + + /** + * Mock the FileStreamer class to override the terminate method. + * + * The terminate method contains an exit statement which will abort the execution of php. + * To prevent this, this method is overridden and will only raise an exception instead. + * + * @return MockObject The mocked FileDownload class. + */ + private function mockFileStreamer(): MockObject + { + $mock = $this->getMockBuilder(FileStreamer::class) + ->onlyMethods(['terminate']) + ->setConstructorArgs([vfsStream::url('source/dummyFile.txt')]) + ->getMock(); + $mock->method('terminate')->willThrowException(new Exception('Mocked')); + + return $mock; + } + + /** + * Test if the correct data is being served according to the requested range. + * + * @dataProvider provideValidRanges + * + * @param string $RequestedRange A valid range. + * @param array $expectedRange Unused. + * @param string $expectedOutput Expected content which is sent to the outputbuffer. + * @param array $expectedHeaders Expected headers which are sent to the outputbuffer. + * + * @return void + */ + public function testStartSingleRange( + string $RequestedRange, + array $expectedRange, + string $expectedOutput, + array $expectedHeaders + ): void { + // Skip multi-range dataset. + if ($this->dataName() == 'multiRange') { + $this->markTestSkipped('Test skipped.'); + } + + $_SERVER['HTTP_RANGE'] = $RequestedRange; + $expectedHeaders = array_merge(self::defaultHeaders, $expectedHeaders); + + // Get a mocked instance of the class with overridden terminate method. + /** @var FileStreamer $mock */ + $mock = $this->mockFileStreamer(); + + try { + // Suppress warning or method will be aborted. + @$mock->start(); + $this->fail(); + } catch (Exception $e) { + $this->assertSame($expectedOutput, Output::getBody()); + $this->assertSame($expectedHeaders, Output::$headers); + } + } + + /** + * Test if the correct data is being served according to the requested ranges. + * + * @return void + */ + public function testStartMultiRange(): void + { + $_SERVER['HTTP_RANGE'] = 'bytes=2-3,5-6,-1'; + $expectedHeaders = [ + 'HTTP/1.1 206 Partial Content', + 'Content-Length: 330', + 'Content-Type: multipart/byteranges; boundary=cafd507eba83d389029d38c0cbe92dc5', + ]; + $expectedHeaders = array_merge(self::defaultHeaders, $expectedHeaders); + $expectedOutput = <<<'TXT' + +--cafd507eba83d389029d38c0cbe92dc5 +Content-Type: text/plain +Content-range: bytes 2-3/10 + +23 +--cafd507eba83d389029d38c0cbe92dc5 +Content-Type: text/plain +Content-range: bytes 5-6/10 + +56 +--cafd507eba83d389029d38c0cbe92dc5 +Content-Type: text/plain +Content-range: bytes 9-9/10 + +9 +--cafd507eba83d389029d38c0cbe92dc5-- + +TXT; + // Ensure the line separators match \r\n. + $expectedOutput = preg_replace('~\R~u', "\r\n", $expectedOutput); + + // Get a mocked instance of the class with overridden terminate method. + /** @var FileStreamer $mock */ + $mock = $this->mockFileStreamer(); + + try { + // Suppress warning or method will be aborted. + @$mock->start(); + $this->fail(); + } catch (Exception $e) { + $this->assertSame($expectedOutput, Output::getBody()); + $this->assertSame($expectedHeaders, Output::$headers); + } + } + + /** + * Test if the correct data is being served when no range is requested. + * + * @return void + */ + public function testStartNoRange(): void + { + unset($_SERVER['HTTP_RANGE']); + // Get a mocked instance of the class with overridden terminate method. + /** @var FileStreamer $mock */ + $mock = $this->mockFileStreamer(); + + // Attachment disposition Test. + try { + // Suppress warning or method will be aborted. + @$mock->start(); + } catch (Exception $e) { + // Test headers. + $this->assertSame(self::defaultHeaders, Output::$headers); + } + + // Inline disposition Test. + Output::reset(); + $mock->setInline(true); + + $expectedHeaders = array_merge(self::defaultHeaders, ['Content-Disposition: inline']); + + try { + // Suppress warning or method will be aborted. + @$mock->start(); + } catch (Exception $e) { + // Test headers. + $this->assertSame($expectedHeaders, Output::$headers); + $this->assertSame('0123456789', Output::getBody()); + } + } + + /** + * Valid ranges are: + * bytes=0-500 // The first 500 bytes. + * bytes=-500 // The last 500 bytes, not 0-500! + * bytes=500- // From byte 500 tot the end. + * bytes=0-500,1000-1499,-200 // The first 500 bytes, From byte 1000 to 1499 and the last 200 bytes. + * + * @return array[] Valid ranges and the expected start/end bytes extracted from these ranges. + */ + public function provideValidRanges(): array + { + return [ + 'singleRange' => [ + 'bytes=3-7', + [['start' => 3, 'end' => 7]], + '34567', + [ + 'HTTP/1.1 206 Partial Content', + 'Content-Length: 5', + 'Content-Range: bytes 3-7/10', + ], + ], + 'endRange' => [ + 'bytes=-3', + [['start' => 7, 'end' => 9]], + '789', + [ + 'HTTP/1.1 206 Partial Content', + 'Content-Length: 3', + 'Content-Range: bytes 7-9/10', + ], + ], + 'startRange' => [ + 'bytes=3-', + [['start' => 3, 'end' => 9]], + '3456789', + [ + 'HTTP/1.1 206 Partial Content', + 'Content-Length: 7', + 'Content-Range: bytes 3-9/10', + ], + ], + 'multiRange' => [ + 'bytes=2-4,5-7,-2', + [ + ['start' => 2, 'end' => 4], + ['start' => 5, 'end' => 7], + ['start' => 8, 'end' => 9], + ], + '23456789', + [], + ], + ]; + } + + /** + * Invalid ranges are: + * - Unit isn't bytes. + * - Start is greater than end. + * + * @return string[][] Invalid ranges. + */ + public function provideInvalidRanges(): array + { + return [ + 'invalidUnit' => [ + 'invalid=3-7', + ], + 'invalidStartSingle' => [ + 'bytes=9-7', + ], + 'invalidStartMulti' => [ + 'bytes=2-4,7-5,-2', + ], + ]; + } +} diff --git a/tests/supportFiles/DigiLive/Output/Output.php b/tests/supportFiles/DigiLive/Output/Output.php new file mode 100644 index 0000000..7d6c049 --- /dev/null +++ b/tests/supportFiles/DigiLive/Output/Output.php @@ -0,0 +1,71 @@ + + * @copyright (c) 2022 Ferry Cools + * @version 1.0.0 + * @license New BSD License http://www.opensource.org/licenses/bsd-license.php + * @todo Get this package from composer. + */ +abstract class Output +{ + /** + * @var array Captured Headers which would have been sent to the output buffer. + */ + public static array $headers = []; + /** + * @var string|null Captured body which would have been sent to the output buffer. + */ + public static ?string $body; + + /** + * Clear the captured output headers and body. + * + * @return void + */ + public static function reset() + { + self::$headers = []; + self::$body = null; + @ob_clean(); + } + + /** + * Get the captured body. + * + * @return string|null + */ + public static function getBody(): ?string + { + Output::$body .= ob_get_contents(); + ob_clean(); + + return self::$body; + } + + /** + * Wrapper function to include function overrides. + * + * To override functions, the overrides need to be declared somewhere. + * To keep this more versatile and tidy, overrides and their namespace should be declared in a separate php file. + * + * @param string $functionsFile Path of php file which contains the function overrides. + * + * @return void + */ + public static function getOverrides(string $functionsFile): void + { + require $functionsFile; + } +} diff --git a/tests/supportFiles/DigiLive/Reflector/Reflector.php b/tests/supportFiles/DigiLive/Reflector/Reflector.php new file mode 100644 index 0000000..3d3cbee --- /dev/null +++ b/tests/supportFiles/DigiLive/Reflector/Reflector.php @@ -0,0 +1,136 @@ + + * @copyright (c) 2022 Ferry Cools + * @version 1.0.0 + * @license New BSD License http://www.opensource.org/licenses/bsd-license.php + * @todo Get this package from composer. + */ +class Reflector +{ + /** + * @var Reflector|null First created instance of this class by method create. + * @see Reflector::create() + */ + private static ?Reflector $instance = null; + /** + * @var object The object to reflect. + */ + private static object $object; + + /** + * @var ReflectionClass Reflection of the class from the reflected object. + */ + private ReflectionClass $reflectedClass; + /** + * @var object Instance of the object from which a reflection class is constructed. + */ + private object $reflectedObject; + + /** + * Construct a reflection class for a given object. + * + * @param object $object Instance of the class to reflect. + */ + private function __construct(object $object) + { + $this->reflectedObject = $object; + $this->reflectedClass = new ReflectionClass($object); + } + + /** + * Create a reflection class for a given object. + * + * If the reflection class already exist for this object, don't create a new one, but return the existing one + * instead. + * + * @param object $object The object to reflect. + * + * @return Reflector|null The reflected object. + */ + public static function create(object $object): ?Reflector + { + if (self::$instance === null || self::$instance::$object !== $object) { + self::$instance = new self($object); + self::$instance::$object = $object; + } + + return self::$instance; + } + + /** + * Unset the class reflection. + * + * @return object The object the reflection class was created for. + * @noinspection PhpUndefinedVariableInspection + */ + public function unset(): object + { + $object = self::$instance::$object; + self::$instance = null; + + return $object; + } + + /** + * Get the value of a private or protected property. + * + * @param string $propertyName Name of the property to set. + * + * @throws ReflectionException If no property exists by that name. + */ + public function __get(string $propertyName) + { + $property = $this->reflectedClass->getProperty($propertyName); + + $property->setAccessible(true); + + return $property->getValue($this->reflectedObject); + } + + /** + * Set the value of a private or protected property. + * + * @param string $propertyName Name of the property to set. + * @param mixed $value Value to set. + * + * @throws ReflectionException If no property exists by that name. + */ + public function __set(string $propertyName, $value): void + { + $property = $this->reflectedClass->getProperty($propertyName); + + $property->setAccessible(true); + + $property->setValue($this->reflectedObject, $value); + } + + /** + * Invoke a private or protected method. + * + * @param string $methodName Name of the method to invoke. + * @param array Parameter values of the method to invoke. + * + * @throws ReflectionException If the method does not exist. + */ + public function __call(string $methodName, array $parameters = []) + { + $method = $this->reflectedClass->getMethod($methodName); + + $method->setAccessible(true); + + return $method->invoke($this->reflectedObject, ...$parameters); + } +} diff --git a/tests/supportFiles/OutputFunctions.php b/tests/supportFiles/OutputFunctions.php new file mode 100644 index 0000000..c214da8 --- /dev/null +++ b/tests/supportFiles/OutputFunctions.php @@ -0,0 +1,88 @@ + + * @copyright (c) 2022 Ferry Cools + * @version 1.0.0 + * @license New BSD License http://www.opensource.org/licenses/bsd-license.php + * @todo Get this package from composer. + */ +declare(strict_types=1); + +namespace DigiLive\FileStreamer; + +// Above namespace must be set the same as the namespace which calls the defined functions below. + +use DigiLive\Output\Output; + +/** + * headers_sent will return false if no HTTP headers have already been sent or true otherwise. + * + * @return false + * @see \headers_sent() + */ +function headers_sent(): bool +{ + return false; +} + +/** + * Capture a raw HTTP header. + * + * Note: + * Unlike the default header function, a captured similar header will not be replaced. + * A new header is captured instead. + * + * @param string $value The header string. + * + * @return void + * @see \header() + */ +function header(string $value) +{ + Output::$headers[] = $value; +} + +/** + * Capture a formatted string. + * + * @param string $format + * @param mixed ...$values + * + * @return void + * @see \printf() + */ +function printf(string $format, ...$values) +{ + Output::$body .= sprintf($format, ...$values); +} + +/** + * Capture the body of the output buffer. + * + * @return void + * @see \ob_get_contents(); + */ +function ob_flush(): void +{ + Output::$body .= ob_get_contents(); + ob_clean(); +} + +/** + * Flush system output buffer. + * + * Since the output buffer is captured, try to flush this buffer should do noting. + * + * @return void + * @see \flush() + */ +function flush(): void +{ +} diff --git a/updateChangelog.php b/updateChangelog.php new file mode 100644 index 0000000..50eba4b --- /dev/null +++ b/updateChangelog.php @@ -0,0 +1,67 @@ + + * @copyright (c) 2022 Ferry Cools + * @license New BSD License http://www.opensource.org/licenses/bsd-license.php + * @version 1.0.0 + * @link https://github.com/DigiLive/fileStreamer + */ + +use DigiLive\GitChangelog\Renderers\Html; +use DigiLive\GitChangelog\Renderers\MarkDown; + +// Instantiate composer's auto loader. +require __DIR__ . '/vendor/autoload.php'; + +// Options for undetermined release +$changelogOptions = [ + 'headTagName' => 'First Release', + 'headTagDate' => 'Soon', + 'titleOrder' => 'ASC', +]; + +// Options for determined release +/*$changelogOptions = [ + 'headTagName' => 'v1.0.0', + 'headTagDate' => '2021-06-08', + 'titleOrder' => 'ASC', +];*/ + +$changelogLabels = ['Add', 'Cut', 'Fix', 'Bump', 'Optimize']; + +// Setup markdown changelog. +$markDownLog = new MarkDown(); +$markDownLog->commitUrl = 'https://github.com/DigiLive/fileStreamer/commit/{hash}'; +$markDownLog->issueUrl = 'https://github.com/DigiLive/fileStreamer/issues/{issue}'; +$markDownLog->setOptions($changelogOptions); +$markDownLog->setLabels(...$changelogLabels); +//$markDownLog->setToTag('v1.0.0'); +$markDownLog->fetchTags(true); + +// Setup html changelog. +/*$htmlLog = new Html(); +$htmlLog->setOptions($changelogOptions); +$htmlLog->commitUrl = 'https://github.com/DigiLive/fileStreamer/commit/{hash}'; +$htmlLog->issueUrl = 'https://github.com/DigiLive/fileStreamer/issues/{issue}'; +$htmlLog->setLabels(...$changelogLabels); +$htmlLog->setToTag('v1.0.0'); +$htmlLog->fetchTags(true);*/ + +// Generate and save. +try { + $markDownLog->build(); + $markDownLog->save('CHANGELOG.md'); +/* $htmlLog->build(); + $htmlLog->save('CHANGELOG.html');*/ +} catch (Exception $e) { + exit($e); +}