<?php

/* --------------------------------------------------------------------

  Chevereto
  http://chevereto.com/

  @author	Rodolfo Berrios A. <http://rodolfoberrios.com/>
			<inbox@rodolfoberrios.com>

  Copyright (C) Rodolfo Berrios A. All rights reserved.
  
  BY USING THIS SOFTWARE YOU DECLARE TO ACCEPT THE CHEVERETO EULA
  http://chevereto.com/license

  --------------------------------------------------------------------- */

namespace CHV;
use G, Exception;

class Upload {
	// filename => name.ext
	// file => /full/path/to/name.ext
	// name => name
	
	public $source, $uploaded;
	
	// Sets the type of resource being uploaded
	public function setType($type) {
		$this->type = $type;
	}
	
	// Set source
	public function setSource($source) {
		$this->source = $source;
		$this->type = G\is_url($this->source) ? 'url' : 'file';
	}
	
	// Set destination
	public function setDestination($destination) {
		$this->destination = G\forward_slash($destination);
	}
	
	// Set storage
	public function setStorageId($storage_id) {
		$this->storage_id = is_numeric($storage_id) ? $storage_id : NULL;
	}
	
	// Set file basename
	public function setFilename($name) {
		$this->name = $name;
	}
	
	// Set options
	public function setOptions($options) {
		$this->options = $options;
	}
	
	// Set individual option
	public function setOption($key, $value) {
		$this->options[$key] = $value;
	}
	
	// Default options
	public function getDefaultOptions() {
		return array(
			'max_size'		=> G\get_bytes('2 MB'),
			'filenaming'	=> 'original'
		);
	}
	
	/**
	 * Do the thing
	 * @Exeption 4xx
	 */
	public function exec() {
		
		$this->validateInput(); // Exception 1
		$this->fetchSource(); // Exception 2
		$this->validateSourceFile(); // Exception 3
		
		// Merge options
		$this->options = array_merge($this->getDefaultOptions(), (array) $this->options);
		
		// Save the source name
		$this->source_name = G\get_filename_without_extension($this->type == "url" ? $this->source : $this->source["name"]);
		
		// Set file extension
		$this->extension = $this->source_image_fileinfo["extension"];
		
		// Workaround the $name
		if(!$this->name) {
			$this->name = $this->source_name;
		}
		
		// Fix file extension
		if(G\get_file_extension($this->name) == $this->extension) {
			$this->name = G\get_filename_without_extension($this->name);
		}
		
		// Set the fixed filename
		$this->fixed_filename = preg_replace('/(.*)\.(th|md|original|lg)\.([\w]+)$/', '$1.$3', $this->name . '.' . $this->extension);
		
        // Workaround for JPEG Exif data
    if($this->extension == 'jpg' and array_key_exists('exif', $this->options)) {
            $this->source_image_exif = NULL;
            if($this->options['exif']) {
                // Fetch JPEG Exif data (when available)
                if(function_exists('exif_read_data')) {
                    $this->source_image_exif = @exif_read_data($this->downstream);
                    if($this->source_image_exif) {
                        $this->source_image_exif['FileName'] = $this->source_filename;
                    }
                }
            } else {
                // Remove JPEG Exif data
                $img = imagecreatefromjpeg($this->downstream);
                if($img) {
                    imagejpeg($img, $this->downstream, 90);
                    imagedestroy($img);
                }
            }
        }
		
		/*
		 * Set uploaded_file
		 * Local storage uploads will be allocated at the target destination
		 * External storage will be allocated to the temp directory
		 */
		if($this->storage_id) {
			$this->uploaded_file = G\forward_slash(dirname($this->downstream)) . '/' . Storage::getStorageValidFilename($this->fixed_filename, $this->storage_id, $this->options['filenaming'], $this->destination);
		} else {
			$this->uploaded_file = G\name_unique_file($this->destination, $this->options['filenaming'], $this->fixed_filename);
		}
		
		$this->source = [
			'filename'		=> $this->source_filename, // file.ext
			'name'			=> $this->source_name, // file
			'image_exif'	=> $this->source_image_exif, // exif-data array
			'fileinfo'		=> G\get_image_fileinfo($this->downstream), // fileinfo array
		];
		
		// Fix image orientation
		if($this->source_image_exif and $this->source_image_exif["Orientation"]) {
			$this->fixImageOrientation($this->downstream, $this->source_image_exif);
		}
		
		$uploaded = @rename($this->downstream, $this->uploaded_file);
		@unlink($this->downstream);
		
		if(file_exists($this->downstream)) {
			error_log("Warning: temp file " . $this->downstream . "wasn't removed.");
		}
		
		if(!$uploaded) {
			throw new UploadException("Can't move temp file to its destination", 400);
		}
		
		// For some PHP environments
		if(!$this->storage_id) {
			@chmod($this->uploaded_file, 0644);
		}
		
		$this->uploaded = array(
			'file'		=> $this->uploaded_file,
			'filename'	=> G\get_filename($this->uploaded_file),
			'name'		=> G\get_filename_without_extension($this->uploaded_file),
			'fileinfo'	=> G\get_image_fileinfo($this->uploaded_file)
		);
		
	}
	
