praiadeseselle/wire/modules/Process/ProcessPageView.module
2022-03-08 15:55:41 +01:00

1426 lines
43 KiB
Text
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 2021 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)
*
*/
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' => 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 wont 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;
}
}