<?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   # ||
|| ###################################################################### ||
\*========================================================================*/

class vB5_Frontend_Controller_Page extends vB5_Frontend_Controller
{
	/**
	 * This method is used from template code to render a template and store it in a variable
	 * @param string $templateName
	 * @param array $data
	 * @param bool $isParentTemplate
	 */
	public static function renderTemplate($templateName, $data = [], $isParentTemplate=true)
	{
		if (empty($templateName))
		{
			return null;
		}

		return vB5_Template::staticRender($templateName, $data, $isParentTemplate);
	}

	public function index($pageid)
	{
		//the api init can redirect.  We need to make sure that happens before we echo anything
		$api = Api_InterfaceAbstract::instance();

		// The register page may need to set a cookie; do this before echoing anything
		if (!empty($_REQUEST['routestring']) AND $_REQUEST['routestring'] === 'register' AND !empty($_REQUEST['ref']))
		{
			$referralCode = $api->callApi('referuser', 'validateReferralCode', [$_REQUEST['ref']]);
			if ($referralCode)
			{
				// The cookie is set for 30 days. This is independent of the setting
				// for how long a referral code is valid. The user must register within
				// 30 days of when they followed the referral link for it to work. In
				// any case, the validity of the actual referral code is checked during
				// registration, which is what is used to assign referrals.
				vB5_Cookie::set('referralcode', $referralCode, 30);
			}
		}

		$fullPageKey = $this->getPageCacheKey();
		$this->echoCachedPage($fullPageKey);
		$top = $this->getTop();

		$router = vB5_ApplicationAbstract::instance()->getRouter();
		$arguments = $router->getArguments();

		$page = $this->getPage($api, $router, $arguments, true);
		// We might have altered the arguments via a hook in the getPage function.  Maintain that.
		// We should flow these back into the router class somehow to ensure that we don't assume
		// at some point that they are still good when they are not (by making them always good).
		$arguments = $page['routeInfo']['arguments'];
		$this->handleGotoNewpost($api, $router, $arguments);

		$queryParameters = $router->getQueryParameters();
		/*
		 *	VBV-12506
		 *	this is where we would add other things to clean up dangerous query params.
		 *	For VBV-12486, I'll just unset anything here that can't use vb:var in the templates,
		 *	but really we should just make a whitelist of expected page object parameters that
		 *	come from the query string and unset EVERYTHING else. For the expected ones, we
		 *	should also force the value into the expected (and hopefully safer) range
		*/
		/*
		 *	VBV-12506
		 *	$doNotReplaceWithQueryParams is a list of parameters that the page object usually
		 *	gets naturally/internally, and we NEVER want to replace with a user provided query
		 *	parameter. (In fact, *when* exactly DO we want to do this???)
		 *	If we don't do this, it's a potential XSS vulnerability for the items that we
		 *	cannot send through vb:var for whatever reason (title for ex)
		 * 	and even if they *are* sent through vb:var, the replacements can sometimes just
		 *	break the page even when it's sent through vb:var (for example, ?pagetemplateid=%0D,
		 *	the new line this inserts in var pageData = {...} in the header template tends to
		 *	break things (tested on Chrome).
		 *	Furthermore, any script that uses the pageData var would get the user injected data
		 *	that might cause more problems down the line.
		 *	Parameter Notes:
		 *		'title'
		 *			Similar to above, but channels are allowed to have HTML in the title, so
		 *			they are intentinoally not escaped in the DB, and the templates can't use
		 *			vb:var.
		 *		'pageid', 'channelid', 'nodeid'
		 *			These are usually set in the arguments, so the array_merge below usually
		 *			takes care of not passing a pageid query string through to the page object,
		 *			but I'm leaving them in just in case.
		 */
		$doNotReplaceWithQueryParams = [
			// title prefix isn't used.  Leaving it in case it comes back.
			'titleprefix',
			'title',
			'pageid',
			'channelid',
			'nodeid',
			'pagetemplateid',
			'url',
			'pagenum',
			'tagCloudTitle',
		];
		foreach ($doNotReplaceWithQueryParams AS $key)
		{
			unset($queryParameters[$key]);
		}

		$arguments = array_merge($queryParameters, $arguments);
		foreach ($arguments AS $key => $value)
		{
			$page[$key] = $value;
		}

		$options = vB5_Template_Options::instance();
		$page['phrasedate'] = $options->get('miscoptions.phrasedate');
		$page['optionsdate'] = $options->get('miscoptions.optionsdate');

		// if no meta description, use node data or global one instead, prefer node data
		if (empty($page['metadescription']) AND !empty($page['nodedescription']))
		{
			$page['metadescription'] = $page['nodedescription'];
		}

		if (empty($page['metadescription']))
		{
			$page['metadescription'] = $options->get('options.description');
		}

		$page['headertitle'] = $this->getPageTitle($api, $page);
		$this->echoPage($page, $top, $fullPageKey);
	}

