<?php
/**
 * Class to create and manage a Zip file.
 *
 * Inspired by CreateZipFile by Rochak Chauhan  www.rochakchauhan.com (http://www.phpclasses.org/browse/package/2322.html)
 * and
 * http://www.pkware.com/documents/casestudies/APPNOTE.TXT Zip file specification.
 *
 * @author A. Grandt
 * @see Distributed under "General Public License"
 * @version 1.1
 */
class Zip {
    private $zipMemoryThreshold = 1048576; // Autocreate tempfile if the zip data exceeds 1048576 bytes (1 MB)
    private $endOfCentralDirectory = "\x50\x4b\x05\x06\x00\x00\x00\x00"; //end of Central directory record
    private $localFileHeader = "\x50\x4b\x03\x04"; // Local file header signature
    private $centralFileHeader = "\x50\x4b\x01\x02"; // Central file header signature

    private $zipData = null;
    private $zipFile = null;
    private $zipComment = null;
    private $cdRec = array(); // central directory
    private $offset = 0;
    private $isFinalized = false;

    private $streamChunkSize = 65536;
    private $streamFilePath = null;
    private $streamTimeStamp = null;
    private $streamComment = null;
    private $streamFile = null;
    private $streamData = null;
    private $streamFileLength = 0;  

    /**
     * Constructor.
     *
     * @param $useZipFile boolean. Write temp zip data to tempFile? Default false
     */
    function __construct($useZipFile = false) {
        if ($useZipFile) {
            $this->zipFile = tmpfile();
        } else {
            $this->zipData = "";
        }
    }

    function __destruct() {
        if (!is_null($this->zipFile)) {
            fclose($this->zipFile);
        }
        $this->zipData= null;
    }

    /**
     * Set Zip archive comment.
     *
     * @param string $newComment New comment. null to clear.
     */
    public function setComment($newComment = null) {
        $this->zipComment = $newComment;
    }

    /**
     * Set zip file to write zip data to.
     * This will cause all present and future data written to this class to be written to this file.
     * This can be used at any time, even after the Zip Archive have been finalized. Any previous file will be closed.
     * Warning: If the given file already exists, it will be overwritten.
     *
     * @param string $fileName
     */
    public function setZipFile($fileName) {
        if (file_exists($fileName)) {
            unlink ($fileName);
        }
        $fd=fopen($fileName, "x+b");
        if (!is_null($this->zipFile)) {
            rewind($this->zipFile);
            while(!feof($this->zipFile)) {
                fwrite($fd, fread($this->zipFile, $this->streamChunkSize));
            } 
            
            fclose($this->zipFile);
        } else {
            fwrite($fd, $this->zipData);
            $this->zipData = null;
        }
        $this->zipFile = $fd;
    }

    /**
     * Add an empty directory entry to the zip archive.
     * Basically this is only used if an empty directory is added.
     *
     * @param string $directoryPath  Directory Path and name to be added to the archive.
     * @param int    $timestamp      (Optional) Timestamp for the added directory, if omitted or set to 0, the current time will be used.
     * @param string $fileComment    (Optional) Comment to be added to the archive for this directory. To use fileComment, timestamp must be given.
     */
    public function addDirectory($directoryPath, $timestamp = 0, $fileComment = null) {
        if ($this->isFinalized) {
            return;
        }
        $this->buildZipEntry($directoryPath, $fileComment, "\x00\x00", "\x00\x00", $timestamp, "\x00\x00\x00\x00", 0, 0, 16);
    }

