1<?php
2declare(strict_types=1);
3
4namespace ZipStream;
5
6use Psr\Http\Message\StreamInterface;
7use ZipStream\Exception\OverflowException;
8use ZipStream\Option\Archive as ArchiveOptions;
9use ZipStream\Option\File as FileOptions;
10use ZipStream\Option\Version;
11
12/**
13 * ZipStream
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 *     $zip = new ZipStream('example.zip');
24 *
25 * 2.  Add one or more files to the archive:
26 *
27 *      * add first file
28 *     $data = file_get_contents('some_file.gif');
29 *     $zip->addFile('some_file.gif', $data);
30 *
31 *      * add second file
32 *     $data = file_get_contents('some_file.gif');
33 *     $zip->addFile('another_file.png', $data);
34 *
35 * 3.  Finish the zip stream:
36 *
37 *     $zip->finish();
38 *
39 * You can also add an archive comment, add comments to individual files,
40 * and adjust the timestamp of files. See the API documentation for each
41 * method below for additional information.
42 *
43 * Example:
44 *
45 *   // create a new zip stream object
46 *   $zip = new ZipStream('some_files.zip');
47 *
48 *   // list of local files
49 *   $files = array('foo.txt', 'bar.jpg');
50 *
51 *   // read and add each file to the archive
52 *   foreach ($files as $path)
53 *     $zip->addFile($path, file_get_contents($path));
54 *
55 *   // write archive footer to stream
56 *   $zip->finish();
57 */
58class ZipStream
59{
60    /**
61     * This number corresponds to the ZIP version/OS used (2 bytes)
62     * From: https://www.iana.org/assignments/media-types/application/zip
63     * The upper byte (leftmost one) indicates the host system (OS) for the
64     * file.  Software can use this information to determine
65     * the line record format for text files etc.  The current
66     * mappings are:
67     *
68     * 0 - MS-DOS and OS/2 (F.A.T. file systems)
69     * 1 - Amiga                     2 - VAX/VMS
70     * 3 - *nix                      4 - VM/CMS
71     * 5 - Atari ST                  6 - OS/2 H.P.F.S.
72     * 7 - Macintosh                 8 - Z-System
73     * 9 - CP/M                      10 thru 255 - unused
74     *
75     * The lower byte (rightmost one) indicates the version number of the
76     * software used to encode the file.  The value/10
77     * indicates the major version number, and the value
78     * mod 10 is the minor version number.
79     * Here we are using 6 for the OS, indicating OS/2 H.P.F.S.
80     * to prevent file permissions issues upon extract (see #84)
81     * 0x603 is 00000110 00000011 in binary, so 6 and 3
82     */
83    const ZIP_VERSION_MADE_BY = 0x603;
84
85    /**
86     * The following signatures end with 0x4b50, which in ASCII is PK,
87     * the initials of the inventor Phil Katz.
88     * See https://en.wikipedia.org/wiki/Zip_(file_format)#File_headers
89     */
90    const FILE_HEADER_SIGNATURE = 0x04034b50;
91    const CDR_FILE_SIGNATURE = 0x02014b50;
92    const CDR_EOF_SIGNATURE = 0x06054b50;
93    const DATA_DESCRIPTOR_SIGNATURE = 0x08074b50;
94    const ZIP64_CDR_EOF_SIGNATURE = 0x06064b50;
95    const ZIP64_CDR_LOCATOR_SIGNATURE = 0x07064b50;
96
97    /**
98     * Global Options
99     *
100     * @var ArchiveOptions
101     */
102    public $opt;
103
104    /**
105     * @var array
106     */
107    public $files = [];
108
109    /**
110     * @var Bigint
111     */
112    public $cdr_ofs;
113
114    /**
115     * @var Bigint
116     */
117    public $ofs;
118
119    /**
120     * @var bool
121     */
122    protected $need_headers;
123
124    /**
125     * @var null|String
126     */
127    protected $output_name;
128
129    /**
130     * Create a new ZipStream object.
131     *
132     * Parameters:
133     *
134     * @param String $name - Name of output file (optional).
135     * @param ArchiveOptions $opt - Archive Options
136     *
137     * Large File Support:
138     *
139     * By default, the method addFileFromPath() will send send files
140     * larger than 20 megabytes along raw rather than attempting to
141     * compress them.  You can change both the maximum size and the
142     * compression behavior using the largeFile* options above, with the
143     * following caveats:
144     *
145     * * For "small" files (e.g. files smaller than largeFileSize), the
146     *   memory use can be up to twice that of the actual file.  In other
147     *   words, adding a 10 megabyte file to the archive could potentially
148     *   occupy 20 megabytes of memory.
149     *
150     * * Enabling compression on large files (e.g. files larger than
151     *   large_file_size) is extremely slow, because ZipStream has to pass
152     *   over the large file once to calculate header information, and then
153     *   again to compress and send the actual data.
154     *
155     * Examples:
156     *
157     *   // create a new zip file named 'foo.zip'
158     *   $zip = new ZipStream('foo.zip');
159     *
160     *   // create a new zip file named 'bar.zip' with a comment
161     *   $opt->setComment = 'this is a comment for the zip file.';
162     *   $zip = new ZipStream('bar.zip', $opt);
163     *
164     * Notes:
165     *
166     * In order to let this library send HTTP headers, a filename must be given
167     * _and_ the option `sendHttpHeaders` must be `true`. This behavior is to
168     * allow software to send its own headers (including the filename), and
169     * still use this library.
170     */
171    public function __construct(?string $name = null, ?ArchiveOptions $opt = null)
172    {
173        $this->opt = $opt ?: new ArchiveOptions();
174
175        $this->output_name = $name;
176        $this->need_headers = $name && $this->opt->isSendHttpHeaders();
177
178        $this->cdr_ofs = new Bigint();
179        $this->ofs = new Bigint();
180    }
181
182    /**
183     * addFile
184     *
185     * Add a file to the archive.
186     *
187     * @param String $name - path of file in archive (including directory).
188     * @param String $data - contents of file
189     * @param FileOptions $options
190     *
191     * File Options:
192     *  time     - Last-modified timestamp (seconds since the epoch) of
193     *             this file.  Defaults to the current time.
194     *  comment  - Comment related to this file.
195     *  method   - Storage method for file ("store" or "deflate")
196     *
197     * Examples:
198     *
199     *   // add a file named 'foo.txt'
200     *   $data = file_get_contents('foo.txt');
201     *   $zip->addFile('foo.txt', $data);
202     *
203     *   // add a file named 'bar.jpg' with a comment and a last-modified
204     *   // time of two hours ago
205     *   $data = file_get_contents('bar.jpg');
206     *   $opt->setTime = time() - 2 * 3600;
207     *   $opt->setComment = 'this is a comment about bar.jpg';
208     *   $zip->addFile('bar.jpg', $data, $opt);
209     */
210    public function addFile(string $name, string $data, ?FileOptions $options = null): void
211    {
212        $options = $options ?: new FileOptions();
213        $options->defaultTo($this->opt);
214
215        $file = new File($this, $name, $options);
216        $file->processData($data);
217    }
218
219    /**
220     * addFileFromPath
221     *
222     * Add a file at path to the archive.
223     *
224     * Note that large files may be compressed differently than smaller
225     * files; see the "Large File Support" section above for more
226     * information.
227     *
228     * @param String $name - name of file in archive (including directory path).
229     * @param String $path - path to file on disk (note: paths should be encoded using
230     *          UNIX-style forward slashes -- e.g '/path/to/some/file').
231     * @param FileOptions $options
232     *
233     * File Options:
234     *  time     - Last-modified timestamp (seconds since the epoch) of
235     *             this file.  Defaults to the current time.
236     *  comment  - Comment related to this file.
237     *  method   - Storage method for file ("store" or "deflate")
238     *
239     * Examples:
240     *
241     *   // add a file named 'foo.txt' from the local file '/tmp/foo.txt'
242     *   $zip->addFileFromPath('foo.txt', '/tmp/foo.txt');
243     *
244     *   // add a file named 'bigfile.rar' from the local file
245     *   // '/usr/share/bigfile.rar' with a comment and a last-modified
246     *   // time of two hours ago
247     *   $path = '/usr/share/bigfile.rar';
248     *   $opt->setTime = time() - 2 * 3600;
249     *   $opt->setComment = 'this is a comment about bar.jpg';
250     *   $zip->addFileFromPath('bigfile.rar', $path, $opt);
251     *
252     * @return void
253     * @throws \ZipStream\Exception\FileNotFoundException
254     * @throws \ZipStream\Exception\FileNotReadableException
255     */
256    public function addFileFromPath(string $name, string $path, ?FileOptions $options = null): void
257    {
258        $options = $options ?: new FileOptions();
259        $options->defaultTo($this->opt);
260
261        $file = new File($this, $name, $options);
262        $file->processPath($path);
263    }
264
265    /**
266     * addFileFromStream
267     *
268     * Add an open stream to the archive.
269     *
270     * @param String $name - path of file in archive (including directory).
271     * @param resource $stream - contents of file as a stream resource
272     * @param FileOptions $options
273     *
274     * File Options:
275     *  time     - Last-modified timestamp (seconds since the epoch) of
276     *             this file.  Defaults to the current time.
277     *  comment  - Comment related to this file.
278     *
279     * Examples:
280     *
281     *   // create a temporary file stream and write text to it
282     *   $fp = tmpfile();
283     *   fwrite($fp, 'The quick brown fox jumped over the lazy dog.');
284     *
285     *   // add a file named 'streamfile.txt' from the content of the stream
286     *   $x->addFileFromStream('streamfile.txt', $fp);
287     *
288     * @return void
289     */
290    public function addFileFromStream(string $name, $stream, ?FileOptions $options = null): void
291    {
292        $options = $options ?: new FileOptions();
293        $options->defaultTo($this->opt);
294
295        $file = new File($this, $name, $options);
296        $file->processStream(new DeflateStream($stream));
297    }
298
299    /**
300     * addFileFromPsr7Stream
301     *
302     * Add an open stream to the archive.
303     *
304     * @param String $name - path of file in archive (including directory).
305     * @param StreamInterface $stream - contents of file as a stream resource
306     * @param FileOptions $options
307     *
308     * File Options:
309     *  time     - Last-modified timestamp (seconds since the epoch) of
310     *             this file.  Defaults to the current time.
311     *  comment  - Comment related to this file.
312     *
313     * Examples:
314     *
315     *   // create a temporary file stream and write text to it
316     *   $fp = tmpfile();
317     *   fwrite($fp, 'The quick brown fox jumped over the lazy dog.');
318     *
319     *   // add a file named 'streamfile.txt' from the content of the stream
320     *   $x->addFileFromPsr7Stream('streamfile.txt', $fp);
321     *
322     * @return void
323     */
324    public function addFileFromPsr7Stream(
325        string $name,
326        StreamInterface $stream,
327        ?FileOptions $options = null
328    ): void {
329        $options = $options ?: new FileOptions();
330        $options->defaultTo($this->opt);
331
332        $file = new File($this, $name, $options);
333        $file->processStream($stream);
334    }
335
336    /**
337     * finish
338     *
339     * Write zip footer to stream.
340     *
341     *  Example:
342     *
343     *   // add a list of files to the archive
344     *   $files = array('foo.txt', 'bar.jpg');
345     *   foreach ($files as $path)
346     *     $zip->addFile($path, file_get_contents($path));
347     *
348     *   // write footer to stream
349     *   $zip->finish();
350     * @return void
351     *
352     * @throws OverflowException
353     */
354    public function finish(): void
355    {
356        // add trailing cdr file records
357        foreach ($this->files as $cdrFile) {
358            $this->send($cdrFile);
359            $this->cdr_ofs = $this->cdr_ofs->add(Bigint::init(strlen($cdrFile)));
360        }
361
362        // Add 64bit headers (if applicable)
363        if (count($this->files) >= 0xFFFF ||
364            $this->cdr_ofs->isOver32() ||
365            $this->ofs->isOver32()) {
366            if (!$this->opt->isEnableZip64()) {
367                throw new OverflowException();
368            }
369
370            $this->addCdr64Eof();
371            $this->addCdr64Locator();
372        }
373
374        // add trailing cdr eof record
375        $this->addCdrEof();
376
377        // The End
378        $this->clear();
379    }
380
381    /**
382     * Send ZIP64 CDR EOF (Central Directory Record End-of-File) record.
383     *
384     * @return void
385     */
386    protected function addCdr64Eof(): void
387    {
388        $num_files = count($this->files);
389        $cdr_length = $this->cdr_ofs;
390        $cdr_offset = $this->ofs;
391
392        $fields = [
393            ['V', static::ZIP64_CDR_EOF_SIGNATURE],     // ZIP64 end of central file header signature
394            ['P', 44],                                  // Length of data below this header (length of block - 12) = 44
395            ['v', static::ZIP_VERSION_MADE_BY],         // Made by version
396            ['v', Version::ZIP64],                      // Extract by version
397            ['V', 0x00],                                // disk number
398            ['V', 0x00],                                // no of disks
399            ['P', $num_files],                          // no of entries on disk
400            ['P', $num_files],                          // no of entries in cdr
401            ['P', $cdr_length],                         // CDR size
402            ['P', $cdr_offset],                         // CDR offset
403        ];
404
405        $ret = static::packFields($fields);
406        $this->send($ret);
407    }
408
409    /**
410     * Create a format string and argument list for pack(), then call
411     * pack() and return the result.
412     *
413     * @param array $fields
414     * @return string
415     */
416    public static function packFields(array $fields): string
417    {
418        $fmt = '';
419        $args = [];
420
421        // populate format string and argument list
422        foreach ($fields as [$format, $value]) {
423            if ($format === 'P') {
424                $fmt .= 'VV';
425                if ($value instanceof Bigint) {
426                    $args[] = $value->getLow32();
427                    $args[] = $value->getHigh32();
428                } else {
429                    $args[] = $value;
430                    $args[] = 0;
431                }
432            } else {
433                if ($value instanceof Bigint) {
434                    $value = $value->getLow32();
435                }
436                $fmt .= $format;
437                $args[] = $value;
438            }
439        }
440
441        // prepend format string to argument list
442        array_unshift($args, $fmt);
443
444        // build output string from header and compressed data
445        return pack(...$args);
446    }
447
448    /**
449     * Send string, sending HTTP headers if necessary.
450     * Flush output after write if configure option is set.
451     *
452     * @param String $str
453     * @return void
454     */
455    public function send(string $str): void
456    {
457        if ($this->need_headers) {
458            $this->sendHttpHeaders();
459        }
460        $this->need_headers = false;
461
462        fwrite($this->opt->getOutputStream(), $str);
463
464        if ($this->opt->isFlushOutput()) {
465            // flush output buffer if it is on and flushable
466            $status = ob_get_status();
467            if (isset($status['flags']) && ($status['flags'] & PHP_OUTPUT_HANDLER_FLUSHABLE)) {
468                ob_flush();
469            }
470
471            // Flush system buffers after flushing userspace output buffer
472            flush();
473        }
474    }
475
476    /**
477     * Send HTTP headers for this stream.
478     *
479     * @return void
480     */
481    protected function sendHttpHeaders(): void
482    {
483        // grab content disposition
484        $disposition = $this->opt->getContentDisposition();
485
486        if ($this->output_name) {
487            // Various different browsers dislike various characters here. Strip them all for safety.
488            $safe_output = trim(str_replace(['"', "'", '\\', ';', "\n", "\r"], '', $this->output_name));
489
490            // Check if we need to UTF-8 encode the filename
491            $urlencoded = rawurlencode($safe_output);
492            $disposition .= "; filename*=UTF-8''{$urlencoded}";
493        }
494
495        $headers = array(
496            'Content-Type' => $this->opt->getContentType(),
497            'Content-Disposition' => $disposition,
498            'Pragma' => 'public',
499            'Cache-Control' => 'public, must-revalidate',
500            'Content-Transfer-Encoding' => 'binary'
501        );
502
503        $call = $this->opt->getHttpHeaderCallback();
504        foreach ($headers as $key => $val) {
505            $call("$key: $val");
506        }
507    }
508
509    /**
510     * Send ZIP64 CDR Locator (Central Directory Record Locator) record.
511     *
512     * @return void
513     */
514    protected function addCdr64Locator(): void
515    {
516        $cdr_offset = $this->ofs->add($this->cdr_ofs);
517
518        $fields = [
519            ['V', static::ZIP64_CDR_LOCATOR_SIGNATURE], // ZIP64 end of central file header signature
520            ['V', 0x00],                                // Disc number containing CDR64EOF
521            ['P', $cdr_offset],                         // CDR offset
522            ['V', 1],                                   // Total number of disks
523        ];
524
525        $ret = static::packFields($fields);
526        $this->send($ret);
527    }
528
529    /**
530     * Send CDR EOF (Central Directory Record End-of-File) record.
531     *
532     * @return void
533     */
534    protected function addCdrEof(): void
535    {
536        $num_files = count($this->files);
537        $cdr_length = $this->cdr_ofs;
538        $cdr_offset = $this->ofs;
539
540        // grab comment (if specified)
541        $comment = $this->opt->getComment();
542
543        $fields = [
544            ['V', static::CDR_EOF_SIGNATURE],   // end of central file header signature
545            ['v', 0x00],                        // disk number
546            ['v', 0x00],                        // no of disks
547            ['v', min($num_files, 0xFFFF)],     // no of entries on disk
548            ['v', min($num_files, 0xFFFF)],     // no of entries in cdr
549            ['V', $cdr_length->getLowFF()],     // CDR size
550            ['V', $cdr_offset->getLowFF()],     // CDR offset
551            ['v', strlen($comment)],            // Zip Comment size
552        ];
553
554        $ret = static::packFields($fields) . $comment;
555        $this->send($ret);
556    }
557
558    /**
559     * Clear all internal variables. Note that the stream object is not
560     * usable after this.
561     *
562     * @return void
563     */
564    protected function clear(): void
565    {
566        $this->files = [];
567        $this->ofs = new Bigint();
568        $this->cdr_ofs = new Bigint();
569        $this->opt = new ArchiveOptions();
570    }
571
572    /**
573     * Is this file larger than large_file_size?
574     *
575     * @param string $path
576     * @return bool
577     */
578    public function isLargeFile(string $path): bool
579    {
580        if (!$this->opt->isStatFiles()) {
581            return false;
582        }
583        $stat = stat($path);
584        return $stat['size'] > $this->opt->getLargeFileSize();
585    }
586
587    /**
588     * Save file attributes for trailing CDR record.
589     *
590     * @param File $file
591     * @return void
592     */
593    public function addToCdr(File $file): void
594    {
595        $file->ofs = $this->ofs;
596        $this->ofs = $this->ofs->add($file->getTotalLength());
597        $this->files[] = $file->getCdrFile();
598    }
599}
600