Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | 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)
1441 ariadna 62
 *   $zip->addFileFromPath(fileName: $path, $path);
1 efrain 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,
1441 ariadna 266
            callback: fn() => $data,
1 efrain 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
1441 ariadna 296
     * $zip->addFileFromPath(
1 efrain 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
 
1441 ariadna 333
        $fileTime = filemtime($path);
334
        if ($fileTime !== false) {
1 efrain 335
            $lastModificationDateTime ??= (new DateTimeImmutable())->setTimestamp($fileTime);
336
        }
337
 
338
        $this->addFileFromCallback(
339
            fileName: $fileName,
340
            callback: function () use ($path) {
341
 
342
                $stream =  fopen($path, 'rb');
343
 
344
                if (!$stream) {
345
                    // @codeCoverageIgnoreStart
346
                    throw new ResourceActionException('fopen');
347
                    // @codeCoverageIgnoreEnd
348
                }
349
 
350
                return $stream;
351
            },
352
            comment: $comment,
353
            compressionMethod: $compressionMethod,
354
            deflateLevel: $deflateLevel,
355
            lastModificationDateTime: $lastModificationDateTime,
356
            maxSize: $maxSize,
357
            exactSize: $exactSize,
358
            enableZeroHeader: $enableZeroHeader,
359
        );
360
    }
361
 
362
    /**
363
     * Add an open stream (resource) to the archive.
364
     *
365
     * ##### File Options
366
     *
367
     * See {@see addFileFromPsr7Stream()}
368
     *
369
     * ##### Examples
370
     *
371
     * ```php
372
     * // create a temporary file stream and write text to it
373
     * $filePointer = tmpfile();
374
     * fwrite($filePointer, 'The quick brown fox jumped over the lazy dog.');
375
     *
376
     * // add a file named 'streamfile.txt' from the content of the stream
377
     * $archive->addFileFromStream(
378
     *   fileName: 'streamfile.txt',
379
     *   stream: $filePointer,
380
     * );
381
     * ```
382
     *
383
     * @param resource $stream contents of file as a stream resource
384
     */
385
    public function addFileFromStream(
386
        string $fileName,
387
        $stream,
388
        string $comment = '',
389
        ?CompressionMethod $compressionMethod = null,
390
        ?int $deflateLevel = null,
391
        ?DateTimeInterface $lastModificationDateTime = null,
392
        ?int $maxSize = null,
393
        ?int $exactSize = null,
394
        ?bool $enableZeroHeader = null,
395
    ): void {
396
        $this->addFileFromCallback(
397
            fileName: $fileName,
1441 ariadna 398
            callback: fn() => $stream,
1 efrain 399
            comment: $comment,
400
            compressionMethod: $compressionMethod,
401
            deflateLevel: $deflateLevel,
402
            lastModificationDateTime: $lastModificationDateTime,
403
            maxSize: $maxSize,
404
            exactSize: $exactSize,
405
            enableZeroHeader: $enableZeroHeader,
406
        );
407
    }
408
 
409
    /**
410
     * Add an open stream to the archive.
411
     *
412
     * ##### Examples
413
     *
414
     * ```php
415
     * $stream = $response->getBody();
416
     * // add a file named 'streamfile.txt' from the content of the stream
417
     * $archive->addFileFromPsr7Stream(
418
     *   fileName: 'streamfile.txt',
419
     *   stream: $stream,
420
     * );
421
     * ```
422
     *
423
     * @param string $fileName
424
     * path of file in archive (including directory)
425
     *
426
     * @param StreamInterface $stream
427
     * contents of file as a stream resource
428
     *
429
     * @param string $comment
430
     * ZIP comment for this file
431
     *
432
     * @param ?CompressionMethod $compressionMethod
433
     * Override `defaultCompressionMethod`
434
     *
435
     * See {@see __construct()}
436
     *
437
     * @param ?int $deflateLevel
438
     * Override `defaultDeflateLevel`
439
     *
440
     * See {@see __construct()}
441
     *
442
     * @param ?DateTimeInterface $lastModificationDateTime
443
     * Set last modification time of file.
444
     *
445
     * Default: `now`
446
     *
447
     * @param ?int $maxSize
448
     * Only read `maxSize` bytes from file.
449
     *
450
     * The file is considered done when either reaching `EOF`
451
     * or the `maxSize`.
452
     *
453
     * @param ?int $exactSize
454
     * Read exactly `exactSize` bytes from file.
455
     * If `EOF` is reached before reading `exactSize` bytes, an error will be
456
     * thrown. The parameter allows for faster size calculations if the `stream`
457
     * does not support `fstat` size or is slow and otherwise known beforehand.
458
     *
459
     * @param ?bool $enableZeroHeader
460
     * Override `defaultEnableZeroHeader`
461
     *
462
     * See {@see __construct()}
463
     */
