<?php 
/**
 * Main handler script for the API.
 * Note that device access, using API keys, is routed by ../.htaccess to ../device/index.php
 */

date_default_timezone_set('UTC');
ini_set('post_max_size','32M');
ini_set('upload_max_filesize','32M');

//GET methods usually abort the transaction to ensure no changes are committed by accident.
//If GET method results in db changes (for example, creating now-mandatory records relating to historical ones),
//set this true to force a database commit after processing
$forceCommit=false;

/*
 * Valid combinations of HTTP verb and URI. Code iterates through these and takes the FIRST match, so
 * put the longest ones first. Optional trailing slash is handled below (as are beginning and end match),
 * so no need to do it in these patterns.
 */
$validPatterns=array(

	//get file (or image thumbnail) by ID, but with filename appended to URL. Browsers may not
	//recognise file type without the file extension even when mimetype is correctly set.
	//Special handling is needed because this looks like a "get by property" API URL.
	'GET /api/[^/]*file/[0-9]+/.*'=>'getById',
	'GET /api/[^/]*thumb/[0-9]+/.*'=>'getById',

	//get one by name, e.g, /api/plate/name/90a6
	'GET /api/[A-Za-z]+/fielddetails'=>'getFieldDetails',
	'GET /api/[A-Za-z]+/fieldDetails'=>'getFieldDetails',


	//get one or more by partial name match, e.g, /api/plate/search/90a6
	'GET /api/search/[^/]+'=>'getByNameLikeMultiClass',
		
	//get one or more by partial name match, e.g, /api/plate/search/90a6
	'GET /api/[A-Za-z]+/search/[^/]+'=>'getByNameLike',
		
	//get associated records for a given record, e.g, /api/group/1234/user
	'GET /api/[A-Za-z]+/[0-9]+/[A-Za-z0-9]+'=>'getAssociated',
		
	//get one or more by three properties, e.g, /api/plate/projectid/1/owner/2/bestscore/9
	'GET /api/[A-Za-z]+/[A-Za-z0-9_]+/[^/]+/[A-Za-z0-9_]+/[^/]+/[A-Za-z0-9_]+/[^/]+'=>'getByProperties',
		
	//get one or more by two properties, e.g, /api/plate/projectid/1/owner/2
	'GET /api/[A-Za-z]+/[A-Za-z0-9_]+/[^/]+/[A-Za-z0-9_]+/[^/]+'=>'getByProperties',

	//get one by name, e.g, /api/plate/name/90a6
	'GET /api/[A-Za-z]+/name/[^/]+'=>'getByName',

	//get one or more by property, e.g, /api/plate/barcode/90a6
	'GET /api/[A-Za-z]+/[A-Za-z0-9_]+/[^/]+'=>'getByProperty',

	//get by database ID, e.g, /api/plate/1234
	'GET /api/[A-Za-z]+/[0-9]+'=>'getById',

	//Delete a record by its database ID
	'DELETE /api/[A-Za-z]+/[0-9]+'=>'delete',
	
    //Call a dedicated function on a record found by its name, e.g, /api/plate/destroyByName/9abc
    'PATCH /api/[A-Za-z]+/[^/]+ByName/[^/]+'=>'actionByName',
    
    //update by database ID, e.g, /api/plate/1234
    'PATCH /api/[A-Za-z]+/[0-9]+'=>'update',
    
    //update site-wide configuration item, e.g, /api/config/parameterNameHere
    'PATCH /api/config/[A-Za-z0-9_]+'=>'updateConfig',
    
    //update user configuration item, e.g, /api/userconfig/parameterNameHere
    'PATCH /api/userconfig/[A-Za-z0-9_]+'=>'updateUserConfig',
    
    //get all, e.g., /api/plate - pagination in request parameters
	'GET /api/[A-Za-z]+'=>'getAll',
	
    //create a new record, e.g., /api/plate - details in request parameters
    'POST /api/[A-Za-z]+'=>'create',
    
    //create a new record in remote AJAX calls
    'PUT /api/corsproxy'=>'create',
    
);


//define class autoloader
require '../vendor/autoload.php';

//connect to the database
database::connect();
database::begin();

//set response type
$_REQUEST['responseType']='application/json'; // Later support xml if someone asks for it

