<?php
/*========================================================================*\
|| ###################################################################### ||
|| # vBulletin 6.2.3 - Licence Number L3138846
|| # ------------------------------------------------------------------ # ||
|| # Copyright 2000-2026 MH Sub I, LLC dba vBulletin. All Rights Reserved.  # ||
|| # This file may not be redistributed in whole or significant part.   # ||
|| # ----------------- VBULLETIN IS NOT FREE SOFTWARE ----------------- # ||
|| # http://www.vbulletin.com | http://www.vbulletin.com/license.html   # ||
|| ###################################################################### ||
\*========================================================================*/

/**
 * Light version of the application, for fixed routes like getting phrases, options, etc. At the time of writing this, the
 * biggest improvement is skipping the route parsing. There's a lot of processing needed for handling forum-type, channel-type urls
 * that isn't needed for the static routes.
 *
 * @package		vBulletin presentation
 */

class vB5_Frontend_ApplicationLight extends vB5_ApplicationAbstract
{
	//This is just the array of routing-type information.  It defines how the request will be processed.
	protected $application = [];

	//This defines the routes that can be handled by this class.
	protected static $quickRoutes = array
	(
		'ajax/loaddata' => [
			'handler'     => 'loadData',
			'requirePost' => true,
		],
		'filedata/fetch' => [
			'handler'     => 'fetchImage',
			'requirePost' => false,
		],
		'external' => [
			'controller'     => 'external',
			'callcontroller' => true,
			'method'         => 'output',
			'requirePost'    => false,
		],
	);

	/**
	 * @var array Quick routes that match the beginning of the route string
	 */
	protected static $quickRoutePrefixMatch = [
		// note, keep this before ajax/api. More specific routes should come before
		// less specific ones, to allow the prefix check to work correctly, see constructor.
		'ajax/apidetach' => [
			'handler'     => 'handleAjaxApiDetached',
			'requirePost' => true,
		],
		'ajax/api' => [
			'handler'     => 'handleAjaxApi',
			'requirePost' => true,
		],
		'ajax/render' => [
			'handler'     => 'callRender',
			'requirePost' => true,
		],
	];

	protected $userid;
	protected $languageid;

	/** Tells whether this class can process this request
	 *
	 * @return bool
	 */
	public static function isQuickRoute()
	{
		if (empty($_REQUEST['routestring']))
		{
			return false;
		}

		if (isset(self::$quickRoutes[$_REQUEST['routestring']]))
		{
			return true;
		}

		foreach (self::$quickRoutePrefixMatch AS $prefix => $route)
		{
			if (substr($_REQUEST['routestring'], 0, strlen($prefix)) == $prefix)
			{
				return true;
			}
		}

		return false;
	}

	/**Standard constructor. We only access applications through init() **/
	protected function __construct()
	{
		if (empty($_REQUEST['routestring']))
		{
			return false;
		}

		if (isset(self::$quickRoutes[$_REQUEST['routestring']]))
		{
			$this->application = self::$quickRoutes[$_REQUEST['routestring']];
			return true;
		}

		foreach (self::$quickRoutePrefixMatch AS $prefix => $route)
		{
			if (substr($_REQUEST['routestring'], 0, strlen($prefix)) == $prefix)
			{
				$this->application = $route;
				return true;
			}
		}

		return false;
	}

	/**
	 * This is the standard way to initialize an application
	 *
	 * @param string $rootDir location of the site root (index.php) file.
	 *
	 * @return vB5_Frontend_ApplicationLight
	 */
	public static function init($rootDir)
	{
		parent::init($rootDir);
		self::$instance->convertInputArrayCharset();
		return self::$instance;
	}

