<?php


/**
 * ZipStream
 * 
 * Streamed, dynamically generated zip archives.
 *
 * @author Paul Duncan <pabs@pablotron.org>
 * @copyright Copyright (C) 2007-2009 Paul Duncan <pabs@pablotron.org>
 * 
 * @author Jonatan Männchen <jonatan@maennchen.ch>
 * @copyright Copyright (C) 2014 Jonatan Männchen <jonatan@maennchen.ch>
 * 
 * @author Jesse Donat <donatj@gmail.com>
 * @copyright Copyright (C) 2014 Jesse Donat <donatj@gmail.com>
 *
 * 
 *
 * Requirements:
 *
 * * PHP version 5.1.2 or newer.
 *
 * Usage:
 *
 * Streaming zip archives is a simple, three-step process:
 *
 * 1.  Create the zip stream:
 *
 *     $zip = new Photography_Management_Base_ZipStream('example.zip');
 *
 * 2.  Add one or more files to the archive:
 *
 *      * add first file
 *     $data = file_get_contents('some_file.gif');
 *     $zip->addFile('some_file.gif', $data);
 *
 *      * add second file
 *     $data = file_get_contents('some_file.gif');
 *     $zip->addFile('another_file.png', $data);
 *
 * 3.  Finish the zip stream:
 *
 *     $zip->finish();
 *
 * You can also add an archive comment, add comments to individual files,
 * and adjust the timestamp of files.  See the API documentation for each
 * method below for additional information.
 *
 * Example:
 *
 *   // create a new zip stream object
 *   $zip = new Photography_Management_Base_ZipStream('some_files.zip');
 *
 *   // list of local files
 *   $files = array('foo.txt', 'bar.jpg');
 *
 *   // read and add each file to the archive
 *   foreach ($files as $path)
 *     $zip->addFile($path, file_get_contents($path));
 * 
 *   // write archive footer to stream
 *   $zip->finish();
 */
class Photography_Management_Base_ZipStream {
	const VERSION = '0.3.0';

	const METHOD_STORE = 'store';
	const METHOD_DEFLATE = 'deflate';

	const OPTION_LARGE_FILE_SIZE      = 'large_file_size';
	const OPTION_LARGE_FILE_METHOD    = 'large_file_method';
	const OPTION_SEND_HTTP_HEADERS    = 'send_http_headers';
	const OPTION_HTTP_HEADER_CALLBACK = 'http_header_callback';
	const OPTION_OUTPUT_STREAM        = 'output_stream';
	const OPTION_CONTENT_TYPE         = 'content_type';
	const OPTION_CONTENT_DISPOSITION  = 'content_disposition';
	
	/**
	 * Global Options
	 *
	 * @var array
	*/
	public $opt = array();
	
	/**
	 * @var array
	 */
	public $files = array();
	
	/**
	 * @var integer
	 */
	public $cdr_ofs = 0;
	
	/**
	 * @var integer
	 */
	public $ofs = 0;

	/**
	 * @var bool
	 */
	protected $need_headers;

	/**
	 * @var null|String
	 */
	protected $output_name;
	
