<?php
/**
 * @brief		cURL REST Class
 * @author		<a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
 * @copyright	(c) Invision Power Services, Inc.
 * @license		https://www.invisioncommunity.com/legal/standards/
 * @package		Invision Community
 * @since		18 Mar 2013
 */

namespace IPS\Http\Request;
 
/* To prevent PHP errors (extending class does not exist) revealing path */

use CurlHandle;
use CurlMultiHandle;
use IPS\Application;
use IPS\Http\Response;
use IPS\Http\Url;
use IPS\IPS;
use IPS\Log;
use Pdp\Domain;
use Pdp\Rules;
use function array_replace;
use function curl_setopt;
use function define;
use function defined;
use function in_array;
use function is_array;
use function is_int;
use function parse_url;
use const CURLOPT_WRITEFUNCTION;
use const PHP_URL_HOST;

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
	exit;
}

/**
 * cURL REST Class
 *
 * @method put( mixed $data = NULL ): Response
 * @method delete( mixed $data = NULL ): Response
 */
class Curl
{
	/**
	 * @brief	URL
	 */
	protected ?Url $url = NULL;
	
	/**
	 * @brief	Curl Handle
	 */
	protected null|false|CurlHandle $curl = NULL;
	
	/**
	 * @brief	Has the Content-Type header been set?
	 * @note	Because cURL will automatically set the Content-Type header to multipart/form-data if we send a POST request with an array, we need to change it to a string if we want to send a different Content-Type
	 * @see		<a href='http://www.php.net/manual/en/function.curl-setopt.php'>PHP: curl_setopt - Manual</a>
	 */
	protected bool $modifiedContentType = FALSE;
	
	/**
	 * @brief	HTTP Version
	 */
	protected string $httpVersion = '1.1';
	
	/**
	 * @brief	Timeout
	 */
	protected int $timeout = 5;
	
	/**
	 * @brief	Follow redirects?
	 */
	protected int|bool $followRedirects = TRUE;

	/**
	 * @brief	Allowed protocols
	 */
	protected array $allowedProtocols	= array();
	
	/**
	 * @brief	Data sent
	 */
	protected mixed $dataForLog = NULL;

	/**
	 * @brief	Headers sent
	 */
	protected array $headersForLog = [];

	/**
	 * @brief   Flag used to show this request is going to the internal cloud server
	 */
	public bool $internalSignedRequest = false;

	/**
	 * @brief   Limit how much data we fetch
	 */
	protected int|null $bytesToGet = null;

	/**
	 * @brief   Untrusted endpoint, redirect only if the host is the same, so google.com > www.google.com is ok, but google.com > badActor.com is not
	 */
	protected bool	$untrusted = false;

	/**
	 * @brief   Allowed content types (null allows all)
	 */
	protected array|null $allowedContentTypes = NULL;

	/**
	 * @var array|null
	 */
	public array|null $cicHeaders = null;
	