//determine request method and URI
$wwwroot=config::getWwwRoot();
$method=strtoupper($_SERVER['REQUEST_METHOD']);

$uri=strtok($_SERVER['REQUEST_URI'],'?');
$uri=rtrim($uri,'/');
$uriArgs=explode('/', substr($uri,1));
$req=$method.' '.$uri;

//get request parameters
$parameters=array();
if('GET'==$method){
	$parameters=$_GET;
} else {
	parse_str(file_get_contents("php://input"),$parameters);
	if(empty($parameters) && !empty($_POST)){
		$parameters=$_POST;
	}
}

//is this a ping to check for session timeout?
$isAuthCheck=('GET /api/authCheck'===rtrim($req,'/'));

//Image filesize check. Needs to happen before session init, because oversize upload pushes CSRF token off the end and
//throws an exception.
if('POST /api/backgroundimage'===$req && (!$_FILES || $_FILES['image']['error'])){
	$msg='Could not upload image. Perhaps it is too big.';
	$size=(int)$_SERVER['CONTENT_LENGTH'];
	$uploadLimit=ini_get('upload_max_filesize');
	if(str_ends_with($uploadLimit,'M')){
		$size=round($size/1024/1024, 2)."MB";
		$msg='Image is too big. File size '.$size.', limit '.$uploadLimit.'B';
	} else if(str_ends_with($uploadLimit,'G')){
		$size=($size/1024/1024/1024)."GB";
		$msg='Image is too big. File size '.$size.', limit '.$uploadLimit.'B';
	}
	ob_clean();
	respond(["error"=>$msg], 400);
}

//attempt to start session
$sid=null;
if(isset($parameters['sid'])){
	$sid=$parameters['sid'];
}
try {
    $result=session::init(new PhpSession(), $isAuthCheck);
	if($isAuthCheck){ respond($result, 200); }
} catch (AuthorizationRequiredException $e) {
	respond(array( 'error'=>$e->getMessage() ),401);
} catch (ServerException $e){
    respond(array( 'error'=>$e->getMessage() ),500);
} catch (BadRequestException $e) {
    respond(array( 'error'=>$e->getMessage() ),400);
} catch (NotFoundException $e) {
    respond(array( 'error'=>$e->getMessage() ),404);
}

//Needs to happen after session init
if(str_starts_with($req,'GET /api/backgroundimage') && count($uriArgs)==3){
	backgroundimage::getById($uriArgs[2]);
	exit();
} else if(str_starts_with($req,'GET /api/backgroundthumb') && count($uriArgs)==3){
	backgroundthumb::getById($uriArgs[2]);
	exit();
}

	/*
	 * Handle login/logout here
	 */
if('POST /api/Login'==$req){
	try {
		if(isset($_POST['createaccount'])){

			//Create account based on SAML user
			$user=shibboleth::createIceBearUser();
			basesession::setUserToSession($user['created']['name']);
			database::commit();
			database::begin(); //this gets aborted below
			$user["createdAccount"]=true;
			echo json_encode($user);

		} else {

			$success=session::login($parameters);
			if($success){
				$user=session::getUser();
				if(isset($_POST['linkaccount']) && shibboleth::isAuthenticated() && empty($user['edupersonprincipalname'])){
					$samlUser=shibboleth::getSamlUser();
					if(empty($samlUser['eppn'])){
						throw new ServerException('Shibboleth user has no eduPersonPrincipalName');
					}
					$user=user::update($user['id'], ['edupersonprincipalname'=>$samlUser['eppn']]);
					database::commit();
					database::begin(); //this gets aborted below
					$success['linkedAccount']=true;
				}

				header('Content-Type: application/json');
				echo json_encode($success);
			}

		}
	} catch(Exception $e){
		respond(array('error'=>$e->getMessage()),$e->getCode(), false);
	}
	database::abort();
	exit;
} else if('POST /api/Logout'==$req || 'GET /api/Logout'==$req){
	$success=session::logout();
	if($success){ echo json_encode($success); }
	database::abort();
	exit;
}

//get arguments from URI - ignore any initial /api/
if(1==count($uriArgs) && 'api'==$uriArgs[0]){
	header('Content-Type: application/json');
	echo json_encode(getClassList());
	exit;
} else if(0<count($uriArgs) && 'api'==$uriArgs[0]){
	array_shift($uriArgs);
} 

