1 <?php
2 /**
3 * @package Joomla.Platform
4 * @subpackage Archive
5 *
6 * @copyright Copyright (C) 2005 - 2017 Open Source Matters, Inc. All rights reserved.
7 * @license GNU General Public License version 2 or later; see LICENSE
8 */
9
10 defined('JPATH_PLATFORM') or die;
11
12 jimport('joomla.filesystem.file');
13 jimport('joomla.filesystem.folder');
14
15 /**
16 * ZIP format adapter for the JArchive class
17 *
18 * The ZIP compression code is partially based on code from:
19 * Eric Mueller <eric@themepark.com>
20 * http://www.zend.com/codex.php?id=535&single=1
21 *
22 * Deins125 <webmaster@atlant.ru>
23 * http://www.zend.com/codex.php?id=470&single=1
24 *
25 * The ZIP compression date code is partially based on code from
26 * Peter Listiak <mlady@users.sourceforge.net>
27 *
28 * This class is inspired from and draws heavily in code and concept from the Compress package of
29 * The Horde Project <https://www.horde.org>
30 *
31 * @contributor Chuck Hagenbuch <chuck@horde.org>
32 * @contributor Michael Slusarz <slusarz@horde.org>
33 * @contributor Michael Cochrane <mike@graftonhall.co.nz>
34 *
35 * @since 11.1
36 */
37 class JArchiveZip implements JArchiveExtractable
38 {
39 /**
40 * ZIP compression methods.
41 *
42 * @var array
43 * @since 11.1
44 */
45 private $_methods = array(
46 0x0 => 'None',
47 0x1 => 'Shrunk',
48 0x2 => 'Super Fast',
49 0x3 => 'Fast',
50 0x4 => 'Normal',
51 0x5 => 'Maximum',
52 0x6 => 'Imploded',
53 0x8 => 'Deflated',
54 );
55
56 /**
57 * Beginning of central directory record.
58 *
59 * @var string
60 * @since 11.1
61 */
62 private $_ctrlDirHeader = "\x50\x4b\x01\x02";
63
64 /**
65 * End of central directory record.
66 *
67 * @var string
68 * @since 11.1
69 */
70 private $_ctrlDirEnd = "\x50\x4b\x05\x06\x00\x00\x00\x00";
71
72 /**
73 * Beginning of file contents.
74 *
75 * @var string
76 * @since 11.1
77 */
78 private $_fileHeader = "\x50\x4b\x03\x04";
79
80 /**
81 * ZIP file data buffer
82 *
83 * @var string
84 * @since 11.1
85 */
86 private $_data = null;
87
88 /**
89 * ZIP file metadata array
90 *
91 * @var array
92 * @since 11.1
93 */
94 private $_metadata = null;
95
96 /**
97 * Create a ZIP compressed file from an array of file data.
98 *
99 * @param string $archive Path to save archive.
100 * @param array $files Array of files to add to archive.
101 *
102 * @return boolean True if successful.
103 *
104 * @since 11.1
105 *
106 * @todo Finish Implementation
107 */
108 public function create($archive, $files)
109 {
110 $contents = array();
111 $ctrldir = array();
112
113 foreach ($files as $file)
114 {
115 $this->_addToZIPFile($file, $contents, $ctrldir);
116 }
117
118 return $this->_createZIPFile($contents, $ctrldir, $archive);
119 }
120
121 /**
122 * Extract a ZIP compressed file to a given path
123 *
124 * @param string $archive Path to ZIP archive to extract
125 * @param string $destination Path to extract archive into
126 * @param array $options Extraction options [unused]
127 *
128 * @return boolean True if successful
129 *
130 * @since 11.1
131 * @throws RuntimeException
132 */
133 public function extract($archive, $destination, array $options = array())
134 {
135 if (!is_file($archive))
136 {
137 return $this->raiseWarning(100, 'Archive does not exist');
138 }
139
140 if ($this->hasNativeSupport())
141 {
142 return $this->extractNative($archive, $destination);
143 }
144
145 return $this->extractCustom($archive, $destination);
146 }
147
148 /**
149 * Temporary private method to isolate JError from the extract method
150 * This code should be removed when JError is removed.
151 *
152 * @param int $code The application-internal error code for this error
153 * @param string $msg The error message, which may also be shown the user if need be.
154 *
155 * @return JException JException instance if JError class exists
156 *
157 * @throws RuntimeException if JError class does not exist
158 */
159 private function raiseWarning($code, $msg)
160 {
161 if (class_exists('JError'))
162 {
163 return JError::raiseWarning($code, $msg);
164 }
165
166 throw new RuntimeException($msg);
167 }
168
169 /**
170 * Tests whether this adapter can unpack files on this computer.
171 *
172 * @return boolean True if supported
173 *
174 * @since 11.3
175 */
176 public static function isSupported()
177 {
178 return self::hasNativeSupport() || extension_loaded('zlib');
179 }
180
181 /**
182 * Method to determine if the server has native zip support for faster handling
183 *
184 * @return boolean True if php has native ZIP support
185 *
186 * @since 11.1
187 */
188 public static function hasNativeSupport()
189 {
190 return function_exists('zip_open') && function_exists('zip_read');
191 }
192
193 /**
194 * Checks to see if the data is a valid ZIP file.
195 *
196 * @param string &$data ZIP archive data buffer.
197 *
198 * @return boolean True if valid, false if invalid.
199 *
200 * @since 11.1
201 */
202 public function checkZipData(&$data)
203 {
204 if (strpos($data, $this->_fileHeader) === false)
205 {
206 return false;
207 }
208
209 return true;
210 }
211
212 /**
213 * Extract a ZIP compressed file to a given path using a php based algorithm that only requires zlib support
214 *
215 * @param string $archive Path to ZIP archive to extract.
216 * @param string $destination Path to extract archive into.
217 *
218 * @return mixed True if successful
219 *
220 * @since 11.1
221 * @throws RuntimeException
222 */
223 protected function extractCustom($archive, $destination)
224 {
225 $this->_data = null;
226 $this->_metadata = null;
227
228 if (!extension_loaded('zlib'))
229 {
230 return $this->raiseWarning(100, 'Zlib not supported');
231 }
232
233 $this->_data = file_get_contents($archive);
234
235 if (!$this->_data)
236 {
237 return $this->raiseWarning(100, 'Unable to read archive (zip)');
238 }
239
240 if (!$this->_readZipInfo($this->_data))
241 {
242 return $this->raiseWarning(100, 'Get ZIP Information failed');
243 }
244
245 for ($i = 0, $n = count($this->_metadata); $i < $n; $i++)
246 {
247 $lastPathCharacter = substr($this->_metadata[$i]['name'], -1, 1);
248
249 if ($lastPathCharacter !== '/' && $lastPathCharacter !== '\\')
250 {
251 $buffer = $this->_getFileData($i);
252 $path = JPath::clean($destination . '/' . $this->_metadata[$i]['name']);
253
254 // Make sure the destination folder exists
255 if (!JFolder::create(dirname($path)))
256 {
257 return $this->raiseWarning(100, 'Unable to create destination');
258 }
259
260 if (JFile::write($path, $buffer) === false)
261 {
262 return $this->raiseWarning(100, 'Unable to write entry');
263 }
264 }
265 }
266
267 return true;
268 }
269
270 /**
271 * Extract a ZIP compressed file to a given path using native php api calls for speed
272 *
273 * @param string $archive Path to ZIP archive to extract
274 * @param string $destination Path to extract archive into
275 *
276 * @return boolean True on success
277 *
278 * @since 11.1
279 * @throws RuntimeException
280 */
281 protected function extractNative($archive, $destination)
282 {
283 $zip = zip_open($archive);
284
285 if (!is_resource($zip))
286 {
287 return $this->raiseWarning(100, 'Unable to open archive');
288 }
289
290 // Make sure the destination folder exists
291 if (!JFolder::create($destination))
292 {
293 return $this->raiseWarning(100, 'Unable to create destination');
294 }
295
296 // Read files in the archive
297 while ($file = @zip_read($zip))
298 {
299 if (!zip_entry_open($zip, $file, 'r'))
300 {
301 return $this->raiseWarning(100, 'Unable to read entry');
302 }
303
304 if (substr(zip_entry_name($file), strlen(zip_entry_name($file)) - 1) != '/')
305 {
306 $buffer = zip_entry_read($file, zip_entry_filesize($file));
307
308 if (JFile::write($destination . '/' . zip_entry_name($file), $buffer) === false)
309 {
310 return $this->raiseWarning(100, 'Unable to write entry');
311 }
312
313 zip_entry_close($file);
314 }
315 }
316
317 @zip_close($zip);
318
319 return true;
320 }
321
322 /**
323 * Get the list of files/data from a ZIP archive buffer.
324 *
325 * <pre>
326 * KEY: Position in zipfile
327 * VALUES: 'attr' -- File attributes
328 * 'crc' -- CRC checksum
329 * 'csize' -- Compressed file size
330 * 'date' -- File modification time
331 * 'name' -- Filename
332 * 'method'-- Compression method
333 * 'size' -- Original file size
334 * 'type' -- File type
335 * </pre>
336 *
337 * @param string &$data The ZIP archive buffer.
338 *
339 * @return boolean True on success
340 *
341 * @since 11.1
342 * @throws RuntimeException
343 */
344 private function _readZipInfo(&$data)
345 {
346 $entries = array();
347
348 // Find the last central directory header entry
349 $fhLast = strpos($data, $this->_ctrlDirEnd);
350
351 do
352 {
353 $last = $fhLast;
354 }
355
356 while (($fhLast = strpos($data, $this->_ctrlDirEnd, $fhLast + 1)) !== false);
357
358 // Find the central directory offset
359 $offset = 0;
360
361 if ($last)
362 {
363 $endOfCentralDirectory = unpack(
364 'vNumberOfDisk/vNoOfDiskWithStartOfCentralDirectory/vNoOfCentralDirectoryEntriesOnDisk/' .
365 'vTotalCentralDirectoryEntries/VSizeOfCentralDirectory/VCentralDirectoryOffset/vCommentLength',
366 substr($data, $last + 4)
367 );
368 $offset = $endOfCentralDirectory['CentralDirectoryOffset'];
369 }
370
371 // Get details from central directory structure.
372 $fhStart = strpos($data, $this->_ctrlDirHeader, $offset);
373 $dataLength = strlen($data);
374
375 do
376 {
377 if ($dataLength < $fhStart + 31)
378 {
379 return $this->raiseWarning(100, 'Invalid Zip Data');
380 }
381
382 $info = unpack('vMethod/VTime/VCRC32/VCompressed/VUncompressed/vLength', substr($data, $fhStart + 10, 20));
383 $name = substr($data, $fhStart + 46, $info['Length']);
384
385 $entries[$name] = array(
386 'attr' => null,
387 'crc' => sprintf('%08s', dechex($info['CRC32'])),
388 'csize' => $info['Compressed'],
389 'date' => null,
390 '_dataStart' => null,
391 'name' => $name,
392 'method' => $this->_methods[$info['Method']],
393 '_method' => $info['Method'],
394 'size' => $info['Uncompressed'],
395 'type' => null,
396 );
397
398 $entries[$name]['date'] = mktime(
399 (($info['Time'] >> 11) & 0x1f),
400 (($info['Time'] >> 5) & 0x3f),
401 (($info['Time'] << 1) & 0x3e),
402 (($info['Time'] >> 21) & 0x07),
403 (($info['Time'] >> 16) & 0x1f),
404 ((($info['Time'] >> 25) & 0x7f) + 1980)
405 );
406
407 if ($dataLength < $fhStart + 43)
408 {
409 return $this->raiseWarning(100, 'Invalid ZIP data');
410 }
411
412 $info = unpack('vInternal/VExternal/VOffset', substr($data, $fhStart + 36, 10));
413
414 $entries[$name]['type'] = ($info['Internal'] & 0x01) ? 'text' : 'binary';
415 $entries[$name]['attr'] = (($info['External'] & 0x10) ? 'D' : '-') . (($info['External'] & 0x20) ? 'A' : '-')
416 . (($info['External'] & 0x03) ? 'S' : '-') . (($info['External'] & 0x02) ? 'H' : '-') . (($info['External'] & 0x01) ? 'R' : '-');
417 $entries[$name]['offset'] = $info['Offset'];
418
419 // Get details from local file header since we have the offset
420 $lfhStart = strpos($data, $this->_fileHeader, $entries[$name]['offset']);
421
422 if ($dataLength < $lfhStart + 34)
423 {
424 return $this->raiseWarning(100, 'Invalid Zip Data');
425 }
426
427 $info = unpack('vMethod/VTime/VCRC32/VCompressed/VUncompressed/vLength/vExtraLength', substr($data, $lfhStart + 8, 25));
428 $name = substr($data, $lfhStart + 30, $info['Length']);
429 $entries[$name]['_dataStart'] = $lfhStart + 30 + $info['Length'] + $info['ExtraLength'];
430
431 // Bump the max execution time because not using the built in php zip libs makes this process slow.
432 @set_time_limit(ini_get('max_execution_time'));
433 }
434
435 while ((($fhStart = strpos($data, $this->_ctrlDirHeader, $fhStart + 46)) !== false));
436
437 $this->_metadata = array_values($entries);
438
439 return true;
440 }
441
442 /**
443 * Returns the file data for a file by offsest in the ZIP archive
444 *
445 * @param integer $key The position of the file in the archive.
446 *
447 * @return string Uncompressed file data buffer.
448 *
449 * @since 11.1
450 */
451 private function _getFileData($key)
452 {
453 $method = $this->_metadata[$key]['_method'];
454
455 if ($method == 0x12 && !extension_loaded('bz2'))
456 {
457 return '';
458 }
459
460 switch ($method)
461 {
462 case 0x8:
463 return gzinflate(substr($this->_data, $this->_metadata[$key]['_dataStart'], $this->_metadata[$key]['csize']));
464
465 case 0x0:
466 // Files that aren't compressed.
467 return substr($this->_data, $this->_metadata[$key]['_dataStart'], $this->_metadata[$key]['csize']);
468
469 case 0x12:
470
471 return bzdecompress(substr($this->_data, $this->_metadata[$key]['_dataStart'], $this->_metadata[$key]['csize']));
472 }
473
474 return '';
475 }
476
477 /**
478 * Converts a UNIX timestamp to a 4-byte DOS date and time format
479 * (date in high 2-bytes, time in low 2-bytes allowing magnitude
480 * comparison).
481 *
482 * @param int $unixtime The current UNIX timestamp.
483 *
484 * @return int The current date in a 4-byte DOS format.
485 *
486 * @since 11.1
487 */
488 protected function _unix2DOSTime($unixtime = null)
489 {
490 $timearray = (is_null($unixtime)) ? getdate() : getdate($unixtime);
491
492 if ($timearray['year'] < 1980)
493 {
494 $timearray['year'] = 1980;
495 $timearray['mon'] = 1;
496 $timearray['mday'] = 1;
497 $timearray['hours'] = 0;
498 $timearray['minutes'] = 0;
499 $timearray['seconds'] = 0;
500 }
501
502 return (($timearray['year'] - 1980) << 25) | ($timearray['mon'] << 21) | ($timearray['mday'] << 16) | ($timearray['hours'] << 11) |
503 ($timearray['minutes'] << 5) | ($timearray['seconds'] >> 1);
504 }
505
506 /**
507 * Adds a "file" to the ZIP archive.
508 *
509 * @param array &$file File data array to add
510 * @param array &$contents An array of existing zipped files.
511 * @param array &$ctrldir An array of central directory information.
512 *
513 * @return void
514 *
515 * @since 11.1
516 *
517 * @todo Review and finish implementation
518 */
519 private function _addToZIPFile(array &$file, array &$contents, array &$ctrldir)
520 {
521 $data = &$file['data'];
522 $name = str_replace('\\', '/', $file['name']);
523
524 /* See if time/date information has been provided. */
525 $ftime = null;
526
527 if (isset($file['time']))
528 {
529 $ftime = $file['time'];
530 }
531
532 // Get the hex time.
533 $dtime = dechex($this->_unix2DosTime($ftime));
534 $hexdtime = chr(hexdec($dtime[6] . $dtime[7])) . chr(hexdec($dtime[4] . $dtime[5])) . chr(hexdec($dtime[2] . $dtime[3]))
535 . chr(hexdec($dtime[0] . $dtime[1]));
536
537 /* Begin creating the ZIP data. */
538 $fr = $this->_fileHeader;
539 /* Version needed to extract. */
540 $fr .= "\x14\x00";
541 /* General purpose bit flag. */
542 $fr .= "\x00\x00";
543 /* Compression method. */
544 $fr .= "\x08\x00";
545 /* Last modification time/date. */
546 $fr .= $hexdtime;
547
548 /* "Local file header" segment. */
549 $unc_len = strlen($data);
550 $crc = crc32($data);
551 $zdata = gzcompress($data);
552 $zdata = substr(substr($zdata, 0, strlen($zdata) - 4), 2);
553 $c_len = strlen($zdata);
554
555 /* CRC 32 information. */
556 $fr .= pack('V', $crc);
557 /* Compressed filesize. */
558 $fr .= pack('V', $c_len);
559 /* Uncompressed filesize. */
560 $fr .= pack('V', $unc_len);
561 /* Length of filename. */
562 $fr .= pack('v', strlen($name));
563 /* Extra field length. */
564 $fr .= pack('v', 0);
565 /* File name. */
566 $fr .= $name;
567
568 /* "File data" segment. */
569 $fr .= $zdata;
570
571 /* Add this entry to array. */
572 $old_offset = strlen(implode('', $contents));
573 $contents[] = &$fr;
574
575 /* Add to central directory record. */
576 $cdrec = $this->_ctrlDirHeader;
577 /* Version made by. */
578 $cdrec .= "\x00\x00";
579 /* Version needed to extract */
580 $cdrec .= "\x14\x00";
581 /* General purpose bit flag */
582 $cdrec .= "\x00\x00";
583 /* Compression method */
584 $cdrec .= "\x08\x00";
585 /* Last mod time/date. */
586 $cdrec .= $hexdtime;
587 /* CRC 32 information. */
588 $cdrec .= pack('V', $crc);
589 /* Compressed filesize. */
590 $cdrec .= pack('V', $c_len);
591 /* Uncompressed filesize. */
592 $cdrec .= pack('V', $unc_len);
593 /* Length of filename. */
594 $cdrec .= pack('v', strlen($name));
595 /* Extra field length. */
596 $cdrec .= pack('v', 0);
597 /* File comment length. */
598 $cdrec .= pack('v', 0);
599 /* Disk number start. */
600 $cdrec .= pack('v', 0);
601 /* Internal file attributes. */
602 $cdrec .= pack('v', 0);
603 /* External file attributes -'archive' bit set. */
604 $cdrec .= pack('V', 32);
605 /* Relative offset of local header. */
606 $cdrec .= pack('V', $old_offset);
607 /* File name. */
608 $cdrec .= $name;
609 /* Optional extra field, file comment goes here. */
610
611 /* Save to central directory array. */
612 $ctrldir[] = &$cdrec;
613 }
614
615 /**
616 * Creates the ZIP file.
617 *
618 * Official ZIP file format: https://support.pkware.com/display/PKZIP/APPNOTE
619 *
620 * @param array &$contents An array of existing zipped files.
621 * @param array &$ctrlDir An array of central directory information.
622 * @param string $path The path to store the archive.
623 *
624 * @return boolean True if successful
625 *
626 * @since 11.1
627 *
628 * @todo Review and finish implementation
629 */
630 private function _createZIPFile(array &$contents, array &$ctrlDir, $path)
631 {
632 $data = implode('', $contents);
633 $dir = implode('', $ctrlDir);
634
635 $buffer = $data . $dir . $this->_ctrlDirEnd . /* Total # of entries "on this disk". */
636 pack('v', count($ctrlDir)) . /* Total # of entries overall. */
637 pack('v', count($ctrlDir)) . /* Size of central directory. */
638 pack('V', strlen($dir)) . /* Offset to start of central dir. */
639 pack('V', strlen($data)) . /* ZIP file comment length. */
640 "\x00\x00";
641
642 if (JFile::write($path, $buffer) === false)
643 {
644 return false;
645 }
646
647 return true;
648 }
649 }
650