	/**
	 * validate_input aka "first stage validation"
	 * This checks for valid input source data
	 * @Exception 1XX
	 */
	protected function validateInput() {
		
		$check_missing = ["type", "source", "destination"];
		missing_values_to_exception($this, "CHV\UploadException", $check_missing, 100);
		
		// Validate $type
		if(!preg_match("/^(url|file)$/", $this->type)) {
			throw new UploadException('Invalid $type "'.$this->type.'"', 110);
		}
		
		// Handle flood
		$flood = $this->handleFlood();
		if($flood) {
			throw new UploadException(strtr('Flood detected. You can only upload %limit% images per %time%', ['%limit%' => $flood['limit'], '%time%' => $flood['by']]), 130);
		}
		
		// Validate $source
		if($this->type == 'file') {
			
			if(count($this->source) < 5) { // Valid $_FILES ?
				throw new UploadException("Invalid file source", 120);
			}
			/*
			if(!$this->isValidImageExtension($this->source["name"])) { // Basic .extension filter
				throw new UploadException("Invalid file extension", 121);
			}
			*/
		} else if($this->type == "url") {
			
			if(!G\is_image_url($this->source) and !G\is_url($this->source)) {
				throw new UploadException("Invalid image URL", 122);
			}
			
			// Removing this saves like 1 second
			/*if(!G\is_valid_url($this->source)) {
				throw new UploadException("can't reach target image URL", 123);
			}*/

		}
		
		// Validate $destination
		if(!is_dir($this->destination)) { // Try to create the missing directory
			
			$base_dir = G\add_ending_slash(G_ROOT_PATH . explode('/', preg_replace('#'.G_ROOT_PATH.'#', '', $this->destination, 1))[0]);
			$base_perms = fileperms($base_dir);
			
			$old_umask = umask(0);
			$make_destination = mkdir($this->destination, $base_perms, true);
			chmod($this->destination, $base_perms);
			umask($old_umask);
			
			if(!$make_destination) {
				throw new UploadException('$destination '.$this->destination.' is not a dir', 130);
			}
			
		}
		
		// Can read $destination dir?
		if(!is_readable($this->destination)) {
			throw new UploadException("Can't read target destination dir", 131);
		}
		
		// Can write $destination dir?
		if(!is_writable($this->destination)) {
			throw new UploadException("Can't write target destination dir", 132);
		}
		
		// Fix $destination trailing
		$this->destination = G\add_ending_slash($this->destination);

	}
	
	/**
	 * Fetch the $source file
	 * @Exception 2XX
	 */
	protected function fetchSource() {
		
		// Set the downstream file		
		$this->downstream = @tempnam(sys_get_temp_dir(), 'chvtemp');
		
		if(!$this->downstream or !@is_writable($this->downstream)) {
			$this->downstream = @tempnam($this->destination, 'chvtemp');
			if(!$this->downstream) {
				throw new UploadException("Can't get a tempnam", 200);
			}
		}
		
		if($this->type == 'file') {
			
			if($this->source['error'] !== UPLOAD_ERR_OK) {
			
				switch($this->source['error']) {
					case UPLOAD_ERR_INI_SIZE: // 1
						throw new UploadException('File too big', 201);
					break;
					case UPLOAD_ERR_FORM_SIZE: // 2
						throw new UploadException('File exceeds form max size', 201);
					break;
					case UPLOAD_ERR_PARTIAL: // 3
						throw new UploadException('File was partially uploaded', 201);
					break;
					case UPLOAD_ERR_NO_FILE: // 4
						throw new UploadException('No file was uploaded', 201);
					break;
					case UPLOAD_ERR_NO_TMP_DIR: // 5
						throw new UploadException('Missing temp folder', 201);
					break;
					case UPLOAD_ERR_CANT_WRITE: // 6
						throw new UploadException('System write error', 201);
					break;
					case UPLOAD_ERR_EXTENSION: // 7
						throw new UploadException('The upload was stopped', 201);
					break;
				}
				
			}

			if(!@rename($this->source['tmp_name'], $this->downstream)) {
				throw new UploadException("Can't move temp file to the target upload dir", 203);
			}

		} else if($this->type == "url") {
			try {
				G\fetch_url($this->source, $this->downstream);
			} catch(Exception $e) {
				throw new UploadException($e->getMessage(), 202);
			}
		}

		$this->source_filename = basename($this->type == "file" ? $this->source["name"] : $this->source);
		
	}
	
	protected function fixImageOrientation($image_filename, $exif) {
		if($exif['Orientation'] == 1) return;
		switch($this->extension) {
			case 'jpg':
				$image = imagecreatefromjpeg($image_filename);
			break;
		}
		switch($exif['Orientation']) {
			case 3:
				$image = imagerotate($image, 180, 0);
			break;
			case 6:
				$image = imagerotate($image, -90, 0);
			break;
			case 8:
				$image = imagerotate($image, 90, 0);
			break;
		}
		imagejpeg($image, $image_filename, 90);
	}
	