$objectType=null;
$action=null;

$keys=array_keys($validPatterns);
$numPatterns=count($keys);
for($i=0;$i<$numPatterns;$i++){
	$pattern=$keys[$i];
	if(preg_match('|^'.$pattern.'/?$|', $req)){
		$action=$validPatterns[$pattern];
		$objectType=$uriArgs[0];
		break;
	}
}

$exceptionParameters=array();
$data=array();
try {

    //validate the CSRF token, if changing anything...
    if('GET'!=$method /* AND NOT LOGGING IN OR OUT! */){
        session::validateCsrfToken($parameters);
    }
    //...but don't pass the CSRF token into the handling classes
    unset($parameters['csrfToken']);


    if(!session::isLoggedIn()){
        throw new AuthorizationRequiredException('Login required');
	}

	$responseCode=200;
	switch($action){
		case 'getById':
		case 'delete':
		case 'update':
			$args=array(urldecode($uriArgs[1]), $parameters);
			$data=callOrThrow($objectType, $action, $args);
			if($data && ("getById"==$action ||"getByName"==$action)){
				$data['objecttype']=$objectType;
			}
			break;
		case 'getByNameLike':
		case 'getByName':
			$args=array(urldecode($uriArgs[2]), $parameters);
			$data=callOrThrow($objectType, $action, $args);
			break;
		case 'create':
			$args=array($parameters);
			$data=callOrThrow($objectType, $action, $args);
			$responseCode=201; // HTTP 201 Created
			break;
		case 'getAll':
			$args=array($parameters);
			$data=callOrThrow($objectType, $action, $args);
			break;
		case 'getByNameLikeMultiClass':
			$args=array(urldecode($uriArgs[1]), $parameters);
			$data=callOrThrow('baseobject', $action, $args);
			break;
		case 'getByProperties':
			$num=1;
			$args=array();
			while(isset($uriArgs[$num]) && isset($uriArgs[$num+1])){
				$args[$uriArgs[$num]]=urldecode($uriArgs[$num+1]);
				$num=$num+2;
			}
			$args=array($args, $parameters);
			$data=callOrThrow($objectType, $action, $args);
			break;
		case 'getByProperty':
			$args=array($uriArgs[1],urldecode($uriArgs[2]), $parameters);
			$data=callOrThrow($objectType, $action, $args);
			break;
		case 'actionByName':
		    // /api/plate/destroyByName/9abc
		    $args=array(urldecode($uriArgs[2]), $parameters);
		    $data=callOrThrow($objectType, $uriArgs[1], $args);
		    break;
		case 'getAssociated':
			$args=array($uriArgs[1], $parameters);
			$data=callOrThrow($objectType, 'get'.urldecode($uriArgs[2]).'s', $args);
			break;
		case 'updateConfig':
		    $itemName=$uriArgs[1];
		    $itemValue=$parameters[$itemName];
		    config::set($itemName, $itemValue);
		    $data=array('updated'=>array($itemName=>$itemValue));
		    break;
		case 'updateUserConfig':
		    $itemName=$uriArgs[1];
		    $itemValue=$parameters[$itemName];
		    userconfig::set($itemName, $itemValue);
		    $data=array('updated'=>array($itemName=>$itemValue));
		    break;
		case 'getFieldDetails':
			$args=[];
			$data=callOrThrow($objectType, $action, $args);
			break;
	}
	if(false===$data){
		throw new BadRequestException('Class or method does not exist');
	} else if(null===$data){
		throw new NotFoundException('None found');
	}
	if("GET"==$method && !$forceCommit){
		database::abort();
	} else {
		database::commit();
	}
	respond($data,$responseCode);
} catch(Exception $e){
	database::abort();
	session::revertAdmin();
	$statusCode=$e->getCode();
	$trace=$e->getTrace();
	$prettyTrace=array();
	$prettyTrace[]=$e->getFile().' line '.$e->getLine();
 	foreach($trace as $t){
 	    $traceLine='';
 	    $line=(isset($t['line'])) ? $t['line'] : '--';
 	    $file=(isset($t['file'])) ? str_replace($wwwroot, '', (str_replace('\\','/',$t['file']))) : '--';
 	    if (array_key_exists('class',$t)){
 	        $traceLine=sprintf("%s:%s %s::%s",
 	            $file,
 	            $line,
 	            $t['class'],
 	            $t['function']
            );
 	    } else {
 	        $traceLine=sprintf("%s:%s %s",
                $file,
                $line,
                $t['function']
            );
 	    }
 	    $prettyTrace[]=$traceLine;
 	}
 	if(Log::isInited()){
		try {
			Log::write(Log::LOGLEVEL_ERROR, 'Exception was thrown, details follow:');
			Log::write(Log::LOGLEVEL_ERROR, $e->getMessage());
			Log::write(Log::LOGLEVEL_ERROR, 'Thrown at '.$prettyTrace[0]);
			foreach($prettyTrace as $t){
				Log::write(Log::LOGLEVEL_ERROR, $t);
			}
			//For 5xx exceptions, also log code and database versions.
			if($statusCode>=500 && $statusCode<600){
				$codeVersion=config::getCodeVersion();
				$databaseVersion=config::getDatabaseVersion();
				Log::write(Log::LOGLEVEL_INFO, 'Code version: '.$codeVersion);
				Log::write(Log::LOGLEVEL_INFO, 'Database version: '.$databaseVersion);
				if($codeVersion!=$databaseVersion){
					Log::write(Log::LOGLEVEL_ERROR, 'Code version and database version do not match.');
				}
			}
		} catch (Exception $logException){}
 	}
	respond(array(
		'requestMethod'=>$method,
		'requestUri'=>$uri,
	    'error'=>$e->getMessage(),
	    'parameters'=>$exceptionParameters,
	    'thrownat'=>$prettyTrace[0],
	    'trace'=>$prettyTrace
	),$statusCode);
}