	/**
	 * Create a new Photography_Management_Base_ZipStream object.
	 *
	 * Parameters:
	 *
	 * @param String $name - Name of output file (optional).
	 * @param array $opt  - Hash of archive options (optional, see "Archive Options"
	 *           below).
	 *
	 * Archive Options:
	 *
	 *   comment             - Comment for this archive.
	 *   content_type        - HTTP Content-Type.  Defaults to 'application/x-zip'.
	 *   content_disposition - HTTP Content-Disposition.  Defaults to 
	 *                         'attachment; filename=\"FILENAME\"', where
	 *                         FILENAME is the specified filename.
	 *   large_file_size     - Size, in bytes, of the largest file to try
	 *                         and load into memory (used by
	 *                         addFileFromPath()).  Large files may also
	 *                         be compressed differently; see the
	 *                         'large_file_method' option.
	 *   large_file_method   - How to handle large files.  Legal values are
	 *                         'store' (the default), or 'deflate'.  Store
	 *                         sends the file raw and is significantly
	 *                         faster, while 'deflate' compresses the file
	 *                         and is much, much slower.  Note that deflate
	 *                         must compress the file twice and extremely
	 *                         slow.
	 *   sendHttpHeaders   - Boolean indicating whether or not to send
	 *                         the HTTP headers for this file.
	 *
	 * Note that content_type and content_disposition do nothing if you are
	 * not sending HTTP headers.
	 *
	 * Large File Support:
	 *
	 * By default, the method addFileFromPath() will send send files
	 * larger than 20 megabytes along raw rather than attempting to
	 * compress them.  You can change both the maximum size and the
	 * compression behavior using the large_file_* options above, with the
	 * following caveats:
	 *
	 * * For "small" files (e.g. files smaller than large_file_size), the
	 *   memory use can be up to twice that of the actual file.  In other
	 *   words, adding a 10 megabyte file to the archive could potentially
	 *   occupty 20 megabytes of memory.
	 *
	 * * Enabling compression on large files (e.g. files larger than
	 *   large_file_size) is extremely slow, because Photography_Management_Base_ZipStream has to pass
	 *   over the large file once to calculate header information, and then
	 *   again to compress and send the actual data.
	 *
	 * Examples:
	 *
	 *   // create a new zip file named 'foo.zip'
	 *   $zip = new Photography_Management_Base_ZipStream('foo.zip');
	 *
	 *   // create a new zip file named 'bar.zip' with a comment
	 *   $zip = new Photography_Management_Base_ZipStream('bar.zip', array(
	 *     'comment' => 'this is a comment for the zip file.',
	 *   ));
	 *
	 * Notes:
	 *
	 * If you do not set a filename, then this library _DOES NOT_ send HTTP
	 * headers by default.  This behavior is to allow software to send its
	 * own headers (including the filename), and still use this library.
	 */
	public function __construct($name = null, $opt = array()) {

		$defaults = array(
			// set large file defaults: size = 20 megabytes
			self::OPTION_LARGE_FILE_SIZE      => 1 * 1024 * 1024,
			self::OPTION_LARGE_FILE_METHOD    => self::METHOD_STORE,
			self::OPTION_SEND_HTTP_HEADERS    => false,
			self::OPTION_HTTP_HEADER_CALLBACK => 'header',
		);
		
		// merge and save options
		$this->opt = array_merge($defaults, $opt);
		
		if (!isset($this->opt[self::OPTION_OUTPUT_STREAM])) {
			$this->opt[self::OPTION_OUTPUT_STREAM] = fopen('php://output', 'w');
		}
		
		$this->output_name  = $name;
		$this->need_headers = $name || $this->opt[self::OPTION_SEND_HTTP_HEADERS];
	}
	
	/**
	 * addFile
	 * 
	 * add a file to the archive
	 *   
	 *  @param String $name - path of file in archive (including directory).
	 *  @param String $data - contents of file
	 *  @param array $opt  - Hash of options for file (optional, see "File Options"
	 *          below).  
	 *
	 * File Options: 
	 *  time     - Last-modified timestamp (seconds since the epoch) of
	 *             this file.  Defaults to the current time.
	 *  comment  - Comment related to this file.
	 *
	 * Examples:
	 *
	 *   // add a file named 'foo.txt'
	 *   $data = file_get_contents('foo.txt');
	 *   $zip->addFile('foo.txt', $data);
	 * 
	 *   // add a file named 'bar.jpg' with a comment and a last-modified
	 *   // time of two hours ago
	 *   $data = file_get_contents('bar.jpg');
	 *   $zip->addFile('bar.jpg', $data, array(
	 *     'time'    => time() - 2 * 3600,
	 *     'comment' => 'this is a comment about bar.jpg',
	 *   ));
	 */
	public function addFile($name, $data, $opt = array()) {
		// compress data
		$zdata = gzdeflate($data);
		
		// calculate header attributes
		$crc  = crc32($data);
		$zlen = strlen($zdata);
		$len  = strlen($data);
		$meth = 0x08;
		
		// send file header
		$this->addFileHeader($name, $opt, $meth, $crc, $zlen, $len);
		
		// print data
		$this->send($zdata);
	}
	
