praiadeseselle/wire/modules/Process/ProcessPageView.module

1427 lines
43 KiB
Text
Raw Permalink Normal View History

2022-03-08 15:55:41 +01:00
<?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;
}
}