__('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(); 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 { $file = $request->getFile(); 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; } }