<?php
/**
 * DataTables PHP libraries.
 *
 * PHP libraries for DataTables and DataTables Editor, utilising PHP 5.3+.
 *
 *  @author    SpryMedia
 *  @copyright 2015 SpryMedia ( http://sprymedia.co.uk )
 *  @license   http://editor.datatables.net/license DataTables Editor
 *  @link      http://editor.datatables.net
 */

namespace DataTables\Editor;
if (!defined('DATATABLES')) exit();

use DataTables;


/**
 * Upload class for Editor. This class provides the ability to easily specify
 * file upload information, specifically how the file should be recorded on
 * the server (database and file system).
 *
 * An instance of this class is attached to a field using the {@see
 * Field->upload()} method. When Editor detects a file upload for that file the
 * information provided for this instance is executed.
 *
 * The configuration is primarily driven through the {@see Upload->db()} and
 * {@see Upload->action()} methods:
 *
 * * {@see Upload->db()} Describes how information about the uploaded file is to be
 *   stored on the database.
 * * {@see Upload->action()} Describes where the file should be stored on the file system
 *   and provides the option of specifying a custom action when a file is
 *   uploaded.
 *
 * Both methods are optional - you can store the file on the server using the
 * {@see Upload->db()} method only if you want to store the file in the database, or if
 * you don't want to store relational data on the database us only 
 * {@see Upload->action()}. However,  the majority of the time it is best to use 
 * both - store information about the file on the database for fast retrieval (using a
 * {@see Editor->leftJoin()} for example) and the file on the file system for direct
 * web access.
 *
 * @example
 * 	 Store information about a file in a table called `files` and the actual
 * 	 file in an `uploads` directory.
 *   ```
 *		Field::inst( 'imageId' )
 *			->upload(
 *				Upload::inst( $_SERVER['DOCUMENT_ROOT'].'/uploads/__ID__.__EXTN__' )
 *			 		->db( 'files', 'id', array(
 *						'webPath'     => Upload::DB_WEB_PATH,
 *						'fileName'    => Upload::DB_FILE_NAME,
 *						'fileSize'    => Upload::DB_FILE_SIZE,
 *						'systemPath'  => Upload::DB_SYSTEM_PATH
 *					) )
 *					->allowedExtensions( array( 'png', 'jpg' ), "Please upload an image file" )
 *			)
 *	```
 *
 * @example
 * 	 As above, but with PHP 5.4 (which allows chaining from new instances of a
 * 	 class)
 *   ```
 *		newField( 'imageId' )
 *			->upload(
 *				new Upload( $_SERVER['DOCUMENT_ROOT'].'/uploads/__ID__.__EXTN__' )
 *			 		->db( 'files', 'id', array(
 *						'webPath'     => Upload::DB_WEB_PATH,
 *						'fileName'    => Upload::DB_FILE_NAME,
 *						'fileSize'    => Upload::DB_FILE_SIZE,
 *						'systemPath'  => Upload::DB_SYSTEM_PATH
 *					) )
 *					->allowedExtensions( array( 'png', 'jpg' ), "Please upload an image file" )
 *			)
 *	```
 */
class Upload extends DataTables\Ext {
	/*  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *
	 * Constants
	 */
	
	/** Database value option (`Db()`) - File content. This should be written to
	 * a blob. Typically this should be avoided and the file saved on the file
	 * system, but there are cases where it can be useful to store the file in
	 * the database.
	 */
	const DB_CONTENT      = 'editor-content';

	/** Database value option (`Db()`) - Content type */
	const DB_CONTENT_TYPE = 'editor-contentType';

	/** Database value option (`Db()`) - File extension */
	const DB_EXTN         = 'editor-extn';

	/** Database value option (`Db()`) - File name (with extension) */
	const DB_FILE_NAME    = 'editor-fileName';

	/** Database value option (`Db()`) - File size (bytes) */
	const DB_FILE_SIZE    = 'editor-fileSize';

	/** Database value option (`Db()`) - MIME type */
	const DB_MIME_TYPE    = 'editor-mimeType';

	/** Database value option (`Db()`) - Full system path to the file */
	const DB_SYSTEM_PATH  = 'editor-systemPath';