464
    public function addFileFromPsr7Stream(
465
        string $fileName,
466
        StreamInterface $stream,
467
        string $comment = '',
468
        ?CompressionMethod $compressionMethod = null,
469
        ?int $deflateLevel = null,
470
        ?DateTimeInterface $lastModificationDateTime = null,
471
        ?int $maxSize = null,
472
        ?int $exactSize = null,
473
        ?bool $enableZeroHeader = null,
474
    ): void {
475
        $this->addFileFromCallback(
476
            fileName: $fileName,
1441 ariadna 477
            callback: fn() => $stream,
1 efrain 478
            comment: $comment,
479
            compressionMethod: $compressionMethod,
480
            deflateLevel: $deflateLevel,
481
            lastModificationDateTime: $lastModificationDateTime,
482
            maxSize: $maxSize,
483
            exactSize: $exactSize,
484
            enableZeroHeader: $enableZeroHeader,
485
        );
486
    }
487
 
488
    /**
489
     * Add a file based on a callback.
490
     *
491
     * This is useful when you want to simulate a lot of files without keeping
492
     * all of the file handles open at the same time.
493
     *
494
     * ##### Examples
495
     *
496
     * ```php
497
     * foreach($files as $name => $size) {
1441 ariadna 498
     *   $archive->addFileFromCallback(
1 efrain 499
     *     fileName: 'streamfile.txt',
500
     *     exactSize: $size,
501
     *     callback: function() use($name): Psr\Http\Message\StreamInterface {
502
     *       $response = download($name);
503
     *       return $response->getBody();
504
     *     }
505
     *   );
506
     * }
507
     * ```
508
     *
509
     * @param string $fileName
510
     * path of file in archive (including directory)
511
     *
512
     * @param Closure $callback
513
     * @psalm-param Closure(): (resource|StreamInterface|string) $callback
514
     * A callback to get the file contents in the shape of a PHP stream,
515
     * a Psr StreamInterface implementation, or a string.
516
     *
517
     * @param string $comment
518
     * ZIP comment for this file
519
     *
520
     * @param ?CompressionMethod $compressionMethod
521
     * Override `defaultCompressionMethod`
522
     *
523
     * See {@see __construct()}
524
     *
525
     * @param ?int $deflateLevel
526
     * Override `defaultDeflateLevel`
527
     *
528
     * See {@see __construct()}
529
     *
530
     * @param ?DateTimeInterface $lastModificationDateTime
531
     * Set last modification time of file.
532
     *
533
     * Default: `now`
534
     *
535
     * @param ?int $maxSize
536
     * Only read `maxSize` bytes from file.
537
     *
538
     * The file is considered done when either reaching `EOF`
539
     * or the `maxSize`.
540
     *
541
     * @param ?int $exactSize
542
     * Read exactly `exactSize` bytes from file.
543
     * If `EOF` is reached before reading `exactSize` bytes, an error will be
544
     * thrown. The parameter allows for faster size calculations if the `stream`
545
     * does not support `fstat` size or is slow and otherwise known beforehand.
546
     *
547
     * @param ?bool $enableZeroHeader
548
     * Override `defaultEnableZeroHeader`
549
     *
550
     * See {@see __construct()}
551
     */
