<?php
/**
 * YANA library
 *
 * Software:  Yana PHP-Framework
 * Version:   {VERSION} - {DATE}
 * License:   GNU GPL  http://www.gnu.org/licenses/
 *
 * This program: can be redistributed and/or modified under the
 * terms of the GNU General Public License as published by the
 * Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see http://www.gnu.org/licenses/.
 *
 * This notice MAY NOT be removed.
 *
 * @package  yana
 * @license  http://www.gnu.org/licenses/gpl.txt
 */

/**
 * read binary large objects (blobs) from database
 *
 * Example of usage:
 * <code>
 * $db = Yana::connect('foo');
 * $id = $db->select('foo.1.foo_file');
 *
 * $file = new DbBlob($id);
 * $file->read();
 *
 * // output file to screen
 * print $file->getContent();
 * // copy file to some destination
 * $file->copy('foo/bar.dat');
 * </code>
 *
 * @access      public
 * @package     yana
 * @subpackage  database
 * @since       2.9.2
 */
class DbBlob extends FileReadonly
{
    /**#@+
     * @access  private
     */

    /** @var int    */ private $size = 0;
    /** @var string */ private $type = 'application/unknown';

    /**#@-*/

    /**
     * @var     string
     * @access  protected
     * @static
     * @ignore
     */
    protected static $blobDir = null;

    /**
     * @var     array
     * @access  private
     * @static
     * @ignore
     */
    private static $files = null;

    /**
     * read file contents
     *
     * Tries to read the blob contents, decompresses it and caches the file attributes like
     * type, size and original filename.
     *
     * An message is issued
     *
     * @access  public
     * @throws  NotReadableException  if the blob is not valid
     * @throws  NotFoundException     if the blob does not exist
     */
    public function read()
    {
        $source = $this->getPath();

        if (!is_file($source)) {
            $message = "The file '{$source}' does not exist (database: blob not found).";
            throw new NotFoundException($message, E_USER_NOTICE);
        }
        $this->content = array();

        if (!preg_match('/\.gz$/', $source)) {
            $message = "The source '{$source}' is not a valid database blob.";
            throw new NotReadableException($message, E_USER_WARNING);
        }
        $i = 0;
        $gz = gzopen($source, 'r');
        while (!gzeof($gz))
        {
            $buffer = gzgets($gz, 4096);
            switch ($i)
            {
                case 0:
                    $buffer = trim($buffer);
                    if (preg_match('/^[\w\.\d\-\_]+$/s', $buffer)) {
                        $this->path = $buffer;
                    } else {
                        trigger_error("Invalid file path: '{$buffer}'.", E_USER_NOTICE);
                    }
                break;

                case 1:
                    $buffer = trim($buffer);
                    if (is_numeric($buffer)) {
                        $this->size = $buffer;
                    } else {
                        trigger_error("Invalid filesize: '{$buffer}'.", E_USER_NOTICE);
                    }
                break;

                case 2:
                    $buffer = trim($buffer);
                    if (preg_match('/^\w+\/[\w-]+$/s', $buffer)) {
                        $this->type = $buffer;
                    } else {
                        trigger_error("Invalid MIME-Type: '{$buffer}'.", E_USER_NOTICE);
                    }
                break;

                default:
                    $this->content[] = $buffer;
                break;

            }
            $i++;
        }
        gzclose($gz);
    }

    /**
     * get mime-type of this file
     *
     * Returns bool(false) on error.
     *
     * @access  public
     * @return  string|bool(false)
     */
    public function getMimeType()
    {
        if (isset($this->type)) {
            return $this->type;
        } else {
            return false;
        }
    }

    /**
     * get size of this file
     *
     * Returns the size of the file in bytes (from cached value).
     * If an error occurs, bool(false) is returned.
     *
     * @access  public
     * @return  int
     */
    public function getFilesize()
    {
        if (isset($this->size)) {
            return $this->size;
        } else {
            return false;
        }
    }