	/** Database value option (`Db()`) - HTTP path to the file. This is derived 
	 * from the system path by removing `$_SERVER['DOCUMENT_ROOT']`. If your
	 * images live outside of the document root a custom value would be to be
	 * used.
	 */
	const DB_WEB_PATH     = 'editor-webPath';

	/** Read from the database - don't write to it
	 */
	const DB_READ_ONLY    = 'editor-readOnly';


	/*  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *
	 * Private parameters
	 */
	
	private $_action = null;
	private $_dbCleanCallback = null;
	private $_dbCleanTableField = null;
	private $_dbTable = null;
	private $_dbPKey = null;
	private $_dbFields = null;
	private $_extns = null;
	private $_extnError = null;
	private $_error = null;
	private $_validators = array();
	private $_where = array();


	/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
	 * Constructor
	 */

	/**
	 * Upload instance constructor
	 * @param string|callable $action Action to take on upload - this is applied
	 *     directly to {@see Upload->action()}.
	 */
	function __construct( $action=null )
	{
		if ( $action ) {
			$this->action( $action );
		}
	}


	/*  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *
	 * Public methods
	 */

	/**
	 * Set the action to take when a file is uploaded. This can be either of:
	 *
	 * * A string - the value given is the full system path to where the
	 *   uploaded file is written to. The value given can include three "macros"
	 *   which are replaced by the script dependent on the uploaded file:
	 *   * `__EXTN__` - the file extension
	 *   * `__NAME__` - the uploaded file's name (including the extension)
	 *   * `__ID__` - Database primary key value if the {@see Upload->db()} method is
	 *     used.
	 * * A closure - if a function is given the responsibility of what to do
	 *   with the uploaded file is transferred to this function. That will
	 *   typically involve writing it to the file system so it can be used
	 *   later.
	 * 
	 * @param  string|callable $action Action to take - see description above.
	 * @return self Current instance, used for chaining
	 */
	public function action ( $action )
	{
		$this->_action = $action;

		return $this;
	}


	/**
	 * An array of valid file extensions that can be uploaded. This is for
	 * simple validation that the file is of the expected type - for example you
	 * might use `[ 'png', 'jpg', 'jpeg', 'gif' ]` for images. The check is
	 * case-insensitive. If no extensions are given, no validation is performed
	 * on the file extension.
	 *
	 * @param  string[] $extn  List of file extensions that are allowable for
	 *     the upload
	 * @param  string $error Error message if a file is uploaded that doesn't
	 *     match the valid list of extensions.
	 * @return self Current instance, used for chaining
	 * @deprecated Use Validate::fileExtensions
	 */
	public function allowedExtensions ( $extn, $error="This file type cannot be uploaded" )
	{
		$this->_extns = $extn;
		$this->_extnError = $error;

		return $this;
	}


	/**
	 * Database configuration method. When used, this method will tell Editor
	 * what information you want written to a database on file upload, should
	 * you wish to store relational information about your file on the database
	 * (this is generally recommended).
	 *
	 * @param  string $table  The name of the table where the file information
	 *     should be stored
	 * @param  string $pkey   Primary key column name. The `Upload` class
	 *     requires that the database table have a single primary key so each
	 *     row can be uniquely identified.
	 * @param  array $fields A list of the fields to be written to on upload.
	 *     The property names are the database columns and the values can be
	 *     defined by the constants of this class. The value can also be a
	 *     string or a closure function if you wish to send custom information
	 *     to the database.
	 * @return self Current instance, used for chaining
	 */
	public function db ( $table, $pkey, $fields )
	{
		$this->_dbTable = $table;
		$this->_dbPKey = $pkey;
		$this->_dbFields = $fields;

		return $this;
	}


	/**
	 * Set a callback function that is used to remove files which no longer have
	 * a reference in a source table.
	 *
	 * @param  callable $callback Function that will be executed on clean. It is
	 *     given an array of information from the database about the orphaned
	 *     rows, and can return true to indicate that the rows should be
	 *     removed from the database. Any other return value (including none)
	 *     will result in the records being retained.
	 * @return self Current instance, used for chaining
	 */
	public function dbClean( $tableField, $callback=null )
	{
		// Argument swapping
		if ( $callback === null ) {
			$callback = $tableField;
			$tableField = null;
		}

		$this->_dbCleanCallback = $callback;
		$this->_dbCleanTableField = $tableField;

		return $this;
	}


