Ir a la última revisión | Autoría | Comparar con el anterior | Ultima modificación | Ver Log |
<?phpdeclare(strict_types=1);namespace ZipStream;use Closure;use DateTimeImmutable;use DateTimeInterface;use GuzzleHttp\Psr7\StreamWrapper;use Psr\Http\Message\StreamInterface;use RuntimeException;use ZipStream\Exception\FileNotFoundException;use ZipStream\Exception\FileNotReadableException;use ZipStream\Exception\OverflowException;use ZipStream\Exception\ResourceActionException;/*** Streamed, dynamically generated zip archives.** ## Usage** Streaming zip archives is a simple, three-step process:** 1. Create the zip stream:** ```php* $zip = new ZipStream(outputName: 'example.zip');* ```** 2. Add one or more files to the archive:** ```php* // add first file* $zip->addFile(fileName: 'world.txt', data: 'Hello World');** // add second file* $zip->addFile(fileName: 'moon.txt', data: 'Hello Moon');* ```** 3. Finish the zip stream:** ```php* $zip->finish();* ```** You can also add an archive comment, add comments to individual files,* and adjust the timestamp of files. See the API documentation for each* method below for additional information.** ## Example** ```php* // create a new zip stream object* $zip = new ZipStream(outputName: 'some_files.zip');** // list of local files* $files = array('foo.txt', 'bar.jpg');** // read and add each file to the archive* foreach ($files as $path)* $zip->addFileFormPath(fileName: $path, $path);** // write archive footer to stream* $zip->finish();* ```*/class ZipStream{/*** This number corresponds to the ZIP version/OS used (2 bytes)* From: https://www.iana.org/assignments/media-types/application/zip* The upper byte (leftmost one) indicates the host system (OS) for the* file. Software can use this information to determine* the line record format for text files etc. The current* mappings are:** 0 - MS-DOS and OS/2 (F.A.T. file systems)* 1 - Amiga 2 - VAX/VMS* 3 - *nix 4 - VM/CMS* 5 - Atari ST 6 - OS/2 H.P.F.S.* 7 - Macintosh 8 - Z-System* 9 - CP/M 10 thru 255 - unused** The lower byte (rightmost one) indicates the version number of the* software used to encode the file. The value/10* indicates the major version number, and the value* mod 10 is the minor version number.* Here we are using 6 for the OS, indicating OS/2 H.P.F.S.* to prevent file permissions issues upon extract (see #84)* 0x603 is 00000110 00000011 in binary, so 6 and 3** @internal*/public const ZIP_VERSION_MADE_BY = 0x603;private bool $ready = true;private int $offset = 0;/*** @var string[]*/private array $centralDirectoryRecords = [];/*** @var resource*/private $outputStream;private readonly Closure $httpHeaderCallback;/*** @var File[]*/private array $recordedSimulation = [];/*** Create a new ZipStream object.** ##### Examples** ```php* // create a new zip file named 'foo.zip'* $zip = new ZipStream(outputName: 'foo.zip');** // create a new zip file named 'bar.zip' with a comment* $zip = new ZipStream(* outputName: 'bar.zip',* comment: 'this is a comment for the zip file.',* );* ```** @param OperationMode $operationMode* The mode can be used to switch between `NORMAL` and `SIMULATION_*` modes.* For details see the `OperationMode` documentation.** Default to `NORMAL`.** @param string $comment* Archive Level Comment** @param StreamInterface|resource|null $outputStream* Override the output of the archive to a different target.** By default the archive is sent to `STDOUT`.** @param CompressionMethod $defaultCompressionMethod* How to handle file compression. Legal values are* `CompressionMethod::DEFLATE` (the default), or* `CompressionMethod::STORE`. `STORE` sends the file raw and is* significantly faster, while `DEFLATE` compresses the file and* is much, much slower.** @param int $defaultDeflateLevel* Default deflation level. Only relevant if `compressionMethod`* is `DEFLATE`.** See details of [`deflate_init`](https://www.php.net/manual/en/function.deflate-init.php#refsect1-function.deflate-init-parameters)** @param bool $enableZip64* Enable Zip64 extension, supporting very large* archives (any size > 4 GB or file count > 64k)** @param bool $defaultEnableZeroHeader* Enable streaming files with single read.** When the zero header is set, the file is streamed into the output* and the size & checksum are added at the end of the file. This is the* fastest method and uses the least memory. Unfortunately not all* ZIP clients fully support this and can lead to clients reporting* the generated ZIP files as corrupted in combination with other* circumstances. (Zip64 enabled, using UTF8 in comments / names etc.)** When the zero header is not set, the length & checksum need to be* defined before the file is actually added. To prevent loading all* the data into memory, the data has to be read twice. If the data* which is added is not seekable, this call will fail.** @param bool $sendHttpHeaders* Boolean indicating whether or not to send* the HTTP headers for this file.** @param ?Closure $httpHeaderCallback* The method called to send HTTP headers** @param string|null $outputName* The name of the created archive.** Only relevant if `$sendHttpHeaders = true`.** @param string $contentDisposition* HTTP Content-Disposition** Only relevant if `sendHttpHeaders = true`.** @param string $contentType* HTTP Content Type** Only relevant if `sendHttpHeaders = true`.** @param bool $flushOutput* Enable flush after every write to output stream.** @return self*/public function __construct(private OperationMode $operationMode = OperationMode::NORMAL,private readonly string $comment = '',$outputStream = null,private readonly CompressionMethod $defaultCompressionMethod = CompressionMethod::DEFLATE,private readonly int $defaultDeflateLevel = 6,private readonly bool $enableZip64 = true,private readonly bool $defaultEnableZeroHeader = true,private bool $sendHttpHeaders = true,?Closure $httpHeaderCallback = null,private readonly ?string $outputName = null,private readonly string $contentDisposition = 'attachment',private readonly string $contentType = 'application/x-zip',private bool $flushOutput = false,) {$this->outputStream = self::normalizeStream($outputStream);$this->httpHeaderCallback = $httpHeaderCallback ?? header(...);}/*** Add a file to the archive.** ##### File Options** See {@see addFileFromPsr7Stream()}** ##### Examples** ```php* // add a file named 'world.txt'* $zip->addFile(fileName: 'world.txt', data: 'Hello World!');** // add a file named 'bar.jpg' with a comment and a last-modified* // time of two hours ago* $zip->addFile(* fileName: 'bar.jpg',* data: $data,* comment: 'this is a comment about bar.jpg',* lastModificationDateTime: new DateTime('2 hours ago'),* );* ```** @param string $data** contents of file*/public function addFile(string $fileName,string $data,string $comment = '',?CompressionMethod $compressionMethod = null,?int $deflateLevel = null,?DateTimeInterface $lastModificationDateTime = null,?int $maxSize = null,?int $exactSize = null,?bool $enableZeroHeader = null,): void {$this->addFileFromCallback(fileName: $fileName,callback: fn () => $data,comment: $comment,compressionMethod: $compressionMethod,deflateLevel: $deflateLevel,lastModificationDateTime: $lastModificationDateTime,maxSize: $maxSize,exactSize: $exactSize,enableZeroHeader: $enableZeroHeader,);}/*** Add a file at path to the archive.** ##### File Options** See {@see addFileFromPsr7Stream()}** ###### Examples** ```php* // add a file named 'foo.txt' from the local file '/tmp/foo.txt'* $zip->addFileFromPath(* fileName: 'foo.txt',* path: '/tmp/foo.txt',* );** // add a file named 'bigfile.rar' from the local file* // '/usr/share/bigfile.rar' with a comment and a last-modified* // time of two hours ago* $zip->addFile(* fileName: 'bigfile.rar',* path: '/usr/share/bigfile.rar',* comment: 'this is a comment about bigfile.rar',* lastModificationDateTime: new DateTime('2 hours ago'),* );* ```** @throws \ZipStream\Exception\FileNotFoundException* @throws \ZipStream\Exception\FileNotReadableException*/public function addFileFromPath(/*** name of file in archive (including directory path).*/string $fileName,/*** path to file on disk (note: paths should be encoded using* UNIX-style forward slashes -- e.g '/path/to/some/file').*/string $path,string $comment = '',?CompressionMethod $compressionMethod = null,?int $deflateLevel = null,?DateTimeInterface $lastModificationDateTime = null,?int $maxSize = null,?int $exactSize = null,?bool $enableZeroHeader = null,): void {if (!is_readable($path)) {if (!file_exists($path)) {throw new FileNotFoundException($path);}throw new FileNotReadableException($path);}if ($fileTime = filemtime($path)) {$lastModificationDateTime ??= (new DateTimeImmutable())->setTimestamp($fileTime);}$this->addFileFromCallback(fileName: $fileName,callback: function () use ($path) {$stream = fopen($path, 'rb');if (!$stream) {// @codeCoverageIgnoreStartthrow new ResourceActionException('fopen');// @codeCoverageIgnoreEnd}return $stream;},comment: $comment,compressionMethod: $compressionMethod,deflateLevel: $deflateLevel,lastModificationDateTime: $lastModificationDateTime,maxSize: $maxSize,exactSize: $exactSize,enableZeroHeader: $enableZeroHeader,);}/*** Add an open stream (resource) to the archive.** ##### File Options** See {@see addFileFromPsr7Stream()}** ##### Examples** ```php* // create a temporary file stream and write text to it* $filePointer = tmpfile();* fwrite($filePointer, 'The quick brown fox jumped over the lazy dog.');** // add a file named 'streamfile.txt' from the content of the stream* $archive->addFileFromStream(* fileName: 'streamfile.txt',* stream: $filePointer,* );* ```** @param resource $stream contents of file as a stream resource*/public function addFileFromStream(string $fileName,$stream,string $comment = '',?CompressionMethod $compressionMethod = null,?int $deflateLevel = null,?DateTimeInterface $lastModificationDateTime = null,?int $maxSize = null,?int $exactSize = null,?bool $enableZeroHeader = null,): void {$this->addFileFromCallback(fileName: $fileName,callback: fn () => $stream,comment: $comment,compressionMethod: $compressionMethod,deflateLevel: $deflateLevel,lastModificationDateTime: $lastModificationDateTime,maxSize: $maxSize,exactSize: $exactSize,enableZeroHeader: $enableZeroHeader,);}/*** Add an open stream to the archive.** ##### Examples** ```php* $stream = $response->getBody();* // add a file named 'streamfile.txt' from the content of the stream* $archive->addFileFromPsr7Stream(* fileName: 'streamfile.txt',* stream: $stream,* );* ```** @param string $fileName* path of file in archive (including directory)** @param StreamInterface $stream* contents of file as a stream resource** @param string $comment* ZIP comment for this file** @param ?CompressionMethod $compressionMethod* Override `defaultCompressionMethod`** See {@see __construct()}** @param ?int $deflateLevel* Override `defaultDeflateLevel`** See {@see __construct()}** @param ?DateTimeInterface $lastModificationDateTime* Set last modification time of file.** Default: `now`** @param ?int $maxSize* Only read `maxSize` bytes from file.** The file is considered done when either reaching `EOF`* or the `maxSize`.** @param ?int $exactSize* Read exactly `exactSize` bytes from file.* If `EOF` is reached before reading `exactSize` bytes, an error will be* thrown. The parameter allows for faster size calculations if the `stream`* does not support `fstat` size or is slow and otherwise known beforehand.** @param ?bool $enableZeroHeader* Override `defaultEnableZeroHeader`** See {@see __construct()}*/public function addFileFromPsr7Stream(string $fileName,StreamInterface $stream,string $comment = '',?CompressionMethod $compressionMethod = null,?int $deflateLevel = null,?DateTimeInterface $lastModificationDateTime = null,?int $maxSize = null,?int $exactSize = null,?bool $enableZeroHeader = null,): void {$this->addFileFromCallback(fileName: $fileName,callback: fn () => $stream,comment: $comment,compressionMethod: $compressionMethod,deflateLevel: $deflateLevel,lastModificationDateTime: $lastModificationDateTime,maxSize: $maxSize,exactSize: $exactSize,enableZeroHeader: $enableZeroHeader,);}/*** Add a file based on a callback.** This is useful when you want to simulate a lot of files without keeping* all of the file handles open at the same time.** ##### Examples** ```php* foreach($files as $name => $size) {* $archive->addFileFromPsr7Stream(* fileName: 'streamfile.txt',* exactSize: $size,* callback: function() use($name): Psr\Http\Message\StreamInterface {* $response = download($name);* return $response->getBody();* }* );* }* ```** @param string $fileName* path of file in archive (including directory)** @param Closure $callback* @psalm-param Closure(): (resource|StreamInterface|string) $callback* A callback to get the file contents in the shape of a PHP stream,* a Psr StreamInterface implementation, or a string.** @param string $comment* ZIP comment for this file** @param ?CompressionMethod $compressionMethod* Override `defaultCompressionMethod`** See {@see __construct()}** @param ?int $deflateLevel* Override `defaultDeflateLevel`** See {@see __construct()}** @param ?DateTimeInterface $lastModificationDateTime* Set last modification time of file.** Default: `now`** @param ?int $maxSize* Only read `maxSize` bytes from file.** The file is considered done when either reaching `EOF`* or the `maxSize`.** @param ?int $exactSize* Read exactly `exactSize` bytes from file.* If `EOF` is reached before reading `exactSize` bytes, an error will be* thrown. The parameter allows for faster size calculations if the `stream`* does not support `fstat` size or is slow and otherwise known beforehand.** @param ?bool $enableZeroHeader* Override `defaultEnableZeroHeader`** See {@see __construct()}*/public function addFileFromCallback(string $fileName,Closure $callback,string $comment = '',?CompressionMethod $compressionMethod = null,?int $deflateLevel = null,?DateTimeInterface $lastModificationDateTime = null,?int $maxSize = null,?int $exactSize = null,?bool $enableZeroHeader = null,): void {$file = new File(dataCallback: function () use ($callback, $maxSize) {$data = $callback();if(is_resource($data)) {return $data;}if($data instanceof StreamInterface) {return StreamWrapper::getResource($data);}$stream = fopen('php://memory', 'rw+');if ($stream === false) {// @codeCoverageIgnoreStartthrow new ResourceActionException('fopen');// @codeCoverageIgnoreEnd}if ($maxSize !== null && fwrite($stream, $data, $maxSize) === false) {// @codeCoverageIgnoreStartthrow new ResourceActionException('fwrite', $stream);// @codeCoverageIgnoreEnd} elseif (fwrite($stream, $data) === false) {// @codeCoverageIgnoreStartthrow new ResourceActionException('fwrite', $stream);// @codeCoverageIgnoreEnd}if (rewind($stream) === false) {// @codeCoverageIgnoreStartthrow new ResourceActionException('rewind', $stream);// @codeCoverageIgnoreEnd}return $stream;},send: $this->send(...),recordSentBytes: $this->recordSentBytes(...),operationMode: $this->operationMode,fileName: $fileName,startOffset: $this->offset,compressionMethod: $compressionMethod ?? $this->defaultCompressionMethod,comment: $comment,deflateLevel: $deflateLevel ?? $this->defaultDeflateLevel,lastModificationDateTime: $lastModificationDateTime ?? new DateTimeImmutable(),maxSize: $maxSize,exactSize: $exactSize,enableZip64: $this->enableZip64,enableZeroHeader: $enableZeroHeader ?? $this->defaultEnableZeroHeader,);if($this->operationMode !== OperationMode::NORMAL) {$this->recordedSimulation[] = $file;}$this->centralDirectoryRecords[] = $file->process();}/*** Add a directory to the archive.** ##### File Options** See {@see addFileFromPsr7Stream()}** ##### Examples** ```php* // add a directory named 'world/'* $zip->addFile(fileName: 'world/');* ```*/public function addDirectory(string $fileName,string $comment = '',?DateTimeInterface $lastModificationDateTime = null,): void {if (!str_ends_with($fileName, '/')) {$fileName .= '/';}$this->addFile(fileName: $fileName,data: '',comment: $comment,compressionMethod: CompressionMethod::STORE,deflateLevel: null,lastModificationDateTime: $lastModificationDateTime,maxSize: 0,exactSize: 0,enableZeroHeader: false,);}/*** Executes a previously calculated simulation.** ##### Example** ```php* $zip = new ZipStream(* outputName: 'foo.zip',* operationMode: OperationMode::SIMULATE_STRICT,* );** $zip->addFile('test.txt', 'Hello World');** $size = $zip->finish();** header('Content-Length: '. $size);** $zip->executeSimulation();* ```*/public function executeSimulation(): void{if($this->operationMode !== OperationMode::NORMAL) {throw new RuntimeException('Zip simulation is not finished.');}foreach($this->recordedSimulation as $file) {$this->centralDirectoryRecords[] = $file->cloneSimulationExecution()->process();}$this->finish();}/*** Write zip footer to stream.** The clase is left in an unusable state after `finish`.** ##### Example** ```php* // write footer to stream* $zip->finish();* ```*/public function finish(): int{$centralDirectoryStartOffsetOnDisk = $this->offset;$sizeOfCentralDirectory = 0;// add trailing cdr file recordsforeach ($this->centralDirectoryRecords as $centralDirectoryRecord) {$this->send($centralDirectoryRecord);$sizeOfCentralDirectory += strlen($centralDirectoryRecord);}// Add 64bit headers (if applicable)if (count($this->centralDirectoryRecords) >= 0xFFFF ||$centralDirectoryStartOffsetOnDisk > 0xFFFFFFFF ||$sizeOfCentralDirectory > 0xFFFFFFFF) {if (!$this->enableZip64) {throw new OverflowException();}$this->send(Zip64\EndOfCentralDirectory::generate(versionMadeBy: self::ZIP_VERSION_MADE_BY,versionNeededToExtract: Version::ZIP64->value,numberOfThisDisk: 0,numberOfTheDiskWithCentralDirectoryStart: 0,numberOfCentralDirectoryEntriesOnThisDisk: count($this->centralDirectoryRecords),numberOfCentralDirectoryEntries: count($this->centralDirectoryRecords),sizeOfCentralDirectory: $sizeOfCentralDirectory,centralDirectoryStartOffsetOnDisk: $centralDirectoryStartOffsetOnDisk,extensibleDataSector: '',));$this->send(Zip64\EndOfCentralDirectoryLocator::generate(numberOfTheDiskWithZip64CentralDirectoryStart: 0x00,zip64centralDirectoryStartOffsetOnDisk: $centralDirectoryStartOffsetOnDisk + $sizeOfCentralDirectory,totalNumberOfDisks: 1,));}// add trailing cdr eof record$numberOfCentralDirectoryEntries = min(count($this->centralDirectoryRecords), 0xFFFF);$this->send(EndOfCentralDirectory::generate(numberOfThisDisk: 0x00,numberOfTheDiskWithCentralDirectoryStart: 0x00,numberOfCentralDirectoryEntriesOnThisDisk: $numberOfCentralDirectoryEntries,numberOfCentralDirectoryEntries: $numberOfCentralDirectoryEntries,sizeOfCentralDirectory: min($sizeOfCentralDirectory, 0xFFFFFFFF),centralDirectoryStartOffsetOnDisk: min($centralDirectoryStartOffsetOnDisk, 0xFFFFFFFF),zipFileComment: $this->comment,));$size = $this->offset;// The End$this->clear();return $size;}/*** @param StreamInterface|resource|null $outputStream* @return resource*/private static function normalizeStream($outputStream){if ($outputStream instanceof StreamInterface) {return StreamWrapper::getResource($outputStream);}if (is_resource($outputStream)) {return $outputStream;}return fopen('php://output', 'wb');}/*** Record sent bytes*/private function recordSentBytes(int $sentBytes): void{$this->offset += $sentBytes;}/*** Send string, sending HTTP headers if necessary.* Flush output after write if configure option is set.*/private function send(string $data): void{if (!$this->ready) {throw new RuntimeException('Archive is already finished');}if ($this->operationMode === OperationMode::NORMAL && $this->sendHttpHeaders) {$this->sendHttpHeaders();$this->sendHttpHeaders = false;}$this->recordSentBytes(strlen($data));if ($this->operationMode === OperationMode::NORMAL) {if (fwrite($this->outputStream, $data) === false) {throw new ResourceActionException('fwrite', $this->outputStream);}if ($this->flushOutput) {// flush output buffer if it is on and flushable$status = ob_get_status();if (isset($status['flags']) && is_int($status['flags']) && ($status['flags'] & PHP_OUTPUT_HANDLER_FLUSHABLE)) {ob_flush();}// Flush system buffers after flushing userspace output bufferflush();}}}/*** Send HTTP headers for this stream.*/private function sendHttpHeaders(): void{// grab content disposition$disposition = $this->contentDisposition;if ($this->outputName) {// Various different browsers dislike various characters here. Strip them all for safety.$safeOutput = trim(str_replace(['"', "'", '\\', ';', "\n", "\r"], '', $this->outputName));// Check if we need to UTF-8 encode the filename$urlencoded = rawurlencode($safeOutput);$disposition .= "; filename*=UTF-8''{$urlencoded}";}$headers = ['Content-Type' => $this->contentType,'Content-Disposition' => $disposition,'Pragma' => 'public','Cache-Control' => 'public, must-revalidate','Content-Transfer-Encoding' => 'binary',];foreach ($headers as $key => $val) {($this->httpHeaderCallback)("$key: $val");}}/*** Clear all internal variables. Note that the stream object is not* usable after this.*/private function clear(): void{$this->centralDirectoryRecords = [];$this->offset = 0;if($this->operationMode === OperationMode::NORMAL) {$this->ready = false;$this->recordedSimulation = [];} else {$this->operationMode = OperationMode::NORMAL;}}}