    /**
     * read the current file id from the session vars
     *
     * Returns the database id of a selected file.
     * This function is supposed to be used in combination
     * with the form-generator.
     *
     * Example of usage:
     * <code>
     * $dbBlob = new DbBlob(DbBlob::getFileId());
     * </code>
     *
     * Returns bool(false) on error.
     *
     * @access  public
     * @param   int   $i         index number in files list, of the file to get
     * @param   bool  $fullsize  show full size or thumb-nail (images only)
     * @static
     * @return  string|bool(false)
     * @since   2.9.3
     * @throws  InvalidArgumentException  if file with index $i does not exist
     */
    public static function getFileId($i = null, $fullsize = null)
    {
        assert('is_null($i) || is_string($i); // Wrong type for argument 1. Integer expected');
        assert('is_null($fullsize) || is_bool($fullsize); // Wrong type for argument 2. Boolean expected');

        /* get default arguments */
        if (is_null($i)) {
            $i = Yana::getRequestVar('target');
            if ($i === false || !is_numeric($i)) {
                return false;
            }
            $i = (int) $i;
        }

        if (is_null($fullsize)) {
            $fullsize = (bool) Yana::getRequestVar('fullsize');
        }

        /* check arguments */
        if (!isset($_SESSION['filelist'][$i])) {
            throw new InvalidArgumentException("Invalid argument. File '$i' is undefined.", E_USER_WARNING);
        }

        $file = $_SESSION['filelist'][$i];

        if (!$fullsize) {
            $id = self::getFileIdFromFilename($file);
            $file = self::getThumbnailFromFileId($id);
        }
        if (!is_file($file)) {
            /* error - file does not exist */
            return false;
        } else {
            return $file;
        }
    }

    /**
     * copy the file to some destination
     *
     * @access   public
     * @param    string  $destFile   destination to copy the file to
     * @param    bool    $overwrite  setting this to false will prevent
     *                               existing files from getting overwritten
     * @return   bool
     * @throws   InvalidArgumentException  on invalid filename
     */
    public function copy($destFile, $overwrite = true)
    {
        assert('is_string($destFile); // Wrong type for argument 1. String expected');
        assert('is_bool($overwrite); // Wrong type for argument 2. Boolean expected');

        /* validity checking */
        if (mb_strlen($destFile) > 512 || !preg_match('/^[\w\d-_\.][\w\d-_\/\.]*$/', $destFile)) {
            throw new InvalidArgumentException("Invalid filename '".$destFile."'.", E_USER_WARNING);
        }

        if (!$overwrite && file_exists($destFile)) {
            DbEventLog::report("Unable to copy to file '{$destFile}'. " .
                "Another file with the same name does already exist.");
            return false;
        } elseif ($overwrite && file_exists($destFile) && !is_writeable($destFile)) {
            DbEventLog::report("Unable to copy file to '{$destFile}'. Permission denied.");
            return false;
        } else {
            $handle = fopen($destFile, "w+");
            if ($handle === false) {
                return false;
            }
            flock($handle, LOCK_EX);
            if (fwrite($handle, $this->getFileContent()) == false) {
                return false;
            }
            flock($handle, LOCK_UN);
            if (fclose($handle) == false) {
                return false;
            }
            chmod($destFile, 0777);
            return true;
        }
    }

    /**
     * create unique id to identify a file
     *
     * @access  public
     * @static
     * @param   DDLColumn  $column  column definition
     * @return  string
     * @ignore
     */
    public static function getNewFileId(DDLColumn $column)
    {
        $tableName = $column->getParent()->getName();
        $columnName = $column->getName();
        return md5(uniqid("$tableName.$columnName.", true));
    }

    /**
     * extract unique file-id from a database value
     *
     * @access  public
     * @static
     * @param   string  $filename filename
     * @return  string
     * @ignore
     */
    public static function getFileIdFromFilename($filename)
    {
        assert('is_string($filename); // Wrong argument type for argument 1. String expected');
        return preg_replace('/^.*?(\w+)\.\w+$/', '$1', $filename);
    }

    /**
     * get filename by id
     *
     * Searches for a matching filename for a given id.
     *
     * @param   string  $id  file id
     * @return  string
     */
    public static function getFilenameFromFileId($id)
    {
        assert('is_string($id); // Wrong argument type argument 1. String expected');
        foreach (glob(self::getDbBlobDir() . "{$id}*") as $filename)
        {
            return $filename;
        }
        return "";
    }

    /**
     * get filename by id
     *
     * Searches for a matching filename for a given id.
     *
     * @param   string  $id  file id
     * @return  string
     */
    public static function getThumbnailFromFileId($id)
    {
        assert('is_string($id); // Wrong argument type argument 1. String expected');
        return self::getDbBlobDir() . "thumb.{$id}.png";
    }