	/**
	 * Add a validation method to check file uploads. Multiple validators can be
	 * added by calling this method multiple times - they will be executed in
	 * sequence when a file has been uploaded.
	 *
	 * @param  callable $fn Validation function. A PHP `$_FILES` parameter is
	 *     passed in for the uploaded file and the return is either a string
	 *     (validation failed and error message), or `null` (validation passed).
	 * @return self Current instance, used for chaining
	 */
	public function validator ( $fn )
	{
		$this->_validators[] = $fn;

		return $this;
	}


	/**
	 * Add a condition to the data to be retrieved from the database. This
	 * must be given as a function to be executed (usually anonymous) and
	 * will be passed in a single argument, the `Query` object, to which
	 * conditions can be added. Multiple calls to this method can be made.
	 *
	 * @param  callable $fn Where function.
	 * @return self Current instance, used for chaining
	 */
	public function where ( $fn )
	{
		$this->_where[] = $fn;

		return $this;
	}


	/*  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *
	 * Internal methods
	 */
	
	/**
	 * Get database information data from the table
	 *
	 * @param \DataTables\Database $db Database
	 * @param number[] [$ids=null] Limit to a specific set of ids
	 * @return array Database information
	 * @internal
	 */
	public function data ( $db, $ids=null )
	{
		if ( ! $this->_dbTable ) {
			return null;
		}

		// Select the details requested, for the columns requested
		$q = $db
			->query( 'select' )
			->table( $this->_dbTable )
			->get( $this->_dbPKey );

		foreach ( $this->_dbFields as $column => $prop ) {
			if ( $prop !== self::DB_CONTENT ) {
				$q->get( $column );
			}
		}

		if ( $ids !== null ) {
			$q->where_in( $this->_dbPKey, $ids );
		}

		for ( $i=0, $ien=count($this->_where) ; $i<$ien ; $i++ ) {
			$q->where( $this->_where[$i] );
		}

		$result = $q->exec()->fetchAll();
		$out = array();

		for ( $i=0, $ien=count($result) ; $i<$ien ; $i++ ) {
			$out[ $result[$i][ $this->_dbPKey ] ] = $result[$i];
		}

		return $out;
	}


	/**
	 * Clean the database
	 * @param  \DataTables\Editor $editor Calling Editor instance
	 * @param  Field $field   Host field
	 * @internal
	 */
	public function dbCleanExec ( $editor, $field )
	{
		// Database and file system clean up BEFORE adding the new file to
		// the db, otherwise it will be removed immediately
		$tables = $editor->table();
		$this->_dbClean( $editor->db(), $tables[0], $field->dbField() );
	}


	/**
	 * Get the set error message
	 * 
	 * @return string Class error
	 * @internal
	 */
	public function error ()
	{
		return $this->_error;
	}