	// should try to collapse thes into the basic page flow but need to sort out any differences
	public function actionSearch()
	{
		//the api init can redirect.  We need to make sure that happens before we echo anything
		$api = Api_InterfaceAbstract::instance();

		$fullPageKey = $this->getPageCacheKey();
		$this->echoCachedPage($fullPageKey);
		$top = $this->getTop();

		$router = vB5_ApplicationAbstract::instance()->getRouter();
		$arguments = $router->getArguments();

		// This ensures we get the correct page when we redirect form the results below.
		// We should either do a proper redirect or reset the router object because this causes
		// all kinds of wierdness.
		$serverData = array_merge($_GET, $_POST);
		if (empty($serverData['searchJSON']) AND empty($serverData['r']) AND empty($serverData['e']))
		{
			$adv_search = $api->callApi('route', 'getRouteByIdent', ['ident' => 'advanced_search'], true);
			$arguments = $adv_search['arguments'];
		}

		$page = $this->getPage($api, $router, $arguments, false);
		//We don't actually change arguments here but future proof in case we change getPage to always call the hook.
		$arguments = $page['routeInfo']['arguments'];

		$phrases = $api->callApi('phrase', 'fetch', [['advanced_search']]);

		$page['title'] = $phrases['advanced_search'];
		$page['url'] = vB5_Route::buildUrl('advanced_search');
		$page['crumbs'] = $router->getBreadcrumbs();

		if (!empty($serverData['searchJSON']))
		{
			$decoded = json_decode($serverData['searchJSON'], true);
			if (!empty($decoded))
			{
				$page['searchJSON'] = json_encode($decoded);
			}
		}
		else if (!empty($serverData['r']))
		{
			$page['resultId'] = $serverData['r'];
			if (!empty($serverData['p']) && is_numeric($serverData['p']))
			{
				$page['currentPage'] = intval($serverData['p']);
			}
		}
		else if (!empty($serverData['e']))
		{
			$route = $api->callApi('route', 'getRouteByIdent', ['ident' => 'advanced_search'], true);
			$page = $api->callApi('page', 'fetchPageById', [$route['arguments']['pageid']]);
			$page['resultId'] = $serverData['e'];
		}

		$page['headertitle'] = $this->getPageTitle($api, $page);
		$this->echoPage($page, $top, $fullPageKey);
	}

