<?php namespace ProcessWire;

/**
 * ProcessWire Page View Process
 *
 * Enables viewing or Processes, one of the core components in connecting ProcessWire to HTTP.
 * 
 * For more details about how Process modules work, please see: 
 * /wire/core/Process.php 
 * 
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 *
 * @method string execute($internal = true)
 * @method string executeExternal()
 * @method ready(array $data = array())
 * @method finished(array $data = array())
 * @method failed(\Exception $e, $reason = '', $page = null, $url = '')
 * @method sendFile($page, $basename)
 * @method string pageNotFound($page, $url, $triggerReady = false, $reason = '', \Exception $e = null)
 * @method string|bool|array|Page pathHooks($path, $out)
 * @method void userNotAllowed(User $user, $page, PagesRequest $request)
 *
 */
class ProcessPageView extends Process {

	public static function getModuleInfo() {
		return array(
			'title' => __('Page View', __FILE__), // getModuleInfo title
			'summary' => __('All page views are routed through this Process', __FILE__), // getModuleInfo summary 
			'version' => 106, 
			'permanent' => true, 
			'permission' => 'page-view',
		);
	}

	/**
	 * Response types 
	 *
	 */
	const responseTypeError = 0;
	const responseTypeNormal = 1; 
	const responseTypeAjax = 2; 
	const responseTypeFile = 4; 
	const responseTypeRedirect = 8; 
	const responseTypeExternal = 16; 
	const responseTypeNoPage = 32;
	const responseTypePathHook = 64;

	/**
	 * Response type (see response type codes above)
	 *
	 */
	protected $responseType = 1; 

	/**
	 * True if any redirects should be delayed until after API ready() has been issued
	 * 
	 */
	protected $delayRedirects = false; 

	/**
	 * @var Page|null
	 * 
	 */
	protected $http404Page = null;

	/**
	 * Return value from first iteration of pathHooks() method (when applicable)
	 * 
	 * @var mixed 
	 * 
	 */
	protected $pathHooksReturnValue = false;

	/**
	 * Construct
	 * 
	 */
	public function __construct() {} // no parent call intentional

	/**
	 * Init
	 * 
	 */
	public function init() {} // no parent call intentional

	/**
	 * Retrieve a page, check access, and render
	 * 
	 * @param bool $internal True if request should be internally processed. False if PW is bootstrapped externally. 
	 * @return string Output of request
 	 *
	 */
	public function ___execute($internal = true) {
	
		if(!$internal) return $this->executeExternal();

		$this->responseType = self::responseTypeNormal;
		$config = $this->wire()->config; 
		$pages = $this->wire()->pages;
		$request = $pages->request();
		$timerKey = $config->debug ? 'ProcessPageView.getPage()' : ''; 
		
		if($config->usePoweredBy !== null) header('X-Powered-By:' . ($config->usePoweredBy ? ' ProcessWire CMS' : ''));
		
		$pages->setOutputFormatting(true); 
		
		if($timerKey) Debug::timer($timerKey); 
		$page = $this->getPage();
		if($timerKey) Debug::saveTimer($timerKey, ($page && $page->id ? $page->path : '')); 
		
		if($page && $page->id) {
			return $this->renderPage($page, $request);
		} else {
			return $this->renderNoPage($request);
		}
	}

	/**
	 * Get requested page
	 * 
	 * @return NullPage|Page
	 * @throws WireException
	 * 
	 */
	public function getPage() {
		return $this->wire()->pages->request()->getPage();
	}

