barracudanetworks/ArchiveStream-php

View on GitHub
src/Archive.php

Summary

Maintainability
A
2 hrs
Test Coverage
<?php
namespace Barracuda\ArchiveStream;

use GMP;

use Barracuda\ArchiveStream\TarArchive as Tar;
use Barracuda\ArchiveStream\ZipArchive as Zip;

/**
 * A streaming archive object.
 */
class Archive
{
    /**
     * Whether to use the specified base path or not for files in the archive.
     * @var bool
     */
    protected $use_container_dir = false;

    /**
     * Base path for files added to the archive.
     * @var string
     */
    protected $container_dir_name = '';

    /**
     * List of errors encountered while generating the archive.
     * @var string
     */
    private $errors = array();

    /**
     * Filename for the error log which will be placed inside the archive.
     * @var string
     */
    private $error_log_filename = 'archive_errors.log';

    /**
     * Message to place at the top of the error log.
     * @var string
     */
    private $error_header_text = 'The following errors were encountered while generating this archive:';

    /**
     * Block size to process files in. Defaults to 1M
     * @var int
     */
    protected $block_size = 1048576;

    /**
     * Create a new ArchiveStream object.
     *
     * @param string   $name          The name of the resulting archive (optional).
     * @param array    $opt           Hash of archive options (see archive options in readme).
     * @param string   $base_path     An optional base path for files to be named under.
     * @param resource $output_stream Output stream for archive contents.
     */
    public function __construct(
        $name = null,
        array $opt = array(),
        $base_path = null,
        $output_stream = null
    )
    {        
                if ($output_stream === null) {
                    // Output stream for cli and web server
                    $output_stream = fopen('php://output', 'w');
                }

            $this->output_stream = $output_stream;

        // save options
        $this->opt = $opt;

        // if a $base_path was passed set the protected property with that value, otherwise leave it empty
        $this->container_dir_name = isset($base_path) ? $base_path . '/' : '';

        // set large file defaults: size = 20 megabytes, method = store
        if (!isset($this->opt['large_file_size']))
        {
            $this->opt['large_file_size'] = 20 * 1024 * 1024;
        }

        if (!isset($this->opt['large_files_only']))
        {
            $this->opt['large_files_only'] = false;
        }

        $this->output_name = $name;
        if ($name || isset($opt['send_http_headers']))
        {
            $this->need_headers = true;
        }

        // turn off output buffering
        while (ob_get_level() > 0)
        {
            // throw away any output left in the buffer
            ob_end_clean();
        }
    }

    /**
     * Create instance based on useragent string
     *
     * @param string   $base_filename A name for the resulting archive (without an extension).
     * @param array    $opt           Map of archive options (see above for list).
     * @param resource $output_stream Output stream for archive contents.
     * @return Zip|Tar for either zip or tar
     */
    public static function instance_by_useragent(
        $base_filename = null,
        array $opt = array(),
        $output_stream = null
    )
    {
                if ($output_stream === null) {
                    // Output stream for cli and web server
                    $output_stream = fopen('php://output', 'w');
                }

        $user_agent = (isset($_SERVER['HTTP_USER_AGENT']) ? strtolower($_SERVER['HTTP_USER_AGENT']) : '');

        // detect windows and use zip
        if (strpos($user_agent, 'windows') !== false)
        {
            $filename = (($base_filename === null) ? null : $base_filename . '.zip');
            return new Zip($filename, $opt, $base_filename, $output_stream);
        }
        // fallback to tar
        else
        {
            $filename = (($base_filename === null) ? null : $base_filename . '.tar');
            return new Tar($filename, $opt, $base_filename, $output_stream);
        }
    }

    /**
     * Add file to the archive
     *
     * Parameters:
     *
     * @param string $name Path of file in the archive (including directory).
     * @param string $data Contents of the file.
     * @param array  $opt  Map of file options (see above for list).
     * @return void
     */
    public function add_file($name, $data, array $opt = array())
    {
        // calculate header attributes
        $this->meth_str = 'deflate';
        $meth = 0x08;

        // send file header
        $this->init_file_stream_transfer($name, strlen($data), $opt, $meth);

        // send data
        $this->stream_file_part($data, $single_part = true);

        // complete the file stream
        $this->complete_file_stream();
    }

    /**
     * Add file by path
     *
     * @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  Map of file options (see above for list).
     * @return void
     */
    public function add_file_from_path($name, $path, array $opt = array())
    {
        if ($this->opt['large_files_only'] || $this->is_large_file($path))
        {
            // file is too large to be read into memory; add progressively
            $this->add_large_file($name, $path, $opt);
        }
        else
        {
            // file is small enough to read into memory; read file contents and
            // handle with add_file()
            $data = file_get_contents($path);
            $this->add_file($name, $data, $opt);
        }
    }

    /**
     * Log an error to be added to the error log in the archive.
     *
     * @param string $message Error text to add to the log file.
     * @return void
     */
    public function push_error($message)
    {
        $this->errors[] = (string) $message;
    }

    /**
     * Set whether or not all elements in the archive will be placed within one container directory.
     *
     * @param bool $bool True to use contaner directory, false to prevent using one. Defaults to false.
     * @return void
     */
    public function set_use_container_dir($bool = false)
    {
        $this->use_container_dir = (bool) $bool;
    }