552
    public function addFileFromCallback(
553
        string $fileName,
554
        Closure $callback,
555
        string $comment = '',
556
        ?CompressionMethod $compressionMethod = null,
557
        ?int $deflateLevel = null,
558
        ?DateTimeInterface $lastModificationDateTime = null,
559
        ?int $maxSize = null,
560
        ?int $exactSize = null,
561
        ?bool $enableZeroHeader = null,
562
    ): void {
563
        $file = new File(
564
            dataCallback: function () use ($callback, $maxSize) {
565
                $data = $callback();
566
 
1441 ariadna 567
                if (is_resource($data)) {
1 efrain 568
                    return $data;
569
                }
570
 
1441 ariadna 571
                if ($data instanceof StreamInterface) {
1 efrain 572
                    return StreamWrapper::getResource($data);
573
                }
574
 
575
 
576
                $stream = fopen('php://memory', 'rw+');
577
                if ($stream === false) {
578
                    // @codeCoverageIgnoreStart
579
                    throw new ResourceActionException('fopen');
580
                    // @codeCoverageIgnoreEnd
581
                }
582
                if ($maxSize !== null && fwrite($stream, $data, $maxSize) === false) {
583
                    // @codeCoverageIgnoreStart
584
                    throw new ResourceActionException('fwrite', $stream);
585
                    // @codeCoverageIgnoreEnd
586
                } elseif (fwrite($stream, $data) === false) {
587
                    // @codeCoverageIgnoreStart
588
                    throw new ResourceActionException('fwrite', $stream);
589
                    // @codeCoverageIgnoreEnd
590
                }
591
                if (rewind($stream) === false) {
592
                    // @codeCoverageIgnoreStart
593
                    throw new ResourceActionException('rewind', $stream);
594
                    // @codeCoverageIgnoreEnd
595
                }
596
 
597
                return $stream;
598
 
599
            },
600
            send: $this->send(...),
601
            recordSentBytes: $this->recordSentBytes(...),
602
            operationMode: $this->operationMode,
603
            fileName: $fileName,
604
            startOffset: $this->offset,
605
            compressionMethod: $compressionMethod ?? $this->defaultCompressionMethod,
606
            comment: $comment,
607
            deflateLevel: $deflateLevel ?? $this->defaultDeflateLevel,
608
            lastModificationDateTime: $lastModificationDateTime ?? new DateTimeImmutable(),
609
            maxSize: $maxSize,
610
            exactSize: $exactSize,
611
            enableZip64: $this->enableZip64,
612
            enableZeroHeader: $enableZeroHeader ?? $this->defaultEnableZeroHeader,
613
        );
614
 
1441 ariadna 615
        if ($this->operationMode !== OperationMode::NORMAL) {
1 efrain 616
            $this->recordedSimulation[] = $file;
617
        }
618
 
619
        $this->centralDirectoryRecords[] = $file->process();
620
    }
621
 
622
    /**
623
     * Add a directory to the archive.
624
     *
625
     * ##### File Options
626
     *
627
     * See {@see addFileFromPsr7Stream()}
628
     *
629
     * ##### Examples
630
     *
631
     * ```php
632
     * // add a directory named 'world/'
1441 ariadna 633
     * $zip->addDirectory(fileName: 'world/');
1 efrain 634
     * ```
635
     */
636
    public function addDirectory(
637
        string $fileName,
638
        string $comment = '',
639
        ?DateTimeInterface $lastModificationDateTime = null,
640
    ): void {
641
        if (!str_ends_with($fileName, '/')) {
642
            $fileName .= '/';
643
        }
644
 
645
        $this->addFile(
646
            fileName: $fileName,
647
            data: '',
648
            comment: $comment,
649
            compressionMethod: CompressionMethod::STORE,
650
            deflateLevel: null,
651
            lastModificationDateTime: $lastModificationDateTime,
652
            maxSize: 0,
653
            exactSize: 0,
654
            enableZeroHeader: false,
655
        );
656
    }
657
 