	/**
	 * Render Page
	 * 
	 * @param Page $page
	 * @param PagesRequest $request
	 * @return bool|mixed|string
	 * @throws WireException
	 * @since 3.0.173
	 * 
	 */
	protected function renderPage(Page $page, PagesRequest $request) {
	
		$config = $this->wire()->config;
		$user = $this->wire()->user;
		
		$page->of(true);
		$originalPage = $page;
		$page = $request->getPageForUser($page, $user);
		$code = $request->getResponseCode();
		$file = $request->getFile();
		
		if($code == 403 && $file && $user->isLoggedin() && $originalPage->id === $user->id) {
			if($user->viewable($file, array('processName' => 'ProcessProfile'))) {
				// config.pagefileSecure is active and request is file for current user
				// allow file to be viewable if connected with user’s profile
				$code = 200;
				$page = $originalPage;
			}
		} 
		
		if($code == 401 || $code == 403) {
			$this->userNotAllowed($user, $originalPage, $request);
		}
		
		if(!$page || !$page->id || $originalPage->id == $config->http404PageID) {
			$this->checkForRedirect($request);
			$s = 'access not allowed';
			$e = new Wire404Exception($s, Wire404Exception::codePermission);
			return $this->pageNotFound($originalPage, $request->getRequestPath(), true, $s, $e);
		}

		if(!$this->delayRedirects) $this->checkForRedirect($request);

		$this->wire('page', $page);
		$this->ready();
		$page = $this->wire()->page; // in case anything changed it

		if($this->delayRedirects) {
			if($page !== $originalPage) $request->checkScheme($page);
			$this->checkForRedirect($request);
		}

		try {
			if($file) {
				$this->responseType = self::responseTypeFile;
				$this->wire()->setStatus(ProcessWire::statusDownload, array('downloadFile' => $file));
				$this->sendFile($page, $file);

			} else {
				$contentType = $this->contentTypeHeader($page, true);
				$this->wire()->setStatus(ProcessWire::statusRender, array('contentType' => $contentType));
				if($config->ajax) $this->responseType = self::responseTypeAjax;
				return $page->render();
			}

		} catch(Wire404Exception $e) {
			// 404 exception thrown during page render
			TemplateFile::clearAll();
			return $this->renderNoPage($request, array(
				'reason404' => '404 thrown during page render',
				'exception404' => $e, 
				'page' => $page, 
				'ready' => true, // let it know ready state already executed
			));

		} catch(\Exception $e) {
			// other exception thrown during page render (re-throw non 404 exceptions)
			$this->responseType = self::responseTypeError;
			$this->failed($e, "Thrown during page render", $page);
			throw $e; 
		}

		return '';
	}

	/**
	 * Render when no page mapped to request URL
	 * 
	 * @param PagesRequest $request
	 * @param array $options
	 * @return array|bool|false|string
	 * @throws WireException
	 * @since 3.0.173
	 * 
	 */
	protected function renderNoPage(PagesRequest $request, array $options = array()) {
		
		$defaults = array(
			'allow404' => true, // allow 404 to be thrown?
			'reason404' => 'Requested URL did not resolve to a Page',
			'exception404' => null, 
			'ready' => false, // are we executing from the API ready state?
			'page' => $this->http404Page(), // potential Page object (default is 404 page)
		);
		
		$options = count($options) ? array_merge($defaults, $options) : $defaults;
		
		$config = $this->wire()->config;
		$hooks = $this->wire()->hooks;
		$input = $this->wire()->input; 
		$pages = $this->wire()->pages;
		
		$requestPath = $request->getRequestPath();
		$pageNumPrefix = $request->getPageNumPrefix();
		$pageNumSegment = '';
		$setPageNum = 0;
		$page = null;
		$out = false;
		
		$this->setResponseType(self::responseTypeNoPage);
		
		if($pageNumPrefix !== null) {
			// request may have a pagination segment
			$pageNumSegment = $this->renderNoPagePagination($requestPath, $pageNumPrefix, $request->getPageNum());
			$setPageNum = $input->pageNum();
		}
			
		if(!$options['ready']) $this->wire('page', $options['page']);

		// run up to 2 times, once before ready state and once after
		for($n = 1; $n <= 2; $n++) {
			
			// only run once if already in ready state
			if($options['ready']) $n = 2;

			// call ready() on 2nd iteration only, allows for ready hooks to set $page
			if($n === 2 && !$options['ready']) $this->ready();
			
			if(!$hooks->hasPathHooks()) continue;

			$this->setResponseType(self::responseTypePathHook);
			
			try {
				$out = $this->pathHooks($requestPath, $out);
			} catch(Wire404Exception $e) {
				$out = false;
			}
		
			// allow for pathHooks() $event->return to persist between init and ready states
			// this makes it possible for ready() call to examine $event->return from init() call
			// in case it wants to concatenate it or something
			if($n === 1) $this->pathHooksReturnValue = $out;

			if($out instanceof Page) {
				// hook returned Page object to set as page to render
				$page = $out;
				$out = true;
			} else {
				// check if hooks changed $page API variable instead
				$page = $this->wire()->page;
			}

			// first hook that determines the $page wins
			if($page && $page->id && $page->id !== $options['page']->id) break;

			$this->setResponseType(self::responseTypeNoPage);
		}
	
		// did a path hook require a redirect for trailing slash (vs non-trailing slash)?
		$redirect = $hooks->getPathHookRedirect();
		if($redirect) {
			// path hook suggests a redirect for proper URL format
			$url = $config->urls->root . ltrim($redirect, '/');
			// if present, add pagination segment back into URL
			if($pageNumSegment) $url = rtrim($url, '/') . "/$pageNumSegment";
			$this->redirect($url);
		}
		
		$this->pathHooksReturnValue = false; // no longer applicable once this line reached
		$hooks->allowPathHooks(false); // no more path hooks allowed
		
		if($page instanceof Page && $page->id && $page->id !== $options['page']->id) {
			// one of the path hooks set the page
			$this->wire('page', $page);
			return $this->renderPage($page, $request); 
		}

		if($out === false) {
			// hook failed to handle request
			if($setPageNum > 1) $input->setPageNum(1);
			if($options['allow404']) {
				$page = $options['page'];
				// hooks to pageNotFound() method may expect NullPage rather than 404 page
				if($page->id == $config->http404PageID) $page = $pages->newNullPage();
				$out = $this->pageNotFound($page, $requestPath, false, $options['reason404'], $options['exception404']);
			} else {
				$out = false;
			}
			
		} else if($out === true) {
			// hook silently handled the request
			$out = '';
			
		} else if(is_array($out)) {
			// hook returned array to convert to JSON
			$jsonFlags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
			$contentTypes = $config->contentTypes;
			if(isset($contentTypes['json'])) header("Content-Type: $contentTypes[json]");
			$out = json_encode($out, $jsonFlags);
		}
		
		return $out;
	}