	/**
	 * Contructor
	 *
	 * @param Url $url				URL
	 * @param int $timeout			Timeout (in seconds)
	 * @param string|null $httpVersion		HTTP Version
	 * @param bool|int $followRedirects	Automatically follow redirects? If a number is provided, will follow up to that number of redirects
	 * @param array|null $allowedProtocols	Protocols allowed (if NULL we default to array( 'http', 'https', 'ftp', 'scp', 'sftp', 'ftps' ))
	 * @param array|null $allowedContentTypes Allowed content types (if null, it will allow all)
	 * @param bool $untrusted			Untrusted endpoint, redirect only if the host is the same, so google.com > www.google.com is ok, but google.com > badActor.com is not
	 * @param int|null $bytesToGet Number of bytes to get. null means get everything you greedy piggie
	 *
	 * @return	void
	 */
	public function __construct( Url $url, int $timeout=5, string $httpVersion=NULL, bool|int $followRedirects=TRUE, array $allowedProtocols=NULL, array $allowedContentTypes=NULL, bool $untrusted=false, int|null $bytesToGet = null)
	{
		/* Init */
		$this->url						= $url;
		$this->curl						= curl_init();
		$this->httpVersion				= $httpVersion ?: '1.1';
		$this->timeout					= $timeout;
		$this->followRedirects			= $followRedirects;
		$this->allowedProtocols			= $allowedProtocols ?: array( 'http', 'https', 'ftp', 'scp', 'sftp', 'ftps' );
		$this->allowedContentTypes	    = $allowedContentTypes;
		$this->bytesToGet				= $bytesToGet;
		$this->untrusted				= $untrusted;

		/* Need to adjust if this is FTP */
		$user	= null;
		$pass	= null;

		if( isset( $this->url->data['scheme'] ) AND $this->url->data['scheme'] == 'ftp' )
		{
			if( isset( $this->url->data['user'] ) AND $this->url->data['user'] AND isset( $this->url->data['pass'] ) AND $this->url->data['pass'] )
			{
				$user	= $this->url->data['user'];
				$pass	= $this->url->data['pass'];

				$this->url->data['user']	= null;
				$this->url->data['pass']	= null;
				$this->url	= $this->url->setFragment( null );
			}

			/* Set our basic settings */
			curl_setopt_array( $this->curl, array(
				CURLOPT_HEADER			=> TRUE,								// Specifies that we want the headers
				CURLOPT_RETURNTRANSFER	=> TRUE,								// Specifies that we want the response
				CURLOPT_SSL_VERIFYPEER	=> FALSE,								// Specifies that we don't need to validate the SSL certificate, if applicable (causes issues with, for example, API calls to CPanel in Nexus)
				CURLOPT_TIMEOUT			=> $timeout,							// The timeout
				CURLOPT_URL				=> (string) $this->url,					// The URL we're requesting
				) );

			/* Need to set user and pass if this is FTP */
			if( $user !== null AND $pass !== null )
			{
				curl_setopt( $this->curl, CURLOPT_USERPWD, $user . ':' . $pass );
			}
		}
		else
		{
			/* Work out HTTP version */
			if( $httpVersion === null )
			{
				$version = curl_version();

				/* Before 7.36 there are some issues handling chunked-encoded data */
				if( version_compare( $version['version'], '7.36', '>=' ) )
				{
					$httpVersion = '1.1';
				}
				else
				{
					$httpVersion = '1.0';
				}
			}

			$httpVersion = ( $httpVersion == '1.1' ? CURL_HTTP_VERSION_1_1 : CURL_HTTP_VERSION_1_0 );
						
			/* Set our basic settings */
			curl_setopt_array( $this->curl, array(
				CURLOPT_HEADER			=> TRUE,								// Specifies that we want the headers
				CURLOPT_HTTP_VERSION	=> $httpVersion,						// Sets the HTTP version
				CURLOPT_RETURNTRANSFER	=> TRUE,								// Specifies that we want the response
				CURLOPT_SSL_VERIFYPEER	=> FALSE,								// Specifies that we don't need to validate the SSL certificate, if applicable (causes issues with, for example, API calls to CPanel in Nexus)
				CURLOPT_TIMEOUT			=> $timeout,							// The timeout
				CURLOPT_URL				=> (string) $this->url,					// The URL we're requesting
				) );
		}
	}
	
	/**
	 * Destructor
	 *
	 * @return	void
	 */
	public function __destruct()
	{
		curl_close( $this->curl );
		if( static::$_multiHandle instanceof CurlMultiHandle )
		{
			curl_multi_close( static::$_multiHandle );
		}
	}
	
	/**
	 * Login
	 *
	 * @param string $username		Username
	 * @param string $password		Password
	 * @return	static
	 */
	public function login( string $username, string $password ): static
	{
		curl_setopt_array( $this->curl, array(
			CURLOPT_HTTPAUTH		=> CURLAUTH_BASIC,
			CURLOPT_USERPWD			=> "{$username}:{$password}"
			) );
		
		return $this;
	}
	
	/**
	 * Set Headers
	 *
	 * @param array $headers		Key/Value a pair of headers
	 * @return	static
	 */
	public function setHeaders( array $headers ): static
	{
		if ( Application::appIsEnabled('cloud') )
		{
			if ( $this->internalSignedRequest and isset( $this->cicHeaders ) )
			{
				$headers = array_replace( $headers, $this->cicHeaders );
			}
		}

		$extra = array();
	
		foreach ( $headers as $k => $v )
		{
			switch ( $k )
			{
				case 'Cookie':
					curl_setopt( $this->curl, CURLOPT_COOKIE, $v );
					break;
				
				case 'Accept-Encoding':
					curl_setopt( $this->curl, CURLOPT_ENCODING, $v );
					break;
				
				case 'Referer':
					curl_setopt( $this->curl, CURLOPT_REFERER, $v );
					break;
					
				case 'User-Agent':
					curl_setopt( $this->curl, CURLOPT_USERAGENT, $v );
					break;
					
				default:
					if ( $k === 'Content-Type' )
					{
						$this->modifiedContentType = TRUE;
					}
					$extra[] = "{$k}: {$v}";
					break;
			}

			$this->headersForLog[ $k ] = "{$k}: {$v}";
		}
		
		if ( !empty( $extra ) )
		{
			curl_setopt( $this->curl, CURLOPT_HTTPHEADER, $extra );
		}
		
		return $this;
	}
	
	
	/**
	 * Toggle SSL checks
	 *
	 * @param boolean $value	True will enable SSL checks, false will disable them
	 * @return	static
	 */
	public function sslCheck( bool $value=TRUE ): static
	{
		curl_setopt_array( $this->curl, array(
			CURLOPT_SSL_VERIFYHOST  => ( $value ) ? 2 : FALSE,
			CURLOPT_SSL_VERIFYPEER	=> $value
		) );
		
		return $this;
	}
	
