diff --git a/wire/core/Page.php b/wire/core/Page.php index 316e3f8e..03296d55 100644 --- a/wire/core/Page.php +++ b/wire/core/Page.php @@ -1538,7 +1538,8 @@ class Page extends WireData implements \Countable, WireMatchable { */ public function getText($key, $oneLine = false, $entities = null) { $value = $this->getMarkup($key); - if(!strlen($value)) return ''; + $length = strlen($value); + if(!$length) return ''; $options = array( 'entities' => (is_null($entities) ? $this->outputFormatting() : (bool) $entities) ); @@ -1547,6 +1548,8 @@ class Page extends WireData implements \Countable, WireMatchable { } else { $value = $this->wire('sanitizer')->markupToText($value, $options); } + // if stripping tags from non-empty value made it empty, just indicate that it was markup and length + if(!strlen(trim($value))) $value = "markup($length)"; return $value; } diff --git a/wire/core/ProcessWire.php b/wire/core/ProcessWire.php index d9759d7b..f8573a31 100644 --- a/wire/core/ProcessWire.php +++ b/wire/core/ProcessWire.php @@ -204,8 +204,9 @@ class ProcessWire extends Wire { $this->wire('hooks', new WireHooks($this, $config), true); - $this->shutdown = $this->wire(new WireShutdown()); $this->setConfig($config); + $this->shutdown = $this->wire(new WireShutdown($config)); + $this->setStatus(self::statusBoot); $this->load($config); if(self::getNumInstances() > 1) { @@ -274,13 +275,6 @@ class ProcessWire extends Wire { $config->debug = $debugIf; } - // If script is being called externally, add an extra shutdown function - if(!$config->internal) register_shutdown_function(function() { - if(error_get_last()) return; - $process = isset($this) ? $this->wire('process') : wire('process'); - if($process == 'ProcessPageView') $process->finished(); - }); - if($config->useFunctionsAPI) { $file = $config->paths->core . 'FunctionsAPI.php'; /** @noinspection PhpIncludeInspection */ @@ -302,9 +296,6 @@ class ProcessWire extends Wire { } } } - - - $this->setStatus(self::statusBoot); } /** @@ -750,6 +741,41 @@ class ProcessWire extends Wire { } } + /** + * Get root path, check it, and optionally auto-detect it if not provided + * + * @param bool|string $rootPath Root path if already known, in which case we’ll just modify as needed + * @return string + * + */ + protected static function getRootPath($rootPath = '') { + + if(strpos($rootPath, '..') !== false) { + $rootPath = realpath($rootPath); + } + + if(empty($rootPath) && !empty($_SERVER['SCRIPT_FILENAME'])) { + // first try to determine from the script filename + $parts = explode(DIRECTORY_SEPARATOR, $_SERVER['SCRIPT_FILENAME']); + array_pop($parts); // most likely: index.php + $rootPath = implode('/', $parts) . '/'; + if(!file_exists($rootPath . 'wire/core/ProcessWire.php')) $rootPath = ''; + } + + if(empty($rootPath)) { + // if unable to determine from script filename, attempt to determine from current file + $parts = explode(DIRECTORY_SEPARATOR, __FILE__); + $parts = array_slice($parts, 0, -3); // removes "ProcessWire.php", "core" and "wire" + $rootPath = implode('/', $parts) . '/'; + } + + if(DIRECTORY_SEPARATOR != '/') { + $rootPath = str_replace(DIRECTORY_SEPARATOR, '/', $rootPath); + } + + return $rootPath; + } + /** * Static method to build a Config object for booting ProcessWire * @@ -762,17 +788,9 @@ class ProcessWire extends Wire { * @throws WireException * */ - public static function buildConfig($rootPath, $rootURL = null, array $options = array()) { - - if(strpos($rootPath, '..') !== false) { - $rootPath = realpath($rootPath); - if($rootPath === false) throw new WireException("Path not found"); - } - - if(DIRECTORY_SEPARATOR != '/') { - $rootPath = str_replace(DIRECTORY_SEPARATOR, '/', $rootPath); - } - + public static function buildConfig($rootPath = '', $rootURL = null, array $options = array()) { + + $rootPath = self::getRootPath($rootPath); $httpHost = ''; $scheme = ''; $siteDir = isset($options['siteDir']) ? $options['siteDir'] : 'site'; diff --git a/wire/core/WireInput.php b/wire/core/WireInput.php index d1d515e5..78dbfcc8 100644 --- a/wire/core/WireInput.php +++ b/wire/core/WireInput.php @@ -577,7 +577,7 @@ class WireInput extends Wire { // page not yet available, attempt to pull URL from request uri $info = parse_url($_SERVER['REQUEST_URI']); $parts = explode('/', $info['path']); - $charset = $config->pageNameCharset; + $charset = $config ? $config->pageNameCharset : ''; foreach($parts as $i => $part) { if($i > 0) $url .= "/"; $url .= ($charset === 'UTF8' ? $sanitizer->pageNameUTF8($part) : $sanitizer->pageName($part, false)); diff --git a/wire/core/WireShutdown.php b/wire/core/WireShutdown.php index 459c1d88..cfa82217 100644 --- a/wire/core/WireShutdown.php +++ b/wire/core/WireShutdown.php @@ -3,7 +3,7 @@ /** * ProcessWire shutdown handler * - * ProcessWire 3.x, Copyright 2016 by Ryan Cramer + * ProcessWire 3.x, Copyright 2018 by Ryan Cramer * * Look for errors at shutdown and log them, plus echo the error if the page is editable * @@ -12,26 +12,80 @@ */ class WireShutdown extends Wire { - + + /** + * Associative array of [ PHP E_* constants (i.e. E_ERROR) => Translated description ] + * + * @var array + * + */ protected $types = array(); - protected $fatalTypes = array(); + + /** + * Regular array of PHP E_* constants that are considered fatal (i.e. E_ERROR) + * + * @var array + * + */ + protected $fatalTypes = array( + E_ERROR, + E_CORE_ERROR, + E_COMPILE_ERROR, + E_USER_ERROR, + E_PARSE, + E_RECOVERABLE_ERROR, + ); + + /** + * Associative array of phrase translations for this module + * + * @var array + * + */ protected $labels = array(); - - public function __construct() { + + /** + * @var Config + * + */ + protected $config; + + /** + * Contents of last error_get_last() call + * + * @var array + * + */ + protected $error = array(); + + /** + * Default HTML to use for error message + * + * Can be overridden with $config->fatalErrorHTML in /site/config.php + * + */ + const defaultFatalErrorHTML = '

{message}
{why}

'; + + /** + * Construct and register shutdown function + * + * @param Config $config + * + */ + public function __construct(Config $config) { + $this->config = $config; register_shutdown_function(array($this, 'shutdown')); - $this->fatalTypes = array( - E_ERROR, - E_CORE_ERROR, - E_COMPILE_ERROR, - E_USER_ERROR, - E_PARSE, - E_RECOVERABLE_ERROR, - ); + // If script is being called externally, add an extra shutdown function + if(!$config->internal) register_shutdown_function(array($this, 'shutdownExternal')); } - + + /** + * Setup our translation labels + * + */ protected function prepareLabels() { $this->types = array( - E_ERROR => $this->_('Error'), + E_ERROR => $this->_('Fatal Error'), E_WARNING => $this->_('Warning'), E_PARSE => $this->_('Parse Error'), E_NOTICE => $this->_('Notice'), @@ -62,62 +116,246 @@ class WireShutdown extends Wire { } - public function shutdown() { - $error = error_get_last(); - if(!$error) return true; - $type = $error['type']; - if(!in_array($type, $this->fatalTypes)) return true; + /** + * Create more informative error message from $error array + * + * @param array $error Error array from PHP’s error_get_last() + * @return string + * + */ + protected function getErrorMessage(array $error) { - $this->prepareLabels(); - $http = isset($_SERVER['HTTP_HOST']); - $config = $this->wire('config'); - $user = $this->wire('user'); - $userName = $user ? $user->name : '?'; - if($config && $config->logIP && isset($_SERVER['REMOTE_ADDR'])) $userName .= " ($_SERVER[REMOTE_ADDR])"; - $page = $this->wire('page'); - $path = ($config ? $config->httpHost : '') . ($page ? $page->url : '/?/'); - if($config && $http) $path = ($config->https ? 'https://' : 'http://') . $path; - $line = $error['line']; - $file = $error['file']; - $message = isset($this->types[$type]) ? $this->types[$type] : $this->types[E_ERROR]; - if(strpos($error['message'], "\t") !== false) $error['message'] = str_replace("\t", ' ', $error['message']); - $message .= ": \t$error[message]"; - if($type != E_USER_ERROR) $message .= ' ' . sprintf($this->labels['line-of-file'], $line, $file) . ' '; - $debug = false; - $log = null; - $why = ''; - $who = ''; - $sendOutput = true; + $type = $error['type']; + + if(isset($this->types[$type])) { + $errorType = $this->types[$type]; + } else { + $errorType = $this->types[E_USER_ERROR]; + } + + $message = str_replace("\t", ' ', $error['message']); + + if($type != E_USER_ERROR) { + $detail = sprintf($this->labels['line-of-file'], $error['line'], $error['file']) . ' '; + } else { + $detail = ''; + } + + return "$errorType: \t$message $detail "; + } - if($config) { - $debug = $config->debug; - $sendOutput = $config->allowExceptions !== true; - if($config->ajax) $http = false; - if((function_exists("\\ProcessWire\\wireMail") || function_exists("wireMail")) && $config->adminEmail && $sendOutput) { - $logMessage = "Page: $path\nUser: $userName\n\n" . str_replace("\t", "\n", $message); - wireMail($config->adminEmail, $config->adminEmail, $this->labels['email-subject'], $logMessage); - } - if($config->paths->logs) { - $logMessage = "$userName\t$path\t" . str_replace("\n", " ", $message); - $log = $this->wire(new FileLog($config->paths->logs . 'errors.txt')); - $log->setDelimeter("\t"); - $log->save($logMessage); - } + /** + * Get WireInput instance and create it if not already present in the API + * + * @return WireInput + * + */ + protected function getWireInput() { + /** @var WireInput $input */ + $input = $this->wire('input'); + if($input) return $input; + $input = $this->wire(new WireInput()); + return $input; + } + + /** + * Get the current request URL or "/?/" if it cannot be determined + * + * @return string + * + */ + protected function getCurrentUrl() { + + /** @var Page|null $page */ + $page = $this->wire('page'); + $input = $this->getWireInput(); + $http = isset($_SERVER['HTTP_HOST']) || isset($_SERVER['REQUEST_URI']); + + if($http) { + // best case, everything available. getting httpUrl requires that $config API var is available... + $url = $input->httpUrl(); + } else if($page) { + // this can occur for non-http request like command line + $url = $page->url(); + } else { + // unable to determine url + $url = '/?/'; + } + + return $url; + } + + /** + * Render an error message and reason why + * + * @param string $message + * @param string $why + * @param bool $useHTML + * + */ + protected function sendErrorMessage($message, $why, $useHTML) { + + $this->sendExistingOutput(); + + // return text-only error + if(!$useHTML) { + echo "\n\n$message\n\n$why\n\n"; + return; + } + + // output HTML error + $html = $this->config->fatalErrorHTML ? $this->config->fatalErrorHTML : self::defaultFatalErrorHTML; + $html = str_replace(array( + '{message}', + '{why}' + ), array( + nl2br(htmlspecialchars($message, ENT_QUOTES, "UTF-8", false)), + htmlspecialchars($why, ENT_QUOTES, "UTF-8", false) + ), $html); + + // make a prettier looking debug backtrace, when applicable + $html = preg_replace('!(]*>\s*)(#\d+\s+[^<]+)!is', '$1$2', $html); + + // reference original file rather than compiled version, when applicable + $html = str_replace('assets/cache/FileCompiler/site/', '', $html); + + // output the error message + echo "\n\n$html\n\n"; + } + + /** + * Send a 500 internal server error + * + * This is a public fatal error that doesn’t reveal anything specific. + * + * @param string $message Message to indicate who error was also sent to + * @param bool $useHTML Output for a web browser? + * + */ + protected function sendError500($message, $useHTML) { + + if($useHTML) { + header("HTTP/1.1 500 Internal Server Error"); + $message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8'); + // file that error message will be output in, when available + $file = $this->config->paths->templates . 'errors/500.html'; + } else { + $file = ''; + } + + $this->sendExistingOutput(); + + if($file && is_file($file)) { + // use defined /site/templates/errors/500.html file + echo str_replace('{message}', $message, file_get_contents($file)); + } else { + // use generic error message, since no 500.html available + echo "\n\n" . $this->labels['unable-complete'] . ($message ? " - $message" : "") . "\n\n"; + } + } + + /** + * Send any existing output while removing PHP’s error message from it (to avoid duplication) + * + */ + protected function sendExistingOutput() { + + $files = TemplateFile::getRenderStack(); + if(!count($files)) return; + + $out = ob_get_clean(); + if(!strlen($out)) return; + + // if error message isn't in existing output, then reutrn as-is + if(empty($this->error['message']) || strpos($out, $this->error['message']) === false) { + echo $out; + return; } - if(!$sendOutput) return true; + $token = ''; + do { + $token .= 'xPW' . mt_rand() . 'SD'; + } while(strpos($out, $token) !== false); + + // replace error message with token + $out = str_replace($this->error['message'], $token, $out); + + // replace anything else on the same line as the PHP error (error type, file, line-number) + $out = preg_replace('/([\r\n]|^)[^\r\n]+' . $token . '[^\r\n]*/', '', $out); + + echo $out; + } - // we populate $who to give an ambiguous indication where the full error message has been sent - if($log) $who .= $this->labels['error-logged'] . ' '; - if($config && $config->adminEmail) $who .= $this->labels['admin-notified']; + /** + * Shutdown function registered with PHP + * + * @return bool + * + */ + public function shutdown() { + + /** @var Config|null $config */ + /** @var User|null $user */ + /** @var Page|null $page */ + + $error = error_get_last(); + + if(!$error) return true; + if(!in_array($error['type'], $this->fatalTypes)) return true; + + $this->error = $error; + $this->prepareLabels(); + $config = $this->config; + $user = $this->wire('user'); // current user, if present + $useHTML = isset($_SERVER['HTTP_HOST']); // is this an HTTP request where we can output HTML? + $name = $user ? $user->name : '?'; // user name + $why = ''; // reason why error is being shown, when access allows + $who = ''; // who/where the error message has been sent + $message = $this->getErrorMessage($error); + $url = $this->getCurrentUrl(); + $sendOutput = $config->allowExceptions !== true; + + // use text-only output if an http request that is ajax + if($useHTML && $config->ajax) $useHTML = false; + + // include IP address is user name if configured to do so + if($config->logIP && isset($_SERVER['REMOTE_ADDR'])) { + $ip = $this->wire('session') ? $this->wire('session')->getIP() : $_SERVER['REMOTE_ADDR']; + $name = "$name ($ip)"; + } + + // send error email if applicable + if($config->adminEmail && $sendOutput && $this->wire('mail')) { + $n = $this->wire('mail')->new() + ->to($config->adminEmail) + ->from($config->adminEmail) + ->subject($this->labels['email-subject']) + ->body("Page: $url\nUser: $name\n\n" . str_replace("\t", "\n", $message)); + if($n) $who .= $this->labels['admin-notified']; + } + + // save to errors.txt log file if applicable + if($config->paths->logs) { + $log = $this->wire(new FileLog($config->paths->logs . 'errors.txt')); + $log->setDelimeter("\t"); + $log->save("$name\t$url\t" . str_replace("\n", " ", $message)); + $who .= ($who ? ' ' : '') . $this->labels['error-logged']; + } + + // if not allowed to send output, then do nothing further + if(!$sendOutput) return true; // we populate $why if we're going to show error details for any of the following reasons: // otherwise $why will NOT be populated with anything - if($debug) $why = $this->labels['debug-mode'] . " (\$config->debug = true; => /site/config.php)."; - else if(!$http) $why = $this->labels['cli-mode']; - else if($user && $user->isSuperuser()) $why = $this->labels['you-superuser']; - else if($config && is_file($config->paths->root . "install.php")) $why = $this->labels['install-php']; - else if($config && !is_file($config->paths->assets . "active.php")) { + if($config->debug) { + $why = $this->labels['debug-mode'] . " (\$config->debug = true; => /site/config.php)."; + } else if(!$useHTML) { + $why = $this->labels['cli-mode']; + } else if($user && $user->isSuperuser()) { + $why = $this->labels['you-superuser']; + } else if($config && is_file($config->paths->root . "install.php")) { + $why = $this->labels['install-php']; + } else if($config && $config->paths->assets && !is_file($config->paths->assets . "active.php")) { // no login has ever occurred or user hasn't logged in since upgrade before this check was in place // check the date the site was installed to ensure we're not dealing with an upgrade $installed = $config->paths->assets . "installed.php"; @@ -125,40 +363,26 @@ class WireShutdown extends Wire { // site was installed within the last 6 hours, safe to assume it's a new install $why = $this->labels['superuser-never']; } - } - + } + if($why) { - // when in debug mode, we can assume the message was already shown, so we just say why. - // when not in debug mode, we display the full error message since error_reporting and display_errors are off. $why = $this->labels['shown-because'] . " $why $who"; - $html = "

{message}
{why}

"; - if($http) { - if($config && $config->fatalErrorHTML) $html = $config->fatalErrorHTML; - $html = str_replace(array('{message}', '{why}'), array( - nl2br(htmlspecialchars($message, ENT_QUOTES, "UTF-8", false)), - htmlspecialchars($why, ENT_QUOTES, "UTF-8", false)), $html); - // make a prettier looking debug backtrace, when applicable - $html = preg_replace('!(]*>\s*)(#\d+\s+[^<]+)!is', '$1$2', $html); - $html = str_replace('assets/cache/FileCompiler/site/', '', $html); - echo "\n\n$html\n\n"; - } else { - echo "\n\n$message\n\n$why\n\n"; - } + $this->sendErrorMessage($message, $why, $useHTML); } else { - // public fatal error that doesn't reveal anything specific - if($http) header("HTTP/1.1 500 Internal Server Error"); - // file that error message will be output in, when available - $file = $config && $http ? $config->paths->templates . 'errors/500.html' : ''; - if($file && is_file($file)) { - // use defined /site/templates/errors/500.html file - echo str_replace('{message}', $who, file_get_contents($file)); - } else { - // use generic error message, since no 500.html available - echo "\n\n" . $this->labels['unable-complete'] . ($who ? " - $who" : "") . "\n\n"; - } + $this->sendError500($who, $useHTML); } return true; } + + /** + * Secondary shutdown call when ProcessWire booted externally + * + */ + public function shutdownExternal() { + if(error_get_last()) return; + $process = $this->wire('process'); + if($process == 'ProcessPageView') $process->finished(); + } }