    /**
     * Add a file to the archive at the specified location and file name.
     *
     * @param string $data        File data.
     * @param string $filePath    Filepath and name to be used in the archive.
     * @param int    $timestamp   (Optional) Timestamp for the added file, if omitted or set to 0, the current time will be used.
     * @param string $fileComment (Optional) Comment to be added to the archive for this file. To use fileComment, timestamp must be given.
     */
    public function addFile($data, $filePath, $timestamp = 0, $fileComment = null)   {
        if ($this->isFinalized) {
            return;
        }

        $gzType = "\x08\x00"; // Compression type 8 = deflate
        $gpFlags = "\x02\x00"; // General Purpose bit flags for compression type 8 it is: 0=Normal, 1=Maximum, 2=Fast, 3=super fast compression.
        $dataLength = strlen($data);
        $fileCRC32 = pack("V", crc32($data));

        $gzData = gzcompress($data);
        $gzData = substr( substr($gzData, 0, strlen($gzData) - 4), 2); // gzcompress adds a 2 byte header and 4 byte CRC we can't use.
        // The 2 byte header does contain useful data, though in this case the 2 parameters we'd be interrested in will always be 8 for compression type, and 2 for General purpose flag.
        $gzLength = strlen($gzData);

        if ($gzLength >= $dataLength) {
            $gzLength = $dataLength;
            $gzData = $data;
            $gzType = "\x00\x00"; // Compression type 0 = stored
            $gpFlags = "\x00\x00"; // Compression type 0 = stored
        }

        if (is_null($this->zipFile) && ($this->offset + $gzLength) > $this->zipMemoryThreshold) {
            $this->zipFile = tmpfile();
            fwrite($this->zipFile, $this->zipData);
            $this->zipData = null;
        }

        $this->buildZipEntry($filePath, $fileComment, $gpFlags, $gzType, $timestamp, $fileCRC32, $gzLength, $dataLength, 32);
        if (is_null($this->zipFile)) {
            $this->zipData .= $gzData;
        } else {
            fwrite($this->zipFile, $gzData);
        }
    }

    /**
     * Add a file to the archive at the specified location and file name.
     *
     * @param string $dataFile    File name/path.
     * @param string $filePath    Filepath and name to be used in the archive.
     * @param int    $timestamp   (Optional) Timestamp for the added file, if omitted or set to 0, the current time will be used.
     * @param string $fileComment (Optional) Comment to be added to the archive for this file. To use fileComment, timestamp must be given.
     */
    public function addLargeFile($dataFile, $filePath, $timestamp = 0, $fileComment = null)   {
        if ($this->isFinalized) {
            return;
        }
    
        $this->openStream($filePath, $timestamp, $fileComment);

        $fh = fopen($dataFile, "rb");
        while(!feof($fh)) {
            $this->addStreamData(fread($fh, $this->streamChunkSize));
        } 
        fclose($fh);

        $this->closeStream();
    }

    /**
     * Create a stream to be used for large entries.
     *
     * @param string $filePath    Filepath and name to be used in the archive.
     * @param int    $timestamp   (Optional) Timestamp for the added file, if omitted or set to 0, the current time will be used.
     * @param string $fileComment (Optional) Comment to be added to the archive for this file. To use fileComment, timestamp must be given.
     */
    public function openStream($filePath, $timestamp = 0, $fileComment = null)   {
        if ($this->isFinalized) {
            return;
        }

        if (is_null($this->zipFile)) {
            $this->zipFile = tmpfile();
            fwrite($this->zipFile, $this->zipData);
            $this->zipData = null;
        }
        
        if (strlen($this->streamFilePath) > 0) {
            closeStream();
        }
        $this->streamFile = tempnam(sys_get_temp_dir(), 'Zip');
        $this->streamData = gzopen($this->streamFile, "w9");
        $this->streamFilePath = $filePath;
        $this->streamTimestamp = $timestamp;
        $this->streamFileComment = $fileComment;
        $this->streamFileLength = 0;
    }
    
    public function addStreamData($data) {
        $length = gzwrite($this->streamData, $data, strlen($data));
        if ($length != strlen($data)) {
            print "<p>Length mismatch</p>\n";
        }
        $this->streamFileLength += $length;
        return $length;
    }
    