    /**
     * Set the name filename for the error log file when it's added to the archive
     *
     * @param string $name Filename for the error log.
     * @return void
     */
    public function set_error_log_filename($name)
    {
        if (isset($name))
        {
            $this->error_log_filename = (string) $name;
        }
    }

    /**
     * Set the first line of text in the error log file
     *
     * @param string $msg Message to display on the first line of the error log file.
     * @return void
     */
    public function set_error_header_text($msg)
    {
        if (isset($msg))
        {
            $this->error_header_text = (string) $msg;
        }
    }

    /***************************
     * PRIVATE UTILITY METHODS *
     ***************************/

    /**
     * Add a large file from the given path
     *
     * @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  Map of file options (see above for list).
     * @return void
     */
    protected function add_large_file($name, $path, array $opt = array())
    {
        // send file header
        $this->init_file_stream_transfer($name, filesize($path), $opt);

        // open input file
        $fh = fopen($path, 'rb');

        // send file blocks
        while ($data = fread($fh, $this->block_size))
        {
            // send data
            $this->stream_file_part($data);
        }

        // close input file
        fclose($fh);

        // complete the file stream
        $this->complete_file_stream();
    }

    /**
     * Is this file larger than large_file_size?
     *
     * @param string $path Path to file on disk.
     * @return bool True if large, false if small.
     */
    protected function is_large_file($path)
    {
        $st = stat($path);
        return ($this->opt['large_file_size'] > 0) && ($st['size'] > $this->opt['large_file_size']);
    }

    /**
     * Send HTTP headers for this stream.
     *
     * @return void
     */
    private function send_http_headers()
    {
        // grab options
        $opt = $this->opt;

        // grab content type from options
        if (isset($opt['content_type']))
        {
            $content_type = $opt['content_type'];
        }
        else
        {
            $content_type = 'application/x-zip';
        }

        // grab content type encoding from options and append to the content type option
        if (isset($opt['content_type_encoding']))
        {
            $content_type .= '; charset=' . $opt['content_type_encoding'];
        }

        // grab content disposition
        $disposition = 'attachment';
        if (isset($opt['content_disposition']))
        {
            $disposition = $opt['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',
        );

        foreach ($headers as $key => $val)
        {
            header("$key: $val");
        }
    }

    /**
     * Send string, sending HTTP headers if necessary.
     *
     * @param string $data Data to send.
     * @return void
     */
    protected function send($data)
    {
        if ($this->need_headers)
        {
            $this->send_http_headers();
        }

        $this->need_headers = false;

        do
        {
            $result = fwrite($this->output_stream, $data);
            $data = substr($data, $result);
            fflush($this->output_stream);
        } while ($data && $result !== false);
    }

    /**
     * If errors were encountered, add an error log file to the end of the archive
     * @return void
     */
    public function add_error_log()
    {
        if (!empty($this->errors))
        {
            $msg = $this->error_header_text;
            foreach ($this->errors as $err)
            {
                $msg .= "\r\n\r\n" . $err;
            }

            // stash current value so it can be reset later
            $temp = $this->use_container_dir;

            // set to false to put the error log file in the root instead of the container directory, if we're using one
            $this->use_container_dir = false;

            $this->add_file($this->error_log_filename, $msg);

            // reset to original value and dump the temp variable
            $this->use_container_dir = $temp;
            unset($temp);
        }
    }

    /**
     * Convert a UNIX timestamp to a DOS timestamp.
     *
     * @param int $when Unix timestamp.
     * @return string DOS timestamp
     */
    protected function dostime($when = 0)
    {
        // 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);
    }

    /**
     * Split a 64-bit integer to two 32-bit integers.
     *
     * @param mixed $value Integer or GMP resource.
     * @return array Containing high and low 32-bit integers.
     */
    protected function int64_split($value)
    {
        // gmp
        if (is_resource($value) || $value instanceof GMP)
        {
            $hex  = str_pad(gmp_strval($value, 16), 16, '0', STR_PAD_LEFT);

            $high = $this->gmp_convert(substr($hex, 0, 8), 16, 10);
            $low  = $this->gmp_convert(substr($hex, 8, 8), 16, 10);
        }
        // int
        else
        {
            $left  = 0xffffffff00000000;
            $right = 0x00000000ffffffff;

            $high = ($value & $left) >>32;
            $low  = $value & $right;
        }

        return array($low, $high);
    }

    /**
     * Create a format string and argument list for pack(), then call pack() and return the result.
     *
     * @param array $fields Key is format string and the value is the data to pack.
     * @return string Binary packed data returned from pack().
     */
    protected function pack_fields(array $fields)
    {
        $fmt = '';
        $args = 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);
    }

    /**
     * Convert a number between bases via GMP.
     *
     * @param int $num    Number to convert.
     * @param int $base_a Base to convert from.
     * @param int $base_b Base to convert to.
     * @return string Number in string format.
     */
    private function gmp_convert($num, $base_a, $base_b)
    {
        $gmp_num = gmp_init($num, $base_a);

        if (!(is_resource($gmp_num) || $gmp_num instanceof GMP))
        {
            // FIXME: Really? We just die here? Can we detect GMP in __constructor() instead maybe?
            die("gmp_convert could not convert [$num] from base [$base_a] to base [$base_b]");
        }

        return gmp_strval($gmp_num, $base_b);
    }
}