ZipStream.php 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864
  1. <?php
  2. declare(strict_types=1);
  3. namespace ZipStream;
  4. use Closure;
  5. use DateTimeImmutable;
  6. use DateTimeInterface;
  7. use GuzzleHttp\Psr7\StreamWrapper;
  8. use Psr\Http\Message\StreamInterface;
  9. use RuntimeException;
  10. use ZipStream\Exception\FileNotFoundException;
  11. use ZipStream\Exception\FileNotReadableException;
  12. use ZipStream\Exception\OverflowException;
  13. use ZipStream\Exception\ResourceActionException;
  14. /**
  15. * Streamed, dynamically generated zip archives.
  16. *
  17. * ## Usage
  18. *
  19. * Streaming zip archives is a simple, three-step process:
  20. *
  21. * 1. Create the zip stream:
  22. *
  23. * ```php
  24. * $zip = new ZipStream(outputName: 'example.zip');
  25. * ```
  26. *
  27. * 2. Add one or more files to the archive:
  28. *
  29. * ```php
  30. * // add first file
  31. * $zip->addFile(fileName: 'world.txt', data: 'Hello World');
  32. *
  33. * // add second file
  34. * $zip->addFile(fileName: 'moon.txt', data: 'Hello Moon');
  35. * ```
  36. *
  37. * 3. Finish the zip stream:
  38. *
  39. * ```php
  40. * $zip->finish();
  41. * ```
  42. *
  43. * You can also add an archive comment, add comments to individual files,
  44. * and adjust the timestamp of files. See the API documentation for each
  45. * method below for additional information.
  46. *
  47. * ## Example
  48. *
  49. * ```php
  50. * // create a new zip stream object
  51. * $zip = new ZipStream(outputName: 'some_files.zip');
  52. *
  53. * // list of local files
  54. * $files = array('foo.txt', 'bar.jpg');
  55. *
  56. * // read and add each file to the archive
  57. * foreach ($files as $path)
  58. * $zip->addFileFormPath(fileName: $path, $path);
  59. *
  60. * // write archive footer to stream
  61. * $zip->finish();
  62. * ```
  63. */
  64. class ZipStream
  65. {
  66. /**
  67. * This number corresponds to the ZIP version/OS used (2 bytes)
  68. * From: https://www.iana.org/assignments/media-types/application/zip
  69. * The upper byte (leftmost one) indicates the host system (OS) for the
  70. * file. Software can use this information to determine
  71. * the line record format for text files etc. The current
  72. * mappings are:
  73. *
  74. * 0 - MS-DOS and OS/2 (F.A.T. file systems)
  75. * 1 - Amiga 2 - VAX/VMS
  76. * 3 - *nix 4 - VM/CMS
  77. * 5 - Atari ST 6 - OS/2 H.P.F.S.
  78. * 7 - Macintosh 8 - Z-System
  79. * 9 - CP/M 10 thru 255 - unused
  80. *
  81. * The lower byte (rightmost one) indicates the version number of the
  82. * software used to encode the file. The value/10
  83. * indicates the major version number, and the value
  84. * mod 10 is the minor version number.
  85. * Here we are using 6 for the OS, indicating OS/2 H.P.F.S.
  86. * to prevent file permissions issues upon extract (see #84)
  87. * 0x603 is 00000110 00000011 in binary, so 6 and 3
  88. *
  89. * @internal
  90. */
  91. public const ZIP_VERSION_MADE_BY = 0x603;
  92. private bool $ready = true;
  93. private int $offset = 0;
  94. /**
  95. * @var string[]
  96. */
  97. private array $centralDirectoryRecords = [];
  98. /**
  99. * @var resource
  100. */
  101. private $outputStream;
  102. private readonly Closure $httpHeaderCallback;
  103. /**
  104. * @var File[]
  105. */
  106. private array $recordedSimulation = [];
  107. /**
  108. * Create a new ZipStream object.
  109. *
  110. * ##### Examples
  111. *
  112. * ```php
  113. * // create a new zip file named 'foo.zip'
  114. * $zip = new ZipStream(outputName: 'foo.zip');
  115. *
  116. * // create a new zip file named 'bar.zip' with a comment
  117. * $zip = new ZipStream(
  118. * outputName: 'bar.zip',
  119. * comment: 'this is a comment for the zip file.',
  120. * );
  121. * ```
  122. *
  123. * @param OperationMode $operationMode
  124. * The mode can be used to switch between `NORMAL` and `SIMULATION_*` modes.
  125. * For details see the `OperationMode` documentation.
  126. *
  127. * Default to `NORMAL`.
  128. *
  129. * @param string $comment
  130. * Archive Level Comment
  131. *
  132. * @param StreamInterface|resource|null $outputStream
  133. * Override the output of the archive to a different target.
  134. *
  135. * By default the archive is sent to `STDOUT`.
  136. *
  137. * @param CompressionMethod $defaultCompressionMethod
  138. * How to handle file compression. Legal values are
  139. * `CompressionMethod::DEFLATE` (the default), or
  140. * `CompressionMethod::STORE`. `STORE` sends the file raw and is
  141. * significantly faster, while `DEFLATE` compresses the file and
  142. * is much, much slower.
  143. *
  144. * @param int $defaultDeflateLevel
  145. * Default deflation level. Only relevant if `compressionMethod`
  146. * is `DEFLATE`.
  147. *
  148. * See details of [`deflate_init`](https://www.php.net/manual/en/function.deflate-init.php#refsect1-function.deflate-init-parameters)
  149. *
  150. * @param bool $enableZip64
  151. * Enable Zip64 extension, supporting very large
  152. * archives (any size > 4 GB or file count > 64k)
  153. *
  154. * @param bool $defaultEnableZeroHeader
  155. * Enable streaming files with single read.
  156. *
  157. * When the zero header is set, the file is streamed into the output
  158. * and the size & checksum are added at the end of the file. This is the
  159. * fastest method and uses the least memory. Unfortunately not all
  160. * ZIP clients fully support this and can lead to clients reporting
  161. * the generated ZIP files as corrupted in combination with other
  162. * circumstances. (Zip64 enabled, using UTF8 in comments / names etc.)
  163. *
  164. * When the zero header is not set, the length & checksum need to be
  165. * defined before the file is actually added. To prevent loading all
  166. * the data into memory, the data has to be read twice. If the data
  167. * which is added is not seekable, this call will fail.
  168. *
  169. * @param bool $sendHttpHeaders
  170. * Boolean indicating whether or not to send
  171. * the HTTP headers for this file.
  172. *
  173. * @param ?Closure $httpHeaderCallback
  174. * The method called to send HTTP headers
  175. *
  176. * @param string|null $outputName
  177. * The name of the created archive.
  178. *
  179. * Only relevant if `$sendHttpHeaders = true`.
  180. *
  181. * @param string $contentDisposition
  182. * HTTP Content-Disposition
  183. *
  184. * Only relevant if `sendHttpHeaders = true`.
  185. *
  186. * @param string $contentType
  187. * HTTP Content Type
  188. *
  189. * Only relevant if `sendHttpHeaders = true`.
  190. *
  191. * @param bool $flushOutput
  192. * Enable flush after every write to output stream.
  193. *
  194. * @return self
  195. */
  196. public function __construct(
  197. private OperationMode $operationMode = OperationMode::NORMAL,
  198. private readonly string $comment = '',
  199. $outputStream = null,
  200. private readonly CompressionMethod $defaultCompressionMethod = CompressionMethod::DEFLATE,
  201. private readonly int $defaultDeflateLevel = 6,
  202. private readonly bool $enableZip64 = true,
  203. private readonly bool $defaultEnableZeroHeader = true,
  204. private bool $sendHttpHeaders = true,
  205. ?Closure $httpHeaderCallback = null,
  206. private readonly ?string $outputName = null,
  207. private readonly string $contentDisposition = 'attachment',
  208. private readonly string $contentType = 'application/x-zip',
  209. private bool $flushOutput = false,
  210. ) {
  211. $this->outputStream = self::normalizeStream($outputStream);
  212. $this->httpHeaderCallback = $httpHeaderCallback ?? header(...);
  213. }
  214. /**
  215. * Add a file to the archive.
  216. *
  217. * ##### File Options
  218. *
  219. * See {@see addFileFromPsr7Stream()}
  220. *
  221. * ##### Examples
  222. *
  223. * ```php
  224. * // add a file named 'world.txt'
  225. * $zip->addFile(fileName: 'world.txt', data: 'Hello World!');
  226. *
  227. * // add a file named 'bar.jpg' with a comment and a last-modified
  228. * // time of two hours ago
  229. * $zip->addFile(
  230. * fileName: 'bar.jpg',
  231. * data: $data,
  232. * comment: 'this is a comment about bar.jpg',
  233. * lastModificationDateTime: new DateTime('2 hours ago'),
  234. * );
  235. * ```
  236. *
  237. * @param string $data
  238. *
  239. * contents of file
  240. */
  241. public function addFile(
  242. string $fileName,
  243. string $data,
  244. string $comment = '',
  245. ?CompressionMethod $compressionMethod = null,
  246. ?int $deflateLevel = null,
  247. ?DateTimeInterface $lastModificationDateTime = null,
  248. ?int $maxSize = null,
  249. ?int $exactSize = null,
  250. ?bool $enableZeroHeader = null,
  251. ): void {
  252. $this->addFileFromCallback(
  253. fileName: $fileName,
  254. callback: fn () => $data,
  255. comment: $comment,
  256. compressionMethod: $compressionMethod,
  257. deflateLevel: $deflateLevel,
  258. lastModificationDateTime: $lastModificationDateTime,
  259. maxSize: $maxSize,
  260. exactSize: $exactSize,
  261. enableZeroHeader: $enableZeroHeader,
  262. );
  263. }
  264. /**
  265. * Add a file at path to the archive.
  266. *
  267. * ##### File Options
  268. *
  269. * See {@see addFileFromPsr7Stream()}
  270. *
  271. * ###### Examples
  272. *
  273. * ```php
  274. * // add a file named 'foo.txt' from the local file '/tmp/foo.txt'
  275. * $zip->addFileFromPath(
  276. * fileName: 'foo.txt',
  277. * path: '/tmp/foo.txt',
  278. * );
  279. *
  280. * // add a file named 'bigfile.rar' from the local file
  281. * // '/usr/share/bigfile.rar' with a comment and a last-modified
  282. * // time of two hours ago
  283. * $zip->addFile(
  284. * fileName: 'bigfile.rar',
  285. * path: '/usr/share/bigfile.rar',
  286. * comment: 'this is a comment about bigfile.rar',
  287. * lastModificationDateTime: new DateTime('2 hours ago'),
  288. * );
  289. * ```
  290. *
  291. * @throws \ZipStream\Exception\FileNotFoundException
  292. * @throws \ZipStream\Exception\FileNotReadableException
  293. */
  294. public function addFileFromPath(
  295. /**
  296. * name of file in archive (including directory path).
  297. */
  298. string $fileName,
  299. /**
  300. * path to file on disk (note: paths should be encoded using
  301. * UNIX-style forward slashes -- e.g '/path/to/some/file').
  302. */
  303. string $path,
  304. string $comment = '',
  305. ?CompressionMethod $compressionMethod = null,
  306. ?int $deflateLevel = null,
  307. ?DateTimeInterface $lastModificationDateTime = null,
  308. ?int $maxSize = null,
  309. ?int $exactSize = null,
  310. ?bool $enableZeroHeader = null,
  311. ): void {
  312. if (!is_readable($path)) {
  313. if (!file_exists($path)) {
  314. throw new FileNotFoundException($path);
  315. }
  316. throw new FileNotReadableException($path);
  317. }
  318. if ($fileTime = filemtime($path)) {
  319. $lastModificationDateTime ??= (new DateTimeImmutable())->setTimestamp($fileTime);
  320. }
  321. $this->addFileFromCallback(
  322. fileName: $fileName,
  323. callback: function () use ($path) {
  324. $stream = fopen($path, 'rb');
  325. if (!$stream) {
  326. // @codeCoverageIgnoreStart
  327. throw new ResourceActionException('fopen');
  328. // @codeCoverageIgnoreEnd
  329. }
  330. return $stream;
  331. },
  332. comment: $comment,
  333. compressionMethod: $compressionMethod,
  334. deflateLevel: $deflateLevel,
  335. lastModificationDateTime: $lastModificationDateTime,
  336. maxSize: $maxSize,
  337. exactSize: $exactSize,
  338. enableZeroHeader: $enableZeroHeader,
  339. );
  340. }
  341. /**
  342. * Add an open stream (resource) to the archive.
  343. *
  344. * ##### File Options
  345. *
  346. * See {@see addFileFromPsr7Stream()}
  347. *
  348. * ##### Examples
  349. *
  350. * ```php
  351. * // create a temporary file stream and write text to it
  352. * $filePointer = tmpfile();
  353. * fwrite($filePointer, 'The quick brown fox jumped over the lazy dog.');
  354. *
  355. * // add a file named 'streamfile.txt' from the content of the stream
  356. * $archive->addFileFromStream(
  357. * fileName: 'streamfile.txt',
  358. * stream: $filePointer,
  359. * );
  360. * ```
  361. *
  362. * @param resource $stream contents of file as a stream resource
  363. */
  364. public function addFileFromStream(
  365. string $fileName,
  366. $stream,
  367. string $comment = '',
  368. ?CompressionMethod $compressionMethod = null,
  369. ?int $deflateLevel = null,
  370. ?DateTimeInterface $lastModificationDateTime = null,
  371. ?int $maxSize = null,
  372. ?int $exactSize = null,
  373. ?bool $enableZeroHeader = null,
  374. ): void {
  375. $this->addFileFromCallback(
  376. fileName: $fileName,
  377. callback: fn () => $stream,
  378. comment: $comment,
  379. compressionMethod: $compressionMethod,
  380. deflateLevel: $deflateLevel,
  381. lastModificationDateTime: $lastModificationDateTime,
  382. maxSize: $maxSize,
  383. exactSize: $exactSize,
  384. enableZeroHeader: $enableZeroHeader,
  385. );
  386. }
  387. /**
  388. * Add an open stream to the archive.
  389. *
  390. * ##### Examples
  391. *
  392. * ```php
  393. * $stream = $response->getBody();
  394. * // add a file named 'streamfile.txt' from the content of the stream
  395. * $archive->addFileFromPsr7Stream(
  396. * fileName: 'streamfile.txt',
  397. * stream: $stream,
  398. * );
  399. * ```
  400. *
  401. * @param string $fileName
  402. * path of file in archive (including directory)
  403. *
  404. * @param StreamInterface $stream
  405. * contents of file as a stream resource
  406. *
  407. * @param string $comment
  408. * ZIP comment for this file
  409. *
  410. * @param ?CompressionMethod $compressionMethod
  411. * Override `defaultCompressionMethod`
  412. *
  413. * See {@see __construct()}
  414. *
  415. * @param ?int $deflateLevel
  416. * Override `defaultDeflateLevel`
  417. *
  418. * See {@see __construct()}
  419. *
  420. * @param ?DateTimeInterface $lastModificationDateTime
  421. * Set last modification time of file.
  422. *
  423. * Default: `now`
  424. *
  425. * @param ?int $maxSize
  426. * Only read `maxSize` bytes from file.
  427. *
  428. * The file is considered done when either reaching `EOF`
  429. * or the `maxSize`.
  430. *
  431. * @param ?int $exactSize
  432. * Read exactly `exactSize` bytes from file.
  433. * If `EOF` is reached before reading `exactSize` bytes, an error will be
  434. * thrown. The parameter allows for faster size calculations if the `stream`
  435. * does not support `fstat` size or is slow and otherwise known beforehand.
  436. *
  437. * @param ?bool $enableZeroHeader
  438. * Override `defaultEnableZeroHeader`
  439. *
  440. * See {@see __construct()}
  441. */
  442. public function addFileFromPsr7Stream(
  443. string $fileName,
  444. StreamInterface $stream,
  445. string $comment = '',
  446. ?CompressionMethod $compressionMethod = null,
  447. ?int $deflateLevel = null,
  448. ?DateTimeInterface $lastModificationDateTime = null,
  449. ?int $maxSize = null,
  450. ?int $exactSize = null,
  451. ?bool $enableZeroHeader = null,
  452. ): void {
  453. $this->addFileFromCallback(
  454. fileName: $fileName,
  455. callback: fn () => $stream,
  456. comment: $comment,
  457. compressionMethod: $compressionMethod,
  458. deflateLevel: $deflateLevel,
  459. lastModificationDateTime: $lastModificationDateTime,
  460. maxSize: $maxSize,
  461. exactSize: $exactSize,
  462. enableZeroHeader: $enableZeroHeader,
  463. );
  464. }
  465. /**
  466. * Add a file based on a callback.
  467. *
  468. * This is useful when you want to simulate a lot of files without keeping
  469. * all of the file handles open at the same time.
  470. *
  471. * ##### Examples
  472. *
  473. * ```php
  474. * foreach($files as $name => $size) {
  475. * $archive->addFileFromPsr7Stream(
  476. * fileName: 'streamfile.txt',
  477. * exactSize: $size,
  478. * callback: function() use($name): Psr\Http\Message\StreamInterface {
  479. * $response = download($name);
  480. * return $response->getBody();
  481. * }
  482. * );
  483. * }
  484. * ```
  485. *
  486. * @param string $fileName
  487. * path of file in archive (including directory)
  488. *
  489. * @param Closure $callback
  490. * @psalm-param Closure(): (resource|StreamInterface|string) $callback
  491. * A callback to get the file contents in the shape of a PHP stream,
  492. * a Psr StreamInterface implementation, or a string.
  493. *
  494. * @param string $comment
  495. * ZIP comment for this file
  496. *
  497. * @param ?CompressionMethod $compressionMethod
  498. * Override `defaultCompressionMethod`
  499. *
  500. * See {@see __construct()}
  501. *
  502. * @param ?int $deflateLevel
  503. * Override `defaultDeflateLevel`
  504. *
  505. * See {@see __construct()}
  506. *
  507. * @param ?DateTimeInterface $lastModificationDateTime
  508. * Set last modification time of file.
  509. *
  510. * Default: `now`
  511. *
  512. * @param ?int $maxSize
  513. * Only read `maxSize` bytes from file.
  514. *
  515. * The file is considered done when either reaching `EOF`
  516. * or the `maxSize`.
  517. *
  518. * @param ?int $exactSize
  519. * Read exactly `exactSize` bytes from file.
  520. * If `EOF` is reached before reading `exactSize` bytes, an error will be
  521. * thrown. The parameter allows for faster size calculations if the `stream`
  522. * does not support `fstat` size or is slow and otherwise known beforehand.
  523. *
  524. * @param ?bool $enableZeroHeader
  525. * Override `defaultEnableZeroHeader`
  526. *
  527. * See {@see __construct()}
  528. */
  529. public function addFileFromCallback(
  530. string $fileName,
  531. Closure $callback,
  532. string $comment = '',
  533. ?CompressionMethod $compressionMethod = null,
  534. ?int $deflateLevel = null,
  535. ?DateTimeInterface $lastModificationDateTime = null,
  536. ?int $maxSize = null,
  537. ?int $exactSize = null,
  538. ?bool $enableZeroHeader = null,
  539. ): void {
  540. $file = new File(
  541. dataCallback: function () use ($callback, $maxSize) {
  542. $data = $callback();
  543. if(is_resource($data)) {
  544. return $data;
  545. }
  546. if($data instanceof StreamInterface) {
  547. return StreamWrapper::getResource($data);
  548. }
  549. $stream = fopen('php://memory', 'rw+');
  550. if ($stream === false) {
  551. // @codeCoverageIgnoreStart
  552. throw new ResourceActionException('fopen');
  553. // @codeCoverageIgnoreEnd
  554. }
  555. if ($maxSize !== null && fwrite($stream, $data, $maxSize) === false) {
  556. // @codeCoverageIgnoreStart
  557. throw new ResourceActionException('fwrite', $stream);
  558. // @codeCoverageIgnoreEnd
  559. } elseif (fwrite($stream, $data) === false) {
  560. // @codeCoverageIgnoreStart
  561. throw new ResourceActionException('fwrite', $stream);
  562. // @codeCoverageIgnoreEnd
  563. }
  564. if (rewind($stream) === false) {
  565. // @codeCoverageIgnoreStart
  566. throw new ResourceActionException('rewind', $stream);
  567. // @codeCoverageIgnoreEnd
  568. }
  569. return $stream;
  570. },
  571. send: $this->send(...),
  572. recordSentBytes: $this->recordSentBytes(...),
  573. operationMode: $this->operationMode,
  574. fileName: $fileName,
  575. startOffset: $this->offset,
  576. compressionMethod: $compressionMethod ?? $this->defaultCompressionMethod,
  577. comment: $comment,
  578. deflateLevel: $deflateLevel ?? $this->defaultDeflateLevel,
  579. lastModificationDateTime: $lastModificationDateTime ?? new DateTimeImmutable(),
  580. maxSize: $maxSize,
  581. exactSize: $exactSize,
  582. enableZip64: $this->enableZip64,
  583. enableZeroHeader: $enableZeroHeader ?? $this->defaultEnableZeroHeader,
  584. );
  585. if($this->operationMode !== OperationMode::NORMAL) {
  586. $this->recordedSimulation[] = $file;
  587. }
  588. $this->centralDirectoryRecords[] = $file->process();
  589. }
  590. /**
  591. * Add a directory to the archive.
  592. *
  593. * ##### File Options
  594. *
  595. * See {@see addFileFromPsr7Stream()}
  596. *
  597. * ##### Examples
  598. *
  599. * ```php
  600. * // add a directory named 'world/'
  601. * $zip->addFile(fileName: 'world/');
  602. * ```
  603. */
  604. public function addDirectory(
  605. string $fileName,
  606. string $comment = '',
  607. ?DateTimeInterface $lastModificationDateTime = null,
  608. ): void {
  609. if (!str_ends_with($fileName, '/')) {
  610. $fileName .= '/';
  611. }
  612. $this->addFile(
  613. fileName: $fileName,
  614. data: '',
  615. comment: $comment,
  616. compressionMethod: CompressionMethod::STORE,
  617. deflateLevel: null,
  618. lastModificationDateTime: $lastModificationDateTime,
  619. maxSize: 0,
  620. exactSize: 0,
  621. enableZeroHeader: false,
  622. );
  623. }
  624. /**
  625. * Executes a previously calculated simulation.
  626. *
  627. * ##### Example
  628. *
  629. * ```php
  630. * $zip = new ZipStream(
  631. * outputName: 'foo.zip',
  632. * operationMode: OperationMode::SIMULATE_STRICT,
  633. * );
  634. *
  635. * $zip->addFile('test.txt', 'Hello World');
  636. *
  637. * $size = $zip->finish();
  638. *
  639. * header('Content-Length: '. $size);
  640. *
  641. * $zip->executeSimulation();
  642. * ```
  643. */
  644. public function executeSimulation(): void
  645. {
  646. if($this->operationMode !== OperationMode::NORMAL) {
  647. throw new RuntimeException('Zip simulation is not finished.');
  648. }
  649. foreach($this->recordedSimulation as $file) {
  650. $this->centralDirectoryRecords[] = $file->cloneSimulationExecution()->process();
  651. }
  652. $this->finish();
  653. }
  654. /**
  655. * Write zip footer to stream.
  656. *
  657. * The clase is left in an unusable state after `finish`.
  658. *
  659. * ##### Example
  660. *
  661. * ```php
  662. * // write footer to stream
  663. * $zip->finish();
  664. * ```
  665. */
  666. public function finish(): int
  667. {
  668. $centralDirectoryStartOffsetOnDisk = $this->offset;
  669. $sizeOfCentralDirectory = 0;
  670. // add trailing cdr file records
  671. foreach ($this->centralDirectoryRecords as $centralDirectoryRecord) {
  672. $this->send($centralDirectoryRecord);
  673. $sizeOfCentralDirectory += strlen($centralDirectoryRecord);
  674. }
  675. // Add 64bit headers (if applicable)
  676. if (count($this->centralDirectoryRecords) >= 0xFFFF ||
  677. $centralDirectoryStartOffsetOnDisk > 0xFFFFFFFF ||
  678. $sizeOfCentralDirectory > 0xFFFFFFFF) {
  679. if (!$this->enableZip64) {
  680. throw new OverflowException();
  681. }
  682. $this->send(Zip64\EndOfCentralDirectory::generate(
  683. versionMadeBy: self::ZIP_VERSION_MADE_BY,
  684. versionNeededToExtract: Version::ZIP64->value,
  685. numberOfThisDisk: 0,
  686. numberOfTheDiskWithCentralDirectoryStart: 0,
  687. numberOfCentralDirectoryEntriesOnThisDisk: count($this->centralDirectoryRecords),
  688. numberOfCentralDirectoryEntries: count($this->centralDirectoryRecords),
  689. sizeOfCentralDirectory: $sizeOfCentralDirectory,
  690. centralDirectoryStartOffsetOnDisk: $centralDirectoryStartOffsetOnDisk,
  691. extensibleDataSector: '',
  692. ));
  693. $this->send(Zip64\EndOfCentralDirectoryLocator::generate(
  694. numberOfTheDiskWithZip64CentralDirectoryStart: 0x00,
  695. zip64centralDirectoryStartOffsetOnDisk: $centralDirectoryStartOffsetOnDisk + $sizeOfCentralDirectory,
  696. totalNumberOfDisks: 1,
  697. ));
  698. }
  699. // add trailing cdr eof record
  700. $numberOfCentralDirectoryEntries = min(count($this->centralDirectoryRecords), 0xFFFF);
  701. $this->send(EndOfCentralDirectory::generate(
  702. numberOfThisDisk: 0x00,
  703. numberOfTheDiskWithCentralDirectoryStart: 0x00,
  704. numberOfCentralDirectoryEntriesOnThisDisk: $numberOfCentralDirectoryEntries,
  705. numberOfCentralDirectoryEntries: $numberOfCentralDirectoryEntries,
  706. sizeOfCentralDirectory: min($sizeOfCentralDirectory, 0xFFFFFFFF),
  707. centralDirectoryStartOffsetOnDisk: min($centralDirectoryStartOffsetOnDisk, 0xFFFFFFFF),
  708. zipFileComment: $this->comment,
  709. ));
  710. $size = $this->offset;
  711. // The End
  712. $this->clear();
  713. return $size;
  714. }
  715. /**
  716. * @param StreamInterface|resource|null $outputStream
  717. * @return resource
  718. */
  719. private static function normalizeStream($outputStream)
  720. {
  721. if ($outputStream instanceof StreamInterface) {
  722. return StreamWrapper::getResource($outputStream);
  723. }
  724. if (is_resource($outputStream)) {
  725. return $outputStream;
  726. }
  727. return fopen('php://output', 'wb');
  728. }
  729. /**
  730. * Record sent bytes
  731. */
  732. private function recordSentBytes(int $sentBytes): void
  733. {
  734. $this->offset += $sentBytes;
  735. }
  736. /**
  737. * Send string, sending HTTP headers if necessary.
  738. * Flush output after write if configure option is set.
  739. */
  740. private function send(string $data): void
  741. {
  742. if (!$this->ready) {
  743. throw new RuntimeException('Archive is already finished');
  744. }
  745. if ($this->operationMode === OperationMode::NORMAL && $this->sendHttpHeaders) {
  746. $this->sendHttpHeaders();
  747. $this->sendHttpHeaders = false;
  748. }
  749. $this->recordSentBytes(strlen($data));
  750. if ($this->operationMode === OperationMode::NORMAL) {
  751. if (fwrite($this->outputStream, $data) === false) {
  752. throw new ResourceActionException('fwrite', $this->outputStream);
  753. }
  754. if ($this->flushOutput) {
  755. // flush output buffer if it is on and flushable
  756. $status = ob_get_status();
  757. if (isset($status['flags']) && is_int($status['flags']) && ($status['flags'] & PHP_OUTPUT_HANDLER_FLUSHABLE)) {
  758. ob_flush();
  759. }
  760. // Flush system buffers after flushing userspace output buffer
  761. flush();
  762. }
  763. }
  764. }
  765. /**
  766. * Send HTTP headers for this stream.
  767. */
  768. private function sendHttpHeaders(): void
  769. {
  770. // grab content disposition
  771. $disposition = $this->contentDisposition;
  772. if ($this->outputName) {
  773. // Various different browsers dislike various characters here. Strip them all for safety.
  774. $safeOutput = trim(str_replace(['"', "'", '\\', ';', "\n", "\r"], '', $this->outputName));
  775. // Check if we need to UTF-8 encode the filename
  776. $urlencoded = rawurlencode($safeOutput);
  777. $disposition .= "; filename*=UTF-8''{$urlencoded}";
  778. }
  779. $headers = [
  780. 'Content-Type' => $this->contentType,
  781. 'Content-Disposition' => $disposition,
  782. 'Pragma' => 'public',
  783. 'Cache-Control' => 'public, must-revalidate',
  784. 'Content-Transfer-Encoding' => 'binary',
  785. ];
  786. foreach ($headers as $key => $val) {
  787. ($this->httpHeaderCallback)("$key: $val");
  788. }
  789. }
  790. /**
  791. * Clear all internal variables. Note that the stream object is not
  792. * usable after this.
  793. */
  794. private function clear(): void
  795. {
  796. $this->centralDirectoryRecords = [];
  797. $this->offset = 0;
  798. if($this->operationMode === OperationMode::NORMAL) {
  799. $this->ready = false;
  800. $this->recordedSimulation = [];
  801. } else {
  802. $this->operationMode = OperationMode::NORMAL;
  803. }
  804. }
  805. }