	/**
	 * Executes the application. Normally this means to get some data. We usually return in json format.
	 *
	 * @return bool
	 * @throws vB_Exception_Api
	 */
	public function execute()
	{
		if (empty($this->application))
		{
			throw new vB_Exception_Api('invalid_request');
		}

		// These handlers must require POST request method, but POST requests can accept parameters passed in via
		// both the post body ($_POST) and querystring in the url ($_GET)
		if ($this->application['requirePost'])
		{
			if (strtoupper($_SERVER['REQUEST_METHOD']) !== 'POST')
			{
				throw new vB5_Exception('Incorrect HTTP Method. Please use a POST request.');
			}

			// Also require a CSRF token check.
			static::checkCSRF();
		}

		$serverData = array_merge($_GET, $_POST);

		if (!empty($this->application['handler']) AND method_exists($this, $this->application['handler']))
		{
			$app = $this->application['handler'];
			call_user_func([$this, $app], $serverData);

			return true;
		}
		else if ($this->application['callcontroller'])
		{
			$response = $this->callController(
				$this->application['controller'],
				$this->application['method']
			);

			// using an array will let us have more control on the response.
			// we can easily extend to support printing different kind of outputs.
			echo $response['response'];

			return true;
		}
		else
		{
			//We need to create a session
			$result = Api_InterfaceAbstract::instance()->callApi(
				$this->application['controller'],
				$this->application['method'],
				$serverData,
				true
			);
		}

		$controller = new vB5_Frontend_Controller();
		$controller->sendAsJson($result);

		return true;
	}

	/**
	 * Calls a controller action and returns the response.
	 *
	 * @param 	string 	Controller name.
	 * @param 	string 	Controller action.
	 *
	 * @return 	array 	Information of controller call:
	 *					- Response => the result from calling the controller action.
	 *
	 */
	private function callController($controller, $action)
	{
		$controller = ucfirst(strtolower($controller));
		$action = ucfirst(strtolower($action));
		$controllerClass = 'vB5_Frontend_Controller_' . $controller;
		$controllerMethod = 'action' . $action;

		if (class_exists($controllerClass) AND method_exists($controllerClass, $controllerMethod))
		{
			$controller = new $controllerClass();
			return ['response' => $controller->$controllerMethod()];
		}

		return ['response' => ''];
	}

	protected function loadData($serverData)
	{
		$options = $serverData['options'] ?? [];
		$phrases = $serverData['phrases'] ?? [];

		$api = Api_InterfaceAbstract::instance(Api_InterfaceAbstract::API_LIGHT);

		$phraseValues = $api->callApi(
			'phrase',
			'getPhrases',
			[$phrases]
		);

		if (isset($phraseValues['errors']))
		{
			$this->sendAsJson($phraseValues);
			return;
		}

		$optionValues = $api->callApi(
			'options',
			'fetchValues',
			[$options]
		);

		//it's just this side of possible that we'll have an option called "errors"
		//and unlike phrases we don't correctly return this as a subfield of the result.
		if (isset($optionValues['errors']) AND is_array($optionValues['errors']))
		{
			$this->sendAsJson($optionValues);
			return;
		}

		$this->sendAsJson([
			'phrases' => $phraseValues['phrases'],
			'options' => $optionValues,
		]);
	}

	/**
	 * Renders a template from an ajax call
	 *
	 * @param array Array of server data (from $_POST and/or $_GET, see execute())
	 */
	protected function callRender($serverData)
	{
		$routeInfo = explode('/', $serverData['routestring']);

		if (count($routeInfo) < 3)
		{
			throw new vB5_Exception_Api('ajax', 'render', [], 'invalid_request');
		}

		$templateName = $routeInfo[2];
		if ($templateName == 'widget_php')
		{
			$result = [
				'template' => '',
				'css_links' => [],
			];
		}
		else
		{
			$this->router = new vB5_Frontend_Routing();
			$this->router->setRouteInfo([
				'action'          => 'actionRender',
				'arguments'       => $serverData,
				'template'        => $templateName,
				// this use of $_GET appears to be fine,
				// since it's setting the route query params
				// not sending the data to the template
				// render
				'queryParameters' => $_GET,
			]);
			Api_InterfaceAbstract::setLight();
			$result = vB5_Template::staticRenderAjax($templateName, $serverData);
		}

		if (isset($result['template']))
		{
			$styleid = vB5_Template_Stylevar::instance()->getPreferredStyleId();
			$api = Api_InterfaceAbstract::instance();
			$api->invokeHook('hookFrontendAfterAjaxRender', ['styleid' => $styleid, 'template' => &$result['template']]);
		}

		$this->sendAsJson($result);
	}

