<?php
/*
 * @package 	RSFirewall!
 * @copyright 	(c) 2009 - 2024 RSJoomla!
 * @link 		https://www.rsjoomla.com/joomla-extensions/joomla-security.html
 * @license 	GNU General Public License https://www.gnu.org/licenses/gpl-3.0.en.html
 */

\defined('_JEXEC') or die;

use Joomla\CMS\Uri\Uri;
use Joomla\CMS\Http\HttpFactory;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;

class RSFirewallGoogleSafeBrowsingV4
{
	public function buildData()
	{
		return json_encode(array(
			'client'     => array(
				'clientId'      => 'RSFirewall!',
				'clientVersion' => (string) new RSFirewallVersion,
			),
			'threatInfo' => array(
				'threatTypes'      => RSFirewallGoogleSafeBrowsing::$threatTypes,
				'platformTypes'    => array('ANY_PLATFORM', 'PLATFORM_TYPE_UNSPECIFIED'),
				'threatEntryTypes' => array('URL'),
				'threatEntries'    => array(
					array('url' => urlencode(Uri::root())),
				)
			)
		));
	}

	/**
	 * @return string
	 * @since 2.11.1
	 */
	public function buildUrl()
	{
		$apiKey = trim(RSFirewallConfig::getInstance()->get('google_safebrowsing_api_key', ''));
		return 'https://safebrowsing.googleapis.com/v4/threatMatches:find?key=' . urlencode($apiKey);
	}

	public function threatSearch()
	{
		try
		{
			$http = HttpFactory::getHttp();

			$request = $http->post(
				$this->buildUrl(),
				$this->buildData(),
				array(
					'Content-Type' => 'application/json'
				)
			);
		}
		catch (Exception $e)
		{
			// Dummy response in case something went wrong
			$request = (object) array(
				'code' => 9999,
				'body' => json_encode(array('error' => array('message' => $e->getMessage())))
			);
		}

		$body = $request->body ? json_decode($request->body, true) : null;

		switch ($request->code)
		{
			case 200:
				if (empty($body))
				{
					return array(
						'success' => true,
						'result' => true,
						'message' => Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_VALID'),
						'details' => ''
					);
				}

				$reason = array();
				// v4 returns matches[] with a single threatType per match
				if (!empty($body['matches']))
				{
					foreach ($body['matches'] as $match)
					{
						$reason[] = $match['threatType'];
					}
				}
				$reason = implode(' ', $reason);

				return array(
					'success' => true,
					'result' => false,
					'message' => Text::sprintf('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_INVALID', $reason),
					'details' => Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_INVALID_DETAILS')
				);
				break;
			case 400:
				return array(
					'success' => true,
					'result' => false,
					'message' => isset($body['error']['message']) ? $body['error']['message'] : Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_BAD_REQUEST'),
					'details' => ''
				);
				break;
			case 403:
				return array(
					'success' => true,
					'result' => false,
					'message' => isset($body['error']['message']) ? $body['error']['message'] : Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_BAD_API_KEY'),
					'details' => Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_HOW_TO_GET_KEY')
				);
				break;
			case 500:
				return array(
					'success' => true,
					'result' => false,
					'message' => isset($body['error']['message']) ? $body['error']['message'] : Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_INTERNAL_SERVER_ERROR'),
					'details' => ''
				);
				break;
			case 503:
				return array(
					'success' => true,
					'result' => false,
					'message' => isset($body['error']['message']) ? $body['error']['message'] : Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_SERVICE_UNAVAILABLE'),
					'details' => ''
				);
				break;
			case 504:
				return array(
					'success' => true,
					'result' => false,
					'message' => isset($body['error']['message']) ? $body['error']['message'] : Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_TIMEOUT'),
					'details' => ''
				);
				break;
			default:
				return array(
					'success' => false,
					'result' => false,
					'message' => isset($body['error']['message']) ? $body['error']['message'] : Text::_('COM_RSIFREWALL_SOMETHING_WENT_WRONG'),
					'details' => ''
				);
				break;
		}
	}
}

class RSFirewallGoogleSafeBrowsingV5
{
	/**
	 * @return string
	 * @since 2.11.1
	 */
	public function buildUrl()
	{
		$apiKey = trim(RSFirewallConfig::getInstance()->get('google_safebrowsing_api_key', ''));
		return 'https://safebrowsing.googleapis.com/v5/urls:search?key=' . urlencode($apiKey) . '&urls=' . urlencode(Uri::root());
	}