	/**
	 * Force TLS
	 *
	 * @return	static
	 */
	public function forceTls(): static
	{
		$curlVersionData = curl_version();
		if ( preg_match( '/^OpenSSL\/(\d+\.\d+\.\d+)/', $curlVersionData['ssl_version'], $openSSLVersionData ) )
		{
			if ( version_compare( $openSSLVersionData[1], '1.0.1', '>=' ) )
			{
				if ( !defined('CURL_SSLVERSION_TLSv1_2') ) // constant not defined in PHP < 5.5
	            {
	                define( 'CURL_SSLVERSION_TLSv1_2', 6 );
	            }
	            curl_setopt( $this->curl, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2 );
			}
			else
			{
				if ( !defined('CURL_SSLVERSION_TLSv1') ) // constant not defined in PHP < 5.5
	            {
	                define( 'CURL_SSLVERSION_TLSv1', 1 );
	            }
	            curl_setopt( $this->curl, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1 );
			}
		}
        		
		return $this;
	}
	
	/**
	 * HTTP GET
	 *
	 * @param mixed|null $data	Data to send with the GET request
	 * @return	Response
	 * @throws    CurlException
	 */
	public function get( mixed $data=NULL ): Response
	{
		/* Specify that this is a GET request */
		curl_setopt( $this->curl, CURLOPT_HTTPGET, TRUE );
		$this->dataForLog = NULL;
		
		if ( $data )
		{
			curl_setopt( $this->curl, CURLOPT_POSTFIELDS, $data );
		}
					
		return $this->_executeAndFollowRedirects( 'GET' );
	}
	
	/**
	 * HTTP POST
	 *
	 * @param mixed|null $data	Data to post (can be array or string)
	 * @return	Response
	 * @throws    CurlException
	 */
	public function post( mixed $data=NULL ): Response
	{
		if ( Application::appIsEnabled('cloud') )
		{
			if ( $this->internalSignedRequest )
			{
				$data = \IPS\Cicloud\prepareInternalPayload( $data );
			}
		}

		/* Specify that this is a POST request */
		curl_setopt( $this->curl, CURLOPT_POST, TRUE );
		
		/* Set the data */
		curl_setopt( $this->curl, CURLOPT_POSTFIELDS, $this->_dataToSend( $data ) );
		
		/* Execute */
		return $this->_executeAndFollowRedirects( 'POST', $data );
	}
	
	/**
	 * HTTP HEAD
	 *
	 * @return	Response
	 * @throws    CurlException
	 */
	public function head(): Response
	{	
		/* Specify the request method */
		curl_setopt( $this->curl, CURLOPT_CUSTOMREQUEST, 'HEAD' );

		/* For HEAD requests, do not try to fetch the body or curl times out */
		curl_setopt( $this->curl, CURLOPT_NOBODY, true );
		
		/* Execute */
		return $this->_executeAndFollowRedirects( 'HEAD' );
	}
		
	/**
	 * Magic Method: __call
	 * Used for other HTTP methods (like PUT and DELETE)
	 *
	 * @param string $method	Method (A HTTP method)
	 * @param array $params	Parameters (a single parameter with data to post, which can be an array or a string)
	 * @return	Response
	 * @throws    CurlException
	 */
	public function __call( string $method, array $params )
	{		
		/* Specify the request method */
		curl_setopt( $this->curl, CURLOPT_CUSTOMREQUEST, mb_strtoupper( $method ) );

		/* If we have any data to send, set it */
		if ( isset( $params[0] ) )
		{
			curl_setopt( $this->curl, CURLOPT_POSTFIELDS, $this->_dataToSend( $params[0] ) );
		}
		
		/* Execute */
		return $this->_executeAndFollowRedirects( mb_strtoupper( $method ), $params );
	}
	