	/**
	 * This handles an ajax api call.
	 *
	 * @param array Array of server data (from $_POST and/or $_GET, see execute())
	 */
	protected function handleAjaxApi($serverData)
	{
		$routeInfo = explode('/', $serverData['routestring']);

		if (count($routeInfo) < 4)
		{
			throw new vB5_Exception_Api('ajax', 'api', [], 'invalid_request');
		}

		//we use : to delineate packages in controller names, but that's a reserved
		//character in the url structure so we use periods in URLs.
		$controller = str_replace('.', ':', $routeInfo[2]);

		$this->sendAsJson(Api_InterfaceAbstract::instance(Api_InterfaceAbstract::API_LIGHT)->callApi(
			$controller,
			$routeInfo[3],
			$serverData,
			true
		));
	}

	/**
	 * This handles an ajax api call, detatched from the current request
	 *
	 * @param array Array of server data (from $_POST and/or $_GET, see execute())
	 */
	protected function handleAjaxApiDetached($serverData)
	{
		// Keep this function in sync with vB5_Frontend_Controller::sendAsJsonAndCloseConnection()
		// TODO: Make the controller function public and have this call it.
		// The main reason I didn't do this now is because there are some differences between this class's
		// sendAsJson() & the controller, and the changes were starting to get a bit too big for this particular
		// JIRA than I was comfortable with.

		//make sure this is a valid request before detaching.
		$routeInfo = explode('/', $serverData['routestring']);
		if (count($routeInfo) < 4)
		{
			throw new vB5_Exception_Api('ajax', 'apidetach', [], 'invalid_request');
		}

		//if we don't get the api before we close the connection we can end up failing
		//to set cookies, which will cause the entire call to fail.  That is bad.
		$api = Api_InterfaceAbstract::instance(Api_InterfaceAbstract::API_LIGHT);

		$this->sendAsJsonAndCloseConnection();
		//we use : to delineate packages in controller names, but that's a reserved
		//character in the url structure so we use periods in URLs.
		$controller = str_replace('.', ':', $routeInfo[2]);

		//don't do anything with the return, we've already let the broswer go.
		$api->callApi(
			$controller,
			$routeInfo[3],
			$serverData,
			true
		);
	}