658
    /**
659
     * Executes a previously calculated simulation.
660
     *
661
     * ##### Example
662
     *
663
     * ```php
664
     * $zip = new ZipStream(
665
     *   outputName: 'foo.zip',
666
     *   operationMode: OperationMode::SIMULATE_STRICT,
667
     * );
668
     *
669
     * $zip->addFile('test.txt', 'Hello World');
670
     *
671
     * $size = $zip->finish();
672
     *
673
     * header('Content-Length: '. $size);
674
     *
675
     * $zip->executeSimulation();
676
     * ```
677
     */
678
    public function executeSimulation(): void
679
    {
1441 ariadna 680
        if ($this->operationMode !== OperationMode::NORMAL) {
1 efrain 681
            throw new RuntimeException('Zip simulation is not finished.');
682
        }
683
 
1441 ariadna 684
        foreach ($this->recordedSimulation as $file) {
1 efrain 685
            $this->centralDirectoryRecords[] = $file->cloneSimulationExecution()->process();
686
        }
687
 
688
        $this->finish();
689
    }
690
 
691
    /**
692
     * Write zip footer to stream.
693
     *
694
     * The clase is left in an unusable state after `finish`.
695
     *
696
     * ##### Example
697
     *
698
     * ```php
699
     * // write footer to stream
700
     * $zip->finish();
701
     * ```
702
     */
703
    public function finish(): int
704
    {
705
        $centralDirectoryStartOffsetOnDisk = $this->offset;
706
        $sizeOfCentralDirectory = 0;
707
 
708
        // add trailing cdr file records
709
        foreach ($this->centralDirectoryRecords as $centralDirectoryRecord) {
710
            $this->send($centralDirectoryRecord);
711
            $sizeOfCentralDirectory += strlen($centralDirectoryRecord);
712
        }
713
 
714
        // Add 64bit headers (if applicable)
715
        if (count($this->centralDirectoryRecords) >= 0xFFFF ||
716
            $centralDirectoryStartOffsetOnDisk > 0xFFFFFFFF ||
717
            $sizeOfCentralDirectory > 0xFFFFFFFF) {
718
            if (!$this->enableZip64) {
719
                throw new OverflowException();
720
            }
721
 
722
            $this->send(Zip64\EndOfCentralDirectory::generate(
723
                versionMadeBy: self::ZIP_VERSION_MADE_BY,
724
                versionNeededToExtract: Version::ZIP64->value,
725
                numberOfThisDisk: 0,
726
                numberOfTheDiskWithCentralDirectoryStart: 0,
727
                numberOfCentralDirectoryEntriesOnThisDisk: count($this->centralDirectoryRecords),
728
                numberOfCentralDirectoryEntries: count($this->centralDirectoryRecords),
729
                sizeOfCentralDirectory: $sizeOfCentralDirectory,
730
                centralDirectoryStartOffsetOnDisk: $centralDirectoryStartOffsetOnDisk,
731
                extensibleDataSector: '',
732
            ));
733
 
734
            $this->send(Zip64\EndOfCentralDirectoryLocator::generate(
735
                numberOfTheDiskWithZip64CentralDirectoryStart: 0x00,
736
                zip64centralDirectoryStartOffsetOnDisk: $centralDirectoryStartOffsetOnDisk + $sizeOfCentralDirectory,
737
                totalNumberOfDisks: 1,
738
            ));
739
        }
740
 
741
        // add trailing cdr eof record
742
        $numberOfCentralDirectoryEntries = min(count($this->centralDirectoryRecords), 0xFFFF);
743
        $this->send(EndOfCentralDirectory::generate(
744
            numberOfThisDisk: 0x00,
745
            numberOfTheDiskWithCentralDirectoryStart: 0x00,
746
            numberOfCentralDirectoryEntriesOnThisDisk: $numberOfCentralDirectoryEntries,
747
            numberOfCentralDirectoryEntries: $numberOfCentralDirectoryEntries,
748
            sizeOfCentralDirectory: min($sizeOfCentralDirectory, 0xFFFFFFFF),
749
            centralDirectoryStartOffsetOnDisk: min($centralDirectoryStartOffsetOnDisk, 0xFFFFFFFF),
750
            zipFileComment: $this->comment,
751
        ));
752
 
753
        $size = $this->offset;
754
 
755
        // The End
756
        $this->clear();
757
 
758
        return $size;
759
    }