    /**
     * remove a binary large object from database
     *
     * This removes a files (blob) from the database.
     * The type of the column must be "file" or "image".
     *
     * IMPORTANT NOTE: This is a low-level function that DOES NOT
     * disassociate the files with the datasets, that reference
     * them.
     *
     * Returns bool(true) on success and bool(false) if the file does not exist.
     *
     * @access  public
     * @param   string    $fileToDelete  filename which would be removed
     * @throws  NotFoundException    when the given file was not found
     * @since   3.1.0
     * @ignore
     */
    public static function removeFile($fileToDelete)
    {
        assert('is_string($fileToDelete);  // Wrong type for argument 1. String expected.');

        if (empty($fileToDelete)) {
            return;
        }

        $id = self::getFileIdFromFilename($fileToDelete);
        $file = self::getFilenameFromFileId($id);
        $thumbFile = self::getThumbnailFromFileId($id);

        // error - file does not exist
        if (!is_file($file)) {
            throw new NotFoundException("File not found: $file");
        }

        // delete file
        unlink($file);
        assert('!is_file($file); // file was not deleted');

        // applies to images only:
        if (is_file($thumbFile)) {
            unlink($thumbFile);
            assert('!is_file($thumbFile); // file was not deleted');
            /* Note: we intentionally (and silently) ignore the case,
             * that an image file exists, but no thumbnail is found.
             */
        }
    }

    /**
     * get datbase blob-directory
     *
     * Returns path to directory where files are stored.
     *
     * @access  protected
     * @static
     * @return  string
     * @ignore
     */
    public static function getDbBlobDir()
    {
        if (is_null(self::$blobDir)) {
            if (isset($GLOBALS['YANA'])) {
                self::$blobDir = $GLOBALS['YANA']->getVar('DBBLOB');
            } else {
                self::$blobDir = 'config/db/.blob/';
            }
            assert('is_dir(self::$blobDir); // Blob-dir does not exist');
            self::$blobDir = realpath(self::$blobDir) . DIRECTORY_SEPARATOR;
        }
        return self::$blobDir;
    }

    /**
     * sanitize file id
     *
     * For file upload error handling see the following example:
     * <code>
     * try
     * {
     *     $filename = self::_sanitizeFile($fileColumn, '1234');
     *     move_uploaded_file($filename, "upload/foo.bar"));
     * }
     * catch (Exception $e)
     * {
     *     switch ($e->getCode())
     *     {
     *        case UPLOAD_ERR_INI_SIZE:
     *            exit('File exceeds upload_max_filesize in php.ini');
     *        break;
     *        case UPLOAD_ERR_FORM_SIZE:
     *            exit('File bigger than 1000000 bytes');
     *        break;
     *        case UPLOAD_ERR_PARTIAL:
     *            exit('File was only partially uploaded');
     *        break;
     *        case UPLOAD_ERR_NO_FILE:
     *            exit('No file was uploaded');
     *        break;
     *        case UPLOAD_ERR_NO_TMP_DIR:
     *            exit('Missing a temporary folder');
     *        break;
     *        case UPLOAD_ERR_CANT_WRITE:
     *            exit('Failed to write file to disk');
     *        break;
     *        case UPLOAD_ERR_EXTENSION:
     *            exit('File upload stopped by extension');
     *        break;
     *        case UPLOAD_ERR_FILE_TYPE:
     *            exit('uploaded file is not a recognized image');
     *        break;
     *        case UPLOAD_ERR_OTHER:
     *            exit('misc. (unexpected) errors');
     *        break;
     *     }
     * }
     * </code>
     *
     * See the PHP manual for more details on these codes.
     *
     * @access  private
     * @static
     * @param   DDLColumn $column  column containing the file
     * @param   string    $id      value of the primary key
     * @return  string
     * @ignore
     */
    private static function _sanitizeFile(DDLColumn $column, $id)
    {
        assert('is_string($id); // Wrong argument type for argument 2. String expected');

        $isImage = $column->getType() === 'image';
        assert('is_array(self::$files[$id]); // uploaded file not found');

        // check if temporary file exists and a filename is provided
        if (empty(self::$files[$id]['tmp_name']) || empty(self::$files[$id]['name'])) {
            if ($isImage) {
                throw new Alert("INVALID_IMAGE", UPLOAD_ERR_NO_FILE);
            } else {
                throw new Alert("UPLOAD_FAILED", UPLOAD_ERR_NO_FILE);
            }
        }
        // temporary variable
        $file = self::$files[$id];
        // get original filename (for reporting purposes only)
        $filename = $file['name'];
        // maximum file size in byte
        $maxSize = (int) $column->getSize();
        // uploaded file is not an image
        if ($isImage && !preg_match('/^image\/\w+$/s', $file['type'])) {
            $options = array(
                'FILE' => "$filename"
                );
            throw new Alert("INVALID_IMAGE", UPLOAD_ERR_FILE_TYPE, $options);
        }
        // check error state
        if (isset($file['error'])) {
            // check type of error
            switch ($file['error'])
            {
                case UPLOAD_ERR_OK:
                    // all fine - proceed!
                break;
                case UPLOAD_ERR_SIZE:
                case UPLOAD_ERR_INI_SIZE:
                case UPLOAD_ERR_FORM_SIZE:
                    $options = array(
                        'FILE' => "$filename",
                        'MAXIMUM' => "$maxSize"
                        );
                    throw new Alert("TOO_LARGE_FILE", $file['error'], $options);
                break;

                case UPLOAD_ERR_FILE_TYPE:
                    $options = array(
                        'FILE' => "$filename"
                        );
                    if ($isImage) {
                        throw new Alert("INVALID_IMAGE", UPLOAD_ERR_FILE_TYPE, $options);
                    } else {
                        throw new Alert("UPLOAD_FAILED", UPLOAD_ERR_FILE_TYPE, $options);
                    }
                break;

                case UPLOAD_ERR_INVALID_TARGET:
                    Log::report("Unable to write uploaded file '{$filename}'.");
                    throw new Error("500", UPLOAD_ERR_INVALID_TARGET);
                break;
                case UPLOAD_ERR_OTHER:
                default:
                    Log::report("Unable to write uploaded file '{$filename}'.");
                    throw new Error("500", $file['error']);
                break;
            } // end switch (error code)
        }

        /* check file size
         *
         * Note: the size value is given in 'byte'
         */
        if ($maxSize > 0 && $file['size'] > $maxSize) {
            $options = array(
                'FILE' => $filename,
                'MAXIMUM' => $maxSize
                );
            throw new Alert("TOO_LARGE_FILE", UPLOAD_ERR_SIZE, $options);
        }

        return $file['tmp_name'];
    }

