<?php
/**
 * Copyright 2000-2017 Horde LLC (http://www.horde.org/)
 *
 * See the enclosed file COPYING for license information (LGPL). If you
 * did not receive this file, see http://www.horde.org/licenses/lgpl21.
 *
 * The ZIP compression code is partially based on code from:
 *   Eric Mueller <eric@themepark.com>
 *   http://www.zend.com/codex.php?id=535&single=1
 *
 *   Deins125 <webmaster@atlant.ru>
 *   http://www.zend.com/codex.php?id=470&single=1
 *
 * The ZIP compression date code is partially based on code from
 *   Peter Listiak <mlady@users.sourceforge.net>
 *
 * Official ZIP file format: https://support.pkware.com/display/PKZIP/APPNOTE
 *
 * @author   Chuck Hagenbuch <chuck@horde.org>
 * @author   Michael Cochrane <mike@graftonhall.co.nz>
 * @author   Michael Slusarz <slusarz@horde.org>
 * @category Horde
 * @license  http://www.horde.org/licenses/lgpl21 LGPL 2.1
 * @package  Compress
 */

/**
 * The Horde_Compress_zip class allows ZIP files to be created and read.
 *
 * @author    Chuck Hagenbuch <chuck@horde.org>
 * @author    Michael Cochrane <mike@graftonhall.co.nz>
 * @author    Michael Slusarz <slusarz@horde.org>
 * @category  Horde
 * @copyright 2000-2017 Horde LLC
 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
 * @package   Compress
 */
class Horde_Compress_Zip extends Horde_Compress_Base
{
    /**
     * Return file list.
     *
     * @see decompress().
     */
    const ZIP_LIST = 1;

    /**
     * Return file data.
     *
     * @see decompress().
     */
    const ZIP_DATA = 2;

    /**
     * Beginning of central directory record.
     */
    const CTRL_DIR_HEADER = "\x50\x4b\x01\x02";

    /**
     * End of central directory record.
     */
    const CTRL_DIR_END = "\x50\x4b\x05\x06\x00\x00\x00\x00";

    /**
     * Beginning of file contents.
     */
    const FILE_HEADER = "\x50\x4b\x03\x04";

    /**
     */
    public $canCompress = true;

    /**
     */
    public $canDecompress = true;

    /**
     * ZIP compression methods.
     *
     * @var array
     */
    protected $_methods = array(
        0x0 => 'None',
        0x1 => 'Shrunk',
        0x2 => 'Super Fast',
        0x3 => 'Fast',
        0x4 => 'Normal',
        0x5 => 'Maximum',
        0x6 => 'Imploded',
        0x8 => 'Deflated'
    );

    /**
     * Temporary data for compressing files.
     *
     * @var array
     */
    protected $_ctrldir;

    /**
     * Temporary contents for compressing files.
     *
     * @var resource
     */
    protected $_tmp;

    /**
     * @param array $data    The data to compress. Requires an array of
     *                       arrays. Each subarray should contain these
     *                       fields:
     *   - data: (string/resource) The data to compress.
     *   - name: (string) The pathname to the file.
     *   - time: (integer) [optional] The timestamp to use for the file.
     * @param array $params  The parameter array.
     *   - stream: (boolean) If set, return a stream instead of a string.
     *             DEFAULT: Return string
     *
     * @return mixed  The ZIP file as either a string or a stream resource.
     */
    public function compress($data, array $params = array())
    {
        if (!Horde_Util::extensionExists('zlib')) {
            throw new Horde_Compress_Exception(
                Horde_Compress_Translation::t(
                    "This server can't compress zip files."
                )
            );
        }

        $this->_ctrldir = array();
        $this->_tmp = fopen('php://temp', 'r+');

        foreach ($data as $val) {
            $this->_addToZipFile($val);
        }

        /* Create the ZIP file. */
        $dir = implode('', $this->_ctrldir);

        fseek($this->_tmp, 0, SEEK_END);
        $offset = ftell($this->_tmp);

        fwrite(
            $this->_tmp,
            $dir . self::CTRL_DIR_END .
            /* Total # of entries "on this disk". */
            pack('v', count($this->_ctrldir)) .
            /* Total # of entries overall. */
            pack('v', count($this->_ctrldir)) .
            /* Size of central directory. */
            pack('V', strlen($dir)) .
            /* Offset to start of central dir. */
            pack('V', $offset) .
            /* ZIP file comment length. */
            "\x00\x00"
        );

        rewind($this->_tmp);

        if (empty($params['stream'])) {
            $out = stream_get_contents($this->_tmp);
            fclose($this->_tmp);
            return $out;
        }

        return $this->_tmp;
    }