	/**
	 * Execute an upload
	 *
	 * @param  \DataTables\Editor $editor Calling Editor instance
	 * @return int Primary key value
	 * @internal
	 */
	public function exec ( $editor )
	{
		$id = null;
		$upload = $_FILES['upload'];

		// Validation - PHP standard validation
		if ( $upload['error'] !== UPLOAD_ERR_OK ) {
			if ( $upload['error'] === UPLOAD_ERR_INI_SIZE ) {
				$this->_error = "File exceeds maximum file upload size"; 
			}
			else {
				$this->_error = "There was an error uploading the file (".$upload['error'].")";
			}
			return false;
		}

		// Validation - acceptable file extensions
		if ( is_array( $this->_extns ) ) {
			$extn = pathinfo($upload['name'], PATHINFO_EXTENSION);

			if ( in_array( strtolower($extn), array_map( 'strtolower', $this->_extns ) ) === false ) {
				$this->_error = $this->_extnError;
				return false;
			}
		}

		$tempFile = tmpfile();
		try {
			$tempFileName = stream_get_meta_data($tempFile)['uri'];
			$moveRes = move_uploaded_file($upload['tmp_name'], $tempFileName);
			if ($tempFile === false || $moveRes === false || strlen($tempFileName) === 0) {
				$this->_error = "An error occurred while moving the uploaded file.";
				return false;
			}
			$upload['tmp_name'] = $tempFileName;

			// Validation - custom callback
			for ( $i=0, $ien=count($this->_validators) ; $i<$ien ; $i++ ) {
				$res = $this->_validators[$i]( $upload );

				if ( is_string( $res ) ) {
					$this->_error = $res;
					return false;
				}
			}

			// Database
			if ( $this->_dbTable ) {
				foreach ( $this->_dbFields as $column => $prop ) {
					// We can't know what the path is, if it has moved into place
					// by an external function - throw an error if this does happen
					if ( ! is_string( $this->_action ) &&
						 ($prop === self::DB_SYSTEM_PATH || $prop === self::DB_WEB_PATH )
					) {
						$this->_error = "Cannot set path information in database ".
							"if a custom method is used to save the file.";

						return false;
					}
				}

				// Commit to the database
				$id = $this->_dbExec( $upload, $editor->db() );
			}

			// Perform file system actions
			return $this->_actionExec( $upload, $id );
		} finally {
			if ($tempFile !== false) {
				@fclose($tempFile);
			}
		}
	}


	/**
	 * Get the primary key column for the table
	 *
	 * @return string Primary key column name
	 * @internal
	 */
	public function pkey ()
	{
		return $this->_dbPKey;
	}


	/**
	 * Get the db table name
	 *
	 * @return string DB table name
	 * @internal
	 */
	public function table ()
	{
		return $this->_dbTable;
	}



	/*  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *  *
	 * Private methods
	 */

	/**
	 * Execute the configured action for the upload
	 *
	 * @param  array $upload $_FILES['upload']
	 * @param  int   $id     Primary key value
	 * @return int   File    identifier - typically the primary key
	 */
	private function _actionExec ( $upload, $id )
	{
		if ( ! is_string( $this->_action ) ) {
			// Custom function
			$action = $this->_action;
			return $action( $upload, $id );
		}

		// Default action - move the file to the location specified by the
		// action string
		$to  = $this->_path( $upload['name'], $id );
		$res = rename( $upload['tmp_name'], $to );

		if ( $res === false ) {
			$this->_error = "An error occurred while moving the uploaded file.";
			return false;
		}

		return $id !== null ?
			$id :
			$to;
	}

	/**
	 * Perform the database clean by first getting the information about the
	 * orphaned rows and then calling the callback function. The callback can
	 * then instruct the rows to be removed through the return value.
	 *
	 * @param  \DataTables\Database $db Database instance
	 * @param  string $editorTable Editor Editor instance table name
	 * @param  string $fieldName   Host field's name
	 */
	private function _dbClean ( $db, $editorTable, $fieldName )
	{
		$callback = $this->_dbCleanCallback;

		if ( ! $this->_dbTable || ! $callback ) {
			return;
		}

		// If there is a table / field that we should use to check if the value
		// is in use, then use that. Otherwise we'll try to use the information
		// from the Editor / Field instance.
		if ( $this->_dbCleanTableField ) {
			$fieldName = $this->_dbCleanTableField;
		}

		$a = explode('.', $fieldName);
		if ( count($a) === 1 ) {
			$table = $editorTable;
			$field = $a[0];
		}
		else if ( count($a) === 2 ) {
			$table = $a[0];
			$field = $a[1];
		}
		else {
			$table = $a[1];
			$field = $a[2];
		}

		// Select the details requested, for the columns requested
		$q = $db
			->query( 'select' )
			->table( $this->_dbTable )
			->get( $this->_dbPKey );

		foreach ( $this->_dbFields as $column => $prop ) {
			if ( $prop !== self::DB_CONTENT ) {
				$q->get( $column );
			}
		}

		$q->where( $this->_dbPKey, '(SELECT '.$field.' FROM '.$table.'  WHERE '.$field.' IS NOT NULL)', 'NOT IN', false );

		$data = $q->exec()->fetchAll();

		if ( count( $data ) === 0 ) {
			return;
		}

		$result = $callback( $data );

		// Delete the selected rows, iff the developer says to do so with the
		// returned value (i.e. acknowledge that the files have be removed from
		// the file system)
		if ( $result === true ) {
			$qDelete = $db
				->query( 'delete' )
				->table( $this->_dbTable );

			for ( $i=0, $ien=count( $data ) ; $i<$ien ; $i++ ) {
				$qDelete->or_where( $this->_dbPKey, $data[$i][ $this->_dbPKey ] );
			}

			$qDelete->exec();
		}
	}