    /**
     * Close the current stream.
     */
    public function closeStream() {
        if ($this->isFinalized || strlen($this->streamFilePath) == 0) {
            return;
        }
        
        fflush($this->streamData);
        gzclose($this->streamData);
        
        $gzType = "\x08\x00"; // Compression type 8 = deflate
        $gpFlags = "\x02\x00"; // General Purpose bit flags for compression type 8 it is: 0=Normal, 1=Maximum, 2=Fast, 3=super fast compression.

        $file_handle = fopen($this->streamFile, "rb");
        $stats = fstat($file_handle);
        $eof = $stats['size'];
        
        fseek($file_handle, $eof-8);
        $fileCRC32 = fread($file_handle, 4);
        $dataLength = $this->streamFileLength;//$gzl[1];

        $gzLength = $eof-10;
        $eof -= 9;
        
        fseek($file_handle, 10);

        $this->buildZipEntry($this->streamFilePath, $this->streamFileComment, $gpFlags, $gzType, $this->streamTimestamp, $fileCRC32, $gzLength, $dataLength, 32);
        while(!feof($file_handle)) {
            fwrite($this->zipFile, fread($file_handle, $this->streamChunkSize));
        } 
        
        unlink($this->streamFile);
        $this->streamFile = null;
        $this->streamData = null;
        $this->streamFilePath = null;
        $this->streamTimestamp = null;
        $this->streamFileComment = null;
        $this->streamFileLength = 0;
    }

    /**
     * Close the archive.
     * A closed archive can no longer have new files added to it.
     */
    public function finalize() {
        if(!$this->isFinalized) {
            if (strlen($this->streamFilePath) > 0) {
                $this->closeStream();
            }
            $cd = implode("", $this->cdRec);
            
            $cdRec = $cd . $this->endOfCentralDirectory
                    . pack("v", sizeof($this->cdRec))
                    . pack("v", sizeof($this->cdRec))
                    . pack("V", strlen($cd))
                    . pack("V", $this->offset); 
            if (!is_null($this->zipComment)) {
                $cdRec .= pack("v", strlen($this->zipComment)) . $this->zipComment;
            } else {
                $cdRec .= "\x00\x00";
            }
                    
            if (is_null($this->zipFile)) {
                $this->zipData .= $cdRec;
            } else {
                fwrite($this->zipFile, $cdRec);
                fflush($this->zipFile);
            }
            $this->isFinalized = true;
            $cd = null;
            $this->cdRec = null;
        }
    }

    /**
     * Get the handle ressource for the archive zip file.
     * If the zip haven't been finalized yet, this will cause it to become finalized
     *
     * @return zip file handle
     */
    public function getZipFile() {
        if(!$this->isFinalized) {
            $this->finalize();
        }
        if (is_null($this->zipFile)) {
            $this->zipFile = tmpfile();
            fwrite($this->zipFile, $this->zipData);
            $this->zipData = null;
        }
        rewind($this->zipFile);
        return $this->zipFile;
    }

    /**
     * Get the zip file contents
     * If the zip haven't been finalized yet, this will cause it to become finalized
     *
     * @return zip data
     */
    public function getZipData() {
        if(!$this->isFinalized) {
            $this->finalize();
        }
        if (is_null($this->zipFile)) {
            return $this->zipData;
        } else {
            rewind($this->zipFile);
            $filestat = fstat($this->zipFile);
            return fread($this->zipFile, $filestat['size']);
        }
    }

    /**
     * Send the archive as a zip download
     *
     * @param String $fileName The name of the Zip archive, ie. "archive.zip".
     * @return void
     */
    function sendZip($fileName) {
        if(!$this->isFinalized) {
            $this->finalize();
        }

        if (!headers_sent($headerFile, $headerLine) or die("<p><strong>Error:</strong> Unable to send file $fileName. HTML Headers have already been sent from <strong>$headerFile</strong> in line <strong>$headerLine</strong></p>")) {
            if (ob_get_contents() === false or die("\n<p><strong>Error:</strong> Unable to send file <strong>$fileName.epub</strong>. Output buffer contains the following text (typically warnings or errors):<br>" . ob_get_contents() . "</p>")) {
                if (ini_get('zlib.output_compression')) {
                    ini_set('zlib.output_compression', 'Off');
                }

                header('Pragma: public');
                header("Last-Modified: " . gmdate("D, d M Y H:i:s T"));
                header("Expires: 0");
                header("Accept-Ranges: bytes");
                header("Connection: close");
                header("Content-Type: application/zip");
                header('Content-Disposition: attachment; filename="' . $fileName . '";' );
                header("Content-Transfer-Encoding: binary");
                header("Content-Length: ". $this->getArchiveSize());

                if (is_null($this->zipFile)) {
                    echo $this->zipData;
                } else {
                    rewind($this->zipFile);

                    while(!feof($this->zipFile)) {
                        echo fread($this->zipFile, $this->streamChunkSize);
                    } 
                }
            }
        }
    }
    
