<?php
/**
 * MachForm
 * 
 * © 2007–2025 Appnitro Software. All rights reserved.
 * 
 * This source code is proprietary and may not be copied, modified,
 * or distributed in any form without express written permission from Appnitro Software.
 * 
 * License information: https://www.machform.com/license-agreement/
 */
	use Firebase\JWT\JWT;
    use Firebase\JWT\Key;

	require('includes/init-form.php');
	
	require('config.php');
	require('includes/db-core.php');
	require('includes/helper-functions.php');
	require('includes/filter-functions.php');
	require('lib/jwt/vendor/autoload.php');
	
	$dbh = mf_connect_db();
	$mf_settings = mf_get_settings($dbh);
	
	$machform_data_path = '';

	//the default is not to store file upload as blob, unless defined otherwise within config.php file
	defined('MF_STORE_FILES_AS_BLOB') or define('MF_STORE_FILES_AS_BLOB',false);

	//the default is to use upload token, unless defined otherwise within config.php file
	defined('MF_USE_UPLOAD_TOKEN') or define('MF_USE_UPLOAD_TOKEN',true);

	if(!empty($_FILES) && !empty($_POST['form_id']) && !empty($_POST['element_id']) && !empty($_POST['file_token']) && !empty($_POST['upload_token'])){
		
		$form_id 	= (int) $_POST['form_id'];
		$element_id = (int) $_POST['element_id'];

		$file_token = trim($_POST['file_token'] ?? '');
		$file_token = preg_replace('/[^\da-z]/i', '', $file_token); //ensure only alphanumeric characters allowed

		if(MF_USE_UPLOAD_TOKEN){
			//verify upload token (JWT)
			$upload_token = trim($_POST['upload_token'] ?? '');
			
			try {
				JWT::$leeway = 60; // allow a 60 second leeway to account for when servers are not perfectly in sync

				//decode & verify JWT (exp and nbf are checked automatically)
				$claims = (array) JWT::decode($upload_token, new Key($mf_settings['customer_id'], 'HS256'));

				$jti = $claims['jti'];
				$fid = $claims['fid'];
				$eid = $claims['eid'];

				if ((int)($claims['fid'] ?? -1) !== $form_id || (int)($claims['eid'] ?? -1) !== $element_id) {
					throw new Exception('Invalid form or element ID');
				}

				if (empty($claims['jti'])) {
					throw new Exception('Missing jti');
				}
				

			} catch (\Firebase\JWT\ExpiredException $e) { 
				abort_with_error('Token expired');
			} catch (\Firebase\JWT\BeforeValidException $e) {
				abort_with_error('Token not yet valid');
			} catch (Throwable $e) {
				abort_with_error('Invalid upload token : '.$e->getMessage());
			}

			//check if the current IP address is not exceeding the hourly limit, to prevent spammer abuse
			//if upload_hourly_limit is set to 0, this means no limit
			if($mf_settings['upload_hourly_limit'] > 0){
				$ip_address_binary = !empty($_SERVER['REMOTE_ADDR']) ? inet_pton($_SERVER['REMOTE_ADDR']) : '';
				
				$query = "SELECT COUNT(*) AS total_upload FROM ".MF_TABLE_PREFIX."upload_tokens_log WHERE ip_address = ? AND used_at >= NOW() - INTERVAL 60 MINUTE";
				$params = array($ip_address_binary);
				
				$sth = mf_do_query($query,$params,$dbh);
				$row = mf_do_fetch_result($sth);
				
				if(!empty($row['total_upload']) && $row['total_upload'] >= $mf_settings['upload_hourly_limit']){
					abort_with_error('Too many uploads from this IP in the last hour.');
				}
			
				try {
					$dbh->beginTransaction();

					//atomically consume 1 usage (only if under max_usage)
					$query = "UPDATE `".MF_TABLE_PREFIX."upload_tokens`
								SET 
									usage_count = usage_count + 1
							WHERE 
									jti = :jti AND fid = :fid AND eid = :eid
								AND 
									usage_count < max_usage";
					$sth = $dbh->prepare($query);
					$sth->execute([
						':jti' => $jti, ':fid' => $fid, ':eid' => $eid
					]);

					if($sth->rowCount() !== 1) {
						// distinguish invalid vs exhausted
						$query = "SELECT usage_count, max_usage FROM `".MF_TABLE_PREFIX."upload_tokens` WHERE jti=? AND fid=? AND eid=?";
						
						$check = $dbh->prepare($query);
						$check->execute([$jti, $fid, $eid]);
						
						$row = $check->fetch(PDO::FETCH_ASSOC);
						$dbh->rollBack();

						if (!$row) { abort_with_error('Invalid token.'); }
						abort_with_error('Token usage limit reached.');
					}

					//log this usage
					$ins = $dbh->prepare("INSERT INTO `".MF_TABLE_PREFIX."upload_tokens_log` (jti,ip_address,used_at,filename) VALUES (?, ?, NOW(), ?)");
					$ins->execute([$jti, $ip_address_binary, $_FILES['element_'.$element_id]['name']]);

					$dbh->commit();
				} catch (Throwable $e) {
					if ($dbh->inTransaction()) $dbh->rollBack();
					abort_with_error('Upload token consume failed.');
				}
			}
		}
		
		//reaching this point, the token exists, consumed correctly, and is now marked used
		//also the IP has not exceeded the hourly limit

		//delete old tokens
		$query = "DELETE FROM `".MF_TABLE_PREFIX."upload_tokens_log` WHERE used_at < NOW() - INTERVAL 14 DAY";
		$params = array();

		mf_do_query($query,$params,$dbh);

		$query = "DELETE FROM `".MF_TABLE_PREFIX."upload_tokens` WHERE issued_at < NOW() - INTERVAL 14 DAY";
		$params = array();

		mf_do_query($query,$params,$dbh);

		//check if ap_form_xxx_listfiles table exist or not, if not exist create one
		$is_listfiles_table_exist = true;
		try{
				$params = array();
				
				$query = "select count(*) from ".MF_TABLE_PREFIX."form_{$form_id}_listfiles";
				$sth = $dbh->prepare($query);

				$sth->execute($params);
		}catch(PDOException $e) {
			$is_listfiles_table_exist = false;
		}

		if($is_listfiles_table_exist === false){
				
				//create table ap_form_xxx_listfiles
				try{
					$params = array();
					$query = "CREATE TABLE `".MF_TABLE_PREFIX."form_{$form_id}_listfiles` (
									  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
									  `date_created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
									  `file_token` text,
									  `file_content` text CHARACTER SET utf8mb4,
									  PRIMARY KEY (`id`),
									  KEY `file_token` (`file_token`(50))
									) DEFAULT CHARACTER SET utf8mb4;";
					$sth = $dbh->prepare($query);

					$sth->execute($params);
				}catch(PDOException $e) {
					//discard silently, in case the table already exist
				}
		}
		
		
		if(!is_writable($machform_data_path.$mf_settings['upload_dir']."/form_{$form_id}/files")){
			$old_mask = umask(0);
			
			mkdir($machform_data_path.$mf_settings['data_dir']."/form_{$form_id}",0755);
			if($mf_settings['data_dir'] != $mf_settings['upload_dir']){
				@mkdir($machform_data_path.$mf_settings['upload_dir']."/form_{$form_id}",0755);
			}
			mkdir($machform_data_path.$mf_settings['upload_dir']."/form_{$form_id}/files",0755);
			@file_put_contents($machform_data_path.$mf_settings['upload_dir']."/form_{$form_id}/files/index.html",' '); //write empty index.html

			umask($old_mask);
		}
		
		$query = "select 
       					element_file_type_list,
       					element_file_enable_size_limit,
       					element_file_size_max
					from 
						".MF_TABLE_PREFIX."form_elements 
				   where 
				   		form_id = ? and element_id = ? and element_status = 1";
		$params = array($form_id,$element_id);
		$sth = mf_do_query($query,$params,$dbh);
		$row = mf_do_fetch_result($sth);
		
		$file_type_list 		= trim($row['element_file_type_list']);
		$file_enable_size_limit	= $row['element_file_enable_size_limit'];
		$file_size_max			= $row['element_file_size_max'];

		//validate file type
		$ext = pathinfo(strtolower($_FILES['element_'.$element_id]['name']), PATHINFO_EXTENSION);
		$ext = preg_replace( '/[^a-z0-9]/i', '', $ext); //make sure the extension only contain alphanumeric

		//if ext is empty, the file extension most likely malicious, reject the upload
		if(empty($ext)){
			abort_with_error('Error! Filetype unknown!');
		}

		if(!empty($file_type_list)){
		
			$file_type_array = explode(',',$file_type_list);
			$file_type_array = array_map('strtolower', $file_type_array);
			$file_type_array = array_map('trim', $file_type_array);
			
			//always block dangerous files, regardless of allowed file types setting
			$blocked_file_type_array = [
				'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml',
				'pl', 'pm', 'cgi', 'perl',
				'py',
				'shtml','html',
				'exe', 'bin', 'bat', 'cmd', 'sh', 'bash',
				'asp', 'aspx', 'asmx', 'asax', 'ascx', 'ashx',
				'jsp', 'jspx', 'wss', 'do', 'action',
				'rb', 'rhtml',
				'sh', 'csh', 'ksh', 'bsh',
				'js'
			];

			$allowed_file_type_array = array_diff($file_type_array, $blocked_file_type_array);
			
			if(empty($allowed_file_type_array) || !in_array($ext,$allowed_file_type_array)){
				abort_with_error('Error! Filetype not allowed!');
			}
		}else{
			//if file_type_list is empty, block all file upload
			abort_with_error('Unable to upload. This field doesn\'t accept any file types');
		}

		//validate for double extension attack
		$exploded = explode('.',$_FILES['element_'.$element_id]['name']);
		if(count($exploded) > 2){
			abort_with_error('Error. File name can only contain one period.');
		}
		
		//validate file size if this rule being enabled
		if(!empty($file_enable_size_limit) && !empty($file_size_max)){
			$file_size_max = $file_size_max * 1048576; //turn into bytes from MB
			if($_FILES['element_'.$element_id]['size'] > $file_size_max){
				abort_with_error('Error! File size exceeded!');
			}
		}
		
		
		//move file and check for invalid file
		$new_file_token = md5(uniqid(rand(), true)); //regenerate filename token, to increase security
		$destination_file = $machform_data_path.$mf_settings['upload_dir']."/form_{$form_id}/files/element_{$element_id}_{$new_file_token}-{$_FILES['element_'.$element_id]['name']}.tmp";
		
		//if destination file already exist having the exact file name (could be happen if user is uploading multiple files using same names)
		if(file_exists($destination_file)){
			//add random numbers to the filename
			$rand_suffix = substr(strtoupper(md5(mt_rand())),0,5);
			$path_parts = pathinfo($_FILES['element_'.$element_id]['name']);

			$destination_file = $machform_data_path.$mf_settings['upload_dir']."/form_{$form_id}/files/element_{$element_id}_{$new_file_token}-{$path_parts['filename']}{$rand_suffix}.{$path_parts['extension']}.tmp";
		}

		$destination_file = mf_sanitize($destination_file);
		$destination_file = str_replace(array('<','>'), '', $destination_file); //prevent nasty php code tag being written into the file

		$source_file	  = $_FILES['element_'.$element_id]['tmp_name'];
		if(move_uploaded_file($source_file,$destination_file)){
			
			//fix image orientation issue for JPEG file, if any
			fix_image_orientation($destination_file);

			if(MF_STORE_FILES_AS_BLOB === true){
				
				//if destination file already exist having the exact file name (could be happen if user is uploading multiple files using same names)
				//make sure to add random suffix
				$query = "SELECT COUNT(*) total_file FROM `".MF_TABLE_PREFIX."form_{$form_id}_files` WHERE file_name = ?";
				$params = array(pathinfo($destination_file,PATHINFO_BASENAME));

				$sth = mf_do_query($query,$params,$dbh);
				$row = mf_do_fetch_result($sth);

				if(!empty($row['total_file'])){
					//add random numbers to the filename
					$rand_suffix = substr(strtoupper(md5(mt_rand())),0,5);
					$path_parts = pathinfo($_FILES['element_'.$element_id]['name']);

					$suffixed_destination_file = $machform_data_path.$mf_settings['upload_dir']."/form_{$form_id}/files/element_{$element_id}_{$new_file_token}-{$path_parts['filename']}{$rand_suffix}.{$path_parts['extension']}.tmp";
					rename($destination_file,$suffixed_destination_file);
					$destination_file = $suffixed_destination_file;
				}

				//save a copy of the uploaded file into the database
				mf_ap_form_files_insert($dbh,$form_id,$destination_file);
			}

			//save the filename into listfiles table
			$query = "INSERT INTO `".MF_TABLE_PREFIX."form_{$form_id}_listfiles`(file_content,file_token) VALUES(?,?);";
			$params = array($destination_file,$file_token);
			mf_do_query($query,$params,$dbh);

			
		}else{
			abort_with_error('Unable to move uploaded file.');
		}
		
	}else{
		abort_with_error('Invalid upload request.');
	}
	
	//reaching this point, the upload is successful
	header('Content-Type: application/json; charset=utf-8');

	echo json_encode([
		'file_token' => $file_token,
		'element_id' => $element_id,
		'form_id' => $form_id,
		'filename' => base64_encode(mf_sanitize($_FILES['element_'.$element_id]['name']))
	]);
	

/* Functions */
function abort_with_error($message, $status_code = 500) {
    error_log("[Upload Error $status_code] $message");
    http_response_code($status_code);
    
	header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['status' => 'error', 'message' => $message]);
    
	exit;
}

function fix_image_orientation($filename) {
	if(function_exists('mime_content_type') && function_exists('exif_read_data')){
	 	if(mime_content_type($filename) == 'image/jpeg'){
		    $exif = @exif_read_data($filename);
		    if($exif && isset($exif['Orientation'])) {
		      $orientation = $exif['Orientation'];
		      
		      if($orientation != 1){
		        $img = imagecreatefromjpeg($filename);
		        $deg = 0;
		        
		        switch ($orientation) {
		          case 3: $deg = 180; break;
		          case 6: $deg = 270; break;
		          case 8: $deg = 90; break;
		        }
		        
		        if ($deg) {
		          $img = imagerotate($img, $deg, 0);        
		        }
		        
		        //rewrite the rotated image back to the disk as $filename 
		        imagejpeg($img, $filename, 95);
		      } 
		    } 
		}
  	}    
}
?>