	public function threatSearch()
	{
		try
		{
			$http = HttpFactory::getHttp();

			$request = $http->get(
				$this->buildUrl()
			);
		}
		catch (Exception $e)
		{
			// Dummy response in case something went wrong
			$request = (object) array(
				'code' => 9999,
				'body' => $e->getMessage()
			);
		}

		switch ($request->code)
		{
			case 200:
				$body = $this->decodeProtobufResponse($request->body);

				if (empty($body))
				{
					return array(
						'success' => true,
						'result'  => true,
						'message' => Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_VALID'),
						'details' => ''
					);
				}

				// v5 returns threats[] with threatTypes[] per ThreatUrl
				$reason = '';
				if (!empty($body['threats']))
				{
					$matched = array();
					foreach ($body['threats'] as $threat)
					{
						if (!empty($threat['threatTypes']))
						{
							foreach ($threat['threatTypes'] as $type)
							{
								if (in_array($type, RSFirewallGoogleSafeBrowsing::$threatTypes))
								{
									$matched[$type] = true;
								}
							}
						}
					}

					if (!empty($matched))
					{
						$reason = implode(' ', array_keys($matched));
					}
				}

				if (empty($reason))
				{
					return array(
						'success' => true,
						'result'  => true,
						'message' => Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_VALID'),
						'details' => ''
					);
				}

				return array(
					'success' => true,
					'result'  => false,
					'message' => Text::sprintf('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_INVALID', $reason),
					'details' => Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_INVALID_DETAILS')
				);
				break;
			case 400:
				return array(
					'success' => true,
					'result'  => false,
					'message' => Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_BAD_API_KEY'),
					'details' => Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_HOW_TO_GET_KEY')
				);
				break;
			case 500:
				return array(
					'success' => true,
					'result'  => false,
					'message' => Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_INTERNAL_SERVER_ERROR'),
					'details' => ''
				);
				break;
			case 503:
				return array(
					'success' => true,
					'result'  => false,
					'message' => Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_SERVICE_UNAVAILABLE'),
					'details' => ''
				);
				break;
			case 504:
				return array(
					'success' => true,
					'result'  => false,
					'message' => Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_TIMEOUT'),
					'details' => ''
				);
				break;
			default:
				return array(
					'success' => false,
					'result'  => false,
					'message' => $request->body ? strip_tags($request->body) : Text::_('COM_RSIFREWALL_SOMETHING_WENT_WRONG'),
					'details' => ''
				);
				break;
		}
	}

	/**
	 * Decode a protobuf SearchUrlsResponse into an associative array.
	 *
	 * Proto schema:
	 *   message SearchUrlsResponse { repeated ThreatUrl threats = 1; string cache_duration = 2; }
	 *   message ThreatUrl { string url = 1; repeated ThreatType threat_types = 2; }
	 *   enum ThreatType { THREAT_TYPE_UNSPECIFIED=0; MALWARE=1; SOCIAL_ENGINEERING=2; UNWANTED_SOFTWARE=3; POTENTIALLY_HARMFUL_APPLICATION=4; }
	 *
	 * @param  string $data Raw protobuf bytes
	 * @return array
	 */
	private function decodeProtobufResponse($data)
	{
		$result = array(
			'threats'       => array(),
			'cacheDuration' => '',
		);

		if (empty($data))
		{
			return $result;
		}

		$pos = 0;
		$len = strlen($data);

		while ($pos < $len)
		{
			$tag        = $this->readVarint($data, $pos, $len);
			$fieldNumber = $tag >> 3;
			$wireType    = $tag & 0x7;

			if ($wireType === 2)
			{
				// Length-delimited
				$length = $this->readVarint($data, $pos, $len);
				$value  = substr($data, $pos, $length);
				$pos   += $length;

				if ($fieldNumber === 1)
				{
					// ThreatUrl sub-message
					$result['threats'][] = $this->decodeThreatUrl($value);
				}
				elseif ($fieldNumber === 2)
				{
					// cache_duration string
					$result['cacheDuration'] = $value;
				}
			}
			elseif ($wireType === 0)
			{
				// Varint — skip unknown varint fields
				$this->readVarint($data, $pos, $len);
			}
			elseif ($wireType === 1)
			{
				$pos += 8; // 64-bit — skip
			}
			elseif ($wireType === 5)
			{
				$pos += 4; // 32-bit — skip
			}
			else
			{
				break; // Unknown wire type, stop parsing
			}
		}

		return $result;
	}