	public function actionSearchresult()
	{
		//the api init can redirect.  We need to make sure that happens before we echo anything
		$api = Api_InterfaceAbstract::instance();

		$fullPageKey = $this->getPageCacheKey();
		$this->echoCachedPage($fullPageKey);
		$top = $this->getTop();

		$router = vB5_ApplicationAbstract::instance()->getRouter();
		$arguments = $router->getArguments();

		// This appears to be an attempt to push the page to advanced search based on a flag.
		// It feels like this should be handled as part of the routing code but that's beyond the
		// scope of the current refactor.
		$serverData = array_merge($_GET, $_POST);
		if (!empty($serverData['AdvSearch']))
		{
			$adv_search = $api->callApi('route', 'getRouteByIdent', ['ident' => 'advanced_search'], true);
			$arguments = $adv_search['arguments'];
		}

		$page = $this->getPage($api, $router, $arguments, false);
		//We don't actually change arguments here but future proof in case we change getPage to always call the hook.
		$arguments = $page['routeInfo']['arguments'];

		// override the default crumb logic.  This should be handled in the routing like all of the other breadcrumbs
		$phrases = $api->callApi('phrase', 'fetch', [['advanced_search', 'search_results']]);
		$page['crumbs'] = [
			0 => [
				'title' => $phrases['advanced_search'],
				'url' => vB5_Template_Runtime::buildUrl('advanced_search', [], [], ['noBaseUrl' => true]),
			],
			1 => [
				'title' => $phrases['search_results'],
				'url' => '',
			],
		];

		// "q" apparently means they're using the quick search box.
		//
		//  The "has q but no searchJSON" block is hit by responsive width search forms.
		//  Responsive width search box/form that's part of the hamburger menu lacks the
		//  #searchForm ID which global.js expects to convert the 'q' into searchJson.keywords.
		//  I don't know if this was intentional or just an oversight, so going with a minimal
		//  change to remove the default sort by title for keyword searches that's been left
		//  from very old code.
		//  If it turns out that people use sort by titles more, we need to make that the default
		//  for BOTH blocks not just this block, otherwise we have inconsistent behavior between
		//  desktop & mobile.
		if (!empty($serverData['q']) AND empty($serverData['searchJSON']))
		{
			$serverData['q'] = str_replace('\\', '', $serverData['q']);
			// Note that the searchJSON block below applies filter_var() on keywords.
			$serverData['searchJSON'] = [
				'keywords' => $serverData['q'],
			];
			unset($serverData['q']);

			// Allegedly this is for VBV-2826 -- PM Search Box returns results from forum
			// I guess the PM search box used to add "type":"vBForum_PrivateMessage" to the
			// post data. When I tested the message center recently (on full width & responsive size)
			// it was using the full searchJSON rather than just 'q' & 'type', so I think this is
			// outdated code, but will leave it until we have time to clean this up.
			// TODO: Check if this is still used anywhere and remove it.
			$searchType = '';
			if (!empty($serverData['type']))
			{
				$serverData['type'] = str_replace(['"', '\\'], '', $serverData['type']);
				$serverData['type'] = strip_tags($serverData['searchJSON']['keywords']);
				$serverData['searchJSON']['type'] = $serverData['type'];
				unset($serverData['type']);
			}

			// Send this through the standardized searchJSON handling below for more consistent behavior...
		}

		if (!empty($serverData['searchJSON']))
		{
			if (is_string($serverData['searchJSON']))
			{
				if (preg_match('/[^\x00-\x7F]/', $serverData['searchJSON']))
				{
					$serverData['searchJSON'] = vB5_String::toUtf8($serverData['searchJSON'], vB5_String::getTempCharset());
				}
				$serverData['searchJSON'] = json_decode($serverData['searchJSON'], true);
			}

			if (!empty($serverData['searchJSON']))
			{
				if (!empty($serverData['searchJSON']['keywords']))
				{
					$serverData['searchJSON']['keywords'] = strip_tags($serverData['searchJSON']['keywords']);
				}
				$serverData['searchJSON'] = json_encode($serverData['searchJSON']);
			}
			else
			{
				$serverData['searchJSON'] = '';
			}

			$page['searchJSON'] = $serverData['searchJSON'];
			$extra = [
				'searchJSON' => !empty($serverData['searchJSON']) ? $serverData['searchJSON'] : '{}'
			];

			if (!empty($serverData['AdvSearch']))
			{
				$extra['AdvSearch'] = 1;
			}

			$page['url'] = str_replace('&amp;', '&', vB5_Route::buildUrl('search', [], $extra));
			$page['crumbs'][0]['url'] = vB5_Template_Runtime::buildUrl('advanced_search', [], ['searchJSON' => $page['searchJSON']], ['noBaseUrl' => true]);
		}
		elseif (!empty($serverData['r']))
		{
			unset($page['crumbs'][0]);
			$page['url'] = str_replace('&amp;', '&', vB5_Route::buildUrl('search', [], ['r' => $serverData['r']]));
			$page['resultId'] = $serverData['r'];
			if (!empty($serverData['p']) && is_numeric($serverData['p']))
			{
				$page['currentPage'] = intval($serverData['p']);
			}
			$page['crumbs'][0]['url'] = vB5_Template_Runtime::buildUrl('advanced_search', [], ['r' => $serverData['r']], ['noBaseUrl' => true]);
		}
		else
		{
			return $this->actionSearch();
		}

		$page['headertitle'] = $this->getPageTitle($api, $page);
		$this->echoPage($page, $top, $fullPageKey);
	}