	/**
	 * Check for pagination in a no-page request (helper to renderNoPage method)
	 * 
	 * - Updates given request path to remove pagination segment.
	 * - Returns found pagination segment or blank if none.
	 * - Redirects to non-slash version if: pagination segment found with trailing slash,
	 *   no $page API var was present, or $page present but does not allow slash.
	 * 
	 * @param string $requestPath
	 * @param string|null $pageNumPrefix
	 * @param int $pageNum
	 * @return string Return found pageNum segment or blank if none
	 * 
	 */
	protected function renderNoPagePagination(&$requestPath, $pageNumPrefix, $pageNum) {
		
		$config = $this->wire()->config;
		
		if($pageNum < 1 || $pageNumPrefix === null) return '';

		// there is a pagination segment present in the request path
		$slash = substr($requestPath, -1) === '/' ? '/' : '';
		$requestPath = rtrim($requestPath, '/');
		$pageNumSegment = $pageNumPrefix . $pageNum;
		
		if(substr($requestPath, -1 * strlen($pageNumSegment)) === $pageNumSegment) {
			// remove pagination segment from request path
			$requestPath = substr($requestPath, 0, -1 * strlen($pageNumSegment));
			$setPageNum = (int) $pageNum;
			if($setPageNum === 1) {
				// disallow specific "/page1" in URL as it is implied by the lack of pagination segment
				$this->redirect($config->urls->root . ltrim($requestPath, '/'));
			} else if($slash) {
				// a trailing slash is present after the pageNum i.e. /page9/
				$page = $this->wire()->page;
				// a $page API var will be present if a 404 was manually thrown from a template file
				// but it likely won't be present if we are leading to a path hook
				if(!$page || !$page->id || !$page->template || !$page->template->allowPageNum) $page = null;
				if($page && ((int) $page->template->slashPageNum) > -1) {
					// $page API var present and trailing slash is okay
				} else {
					// no $page API var present or trailing slash on pageNum disallowed
					// enforce no trailing slashes for pagination numbers
					$this->redirect($config->urls->root . ltrim($requestPath, '/') . $pageNumSegment);
				}
			}
			$this->wire()->input->setPageNum($pageNum);
			
		} else {
			// not a pagination segment
			// add the slash back to restore requestPath
			$requestPath .= $slash;
			$pageNumSegment = '';
		}
		
		return $pageNumSegment;
	}