	/**
	 * Add a record to the database for a newly uploaded file
	 *
	 * @param  array                $upload $_FILES['upload']
	 * @param  \DataTables\Database $db     Database instance
	 * @return int Primary key value for the newly uploaded file
	 */
	private function _dbExec ( $upload, $db )
	{
		$pathFields = array();

		// Insert the details requested, for the columns requested
		$q = $db
			->query( 'insert' )
			->table( $this->_dbTable )
			->pkey( $this->_dbPKey );

		foreach ( $this->_dbFields as $column => $prop ) {
			switch ( $prop ) {
				case self::DB_READ_ONLY:
					break;

				case self::DB_CONTENT:
					$q->set( $column, file_get_contents($upload['tmp_name']) );
					break;

				case self::DB_CONTENT_TYPE:
				case self::DB_MIME_TYPE:
					$finfo = finfo_open(FILEINFO_MIME);
					$mime = finfo_file($finfo, $upload['tmp_name']);
					finfo_close($finfo);

					$q->set( $column, $mime );
					break;

				case self::DB_EXTN:
					$extn = pathinfo($upload['name'], PATHINFO_EXTENSION);
					$q->set( $column, $extn );
					break;

				case self::DB_FILE_NAME:
					$q->set( $column, $upload['name'] );
					break;

				case self::DB_FILE_SIZE:
					$q->set( $column, $upload['size'] );
					break;

				case self::DB_SYSTEM_PATH:
					$pathFields[ $column ] = self::DB_SYSTEM_PATH;
					$q->set( $column, '-' ); // Use a temporary value to avoid cases 
					break;                   // where the db will reject empty values

				case self::DB_WEB_PATH:
					$pathFields[ $column ] = self::DB_WEB_PATH;
					$q->set( $column, '-' ); // Use a temporary value (as above)
					break;

				default:
					if ( is_callable($prop) && is_object($prop) ) { // is a closure
						$q->set( $column, $prop( $db, $upload ) );
					}
					else {
						$q->set( $column, $prop );
					}

					break;
			}
		}

		$res = $q->exec();
		$id  = $res->insertId();

		// Update the newly inserted row with the path information. We have to
		// use a second statement here as we don't know in advance what the
		// database schema is and don't want to prescribe that certain triggers
		// etc be created. It makes it a bit less efficient but much more
		// compatible
		if ( count( $pathFields ) ) {
			// For this to operate the action must be a string, which is
			// validated in the `exec` method
			$path = $this->_path( $upload['name'], $id );
			$webPath = str_replace($_SERVER['DOCUMENT_ROOT'], '', $path);
			$q = $db
				->query( 'update' )
				->table( $this->_dbTable )
				->where( $this->_dbPKey, $id );

			foreach ( $pathFields as $column => $type ) {
				$q->set( $column, $type === self::DB_WEB_PATH ? $webPath : $path );
			}

			$q->exec();
		}

		return $id;
	}


	/**
	 * Apply macros to a user specified path
	 *
	 * @param  string $name File path
	 * @param  int $id Primary key value for the file
	 * @return string Resolved path
	 */
	private function _path ( $name, $id )
	{
		$extn = pathinfo( $name, PATHINFO_EXTENSION );

		$to = $this->_action;
		$to = str_replace( "__NAME__", $name, $to   );
		$to = str_replace( "__ID__",   $id,   $to   );
		$to = str_replace( "__EXTN__", $extn, $to );

		return $to;
	}
}