    /**
     * @param array $params  The parameter array.
     *   - action: (integer) [REQUIRED] The action to take on the data. Either
     *             self::ZIP_LIST or self::ZIP_DATA.
     *   - info: (array) [REQUIRED for ZIP_DATA] The zipfile list.
     *   - key: (integer) [REQUIRED for ZIP_DATA] The position of the file in
     *          the archive list.
     *
     * @return mixed  If action is self::ZIP_DATA, the uncompressed data. If
     *                action is self::ZIP_LIST, an array with the KEY as the
     *                position in the zipfile and these values:
     *   - attr: File attributes
     *   - crc: CRC checksum
     *   - csize: Compressed file size
     *   - date: File modification time
     *   - name: Filename
     *   - method: Compression method
     *   - size: Original file size
     *   - type: File type
     * @throws Horde_Compress_Exception
     */
    public function decompress($data, array $params = array())
    {
        if (isset($params['action'])) {
            switch ($params['action']) {
            case self::ZIP_LIST:
                return $this->_getZipInfo($data);

            case self::ZIP_DATA:
                return $this->_getZipData(
                    $data, $params['info'], $params['key']
                );
            }
        }
    }

    /**
     * Get the list of files/data from the zip archive.
     *
     * @param string $data  The zipfile data.
     *
     * @return array  See decompress() for the format.
     *
     * @throws Horde_Compress_Exception
     */
    protected function _getZipInfo($data)
    {
        $entries = array();

        /* Get details from Central directory structure. */
        $fhStart = strpos($data, self::CTRL_DIR_HEADER);

        do {
            if (strlen($data) < $fhStart + 31) {
                throw new Horde_Compress_Exception(
                    Horde_Compress_Translation::t("Invalid ZIP data")
                );
            }
            $info = unpack(
                'vMethod/VTime/VCRC32/VCompressed/VUncompressed/vLength',
                substr($data, $fhStart + 10, 20)
            );

            if (!isset($this->_methods[$info['Method']])) {
                throw new Horde_Compress_Exception(
                    Horde_Compress_Translation::t("Invalid ZIP data")
                );
            }

            $name = substr($data, $fhStart + 46, $info['Length']);

            $entries[$name] = array(
                'attr' => null,
                'crc' => sprintf("%08s", dechex($info['CRC32'])),
                'csize' => $info['Compressed'],
                'date' => null,
                '_dataStart' => null,
                'name' => $name,
                'method' => $this->_methods[$info['Method']],
                '_method' => $info['Method'],
                'size' => $info['Uncompressed'],
                'type' => null
            );

            $entries[$name]['date'] =
                mktime((($info['Time'] >> 11) & 0x1f),
                       (($info['Time'] >> 5) & 0x3f),
                       (($info['Time'] << 1) & 0x3e),
                       (($info['Time'] >> 21) & 0x0f),
                       (($info['Time'] >> 16) & 0x1f),
                       ((($info['Time'] >> 25) & 0x7f) + 1980));

            if (strlen($data) < $fhStart + 43) {
                throw new Horde_Compress_Exception(
                    Horde_Compress_Translation::t("Invalid ZIP data")
                );
            }
            $info = unpack(
                'vInternal/VExternal',
                substr($data, $fhStart + 36, 6)
            );

            $entries[$name]['type'] = ($info['Internal'] & 0x01)
                ? 'text'
                : 'binary';
            $entries[$name]['attr'] =
                (($info['External'] & 0x10) ? 'D' : '-') .
                (($info['External'] & 0x20) ? 'A' : '-') .
                (($info['External'] & 0x03) ? 'S' : '-') .
                (($info['External'] & 0x02) ? 'H' : '-') .
                (($info['External'] & 0x01) ? 'R' : '-');
        } while (($fhStart = strpos($data, self::CTRL_DIR_HEADER, $fhStart + 46)) !== false);

        /* Get details from local file header. */
        $fhStart = strpos($data, self::FILE_HEADER);

        $data_len = strlen($data);

        do {
            if ($data_len < $fhStart + 34) {
                throw new Horde_Compress_Exception(
                    Horde_Compress_Translation::t("Invalid ZIP data")
                );
            }
            $info = unpack(
                'vMethod/VTime/VCRC32/VCompressed/VUncompressed/vLength/vExtraLength',
                substr($data, $fhStart + 8, 25)
            );
            $name = substr($data, $fhStart + 30, $info['Length']);
            if (isset($entries[$name])) {
                $entries[$name]['_dataStart'] = $fhStart + 30
                    + $info['Length'] + $info['ExtraLength'];
            }
        } while ($data_len > $fhStart + 30 + $info['Length'] &&
                 ($fhStart = strpos($data, self::FILE_HEADER, $fhStart + 30 + $info['Length'])) !== false);

        return array_values($entries);
    }

    /**
     * Returns the data for a specific archived file.
     *
     * @param string $data  The zip archive contents.
     * @param array $info   The information array from _getZipInfo().
     * @param integer $key  The position of the file in the archive.
     *
     * @return string  The file data.
     */
    protected function _getZipData($data, $info, $key)
    {
        if (($info[$key]['_method'] == 0x8) &&
            Horde_Util::extensionExists('zlib')) {
            /* If the file has been deflated, and zlib is installed,
               then inflate the data again. */
            return @gzinflate(
                substr($data, $info[$key]['_dataStart'], $info[$key]['csize'])
            );
        } elseif ($info[$key]['_method'] == 0x0) {
            /* Files that aren't compressed. */
            return substr(
                $data, $info[$key]['_dataStart'], $info[$key]['csize']
            );
        }

        return '';
    }