	/**
	 * Decode a protobuf ThreatUrl message.
	 *
	 * @param  string $data Raw protobuf bytes of ThreatUrl
	 * @return array
	 */
	private function decodeThreatUrl($data)
	{
		static $threatTypeMap = array(
			0 => 'THREAT_TYPE_UNSPECIFIED',
			1 => 'MALWARE',
			2 => 'SOCIAL_ENGINEERING',
			3 => 'UNWANTED_SOFTWARE',
			4 => 'POTENTIALLY_HARMFUL_APPLICATION',
		);

		$threatUrl = array(
			'url'         => '',
			'threatTypes' => array(),
		);

		$pos = 0;
		$len = strlen($data);

		while ($pos < $len)
		{
			$tag         = $this->readVarint($data, $pos, $len);
			$fieldNumber = $tag >> 3;
			$wireType    = $tag & 0x7;

			if ($wireType === 2)
			{
				// Length-delimited
				$length = $this->readVarint($data, $pos, $len);
				$value  = substr($data, $pos, $length);
				$pos   += $length;

				if ($fieldNumber === 1)
				{
					// url string
					$threatUrl['url'] = $value;
				}
				elseif ($fieldNumber === 2)
				{
					// Packed repeated enum
					$innerPos = 0;
					$innerLen = strlen($value);
					while ($innerPos < $innerLen)
					{
						$enumVal = $this->readVarint($value, $innerPos, $innerLen);
						if (isset($threatTypeMap[$enumVal]))
						{
							$threatUrl['threatTypes'][] = $threatTypeMap[$enumVal];
						}
					}
				}
			}
			elseif ($wireType === 0)
			{
				// Varint
				$val = $this->readVarint($data, $pos, $len);

				if ($fieldNumber === 2 && isset($threatTypeMap[$val]))
				{
					// Non-packed repeated enum (one value at a time)
					$threatUrl['threatTypes'][] = $threatTypeMap[$val];
				}
			}
			elseif ($wireType === 1)
			{
				$pos += 8;
			}
			elseif ($wireType === 5)
			{
				$pos += 4;
			}
			else
			{
				break;
			}
		}

		return $threatUrl;
	}

	/**
	 * Read a varint from binary protobuf data.
	 *
	 * @param  string $data
	 * @param  int    &$pos Current position (advanced by this method)
	 * @param  int    $len  Total data length
	 * @return int
	 */
	private function readVarint($data, &$pos, $len)
	{
		$result = 0;
		$shift  = 0;

		do
		{
			if ($pos >= $len)
			{
				break;
			}

			$byte   = ord($data[$pos]);
			$pos++;
			$result |= ($byte & 0x7F) << $shift;
			$shift  += 7;
		}
		while ($byte & 0x80);

		return $result;
	}
}

class RSFirewallGoogleSafeBrowsing
{
	/**
	 * @var string
	 * @since 2.11.1
	 */
	private $apiKey;
	/**
	 * @var string
	 * @since 2.11.1
	 */
	private $url;
	/**
	 * @var mixed|string
	 * @since 2.11.1
	 */
	private $data;
	/**
	 * @var string
	 */
	private $version;
	/**
	 * @var array
	 */
	public static $threatTypes = array('MALWARE', 'SOCIAL_ENGINEERING', 'UNWANTED_SOFTWARE', 'POTENTIALLY_HARMFUL_APPLICATION', 'THREAT_TYPE_UNSPECIFIED');
	/**
	 * @var RSFirewallGoogleSafeBrowsingV4 | RSFirewallGoogleSafeBrowsingV5
	 */
	private $api;

	/**
	 * RSFirewallGoogleSafeBrowsing constructor.
	 *
	 * @since 2.11.1
	 */
	public function __construct()
	{
		$this->version  = RSFirewallConfig::getInstance()->get('google_safebrowsing_version', 'v4');

		if ($this->version === 'v5')
		{
			$this->api = new RSFirewallGoogleSafeBrowsingV5;
		}
		else
		{
			$this->api = new RSFirewallGoogleSafeBrowsingV4;
		}
	}

	public function threatSearch()
	{
		return $this->api->threatSearch();
	}

	/**
	 * @param string $api
	 *
	 * @since 2.11.1
	 * @return RSFirewallGoogleSafeBrowsing
	 */
	public static function getInstance()
	{
		static $inst;
		if (!$inst)
		{
			$inst = new RSFirewallGoogleSafeBrowsing;
		}

		return $inst;
	}

	/**
	 * @return mixed
	 * @since 2.11.1
	 */
	public static function getGoogleResponse()
	{
		return RSFirewallGoogleSafeBrowsing::getInstance()->threatSearch();
	}

	/**
	 * @return array
	 * @since 2.11.1
	 */
	public function check()
	{
		$apiKey = trim(RSFirewallConfig::getInstance()->get('google_safebrowsing_api_key', ''));
		if (empty($apiKey))
		{
			return array(
				'success' => true,
				'result'  => false,
				'message' => Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_NO_API_KEY'),
				'details' => Text::_('COM_RSFIREWALL_GOOGLE_SAFE_BROWSER_STEP_SKIPPED')
			);
		}

		$cache = Factory::getCache('com_rsfirewall');
		$cache->setCaching(true);
		$cache->setLifeTime(86400);

		return $cache->get(array('RSFirewallGoogleSafeBrowsing', 'getGoogleResponse'));
	}
}