__('Page View', __FILE__), // getModuleInfo title 'summary' => __('All page views are routed through this Process', __FILE__), // getModuleInfo summary 'version' => 104, '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; /** * URL that should be redirected to for this request * * Set by other methods in this class, and checked by the execute method before rendering. * */ protected $redirectURL = ''; /** * True if any redirects should be delayed until after API ready() has been issued * */ protected $delayRedirects = false; /** * Sanitized path that generated this request * * Set by the getPage() method and passed to the pageNotFound function. * */ protected $requestPath = ''; /** * Unsanitized URL from $_SERVER['REQUEST_URI'] * * @var string * */ protected $dirtyURL = ''; /** * Requested filename, if URL in /path/to/page/-/filename.ext format * */ protected $requestFile = ''; /** * Page number found in the URL or null if not found * */ protected $pageNum = null; /** * Page number prefix found in the URL or null if not found * */ protected $pageNumPrefix = null; /** * @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() { $this->dirtyURL = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : ''; if(empty($this->dirtyURL) && !empty($_SERVER['QUERY_STRING'])) $this->dirtyURL = '?' . $_SERVER['QUERY_STRING']; // check if there is an 'it' GET variable present in the request URL query string, which we don't want here if(isset($_GET['it']) && (strpos($this->dirtyURL, '?it=') !== false || strpos($this->dirtyURL, '&it='))) { // force to use path in request url rather than contents of 'it' var list($it,) = explode('?', $this->dirtyURL); $rootURL = $this->wire('config')->urls->root; if(strlen($rootURL) > 1 && strpos($it, $rootURL) === 0) $it = substr($it, strlen($rootURL)-1); $it = str_replace('index.php', '', $it); $_GET['it'] = $it; } // 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; $timerKey = $config->debug ? 'ProcessPageView.getPage()' : ''; if($config->usePoweredBy !== null) header('X-Powered-By:' . ($config->usePoweredBy ? ' ProcessWire CMS' : '')); $this->wire()->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->renderNoPage(); return $this->renderPage($page); } /** * Render Page * * @param Page $page * @return bool|mixed|string * @throws WireException * @since 3.0.173 * */ protected function renderPage(Page $page) { $config = $this->wire()->config; $page->setOutputFormatting(true); $_page = $page; $page = $this->checkAccess($page); if(!$page || $_page->id == $config->http404PageID) { $s = 'access not allowed'; $e = new Wire404Exception($s, Wire404Exception::codePermission); return $this->pageNotFound($_page, $this->requestPath, true, $s, $e); } if(!$this->delayRedirects) { $this->checkProtocol($page); if($this->redirectURL) $this->redirect($this->redirectURL); } $this->wire('page', $page); $this->ready(); $page = $this->wire('page'); // in case anything changed it if($this->delayRedirects) { $this->checkProtocol($page); if($this->redirectURL) $this->redirect($this->redirectURL); } try { if($this->requestFile) { $this->responseType = self::responseTypeFile; $this->wire()->setStatus(ProcessWire::statusDownload, array('downloadFile' => $this->requestFile)); $this->sendFile($page, $this->requestFile); } 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 TemplateFile::clearAll(); return $this->renderNoPage(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 (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 array $options * @return array|bool|false|string * @throws WireException * @since 3.0.173 * */ protected function renderNoPage(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; $requestPath = $this->requestPath; $setPageNum = 0; $pageNumSegment = ''; $page = null; $out = false; $this->setResponseType(self::responseTypeNoPage); if($this->pageNum > 0 && $this->pageNumPrefix !== null) { // there is a pagination segment present in the request path $slash = substr($requestPath, -1) === '/' ? '/' : ''; $requestPath = rtrim($requestPath, '/'); $pageNumSegment = $this->pageNumPrefix . $this->pageNum; if(substr($requestPath, -1 * strlen($pageNumSegment)) === $pageNumSegment) { // remove pagination segment from request path $requestPath = substr($requestPath, 0, -1 * strlen($pageNumSegment)); $setPageNum = $this->pageNum; // disallow specific "/page1" in URL as it is implied by the lack of pagination segment if($setPageNum === 1) $this->redirect($config->urls->root . ltrim($requestPath, '/')); // enforce no trailing slashes for pagination numbers if($slash) $this->redirect($config->urls->root . ltrim($requestPath, '/') . $pageNumSegment); $input->setPageNum($this->pageNum); } else { // not a pagination segment // add the slash back to restore requestPath $requestPath .= $slash; $pageNumSegment = ''; } } 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(is_object($out) && $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 && $page->id && $page instanceof Page && $page->id !== $options['page']->id) { // one of the path hooks set the page $this->wire('page', $page); return $this->renderPage($page); } 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 = $this->wire(new NullPage()); $out = $this->pageNotFound($page, $this->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; } /** * 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); } /** * Get the requested page and populate it with identified urlSegments or page numbers * * @return Page|null * */ protected function getPage() { $config = $this->wire()->config; $sanitizer = $this->wire()->sanitizer; // force redirect to actual page URL? (if different from request URL) $forceRedirect = false; // did URL end with index.php|htm|html? If so we might redirect if a page matches without it. $hasIndexFile = false; // get the requested path $it = $this->getPageRequestPath(); if($it === false) return null; // check if there are index files in the request if(strpos($it, '/index.') !== false && preg_match('{/index\.(php|html?)$}', $it, $matches)) { // if request is to index.php|htm|html, make note of it to determine if we can redirect later $hasIndexFile = true; } else if(strpos($this->dirtyURL, 'index.php') !== false && strpos($it, 'index.php') === false) { // if request contains index.php and the request path ($it) does not, force redirect to correct version if(preg_match('!/index\.php$!', parse_url($this->dirtyURL, PHP_URL_PATH))) $forceRedirect = true; } // check if request is for a secure pagefile if($this->pagefileSecurePossible($it)) { $page = $this->checkRequestFile($it); if(is_object($page)) { $this->responseType = self::responseTypeFile; return $page; // Page or NullPage } } // check for pagination segment if($this->checkPageNumPath($it)) { // path has a pagination prefix and number in it, // populated to $this->pageNumPrefix and $this->pageNumPath $page = null; } else { // no pagination number, see if it already resolves to a Page $page = $this->pagesGet($it); } if($page && $page->id) { // path resolves to page with NO pageNum or urlSegments present if($forceRedirect) { // index.php in URL redirect to actual page URL $this->redirectURL = $page->url; } else if($page->id > 1) { // trailing slash vs. non trailing slash, enforced if not homepage // redirect to proper trailed slash version if incorrect version is present. // note: this section only executed if no URL segments or page numbers were present $hasTrailingSlash = substr($it, -1) == '/'; $slashUrls = $page->template->slashUrls; if((!$hasTrailingSlash && $slashUrls !== 0) || ($hasTrailingSlash && $slashUrls === 0)) { $this->redirectURL = $page->url; } } return $page; } // check for globally unique page which can redirect $name = trim($it, '/'); if($name && strpos($name, '/') === false && $sanitizer->pageNameUTF8($name) === $name) { $page = $this->pagesGet($name, 'name', 'status=' . Page::statusUnique); // if found, redirect to globally unique page if($page && $page->viewable()) $this->redirectURL = $page->url; } // populate request path to class as other methods will now use it $this->requestPath = $it; // check if path with URL segments can resolve to a page $urlSegments = array(); if(!$page) $page = $this->getPageUrlSegments($it, $urlSegments); // if URL segments and/or page numbers are present and not allowed then abort if($page && count($urlSegments) && !$this->checkUrlSegments($urlSegments, $page)) { // found URL segments were checked and found not to be allowed here if($hasIndexFile && count($urlSegments) === 1) { // index.php|htm|html segments if not used by page can redirect to URL without it $forceRedirect = true; } else { // page with invalid URL segments becomes a 404 $page = null; } } // if no page found for guest user, check if path was in admin and map to admin root if(!$page && $this->wire()->user->isGuest()) { // this ensures that no admin requests resolve to a 404 and instead show login form $adminPath = substr($config->urls->admin, strlen($config->urls->root)-1); if(strpos($this->requestPath, $adminPath) === 0) { $page = $this->wire()->pages->get($config->adminRootPageID); } $forceRedirect = false; } if($forceRedirect && $page && !$this->redirectURL) { $this->redirectURL = $page->url; } return $page; } /** * Given a path with URL segments, get matching Page and populate given $urlSegments array * * @param string $path * @param array $urlSegments * @return null|Page * */ protected function getPageUrlSegments($path, array &$urlSegments) { $numSegments = 0; $maxSegments = $this->wire()->config->maxUrlSegments; $maxSegments = $maxSegments === null ? 4 : (int) $maxSegments; $page = null; // if the page isn't found, then check if a page one path level before exists // this loop allows for us to have both a urlSegment and a pageNum while(!$page && $numSegments < $maxSegments) { $path = rtrim($path, '/'); $pos = strrpos($path, '/') + 1; $urlSegment = substr($path, $pos); $urlSegments[$numSegments] = $urlSegment; $path = substr($path, 0, $pos); // $path no longer includes the urlSegment $page = $this->pagesGet($path); $numSegments++; } return $page; } /** * Get the requested path * * @return bool|string Return false on fail or path on success * */ protected function getPageRequestPath() { $config = $this->wire()->config; $sanitizer = $this->wire()->sanitizer; /** @var string $shit Dirty URL */ /** @var string $it Clean URL */ if(isset($_GET['it'])) { // normal request $shit = trim($_GET['it']); } else if(isset($_SERVER['REQUEST_URI'])) { // abnormal request, something about request URL made .htaccess skip it, or index.php called directly $rootUrl = $config->urls->root; $shit = trim($_SERVER['REQUEST_URI']); if(strpos($shit, '?') !== false) list($shit,) = explode('?', $shit, 2); if($rootUrl != '/') { if(strpos($shit, $rootUrl) === 0) { // remove root URL from request $shit = substr($shit, strlen($rootUrl) - 1); } else { // request URL outside of our root directory return false; } } } else { $shit = '/'; } if($shit === '/') { $it = '/'; } else { $it = preg_replace('{[^-_./a-zA-Z0-9]}', '', $shit); // clean } unset($_GET['it']); if($shit !== $it) { // sanitized URL does not match requested URL if($config->pageNameCharset == 'UTF8') { // test for extended page name URL $it = $sanitizer->pagePathNameUTF8($shit); } if($shit !== $it) { // if still does not match then fail return false; } } $maxUrlDepth = $config->maxUrlDepth; if($maxUrlDepth > 0 && substr_count($it, '/') > $config->maxUrlDepth) return false; if(!isset($it[0]) || $it[0] != '/') $it = "/$it"; if(strpos($it, '//') !== false) return false; return $it; } /** * Check if given path has a page/pagination number and return it if so (return 0 if not) * * @param string $path * @return int * */ protected function checkPageNumPath($path) { $hasPrefix = false; $pageNumUrlPrefixes = $this->pageNumUrlPrefixes(); foreach($pageNumUrlPrefixes as $prefix) { if(strpos($path, '/' . $prefix) !== false) { $hasPrefix = true; break; } } if($hasPrefix && preg_match('{/(' . implode('|', $pageNumUrlPrefixes) . ')(\d+)/?$}', $path, $matches)) { // URL contains a page number, but we'll let it be handled by the checkUrlSegments function later $this->pageNumPrefix = $matches[1]; $this->pageNum = (int) $matches[2]; return $this->pageNum; } return 0; } /** * Check if the requested URL is to a secured page file * * This function sets $this->requestFile when it finds one. * Returns Page when a pagefile was found and matched to a page. * Returns NullPage when request should result in a 404. * Returns true, and updates $it, when pagefile was found using old/deprecated method. * Returns false when none found. * * @param string $it Request URL * @return bool|Page|NullPage * */ protected function checkRequestFile(&$it) { $config = $this->wire()->config; $pages = $this->wire()->pages; // request with url to root (applies only if site runs from subdirectory) $itRoot = rtrim($config->urls->root, '/') . $it; // check for secured filename, method 1: actual file URL, minus leading "." or "-" if(strpos($itRoot, $config->urls->files) === 0) { // request is for file in site/assets/files/... $idAndFile = substr($itRoot, strlen($config->urls->files)); // matching in $idAndFile: 1234/file.jpg, 1/2/3/4/file.jpg, 1234/subdir/file.jpg, 1/2/3/4/subdir/file.jpg, etc. if(preg_match('{^(\d[\d\/]*)/([-_a-zA-Z0-9][-_./a-zA-Z0-9]+)$}', $idAndFile, $matches) && strpos($matches[2], '.')) { // request is consistent with those that would match to a file $idPath = trim($matches[1], '/'); $file = trim($matches[2], '.'); if(!strpos($file, '.')) return $pages->newNullPage(); if(!ctype_digit("$idPath")) { // extended paths where id separated by slashes, i.e. 1/2/3/4 if($config->pagefileExtendedPaths) { // allow extended paths $idPath = str_replace('/', '', $matches[1]); if(!ctype_digit("$idPath")) return $pages->newNullPage(); } else { // extended paths not allowed return $pages->newNullPage(); } } if(strpos($file, '/') !== false) { // file in subdirectory (for instance ProDrafts uses subdirectories for draft files) list($subdir, $file) = explode('/', $file, 2); if(strpos($file, '/') !== false) { // there is more than one subdirectory, which we do not allow return $pages->newNullPage(); } else if(strpos($subdir, '.') !== false || strlen($subdir) > 128) { // subdirectory has a "." in it or subdir length is too long return $pages->newNullPage(); } else if(!preg_match('/^[a-zA-Z0-9][-_a-zA-Z0-9]+$/', $subdir)) { // subdirectory not in expected format return $pages->newNullPage(); } $file = trim($file, '.'); $this->requestFile = "$subdir/$file"; } else { // file without subdirectory $this->requestFile = $file; } return $pages->get((int) $idPath); // Page or NullPage } else { // request was to something in /site/assets/files/ but we don't recognize it // tell caller that this should be a 404 return $pages->newNullPage(); } } // check for secured filename: method 2 (deprecated), used only if $config->pagefileUrlPrefix is defined $filePrefix = $config->pagefileUrlPrefix; if($filePrefix && strpos($it, '/' . $filePrefix) !== false) { if(preg_match('{^(.*/)' . $filePrefix . '([-_.a-zA-Z0-9]+)$}', $it, $matches) && strpos($matches[2], '.')) { $it = $matches[1]; $this->requestFile = $matches[2]; return true; } } return false; } /** * Identify and populate URL segments and page numbers * * @param array $urlSegments URL segments as found in getPage() * @param Page $page * @return bool Returns false if URL segments found and aren't allowed * */ protected function checkUrlSegments(array $urlSegments, Page $page) { if(!count($urlSegments)) return true; $input = $this->wire()->input; $lastSegment = reset($urlSegments); $urlSegments = array_reverse($urlSegments); $pageNum = 1; $template = $page->template; // check if the last urlSegment is setting a page number and that page numbers are allowed if(!is_null($this->pageNum) && $lastSegment === "$this->pageNumPrefix$this->pageNum" && $template->allowPageNum) { // meets the requirements for a page number: last portion of URL and starts with 'page' $pageNum = (int) $this->pageNum; if($pageNum < 1) $pageNum = 1; if($pageNum > 1 && !$this->wire()->user->isLoggedin()) { $maxPageNum = $this->wire()->config->maxPageNum; if(!$maxPageNum) $maxPageNum = 999; if($pageNum > $maxPageNum) return false; } $page->setQuietly('pageNum', $pageNum); // backwards compatibility $input->setPageNum($pageNum); array_pop($urlSegments); } // return false if URL segments aren't allowed with this page template if($template->name !== 'admin' && count($urlSegments)) { if(!$this->isAllowedUrlSegment($page, $urlSegments)) return false; } // now set the URL segments to the $input API variable $cnt = 1; foreach($urlSegments as $urlSegment) { if($cnt == 1) $page->setQuietly('urlSegment', $urlSegment); // backwards compatibility $input->setUrlSegment($cnt, $urlSegment); $cnt++; } if($pageNum > 1 || count($urlSegments)) { $hasTrailingSlash = substr($this->requestPath, -1) == '/'; // $url=URL with urlSegments and no trailing slash // $url = rtrim(rtrim($page->url, '/') . '/' . $this->input->urlSegmentStr, '/'); $redirectPath = null; if($pageNum > 1 && $template->slashPageNum) { if($template->slashPageNum == 1 && !$hasTrailingSlash) { // enforce trailing slash on page numbers //$this->redirectURL = "$url/$this->pageNumPrefix$pageNum/"; $redirectPath = "/$this->pageNumPrefix$pageNum/"; } else if($template->slashPageNum == -1 && $hasTrailingSlash) { // enforce NO trailing slash on page numbers // $this->redirectURL = "$url/$this->pageNumPrefix$pageNum"; $redirectPath = "/$this->pageNumPrefix$pageNum"; } } else if(count($urlSegments) && $template->slashUrlSegments) { if($template->slashUrlSegments == 1 && !$hasTrailingSlash) { // enforce trailing slash with URL segments // $this->redirectURL = "$url/"; $redirectPath = "/"; } else if($template->slashUrlSegments == -1 && $hasTrailingSlash) { // enforce no trailing slash with URL segments // $this->redirectURL = $url; $redirectPath = ""; } } if($redirectPath !== null) { // redirect will occur to a proper slash format $modules = $this->wire()->modules; if($modules->isInstalled('LanguageSupportPageNames')) { // ensure that LanguageSupportPageNames reaches a ready() state, since // it can modify the output of $page->url (if installed) $this->wire('page', $page); $modules->get('LanguageSupportPageNames')->ready(); } $this->redirectURL = rtrim(rtrim($page->url, '/') . '/' . $input->urlSegmentStr, '/') . $redirectPath; } } return true; } /** * Is the given URL segment allowed according to the page template's settings? * * @param Page $page * @param string|array $segment May be a single segment or path of segments * @return bool * */ protected function isAllowedUrlSegment(Page $page, $segment) { $urlSegments = $page->template->urlSegments(); if(is_array($urlSegments)) { // only specific URL segments are allowed if(is_array($segment)) $segment = implode('/', $segment); $segment = trim($segment, '/'); $allowed = false; foreach($urlSegments as $allowedSegment) { if(strpos($allowedSegment, 'regex:') === 0) { $regex = '{' . trim(substr($allowedSegment, 6)) . '}'; $allowed = preg_match($regex, $segment); } else if($segment === $allowedSegment) { $allowed = true; } if($allowed) break; } return $allowed; } else if($urlSegments > 0) { // all URL segments are allowed return true; } else { // no URL segments are allowed return false; } } /** * Check that the current user has access to the page and return it * * If the user doesn't have access, then a login Page or NULL (for 404) is returned instead. * * @param Page $page * @return Page|null * */ protected function checkAccess($page) { $user = $this->wire()->user; if($this->requestFile) { // if a file was requested, we still allow view even if page doesn't have template file if($page->viewable($this->requestFile) === false) return null; if($page->viewable(false)) return $page; // if($page->editable()) return $page; if($this->checkAccessDelegated($page)) return $page; if($page->status < Page::statusUnpublished && $user->hasPermission('page-view', $page)) return $page; } else if($page->viewable()) { return $page; } else if($page->parent_id && $page->parent->template->name === 'admin' && $page->parent->viewable()) { // check for special case in admin when Process::executeSegment() collides with page name underneath // example: a role named "edit" is created and collides with ProcessPageType::executeEdit() $input = $this->wire()->input; if($user->isLoggedin() && $page->editable() && !strlen($input->urlSegmentStr())) { $input->setUrlSegment(1, $page->name); return $page->parent; } } $accessTemplate = $page->getAccessTemplate(); $redirectLogin = $accessTemplate ? $accessTemplate->redirectLogin : false; // if we won’t be presenting a login form then $page converts to null (404) if(!$redirectLogin) return null; $config = $this->wire()->config; $disallowIDs = array($config->trashPageID); // don't allow login redirect for these pages $loginRequestURL = $this->redirectURL; $loginPageID = $config->loginPageID; $requestPage = $page; $session = $this->wire()->session; $input = $this->wire()->input; $ns = 'ProcessPageView'; if($page->id && in_array($page->id, $disallowIDs)) { // don't allow login redirect when matching disallowIDs $page = null; } else if(ctype_digit("$redirectLogin")) { // redirect login provided as a page ID $redirectLogin = (int) $redirectLogin; // if given ID 1 then this maps to the admin login page if($redirectLogin === 1) $redirectLogin = $loginPageID; $page = $this->pages->get($redirectLogin); } else { // redirect login provided as a URL, optionally with an {id} tag for requested page ID $redirectLogin = str_replace('{id}', $page->id, $redirectLogin); $this->redirectURL = $redirectLogin; } if(empty($loginRequestURL)) { $loginRequestURL = $session->getFor($ns, 'loginRequestURL'); } // in case anything after login needs to know the originally requested page/URL if(empty($loginRequestURL) && $page && $requestPage && $requestPage->id) { if($requestPage->id != $loginPageID && !$input->get('loggedout')) { $loginRequestURL = $input->url(array('page' => $requestPage)); 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', $requestPage->id); $session->setFor($ns, 'loginRequestURL', $loginRequestURL); } } return $page; } /** * Check access to a delegated page (like a repeater) * * Note: this should move to PagePermissions.module or FieldtypeRepeater.module * if a similar check is needed somewhere else in the core. * * @param Page $page * @return Page|null|bool * */ protected function checkAccessDelegated(Page $page) { if(strpos($page->template->name, 'repeater_') == 0) { if(!$this->wire('modules')->isInstalled('FieldtypeRepeater')) return false; $fieldName = substr($page->template->name, strpos($page->template->name, '_') + 1); // repeater_(fieldName) if(!$fieldName) return false; $field = $this->wire('fields')->get($fieldName); if(!$field) return false; $forPageID = substr($page->parent->name, strrpos($page->parent->name, '-') + 1); // for-page-(id) $forPage = $this->wire('pages')->get((int) $forPageID); // delegate viewable check to the page the repeater lives on if($forPage->id) { if($forPage->viewable($field)) return $page; if(strpos($forPage->template->name, 'repeater_') === 0) { // go recursive for nested repeaters $forPage = $this->checkAccessDelegated($forPage); if($forPage && $forPage->id) return $forPage; } } } return null; } /** * If the template requires a different protocol than what is here, then redirect to it. * * This method just silently sets the $this->redirectURL var if a redirect is needed. * Note this does not work if GET vars are present in the URL -- they will be lost in the redirect. * * @param Page $page * */ protected function checkProtocol($page) { /** @var Config $config */ $config = $this->wire('config'); $requireHTTPS = $page->template->https; if($requireHTTPS == 0 || $config->noHTTPS) return; // neither HTTP or HTTPS required $isHTTPS = $config->https; $scheme = ''; if($requireHTTPS == -1 && $isHTTPS) { // HTTP required: redirect to HTTP non-secure version $scheme = "http"; } else if($requireHTTPS == 1 && !$isHTTPS) { // HTTPS required: redirect to HTTPS secure version $scheme = "https"; } if(!$scheme) return; if($this->redirectURL) { if(strpos($this->redirectURL, '://') !== false) { $url = str_replace(array('http://', 'https://'), "$scheme://", $this->redirectURL); } else { $url = "$scheme://$config->httpHost$this->redirectURL"; } } else { $url = "$scheme://$config->httpHost$page->url"; } $input = $this->wire('input'); if($this->redirectURL) { // existing redirectURL will already have segments/page numbers as needed } else { $urlSegmentStr = $input->urlSegmentStr; if(strlen($urlSegmentStr) && $page->template->urlSegments) { $url = rtrim($url, '/') . '/' . $urlSegmentStr; if($page->template->slashUrlSegments) { // use defined setting for trailing slash if($page->template->slashUrlSegments == 1) $url .= '/'; } else { // use whatever the request came with if(substr($this->requestPath, -1) == '/') $url .= '/'; } } $pageNum = $input->pageNum; if($pageNum > 1 && $page->template->allowPageNum) { $prefix = $this->pageNumPrefix ? $this->pageNumPrefix : $this->wire('config')->pageNumUrlPrefix; if(!$prefix) $prefix = 'page'; $url = rtrim($url, '/') . "/$prefix$pageNum"; if($page->template->slashPageNum) { // defined setting for trailing slash if($page->template->slashPageNum == 1) $url .= '/'; } else { // use whatever setting the URL came with if(substr($this->requestPath, '-1') == '/') $url .= '/'; } } } $this->redirectURL = $url; } /** * Get Page from $pages via path (or other property) or return null if it does not exist * * @param string $value Value to match * @param string $property Property being matched (default='path') * @param string $selector Additional selector to apply (default='status<9999999'); * @return null|Page * @since 3.0.168 * */ protected function pagesGet($value, $property = 'path', $selector = 'status<9999999') { if(!is_int($value)) $value = $this->wire()->sanitizer->selectorValue($value, array( 'maxLength' => 2048, 'maxBytes' => 6144, 'allowArray' => false, 'allowSpace' => false, 'blacklist' => array(',', "'"), )); $page = $this->wire()->pages->get("$property=$value, $selector"); return $page->id ? $page : null; } /** * 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) { $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; } /** * Are secure pagefiles possible on this system and url? * * @param string $url * @return bool * @since 3.0.166 * */ protected function pagefileSecurePossible($url) { $config = $this->wire()->config; // if URL does not start from root, prepend root if(strpos($url, $config->urls->root) !== 0) $url = $config->urls->root . ltrim($url, '/'); // if URL is not pointing to the files structure, then this is not a files URL if(strpos($url, $config->urls->files) !== 0) return false; // pagefileSecure option is enabled and URL pointing to files if($config->pagefileSecure) return true; // check if any templates allow pagefileSecure option $allow = false; foreach($this->wire()->templates as $template) { if(!$template->pagefileSecure) continue; $allow = true; break; } // if at least one template supports pagefileSecure option we will return true here return $allow; } /** * Given a request path (that does not exist) return the closest parent that does exist * * CURRENTLY NOT USED (future use) * * @param string $requestPath Request path * @param string $parentPath Optional minimum required parent path * @return null|Page Returns found parent on success or null if none found * @since 3.0.168 * */ private function getClosestParentPage($requestPath, $parentPath = '') { $requestPath = trim($requestPath, '/'); $parentPath = trim($parentPath, '/'); // if request path is not in the required start path then exit early if($parentPath !== '') { if(stripos("/$requestPath/", "/$parentPath/") !== 0) return null; } $sanitizer = $this->wire()->sanitizer; $parent = null; $path = ''; // attempt to match page from beginning of path to find closest parent $segments = explode('/', $requestPath); foreach($segments as $segment) { $seg = $sanitizer->pageName($segment); if($seg !== $segment) break; $path .= "/$seg"; if($parentPath !== '' && $path === "/$parentPath") continue; $page = $this->pagesGet($path); if(!$page->id) break; $parent = $page; } if($parentPath !== '' && !$parent) $parent = $this->pagesGet("/$parentPath"); return $parent && $parent->id ? $parent : null; } /** * Get page num URL prefixes * * @return array * */ protected function pageNumUrlPrefixes() { $config = $this->wire()->config; $pageNumUrlPrefixes = $config->pageNumUrlPrefixes; $returnValue = array(); if(!is_array($pageNumUrlPrefixes)) $pageNumUrlPrefixes = array(); if(count($pageNumUrlPrefixes)) { foreach($pageNumUrlPrefixes as $prefix) { $returnValue[$prefix] = $prefix; } } else { $prefix = $config->pageNumUrlPrefix; if(strlen($prefix)) $returnValue[$prefix] = $prefix; } return $returnValue; } }