    /**
     * Checks to see if the data is a valid ZIP file.
     *
     * @param string $data  The ZIP file data.
     *
     * @return boolean  True if valid, false if invalid.
     */
    public function checkZipData($data)
    {
        return (strpos($data, self::FILE_HEADER) !== false);
    }

    /**
     * Converts a UNIX timestamp to a 4-byte DOS date and time format (date in
     * high 2-bytes, time in low 2-bytes allowing magnitude comparison).
     *
     * @param integer $unixtime  The current UNIX timestamp.
     *
     * @return integer  The current date in a 4-byte DOS format.
     */
    protected function _unix2DOSTime($unixtime = null)
    {
        $timearray = (is_null($unixtime)) ? getdate() : getdate($unixtime);

        if ($timearray['year'] < 1980) {
            $timearray['year']    = 1980;
            $timearray['mon']     = 1;
            $timearray['mday']    = 1;
            $timearray['hours']   = 0;
            $timearray['minutes'] = 0;
            $timearray['seconds'] = 0;
        }

        return (($timearray['year'] - 1980) << 25) |
                ($timearray['mon'] << 21) |
                ($timearray['mday'] << 16) |
                ($timearray['hours'] << 11) |
                ($timearray['minutes'] << 5) |
                ($timearray['seconds'] >> 1);
    }

    /**
     * Adds a "file" to the ZIP archive.
     *
     * @param array $file  See self::createZipFile().
     */
    protected function _addToZipFile($file)
    {
        $name = str_replace('\\', '/', $file['name']);

        /* See if time/date information has been provided. */
        $ftime = (isset($file['time'])) ? $file['time'] : null;

        /* Get the hex time. */
        $dtime    = sprintf('%08s', dechex($this->_unix2DosTime($ftime)));
        $hexdtime = chr(hexdec($dtime[6] . $dtime[7])) .
                    chr(hexdec($dtime[4] . $dtime[5])) .
                    chr(hexdec($dtime[2] . $dtime[3])) .
                    chr(hexdec($dtime[0] . $dtime[1]));

        /* "Local file header" segment. */
        if (is_resource($file['data'])) {
            $zdata = fopen('php://temp', 'r+');

            $params = new stdClass;
            stream_filter_register(
                'horde_compress_filter_crc32', 'Horde_Stream_Filter_Crc32'
            );
            $filter = stream_filter_prepend(
                $file['data'],
                'horde_compress_filter_crc32',
                STREAM_FILTER_READ,
                $params
            );
            $filter2 = stream_filter_append(
                $zdata,
                'zlib.deflate',
                STREAM_FILTER_WRITE
            );

            rewind($file['data']);
            stream_copy_to_stream($file['data'], $zdata);

            $crc = $params->crc32;
            $unc_len = ftell($file['data']);

            stream_filter_remove($filter2);
            stream_filter_remove($filter);

            fseek($zdata, 0, SEEK_END);
            $c_len = ftell($zdata);
        } else {
            $unc_len = strlen($file['data']);
            $crc = crc32($file['data']);
            $zdata = gzdeflate($file['data']);
            $c_len = strlen($zdata);
        }

        /* Common data for the two entries. */
        $common =
            "\x14\x00" .                /* Version needed to extract. */
            "\x00\x00" .                /* General purpose bit flag. */
            "\x08\x00" .                /* Compression method. */
            $hexdtime .                 /* Last modification time/date. */
            pack('V', $crc) .           /* CRC 32 information. */
            pack('V', $c_len) .         /* Compressed filesize. */
            pack('V', $unc_len) .       /* Uncompressed filesize. */
            pack('v', strlen($name)) .  /* Length of filename. */
            pack('v', 0);               /* Extra field length. */

        /* Add this entry to zip data. */
        fseek($this->_tmp, 0, SEEK_END);
        $old_offset = ftell($this->_tmp);

        fwrite($this->_tmp,
            self::FILE_HEADER .  /* Begin creating the ZIP data. */
            $common .            /* Common data. */
            $name
        );

        /* "File data" segment. */
        if (is_resource($zdata)) {
            rewind($zdata);
            stream_copy_to_stream($zdata, $this->_tmp);
        } else {
            fwrite($this->_tmp, $zdata);
        }

        /* Add to central directory record. */
        $this->_ctrldir[] =
            self::CTRL_DIR_HEADER .
            "\x00\x00" .              /* Version made by. */
            $common .                 /* Common data. */
            pack('v', 0) .            /* File comment length. */
            pack('v', 0) .            /* Disk number start. */
            pack('v', 0) .            /* Internal file attributes. */
            pack('V', 32) .           /* External file attributes -
                                       * 'archive' bit set. */
            pack('V', $old_offset) .  /* Relative offset of local header. */
            $name;                    /* File name. */
    }

}