    public function getArchiveSize() {
        if (is_null($this->zipFile)) {
            return strlen($this->zipData);
        }
        $filestat = fstat($this->zipFile);
        return $filestat['size'];
    }

    /**
     * Calculate the 2 byte dostime used in the zip entries.
     *
     * @param int $timestamp
     * @return 2-byte encoded DOS Date
     */
    private function getDosTime($timestamp = 0) {
        $timestamp = (int)$timestamp;
        $date = ($timestamp == 0 ? getdate() : getDate($timestamp));
        if ($date["year"] >= 1980) {
            return pack("V", (($date["mday"] + ($date["mon"] << 5) + (($date["year"]-1980) << 9)) << 16) |
                (($date["seconds"] >> 1) + ($date["minutes"] << 5) + ($date["hours"] << 11)));
        }
        return "\x00\x00\x00\x00";
    }

    /**
     * Build the Zip file structures
     * 
     * @param unknown_type $filePath
     * @param unknown_type $fileComment
     * @param unknown_type $gpFlags
     * @param unknown_type $gzType
     * @param unknown_type $timestamp
     * @param unknown_type $fileCRC32
     * @param unknown_type $gzLength
     * @param unknown_type $dataLength
     * @param integer $extFileAttr 16 for directories, 32 for files.
     */
    private function buildZipEntry($filePath, $fileComment, $gpFlags, $gzType, $timestamp, $fileCRC32, $gzLength, $dataLength, $extFileAttr) {
        $filePath = str_replace("\\", "/", $filePath);
        $fileCommentLength = (is_null($fileComment) ? 0 : strlen($fileComment));
        $dosTime = $this->getDosTime($timestamp);
        
        $zipEntry  = $this->localFileHeader;
        $zipEntry .= "\x14\x00"; // Version needed to extract
        $zipEntry .= $gpFlags . $gzType . $dosTime. $fileCRC32;
        $zipEntry .= pack("VV", $gzLength, $dataLength);
        $zipEntry .= pack("v", strlen($filePath) ); // File name length
        $zipEntry .= "\x00\x00"; // Extra field length
        $zipEntry .= $filePath; // FileName . Extra field

        if (is_null($this->zipFile)) {
            $this->zipData .= $zipEntry;
        } else {
            fwrite($this->zipFile, $zipEntry);
        }
                
        $cdEntry  = $this->centralFileHeader;
        $cdEntry .= "\x00\x00"; // Made By Version
        $cdEntry .= "\x14\x00"; // Version Needed to extract
        $cdEntry .= $gpFlags . $gzType . $dosTime. $fileCRC32;
        $cdEntry .= pack("VV", $gzLength, $dataLength);
        $cdEntry .= pack("v", strlen($filePath)); // Filename length
        $cdEntry .= "\x00\x00"; // Extra field length
        $cdEntry .= pack("v", $fileCommentLength); // File comment length
        $cdEntry .= "\x00\x00"; // Disk number start
        $cdEntry .= "\x00\x00"; // internal file attributes
        $cdEntry .= pack("V", $extFileAttr ); // External file attributes
        $cdEntry .= pack("V", $this->offset ); // Relative offset of local header
        $cdEntry .= $filePath; // FileName . Extra field
        if (!is_null($fileComment)) {
            $cdEntry .= $fileComment; // Comment
        }

        $this->cdRec[] = $cdEntry;
        $this->offset += strlen($zipEntry) + $gzLength;
    }
}