Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.
   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * Implementation of zip packer.
  19   *
  20   * @package   core_files
  21   * @copyright 2008 Petr Skoda (http://skodak.org)
  22   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  defined('MOODLE_INTERNAL') || die();
  26  
  27  require_once("$CFG->libdir/filestorage/file_packer.php");
  28  require_once("$CFG->libdir/filestorage/zip_archive.php");
  29  
  30  /**
  31   * Utility class - handles all zipping and unzipping operations.
  32   *
  33   * @package   core_files
  34   * @category  files
  35   * @copyright 2008 Petr Skoda (http://skodak.org)
  36   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  37   */
  38  class zip_packer extends file_packer {
  39  
  40      /**
  41       * Zip files and store the result in file storage.
  42       *
  43       * @param array $files array with full zip paths (including directory information)
  44       *              as keys (archivepath=>ospathname or archivepath/subdir=>stored_file or archivepath=>array('content_as_string'))
  45       * @param int $contextid context ID
  46       * @param string $component component
  47       * @param string $filearea file area
  48       * @param int $itemid item ID
  49       * @param string $filepath file path
  50       * @param string $filename file name
  51       * @param int $userid user ID
  52       * @param bool $ignoreinvalidfiles true means ignore missing or invalid files, false means abort on any error
  53       * @param file_progress $progress Progress indicator callback or null if not required
  54       * @return stored_file|bool false if error stored_file instance if ok
  55       */
  56      public function archive_to_storage(array $files, $contextid,
  57              $component, $filearea, $itemid, $filepath, $filename,
  58              $userid = NULL, $ignoreinvalidfiles=true, file_progress $progress = null) {
  59          global $CFG;
  60  
  61          $fs = get_file_storage();
  62  
  63          check_dir_exists($CFG->tempdir.'/zip');
  64          $tmpfile = tempnam($CFG->tempdir.'/zip', 'zipstor');
  65  
  66          if ($result = $this->archive_to_pathname($files, $tmpfile, $ignoreinvalidfiles, $progress)) {
  67              if ($file = $fs->get_file($contextid, $component, $filearea, $itemid, $filepath, $filename)) {
  68                  if (!$file->delete()) {
  69                      @unlink($tmpfile);
  70                      return false;
  71                  }
  72              }
  73              $file_record = new stdClass();
  74              $file_record->contextid = $contextid;
  75              $file_record->component = $component;
  76              $file_record->filearea  = $filearea;
  77              $file_record->itemid    = $itemid;
  78              $file_record->filepath  = $filepath;
  79              $file_record->filename  = $filename;
  80              $file_record->userid    = $userid;
  81              $file_record->mimetype  = 'application/zip';
  82  
  83              $result = $fs->create_file_from_pathname($file_record, $tmpfile);
  84          }
  85          @unlink($tmpfile);
  86          return $result;
  87      }
  88  
  89      /**
  90       * Zip files and store the result in os file.
  91       *
  92       * @param array $files array with zip paths as keys (archivepath=>ospathname or archivepath=>stored_file or archivepath=>array('content_as_string'))
  93       * @param string $archivefile path to target zip file
  94       * @param bool $ignoreinvalidfiles true means ignore missing or invalid files, false means abort on any error
  95       * @param file_progress $progress Progress indicator callback or null if not required
  96       * @return bool true if file created, false if not
  97       */
  98      public function archive_to_pathname(array $files, $archivefile,
  99              $ignoreinvalidfiles=true, file_progress $progress = null) {
 100          $ziparch = new zip_archive();
 101          if (!$ziparch->open($archivefile, file_archive::OVERWRITE)) {
 102              return false;
 103          }
 104  
 105          $abort = false;
 106          foreach ($files as $archivepath => $file) {
 107              $archivepath = trim($archivepath, '/');
 108  
 109              // Record progress each time around this loop.
 110              if ($progress) {
 111                  $progress->progress();
 112              }
 113  
 114              if (is_null($file)) {
 115                  // Directories have null as content.
 116                  if (!$ziparch->add_directory($archivepath.'/')) {
 117                      debugging("Can not zip '$archivepath' directory", DEBUG_DEVELOPER);
 118                      if (!$ignoreinvalidfiles) {
 119                          $abort = true;
 120                          break;
 121                      }
 122                  }
 123  
 124              } else if (is_string($file)) {
 125                  if (!$this->archive_pathname($ziparch, $archivepath, $file, $progress)) {
 126                      debugging("Can not zip '$archivepath' file", DEBUG_DEVELOPER);
 127                      if (!$ignoreinvalidfiles) {
 128                          $abort = true;
 129                          break;
 130                      }
 131                  }
 132  
 133              } else if (is_array($file)) {
 134                  $content = reset($file);
 135                  if (!$ziparch->add_file_from_string($archivepath, $content)) {
 136                      debugging("Can not zip '$archivepath' file", DEBUG_DEVELOPER);
 137                      if (!$ignoreinvalidfiles) {
 138                          $abort = true;
 139                          break;
 140                      }
 141                  }
 142  
 143              } else {
 144                  if (!$this->archive_stored($ziparch, $archivepath, $file, $progress)) {
 145                      debugging("Can not zip '$archivepath' file", DEBUG_DEVELOPER);
 146                      if (!$ignoreinvalidfiles) {
 147                          $abort = true;
 148                          break;
 149                      }
 150                  }
 151              }
 152          }
 153  
 154          if (!$ziparch->close()) {
 155              @unlink($archivefile);
 156              return false;
 157          }
 158  
 159          if ($abort) {
 160              @unlink($archivefile);
 161              return false;
 162          }
 163  
 164          return true;
 165      }
 166  
 167      /**
 168       * Perform archiving file from stored file.
 169       *
 170       * @param zip_archive $ziparch zip archive instance
 171       * @param string $archivepath file path to archive
 172       * @param stored_file $file stored_file object
 173       * @param file_progress $progress Progress indicator callback or null if not required
 174       * @return bool success
 175       */
 176      private function archive_stored($ziparch, $archivepath, $file, file_progress $progress = null) {
 177          $result = $file->archive_file($ziparch, $archivepath);
 178          if (!$result) {
 179              return false;
 180          }
 181  
 182          if (!$file->is_directory()) {
 183              return true;
 184          }
 185  
 186          $baselength = strlen($file->get_filepath());
 187          $fs = get_file_storage();
 188          $files = $fs->get_directory_files($file->get_contextid(), $file->get_component(), $file->get_filearea(), $file->get_itemid(),
 189                                            $file->get_filepath(), true, true);
 190          foreach ($files as $file) {
 191              // Record progress for each file.
 192              if ($progress) {
 193                  $progress->progress();
 194              }
 195  
 196              $path = $file->get_filepath();
 197              $path = substr($path, $baselength);
 198              $path = $archivepath.'/'.$path;
 199              if (!$file->is_directory()) {
 200                  $path = $path.$file->get_filename();
 201              }
 202              // Ignore result here, partial zipping is ok for now.
 203              $file->archive_file($ziparch, $path);
 204          }
 205  
 206          return true;
 207      }
 208  
 209      /**
 210       * Perform archiving file from file path.
 211       *
 212       * @param zip_archive $ziparch zip archive instance
 213       * @param string $archivepath file path to archive
 214       * @param string $file path name of the file
 215       * @param file_progress $progress Progress indicator callback or null if not required
 216       * @return bool success
 217       */
 218      private function archive_pathname($ziparch, $archivepath, $file,
 219              file_progress $progress = null) {
 220          // Record progress each time this function is called.
 221          if ($progress) {
 222              $progress->progress();
 223          }
 224  
 225          if (!file_exists($file)) {
 226              return false;
 227          }
 228  
 229          if (is_file($file)) {
 230              if (!is_readable($file)) {
 231                  return false;
 232              }
 233              return $ziparch->add_file_from_pathname($archivepath, $file);
 234          }
 235          if (is_dir($file)) {
 236              if ($archivepath !== '') {
 237                  $ziparch->add_directory($archivepath);
 238              }
 239              $files = new DirectoryIterator($file);
 240              foreach ($files as $file) {
 241                  if ($file->isDot()) {
 242                      continue;
 243                  }
 244                  $newpath = $archivepath.'/'.$file->getFilename();
 245                  $this->archive_pathname($ziparch, $newpath, $file->getPathname(), $progress);
 246              }
 247              unset($files); // Release file handles.
 248              return true;
 249          }
 250      }
 251  
 252      /**
 253       * Unzip file to given file path (real OS filesystem), existing files are overwritten.
 254       *
 255       * @todo MDL-31048 localise messages
 256       * @param string|stored_file $archivefile full pathname of zip file or stored_file instance
 257       * @param string $pathname target directory
 258       * @param array $onlyfiles only extract files present in the array. The path to files MUST NOT
 259       *              start with a /. Example: array('myfile.txt', 'directory/anotherfile.txt')
 260       * @param file_progress $progress Progress indicator callback or null if not required
 261       * @param bool $returnbool Whether to return a basic true/false indicating error state, or full per-file error
 262       * details.
 263       * @return bool|array list of processed files; false if error
 264       */
 265      public function extract_to_pathname($archivefile, $pathname,
 266              array $onlyfiles = null, file_progress $progress = null, $returnbool = false) {
 267          global $CFG;
 268  
 269          if (!is_string($archivefile)) {
 270              return $archivefile->extract_to_pathname($this, $pathname, $progress);
 271          }
 272  
 273          $processed = array();
 274          $success = true;
 275  
 276          $pathname = rtrim($pathname, '/');
 277          if (!is_readable($archivefile)) {
 278              return false;
 279          }
 280          $ziparch = new zip_archive();
 281          if (!$ziparch->open($archivefile, file_archive::OPEN)) {
 282              return false;
 283          }
 284  
 285          // Get the number of files (approx).
 286          if ($progress) {
 287              $approxmax = $ziparch->estimated_count();
 288              $done = 0;
 289          }
 290  
 291          foreach ($ziparch as $info) {
 292              // Notify progress.
 293              if ($progress) {
 294                  $progress->progress($done, $approxmax);
 295                  $done++;
 296              }
 297  
 298              $size = $info->size;
 299              $name = $info->pathname;
 300              $origname = $name;
 301  
 302              // File names cannot end with dots on Windows and trailing dots are replaced with underscore.
 303              if ($CFG->ostype === 'WINDOWS') {
 304                  $name = preg_replace('~([^/]+)\.(/|$)~', '\1_\2', $name);
 305              }
 306  
 307              if ($name === '' or array_key_exists($name, $processed)) {
 308                  // Probably filename collisions caused by filename cleaning/conversion.
 309                  continue;
 310              } else if (is_array($onlyfiles) && !in_array($origname, $onlyfiles)) {
 311                  // Skipping files which are not in the list.
 312                  continue;
 313              }
 314  
 315              if ($info->is_directory) {
 316                  $newdir = "$pathname/$name";
 317                  // directory
 318                  if (is_file($newdir) and !unlink($newdir)) {
 319                      $processed[$name] = 'Can not create directory, file already exists'; // TODO: localise
 320                      $success = false;
 321                      continue;
 322                  }
 323                  if (is_dir($newdir)) {
 324                      //dir already there
 325                      $processed[$name] = true;
 326                  } else {
 327                      if (mkdir($newdir, $CFG->directorypermissions, true)) {
 328                          $processed[$name] = true;
 329                      } else {
 330                          $processed[$name] = 'Can not create directory'; // TODO: localise
 331                          $success = false;
 332                      }
 333                  }
 334                  continue;
 335              }
 336  
 337              $parts = explode('/', trim($name, '/'));
 338              $filename = array_pop($parts);
 339              $newdir = rtrim($pathname.'/'.implode('/', $parts), '/');
 340  
 341              if (!is_dir($newdir)) {
 342                  if (!mkdir($newdir, $CFG->directorypermissions, true)) {
 343                      $processed[$name] = 'Can not create directory'; // TODO: localise
 344                      $success = false;
 345                      continue;
 346                  }
 347              }
 348  
 349              $newfile = "$newdir/$filename";
 350  
 351              if (strpos($newfile, './') > 1 || $name !== $origname) {
 352                  // The path to the entry contains a directory ending with dot. We cannot use extract_to() due to
 353                  // upstream PHP bugs #69477, #74619 and #77214. Extract the file from its stream which is slower but
 354                  // should work even in this case.
 355                  if (!$fp = fopen($newfile, 'wb')) {
 356                      $processed[$name] = 'Can not write target file'; // TODO: localise.
 357                      $success = false;
 358                      continue;
 359                  }
 360  
 361                  if (!$fz = $ziparch->get_stream($info->index)) {
 362                      $processed[$name] = 'Can not read file from zip archive'; // TODO: localise.
 363                      $success = false;
 364                      fclose($fp);
 365                      continue;
 366                  }
 367  
 368                  while (!feof($fz)) {
 369                      $content = fread($fz, 262143);
 370                      fwrite($fp, $content);
 371                  }
 372  
 373                  fclose($fz);
 374                  fclose($fp);
 375  
 376              } else {
 377                  if (!$fz = $ziparch->extract_to($pathname, $info->index)) {
 378                      $processed[$name] = 'Can not read file from zip archive'; // TODO: localise.
 379                      $success = false;
 380                      continue;
 381                  }
 382              }
 383  
 384              // Check that the file was correctly created in the destination.
 385              if (!file_exists($newfile)) {
 386                  $processed[$name] = 'Unknown error during zip extraction (file not created).'; // TODO: localise.
 387                  $success = false;
 388                  continue;
 389              }
 390  
 391              // Check that the size of extracted file matches the expectation.
 392              if (filesize($newfile) !== $size) {
 393                  $processed[$name] = 'Unknown error during zip extraction (file size mismatch).'; // TODO: localise.
 394                  $success = false;
 395                  @unlink($newfile);
 396                  continue;
 397              }
 398  
 399              $processed[$name] = true;
 400          }
 401  
 402          $ziparch->close();
 403  
 404          if ($returnbool) {
 405              return $success;
 406          } else {
 407              return $processed;
 408          }
 409      }
 410  
 411      /**
 412       * Unzip file to given file path (real OS filesystem), existing files are overwritten.
 413       *
 414       * @todo MDL-31048 localise messages
 415       * @param string|stored_file $archivefile full pathname of zip file or stored_file instance
 416       * @param int $contextid context ID
 417       * @param string $component component
 418       * @param string $filearea file area
 419       * @param int $itemid item ID
 420       * @param string $pathbase file path
 421       * @param int $userid user ID
 422       * @param file_progress $progress Progress indicator callback or null if not required
 423       * @return array|bool list of processed files; false if error
 424       */
 425      public function extract_to_storage($archivefile, $contextid,
 426              $component, $filearea, $itemid, $pathbase, $userid = NULL,
 427              file_progress $progress = null) {
 428          global $CFG;
 429  
 430          if (!is_string($archivefile)) {
 431              return $archivefile->extract_to_storage($this, $contextid, $component,
 432                      $filearea, $itemid, $pathbase, $userid, $progress);
 433          }
 434  
 435          check_dir_exists($CFG->tempdir.'/zip');
 436  
 437          $pathbase = trim($pathbase, '/');
 438          $pathbase = ($pathbase === '') ? '/' : '/'.$pathbase.'/';
 439          $fs = get_file_storage();
 440  
 441          $processed = array();
 442  
 443          $ziparch = new zip_archive();
 444          if (!$ziparch->open($archivefile, file_archive::OPEN)) {
 445              return false;
 446          }
 447  
 448          // Get the number of files (approx).
 449          if ($progress) {
 450              $approxmax = $ziparch->estimated_count();
 451              $done = 0;
 452          }
 453  
 454          foreach ($ziparch as $info) {
 455              // Notify progress.
 456              if ($progress) {
 457                  $progress->progress($done, $approxmax);
 458                  $done++;
 459              }
 460  
 461              $size = $info->size;
 462              $name = $info->pathname;
 463  
 464              if ($name === '' or array_key_exists($name, $processed)) {
 465                  //probably filename collisions caused by filename cleaning/conversion
 466                  continue;
 467              }
 468  
 469              if ($info->is_directory) {
 470                  $newfilepath = $pathbase.$name.'/';
 471                  $fs->create_directory($contextid, $component, $filearea, $itemid, $newfilepath, $userid);
 472                  $processed[$name] = true;
 473                  continue;
 474              }
 475  
 476              $parts = explode('/', trim($name, '/'));
 477              $filename = array_pop($parts);
 478              $filepath = $pathbase;
 479              if ($parts) {
 480                  $filepath .= implode('/', $parts).'/';
 481              }
 482  
 483              if ($size < 2097151) {
 484                  // Small file.
 485                  if (!$fz = $ziparch->get_stream($info->index)) {
 486                      $processed[$name] = 'Can not read file from zip archive'; // TODO: localise
 487                      continue;
 488                  }
 489                  $content = '';
 490                  while (!feof($fz)) {
 491                      $content .= fread($fz, 262143);
 492                  }
 493                  fclose($fz);
 494                  if (strlen($content) !== $size) {
 495                      $processed[$name] = 'Unknown error during zip extraction'; // TODO: localise
 496                      // something went wrong :-(
 497                      unset($content);
 498                      continue;
 499                  }
 500  
 501                  if ($file = $fs->get_file($contextid, $component, $filearea, $itemid, $filepath, $filename)) {
 502                      if (!$file->delete()) {
 503                          $processed[$name] = 'Can not delete existing file'; // TODO: localise
 504                          continue;
 505                      }
 506                  }
 507                  $file_record = new stdClass();
 508                  $file_record->contextid = $contextid;
 509                  $file_record->component = $component;
 510                  $file_record->filearea  = $filearea;
 511                  $file_record->itemid    = $itemid;
 512                  $file_record->filepath  = $filepath;
 513                  $file_record->filename  = $filename;
 514                  $file_record->userid    = $userid;
 515                  if ($fs->create_file_from_string($file_record, $content)) {
 516                      $processed[$name] = true;
 517                  } else {
 518                      $processed[$name] = 'Unknown error during zip extraction'; // TODO: localise
 519                  }
 520                  unset($content);
 521                  continue;
 522  
 523              } else {
 524                  // large file, would not fit into memory :-(
 525                  $tmpfile = tempnam($CFG->tempdir.'/zip', 'unzip');
 526                  if (!$fp = fopen($tmpfile, 'wb')) {
 527                      @unlink($tmpfile);
 528                      $processed[$name] = 'Can not write temp file'; // TODO: localise
 529                      continue;
 530                  }
 531                  if (!$fz = $ziparch->get_stream($info->index)) {
 532                      @unlink($tmpfile);
 533                      $processed[$name] = 'Can not read file from zip archive'; // TODO: localise
 534                      continue;
 535                  }
 536                  while (!feof($fz)) {
 537                      $content = fread($fz, 262143);
 538                      fwrite($fp, $content);
 539                  }
 540                  fclose($fz);
 541                  fclose($fp);
 542                  if (filesize($tmpfile) !== $size) {
 543                      $processed[$name] = 'Unknown error during zip extraction'; // TODO: localise
 544                      // something went wrong :-(
 545                      @unlink($tmpfile);
 546                      continue;
 547                  }
 548  
 549                  if ($file = $fs->get_file($contextid, $component, $filearea, $itemid, $filepath, $filename)) {
 550                      if (!$file->delete()) {
 551                          @unlink($tmpfile);
 552                          $processed[$name] = 'Can not delete existing file'; // TODO: localise
 553                          continue;
 554                      }
 555                  }
 556                  $file_record = new stdClass();
 557                  $file_record->contextid = $contextid;
 558                  $file_record->component = $component;
 559                  $file_record->filearea  = $filearea;
 560                  $file_record->itemid    = $itemid;
 561                  $file_record->filepath  = $filepath;
 562                  $file_record->filename  = $filename;
 563                  $file_record->userid    = $userid;
 564                  if ($fs->create_file_from_pathname($file_record, $tmpfile)) {
 565                      $processed[$name] = true;
 566                  } else {
 567                      $processed[$name] = 'Unknown error during zip extraction'; // TODO: localise
 568                  }
 569                  @unlink($tmpfile);
 570                  continue;
 571              }
 572          }
 573          $ziparch->close();
 574          return $processed;
 575      }
 576  
 577      /**
 578       * Returns array of info about all files in archive.
 579       *
 580       * @param string|file_archive $archivefile
 581       * @return array of file infos
 582       */
 583      public function list_files($archivefile) {
 584          if (!is_string($archivefile)) {
 585              return $archivefile->list_files();
 586          }
 587  
 588          $ziparch = new zip_archive();
 589          if (!$ziparch->open($archivefile, file_archive::OPEN)) {
 590              return false;
 591          }
 592          $list = $ziparch->list_files();
 593          $ziparch->close();
 594          return $list;
 595      }
 596  
 597  }