	/**
	 * Outputs an attachment
	 *
	 * @param array Array of server data (from $_POST and/or $_GET, see execute())
	 */
	protected function fetchImage($serverData)
	{
		$api = Api_InterfaceAbstract::instance('light');

		$request = [
			'id'          => 0,
			'type'        => '',
			'includeData' => true,
		];

		if (isset($serverData['type']) AND !empty($serverData['type']))
		{
			$request['type'] = $serverData['type'];
		}
		else if (!empty($serverData['thumb']) AND intval($serverData['thumb']))
		{
			$request['type'] = 'thumb';
		}

		$isAttachment = false;
		$fileInfo = [];
		$filename = '';
		if (!empty($serverData['id']) AND intval($serverData['id']))
		{
			// Don't put an intval() call in an if condition and then subsequently
			// *use* the non-intval'ed value. Normally, you'd use intval to
			// typecast *before* the if condition.
			$request['id'] = intval($serverData['id']);

			set_error_handler([$this, 'handleImageError'], E_ALL) ;

			// we can have type photo nodes coming in via the id parameter when text.previewimage is used in article listings or the content slider module.
			$nodeInfo = $api->callApi('node', 'getNode', ['nodeid' => $request['id']]);
			if (!isset($nodeInfo['errors']))
			{
				$contentType = $api->callApi('contenttype', 'fetchContentTypeClassFromId', ['contenttypeid' => $nodeInfo['contenttypeid']]);
				if ($contentType == 'Photo')
				{
					$fileInfo = $api->callApi('content_photo', 'fetchImageByPhotoid', $request);
				}
				else
				{
					$fileInfo = $api->callApi('content_attach', 'fetchImage', $request);
					$isAttachment = true;
				}
			}
		}
		else if (!empty($serverData['filedataid']) AND intval($serverData['filedataid']))
		{
			$request['id'] = intval($serverData['filedataid']);

			if (!empty($serverData['attachmentnodeid']))
			{
				$request['attachmentnodeid'] = (int) $serverData['attachmentnodeid'];
				/*
					$isAttachment note :
					This is used by the edit form (contententry_panel_attachments_item &
					editor_gallery_photoblock templates), so I think we can skip the "is
					this a photo or attachment" check and skip incrementing the view count.
				 */
			}

			set_error_handler([$this, 'handleImageError'], E_ALL) ;
			$fileInfo = $api->callApi('filedata', 'fetchImageByFiledataid', $request);
		}
		else if (!empty($serverData['photoid']) AND intval($serverData['photoid']))
		{
			$request['id'] = intval($serverData['photoid']);
			$fileInfo = $api->callApi('content_photo', 'fetchImageByPhotoid', $request);
		}
		else if (!empty($serverData['linkid']) AND intval($serverData['linkid']))
		{
			$request['id'] = intval($serverData['linkid']);
			$request['includeData'] = false;
			set_error_handler([$this, 'handleImageError'], E_ALL) ;
			$fileInfo = $api->callApi('content_link', 'fetchImageByLinkId', $request);
		}
		else if (!empty($serverData['attachid']) AND intval($serverData['attachid']))
		{
			$request['id'] = intval($serverData['attachid']);
			set_error_handler([$this, 'handleImageError'], E_ALL) ;
			$fileInfo = $api->callApi('content_attach', 'fetchImage', $request);
			$isAttachment = true;
		}
		else if (!empty($serverData['channelid']) AND intval($serverData['channelid']))
		{
			$request['id'] = intval($serverData['channelid']);
			set_error_handler([$this, 'handleImageError'], E_ALL) ;
			$fileInfo = $api->callApi('content_channel', 'fetchChannelIcon', $request);
		}

		if (!empty($fileInfo['filedata']))
		{
			header('ETag: "' . $fileInfo['filedataid'] . '"');
			header('Accept-Ranges: bytes');
			header('Content-Transfer-Encoding: binary');

			//We didn't previously use the filename field even though it always appeared to match what we construct here.
			//It's not clear why.  However we've updated the value of this field to better reflect the proper filenames
			//for attachments and we *don't* want to be figuring this out here.  Leaving the old method in case there
			//is somewhere we don't
			$extension = strtolower($fileInfo['extension']);
			if (!empty($fileInfo['filename']))
			{
				$filename = $fileInfo['filename'];
			}
			else
			{
				$filename = 'image_' . $fileInfo['filedataid'] .  '.' . $extension;
			}

			//check the mime type for things we want to include inline.  Fall back to the old way
			//of checking the extension for images.  This should make it easier to classify
			//unknown extensions based on the attachment type record.
			if (
				in_array($this->getFileBaseType($fileInfo), ['image', 'video', 'audio']) OR
				in_array($extension, ['jpg', 'jpe', 'jpeg', 'gif', 'png'])
			)
			{
				$disposition = 'inline';
			}
			else
			{
				$disposition = 'attachment';
			}

			header('Content-Disposition: ' . $disposition . '; filename="' . $filename . '"');

			// set up the output; either the full file or a range of bytes
			$output = '';
			if (isset($_SERVER['HTTP_RANGE']))
			{
				// output a specific range of bytes

				// set the default range to the whole file
				$rangeStart = 0;
				$rangeEnd = $fileInfo['filesize'] - 1;
				$multipleRanges = false;

				// parse the range header
				if (preg_match('#bytes\s*=\s*(\d+)\s*-\s*(\d*)(\D?.*)#i', $_SERVER['HTTP_RANGE'], $matches))
				{
					$rangeStart = (int) $matches[1];
					if (!empty($matches[2]))
					{
						$rangeEnd = (int) $matches[2];
					}
					$multipleRanges = (!empty($matches[3]) AND strpos($matches[3], ',') !== false);
				}

				$rangeLength = $rangeEnd - $rangeStart + 1;

				$isValidRange = (
					$rangeLength > 0
					AND
					$rangeLength <= $fileInfo['filesize']
					AND
					$rangeStart >= 0
					AND
					$rangeStart < $fileInfo['filesize'] - 1
					AND
					$rangeEnd > 0
					AND
					$rangeEnd < $fileInfo['filesize']
					AND
					$rangeStart < $rangeEnd
					AND
					!$multipleRanges
				);

				header('Cache-Control: public, must-revalidate, max-age=0');
				header('Pragma: no-cache');
				header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $fileInfo['dateline']) . ' GMT');

				if ($isValidRange)
				{
					header('HTTP/1.1 206 Partial Content');
					header('Content-Length: ' . $rangeLength);
					header('Content-Range: bytes ' . $rangeStart . '-' . $rangeEnd . '/' . $fileInfo['filesize']);
					if ($rangeStart == 0 AND $rangeLength == $fileInfo['filesize'])
					{
						$output = $fileInfo['filedata'];
					}
					else
					{
						// substr acts on bytes, not characters in the data.
						// mbstring.func_overload can mess with that, but it's deprecated and shouldn't be used.
						$output = substr($fileInfo['filedata'], $rangeStart, $rangeLength);
					}
				}
				else
				{
					header('HTTP/1.1 416 Requested Range Not Satisfiable');
					header('Content-Length: 0');
					header('Content-Range: bytes 0-' . ($fileInfo['filesize'] - 1) . '/' . $fileInfo['filesize']);
					$output = '';
				}
			}
			else
			{
				// output the entire file
				header('Content-Length: ' . $fileInfo['filesize']);
				header('Cache-control: max-age=31536000, private');
				header('Expires: ' . gmdate("D, d M Y H:i:s", time() + 31536000) . ' GMT');
				header('Pragma:');
				header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $fileInfo['dateline']) . ' GMT');
				$output = $fileInfo['filedata'];
			}

			// Attachment type headers specified in the Admin CP do this right before sending output so the headers can
			// potentially override ones we've already set foreach ($fileInfo['headers'] AS $header)
			foreach ($fileInfo['headers'] AS $header)
			{
				header($header);
			}

			echo $output;

			// Attachment view tracking. If below process takes too long, we'll want to send the output & close the connection
			// to browser via $this->outputToBrowserAndCloseConnection($output) instead of echo $output above.
			$options = $api->callApi('options', 'fetchValues', [['attachmentviewstrack']]);
			$doAttachIncrement = !empty($options['attachmentviewstrack']);
			if ($isAttachment AND $doAttachIncrement)
			{
				// If we're serving the file in chunks, only increment once.
				// For simplicity, let's increment at the start, though incrementing at the end
				// might make more sense (in case the file is very large and download is cancelled)
				if (!isset($_SERVER['HTTP_RANGE']) OR
					(
						!empty($isValidRange) AND isset($rangeStart) AND $rangeStart== 0
					)
				)
				{
					// Currently if $isAttachment is true $request['id'] is set, and is the attachment's nodeid.
					$assertor = vB::getDbAssertor();
					$assertor->assertQuery('vBForum:incrementAttachCounter', ['nodeid' => $request['id']]);
				}
			}

		}
		else
		{
			if (($fileInfo['errors'][0][0] ?? '') == 'no_attach_view_permissions')
			{
				// differentiate permission errors from other errors per VBV-11121
				return self::handleNoPermission();
			}
			$this->invalidFileResult($api);
		}
	}

	//this is terrible, but I don't want to try to fix the entire way we handle mime-types for
	//attachments at present so we'll parse it out of the generic headers.
	private function getFileBaseType($fileInfo)
	{
		$re = "#Content-Type *: *([^;]*)#i";
		$type = '';
		foreach ($fileInfo['headers'] AS $header)
		{
			if (preg_match($re, $header, $matches))
			{
				$type = $matches[1];
			}
		}

		if ($type)
		{
			$type = explode('/', $type, 2)[0];
		}

		return strtolower($type);
	}


	private function invalidFileResult($api)
	{
		$apiresult = $api->callApi('phrase', 'renderPhrases', [['invalid_file_specified' => 'invalid_file_specified']]);
		if (!isset($apiresult['errors']))
		{
			$error = $apiresult['phrases']['invalid_file_specified'];
		}
		else
		{
			//if we can't look up the phrase let's return *something*
			$error = "Invalid File Specified";
		}

		header("Content-Type: text/plain");
		header("Content-Length: " . strlen($error));
		http_response_code(404);
		echo $error;
		exit;
	}

	/**
	 * Handle and error for an image
	 *
	 * If it's a fatal error we will display the invalid file response.  Otherwise we'll attempt
	 * to supress any output and hope for the best (since any messages will screw up the image)
	 * In either case if we would normally display/log the message we will attempt to mimic
	 * that behavior.
	 */
	public function handleImageError($errno, $errstr, $errfile, $errline)
	{
		//Note that not all of these error codes are trappable and therefore
		//many cannot actually occur here.  They are listed for completeness
		//and possible future proofing if that changes.
		$label = "";
		$fatal = false;
		switch($errno)
		{
			case E_DEPRECATED:
			case E_USER_DEPRECATED:
				$label = "Notice";
				break;

			case E_WARNING:
			case E_CORE_WARNING:
			case E_USER_WARNING:
			case E_COMPILE_WARNING:
				$label = "Warning";
				break;

			case E_NOTICE:
			case E_USER_NOTICE:
				$label = "Notice";
				break;

			case E_ERROR:
			case E_PARSE:
			case E_CORE_ERROR:
			case E_COMPILE_ERROR:
			case E_USER_ERROR:
			case E_RECOVERABLE_ERROR:
				$label = "Fatal error";
				$fatal = true;
				break;

			//if we don't know what the error type is, php added it after 5.6
			//we'll punt to the system error handler because we simply don't know
			//what we are dealing with.  This risks leaking the path on files, but
			//that's not as bad as exiting on a warning or not exiting on a fatal error
			default:
				return false;
				break;
		}

		//php changed the way dispay_errors is reported in version 5.2.  We probably don't
		//have to care about the old way, but this covers all of the bases.
		$display_errors = in_array(strtolower(ini_get('display_errors')), ['on', '1']);
		if ((error_reporting() & $errno) AND $display_errors)
		{
			$message = "$label: $errstr in $errfile on line $errline";

			//try to mimic the logging behavior of the default function
			if (ini_get('log_errors'))
			{
				error_log($message);
			}
		}

		if ($fatal)
		{
			$api = Api_InterfaceAbstract::instance('light');
			$this->invalidFileResult($api);
		}

		//we've got this -- no need to bother the default handler
		return true;
	}

	/**
	 * Displays a vB page for exceptions
	 *
	 *	@param	mixed 	exception
	 *	@param	bool 	Bypass API and display simple error message
	 */
	public static function handleException($exception, $simple = false)
	{
		$config = vB5_Config::instance();

		if ($config->debug)
		{
			echo "Exception ". $exception->getMessage() . ' in file ' . $exception->getFile() . ", line " . $exception->getLine() .
				"<br />\n". $exception->getTrace();
		}

		if (!headers_sent())
		{
			// Set HTTP Headers
			if ($exception instanceof vB5_Exception_404)
			{
				http_response_code(404);
			}
			else
			{
				http_response_code(500);
			}
		}
		die();
	}
}

/*=========================================================================*\
|| #######################################################################
|| # Downloaded: 13:27, Sun Aug 30th 2026
|| # CVS: $RCSfile$ - $Revision: 123056 $
|| #######################################################################
\*=========================================================================*/