	private function getTop()
	{
		$top = vB5_ApplicationAbstract::getPreheader();
		if (vB5_Request::get('useEarlyFlush'))
		{
			// We may want to create PHP sessions at some point but we don't know yet and this is our last change to
			// initalize it properly.  Creating the/session is likely less overhead than figuring out if we need to
			// and we'd like to expand the user of PHP sessions in the future.
			if (session_status() == PHP_SESSION_NONE)
			{
				session_start();
			}
			echo $top;
			flush();
		}

		return $top;
	}

	// we didn't previously trigger the hookGetPageMain hook on the search pages, arguably we should.
	private function getPage($api, $router, $arguments, $dohook)
	{
		$userAction = $router->getUserAction();
		$pageKey = $router->getPageKey();

		$api->callApi('page', 'preload', [$pageKey]);
		if ($userAction)
		{
			$api->callApi(
				'wol',
				'register',
				[
					$userAction['action'],
					$userAction['params'],
					$pageKey,
					vB::getRequest()->getScriptPath(),
					(!empty($arguments['nodeid']) ? $arguments['nodeid'] : 0)
				]
			);
		}

		if (isset($arguments['pagenum']))
		{
			$arguments['pagenum'] = intval($arguments['pagenum']) > 0 ? intval($arguments['pagenum']) : 1;
		}

		$pageid = intval($arguments['pageid'] ?? $arguments['contentid'] ?? 0);
		if ($pageid < 1)
		{
			throw new vB5_Exception_404('invalid_page_url');
		}

		$page = $api->callApi('page', 'fetchPageById', [$pageid, $arguments]);
		if ($dohook)
		{
			$api->invokeHook('hookGetPageMain', [
				'pageid' => $pageid,
				'useraction' => $userAction,
				'page' => &$page,
				'pagekey' => &$pageKey,
				'arguments' => &$arguments,
			]);
		}

		if (!$page)
		{
			throw new vB5_Exception_404('invalid_page_url');
		}

		$page['routeInfo'] = [
			'routeId' => $router->getRouteId(),
			'arguments'	=> $arguments,
			'queryParameters' => $router->getQueryParameters()
		];
		$page['crumbs'] = $router->getBreadcrumbs();
		$page['headlinks'] = $router->getHeadLinks();
		$page['pageKey'] = $pageKey;

		// default value for pageSchema
		$page['pageSchema'] = 'WebPage';

		return $page;
	}