	/**
	 * Data to send
	 *
	 * @param mixed|null $data	Data to post (can be array or string)
	 * @return	mixed
	 */
	protected function _dataToSend( mixed $data=NULL ): mixed
	{	
		$this->dataForLog = $data;
		if ( !$this->modifiedContentType and is_array( $data ) )
		{
			$data = http_build_query( $data, '', '&' );
		}
		return $data;
	}
	
	/**
	 * Execute the request
	 *
	 * @return	Response
	 * @throws	CurlException
	 *@todo    Remove if multi handle works out long term.
	 */
	protected function _execute(): Response
	{
		if ( $this->bytesToGet !== null )
		{
			/* Why not CURLOPT_RANGE? Servers can choose to ignore it (https://curl.se/libcurl/c/CURLOPT_RANGE.html) */
			$data = '';
			$headerProcessed = false;
			$headers = '';
			$bytesToGet = $this->bytesToGet;
			curl_setopt( $this->curl, CURLOPT_WRITEFUNCTION, function( $ch, $chunk ) use ( &$data, &$headerProcessed, &$headers, $bytesToGet )
			{
				if ( ! $headerProcessed )
				{
					// Find the end of the headers
					$endOfHeaders = strpos( $chunk, "\r\n" );

					if ( $endOfHeaders !== false )
					{
						$headerProcessed = true; // Now start processing the content
					}
					else
					{
						$headers .= $chunk;
						return strlen( $chunk ); // No headers ending found, skip this part of data
					}
				}

				$length = strlen( $data ) + strlen( $chunk );

				if ( $length >= $bytesToGet )
				{
					$data .= substr( $chunk, 0, $bytesToGet - strlen( $data ) );
					return 0;
				}
				$data .= $chunk;
				return strlen( $chunk );
			} );

			curl_exec( $this->curl );
			$output = $headers . $data;
		}
		else
		{
			/* Execute */
			$output = curl_exec( $this->curl );
		}

		/* Log - but because the output can be large, only do this if we explicitly have debug logging enabled */
		Log::debug( "\n\n------------------------------------\ncURL REQUEST: {$this->url}\n------------------------------------\n\n" . implode( "\n", $this->headersForLog ) . "\n\n"  . var_export( $this->dataForLog, TRUE ) . "\n\n------------------------------------\nRESPONSE\n------------------------------------\n\n" . $output, 'request' );
		
		/* Errors? */
		if ( $output === FALSE )
		{
			throw new CurlException( $this->url . "\n" . curl_error( $this->curl ), curl_errno( $this->curl ) );
		}

		/* If this is FTP we need to fudge the headers a little */
		if( isset( $this->url->data['scheme'] ) and $this->url->data['scheme'] == 'ftp' )
		{
			$output = "HTTP/1.1 200 OK\nFTP: True\r\n\r\n" . $output;
		}
				
		/* Return it */
		$response = new Response( $output );

		if ( ! $this->isContentTypeValid( $response ) )
		{
			throw new CurlException( 'Invalid content type' );
		}

		return $response;
	}

	/**
	 * Is the returned content type valid?
	 *
	 * @param Response $response
	 * @return bool
	 */
	protected function isContentTypeValid( Response $response ): bool
	{
		if ( $this->allowedContentTypes !== null and $response->httpHeaders !== null )
		{
			$headers = array_change_key_case( $response->httpHeaders, CASE_LOWER );
			$contentType = $headers['content-type'] ?? 'unknown/unknown';
			if ( strstr( $contentType, ';' ) )
			{
				$contentType = explode( ';', $contentType );
				$contentType = $contentType[0];
			}

			if ( ! in_array( strtolower( $contentType ), $this->allowedContentTypes ) )
			{
				return false;
			}
		}

		return true;
	}

	/**
	 * @breif   Store the cURL Multi Handle
	 */
	protected static null|false|CurlMultiHandle $_multiHandle = NULL;