/****************************************
 * Supporting functions below
 ***************************************/

/**
 * Returns the data to the client in the appropriate format, with the supplied HTTP response code.
 * @param array $data
 * @param int $responseCode
 * @param bool $exitAfter
 */
function respond(array $data, int $responseCode, bool $exitAfter=true): void {
    header('Access-Control-Allow-Origin: *');
	http_response_code($responseCode);
	header('Content-Type: application/json');
	$json=json_encode(sanitize($data));
	echo $json;
	if($exitAfter){ exit(); }
}

function sanitize(array $data): array
{
	foreach($data as &$v){
		if(is_array($v)){
			$v=sanitize($v);
		} else {
            if(is_null($v)){ $v=''; }
            if(PHP_MAJOR_VERSION>=8 && PHP_MINOR_VERSION>=1){
                $v=htmlspecialchars($v, ENT_QUOTES); //Needed for >=PHP8.1 - see docs
            } else {
                $v=htmlspecialchars($v);
            }
		}
	}
	return $data;
}

/**
 * Calls a class method with the supplied arguments, throwing a BadRequestException if class or method does not exist.
 * @param string $className The class name
 * @param string $methodName The method name
 * @param array $args The arguments to pass to the method
 * @throws BadRequestException if the class or method does not exist
 * @return mixed The results of the method call
 */
function callOrThrow(string $className, string $methodName, array $args=array()): mixed {
	return baseobject::callOrThrow($className, $methodName, $args);
}

function getClassList(){
	$classesDir=rtrim($_SERVER['DOCUMENT_ROOT'],'/').'/classes/';
	$directories=[
		'model'=>['session','report','screenfromrockmaker'],
		'core'=>[
			'audiorecording','audiorecordingfile','basereport','basesession','diskusage','database','corsproxy','Log',
			'permission','updater','userconfig','validator'
		]
	];
	$ret=[
		"message"=>"This is the API",
		"classes"=>[],
		"suppressed"=>[]
	];
	foreach ($directories as $directory=>$ignoreList){
		$ret["classes"][$directory]=[];
		$dir = dir($classesDir.$directory);
		$filename = $dir->read();
		while (!empty($filename)) {
			if (preg_match('/.*\.class\.php$/', $filename)) {
				$className = str_replace('.class.php', '', $filename);
				if (!in_array($className,$ignoreList)) {
					$ret["classes"][$directory][] = $className;
				} else {
					$ret['suppressed'][]="$directory/$className";
				}
			}
			$filename = $dir->read();
		}
	}
	return $ret;
}