Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

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

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