	// this feels like it should be part of the route and not here in the controller.  It's very specific
	private function handleGotoNewpost($api, $router, $arguments)
	{
		// Go to the first new / unread post for this user in this topic
		if (($_REQUEST['goto'] ?? '') == 'newpost' AND !empty($arguments['nodeid']) AND !empty($arguments['channelid']))
		{
			$readMarkCutoff = time() - ($this->vboptions['markinglimit'] * 86400);
			if (vB5_User::get('userid'))
			{
				// Database read marking
				['readtime' => $channelRead] = $api->callApi('node', 'getNodeReadTime', [$arguments['channelid']]);
				['readtime' => $topicRead] = $api->callApi('node', 'getNodeReadTime', [$arguments['nodeid']]);
				$topicView = max($topicRead, $channelRead, $readMarkCutoff);
			}
			else
			{
				// Guest read marking. Only based on cutoff since cookie read marking is removed.
				$topicView = $readMarkCutoff;
			}
			$topicView = intval($topicView);

			// Get the first unread reply
			['nodeid' => $goToNodeId] = $api->callApi('node', 'getFirstChildAfterTime', [$arguments['nodeid'], $topicView]);

			if (empty($goToNodeId))
			{
				$thread = $api->callApi('node', 'getNodes', [[$arguments['nodeid']]]);
				if (!empty($thread) AND isset($thread[$arguments['nodeid']]))
				{
					$goToNodeId = $thread[$arguments['nodeid']]['lastcontentid'];
				}
			}

			if ($goToNodeId)
			{
				// Redirect to the new post
				$urlCache = vB5_Template_Url::instance();
				$urlKey = $urlCache->register($router->getRouteId(), ['nodeid' => $arguments['nodeid']], ['p' => $goToNodeId]);
				$replacements = $urlCache->finalBuildUrls([$urlKey]);
				$url = $replacements[$urlKey];
				if ($url)
				{
					$url .= '#post' . $goToNodeId;
					if (headers_sent())
					{
						echo '<script type="text/javascript">window.location = "' . $url . '";</script>';
					}
					else
					{
						header('Location: ' . $url);
					}
					exit;
				}
			}
		}
	}

	private function getPageCacheKey() : string
	{
		if (
			vB5_Config::instance()->debug OR
			vB5_User::get('userid') OR
			vB5_Request::get('cachePageForGuestTime') == 0
		)
		{
			return '';
		}

		// We don't want to cache some specific pages guests interact with.  We should make that
		// a property of the page/route rather than this hard coded check.
		if (in_array($_REQUEST['routestring'] ?? '', ['register', 'lostpw']))
		{
			return '';
		}

		// languageid should be in the pagekey to fix VBV-8095
		$fullPageKey = 'vBPage_' . md5(serialize($_REQUEST)) . '_' . vB::getCurrentSession()->get('languageid');

		$styleid = vB5_Cookie::get('userstyleid', vB5_Cookie::TYPE_UINT);
		if (!empty($styleid))
		{
			$fullPageKey .= '_' . $styleid;
		}

		return $fullPageKey;
	}

	private function echoCachedPage($fullPageKey)
	{
		if ($fullPageKey)
		{
			$fullPage = vB_Cache::instance(vB_Cache::CACHE_LARGE)->read($fullPageKey);
			if (!empty($fullPage))
			{
				echo $fullPage;
				exit;
			}
		}
	}

	private function echoPage(array $page, string $top, string $fullPageKey) : void
	{
		// This doesn't appear to be used anywhere -- the js accesses the cookie value directly.
		$page['ignore_np_notices'] = vB5_ApplicationAbstract::getIgnoreNPNotices();

		$templater = new vB5_Template($page['screenlayouttemplate']);
		$templater->registerGlobal('page', $page);
		$pagetext = $this->processPage($templater->render());

		// Trigger off the key here so that we don't need duplicate the cache/don't cache logic
		// from when we checked for a cached page initially.  If we don't have a key it's because
		// we don't want to cache pages.
		if ($fullPageKey)
		{
			vB_Cache::instance()->write($fullPageKey, $top . $pagetext, vB5_Request::get('cachePageForGuestTime'), 'vbCachedFullPage');
		}

		if (!vB5_Request::get('useEarlyFlush'))
		{
			echo $top;
		}

		echo $pagetext;
	}
}

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