Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

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