* For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace PhpZip; use PhpZip\Constants\UnixStat; use PhpZip\Constants\ZipCompressionLevel; use PhpZip\Constants\ZipCompressionMethod; use PhpZip\Constants\ZipEncryptionMethod; use PhpZip\Constants\ZipOptions; use PhpZip\Constants\ZipPlatform; use PhpZip\Exception\InvalidArgumentException; use PhpZip\Exception\ZipEntryNotFoundException; use PhpZip\Exception\ZipException; use PhpZip\IO\Stream\ResponseStream; use PhpZip\IO\Stream\ZipEntryStreamWrapper; use PhpZip\IO\ZipReader; use PhpZip\IO\ZipWriter; use PhpZip\Model\Data\ZipFileData; use PhpZip\Model\Data\ZipNewData; use PhpZip\Model\ImmutableZipContainer; use PhpZip\Model\ZipContainer; use PhpZip\Model\ZipEntry; use PhpZip\Model\ZipEntryMatcher; use PhpZip\Util\FilesUtil; use PhpZip\Util\StringUtil; use Psr\Http\Message\ResponseInterface; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\SplFileInfo as SymfonySplFileInfo; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; /** * Create, open .ZIP files, modify, get info and extract files. * * Implemented support traditional PKWARE encryption and WinZip AES encryption. * Implemented support ZIP64. * * @see https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT .ZIP File Format Specification */ class ZipFile implements \Countable, \ArrayAccess, \Iterator { /** @var array default mime types */ private const DEFAULT_MIME_TYPES = [ 'zip' => 'application/zip', 'apk' => 'application/vnd.android.package-archive', 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'epub' => 'application/epub+zip', 'jar' => 'application/java-archive', 'odt' => 'application/vnd.oasis.opendocument.text', 'pptx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'xpi' => 'application/x-xpinstall', ]; protected ZipContainer $zipContainer; private ?ZipReader $reader = null; public function __construct() { $this->zipContainer = $this->createZipContainer(); } /** * @param resource $inputStream */ protected function createZipReader($inputStream, array $options = []): ZipReader { return new ZipReader($inputStream, $options); } protected function createZipWriter(): ZipWriter { return new ZipWriter($this->zipContainer); } protected function createZipContainer(?ImmutableZipContainer $sourceContainer = null): ZipContainer { return new ZipContainer($sourceContainer); } /** * Open zip archive from file. * * @throws ZipException if can't open file * * @return ZipFile */ public function openFile(string $filename, array $options = []): self { if (!file_exists($filename)) { throw new ZipException("File {$filename} does not exist."); } /** @psalm-suppress InvalidArgument */ set_error_handler( static function (int $errorNumber, string $errorString): ?bool { throw new InvalidArgumentException($errorString, $errorNumber); } ); $handle = fopen($filename, 'rb'); restore_error_handler(); return $this->openFromStream($handle, $options); } /** * Open zip archive from raw string data. * * @throws ZipException if can't open temp stream * * @return ZipFile */ public function openFromString(string $data, array $options = []): self { if ($data === '') { throw new InvalidArgumentException('Empty string passed'); } if (!($handle = fopen('php://temp', 'r+b'))) { // @codeCoverageIgnoreStart throw new ZipException('A temporary resource cannot be opened for writing.'); // @codeCoverageIgnoreEnd } fwrite($handle, $data); rewind($handle); return $this->openFromStream($handle, $options); } /** * Open zip archive from stream resource. * * @param resource $handle * * @throws ZipException * * @return ZipFile */ public function openFromStream($handle, array $options = []): self { $this->reader = $this->createZipReader($handle, $options); $this->zipContainer = $this->createZipContainer($this->reader->read()); return $this; } /** * @return string[] returns the list files */ public function getListFiles(): array { // strval is needed to cast entry names to string type return array_map('strval', array_keys($this->zipContainer->getEntries())); } /** * @return int returns the number of entries in this ZIP file */ public function count(): int { return $this->zipContainer->count(); } /** * Returns the file comment. * * @return string|null the file comment */ public function getArchiveComment(): ?string { return $this->zipContainer->getArchiveComment(); } /** * Set archive comment. * * @param ?string $comment * * @return ZipFile */ public function setArchiveComment(?string $comment = null): self { $this->zipContainer->setArchiveComment($comment); return $this; } /** * Checks if there is an entry in the archive. */ public function hasEntry(string $entryName): bool { return $this->zipContainer->hasEntry($entryName); } /** * Returns ZipEntry object. * * @throws ZipEntryNotFoundException */ public function getEntry(string $entryName): ZipEntry { return $this->zipContainer->getEntry($entryName); } /** * Checks that the entry in the archive is a directory. * Returns true if and only if this ZIP entry represents a directory entry * (i.e. end with '/'). * * @throws ZipEntryNotFoundException */ public function isDirectory(string $entryName): bool { return $this->getEntry($entryName)->isDirectory(); } /** * Returns entry comment. * * @throws ZipEntryNotFoundException * @throws ZipException */ public function getEntryComment(string $entryName): string { return $this->getEntry($entryName)->getComment(); } /** * Set entry comment. * * @param ?string $comment * * @throws ZipEntryNotFoundException * @throws ZipException * * @return ZipFile */ public function setEntryComment(string $entryName, ?string $comment = null): self { $this->getEntry($entryName)->setComment($comment); return $this; } /** * Returns the entry contents. * * @throws ZipException * @throws ZipEntryNotFoundException */ public function getEntryContents(string $entryName): string { $zipData = $this->zipContainer->getEntry($entryName)->getData(); if ($zipData === null) { throw new ZipException(sprintf('No data for zip entry %s', $entryName)); } return $zipData->getDataAsString(); } /** * @throws ZipEntryNotFoundException * @throws ZipException * * @return resource */ public function getEntryStream(string $entryName) { $resource = ZipEntryStreamWrapper::wrap($this->zipContainer->getEntry($entryName)); rewind($resource); return $resource; } public function matcher(): ZipEntryMatcher { return $this->zipContainer->matcher(); } /** * Returns an array of zip records (ex. for modify time). * * @return ZipEntry[] array of raw zip entries */ public function getEntries(): array { return $this->zipContainer->getEntries(); } /** * Extract the archive contents (unzip). * * Extract the complete archive or the given files to the specified destination. * * @param string $destDir location where to extract the files * @param mixed $entries entries to extract (array, string or null) * @param array $options extract options * @param array|null $extractedEntries if the extractedEntries argument is present, * then the specified array will be filled with * information about the extracted entries * * @throws ZipException * * @return ZipFile */ public function extractTo( string $destDir, $entries = null, array $options = [], ?array &$extractedEntries = [] ): self { if (!file_exists($destDir)) { throw new ZipException(sprintf('Destination %s not found', $destDir)); } if (!is_dir($destDir)) { throw new ZipException('Destination is not directory'); } if (!is_writable($destDir)) { throw new ZipException('Destination is not writable directory'); } if ($extractedEntries === null) { $extractedEntries = []; } $defaultOptions = [ ZipOptions::EXTRACT_SYMLINKS => false, ]; /** @noinspection AdditionOperationOnArraysInspection */ $options += $defaultOptions; $zipEntries = $this->zipContainer->getEntries(); if (!empty($entries)) { if (\is_string($entries)) { $entries = (array) $entries; } if (\is_array($entries)) { $entries = array_unique($entries); $zipEntries = array_intersect_key($zipEntries, array_flip($entries)); } } if (empty($zipEntries)) { return $this; } /** @var int[] $lastModDirs */ $lastModDirs = []; krsort($zipEntries, \SORT_NATURAL); $symlinks = []; $destDir = rtrim($destDir, '/\\'); foreach ($zipEntries as $entryName => $entry) { $unixMode = $entry->getUnixMode(); $entryName = FilesUtil::normalizeZipPath($entryName); $file = $destDir . \DIRECTORY_SEPARATOR . $entryName; $extractedEntries[$file] = $entry; $modifyTimestamp = $entry->getMTime()->getTimestamp(); $atime = $entry->getATime(); $accessTimestamp = $atime === null ? null : $atime->getTimestamp(); $dir = $entry->isDirectory() ? $file : \dirname($file); if (!is_dir($dir)) { $dirMode = $entry->isDirectory() ? $unixMode : 0755; if ($dirMode === 0) { $dirMode = 0755; } if (!mkdir($dir, $dirMode, true) && !is_dir($dir)) { // @codeCoverageIgnoreStart throw new \RuntimeException(sprintf('Directory "%s" was not created', $dir)); // @codeCoverageIgnoreEnd } chmod($dir, $dirMode); } $parts = explode('/', rtrim($entryName, '/')); $path = $destDir . \DIRECTORY_SEPARATOR; foreach ($parts as $part) { if (!isset($lastModDirs[$path]) || $lastModDirs[$path] > $modifyTimestamp) { $lastModDirs[$path] = $modifyTimestamp; } $path .= $part . \DIRECTORY_SEPARATOR; } if ($entry->isDirectory()) { $lastModDirs[$dir] = $modifyTimestamp; continue; } $zipData = $entry->getData(); if ($zipData === null) { continue; } if ($entry->isUnixSymlink()) { $symlinks[$file] = $zipData->getDataAsString(); continue; } /** @psalm-suppress InvalidArgument */ set_error_handler( static function (int $errorNumber, string $errorString) use ($entry, $file): ?bool { throw new ZipException( sprintf( 'Cannot extract zip entry %s. File %s cannot open for write. %s', $entry->getName(), $file, $errorString ), $errorNumber ); } ); $handle = fopen($file, 'w+b'); restore_error_handler(); try { $zipData->copyDataToStream($handle); } catch (ZipException $e) { unlink($file); throw $e; } fclose($handle); if ($unixMode === 0) { $unixMode = 0644; } chmod($file, $unixMode); if ($accessTimestamp !== null) { /** @noinspection PotentialMalwareInspection */ touch($file, $modifyTimestamp, $accessTimestamp); } else { touch($file, $modifyTimestamp); } } $allowSymlink = (bool) $options[ZipOptions::EXTRACT_SYMLINKS]; foreach ($symlinks as $linkPath => $target) { if (!FilesUtil::symlink($target, $linkPath, $allowSymlink)) { unset($extractedEntries[$linkPath]); } } krsort($lastModDirs, \SORT_NATURAL); foreach ($lastModDirs as $dir => $lastMod) { touch($dir, $lastMod); } ksort($extractedEntries); return $this; } /** * Add entry from the string. * * @param string $entryName zip entry name * @param string $contents string contents * @param int|null $compressionMethod Compression method. * Use {@see ZipCompressionMethod::STORED}, * {@see ZipCompressionMethod::DEFLATED} or * {@see ZipCompressionMethod::BZIP2}. * If null, then auto choosing method. * * @throws ZipException * * @return ZipFile */ public function addFromString(string $entryName, string $contents, ?int $compressionMethod = null): self { $entryName = $this->normalizeEntryName($entryName); $length = \strlen($contents); if ($compressionMethod === null || $compressionMethod === ZipEntry::UNKNOWN) { if ($length < 512) { $compressionMethod = ZipCompressionMethod::STORED; } else { $mimeType = FilesUtil::getMimeTypeFromString($contents); $compressionMethod = FilesUtil::isBadCompressionMimeType($mimeType) ? ZipCompressionMethod::STORED : ZipCompressionMethod::DEFLATED; } } $zipEntry = new ZipEntry($entryName); $zipEntry->setData(new ZipNewData($zipEntry, $contents)); $zipEntry->setUncompressedSize($length); $zipEntry->setCompressionMethod($compressionMethod); $zipEntry->setCreatedOS(ZipPlatform::OS_UNIX); $zipEntry->setExtractedOS(ZipPlatform::OS_UNIX); $zipEntry->setUnixMode(0100644); $zipEntry->setTime(time()); $this->addZipEntry($zipEntry); return $this; } protected function normalizeEntryName(string $entryName): string { $entryName = ltrim($entryName, '\\/'); if (\DIRECTORY_SEPARATOR === '\\') { $entryName = str_replace('\\', '/', $entryName); } if ($entryName === '') { throw new InvalidArgumentException('Empty entry name'); } return $entryName; } /** * @throws ZipException * * @return ZipEntry[] */ public function addFromFinder(Finder $finder, array $options = []): array { $defaultOptions = [ ZipOptions::STORE_ONLY_FILES => false, ZipOptions::COMPRESSION_METHOD => null, ZipOptions::MODIFIED_TIME => null, ]; /** @noinspection AdditionOperationOnArraysInspection */ $options += $defaultOptions; if ($options[ZipOptions::STORE_ONLY_FILES]) { $finder->files(); } $entries = []; foreach ($finder as $fileInfo) { if ($fileInfo->isReadable()) { $entry = $this->addSplFile($fileInfo, null, $options); $entries[$entry->getName()] = $entry; } } return $entries; } /** * @param ?string $entryName * * @throws ZipException */ public function addSplFile(\SplFileInfo $file, ?string $entryName = null, array $options = []): ZipEntry { if ($file instanceof \DirectoryIterator) { throw new InvalidArgumentException('File should not be \DirectoryIterator.'); } $defaultOptions = [ ZipOptions::COMPRESSION_METHOD => null, ZipOptions::MODIFIED_TIME => null, ]; /** @noinspection AdditionOperationOnArraysInspection */ $options += $defaultOptions; if (!$file->isReadable()) { throw new InvalidArgumentException(sprintf('File %s is not readable', $file->getPathname())); } if ($entryName === null) { if ($file instanceof SymfonySplFileInfo) { $entryName = $file->getRelativePathname(); } else { $entryName = $file->getBasename(); } } $entryName = $this->normalizeEntryName($entryName); $entryName = $file->isDir() ? rtrim($entryName, '/\\') . '/' : $entryName; $zipEntry = new ZipEntry($entryName); $zipEntry->setCreatedOS(ZipPlatform::OS_UNIX); $zipEntry->setExtractedOS(ZipPlatform::OS_UNIX); $zipData = null; $filePerms = $file->getPerms(); if ($file->isLink()) { $linkTarget = $file->getLinkTarget(); $lengthLinkTarget = \strlen($linkTarget); $zipEntry->setCompressionMethod(ZipCompressionMethod::STORED); $zipEntry->setUncompressedSize($lengthLinkTarget); $zipEntry->setCompressedSize($lengthLinkTarget); $zipEntry->setCrc(crc32($linkTarget)); $filePerms |= UnixStat::UNX_IFLNK; $zipData = new ZipNewData($zipEntry, $linkTarget); } elseif ($file->isFile()) { if (isset($options[ZipOptions::COMPRESSION_METHOD])) { $compressionMethod = $options[ZipOptions::COMPRESSION_METHOD]; } elseif ($file->getSize() < 512) { $compressionMethod = ZipCompressionMethod::STORED; } else { $compressionMethod = FilesUtil::isBadCompressionFile($file->getPathname()) ? ZipCompressionMethod::STORED : ZipCompressionMethod::DEFLATED; } $zipEntry->setCompressionMethod($compressionMethod); $zipData = new ZipFileData($zipEntry, $file); } elseif ($file->isDir()) { $zipEntry->setCompressionMethod(ZipCompressionMethod::STORED); $zipEntry->setUncompressedSize(0); $zipEntry->setCompressedSize(0); $zipEntry->setCrc(0); } $zipEntry->setUnixMode($filePerms); $timestamp = null; if (isset($options[ZipOptions::MODIFIED_TIME])) { $mtime = $options[ZipOptions::MODIFIED_TIME]; if ($mtime instanceof \DateTimeInterface) { $timestamp = $mtime->getTimestamp(); } elseif (is_numeric($mtime)) { $timestamp = (int) $mtime; } elseif (\is_string($mtime)) { $timestamp = strtotime($mtime); if ($timestamp === false) { $timestamp = null; } } } if ($timestamp === null) { $timestamp = $file->getMTime(); } $zipEntry->setTime($timestamp); $zipEntry->setData($zipData); $this->addZipEntry($zipEntry); return $zipEntry; } protected function addZipEntry(ZipEntry $zipEntry): void { $this->zipContainer->addEntry($zipEntry); } /** * Add entry from the file. * * @param string $filename destination file * @param string|null $entryName zip Entry name * @param int|null $compressionMethod Compression method. * Use {@see ZipCompressionMethod::STORED}, * {@see ZipCompressionMethod::DEFLATED} or * {@see ZipCompressionMethod::BZIP2}. * If null, then auto choosing method. * * @throws ZipException * * @return ZipFile */ public function addFile(string $filename, ?string $entryName = null, ?int $compressionMethod = null): self { $this->addSplFile( new \SplFileInfo($filename), $entryName, [ ZipOptions::COMPRESSION_METHOD => $compressionMethod, ] ); return $this; } /** * Add entry from the stream. * * @param resource $stream stream resource * @param string $entryName zip Entry name * @param int|null $compressionMethod Compression method. * Use {@see ZipCompressionMethod::STORED}, * {@see ZipCompressionMethod::DEFLATED} or * {@see ZipCompressionMethod::BZIP2}. * If null, then auto choosing method. * * @throws ZipException * * @return ZipFile */ public function addFromStream($stream, string $entryName, ?int $compressionMethod = null): self { if (!\is_resource($stream)) { throw new InvalidArgumentException('Stream is not resource'); } $entryName = $this->normalizeEntryName($entryName); $zipEntry = new ZipEntry($entryName); $fstat = fstat($stream); if ($fstat !== false) { $unixMode = $fstat['mode']; $length = $fstat['size']; if ($compressionMethod === null || $compressionMethod === ZipEntry::UNKNOWN) { if ($length < 512) { $compressionMethod = ZipCompressionMethod::STORED; } else { rewind($stream); $bufferContents = stream_get_contents($stream, min(1024, $length)); rewind($stream); $mimeType = FilesUtil::getMimeTypeFromString($bufferContents); $compressionMethod = FilesUtil::isBadCompressionMimeType($mimeType) ? ZipCompressionMethod::STORED : ZipCompressionMethod::DEFLATED; } $zipEntry->setUncompressedSize($length); } } else { $unixMode = 0100644; if ($compressionMethod === null || $compressionMethod === ZipEntry::UNKNOWN) { $compressionMethod = ZipCompressionMethod::DEFLATED; } } $zipEntry->setCreatedOS(ZipPlatform::OS_UNIX); $zipEntry->setExtractedOS(ZipPlatform::OS_UNIX); $zipEntry->setUnixMode($unixMode); $zipEntry->setCompressionMethod($compressionMethod); $zipEntry->setTime(time()); $zipEntry->setData(new ZipNewData($zipEntry, $stream)); $this->addZipEntry($zipEntry); return $this; } /** * Add an empty directory in the zip archive. * * @throws ZipException * * @return ZipFile */ public function addEmptyDir(string $dirName): self { $dirName = $this->normalizeEntryName($dirName); $dirName = rtrim($dirName, '\\/') . '/'; $zipEntry = new ZipEntry($dirName); $zipEntry->setCompressionMethod(ZipCompressionMethod::STORED); $zipEntry->setUncompressedSize(0); $zipEntry->setCompressedSize(0); $zipEntry->setCrc(0); $zipEntry->setCreatedOS(ZipPlatform::OS_UNIX); $zipEntry->setExtractedOS(ZipPlatform::OS_UNIX); $zipEntry->setUnixMode(040755); $zipEntry->setTime(time()); $this->addZipEntry($zipEntry); return $this; } /** * Add directory not recursively to the zip archive. * * @param string $inputDir Input directory * @param string $localPath add files to this directory, or the root * @param int|null $compressionMethod Compression method. * Use {@see ZipCompressionMethod::STORED}, * {@see ZipCompressionMethod::DEFLATED} or * {@see ZipCompressionMethod::BZIP2}. * If null, then auto choosing method. * * @throws ZipException * * @return ZipFile */ public function addDir(string $inputDir, string $localPath = '/', ?int $compressionMethod = null): self { if ($inputDir === '') { throw new InvalidArgumentException('The input directory is not specified'); } if (!is_dir($inputDir)) { throw new InvalidArgumentException(sprintf('The "%s" directory does not exist.', $inputDir)); } $inputDir = rtrim($inputDir, '/\\') . \DIRECTORY_SEPARATOR; $directoryIterator = new \DirectoryIterator($inputDir); return $this->addFilesFromIterator($directoryIterator, $localPath, $compressionMethod); } /** * Add recursive directory to the zip archive. * * @param string $inputDir Input directory * @param string $localPath add files to this directory, or the root * @param int|null $compressionMethod Compression method. * Use {@see ZipCompressionMethod::STORED}, {@see * ZipCompressionMethod::DEFLATED} or * {@see ZipCompressionMethod::BZIP2}. * If null, then auto choosing method. * * @throws ZipException * * @return ZipFile * * @see ZipCompressionMethod::STORED * @see ZipCompressionMethod::DEFLATED * @see ZipCompressionMethod::BZIP2 */ public function addDirRecursive(string $inputDir, string $localPath = '/', ?int $compressionMethod = null): self { if ($inputDir === '') { throw new InvalidArgumentException('The input directory is not specified'); } if (!is_dir($inputDir)) { throw new InvalidArgumentException(sprintf('The "%s" directory does not exist.', $inputDir)); } $inputDir = rtrim($inputDir, '/\\') . \DIRECTORY_SEPARATOR; $directoryIterator = new \RecursiveDirectoryIterator($inputDir); return $this->addFilesFromIterator($directoryIterator, $localPath, $compressionMethod); } /** * Add directories from directory iterator. * * @param \Iterator $iterator directory iterator * @param string $localPath add files to this directory, or the root * @param int|null $compressionMethod Compression method. * Use {@see ZipCompressionMethod::STORED}, {@see * ZipCompressionMethod::DEFLATED} or * {@see ZipCompressionMethod::BZIP2}. * If null, then auto choosing method. * * @throws ZipException * * @return ZipFile * * @see ZipCompressionMethod::STORED * @see ZipCompressionMethod::DEFLATED * @see ZipCompressionMethod::BZIP2 */ public function addFilesFromIterator( \Iterator $iterator, string $localPath = '/', ?int $compressionMethod = null ): self { if ($localPath !== '') { $localPath = trim($localPath, '\\/'); } else { $localPath = ''; } $iterator = $iterator instanceof \RecursiveIterator ? new \RecursiveIteratorIterator($iterator) : new \IteratorIterator($iterator); /** * @var string[] $files * @var string $path */ $files = []; foreach ($iterator as $file) { if ($file instanceof \SplFileInfo) { if ($file->getBasename() === '..') { continue; } if ($file->getBasename() === '.') { $files[] = \dirname($file->getPathname()); } else { $files[] = $file->getPathname(); } } } if (empty($files)) { return $this; } natcasesort($files); $path = array_shift($files); $this->doAddFiles($path, $files, $localPath, $compressionMethod); return $this; } /** * Add files from glob pattern. * * @param string $inputDir Input directory * @param string $globPattern glob pattern * @param string $localPath add files to this directory, or the root * @param int|null $compressionMethod Compression method. * Use {@see ZipCompressionMethod::STORED}, * {@see ZipCompressionMethod::DEFLATED} or * {@see ZipCompressionMethod::BZIP2}. * If null, then auto choosing method. * * @throws ZipException * * @return ZipFile * @sse https://en.wikipedia.org/wiki/Glob_(programming) Glob pattern syntax */ public function addFilesFromGlob( string $inputDir, string $globPattern, string $localPath = '/', ?int $compressionMethod = null ): self { return $this->addGlob($inputDir, $globPattern, $localPath, false, $compressionMethod); } /** * Add files from glob pattern. * * @param string $inputDir Input directory * @param string $globPattern glob pattern * @param string $localPath add files to this directory, or the root * @param bool $recursive recursive search * @param int|null $compressionMethod Compression method. * Use {@see ZipCompressionMethod::STORED}, * {@see ZipCompressionMethod::DEFLATED} or * {@see ZipCompressionMethod::BZIP2}. * If null, then auto choosing method. * * @throws ZipException * * @return ZipFile * * @sse https://en.wikipedia.org/wiki/Glob_(programming) Glob pattern syntax */ private function addGlob( string $inputDir, string $globPattern, string $localPath = '/', bool $recursive = true, ?int $compressionMethod = null ): self { if ($inputDir === '') { throw new InvalidArgumentException('The input directory is not specified'); } if (!is_dir($inputDir)) { throw new InvalidArgumentException(sprintf('The "%s" directory does not exist.', $inputDir)); } if (empty($globPattern)) { throw new InvalidArgumentException('The glob pattern is not specified'); } $inputDir = rtrim($inputDir, '/\\') . \DIRECTORY_SEPARATOR; $globPattern = $inputDir . $globPattern; $filesFound = FilesUtil::globFileSearch($globPattern, \GLOB_BRACE, $recursive); if (empty($filesFound)) { return $this; } $this->doAddFiles($inputDir, $filesFound, $localPath, $compressionMethod); return $this; } /** * Add files recursively from glob pattern. * * @param string $inputDir Input directory * @param string $globPattern glob pattern * @param string $localPath add files to this directory, or the root * @param int|null $compressionMethod Compression method. * Use {@see ZipCompressionMethod::STORED}, * {@see ZipCompressionMethod::DEFLATED} or * {@see ZipCompressionMethod::BZIP2}. * If null, then auto choosing method. * * @throws ZipException * * @return ZipFile * @sse https://en.wikipedia.org/wiki/Glob_(programming) Glob pattern syntax */ public function addFilesFromGlobRecursive( string $inputDir, string $globPattern, string $localPath = '/', ?int $compressionMethod = null ): self { return $this->addGlob($inputDir, $globPattern, $localPath, true, $compressionMethod); } /** * Add files from regex pattern. * * @param string $inputDir search files in this directory * @param string $regexPattern regex pattern * @param string $localPath add files to this directory, or the root * @param int|null $compressionMethod Compression method. * Use {@see ZipCompressionMethod::STORED}, * {@see ZipCompressionMethod::DEFLATED} or * {@see ZipCompressionMethod::BZIP2}. * If null, then auto choosing method. * * @throws ZipException * * @return ZipFile * * @internal param bool $recursive Recursive search */ public function addFilesFromRegex( string $inputDir, string $regexPattern, string $localPath = '/', ?int $compressionMethod = null ): self { return $this->addRegex($inputDir, $regexPattern, $localPath, false, $compressionMethod); } /** * Add files from regex pattern. * * @param string $inputDir search files in this directory * @param string $regexPattern regex pattern * @param string $localPath add files to this directory, or the root * @param bool $recursive recursive search * @param int|null $compressionMethod Compression method. * Use {@see ZipCompressionMethod::STORED}, * {@see ZipCompressionMethod::DEFLATED} or * {@see ZipCompressionMethod::BZIP2}. * If null, then auto choosing method. * * @throws ZipException * * @return ZipFile */ private function addRegex( string $inputDir, string $regexPattern, string $localPath = '/', bool $recursive = true, ?int $compressionMethod = null ): self { if ($regexPattern === '') { throw new InvalidArgumentException('The regex pattern is not specified'); } if ($inputDir === '') { throw new InvalidArgumentException('The input directory is not specified'); } if (!is_dir($inputDir)) { throw new InvalidArgumentException(sprintf('The "%s" directory does not exist.', $inputDir)); } $inputDir = rtrim($inputDir, '/\\') . \DIRECTORY_SEPARATOR; $files = FilesUtil::regexFileSearch($inputDir, $regexPattern, $recursive); if (empty($files)) { return $this; } $this->doAddFiles($inputDir, $files, $localPath, $compressionMethod); return $this; } /** * @param ?int $compressionMethod * * @throws ZipException */ private function doAddFiles( string $fileSystemDir, array $files, string $zipPath, ?int $compressionMethod = null ): void { $fileSystemDir = rtrim($fileSystemDir, '/\\') . \DIRECTORY_SEPARATOR; if (!empty($zipPath)) { $zipPath = trim($zipPath, '\\/') . '/'; } else { $zipPath = '/'; } /** * @var string $file */ foreach ($files as $file) { $filename = str_replace($fileSystemDir, $zipPath, $file); $filename = ltrim($filename, '\\/'); if (is_dir($file) && FilesUtil::isEmptyDir($file)) { $this->addEmptyDir($filename); } elseif (is_file($file)) { $this->addFile($file, $filename, $compressionMethod); } } } /** * Add files recursively from regex pattern. * * @param string $inputDir search files in this directory * @param string $regexPattern regex pattern * @param string $localPath add files to this directory, or the root * @param int|null $compressionMethod Compression method. * Use {@see ZipCompressionMethod::STORED}, * {@see ZipCompressionMethod::DEFLATED} or * {@see ZipCompressionMethod::BZIP2}. * If null, then auto choosing method. * * @throws ZipException * * @return ZipFile * * @internal param bool $recursive Recursive search */ public function addFilesFromRegexRecursive( string $inputDir, string $regexPattern, string $localPath = '/', ?int $compressionMethod = null ): self { return $this->addRegex($inputDir, $regexPattern, $localPath, true, $compressionMethod); } /** * Add array data to archive. * Keys is local names. * Values is contents. * * @param array $mapData associative array for added to zip */ public function addAll(array $mapData): void { foreach ($mapData as $localName => $content) { $this[$localName] = $content; } } /** * Rename the entry. * * @param string $oldName old entry name * @param string $newName new entry name * * @throws ZipException * * @return ZipFile */ public function rename(string $oldName, string $newName): self { $oldName = ltrim($oldName, '\\/'); $newName = ltrim($newName, '\\/'); if ($oldName !== $newName) { $this->zipContainer->renameEntry($oldName, $newName); } return $this; } /** * Delete entry by name. * * @param string $entryName zip Entry name * * @throws ZipEntryNotFoundException if entry not found * * @return ZipFile */ public function deleteFromName(string $entryName): self { $entryName = ltrim($entryName, '\\/'); if (!$this->zipContainer->deleteEntry($entryName)) { throw new ZipEntryNotFoundException($entryName); } return $this; } /** * Delete entries by glob pattern. * * @param string $globPattern Glob pattern * * @return ZipFile * @sse https://en.wikipedia.org/wiki/Glob_(programming) Glob pattern syntax */ public function deleteFromGlob(string $globPattern): self { if (empty($globPattern)) { throw new InvalidArgumentException('The glob pattern is not specified'); } $globPattern = '~' . FilesUtil::convertGlobToRegEx($globPattern) . '~si'; $this->deleteFromRegex($globPattern); return $this; } /** * Delete entries by regex pattern. * * @param string $regexPattern Regex pattern * * @return ZipFile */ public function deleteFromRegex(string $regexPattern): self { if (empty($regexPattern)) { throw new InvalidArgumentException('The regex pattern is not specified'); } $this->matcher()->match($regexPattern)->delete(); return $this; } /** * Delete all entries. * * @return ZipFile */ public function deleteAll(): self { $this->zipContainer->deleteAll(); return $this; } /** * Set compression level for new entries. * * @return ZipFile * * @see ZipCompressionLevel::NORMAL * @see ZipCompressionLevel::SUPER_FAST * @see ZipCompressionLevel::FAST * @see ZipCompressionLevel::MAXIMUM */ public function setCompressionLevel(int $compressionLevel = ZipCompressionLevel::NORMAL): self { foreach ($this->zipContainer->getEntries() as $entry) { $entry->setCompressionLevel($compressionLevel); } return $this; } /** * @throws ZipException * * @return ZipFile * * @see ZipCompressionLevel::NORMAL * @see ZipCompressionLevel::SUPER_FAST * @see ZipCompressionLevel::FAST * @see ZipCompressionLevel::MAXIMUM */ public function setCompressionLevelEntry(string $entryName, int $compressionLevel): self { $this->getEntry($entryName)->setCompressionLevel($compressionLevel); return $this; } /** * @param int $compressionMethod Compression method. * Use {@see ZipCompressionMethod::STORED}, * {@see ZipCompressionMethod::DEFLATED} or * {@see ZipCompressionMethod::BZIP2}. * If null, then auto choosing method. * * @throws ZipException * * @return ZipFile * * @see ZipCompressionMethod::STORED * @see ZipCompressionMethod::DEFLATED * @see ZipCompressionMethod::BZIP2 */ public function setCompressionMethodEntry(string $entryName, int $compressionMethod): self { $this->zipContainer ->getEntry($entryName) ->setCompressionMethod($compressionMethod) ; return $this; } /** * Set password to all input encrypted entries. * * @param string $password Password * * @return ZipFile */ public function setReadPassword(string $password): self { $this->zipContainer->setReadPassword($password); return $this; } /** * Set password to concrete input entry. * * @param string $password Password * * @throws ZipException * * @return ZipFile */ public function setReadPasswordEntry(string $entryName, string $password): self { $this->zipContainer->setReadPasswordEntry($entryName, $password); return $this; } /** * Sets a new password for all files in the archive. * * @param string $password Password * @param int|null $encryptionMethod Encryption method * * @throws ZipEntryNotFoundException * * @return ZipFile */ public function setPassword(string $password, ?int $encryptionMethod = ZipEncryptionMethod::WINZIP_AES_256): self { $this->zipContainer->setWritePassword($password); if ($encryptionMethod !== null) { $this->zipContainer->setEncryptionMethod($encryptionMethod); } return $this; } /** * Sets a new password of an entry defined by its name. * * @param ?int $encryptionMethod * * @throws ZipException * * @return ZipFile */ public function setPasswordEntry(string $entryName, string $password, ?int $encryptionMethod = null): self { $this->getEntry($entryName)->setPassword($password, $encryptionMethod); return $this; } /** * Disable encryption for all entries that are already in the archive. * * @throws ZipEntryNotFoundException * * @return ZipFile */ public function disableEncryption(): self { $this->zipContainer->removePassword(); return $this; } /** * Disable encryption of an entry defined by its name. * * @throws ZipEntryNotFoundException * * @return ZipFile */ public function disableEncryptionEntry(string $entryName): self { $this->zipContainer->removePasswordEntry($entryName); return $this; } /** * Undo all changes done in the archive. * * @return ZipFile */ public function unchangeAll(): self { $this->zipContainer->unchangeAll(); return $this; } /** * Undo change archive comment. * * @return ZipFile */ public function unchangeArchiveComment(): self { $this->zipContainer->unchangeArchiveComment(); return $this; } /** * Revert all changes done to an entry with the given name. * * @param string|ZipEntry $entry Entry name or ZipEntry * * @return ZipFile */ public function unchangeEntry($entry): self { $this->zipContainer->unchangeEntry($entry); return $this; } /** * Save as file. * * @param string $filename Output filename * * @throws ZipException * * @return ZipFile */ public function saveAsFile(string $filename): self { $tempFilename = $filename . '.temp' . uniqid('', false); /** @psalm-suppress InvalidArgument */ set_error_handler( static function (int $errorNumber, string $errorString): ?bool { throw new InvalidArgumentException($errorString, $errorNumber); } ); $handle = fopen($tempFilename, 'w+b'); restore_error_handler(); $this->saveAsStream($handle); $reopen = false; if ($this->reader !== null) { $meta = $this->reader->getStreamMetaData(); if ($meta['wrapper_type'] === 'plainfile' && isset($meta['uri'])) { $readFilePath = realpath($meta['uri']); $writeFilePath = realpath($filename); if ($readFilePath !== false && $writeFilePath !== false && $readFilePath === $writeFilePath) { $this->reader->close(); $reopen = true; } } } if (!rename($tempFilename, $filename)) { if (is_file($tempFilename)) { unlink($tempFilename); } throw new ZipException(sprintf('Cannot move %s to %s', $tempFilename, $filename)); } if ($reopen) { return $this->openFile($filename); } return $this; } /** * Save as stream. * * @param resource $handle Output stream resource * * @throws ZipException * * @return ZipFile */ public function saveAsStream($handle): self { if (!\is_resource($handle)) { throw new InvalidArgumentException('handle is not resource'); } $this->writeZipToStream($handle); fclose($handle); return $this; } /** * Output .ZIP archive as attachment. * Die after output. * * @param string $outputFilename Output filename * @param string|null $mimeType Mime-Type * @param bool $attachment Http Header 'Content-Disposition' if true then attachment otherwise inline * * @throws ZipException */ public function outputAsAttachment(string $outputFilename, ?string $mimeType = null, bool $attachment = true): void { [ 'resource' => $resource, 'headers' => $headers, ] = $this->getOutputData($outputFilename, $mimeType, $attachment); if (!headers_sent()) { foreach ($headers as $key => $value) { header($key . ': ' . $value); } } rewind($resource); try { echo stream_get_contents($resource, -1, 0); } finally { fclose($resource); } } /** * @param ?string $mimeType * * @throws ZipException */ private function getOutputData(string $outputFilename, ?string $mimeType = null, bool $attachment = true): array { $mimeType ??= $this->getMimeTypeByFilename($outputFilename); if (!($handle = fopen('php://temp', 'w+b'))) { throw new InvalidArgumentException('php://temp cannot open for write.'); } $this->writeZipToStream($handle); $this->close(); $size = fstat($handle)['size']; $contentDisposition = $attachment ? 'attachment' : 'inline'; $name = basename($outputFilename); if (!empty($name)) { $contentDisposition .= '; filename="' . $name . '"'; } return [ 'resource' => $handle, 'headers' => [ 'Content-Disposition' => $contentDisposition, 'Content-Type' => $mimeType, 'Content-Length' => $size, ], ]; } protected function getMimeTypeByFilename(string $outputFilename): string { $ext = strtolower(pathinfo($outputFilename, \PATHINFO_EXTENSION)); if (!empty($ext) && isset(self::DEFAULT_MIME_TYPES[$ext])) { return self::DEFAULT_MIME_TYPES[$ext]; } return self::DEFAULT_MIME_TYPES['zip']; } /** * Output .ZIP archive as PSR-7 Response. * * @param ResponseInterface $response Instance PSR-7 Response * @param string $outputFilename Output filename * @param string|null $mimeType Mime-Type * @param bool $attachment Http Header 'Content-Disposition' if true then attachment otherwise inline * * @throws ZipException * * @deprecated deprecated since version 2.0, replace to {@see ZipFile::outputAsPsr7Response} */ public function outputAsResponse( ResponseInterface $response, string $outputFilename, ?string $mimeType = null, bool $attachment = true ): ResponseInterface { @trigger_error( sprintf( 'Method %s is deprecated. Replace to %s::%s', __METHOD__, __CLASS__, 'outputAsPsr7Response' ), \E_USER_DEPRECATED ); return $this->outputAsPsr7Response($response, $outputFilename, $mimeType, $attachment); } /** * Output .ZIP archive as PSR-7 Response. * * @param ResponseInterface $response Instance PSR-7 Response * @param string $outputFilename Output filename * @param string|null $mimeType Mime-Type * @param bool $attachment Http Header 'Content-Disposition' if true then attachment otherwise inline * * @throws ZipException * * @since 4.0.0 */ public function outputAsPsr7Response( ResponseInterface $response, string $outputFilename, ?string $mimeType = null, bool $attachment = true ): ResponseInterface { [ 'resource' => $resource, 'headers' => $headers, ] = $this->getOutputData($outputFilename, $mimeType, $attachment); foreach ($headers as $key => $value) { /** @noinspection CallableParameterUseCaseInTypeContextInspection */ $response = $response->withHeader($key, (string) $value); } return $response->withBody(new ResponseStream($resource)); } /** * Output .ZIP archive as Symfony Response. * * @param string $outputFilename Output filename * @param string|null $mimeType Mime-Type * @param bool $attachment Http Header 'Content-Disposition' if true then attachment otherwise inline * * @throws ZipException * * @since 4.0.0 */ public function outputAsSymfonyResponse( string $outputFilename, ?string $mimeType = null, bool $attachment = true ): Response { [ 'resource' => $resource, 'headers' => $headers, ] = $this->getOutputData($outputFilename, $mimeType, $attachment); return new StreamedResponse( static function () use ($resource): void { if (!($output = fopen('php://output', 'w+b'))) { throw new InvalidArgumentException('php://output cannot open for write.'); } rewind($resource); stream_copy_to_stream($resource, $output); fclose($output); fclose($resource); }, 200, $headers ); } /** * @param resource $handle * * @throws ZipException */ protected function writeZipToStream($handle): void { $this->onBeforeSave(); $this->createZipWriter()->write($handle); } /** * Returns the zip archive as a string. * * @throws ZipException */ public function outputAsString(): string { if (!($handle = fopen('php://temp', 'w+b'))) { throw new InvalidArgumentException('php://temp cannot open for write.'); } $this->writeZipToStream($handle); rewind($handle); try { return stream_get_contents($handle); } finally { fclose($handle); } } /** * Event before save or output. */ protected function onBeforeSave(): void { } /** * Close zip archive and release input stream. */ public function close(): void { if ($this->reader !== null) { $this->reader->close(); $this->reader = null; } $this->zipContainer = $this->createZipContainer(); gc_collect_cycles(); } /** * Save and reopen zip archive. * * @throws ZipException * * @return ZipFile */ public function rewrite(): self { if ($this->reader === null) { throw new ZipException('input stream is null'); } $meta = $this->reader->getStreamMetaData(); if ($meta['wrapper_type'] !== 'plainfile' || !isset($meta['uri'])) { throw new ZipException('Overwrite is only supported for open local files.'); } return $this->saveAsFile($meta['uri']); } /** * Release all resources. */ public function __destruct() { $this->close(); } /** * Offset to set. * * @see http://php.net/manual/en/arrayaccess.offsetset.php * * @param mixed $offset the offset to assign the value to * @param string|\DirectoryIterator|\SplFileInfo|resource $value the value to set * * @throws ZipException * * @see ZipFile::addFromString * @see ZipFile::addEmptyDir * @see ZipFile::addFile * @see ZipFile::addFilesFromIterator */ public function offsetSet($offset, $value): void { if ($offset === null) { throw new InvalidArgumentException('Key must not be null, but must contain the name of the zip entry.'); } $offset = ltrim((string) $offset, '\\/'); if ($offset === '') { throw new InvalidArgumentException('Key is empty, but must contain the name of the zip entry.'); } if ($value instanceof \DirectoryIterator) { $this->addFilesFromIterator($value, $offset); } elseif ($value instanceof \SplFileInfo) { $this->addSplFile($value, $offset); } elseif (StringUtil::endsWith($offset, '/')) { $this->addEmptyDir($offset); } elseif (\is_resource($value)) { $this->addFromStream($value, $offset); } else { $this->addFromString($offset, (string) $value); } } /** * Offset to unset. * * @see http://php.net/manual/en/arrayaccess.offsetunset.php * * @param mixed $offset zip entry name * * @throws ZipEntryNotFoundException */ public function offsetUnset($offset): void { $this->deleteFromName($offset); } /** * Return the current element. * * @see http://php.net/manual/en/iterator.current.php * * @throws ZipException */ public function current(): ?string { return $this->offsetGet($this->key()); } /** * Offset to retrieve. * * @see http://php.net/manual/en/arrayaccess.offsetget.php * * @param mixed $offset zip entry name * * @throws ZipException */ public function offsetGet($offset): ?string { return $this->getEntryContents($offset); } /** * Return the key of the current element. * * @see http://php.net/manual/en/iterator.key.php * * @return string|null scalar on success, or null on failure */ public function key(): ?string { return key($this->zipContainer->getEntries()); } /** * Move forward to next element. * * @see http://php.net/manual/en/iterator.next.php */ public function next(): void { next($this->zipContainer->getEntries()); } /** * Checks if current position is valid. * * @see http://php.net/manual/en/iterator.valid.php * * @return bool The return value will be casted to boolean and then evaluated. * Returns true on success or false on failure. */ public function valid(): bool { $key = $this->key(); return $key !== null && isset($this->zipContainer->getEntries()[$key]); } /** * Whether a offset exists. * * @see http://php.net/manual/en/arrayaccess.offsetexists.php * * @param mixed $offset an offset to check for * * @return bool true on success or false on failure. * The return value will be casted to boolean if non-boolean was returned. */ public function offsetExists($offset): bool { return isset($this->zipContainer->getEntries()[$offset]); } /** * Rewind the Iterator to the first element. * * @see http://php.net/manual/en/iterator.rewind.php */ public function rewind(): void { reset($this->zipContainer->getEntries()); } }