760
 
761
    /**
762
     * @param StreamInterface|resource|null $outputStream
763
     * @return resource
764
     */
765
    private static function normalizeStream($outputStream)
766
    {
767
        if ($outputStream instanceof StreamInterface) {
768
            return StreamWrapper::getResource($outputStream);
769
        }
770
        if (is_resource($outputStream)) {
771
            return $outputStream;
772
        }
1441 ariadna 773
        $resource = fopen('php://output', 'wb');
774
 
775
        if ($resource === false) {
776
            throw new RuntimeException('fopen of php://output failed');
777
        }
778
 
779
        return $resource;
1 efrain 780
    }
781
 
782
    /**
783
     * Record sent bytes
784
     */
785
    private function recordSentBytes(int $sentBytes): void
786
    {
787
        $this->offset += $sentBytes;
788
    }
789
 
790
    /**
791
     * Send string, sending HTTP headers if necessary.
792
     * Flush output after write if configure option is set.
793
     */
794
    private function send(string $data): void
795
    {
796
        if (!$this->ready) {
797
            throw new RuntimeException('Archive is already finished');
798
        }
799
 
800
        if ($this->operationMode === OperationMode::NORMAL && $this->sendHttpHeaders) {
801
            $this->sendHttpHeaders();
802
            $this->sendHttpHeaders = false;
803
        }
804
 
805
        $this->recordSentBytes(strlen($data));
806
 
807
        if ($this->operationMode === OperationMode::NORMAL) {
808
            if (fwrite($this->outputStream, $data) === false) {
809
                throw new ResourceActionException('fwrite', $this->outputStream);
810
            }
811
 
812
            if ($this->flushOutput) {
813
                // flush output buffer if it is on and flushable
814
                $status = ob_get_status();
815
                if (isset($status['flags']) && is_int($status['flags']) && ($status['flags'] & PHP_OUTPUT_HANDLER_FLUSHABLE)) {
816
                    ob_flush();
817
                }
818
 
819
                // Flush system buffers after flushing userspace output buffer
820
                flush();
821
            }
822
        }
823
    }
824
 
1441 ariadna 825
    /**
826
    * Send HTTP headers for this stream.
827
    */
1 efrain 828
    private function sendHttpHeaders(): void
829
    {
830
        // grab content disposition
831
        $disposition = $this->contentDisposition;
832
 
1441 ariadna 833
        if ($this->outputName !== null) {
1 efrain 834
            // Various different browsers dislike various characters here. Strip them all for safety.
835
            $safeOutput = trim(str_replace(['"', "'", '\\', ';', "\n", "\r"], '', $this->outputName));
836
 
837
            // Check if we need to UTF-8 encode the filename
838
            $urlencoded = rawurlencode($safeOutput);
839
            $disposition .= "; filename*=UTF-8''{$urlencoded}";
840
        }
841
 
842
        $headers = [
843
            'Content-Type' => $this->contentType,
844
            'Content-Disposition' => $disposition,
845
            'Pragma' => 'public',
846
            'Cache-Control' => 'public, must-revalidate',
847
            'Content-Transfer-Encoding' => 'binary',
848
        ];
849
 
850
        foreach ($headers as $key => $val) {
851
            ($this->httpHeaderCallback)("$key: $val");
852
        }
853
    }
854
 
855
    /**
856
     * Clear all internal variables. Note that the stream object is not
857
     * usable after this.
858
     */
859
    private function clear(): void
860
    {
861
        $this->centralDirectoryRecords = [];
862
        $this->offset = 0;
863
 
1441 ariadna 864
        if ($this->operationMode === OperationMode::NORMAL) {
1 efrain 865
            $this->ready = false;
866
            $this->recordedSimulation = [];
867
        } else {
868
            $this->operationMode = OperationMode::NORMAL;
869
        }
870
    }
871
}