	/**
	 * Called when a 401 unauthorized or 403 forbidden request 
	 * 
	 * #pw-hooker
	 * 
	 * @param User $user
	 * @param Page|NullPage|null $page
	 * @param PagesRequest $request
	 * @since 3.0.186
	 * 
	 */
	protected function ___userNotAllowed(User $user, $page, PagesRequest $request) {
		
		$input = $this->wire()->input;
		$config = $this->wire()->config;
		$session = $this->wire()->session;

		if(!$session || !$page || !$page->id) return;
		if($user->isLoggedin()) return;

		$loginRequestURL = $request->getRedirectUrl();
		$ns = 'ProcessPageView'; // session namespace

		if(empty($loginRequestURL)) {
			$loginRequestURL = $session->getFor($ns, 'loginRequestURL');
		}

		if(!empty($loginRequestURL)) return;
		if($page->id == $config->loginPageID) return;
		if($input->get('loggedout')) return;

		$loginRequestURL = $input->url(array('page' => $page));

		if(!empty($_GET)) {
			$queryString = $input->queryStringClean(array(
				'maxItems' => 10,
				'maxLength' => 500,
				'maxNameLength' => 20,
				'maxValueLength' => 200,
				'sanitizeName' => 'fieldName',
				'sanitizeValue' => 'name',
				'entityEncode' => false,
			));
			if(strlen($queryString)) $loginRequestURL .= "?$queryString";
		}

		$session->setFor($ns, 'loginRequestPageID', $page->id);
		$session->setFor($ns, 'loginRequestURL', $loginRequestURL);
	}
	
	/**
	 * Check request for redirect and apply it when appropriate
	 *
	 * @param PagesRequest $request
	 *
	 */
	protected function checkForRedirect(PagesRequest $request) {
		$redirectUrl = $request->getRedirectUrl();
		if($redirectUrl) $this->redirect($redirectUrl, $request->getRedirectType());
	}

	/**
	 * Get and optionally send the content-type header
	 * 
	 * @param Page $page
	 * @param bool $send
	 * @return string
	 * 
	 */
	protected function contentTypeHeader(Page $page, $send = false) {
		
		$config = $this->wire()->config;
		$contentType = $page->template->contentType;
		
		if(!$contentType) return '';
		
		if(strpos($contentType, '/') === false) {
			if(isset($config->contentTypes[$contentType])) {
				$contentType = $config->contentTypes[$contentType];
			} else {
				$contentType = '';
			}
		}
		
		if($contentType && $send) header("Content-Type: $contentType");
		
		return $contentType;
	}

	/**
	 * Method executed when externally bootstrapped
	 * 
	 * @return string blank string
	 * 
	 */
	public function ___executeExternal() {
		$this->setResponseType(self::responseTypeExternal);
		$config = $this->wire()->config; 
		$config->external = true; 
		if($config->externalPageID) {
			$page = $this->wire()->pages->get((int) $config->externalPageID); 
		} else {
			$page = $this->wire()->pages->newNullPage();
		}
		$this->wire('page', $page);
		$this->ready();
		$this->wire()->setStatus(ProcessWire::statusRender, array('contentType' => 'external'));
		return '';
	}

	/**
	 * Hook called when the $page API var is ready, and before the $page is rendered. 
	 * 
	 * @param array $data
	 *
	 */
	public function ___ready(array $data = array()) { 
		$this->wire()->setStatus(ProcessWire::statusReady, $data);
	}

	/**
	 * Hook called with the pageview has been finished and output has been sent. Note this is called in /index.php.
	 * 
	 * @param array $data
	 *	
 	 */
	public function ___finished(array $data = array()) { 
		$this->wire()->setStatus(ProcessWire::statusFinished, $data);
	}

	/**
	 * Hook called when the pageview failed to finish due to an Exception or Error.
	 *
	 * Sends a copy of the throwable that occurred.
	 * 
	 * @param \Throwable $e Exception or Error
	 * @param string $reason
	 * @param Page|null $page
	 * @param string $url
	 *	
 	 */
	public function ___failed($e, $reason = '', $page = null, $url = '') { 
		$this->wire()->setStatusFailed($e, $reason, $page, $url); 
	}