    /**
     * handle file uploads
     *
     * The purpose of this method is to handle an uploaded file.
     *
     * The file is taken from the $_FILES array at the index provided by
     * the argument $id ($_FILES[$id]). The argument is case-insensitive.
     *
     * If the file already exists, it will get replaced.
     *
     * Example of usage:
     *
     * HTML-Code:
     * <code>
     * <form method="POST" action="{$PHP_SELF}" enctype="multipart/form-data">
     * ...
     * Upload file: <input type="file" name="my_file" />
     * ...
     * </form>
     * </code>
     *
     * PHP-Code:
     * <code>
     * // get data from form field 'my_file'
     * $id = 'my_file';
     * // get data from form field 'my_file'
     * $column = $db->schema->{'my_table'}->{'my_file'};
     * // database value
     * $row['my_file'] = DbBlob::getNewFileId($column);
     * $fileId = $row['my_file'];
     *
     * // call method
     * $filename = DbBlob::uploadFile($column, $id, $fileId);
     * </code>
     *
     * Note: this function may be used for images and other files alike.
     * It determines the type of the target column automatically and reacts accordingly.
     *
     * @access  public
     * @static
     * @param   DDLColumn  $column  Expected a DDLColumn object as entry
     * @param   string     $id      Identifier of the form-field from which the file has been sent.
     *                              Same as id of file to upload in array $_FILES (case-insensitive)
     * @param   string     $fileId  name of target file
     * @return  string
     * @ignore
     * @throws  Alert               when the upload failed due to a problem with the given input
     * @throws  Error               when the upload failed due to a problem on the server
     */
    public static function uploadFile(DDLColumn $column, $id, $fileId = "")
    {
        assert('is_string($id);     // Wrong argument type for argument 2. String expected');
        assert('is_string($fileId); // Wrong argument type for argument 3. String expected');
        if ($fileId === "") {
            $fileId = self::getNewFileId($column);
            assert('is_string($fileId);');
            unset($tableName, $columnName);
        }

        /* get directory */
        assert('!isset($dir); // Cannot redeclare var $dir');
        $dir = self::getDbBlobDir();
        if ($dir === false) {
            throw new Alert("UPLOAD_FAILED");
        }
        $isImage = $column->getType() === 'image';

        // lower-case Id and files-array
        $id = mb_strtolower($id);
        if (!isset(self::$files)) {
            self::$files = array_change_key_case($_FILES, CASE_LOWER);
        }
        $fileTempName = self::_sanitizeFile($column, $id);
        $filename = self::$files[$id]['name'];

        /* handle errors */
        if (!is_file($fileTempName) || !is_readable($fileTempName)) {
            throw new Alert("UPLOAD_FAILED", E_USER_NOTICE, array('FILE' => $filename));

        } /* end error handling */

        /* 1. file uploads */
        if (!$isImage) {

            /* name of output file */
            $path = "{$dir}/{$fileId}.gz";
            return self::_saveFileTo($id, $path);

        /* 2. image uploads */
        } else {

            /* name of output files */
            $path = "{$dir}/{$fileId}";
            $thumbnailPath = "{$dir}/thumb.{$fileId}";
            $imageSettings = $column->getImageSettings();
            if (!is_array($imageSettings)) {
                throw new Alert("INVALID_IMAGE");
            }
            return self::_saveImageTo($id, $path, $thumbnailPath, $imageSettings);
        }
    }