	/**
	 * validate_source_file aka "second stage validation"
	 * This checks for valid input source data
	 * @Exception 3XX
	 */
	protected function validateSourceFile() {
		
		// Nothing to do here
		if(!file_exists($this->downstream)) {
			throw new UploadException("Can't fetch target upload source (downstream)", 300);
		}
		
		$this->source_image_fileinfo = G\get_image_fileinfo($this->downstream);
		
		// file info?
		if(!$this->source_image_fileinfo) {
			throw new UploadException("Can't get target upload source info", 310);
		}
		
		// Valid image fileinto?
		if($this->source_image_fileinfo["width"] == "" or $this->source_image_fileinfo["height"] == "") {
			throw new UploadException("Invalid image", 311);
		}
		
		// Mime
		if(!$this->isValidImageMime($this->source_image_fileinfo["mime"])) {
			throw new UploadException("Invalid image mimetype", 312);
		}
		
		// Size
		if(!$this->options['max_size']) {
			$this->options['max_size'] = self::getDefaultOptions()['max_size'];
		}
		if($this->source_image_fileinfo["size"] > $this->options["max_size"]) {
			throw new UploadException("File too big - max " . G\format_bytes($this->options["max_size"]), 313);
		}

		// BMP?
		if($this->source_image_fileinfo['extension'] == 'bmp') {
			$this->ImageConvert = new ImageConvert($this->downstream, "png", $this->downstream);
			$this->downstream = $this->ImageConvert->out;
			$this->source_image_fileinfo = G\get_image_fileinfo($this->downstream);
		}

	}
	
	// Handle flood uploads
	protected function handleFlood() {
		
		$logged_user = Login::getUser();
		
		if(!getSetting('flood_uploads_protection') or $logged_user['is_admin']) {
			return false;
		}

		$flood_limit = [];
		foreach(['minute', 'hour', 'day', 'week', 'month'] as $v) {
			$flood_limit[$v] = getSetting('flood_uploads_' . $v);
		}

		try {
			$db = DB::getInstance();
			$flood_db = $db->queryFetchSingle("
				SELECT
					COUNT(IF(image_date_gmt >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 MINUTE), 1, NULL)) AS minute,
					COUNT(IF(image_date_gmt >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 HOUR), 1, NULL)) AS hour,
					COUNT(IF(image_date_gmt >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 DAY), 1, NULL)) AS day,
					COUNT(IF(image_date_gmt >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 WEEK), 1, NULL)) AS week,
					COUNT(IF(image_date_gmt >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 MONTH), 1, NULL)) AS month
				FROM ".DB::getTable('images')." WHERE image_uploader_ip='".G\get_client_ip()."' AND image_date_gmt >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 MONTH)");
		} catch(Exception $e) {} // Silence
		
		$is_flood = FALSE;
		$flood_by = '';
		foreach(['minute', 'hour', 'day', 'week', 'month'] as $v) {
			if($flood_limit[$v] > 0 and $flood_db[$v] >= $flood_limit[$v]) {
				$flood_by = $v;
				$is_flood = TRUE;
				break;
			}
		}
		
		if($is_flood) {
			if(getSetting('flood_uploads_notify') and !$_SESSION['flood_uploads_notify'][$flood_by]) {
				try {
					$message_report = '<html><body>' . "\n";
					$message_report .= strtr('Flooding IP <a href="'.G\get_base_url('search/images/?q=ip:%ip').'">%ip</a>', ['%ip' => G\get_client_ip()]) . '<br>';
					if($logged_user) {
						$message_report .= 'User <a href="'.$logged_user['url'].'">'.$logged_user['name'].'</a><br>';
					}
					$message_report .= '<br>';
					$message_report .= '<b>Uploads per time period</b>'."<br>";
					$message_report .= 'Minute: '.$flood_db['minute']."<br>";
					$message_report .= 'Hour: '.$flood_db['hour']."<br>";
					$message_report .= 'Week: '.$flood_db['day']."<br>";
					$message_report .= 'Month: '.$flood_db['week']."<br>";
					$message_report .= '</body></html>';
					send_mail(getSetting('email_incoming_email'), 'Flood report IP '. G\get_client_ip(), $message_report);
					$_SESSION['flood_uploads_notify'][$flood_by] = true;
				} catch(Exception $e) {} // Silence
			}
			
			return ['flood' => TRUE, 'limit' => $flood_limit[$flood_by], 'count' => $flood_db[$flood_by], 'by' => $flood_by];
		}

	}
	
	protected function isValidImageExtension($filename) {
		return preg_match('/^.*\.(?:jpe?g|gif|png|bmp)$/i', $filename);
	}
	
	protected function isValidImageMime($mime) {
		return preg_match("@image/(gif|pjpeg|jpeg|png|x-png|bmp|x-ms-bmp|x-windows-bmp)$@", $mime);
	}
	
	protected function isValidNamingOption($string) {
		return in_array($string, array("mixed", "random", "original"));
	}
}

class UploadException extends Exception {}