	/**
	 * Passthru a file for a non-public page
	 *
	 * If the page is public, then it just does a 301 redirect to the file.
	 * 
	 * @param Page $page
	 * @param string $basename 
	 * @param array $options
	 * @throws Wire404Exception
	 *
	 */
	protected function ___sendFile($page, $basename, array $options = array()) {

		$err = 'File not found';

		if(!$page->hasFilesPath()) {
			throw new Wire404Exception($err, Wire404Exception::codeFile);
		}

		$filename = $page->filesPath() . $basename; 
		
		if(!file_exists($filename)) {
			throw new Wire404Exception($err, Wire404Exception::codeFile);
		}
	
		if(!$page->secureFiles()) {
			// if file is not secured, redirect to it
			// (potentially deprecated, only necessary for method 2 in checkRequestFile)
			$this->redirect($page->filesManager->url() . $basename);
			return;
		}

		// options for WireHttp::sendFile
		$defaults = array('exit' => false, 'limitPath' => $page->filesPath());
		$options = array_merge($defaults, $options);
		
		$this->wire()->files->send($filename, $options);
	}

	/**
	 * Called when a page is not found, sends 404 header, and displays the configured 404 page instead.
	 *
	 * Method is hookable, for instance if you wanted to log 404s. When hooking this method note that it
	 * must be hooked sometime before the ready state. 
	 * 
	 * @param Page|null $page Page that was found if applicable (like if user didn't have permission or $page's template threw the 404). If not applicable then NULL will be given instead.
	 * @param string $url The URL that the request originated from (like $_SERVER['REQUEST_URI'] but already sanitized)
	 * @param bool $triggerReady Whether or not the ready() hook should be triggered (default=false)
	 * @param string $reason Reason why 404 occurred, for debugging purposes (en text)
	 * @param WireException|Wire404Exception $exception Exception that was thrown or that indicates details of error
	 * @throws WireException
	 * @return string
	 */
	protected function ___pageNotFound($page, $url, $triggerReady = false, $reason = '', $exception = null) {
		
		if(!$exception) {
			// create exception but do not throw
			$exception = new Wire404Exception($reason, Wire404Exception::codeNonexist); 
		}

		$this->failed($exception, $reason, $page, $url); 
		$this->responseType = self::responseTypeError; 
		$this->header404();

		$page = $this->http404Page();
		if($page->id) {
			$this->wire('page', $page); 
			if($triggerReady) $this->ready();
			return $page->render();
		} else {
			return "404 page not found";
		}
	}

	/**
	 * Handler for path hooks
	 * 
	 * No need to hook this method directly, instead use a path hook. 
	 * 
	 * #pw-internal
	 * 
	 * @param string $path
	 * @param bool|string|array|Page Output so far, or false if none
	 * @return bool|string|array 
	 *   Return false if path cannot be handled
	 *   Return true if path handled silently
	 *   Return string for output to send
	 *   Return array for JSON output to send
	 *   Return Page object to make it the page that is rendered
	 * 
	 */
	protected function ___pathHooks($path, $out) {
		if($path && $out) {} // ignore
		return $this->pathHooksReturnValue;
	}

	/**
	 * @return NullPage|Page
	 * 
	 */
	protected function http404Page() {
		if($this->http404Page) return $this->http404Page;
		$config = $this->config;
		$pages = $this->wire()->pages;
		$this->http404Page = $config->http404PageID ? $pages->get($config->http404PageID) : $pages->newNullPage(); 
		return $this->http404Page;
	}
	
	/**
	 * Send a 404 header, but not more than once per request
	 * 
	 */
	protected function header404() {
		static $n = 0;
		if($n) return;
		$http = new WireHttp();
		$this->wire($http);
		$http->sendStatusHeader(404);
		$n++;
	}

	/**
	 * Perform redirect
	 * 
	 * @param string $url
	 * @param bool $permanent
	 * 
	 */
	protected function redirect($url, $permanent = true) {
		$session = $this->wire()->session;
		$this->setResponseType(self::responseTypeRedirect);
		if($permanent === true || $permanent === 301) {
			$session->redirect($url);
		} else {
			$session->location($url);
		}
	}

	/**
	 * Return the response type for this request, as one of the responseType constants
	 *
	 * @return int
	 *
	 */
	public function getResponseType() {
		return $this->responseType; 
	}

	/**
	 * Set the response type for this request, see responseType constants in this class
	 *
	 * @param int $responseType
	 *
	 */
	public function setResponseType($responseType) {
		$this->responseType = (int) $responseType; 
	}

	/**
	 * Set whether any redirects should be performed after the API ready() call
	 * 
	 * This is used by LanguageSupportPageNames to delay redirects until after http/https schema is determined. 
	 *
	 * @param bool $delayRedirects
	 *
	 */
	public function setDelayRedirects($delayRedirects) {
		$this->delayRedirects = $delayRedirects ? true : false;
	}
}