	/**
	 * addFileFromPath
	 * 
	 * add a file at path to the archive.
	 *
	 * Note that large files may be compresed differently than smaller
	 * files; see the "Large File Support" section above for more
	 * information.
	 *   
	 *  @param String $name - name of file in archive (including directory path).
	 *  @param String $path - path to file on disk (note: paths should be encoded using
	 *          UNIX-style forward slashes -- e.g '/path/to/some/file').
	 *  @param array $opt  - Hash of options for file (optional, see "File Options"
	 *          below).  
	 *
	 * File Options: 
	 *  time     - Last-modified timestamp (seconds since the epoch) of
	 *             this file.  Defaults to the current time.
	 *  comment  - Comment related to this file.
	 *
	 * Examples:
	 *
	 *   // add a file named 'foo.txt' from the local file '/tmp/foo.txt'
	 *   $zip->addFileFromPath('foo.txt', '/tmp/foo.txt');
	 * 
	 *   // add a file named 'bigfile.rar' from the local file
	 *   // '/usr/share/bigfile.rar' with a comment and a last-modified
	 *   // time of two hours ago
	 *   $path = '/usr/share/bigfile.rar';
	 *   $zip->addFileFromPath('bigfile.rar', $path, array(
	 *     'time'    => time() - 2 * 3600,
	 *     'comment' => 'this is a comment about bar.jpg',
	 *   ));
	 * 
	 * @return void
	 *
	 */
	public function addFileFromPath($name, $path, $opt = array()) {
		if(!is_readable($path)) {
			if(!file_exists($path)) {
//				throw new FileNotFoundException($path);
			}
//			throw new FileNotReadableException($path);
			return;
		}
		if ($this->isLargeFile($path)) {
			// file is too large to be read into memory; add progressively
			$this->addLargeFile($name, $path, $opt);
		} else {
			// file is small enough to read into memory; read file contents and
			// handle with addFile()
			$data = file_get_contents($path);
			$this->addFile($name, $data, $opt);
		}
	}
	
	/**
	 * addFile_from_stream
	 * 
	 * dds an open stream to the archive uncompressed
	 *
	 * @param String $name - path of file in archive (including directory).
	 * @param Resource $stream - contents of file as a stream resource
	 * @param array $opt - Hash of options for file (optional, see "File Options" below).
	 * 
	 * File Options:
	 *  time     - Last-modified timestamp (seconds since the epoch) of
	 *             this file.  Defaults to the current time.
	 *  comment  - Comment related to this file.
	 *
	 * Examples:
	 *
	 *   // create a temporary file stream and write text to it
	 *   $fp = tmpfile();
	 *   fwrite($fp, 'The quick brown fox jumped over the lazy dog.');
	 *
	 *   // add a file named 'streamfile.txt' from the content of the stream
	 *   $x->addFile_from_stream('streamfile.txt', $fp);
	 *
	 * @return void
	 */
	public function addFileFromStream($name, $stream, $opt = array()) {
		$block_size = 1048576; // process in 1 megabyte chunks
		$algo       = 'crc32b';
		$meth       = 0x00;
		
		// calculate header attributes
		fseek($stream, 0, SEEK_END);
		$zlen = $len = ftell($stream);
		
		rewind($stream);
		$hash_ctx = hash_init($algo);
		hash_update_stream($hash_ctx, $stream);

		$crc = hexdec(hash_final($hash_ctx));
		
		// send file header
		$this->addFileHeader($name, $opt, $meth, $crc, $zlen, $len);
		
		rewind($stream);
		while (!feof($stream)) {
			$data = fread($stream, $block_size);
			// send data
			$this->send($data);
		}
	}
	
