diff --git a/wire/core/WireShutdown.php b/wire/core/WireShutdown.php index 9e643de4..fbb8842b 100644 --- a/wire/core/WireShutdown.php +++ b/wire/core/WireShutdown.php @@ -3,7 +3,7 @@ /** * ProcessWire shutdown handler * - * ProcessWire 3.x, Copyright 2018 by Ryan Cramer + * ProcessWire 3.x, Copyright 2020 by Ryan Cramer * * Look for errors at shutdown and log them, plus echo the error if the page is editable * @@ -36,6 +36,30 @@ class WireShutdown extends Wire { E_RECOVERABLE_ERROR, ); + /** + * Fatal error response info, not used unless set manually by $shutdown->setFatalErrorResponse() + * + * - `code` (int): Fatal error http status code (0=use $config->fatalErrorCode instead) + * - `headers` (array): Any additional headers to include in fatal error, in format [ "Header-Name: Header-Value" ] + * - `adminEmail` (string): Administrator email address to send error to (overrides $config->adminEmail) + * - `fromEmail` (string): From email address for email to administrator (default=same as adminEmail) + * - `emailSubject` (string): Override email subject (default=use built-in translatable subject) + * - `emailBody` (string): Override default email body (text-only). Should have {url}, {user} and {message} placeholders. + * - `words` (array): Spicy but calming words to prepend to visible error messages. + * + * @var array + * + */ + protected $fatalErrorResponse = array( + 'code' => 0, + 'headers' => array(), + 'adminEmail' => '', + 'fromEmail' => '', + 'emailSubject' => '', + 'emailBody' => '', + 'words' => array(), + ); + /** * Associative array of phrase translations for this module * @@ -66,6 +90,12 @@ class WireShutdown extends Wire { */ const defaultFatalErrorHTML = '

{message}
{why}

