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
25defined('MOODLE_INTERNAL') || die();
26
27require_once("$CFG->libdir/filestorage/file_packer.php");
28require_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 */
38class 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}
598