    /**
     * image uploads
     *
     * Returns path to saved image.
     *
     * @access  private
     * @static
     * @param   string  $id               id
     * @param   string  $path             path
     * @param   string  $thumbnailPath    thumbnailPath
     * @param   array   $imageSettings    imageSettings
     */
    private static function _saveImageTo($id, $path, $thumbnailPath, array $imageSettings)
    {
        assert('is_string($id); // Wrong argument type for argument 1. String expected');
        assert('is_string($path); // Wrong argument type for argument 2. String expected');
        assert('is_string($thumbnailPath); // Wrong argument type for argument 3. String expected');

        /*
         * check file type
         *
         * Note: never rely on the given file type - it is send by the browser and might be fake!
         */
        if (preg_match('/^image\/(\w+)$/s', self::$files[$id]['type'], $mimetype)) {
            $mimetype = $mimetype[1];
        } else {
            $mimetype = 'png';
        }
        $fileTempName = self::$files[$id]['tmp_name'];

        /*
         * process image
         *
         * Note: this also checks the file content and will
         * set a "broken" flag if the image is not valid.
         */
        $image = new Image($fileTempName, $mimetype);
        /*
         * check if the "broken" flag has been set
         */
        if ($image->isBroken()) {
            throw new Alert("INVALID_IMAGE", UPLOAD_ERR_FILE_TYPE);
        }
        $width = $imageSettings['width'];
        $height = $imageSettings['height'];
        $ratio = !empty($imageSettings['ratio']);
        $background = $imageSettings['background'];
        if (preg_match('/#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i', "$background", $rgb)) {
            $background = array(
                hexdec($rgb[1]),
                hexdec($rgb[2]),
                hexdec($rgb[3])
            );
        }
        // resize
        $image->createThumbnail($width, $height, $ratio, $background);
        $image->outputToFile($path, $mimetype);
        // create thumbnail
        $image->createThumbnail(75, 75, true, $background);
        $image->outputToFile($thumbnailPath, 'png');
        return $path;
    }

    /**
     * file uploads
     *
     * Returns path to saved file.
     *
     * @access  private
     * @static
     * @param   string  $id     id
     * @param   string  $path   path
     * @return  string
     */
    private static function _saveFileTo($id, $path)
    {
        assert('is_string($id); // Wrong argument type for argument 1. String expected');
        assert('is_string($path); // Wrong argument type for argument 2. String expected');

        /*
         * mime-type
         *
         * The Mime-type is saved, so it may be sent to the client on download.
         */
        assert('!isset($mimetype); // Cannot redeclare var $mimetype');
        if (!empty(self::$files[$id]['type'])) {
            $mimetype = self::$files[$id]['type'];
            $mimetype = preg_replace('/\s/', ' ', $mimetype);
        }

        /*
         * create zip file
         */
        assert('!isset($gz); // Cannot redeclare var $gz');
        $gz = gzopen($path, 'w9');
        /*
         * insert Header information
         *
         * 1) original filename
         * 2) original size
         * 3) original mime-type
         *
         * Note: line break "\n" is used as a delimiter
         */
        gzwrite($gz, "$filename\n");
        gzwrite($gz, filesize($fileTempName)."\n");
        gzwrite($gz, "$mimetype\n");
        /*
         * save file contents
         */
        gzwrite($gz, file_get_contents($fileTempName));
        gzclose($gz);
        return $filename;
    }
}

?>