	/**
	 * finish
	 * 
	 * Write zip footer to stream.
	 * 
	 *  Example:
	 *
	 *   // add a list of files to the archive
	 *   $files = array('foo.txt', 'bar.jpg');
	 *   foreach ($files as $path)
	 *     $zip->addFile($path, file_get_contents($path));
	 * 
	 *   // write footer to stream
	 *   $zip->finish();
	 * 
	 * @return void
	 */
	public function finish() {
		// add trailing cdr record
		$this->addCdr($this->opt);
		$this->clear();
	}

	/**
	 * Create and send zip header for this file.
	 *
	 * @param String  $name
	 * @param Array   $opt
	 * @param Integer $meth
	 * @param string  $crc
	 * @param Integer $zlen
	 * @param Integer $len
	 * @return void
	 */
	protected function addFileHeader($name, $opt, $meth, $crc, $zlen, $len) {
		// strip leading slashes from file name
		// (fixes bug in windows archive viewer)
		$name = preg_replace('/^\\/+/', '', $name);
		
		// calculate name length
		$nlen = strlen($name);
		
		// create dos timestamp
		$opt['time'] = isset($opt['time']) && !empty($opt['time']) ? $opt['time'] : time();
		$dts         = $this->dostime($opt['time']);
		
		// build file header
		$fields = array( // (from V.A of APPNOTE.TXT)
			array(
				'V',
				0x04034b50
			), // local file header signature

			//array('v', (6 << 8) + 3),   // version needed to extract
			array(
				'v',
				0x000A
			), // version needed to extract
			//FIXED as mentioned in http://linlog.skepticats.com/entries/2012/02/Streaming_ZIP_files_in_PHP.php
			//and http://stackoverflow.com/questions/5573211/dynamically-created-zip-files-by-zipstream-in-php-wont-open-in-osx

			array(
				'v',
				0x00
			), // general purpose bit flag
			array(
				'v',
				$meth
			), // compresion method (deflate or store)
			array(
				'V',
				$dts
			), // dos timestamp
			array(
				'V',
				$crc
			), // crc32 of data
			array(
				'V',
				$zlen
			), // compressed data length
			array(
				'V',
				$len
			), // uncompressed data length
			array(
				'v',
				$nlen
			), // filename length
			array(
				'v',
				0
			) // extra data len
		);
		
		// pack fields and calculate "total" length
		$ret     = $this->packFields($fields);
		$cdr_len = strlen($ret) + $nlen + $zlen;
		
		// print header and filename
		$this->send($ret . $name);
		
		// add to central directory record and increment offset
		$this->addToCdr($name, $opt, $meth, $crc, $zlen, $len, $cdr_len);
	}
	
	/**
	 * Add a large file from the given path.
	 *
	 * @param String $name
	 * @param String $path
	 * @param array $opt
	 * @return void
	 *
	 */
	protected function addLargeFile($name, $path, $opt = array()) {
		$st         = stat($path);
		$block_size = 1024 * 8;//1048576; // process in 1 megabyte chunks
		$algo       = 'crc32b';
		
		// calculate header attributes
		$zlen = $len = $st['size'];
		
		$meth_str = $this->opt[self::OPTION_LARGE_FILE_METHOD];
		if ($meth_str == self::METHOD_STORE) {
			// store method
			$meth = 0x00;
			$crc = hexdec(hash_file($algo, $path));
		} elseif ($meth_str == self::METHOD_DEFLATE) {
			// deflate method
			$meth = 0x08;
			
			// open file, calculate crc and compressed file length
			$fh       = fopen($path, 'rb');
			$hash_ctx = hash_init($algo);
			$zlen     = 0;
			
			// read each block, update crc and zlen
			while (!feof($fh)) {
				$data = fread($fh, $block_size);
				hash_update($hash_ctx, $data);
			}

			rewind($fh);
			$filter = stream_filter_append($fh, 'zlib.deflate', STREAM_FILTER_READ, 6);

			while (!feof($fh)) {
				$data = fread($fh, $block_size);
				$zlen += strlen($data);
			}

			stream_filter_remove($filter);
			
			// close file and finalize crc
			fclose($fh);

			$crc = hexdec(hash_final($hash_ctx));
		} else {
//			throw new InvalidOptionException('large_file_method', array(self::METHOD_STORE, self::METHOD_DEFLATE), $meth_str);
			return;
		}
		
		// send file header
		$this->addFileHeader($name, $opt, $meth, $crc, $zlen, $len);
		
		// open input file
		$fh = fopen($path, 'rb');

		if ($meth_str == self::METHOD_DEFLATE) {
			$filter = stream_filter_append($fh, 'zlib.deflate', STREAM_FILTER_READ, 6);
		}
		
		// send file blocks
		while (!feof($fh)) {
			$data = fread($fh, $block_size);

			// send data
			$this->send($data);
		}

		if (isset($filter) && is_resource($filter)) {
			stream_filter_remove($filter);
		}
		
		// close input file
		fclose($fh);
	}
	
