Merge branch 'MDL-81713-main' of https://github.com/junpataleta/moodle
[moodle.git] / lib / filestorage / zip_packer.php
blob39cedfda4ae2e839b9bad0e47385d3f4e98005f2
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.
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/>.
17 /**
18 * Implementation of zip packer.
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
25 defined('MOODLE_INTERNAL') || die();
27 require_once("$CFG->libdir/filestorage/file_packer.php");
28 require_once("$CFG->libdir/filestorage/zip_archive.php");
30 /**
31 * Utility class - handles all zipping and unzipping operations.
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
38 class zip_packer extends file_packer {
40 /**
41 * Zip files and store the result in file storage.
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
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;
61 $fs = get_file_storage();
63 check_dir_exists($CFG->tempdir.'/zip');
64 $tmpfile = tempnam($CFG->tempdir.'/zip', 'zipstor');
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;
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';
83 $result = $fs->create_file_from_pathname($file_record, $tmpfile);
85 @unlink($tmpfile);
86 return $result;
89 /**
90 * Zip files and store the result in os file.
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
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;
105 $abort = false;
106 foreach ($files as $archivepath => $file) {
107 $archivepath = trim($archivepath, '/');
109 // Record progress each time around this loop.
110 if ($progress) {
111 $progress->progress();
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;
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;
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;
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;
154 if (!$ziparch->close()) {
155 @unlink($archivefile);
156 return false;
159 if ($abort) {
160 @unlink($archivefile);
161 return false;
164 return true;
168 * Perform archiving file from stored file.
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
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;
182 if (!$file->is_directory()) {
183 return true;
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();
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();
202 // Ignore result here, partial zipping is ok for now.
203 $file->archive_file($ziparch, $path);
206 return true;
210 * Perform archiving file from file path.
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
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();
225 if (!file_exists($file)) {
226 return false;
229 if (is_file($file)) {
230 if (!is_readable($file)) {
231 return false;
233 return $ziparch->add_file_from_pathname($archivepath, $file);
235 if (is_dir($file)) {
236 if ($archivepath !== '') {
237 $ziparch->add_directory($archivepath);
239 $files = new DirectoryIterator($file);
240 foreach ($files as $file) {
241 if ($file->isDot()) {
242 continue;
244 $newpath = $archivepath.'/'.$file->getFilename();
245 $this->archive_pathname($ziparch, $newpath, $file->getPathname(), $progress);
247 unset($files); // Release file handles.
248 return true;
253 * Unzip file to given file path (real OS filesystem), existing files are overwritten.
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
265 public function extract_to_pathname($archivefile, $pathname,
266 array $onlyfiles = null, file_progress $progress = null, $returnbool = false) {
267 global $CFG;
269 if (!is_string($archivefile)) {
270 return $archivefile->extract_to_pathname($this, $pathname, $progress);
273 $processed = array();
274 $success = true;
276 $pathname = rtrim($pathname, '/');
277 if (!is_readable($archivefile)) {
278 return false;
280 $ziparch = new zip_archive();
281 if (!$ziparch->open($archivefile, file_archive::OPEN)) {
282 return false;
285 // Get the number of files (approx).
286 if ($progress) {
287 $approxmax = $ziparch->estimated_count();
288 $done = 0;
291 foreach ($ziparch as $info) {
292 // Notify progress.
293 if ($progress) {
294 $progress->progress($done, $approxmax);
295 $done++;
298 $size = $info->size;
299 $name = $info->pathname;
300 $origname = $name;
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);
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;
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;
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;
334 continue;
337 $parts = explode('/', trim($name, '/'));
338 $filename = array_pop($parts);
339 $newdir = rtrim($pathname.'/'.implode('/', $parts), '/');
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;
349 $newfile = "$newdir/$filename";
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;
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;
368 while (!feof($fz)) {
369 $content = fread($fz, 262143);
370 fwrite($fp, $content);
373 fclose($fz);
374 fclose($fp);
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;
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;
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;
399 $processed[$name] = true;
402 $ziparch->close();
404 if ($returnbool) {
405 return $success;
406 } else {
407 return $processed;
412 * Unzip file to given file path (real OS filesystem), existing files are overwritten.
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
425 public function extract_to_storage($archivefile, $contextid,
426 $component, $filearea, $itemid, $pathbase, $userid = NULL,
427 file_progress $progress = null) {
428 global $CFG;
430 if (!is_string($archivefile)) {
431 return $archivefile->extract_to_storage($this, $contextid, $component,
432 $filearea, $itemid, $pathbase, $userid, $progress);
435 check_dir_exists($CFG->tempdir.'/zip');
437 $pathbase = trim($pathbase, '/');
438 $pathbase = ($pathbase === '') ? '/' : '/'.$pathbase.'/';
439 $fs = get_file_storage();
441 $processed = array();
443 $ziparch = new zip_archive();
444 if (!$ziparch->open($archivefile, file_archive::OPEN)) {
445 return false;
448 // Get the number of files (approx).
449 if ($progress) {
450 $approxmax = $ziparch->estimated_count();
451 $done = 0;
454 foreach ($ziparch as $info) {
455 // Notify progress.
456 if ($progress) {
457 $progress->progress($done, $approxmax);
458 $done++;
461 $size = $info->size;
462 $name = $info->pathname;
464 if ($name === '' or array_key_exists($name, $processed)) {
465 //probably filename collisions caused by filename cleaning/conversion
466 continue;
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;
476 $parts = explode('/', trim($name, '/'));
477 $filename = array_pop($parts);
478 $filepath = $pathbase;
479 if ($parts) {
480 $filepath .= implode('/', $parts).'/';
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;
489 $content = '';
490 $realfilesize = 0;
491 while (!feof($fz)) {
492 $content .= fread($fz, 262143);
493 $realfilesize = strlen($content); // Current file size.
495 // More was read than was expected, which indicates a malformed/malicious archive.
496 // Break and let the error handling below take care of the file clean up.
497 if ($realfilesize > $size) {
498 break;
501 fclose($fz);
502 if (strlen($content) !== $size) {
503 $processed[$name] = 'Unknown error during zip extraction'; // TODO: localise
504 // something went wrong :-(
505 unset($content);
506 continue;
509 if ($file = $fs->get_file($contextid, $component, $filearea, $itemid, $filepath, $filename)) {
510 if (!$file->delete()) {
511 $processed[$name] = 'Can not delete existing file'; // TODO: localise
512 continue;
515 $file_record = new stdClass();
516 $file_record->contextid = $contextid;
517 $file_record->component = $component;
518 $file_record->filearea = $filearea;
519 $file_record->itemid = $itemid;
520 $file_record->filepath = $filepath;
521 $file_record->filename = $filename;
522 $file_record->userid = $userid;
523 if ($fs->create_file_from_string($file_record, $content)) {
524 $processed[$name] = true;
525 } else {
526 $processed[$name] = 'Unknown error during zip extraction'; // TODO: localise
528 unset($content);
529 continue;
531 } else {
532 // large file, would not fit into memory :-(
533 $tmpfile = tempnam($CFG->tempdir.'/zip', 'unzip');
534 if (!$fp = fopen($tmpfile, 'wb')) {
535 @unlink($tmpfile);
536 $processed[$name] = 'Can not write temp file'; // TODO: localise
537 continue;
539 if (!$fz = $ziparch->get_stream($info->index)) {
540 @unlink($tmpfile);
541 $processed[$name] = 'Can not read file from zip archive'; // TODO: localise
542 continue;
544 $realfilesize = 0;
545 while (!feof($fz)) {
546 $content = fread($fz, 262143);
547 $numofbytes = fwrite($fp, $content);
548 $realfilesize += $numofbytes; // Current file size.
550 // More was read than was expected, which indicates a malformed/malicious archive.
551 // Break and let the error handling below take care of the file clean up.
552 if ($realfilesize > $size) {
553 break;
556 fclose($fz);
557 fclose($fp);
558 if (filesize($tmpfile) !== $size) {
559 $processed[$name] = 'Unknown error during zip extraction'; // TODO: localise
560 // something went wrong :-(
561 @unlink($tmpfile);
562 continue;
565 if ($file = $fs->get_file($contextid, $component, $filearea, $itemid, $filepath, $filename)) {
566 if (!$file->delete()) {
567 @unlink($tmpfile);
568 $processed[$name] = 'Can not delete existing file'; // TODO: localise
569 continue;
572 $file_record = new stdClass();
573 $file_record->contextid = $contextid;
574 $file_record->component = $component;
575 $file_record->filearea = $filearea;
576 $file_record->itemid = $itemid;
577 $file_record->filepath = $filepath;
578 $file_record->filename = $filename;
579 $file_record->userid = $userid;
580 if ($fs->create_file_from_pathname($file_record, $tmpfile)) {
581 $processed[$name] = true;
582 } else {
583 $processed[$name] = 'Unknown error during zip extraction'; // TODO: localise
585 @unlink($tmpfile);
586 continue;
589 $ziparch->close();
590 return $processed;
594 * Returns array of info about all files in archive.
596 * @param string|file_archive $archivefile
597 * @return array of file infos
599 public function list_files($archivefile) {
600 if (!is_string($archivefile)) {
601 return $archivefile->list_files();
604 $ziparch = new zip_archive();
605 if (!$ziparch->open($archivefile, file_archive::OPEN)) {
606 return false;
608 $list = $ziparch->list_files();
609 $ziparch->close();
610 return $list;