artabro/wire/modules/Process/ProcessPageView.module
2024-08-27 11:35:37 +02:00

710 lines
20 KiB
Text

<?php namespace ProcessWire;
/**
* ProcessWire Page View Process
*
* Enables viewing or Processes, one of the core components in connecting ProcessWire to HTTP.
*
* For more details about how Process modules work, please see:
* /wire/core/Process.php
*
* ProcessWire 3.x, Copyright 2023 by Ryan Cramer
* https://processwire.com
*
* @method string execute($internal = true)
* @method string executeExternal()
* @method ready(array $data = array())
* @method finished(array $data = array())
* @method failed(\Exception $e, $reason = '', $page = null, $url = '')
* @method sendFile($page, $basename)
* @method string pageNotFound($page, $url, $triggerReady = false, $reason = '', \Exception $e = null)
* @method string|bool|array|Page pathHooks($path, $out)
* @method void userNotAllowed(User $user, $page, PagesRequest $request)
*
*/
class ProcessPageView extends Process {
public static function getModuleInfo() {
return array(
'title' => __('Page View', __FILE__), // getModuleInfo title
'summary' => __('All page views are routed through this Process', __FILE__), // getModuleInfo summary
'version' => 106,
'permanent' => true,
'permission' => 'page-view',
);
}
/**
* Response types
*
*/
const responseTypeError = 0;
const responseTypeNormal = 1;
const responseTypeAjax = 2;
const responseTypeFile = 4;
const responseTypeRedirect = 8;
const responseTypeExternal = 16;
const responseTypeNoPage = 32;
const responseTypePathHook = 64;
/**
* Response type (see response type codes above)
*
*/
protected $responseType = 1;
/**
* True if any redirects should be delayed until after API ready() has been issued
*
*/
protected $delayRedirects = false;
/**
* @var Page|null
*
*/
protected $http404Page = null;
/**
* Return value from first iteration of pathHooks() method (when applicable)
*
* @var mixed
*
*/
protected $pathHooksReturnValue = false;
/**
* Construct
*
*/
public function __construct() {} // no parent call intentional
/**
* Init
*
*/
public function init() {} // no parent call intentional
/**
* Retrieve a page, check access, and render
*
* @param bool $internal True if request should be internally processed. False if PW is bootstrapped externally.
* @return string Output of request
*
*/
public function ___execute($internal = true) {
if(!$internal) return $this->executeExternal();
$this->responseType = self::responseTypeNormal;
$config = $this->wire()->config;
$pages = $this->wire()->pages;
$request = $pages->request();
$timerKey = $config->debug ? 'ProcessPageView.getPage()' : '';
if($config->usePoweredBy !== null) header('X-Powered-By:' . ($config->usePoweredBy ? ' ProcessWire CMS' : ''));
$pages->setOutputFormatting(true);
if($timerKey) Debug::timer($timerKey);
$page = $this->getPage();
if($timerKey) Debug::saveTimer($timerKey, ($page && $page->id ? $page->path : ''));
if($page && $page->id) {
return $this->renderPage($page, $request);
} else {
return $this->renderNoPage($request);
}
}
/**
* Get requested page
*
* @return NullPage|Page
* @throws WireException
*
*/
public function getPage() {
return $this->wire()->pages->request()->getPage();
}
/**
* Render Page
*
* @param Page $page
* @param PagesRequest $request
* @return bool|mixed|string
* @throws WireException
* @since 3.0.173
*
*/
protected function renderPage(Page $page, PagesRequest $request) {
$config = $this->wire()->config;
$user = $this->wire()->user;
$page->of(true);
$originalPage = $page;
$page = $request->getPageForUser($page, $user);
$code = $request->getResponseCode();
if($code == 401 || $code == 403) {
$this->userNotAllowed($user, $originalPage, $request);
}
if(!$page || !$page->id || $originalPage->id == $config->http404PageID) {
$this->checkForRedirect($request);
$s = 'access not allowed';
$e = new Wire404Exception($s, Wire404Exception::codePermission);
return $this->pageNotFound($originalPage, $request->getRequestPath(), true, $s, $e);
}
if(!$this->delayRedirects) $this->checkForRedirect($request);
$this->wire('page', $page);
$this->ready();
$page = $this->wire()->page; // in case anything changed it
if($this->delayRedirects) {
if($page !== $originalPage) $request->checkScheme($page);
$this->checkForRedirect($request);
}
try {
$file = $request->getFile();
if($file) {
$this->responseType = self::responseTypeFile;
$this->wire()->setStatus(ProcessWire::statusDownload, array('downloadFile' => $file));
$this->sendFile($page, $file);
} else {
$contentType = $this->contentTypeHeader($page, true);
$this->wire()->setStatus(ProcessWire::statusRender, array('contentType' => $contentType));
if($config->ajax) $this->responseType = self::responseTypeAjax;
return $page->render();
}
} catch(Wire404Exception $e) {
// 404 exception thrown during page render
TemplateFile::clearAll();
return $this->renderNoPage($request, array(
'reason404' => '404 thrown during page render',
'exception404' => $e,
'page' => $page,
'ready' => true, // let it know ready state already executed
));
} catch(\Exception $e) {
// other exception thrown during page render (re-throw non 404 exceptions)
$this->responseType = self::responseTypeError;
$this->failed($e, "Thrown during page render", $page);
throw $e;
}
return '';
}
/**
* Render when no page mapped to request URL
*
* @param PagesRequest $request
* @param array $options
* @return array|bool|false|string
* @throws WireException
* @since 3.0.173
*
*/
protected function renderNoPage(PagesRequest $request, array $options = array()) {
$defaults = array(
'allow404' => true, // allow 404 to be thrown?
'reason404' => 'Requested URL did not resolve to a Page',
'exception404' => null,
'ready' => false, // are we executing from the API ready state?
'page' => $this->http404Page(), // potential Page object (default is 404 page)
);
$options = count($options) ? array_merge($defaults, $options) : $defaults;
$config = $this->wire()->config;
$hooks = $this->wire()->hooks;
$input = $this->wire()->input;
$pages = $this->wire()->pages;
$requestPath = $request->getRequestPath();
$pageNumPrefix = $request->getPageNumPrefix();
$pageNumSegment = '';
$setPageNum = 0;
$page = null;
$out = false;
$this->setResponseType(self::responseTypeNoPage);
if($pageNumPrefix !== null) {
// request may have a pagination segment
$pageNumSegment = $this->renderNoPagePagination($requestPath, $pageNumPrefix, $request->getPageNum());
$setPageNum = $input->pageNum();
}
if(!$options['ready']) $this->wire('page', $options['page']);
// run up to 2 times, once before ready state and once after
for($n = 1; $n <= 2; $n++) {
// only run once if already in ready state
if($options['ready']) $n = 2;
// call ready() on 2nd iteration only, allows for ready hooks to set $page
if($n === 2 && !$options['ready']) $this->ready();
if(!$hooks->hasPathHooks()) continue;
$this->setResponseType(self::responseTypePathHook);
try {
$out = $this->pathHooks($requestPath, $out);
} catch(Wire404Exception $e) {
$out = false;
}
// allow for pathHooks() $event->return to persist between init and ready states
// this makes it possible for ready() call to examine $event->return from init() call
// in case it wants to concatenate it or something
if($n === 1) $this->pathHooksReturnValue = $out;
if($out instanceof Page) {
// hook returned Page object to set as page to render
$page = $out;
$out = true;
} else {
// check if hooks changed $page API variable instead
$page = $this->wire()->page;
}
// first hook that determines the $page wins
if($page && $page->id && $page->id !== $options['page']->id) break;
$this->setResponseType(self::responseTypeNoPage);
}
// did a path hook require a redirect for trailing slash (vs non-trailing slash)?
$redirect = $hooks->getPathHookRedirect();
if($redirect) {
// path hook suggests a redirect for proper URL format
$url = $config->urls->root . ltrim($redirect, '/');
// if present, add pagination segment back into URL
if($pageNumSegment) $url = rtrim($url, '/') . "/$pageNumSegment";
$this->redirect($url);
}
$this->pathHooksReturnValue = false; // no longer applicable once this line reached
$hooks->allowPathHooks(false); // no more path hooks allowed
if($page instanceof Page && $page->id && $page->id !== $options['page']->id) {
// one of the path hooks set the page
$this->wire('page', $page);
return $this->renderPage($page, $request);
}
if($out === false) {
// hook failed to handle request
if($setPageNum > 1) $input->setPageNum(1);
if($options['allow404']) {
$page = $options['page'];
// hooks to pageNotFound() method may expect NullPage rather than 404 page
if($page->id == $config->http404PageID) $page = $pages->newNullPage();
$out = $this->pageNotFound($page, $requestPath, false, $options['reason404'], $options['exception404']);
} else {
$out = false;
}
} else if($out === true) {
// hook silently handled the request
$out = '';
} else if(is_array($out)) {
// hook returned array to convert to JSON
$jsonFlags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
$contentTypes = $config->contentTypes;
if(isset($contentTypes['json'])) header("Content-Type: $contentTypes[json]");
$out = json_encode($out, $jsonFlags);
}
return $out;
}
/**
* Check for pagination in a no-page request (helper to renderNoPage method)
*
* - Updates given request path to remove pagination segment.
* - Returns found pagination segment or blank if none.
* - Redirects to non-slash version if: pagination segment found with trailing slash,
* no $page API var was present, or $page present but does not allow slash.
*
* @param string $requestPath
* @param string|null $pageNumPrefix
* @param int $pageNum
* @return string Return found pageNum segment or blank if none
*
*/
protected function renderNoPagePagination(&$requestPath, $pageNumPrefix, $pageNum) {
$config = $this->wire()->config;
if($pageNum < 1 || $pageNumPrefix === null) return '';
// there is a pagination segment present in the request path
$slash = substr($requestPath, -1) === '/' ? '/' : '';
$requestPath = rtrim($requestPath, '/');
$pageNumSegment = $pageNumPrefix . $pageNum;
if(substr($requestPath, -1 * strlen($pageNumSegment)) === $pageNumSegment) {
// remove pagination segment from request path
$requestPath = substr($requestPath, 0, -1 * strlen($pageNumSegment));
$setPageNum = (int) $pageNum;
if($setPageNum === 1) {
// disallow specific "/page1" in URL as it is implied by the lack of pagination segment
$this->redirect($config->urls->root . ltrim($requestPath, '/'));
} else if($slash) {
// a trailing slash is present after the pageNum i.e. /page9/
$page = $this->wire()->page;
// a $page API var will be present if a 404 was manually thrown from a template file
// but it likely won't be present if we are leading to a path hook
if(!$page || !$page->id || !$page->template || !$page->template->allowPageNum) $page = null;
if($page && ((int) $page->template->slashPageNum) > -1) {
// $page API var present and trailing slash is okay
} else {
// no $page API var present or trailing slash on pageNum disallowed
// enforce no trailing slashes for pagination numbers
$this->redirect($config->urls->root . ltrim($requestPath, '/') . $pageNumSegment);
}
}
$this->wire()->input->setPageNum($pageNum);
} else {
// not a pagination segment
// add the slash back to restore requestPath
$requestPath .= $slash;
$pageNumSegment = '';
}
return $pageNumSegment;
}
/**
* Called when a 401 unauthorized or 403 forbidden request
*
* #pw-hooker
*
* @param User $user
* @param Page|NullPage|null $page
* @param PagesRequest $request
* @since 3.0.186
*
*/
protected function ___userNotAllowed(User $user, $page, PagesRequest $request) {
$input = $this->wire()->input;
$config = $this->wire()->config;
$session = $this->wire()->session;
if(!$session || !$page || !$page->id) return;
if($user->isLoggedin()) return;
$loginRequestURL = $request->getRedirectUrl();
$ns = 'ProcessPageView'; // session namespace
if(empty($loginRequestURL)) {
$loginRequestURL = $session->getFor($ns, 'loginRequestURL');
}
if(!empty($loginRequestURL)) return;
if($page->id == $config->loginPageID) return;
if($input->get('loggedout')) return;
$loginRequestURL = $input->url(array('page' => $page));
if(!empty($_GET)) {
$queryString = $input->queryStringClean(array(
'maxItems' => 10,
'maxLength' => 500,
'maxNameLength' => 20,
'maxValueLength' => 200,
'sanitizeName' => 'fieldName',
'sanitizeValue' => 'name',
'entityEncode' => false,
));
if(strlen($queryString)) $loginRequestURL .= "?$queryString";
}
$session->setFor($ns, 'loginRequestPageID', $page->id);
$session->setFor($ns, 'loginRequestURL', $loginRequestURL);
}
/**
* Check request for redirect and apply it when appropriate
*
* @param PagesRequest $request
*
*/
protected function checkForRedirect(PagesRequest $request) {
$redirectUrl = $request->getRedirectUrl();
if($redirectUrl) $this->redirect($redirectUrl, $request->getRedirectType());
}
/**
* Get and optionally send the content-type header
*
* @param Page $page
* @param bool $send
* @return string
*
*/
protected function contentTypeHeader(Page $page, $send = false) {
$config = $this->wire()->config;
$contentType = $page->template->contentType;
if(!$contentType) return '';
if(strpos($contentType, '/') === false) {
if(isset($config->contentTypes[$contentType])) {
$contentType = $config->contentTypes[$contentType];
} else {
$contentType = '';
}
}
if($contentType && $send) header("Content-Type: $contentType");
return $contentType;
}
/**
* Method executed when externally bootstrapped
*
* @return string blank string
*
*/
public function ___executeExternal() {
$this->setResponseType(self::responseTypeExternal);
$config = $this->wire()->config;
$config->external = true;
if($config->externalPageID) {
$page = $this->wire()->pages->get((int) $config->externalPageID);
} else {
$page = $this->wire()->pages->newNullPage();
}
$this->wire('page', $page);
$this->ready();
$this->wire()->setStatus(ProcessWire::statusRender, array('contentType' => 'external'));
return '';
}
/**
* Hook called when the $page API var is ready, and before the $page is rendered.
*
* @param array $data
*
*/
public function ___ready(array $data = array()) {
$this->wire()->setStatus(ProcessWire::statusReady, $data);
}
/**
* Hook called with the pageview has been finished and output has been sent. Note this is called in /index.php.
*
* @param array $data
*
*/
public function ___finished(array $data = array()) {
$this->wire()->setStatus(ProcessWire::statusFinished, $data);
}
/**
* Hook called when the pageview failed to finish due to an Exception or Error.
*
* Sends a copy of the throwable that occurred.
*
* @param \Throwable $e Exception or Error
* @param string $reason
* @param Page|null $page
* @param string $url
*
*/
public function ___failed($e, $reason = '', $page = null, $url = '') {
$this->wire()->setStatusFailed($e, $reason, $page, $url);
}
/**
* Passthru a file for a non-public page
*
* If the page is public, then it just does a 301 redirect to the file.
*
* @param Page $page
* @param string $basename
* @param array $options
* @throws Wire404Exception
*
*/
protected function ___sendFile($page, $basename, array $options = array()) {
$err = 'File not found';
if(!$page->hasFilesPath()) {
throw new Wire404Exception($err, Wire404Exception::codeFile);
}
$filename = $page->filesPath() . $basename;
if(!file_exists($filename)) {
throw new Wire404Exception($err, Wire404Exception::codeFile);
}
if(!$page->secureFiles()) {
// if file is not secured, redirect to it
// (potentially deprecated, only necessary for method 2 in checkRequestFile)
$this->redirect($page->filesManager->url() . $basename);
return;
}
// options for WireHttp::sendFile
$defaults = array('exit' => false, 'limitPath' => $page->filesPath());
$options = array_merge($defaults, $options);
$this->wire()->files->send($filename, $options);
}
/**
* Called when a page is not found, sends 404 header, and displays the configured 404 page instead.
*
* Method is hookable, for instance if you wanted to log 404s. When hooking this method note that it
* must be hooked sometime before the ready state.
*
* @param Page|null $page Page that was found if applicable (like if user didn't have permission or $page's template threw the 404). If not applicable then NULL will be given instead.
* @param string $url The URL that the request originated from (like $_SERVER['REQUEST_URI'] but already sanitized)
* @param bool $triggerReady Whether or not the ready() hook should be triggered (default=false)
* @param string $reason Reason why 404 occurred, for debugging purposes (en text)
* @param WireException|Wire404Exception $exception Exception that was thrown or that indicates details of error
* @throws WireException
* @return string
*/
protected function ___pageNotFound($page, $url, $triggerReady = false, $reason = '', $exception = null) {
if(!$exception) {
// create exception but do not throw
$exception = new Wire404Exception($reason, Wire404Exception::codeNonexist);
}
$this->failed($exception, $reason, $page, $url);
$this->responseType = self::responseTypeError;
$this->header404();
$page = $this->http404Page();
if($page->id) {
$this->wire('page', $page);
if($triggerReady) $this->ready();
return $page->render();
} else {
return "404 page not found";
}
}
/**
* Handler for path hooks
*
* No need to hook this method directly, instead use a path hook.
*
* #pw-internal
*
* @param string $path
* @param bool|string|array|Page Output so far, or false if none
* @return bool|string|array
* Return false if path cannot be handled
* Return true if path handled silently
* Return string for output to send
* Return array for JSON output to send
* Return Page object to make it the page that is rendered
*
*/
protected function ___pathHooks($path, $out) {
if($path && $out) {} // ignore
return $this->pathHooksReturnValue;
}
/**
* @return NullPage|Page
*
*/
protected function http404Page() {
if($this->http404Page) return $this->http404Page;
$config = $this->config;
$pages = $this->wire()->pages;
$this->http404Page = $config->http404PageID ? $pages->get($config->http404PageID) : $pages->newNullPage();
return $this->http404Page;
}
/**
* Send a 404 header, but not more than once per request
*
*/
protected function header404() {
static $n = 0;
if($n) return;
$http = new WireHttp();
$this->wire($http);
$http->sendStatusHeader(404);
$n++;
}
/**
* Perform redirect
*
* @param string $url
* @param bool $permanent
*
*/
protected function redirect($url, $permanent = true) {
$session = $this->wire()->session;
$this->setResponseType(self::responseTypeRedirect);
if($permanent === true || $permanent === 301) {
$session->redirect($url);
} else {
$session->location($url);
}
}
/**
* Return the response type for this request, as one of the responseType constants
*
* @return int
*
*/
public function getResponseType() {
return $this->responseType;
}
/**
* Set the response type for this request, see responseType constants in this class
*
* @param int $responseType
*
*/
public function setResponseType($responseType) {
$this->responseType = (int) $responseType;
}
/**
* Set whether any redirects should be performed after the API ready() call
*
* This is used by LanguageSupportPageNames to delay redirects until after http/https schema is determined.
*
* @param bool $delayRedirects
*
*/
public function setDelayRedirects($delayRedirects) {
$this->delayRedirects = $delayRedirects ? true : false;
}
}