'; + /** + * Default email body for emailed fatal errors + * + */ + const defaultEmailBody = "URL: {url}\nUser: {user}\n\n{message}"; + /** * Construct and register shutdown function * @@ -79,6 +109,24 @@ class WireShutdown extends Wire { if(!$config->internal) register_shutdown_function(array($this, 'shutdownExternal')); } + /** + * Set fatal error response info including http code, optional extra headers, and more + * + * @param array $options + * - `code` (int): http code to send, or omit to use default (500) + * - `headers` (array): Optional additional headers to send, in format [ "Header-Name: Header-Value" ] + * - `adminEmail` (string): Administrator email address to send error to (overrides $config->adminEmail) + * - `fromEmail` (string): From email address for email to administrator (default=same as adminEmail) + * - `emailSubject` (string): Override email subject (default=use built-in translatable subject) + * - `emailBody` (string): Override default email body (text-only). Should have {url}, {user} and {message} placeholders. + * - `words` (array): Spicy but calming words to prepend to visible error messages. + * @since 3.0.166 + * + */ + public function setFatalErrorResponse(array $options) { + $this->fatalErrorResponse = array_merge($this->fatalErrorResponse, $options); + } + /** * Setup our translation labels * @@ -278,7 +326,9 @@ class WireShutdown extends Wire { */ protected function seasonErrorMessage($message) { - $spices = array( + $spices = $this->fatalErrorResponse['words']; + + if(empty($spices)) $spices = array( 'Oops', 'Darn', 'Dangit', 'Oh no', 'Ah snap', 'So sorry', 'Well well', 'Ouch', 'Arrgh', 'Umm', 'Snapsicles', 'Oh snizzle', 'Look', 'What the', 'Uff da', 'Yikes', 'Aw shucks', 'Oye', 'Rats', 'Hmm', 'Yow', 'Not again', @@ -286,7 +336,9 @@ class WireShutdown extends Wire { ); $spice = $spices[array_rand($spices)]; - $message = "{$spice}… $message"; + if(!ctype_punct(substr($spice, -1))) $spice .= '…'; + + $message = "$spice $message"; return $message; } @@ -301,14 +353,22 @@ class WireShutdown extends Wire { include_once(dirname(__FILE__) . '/WireHttp.php'); $http = new WireHttp(); $codes = $http->getHttpCodes(); - $code = (int) ($this->config ? $this->config->fatalErrorCode : 500); + $code = 500; + if($this->fatalErrorResponse['code']) {; + $code = (int) $this->fatalErrorResponse['code']; + } else if($this->config) { + $code = (int) $this->config->fatalErrorCode; + } if(!isset($codes[$code])) $code = 500; $http->sendStatusHeader($code); + foreach($this->fatalErrorResponse['headers'] as $header) { + $http->sendHeader($header); + } return $code; } /** - * Send a 500 internal server error + * Send a fatal error * * This is a public fatal error that doesn’t reveal anything specific. * @@ -419,10 +479,6 @@ class WireShutdown extends Wire { */ public function shutdown() { - /** @var Config|null $config */ - /** @var User|null $user */ - /** @var Page|null $page */ - $error = error_get_last(); if(!$error) return true; @@ -431,11 +487,10 @@ class WireShutdown extends Wire { $this->error = $error; $this->prepareLabels(); $config = $this->config; - $user = $this->wire('user'); // current user, if present + $user = $this->wire()->user; /** @var User|null $user */ $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 + $name = $user && $user->id ? $user->name : '?'; // user name + $who = array(); // who/where the error message has been sent $message = $this->getErrorMessage($error); $url = $this->getCurrentUrl(); $sendOutput = $config->allowExceptions !== true; @@ -449,49 +504,26 @@ class WireShutdown extends Wire { if(strlen($ip)) $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)) - ->send(); - 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']; + // save to errors.txt log file + if($this->saveFatalLog($url, $name, $message)) { + $who[] = $this->labels['error-logged']; } // if not allowed to send output, then do nothing further if(!$sendOutput) return true; + + // send error email if applicable + if($this->sendFatalEmail($url, $name, $message)) { + $who[] = $this->labels['admin-notified']; + } // 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($config->debug) { - $why = $this->labels['debug-mode'] . " (\$config->debug = true; => /site/config.php)."; - } else if($config->cli) { - $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"; - if(!is_file($installed) || (filemtime($installed) > (time() - 21600))) { - // site was installed within the last 6 hours, safe to assume it's a new install - $why = $this->labels['superuser-never']; - } - } + $why = $this->getReasonsWhy(); + $who = implode(' ', $who); - if($why) { + if(count($why)) { + $why = reset($why); // show only 1st reason $why = $this->labels['shown-because'] . " $why $who"; $message = $this->amendErrorMessage($message); $this->sendFatalHeader(); @@ -502,6 +534,126 @@ class WireShutdown extends Wire { return true; } + + /** + * Get reasons why a fatal error message is shown + * + * If error details should not be shown then return a blank array + * + * @return array + * + */ + protected function getReasonsWhy() { + + $config = $this->config; + $user = $this->wire()->user; + $why = array(); + + if($user && $user->isSuperuser()) { + $why[] = $this->labels['you-superuser']; + } + + if(!$config) return $why; + + if($config->debug) { + $why[] = $this->labels['debug-mode'] . " (\$config->debug = true; => /site/config.php)."; + } + + if($config->cli) { + $why[] = $this->labels['cli-mode']; + } + + if(is_file($config->paths->root . 'install.php')) { + $why[] = $this->labels['install-php']; + } + + $path = $config->paths->assets; + + if($path && !is_file($path . '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 = $path . 'installed.php'; + $ts = time() - 21600; + if(!is_file($installed) || (filemtime($installed) > $ts)) { + // site was installed within the last 6 hours, safe to assume it’s a new install + $why[] = $this->labels['superuser-never']; + } + } + + return $why; + } + + /** + * Save fatal error to log + * + * @param string $url + * @param string $userName + * @param string $message + * @return bool + * + */ + protected function saveFatalLog($url, $userName, $message) { + // save to errors.txt log file if applicable + $config = $this->config; + if(!$config->paths->logs) return false; + $message = str_replace(array("\n", "\t"), " ", $message); + try { + $log = $this->wire(new FileLog($config->paths->logs . 'errors.txt')); + $log->setDelimeter("\t"); + $saved = $log->save("$userName\t$url\t$message"); + } catch(\Exception $e) { + $saved = false; + } + return $saved; + } + + /** + * Send fatal error email + * + * @param string $url + * @param string $userName + * @param string $message + * @return bool + * + */ + protected function sendFatalEmail($url, $userName, $message) { + + $mail = $this->wire()->mail; + if(!$mail) return false; + + $adminEmail = $this->fatalErrorResponse['adminEmail']; + if(empty($adminEmail) && $this->config) $adminEmail = $this->config->adminEmail; + if(empty($adminEmail)) return false; + + $fromEmail = $this->fatalErrorResponse['fromEmail']; + if(empty($fromEmail)) $fromEmail = $adminEmail; + + $emailSubject = $this->fatalErrorResponse['emailSubject']; + if(empty($emailSubject)) $emailSubject = $this->labels['email-subject']; + + $emailBody = $this->fatalErrorResponse['emailBody']; + if(empty($emailSubject)) $emailBody = self::defaultEmailBody; + + $message = str_replace("\t", "\n", $message); + + $emailBody = str_replace( + array('{url}', '{user}', '{message}'), + array($url, $userName, $message), + $emailBody + ); + + try { + $sent = $mail->to($adminEmail) + ->from($fromEmail) + ->subject($emailSubject) + ->body($emailBody) + ->send(); + } catch(\Exception $e) { + $sent = false; + } + + return $sent ? true : false; + } /** * Secondary shutdown call when ProcessWire booted externally