	/**
	 * Execute the request via cURL Multi - We use this to cache and share connections between requests
	 *
	 * @return	Response
	 * @throws	CurlException
	 */
	protected function _executeMh(): Response
	{
		/* If we're restricting by bytes, then we need to use the plain execute */
		if( $this->bytesToGet !== null )
		{
			return $this->_execute();
		}

		/* Init multi handle if needed */
		if( empty( static::$_multiHandle ) )
		{
			static::$_multiHandle = curl_multi_init();
		}

		/* Store handle for this request */
		curl_multi_add_handle( static::$_multiHandle, $this->curl );

		/* Execute request and wait for response */
		$active = 0;
		do
		{
			$ret = curl_multi_exec( static::$_multiHandle, $active );
		}
		while( $ret == CURLM_CALL_MULTI_PERFORM );

		while( $active && $ret == CURLM_OK )
		{
			if( curl_multi_select( static::$_multiHandle ) != -1 )
			{
				do
				{
					$mrc = curl_multi_exec( static::$_multiHandle, $active );
				}
				while( $mrc == CURLM_CALL_MULTI_PERFORM );
			}
		}

		$output = curl_multi_getcontent( $this->curl );

		/* Remove handles we no longer need */
		curl_multi_remove_handle( static::$_multiHandle, $this->curl );

		/* Log - but because the output can be large, only do this if we explicitly have debug logging enabled */
		Log::debug( "\n\n------------------------------------\ncURL REQUEST: {$this->url}\n------------------------------------\n\n" . implode( "\n", $this->headersForLog ) . "\n\n"  . var_export( $this->dataForLog, TRUE ) . "\n\n------------------------------------\nRESPONSE\n------------------------------------\n\n" . $output, 'request' );

		/* Errors? */
		if ( $output === FALSE )
		{
			throw new CurlException( $this->url . "\n" . curl_error( $this->curl ), curl_errno( $this->curl ) );
		}

		/* If this is FTP we need to fudge the headers a little */
		if( isset( $this->url->data['scheme'] ) and $this->url->data['scheme'] == 'ftp' )
		{
			$output = "HTTP/1.1 200 OK\nFTP: True\r\n\r\n" . $output;
		}

		/* Return it */
		$response = new Response( $output );

		if ( ! $this->isContentTypeValid( $response ) )
		{
			throw new CurlException( 'Invalid content type' );
		}

		return $response;
	}
	
	/**
	 * Execute the request and follow redirects id necessary
	 *
	 * @param string $method		Request method to use
	 * @param array|null $params		Parameters to send with request
	 * @return	Response
	 * @throws	CurlException
	 */
	protected function _executeAndFollowRedirects( string $method, mixed $params=NULL ): Response
	{
		/* Execute */
		$response = $this->_executeMh();

		/* Either return it or follow it */
		if ( $this->followRedirects and in_array( $response->httpResponseCode, array( 301, 302, 303, 307, 308 ) ) )
		{
			/* Fix missing hostname in location */
			foreach( $response->httpHeaders as $k => $v )
			{
				if( mb_strtolower( $k ) == 'location' )
				{
					$location = $v;
				}
			}

			if( isset( $location ) and parse_url( $location, PHP_URL_HOST ) === NULL )
			{
				$location = $this->url->data['scheme'] . '://' . $this->url->data['host'] . $location;
			}

			if ( isset( $location ) and $this->untrusted )
			{
				/* We want to make sure that the main domain is the same. So domain.com/foo can redirect to domain.com/newfoo and domain.co.uk can redirect to www.domain.co.uk but domain.co.uk cannot redirect to anotherdomain.com */
				$pass = false;
				if ( strtolower( $this->url->data['host'] ) === strtolower( parse_url( $location, PHP_URL_HOST ) ) )
				{
					// 1: Do a simple test: sub.domain.tld/foo -> sub.domain.tld/bar
					$pass = true;
				}
				else
				{
					// 2: Now we need to check for sub.domain.tld to www.domain.tld, or domain.tld to www.domain.tld
					IPS::$PSR0Namespaces['Pdp'] = \IPS\ROOT_PATH .'/system/3rd_party/Pdp';

					$publicSuffixList = Rules::fromPath( \IPS\ROOT_PATH .'/system/3rd_party/Pdp/tlds.dat' );
					$newDomain = Domain::fromIDNA2008( parse_url( $location, PHP_URL_HOST ) );
					$result = $publicSuffixList->resolve( $newDomain );
					$newLocation = $result->registrableDomain()->toString();

					$oldDomain = Domain::fromIDNA2008( $this->url->data['host'] );
					$result = $publicSuffixList->resolve( $oldDomain );
					$oldLocation = $result->registrableDomain()->toString();

					if ( strtolower( $oldLocation ) === strtolower( $newLocation ) )
					{
						$pass = true;
					}
				}

				if ( ! $pass )
				{
					throw new Exception( 'untrusted_redirect' );
				}
			}

			$newRequest = Url::external( $location );

			if( !in_array( $newRequest->data['scheme'], $this->allowedProtocols ) )
			{
				throw new Exception( 'protocol_not_followed' );
			}

			$newRequest = $newRequest->request( $this->timeout, $this->httpVersion, is_int( $this->followRedirects ) ? ( $this->followRedirects - 1 ) : $this->followRedirects );
			return $newRequest->$method( $params );
		}
		return $response;
	}
}

/**
 * CURL Exception Class
 */
class CurlException extends Exception
{ }