Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 400 and 401] [Versions 401 and 402] [Versions 401 and 403]

   1  <?php
   2  declare(strict_types=1);
   3  
   4  namespace ZipStream;
   5  
   6  use Psr\Http\Message\StreamInterface;
   7  use ZipStream\Exception\OverflowException;
   8  use ZipStream\Option\Archive as ArchiveOptions;
   9  use ZipStream\Option\File as FileOptions;
  10  use 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   */
  58  class 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       *   $stream = $response->getBody();
 316       *   // add a file named 'streamfile.txt' from the content of the stream
 317       *   $x->addFileFromPsr7Stream('streamfile.txt', $stream);
 318       *
 319       * @return void
 320       */
 321      public function addFileFromPsr7Stream(
 322          string $name,
 323          StreamInterface $stream,
 324          ?FileOptions $options = null
 325      ): void {
 326          $options = $options ?: new FileOptions();
 327          $options->defaultTo($this->opt);
 328  
 329          $file = new File($this, $name, $options);
 330          $file->processStream($stream);
 331      }
 332  
 333      /**
 334       * finish
 335       *
 336       * Write zip footer to stream.
 337       *
 338       *  Example:
 339       *
 340       *   // add a list of files to the archive
 341       *   $files = array('foo.txt', 'bar.jpg');
 342       *   foreach ($files as $path)
 343       *     $zip->addFile($path, file_get_contents($path));
 344       *
 345       *   // write footer to stream
 346       *   $zip->finish();
 347       * @return void
 348       *
 349       * @throws OverflowException
 350       */
 351      public function finish(): void
 352      {
 353          // add trailing cdr file records
 354          foreach ($this->files as $cdrFile) {
 355              $this->send($cdrFile);
 356              $this->cdr_ofs = $this->cdr_ofs->add(Bigint::init(strlen($cdrFile)));
 357          }
 358  
 359          // Add 64bit headers (if applicable)
 360          if (count($this->files) >= 0xFFFF ||
 361              $this->cdr_ofs->isOver32() ||
 362              $this->ofs->isOver32()) {
 363              if (!$this->opt->isEnableZip64()) {
 364                  throw new OverflowException();
 365              }
 366  
 367              $this->addCdr64Eof();
 368              $this->addCdr64Locator();
 369          }
 370  
 371          // add trailing cdr eof record
 372          $this->addCdrEof();
 373  
 374          // The End
 375          $this->clear();
 376      }
 377  
 378      /**
 379       * Send ZIP64 CDR EOF (Central Directory Record End-of-File) record.
 380       *
 381       * @return void
 382       */
 383      protected function addCdr64Eof(): void
 384      {
 385          $num_files = count($this->files);
 386          $cdr_length = $this->cdr_ofs;
 387          $cdr_offset = $this->ofs;
 388  
 389          $fields = [
 390              ['V', static::ZIP64_CDR_EOF_SIGNATURE],     // ZIP64 end of central file header signature
 391              ['P', 44],                                  // Length of data below this header (length of block - 12) = 44
 392              ['v', static::ZIP_VERSION_MADE_BY],         // Made by version
 393              ['v', Version::ZIP64],                      // Extract by version
 394              ['V', 0x00],                                // disk number
 395              ['V', 0x00],                                // no of disks
 396              ['P', $num_files],                          // no of entries on disk
 397              ['P', $num_files],                          // no of entries in cdr
 398              ['P', $cdr_length],                         // CDR size
 399              ['P', $cdr_offset],                         // CDR offset
 400          ];
 401  
 402          $ret = static::packFields($fields);
 403          $this->send($ret);
 404      }
 405  
 406      /**
 407       * Create a format string and argument list for pack(), then call
 408       * pack() and return the result.
 409       *
 410       * @param array $fields
 411       * @return string
 412       */
 413      public static function packFields(array $fields): string
 414      {
 415          $fmt = '';
 416          $args = [];
 417  
 418          // populate format string and argument list
 419          foreach ($fields as [$format, $value]) {
 420              if ($format === 'P') {
 421                  $fmt .= 'VV';
 422                  if ($value instanceof Bigint) {
 423                      $args[] = $value->getLow32();
 424                      $args[] = $value->getHigh32();
 425                  } else {
 426                      $args[] = $value;
 427                      $args[] = 0;
 428                  }
 429              } else {
 430                  if ($value instanceof Bigint) {
 431                      $value = $value->getLow32();
 432                  }
 433                  $fmt .= $format;
 434                  $args[] = $value;
 435              }
 436          }
 437  
 438          // prepend format string to argument list
 439          array_unshift($args, $fmt);
 440  
 441          // build output string from header and compressed data
 442          return pack(...$args);
 443      }
 444  
 445      /**
 446       * Send string, sending HTTP headers if necessary.
 447       * Flush output after write if configure option is set.
 448       *
 449       * @param String $str
 450       * @return void
 451       */
 452      public function send(string $str): void
 453      {
 454          if ($this->need_headers) {
 455              $this->sendHttpHeaders();
 456          }
 457          $this->need_headers = false;
 458  
 459          $outputStream = $this->opt->getOutputStream();
 460  
 461          if ($outputStream instanceof StreamInterface) {
 462              $outputStream->write($str);
 463          } else {
 464              fwrite($outputStream, $str);
 465          }
 466  
 467          if ($this->opt->isFlushOutput()) {
 468              // flush output buffer if it is on and flushable
 469              $status = ob_get_status();
 470              if (isset($status['flags']) && ($status['flags'] & PHP_OUTPUT_HANDLER_FLUSHABLE)) {
 471                  ob_flush();
 472              }
 473  
 474              // Flush system buffers after flushing userspace output buffer
 475              flush();
 476          }
 477      }
 478  
 479      /**
 480       * Send HTTP headers for this stream.
 481       *
 482       * @return void
 483       */
 484      protected function sendHttpHeaders(): void
 485      {
 486          // grab content disposition
 487          $disposition = $this->opt->getContentDisposition();
 488  
 489          if ($this->output_name) {
 490              // Various different browsers dislike various characters here. Strip them all for safety.
 491              $safe_output = trim(str_replace(['"', "'", '\\', ';', "\n", "\r"], '', $this->output_name));
 492  
 493              // Check if we need to UTF-8 encode the filename
 494              $urlencoded = rawurlencode($safe_output);
 495              $disposition .= "; filename*=UTF-8''{$urlencoded}";
 496          }
 497  
 498          $headers = array(
 499              'Content-Type' => $this->opt->getContentType(),
 500              'Content-Disposition' => $disposition,
 501              'Pragma' => 'public',
 502              'Cache-Control' => 'public, must-revalidate',
 503              'Content-Transfer-Encoding' => 'binary'
 504          );
 505  
 506          $call = $this->opt->getHttpHeaderCallback();
 507          foreach ($headers as $key => $val) {
 508              $call("$key: $val");
 509          }
 510      }
 511  
 512      /**
 513       * Send ZIP64 CDR Locator (Central Directory Record Locator) record.
 514       *
 515       * @return void
 516       */
 517      protected function addCdr64Locator(): void
 518      {
 519          $cdr_offset = $this->ofs->add($this->cdr_ofs);
 520  
 521          $fields = [
 522              ['V', static::ZIP64_CDR_LOCATOR_SIGNATURE], // ZIP64 end of central file header signature
 523              ['V', 0x00],                                // Disc number containing CDR64EOF
 524              ['P', $cdr_offset],                         // CDR offset
 525              ['V', 1],                                   // Total number of disks
 526          ];
 527  
 528          $ret = static::packFields($fields);
 529          $this->send($ret);
 530      }
 531  
 532      /**
 533       * Send CDR EOF (Central Directory Record End-of-File) record.
 534       *
 535       * @return void
 536       */
 537      protected function addCdrEof(): void
 538      {
 539          $num_files = count($this->files);
 540          $cdr_length = $this->cdr_ofs;
 541          $cdr_offset = $this->ofs;
 542  
 543          // grab comment (if specified)
 544          $comment = $this->opt->getComment();
 545  
 546          $fields = [
 547              ['V', static::CDR_EOF_SIGNATURE],   // end of central file header signature
 548              ['v', 0x00],                        // disk number
 549              ['v', 0x00],                        // no of disks
 550              ['v', min($num_files, 0xFFFF)],     // no of entries on disk
 551              ['v', min($num_files, 0xFFFF)],     // no of entries in cdr
 552              ['V', $cdr_length->getLowFF()],     // CDR size
 553              ['V', $cdr_offset->getLowFF()],     // CDR offset
 554              ['v', strlen($comment)],            // Zip Comment size
 555          ];
 556  
 557          $ret = static::packFields($fields) . $comment;
 558          $this->send($ret);
 559      }
 560  
 561      /**
 562       * Clear all internal variables. Note that the stream object is not
 563       * usable after this.
 564       *
 565       * @return void
 566       */
 567      protected function clear(): void
 568      {
 569          $this->files = [];
 570          $this->ofs = new Bigint();
 571          $this->cdr_ofs = new Bigint();
 572          $this->opt = new ArchiveOptions();
 573      }
 574  
 575      /**
 576       * Is this file larger than large_file_size?
 577       *
 578       * @param string $path
 579       * @return bool
 580       */
 581      public function isLargeFile(string $path): bool
 582      {
 583          if (!$this->opt->isStatFiles()) {
 584              return false;
 585          }
 586          $stat = stat($path);
 587          return $stat['size'] > $this->opt->getLargeFileSize();
 588      }
 589  
 590      /**
 591       * Save file attributes for trailing CDR record.
 592       *
 593       * @param File $file
 594       * @return void
 595       */
 596      public function addToCdr(File $file): void
 597      {
 598          $file->ofs = $this->ofs;
 599          $this->ofs = $this->ofs->add($file->getTotalLength());
 600          $this->files[] = $file->getCdrFile();
 601      }
 602  }