	/**
	 * Is this file larger than large_file_size?
	 *
	 * @param string $path
	 * @return Boolean
	 */
	protected function isLargeFile($path) {
		$st = stat($path);
		return ($this->opt[self::OPTION_LARGE_FILE_SIZE] > 0) && ($st['size'] > $this->opt[self::OPTION_LARGE_FILE_SIZE]);
	}
	
	/**
	 * Save file attributes for trailing CDR record.
	 *
	 * @param String  $name
	 * @param Array   $opt
	 * @param Integer $meth
	 * @param string  $crc
	 * @param Integer $zlen
	 * @param Integer $len
	 * @param Integer $rec_len
	 * @return void
	 * @return void
	 */
	private function addToCdr($name, $opt, $meth, $crc, $zlen, $len, $rec_len) {
		$this->files[] = array(
			$name,
			$opt,
			$meth,
			$crc,
			$zlen,
			$len,
			$this->ofs
		);
		$this->ofs += $rec_len;
	}
	
	/**
	 * Send CDR record for specified file.
	 * 
	 * @param array $args
	 * @return void
	 */
	protected function addCdrFile($args) {
		list($name, $opt, $meth, $crc, $zlen, $len, $ofs) = $args;
		
		// get attributes
		$comment = isset($opt['comment']) && !empty($opt['comment']) ? $opt['comment'] : '';
		
		// get dos timestamp
		$dts = $this->dostime($opt['time']);
		
		$fields = array( // (from V,F of APPNOTE.TXT)
			array(
				'V',
				0x02014b50
			), // central file header signature
			array(
				'v',
				(6 << 8) + 3
			), // version made by
			array(
				'v',
				(6 << 8) + 3
			), // version needed to extract
			array(
				'v',
				0x00
			), // general purpose bit flag
			array(
				'v',
				$meth
			), // compresion method (deflate or store)
			array(
				'V',
				$dts
			), // dos timestamp
			array(
				'V',
				$crc
			), // crc32 of data
			array(
				'V',
				$zlen
			), // compressed data length
			array(
				'V',
				$len
			), // uncompressed data length
			array(
				'v',
				strlen($name)
			), // filename length
			array(
				'v',
				0
			), // extra data len
			array(
				'v',
				strlen($comment)
			), // file comment length
			array(
				'v',
				0
			), // disk number start
			array(
				'v',
				0
			), // internal file attributes
			array(
				'V',
				32
			), // external file attributes
			array(
				'V',
				$ofs
			) // relative offset of local header
		);
		
		// pack fields, then append name and comment
		$ret = $this->packFields($fields) . $name . $comment;
		
		$this->send($ret);
		
		// increment cdr offset
		$this->cdr_ofs += strlen($ret);
	}
	
	/**
	 * Send CDR EOF (Central Directory Record End-of-File) record.
	 * 
	 * @param array $opt
	 * @return void
	 */
	protected function addCdrEof($opt = null) {
		$num     = count($this->files);
		$cdr_len = $this->cdr_ofs;
		$cdr_ofs = $this->ofs;
		
		// grab comment (if specified)
		$comment = '';
		if ($opt && isset($opt['comment'])) {
			$comment = $opt['comment'];
		}
		
		$fields = array( // (from V,F of APPNOTE.TXT)
			array(
				'V',
				0x06054b50
			), // end of central file header signature
			array(
				'v',
				0x00
			), // this disk number
			array(
				'v',
				0x00
			), // number of disk with cdr
			array(
				'v',
				$num
			), // number of entries in the cdr on this disk
			array(
				'v',
				$num
			), // number of entries in the cdr
			array(
				'V',
				$cdr_len
			), // cdr size
			array(
				'V',
				$cdr_ofs
			), // cdr ofs
			array(
				'v',
				strlen($comment)
			) // zip file comment length
		);
		
		$ret = $this->packFields($fields) . $comment;
		$this->send($ret);
	}
	
	/**
	 * Add CDR (Central Directory Record) footer.
	 * 
	 * @param array $opt
	 * @return void
	 */
	protected function addCdr($opt = null) {
		foreach ($this->files as $file)
			$this->addCdrFile($file);
		$this->addCdrEof($opt);
	}
	
	/**
	 * Clear all internal variables.  Note that the stream object is not
	 * usable after this.
	 * 
	 * @return void
	 */
	protected function clear() {
		$this->files   = array();
		$this->ofs     = 0;
		$this->cdr_ofs = 0;
		$this->opt     = array();
	}
	
	/**
	 *  Send HTTP headers for this stream.
	 * 
	 * @return void
	 */
	protected function sendHttpHeaders() {
		// grab options
		$opt = $this->opt;
		
		// grab content type from options
		$content_type = 'application/x-zip';
		if (isset($opt[self::OPTION_CONTENT_TYPE])) {
			$content_type = $this->opt[self::OPTION_CONTENT_TYPE];
		}
		
		// grab content disposition 
		$disposition = 'attachment';
		if (isset($opt[self::OPTION_CONTENT_DISPOSITION])) {
			$disposition = $opt[self::OPTION_CONTENT_DISPOSITION];
		}
		
		if ($this->output_name) {
			$disposition .= "; filename=\"{$this->output_name}\"";
		}
		
		$headers = array(
			'Content-Type' => $content_type,
			'Content-Disposition' => $disposition,
			'Pragma' => 'public',
			'Cache-Control' => 'public, must-revalidate',
			'Content-Transfer-Encoding' => 'binary'
		);
		
		$call = $this->opt[self::OPTION_HTTP_HEADER_CALLBACK];
		foreach ($headers as $key => $val)
			$call("$key: $val");
	}
	
	/**
	 * Send string, sending HTTP headers if necessary.
	 * 
	 * @param String $str
	 * @return void
	 */
	protected function send($str) {
		if ($this->need_headers) {
			$this->sendHttpHeaders();
		}
		$this->need_headers = false;
		
		fwrite($this->opt[self::OPTION_OUTPUT_STREAM], $str);
	}
	
	/**
	 * Convert a UNIX timestamp to a DOS timestamp.
	 * 
	 * @param Integer $when
	 * @return Integer DOS Timestamp
	 */
	protected final function dostime($when) {
		// get date array for timestamp
		$d = getdate($when);
		
		// set lower-bound on dates
		if ($d['year'] < 1980) {
			$d = array(
				'year' => 1980,
				'mon' => 1,
				'mday' => 1,
				'hours' => 0,
				'minutes' => 0,
				'seconds' => 0
			);
		}
		
		// remove extra years from 1980
		$d['year'] -= 1980;
		
		// return date string
		return ($d['year'] << 25) | ($d['mon'] << 21) | ($d['mday'] << 16) | ($d['hours'] << 11) | ($d['minutes'] << 5) | ($d['seconds'] >> 1);
	}
	
	/**
	 * Create a format string and argument list for pack(), then call
	 * pack() and return the result.
	 * 
	 * @param array $fields
	 * @return string
	 */
	protected function packFields($fields) {
		list($fmt, $args) = array(
			'',
			array()
		);
		
		// populate format string and argument list
		foreach ($fields as $field) {
			$fmt .= $field[0];
			$args[] = $field[1];
		}
		
		// prepend format string to argument list
		array_unshift($args, $fmt);
		
		// build output string from header and compressed data
		return call_user_func_array('pack', $args);
	}
}
