diff --git a/Sources/ErrorHandler.php b/Sources/ErrorHandler.php index 78f3aa5055..029a861ede 100644 --- a/Sources/ErrorHandler.php +++ b/Sources/ErrorHandler.php @@ -1,33 +1,21 @@ settings['current_include_filename'])) { - $array = debug_backtrace(); - $count = \count($array); - - for ($i = 0; $i < $count; $i++) { - if ($array[$i]['function'] != 'SMF\\Theme::loadSubTemplate') { - continue; - } - - // This is a bug in PHP, with eval, it seems! - if (empty($array[$i]['args'])) { - $i++; - } - - break; - } - - if (isset($array[$i]) && !empty($array[$i]['args'])) { - $file = realpath(Theme::$current->settings['current_include_filename']) . ' (' . $array[$i]['args'][0] . ' sub template - eval?)'; - } else { - $file = realpath(Theme::$current->settings['current_include_filename']) . ' (eval?)'; - } - } - - if (DebugUtils::isDebugEnabled()) { - // Commonly, undefined indexes will occur inside attributes; try to show them anyway! - if ($error_level % 255 != E_ERROR) { - $temporary = ob_get_contents(); - - if (str_ends_with($temporary, '="')) { - echo '"'; - } - } - - // Debugging! This should look like a PHP error message. - echo "
\n", $error_level % 255 == E_ERROR ? 'Error' : ($error_level % 255 == E_WARNING ? 'Warning' : 'Notice'), ': ', $error_string, ' in ', $file, ' on line ', $line, '
'; - } - - $error_type = stripos($error_string, 'undefined') !== false ? 'undefined_vars' : 'general'; - - $message = self::log($error_level . ': ' . $error_string, $error_type, $file, $line); - - // Let's give integrations a chance to output a bit differently - IntegrationHook::call('integrate_output_error', [$message, $error_type, $error_level, $file, $line]); - - // Dying on these errors only causes MORE problems (blank pages!) - if ($file == 'Unknown') { - return; - } - - // If this is an E_ERROR or E_USER_ERROR.... die. Violently so. - if ($error_level % 255 == E_ERROR) { - Utils::obExit(false); - } else { - return; - } - - // If this is an E_ERROR, E_USER_ERROR, E_WARNING, or E_USER_WARNING.... die. Violently so. - if ($error_level % 255 == E_ERROR || $error_level % 255 == E_WARNING) { - self::fatal(isset(User::$me) && User::$me->allowedTo('admin_forum') ? $message : $error_string, false); - } - - // We should NEVER get to this point. Any fatal error MUST quit, or very bad things can happen. - if ($error_level % 255 == E_ERROR) { - die('No direct access...'); - } + self::getService()->handleError($error_level, $error_string, $file, $line); } /*********************** @@ -164,7 +89,7 @@ public function __construct(int $error_level, string $error_string, string $file */ public static function call(int $error_level, string $error_string, string $file, int $line): void { - new self($error_level, $error_string, $file, $line); + self::getService()->call($error_level, $error_string, $file, $line); } /** @@ -176,13 +101,7 @@ public static function call(int $error_level, string $error_string, string $file */ public static function catch(\Throwable $e): void { - $message = Lang::txtExists($e->getMessage(), file: 'Errors') ? Lang::getTxt($e->getMessage(), file: 'Errors') : $e->getMessage(); - - if (!empty(Config::$modSettings['enableErrorLogging'])) { - self::log($message, 'general', $e->getFile(), $e->getLine(), $e->getTrace()); - } - - self::fatal($message, false); + self::getService()->catch($e); } /** @@ -201,117 +120,7 @@ public static function catch(\Throwable $e): void */ public static function log(string $error_message, string|bool $error_type = 'general', string $file = '', int $line = 0, ?array $backtrace = null): string { - static $last_error; - static $tried_hook = false; - static $error_call = 0; - - $error_call++; - - // Collect a backtrace - if (!DebugUtils::isDebugEnabled()) { - $backtrace = $backtrace ?? debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); - } else { - // This is how to keep the args but skip the objects. - $backtrace = $backtrace ?? debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS & DEBUG_BACKTRACE_PROVIDE_OBJECT); - } - - // Are we in a loop? - if ($error_call > 2) { - var_dump($backtrace); - - die('Error: loop detected. The database may have failed or crashed.'); - } - - // Check if error logging is actually on. - if (empty(Config::$modSettings['enableErrorLogging'])) { - return $error_message; - } - - // Basically, htmlspecialchars it minus &. (for entities!) - $error_message = strtr($error_message, ['<' => '<', '>' => '>', '"' => '"']); - - $error_message = strtr($error_message, ['<br />' => '
', '<br>' => '
', '<b>' => '', '</b>' => '', "\n" => '
']); - - // Add a file and line to the error message? - // Don't use the actual txt entries for file and line. - // Instead use %1$s for file and %2$s for line. - // Windows style slashes don't play well, lets convert them to the UNIX style. - $file = str_replace('\\', '/', $file); - - // Find the best path and query string we can... - if (SMF === 'SSI') { - $request_url = ($_SERVER['REQUEST_SCHEME'] ?? 'http') . '://' . ($_SERVER['SERVER_NAME'] ?? 'unknown') . '/' . ltrim($_SERVER['REQUEST_URI'] ?? (($_SERVER['DOCUMENT_URI'] ?? $_SERVER['SCRIPT_NAME'] ?? 'unknown.php') . !empty($_SERVER['QUERY_STRING']) ? '?' . $_SERVER['QUERY_STRING'] : ''), '/'); - } elseif (str_starts_with(($_SERVER['REQUEST_URL'] ?? ''), Config::$boardurl)) { - $request_url = substr($_SERVER['REQUEST_URL'], \strlen(Config::$boardurl)); - } else { - $request_url = ($_SERVER['REQUEST_URL'] ?? ''); - } - - // Don't log the session hash in the url twice, it's a waste. - $request_url = Utils::htmlspecialchars(preg_replace(['~([?&;]sesc)=[^&;]+~', '~' . session_name() . '=' . session_id() . '[&;]~'], ['$1', ''], $request_url)); - - // Just so we know what board error messages are from. - if (isset($_POST['board']) && !isset($_GET['board']) && SMF !== 'SSI') { - $request_url .= ($request_url == '' ? 'board=' : ';board=') . $_POST['board']; - } - - // This prevents us from infinite looping if the hook or call produces an error. - $other_error_types = []; - - if (empty($tried_hook)) { - $tried_hook = true; - - // Allow the hook to change the error_type and know about the error. - IntegrationHook::call('integrate_error_types', [&$other_error_types, &$error_type, $error_message, $file, $line]); - - self::$known_error_types = array_merge(self::$known_error_types, $other_error_types); - } - - // Make sure the category that was specified is a valid one - $error_type = \in_array($error_type, self::$known_error_types) && $error_type !== true ? $error_type : 'general'; - - // Leave out the call to this method. - array_splice($backtrace, 0, 1); - $backtrace = Utils::jsonEncode($backtrace); - - // Don't log the same error countless times, as we can get in a cycle of depression... - $error_info = [ - User::$me->id ?? User::$my_id ?? 0, - time(), - User::$me->ip ?? IP::getUserIP(), - $request_url, - $error_message, - (string) (User::$sc ?? ''), - $error_type, - $file, - $line, - $backtrace, - ]; - - if (empty($last_error) || $last_error != $error_info) { - // Insert the error into the database. - Db::$db->error_insert($error_info); - $last_error = $error_info; - - // Get an error count, if necessary - if (!isset(Utils::$context['num_errors'])) { - $query = Db::$db->query( - 'SELECT COUNT(*) - FROM {db_prefix}log_errors', - [], - ); - list(Utils::$context['num_errors']) = Db::$db->fetch_row($query); - Db::$db->free_result($query); - } else { - Utils::$context['num_errors']++; - } - } - - // Reset error call - $error_call = 0; - - // Return the message to make things simpler. - return $error_message; + return self::getService()->log($error_message, $error_type, $file, $line, $backtrace); } /** @@ -328,18 +137,7 @@ public static function log(string $error_message, string|bool $error_type = 'gen */ public static function fatal(string $error, string|bool $log = 'general', int $status = 500): void { - // Send the appropriate HTTP status header - set this to 0 or false if you don't want to send one at all - if (!empty($status)) { - Utils::sendHttpStatus($status); - } - - // We don't have Lang::$txt yet, but that's okay... - if (empty(Lang::$txt)) { - die($error); - } - - self::logOnline($error); - self::setupFatalContext($log ? self::log($error, $log) : $error); + self::getService()->fatal($error, $log, $status); } /** @@ -362,45 +160,7 @@ public static function fatal(string $error, string|bool $log = 'general', int $s */ public static function fatalLang(string $error, string|bool $log = 'general', array $sprintf = [], int $status = 403, string $file = 'Errors'): void { - static $fatal_error_called = false; - - // Send the status header - set this to 0 or false if you don't want to send one at all. - if (!empty($status)) { - Utils::sendHttpStatus($status); - } - - // Try to load a theme if we don't have one. - if (empty(Utils::$context['theme_loaded']) && empty($fatal_error_called)) { - $fatal_error_called = true; - Theme::load(); - } - - // Attempt to load the text string. - $error_message = Lang::getTxt($error, $sprintf, file: 'Errors'); - - // Send a custom header if we have a custom message. - if (isset($_REQUEST['js']) || isset($_REQUEST['xml']) || isset($_REQUEST['ajax'])) { - header('X-SMF-errormsg: ' . $error_message); - } - - // If we have no theme stuff we can't have the language file... - if (empty(Utils::$context['theme_loaded'])) { - die($error); - } - - // Log the error in the forum's language, but don't waste the time if we aren't logging - if ($log) { - $error_message = Lang::getTxt($error, $sprintf, file: $file, lang: Lang::$default); - self::log($error_message, $log); - } - - // Load the language file, only if it needs to be reloaded - if (!$log || Lang::$default != User::$me->language) { - $error_message = Lang::getTxt($error, $sprintf, file: $file, lang: User::$me->language); - } - - self::logOnline($error, $sprintf); - self::setupFatalContext($error_message, $error); + self::getService()->fatalLang($error, $log, $sprintf, $status, $file); } /** @@ -409,32 +169,10 @@ public static function fatalLang(string $error, string|bool $log = 'general', ar * It shows a complete page independent of language files or themes. * It is used only if $maintenance = 2 in Settings.php. * It stops further execution of the script. - * @todo: As of PHP 8.1, this return type can be 'never' */ public static function displayMaintenanceMessage(): void { - self::setFatalHeaders(); - - if (!empty(Config::$maintenance)) { - $mtitle = Config::$mtitle; - $mmessage = Config::$mmessage; - - echo << - - - - {$mtitle} - - -

{$mtitle}

- {$mmessage} - - - END; - } - - die(); + self::getService()->displayMaintenanceMessage(); } /** @@ -443,49 +181,10 @@ public static function displayMaintenanceMessage(): void * It shows a complete page independent of language files or themes. * It is used only if there's no way to connect to the database. * It stops further execution of the script. - * @todo: As of PHP 8.1, this return type can be 'never' */ public static function displayDbError(): void { - self::setFatalHeaders(); - - // For our purposes, we're gonna want this on if at all possible. - CacheApi::$enable = 1; - - if (($temp = CacheApi::get('db_last_error', 600)) !== null) { - Config::$db_last_error = max(Config::$db_last_error, $temp); - } - - if (Config::$db_last_error < time() - 3600 * 24 * 3 && empty(Config::$maintenance) && !empty(Config::$db_error_send)) { - // Avoid writing to the Settings.php file if at all possible; use shared memory instead. - CacheApi::put('db_last_error', time(), 600); - - if (($temp = CacheApi::get('db_last_error', 600)) === null) { - self::logLastDatabaseError(); - } - - $db_error = isset(Db::$db) ? @Db::$db->error() : ''; - - // Language files aren't loaded yet :(. - @mail(Config::$webmaster_email, Config::$mbname . ': SMF Database Error!', 'There has been a problem with the database!' . ($db_error == '' ? '' : "\n" . Db::$db->title . ' reported:' . "\n" . $db_error) . "\n\n" . 'This is a notice email to let you know that SMF could not connect to the database, contact your host if this continues.'); - } - - // What to do? Language files haven't and can't be loaded yet... - echo << - - - - Connection Problems - - -

Connection Problems

- Sorry, SMF was unable to connect to the database. This may be caused by the server being busy. Please try again later. - - - END; - - die(); + self::getService()->displayDbError(); } /** @@ -494,29 +193,10 @@ public static function displayDbError(): void * It shows a complete page independent of language files or themes. * It is used only if the load averages are too high to continue execution. * It stops further execution of the script. - * @todo: As of PHP 8.1, this return type can be 'never' */ public static function displayLoadAvgError(): void { - // If this is a load average problem, display an appropriate message (but we still don't have language files!) - - self::setFatalHeaders(); - - echo << - - - - Temporarily Unavailable - - -

Temporarily Unavailable

- Due to high stress on the server the forum is temporarily unavailable. Please try again later. - - - END; - - die(); + self::getService()->displayLoadAvgError(); } /************************* @@ -524,203 +204,37 @@ public static function displayLoadAvgError(): void *************************/ /** - * Small utility function for fatal error pages. - * Used by self::fatal() and self::fatalLang(). - * - * @param string $error The error - */ - protected static function logOnline(string $error, array $sprintf = []): void - { - // Don't bother if Who's Online is disabled. - if (empty(Config::$modSettings['who_enabled'])) { - return; - } - - // Maybe they came from SSI or similar where sessions are not recorded? - if (SMF == 'SSI' || SMF == 'BACKGROUND') { - return; - } - - $session_id = !empty(User::$me->is_guest) ? 'ip' . User::$me->ip : session_id(); - - // First, we have to get the online log, because we need to break apart the serialized string. - $request = Db::$db->query( - 'SELECT url - FROM {db_prefix}log_online - WHERE session = {string:session}', - [ - 'session' => $session_id, - ], - ); - - if (Db::$db->num_rows($request) != 0) { - list($url) = Db::$db->fetch_row($request); - - $url = Utils::jsonDecode($url, true); - $url['error'] = $error; - - // Url field got a max length of 1024 in db - if (\strlen($url['error']) > 500) { - $url['error'] = substr($url['error'], 0, 500); - } - - if (!empty($sprintf)) { - $url['error_params'] = $sprintf; - } - - Db::$db->query( - 'UPDATE {db_prefix}log_online - SET url = {string:url} - WHERE session = {string:session}', - [ - 'url' => Utils::jsonEncode($url), - 'session' => $session_id, - ], - ); - } - Db::$db->free_result($request); - } - - /** - * Small utility function for fatal error pages. - * - * Used by self::displayMaintenanceMessage(), self::displayDbError(), and - * self::displayLoadAvgError(). - */ - protected static function setFatalHeaders(): void - { - if (headers_sent()) { - return; - } - - // Don't cache this page! - header('expires: Mon, 26 Jul 1997 05:00:00 GMT'); - header('last-modified: ' . gmdate('D, d M Y H:i:s') . ' GMT'); - header('cache-control: no-cache'); - - // Send the right error codes. - Utils::sendHttpStatus(503, 'Service Temporarily Unavailable'); - header('status: 503 Service Temporarily Unavailable'); - header('retry-after: 3600'); - } - - /** - * It is called by self::fatal() and self::fatalLang(). + * Get the ErrorHandlerService instance from the container. * - * @uses template_fatal_error() + * This method provides lazy initialization and caching of the service instance. + * It first attempts to retrieve the service from the DI container, falling back + * to direct instantiation if the container is not available (e.g., during early + * bootstrap or error conditions). * - * @param string $error_message The error message - * @param null|string $error_code An error code + * @return ErrorHandlerService The error handler service instance. */ - protected static function setupFatalContext(string $error_message, ?string $error_code = null): void + protected static function getService(): ErrorHandlerService { - static $level = 0; - - if ( - // Don't get caught in a recursive loop. - ++$level > 1 - // If we hit a fatal error during install, don't try to load the theme. - || \defined('SMF_INSTALLING') - ) { - die($error_message); - } - - // Maybe they came from dlattach or similar? - if (SMF != 'SSI' && SMF != 'BACKGROUND' && empty(Utils::$context['theme_loaded'])) { - Theme::load(); + // Return cached instance if available + if (self::$service !== null) { + return self::$service; } - // Don't bother indexing errors mate... - Utils::$context['robot_no_index'] = true; + // Try to get the service from the container + try { + self::$service = Container::get(ErrorHandlerService::class); - if (!isset(Utils::$context['error_title'])) { - Utils::$context['error_title'] = Lang::getTxt('error_occured', file: 'General'); + return self::$service; + } catch (\Throwable $e) { + // Container not available or service not registered + // Fall through to manual instantiation } - Utils::$context['error_message'] = Utils::$context['error_message'] ?? $error_message; - - Utils::$context['error_code'] = isset($error_code) ? 'id="' . $error_code . '" ' : ''; - - Utils::$context['error_link'] = Utils::$context['error_link'] ?? 'javascript:document.location=document.referrer'; - - if (empty(Utils::$context['page_title'])) { - Utils::$context['page_title'] = Utils::$context['error_title']; - } - - Theme::loadTemplate('Errors'); - Utils::$context['sub_template'] = 'fatal_error'; - - // If this is SSI, what do they want us to do? - if (SMF == 'SSI') { - if (!empty(SSI::$on_error_method) && SSI::$on_error_method !== true && \is_callable(SSI::$on_error_method)) { - \call_user_func(SSI::$on_error_method); - } elseif (empty(SSI::$on_error_method) || SSI::$on_error_method !== true) { - Theme::loadSubTemplate('fatal_error'); - } - - // No layers? - if (empty(SSI::$on_error_method) || SSI::$on_error_method !== true) { - exit; - } - } - // Alternatively from the cron call? - elseif (SMF == 'BACKGROUND') { - // We can't rely on even having language files available. - if (\defined('FROM_CLI') && FROM_CLI) { - echo 'cron error: ', Utils::$context['error_message']; - } else { - echo 'An error occurred. More information may be available in your logs.'; - } - - exit; - } - - // We want whatever for the header, and a footer. (footer includes sub template!) - Utils::obExit(null, true, false, true); - - /* DO NOT IGNORE: - If you are creating a bridge to SMF or modifying this function, you MUST - make ABSOLUTELY SURE that this function quits and DOES NOT RETURN TO NORMAL - PROGRAM FLOW. Otherwise, security error messages will not be shown, and - your forum will be in a very easily hackable state. - */ - die('No direct access...'); - } - - /** - * Logs the last database error into a file. - * Attempts to use the backup file first, to store the last database error - * and only update db_last_error.php if the first was successful. - * - * @return bool true if successfully able to write the last database error. - */ - protected static function logLastDatabaseError(): bool - { - // Make a note of the last modified time in case someone does this before us - $last_db_error_change = @filemtime(Config::$cachedir . '/db_last_error.php'); - - // save the old file before we do anything - $file = Config::$cachedir . '/db_last_error.php'; - $dberror_backup_fail = !@is_writable(Config::$cachedir . '/db_last_error_bak.php') || !@copy($file, Config::$cachedir . '/db_last_error_bak.php'); - $dberror_backup_fail = !$dberror_backup_fail ? (!file_exists(Config::$cachedir . '/db_last_error_bak.php') || filesize(Config::$cachedir . '/db_last_error_bak.php') === 0) : $dberror_backup_fail; - - clearstatcache(); - - if (filemtime(Config::$cachedir . '/db_last_error.php') === $last_db_error_change) { - // Write the change - $write_db_change = '<' . '?' . "php\n" . '$db_last_error = ' . time() . ';' . "\n"; - $written_bytes = file_put_contents(Config::$cachedir . '/db_last_error.php', $write_db_change, LOCK_EX); - - // survey says ... - if ($written_bytes !== \strlen($write_db_change) && !$dberror_backup_fail) { - // Oops. maybe we have no more disk space left, or some other troubles, troubles... - // Copy the file back and run for your life! - @copy(Config::$cachedir . '/db_last_error_bak.php', Config::$cachedir . '/db_last_error.php'); - } else { - return true; - } - } + // Fallback: create instance directly + // This ensures the error handler works even during early bootstrap + // or when the container is not available + self::$service = new ErrorHandlerService(); - return false; + return self::$service; } } diff --git a/Sources/Container.php b/Sources/Infrastructure/Container.php similarity index 88% rename from Sources/Container.php rename to Sources/Infrastructure/Container.php index aa21c5fe06..4bd19b59cd 100644 --- a/Sources/Container.php +++ b/Sources/Infrastructure/Container.php @@ -13,12 +13,13 @@ declare(strict_types=1); -namespace SMF; +namespace SMF\Infrastructure; use League\Container\Container as LeagueContainer; /** * A wrapper for the League\Container dependency injection container. + * Its meant to be a temporary wrapper until all global vars are moved to dependencies. */ class Container { @@ -42,6 +43,7 @@ public static function init(): void { if (!isset(self::$instance)) { self::$instance = new LeagueContainer(); + self::$instance->addServiceProvider(new ServiceProvider()); } } diff --git a/Sources/Infrastructure/ServiceProvider.php b/Sources/Infrastructure/ServiceProvider.php new file mode 100644 index 0000000000..777e70f4a1 --- /dev/null +++ b/Sources/Infrastructure/ServiceProvider.php @@ -0,0 +1,41 @@ +services = array_filter(array_merge($coreServices, $services)); + } + + public function provides(string $id): bool + { + return \array_key_exists($id, $this->services); + } + + public function register(): void + { + $container = $this->getContainer(); + + foreach ($this->services as $id => $config) { + $method = ($config['shared'] ?? false) ? 'addShared' : 'add'; + + $container->$method($id) + ->addArguments($config['arguments'] ?? []); + } + } +} diff --git a/Sources/Infrastructure/ServicesList.php b/Sources/Infrastructure/ServicesList.php new file mode 100644 index 0000000000..3e5e4e79bc --- /dev/null +++ b/Sources/Infrastructure/ServicesList.php @@ -0,0 +1,14 @@ + [ +// 'arguments' => [$db_server, $db_user], +// 'shared' => true // false will create a new instance everytime +//], +return [ + ErrorHandlerService::class => [ + 'shared' => true, + ], +]; diff --git a/Sources/Infrastructure/index.php b/Sources/Infrastructure/index.php new file mode 100644 index 0000000000..2844a3b9e7 --- /dev/null +++ b/Sources/Infrastructure/index.php @@ -0,0 +1,8 @@ +settings['current_include_filename'])) { + $array = debug_backtrace(); + $count = \count($array); + + for ($i = 0; $i < $count; $i++) { + if ($array[$i]['function'] != 'SMF\\Theme::loadSubTemplate') { + continue; + } + + // This is a bug in PHP, with eval, it seems! + if (empty($array[$i]['args'])) { + $i++; + } + + break; + } + + if (isset($array[$i]) && !empty($array[$i]['args'])) { + $file = realpath(Theme::$current->settings['current_include_filename']) . ' (' . $array[$i]['args'][0] . ' sub template - eval?)'; + } else { + $file = realpath(Theme::$current->settings['current_include_filename']) . ' (eval?)'; + } + } + + if (DebugUtils::isDebugEnabled()) { + // Commonly, undefined indexes will occur inside attributes; try to show them anyway! + if ($error_level % 255 != E_ERROR) { + $temporary = ob_get_contents(); + + if (str_ends_with($temporary, '="')) { + echo '"'; + } + } + + // Debugging! This should look like a PHP error message. + echo "
\n", $error_level % 255 == E_ERROR ? 'Error' : ($error_level % 255 == E_WARNING ? 'Warning' : 'Notice'), ': ', $error_string, ' in ', $file, ' on line ', $line, '
'; + } + + $error_type = stripos($error_string, 'undefined') !== false ? 'undefined_vars' : 'general'; + + $message = $this->log($error_level . ': ' . $error_string, $error_type, $file, $line); + + // Let's give integrations a chance to output a bit differently + IntegrationHook::call('integrate_output_error', [$message, $error_type, $error_level, $file, $line]); + + // Dying on these errors only causes MORE problems (blank pages!) + if ($file == 'Unknown') { + return; + } + + // If this is an E_ERROR or E_USER_ERROR.... die. Violently so. + if ($error_level % 255 == E_ERROR) { + Utils::obExit(false); + } else { + return; + } + + // If this is an E_ERROR, E_USER_ERROR, E_WARNING, or E_USER_WARNING.... die. Violently so. + if ($error_level % 255 == E_ERROR || $error_level % 255 == E_WARNING) { + $this->fatal(isset(User::$me) && User::$me->allowedTo('admin_forum') ? $message : $error_string, false); + } + + // We should NEVER get to this point. Any fatal error MUST quit, or very bad things can happen. + if ($error_level % 255 == E_ERROR) { + die('No direct access...'); + } + } + + /** + * Convenience method to create an instance of this class. + * + * @param int $error_level A pre-defined error-handling constant. + * (see {@link https://php.net/errorfunc.constants}) + * @param string $error_string The error message. + * @param string $file The file where the error occurred. + * @param int $line The line where the error occurred. + */ + public function call(int $error_level, string $error_string, string $file, int $line): void + { + $this->handleError($error_level, $error_string, $file, $line); + } + + /** + * Generic handler for uncaught exceptions. + * + * Always ends execution. + * + * @param \Throwable $e The uncaught exception. + */ + public function catch(\Throwable $e): void + { + $message = Lang::txtExists($e->getMessage(), file: 'Errors') ? Lang::getTxt($e->getMessage(), file: 'Errors') : $e->getMessage(); + + if (!empty(Config::$modSettings['enableErrorLogging'])) { + $this->log($message, 'general', $e->getFile(), $e->getLine(), $e->getTrace()); + } + + $this->fatal($message, false); + } + + /** + * Log an error, if the error logging is enabled. + * + * $file and $line should be __FILE__ and __LINE__, respectively. + * + * Example use: + * die(ErrorHandlerService::log($msg)); + * + * @param string $error_message The message to log. + * @param string|bool $error_type The type of error. + * @param string $file The name of the file where this error occurred. + * @param int $line The line where the error occurred. + * @return string The message that was logged. + */ + public function log(string $error_message, string|bool $error_type = 'general', string $file = '', int $line = 0, ?array $backtrace = null): string + { + static $last_error; + static $tried_hook = false; + static $error_call = 0; + + $error_call++; + + // Collect a backtrace + if (!DebugUtils::isDebugEnabled()) { + $backtrace = $backtrace ?? debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + } else { + // This is how to keep the args but skip the objects. + $backtrace = $backtrace ?? debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS & DEBUG_BACKTRACE_PROVIDE_OBJECT); + } + + // Are we in a loop? + if ($error_call > 2) { + var_dump($backtrace); + + die('Error: loop detected. The database may have failed or crashed.'); + } + + // Check if error logging is actually on. + if (empty(Config::$modSettings['enableErrorLogging'])) { + return $error_message; + } + + // Basically, htmlspecialchars it minus &. (for entities!) + $error_message = strtr($error_message, ['<' => '<', '>' => '>', '"' => '"']); + + $error_message = strtr($error_message, ['<br />' => '
', '<br>' => '
', '<b>' => '', '</b>' => '', "\n" => '
']); + + // Add a file and line to the error message? + // Don't use the actual txt entries for file and line. + // Instead use %1$s for file and %2$s for line. + // Windows style slashes don't play well, lets convert them to the UNIX style. + $file = str_replace('\\', '/', $file); + + // Find the best path and query string we can... + if (SMF === 'SSI') { + $request_url = ($_SERVER['REQUEST_SCHEME'] ?? 'http') . '://' . ($_SERVER['SERVER_NAME'] ?? 'unknown') . '/' . ltrim($_SERVER['REQUEST_URI'] ?? (($_SERVER['DOCUMENT_URI'] ?? $_SERVER['SCRIPT_NAME'] ?? 'unknown.php') . !empty($_SERVER['QUERY_STRING']) ? '?' . $_SERVER['QUERY_STRING'] : ''), '/'); + } elseif (str_starts_with(($_SERVER['REQUEST_URL'] ?? ''), Config::$boardurl)) { + $request_url = substr($_SERVER['REQUEST_URL'], \strlen(Config::$boardurl)); + } else { + $request_url = ($_SERVER['REQUEST_URL'] ?? ''); + } + + // Don't log the session hash in the url twice, it's a waste. + $request_url = Utils::htmlspecialchars(preg_replace(['~([?&;]sesc)=[^&;]+~', '~' . session_name() . '=' . session_id() . '[&;]~'], ['$1', ''], $request_url)); + + // Just so we know what board error messages are from. + if (isset($_POST['board']) && !isset($_GET['board']) && SMF !== 'SSI') { + $request_url .= ($request_url == '' ? 'board=' : ';board=') . $_POST['board']; + } + + // This prevents us from infinite looping if the hook or call produces an error. + $other_error_types = []; + + if (empty($tried_hook)) { + $tried_hook = true; + + // Allow the hook to change the error_type and know about the error. + IntegrationHook::call('integrate_error_types', [&$other_error_types, &$error_type, $error_message, $file, $line]); + + $this->known_error_types = array_merge($this->known_error_types, $other_error_types); + } + + // Make sure the category that was specified is a valid one + $error_type = \in_array($error_type, $this->known_error_types) && $error_type !== true ? $error_type : 'general'; + + // Leave out the call to this method. + array_splice($backtrace, 0, 1); + $backtrace = Utils::jsonEncode($backtrace); + + // Don't log the same error countless times, as we can get in a cycle of depression... + $error_info = [ + User::$me->id ?? User::$my_id ?? 0, + time(), + User::$me->ip ?? IP::getUserIP(), + $request_url, + $error_message, + (string) (User::$sc ?? ''), + $error_type, + $file, + $line, + $backtrace, + ]; + + if (empty($last_error) || $last_error != $error_info) { + // Insert the error into the database. + Db::$db->error_insert($error_info); + $last_error = $error_info; + + // Get an error count, if necessary + if (!isset(Utils::$context['num_errors'])) { + $query = Db::$db->query( + 'SELECT COUNT(*) + FROM {db_prefix}log_errors', + [], + ); + list(Utils::$context['num_errors']) = Db::$db->fetch_row($query); + Db::$db->free_result($query); + } else { + Utils::$context['num_errors']++; + } + } + + // Reset error call + $error_call = 0; + + // Return the message to make things simpler. + return $error_message; + } + + /** + * An unrecoverable error. + * + * This function stops execution and displays an error message. + * It logs the error message if $log is specified. + * + * @param string $error The error message + * @param string|bool $log What type of error to log this as. Set to false + * to not log the error. Default: 'general'. + * @param int $status The HTTP status code associated with this error. + * Default: 500. + */ + public function fatal(string $error, string|bool $log = 'general', int $status = 500): void + { + // Send the appropriate HTTP status header - set this to 0 or false if you don't want to send one at all + if (!empty($status)) { + $this->sendHttpStatus($status); + } + + // We don't have Lang::$txt yet, but that's okay... + if (empty(Lang::$txt)) { + die($error); + } + + $this->logOnline($error); + $this->setupFatalContext($log ? $this->log($error, $log) : $error); + } + + /** + * Shows a fatal error with a message stored in the language file. + * + * This function stops execution and displays an error message by key. + * - uses the string with the error_message_key key. + * - logs the error in the forum's default language while displaying the error + * message in the user's language. + * - uses Errors language file and applies the $sprintf information if specified. + * - the information is logged if log is specified. + * + * @param string $error The error message. + * @param string|bool $log What type of error to log this as. Set to false + * to not log the error. Default: 'general'. + * @param array $sprintf An array of data to be substituted into the specified message. + * @param int $status The HTTP status code associated with this error. Default: 403. + * @param string $file Language file that holds the localized error message string. + * Default: 'Errors'. + */ + public function fatalLang(string $error, string|bool $log = 'general', array $sprintf = [], int $status = 403, string $file = 'Errors'): void + { + static $fatal_error_called = false; + + // Send the status header - set this to 0 or false if you don't want to send one at all. + if (!empty($status)) { + $this->sendHttpStatus($status); + } + + // Try to load a theme if we don't have one. + if (empty(Utils::$context['theme_loaded']) && empty($fatal_error_called)) { + $fatal_error_called = true; + Theme::load(); + } + + // Attempt to load the text string. + $error_message = Lang::getTxt($error, $sprintf, file: 'Errors'); + + // Send a custom header if we have a custom message. + if (isset($_REQUEST['js']) || isset($_REQUEST['xml']) || isset($_REQUEST['ajax'])) { + header('X-SMF-errormsg: ' . $error_message); + } + + // If we have no theme stuff we can't have the language file... + if (empty(Utils::$context['theme_loaded'])) { + die($error); + } + + // Log the error in the forum's language, but don't waste the time if we aren't logging + if ($log) { + $error_message = Lang::getTxt($error, $sprintf, file: $file, lang: Lang::$default); + $this->log($error_message, $log); + } + + // Load the language file, only if it needs to be reloaded + if (!$log || Lang::$default != User::$me->language) { + $error_message = Lang::getTxt($error, $sprintf, file: $file, lang: User::$me->language); + } + + $this->logOnline($error, $sprintf); + $this->setupFatalContext($error_message, $error); + } + + /** + * Show a message for the (full block) maintenance mode. + * + * It shows a complete page independent of language files or themes. + * It is used only if $maintenance = 2 in Settings.php. + * It stops further execution of the script. + * @todo: As of PHP 8.1, this return type can be 'never' + */ + public function displayMaintenanceMessage(): void + { + $this->setFatalHeaders(); + + if (!empty(Config::$maintenance)) { + $mtitle = Config::$mtitle; + $mmessage = Config::$mmessage; + + echo << + + + + {$mtitle} + + +

{$mtitle}

+ {$mmessage} + + + END; + } + + die(); + } + + /** + * Show an error message for the connection problems. + * + * It shows a complete page independent of language files or themes. + * It is used only if there's no way to connect to the database. + * It stops further execution of the script. + * @todo: As of PHP 8.1, this return type can be 'never' + */ + public function displayDbError(): void + { + $this->setFatalHeaders(); + + // For our purposes, we're gonna want this on if at all possible. + CacheApi::$enable = 1; + + if (($temp = CacheApi::get('db_last_error', 600)) !== null) { + Config::$db_last_error = max(Config::$db_last_error, $temp); + } + + if (Config::$db_last_error < time() - 3600 * 24 * 3 && empty(Config::$maintenance) && !empty(Config::$db_error_send)) { + // Avoid writing to the Settings.php file if at all possible; use shared memory instead. + CacheApi::put('db_last_error', time(), 600); + + if (($temp = CacheApi::get('db_last_error', 600)) === null) { + $this->logLastDatabaseError(); + } + + $db_error = isset(Db::$db) ? @Db::$db->error() : ''; + + // Language files aren't loaded yet :(. + @mail(Config::$webmaster_email, Config::$mbname . ': SMF Database Error!', 'There has been a problem with the database!' . ($db_error == '' ? '' : "\n" . Db::$db->title . ' reported:' . "\n" . $db_error) . "\n\n" . 'This is a notice email to let you know that SMF could not connect to the database, contact your host if this continues.'); + } + + // What to do? Language files haven't and can't be loaded yet... + echo << + + + + Connection Problems + + +

Connection Problems

+ Sorry, SMF was unable to connect to the database. This may be caused by the server being busy. Please try again later. + + + END; + + die(); + } + + /** + * Show an error message for load average blocking problems. + * + * It shows a complete page independent of language files or themes. + * It is used only if the load averages are too high to continue execution. + * It stops further execution of the script. + * @todo: As of PHP 8.1, this return type can be 'never' + */ + public function displayLoadAvgError(): void + { + // If this is a load average problem, display an appropriate message (but we still don't have language files!) + + $this->setFatalHeaders(); + + echo << + + + + Temporarily Unavailable + + +

Temporarily Unavailable

+ Due to high stress on the server the forum is temporarily unavailable. Please try again later. + + + END; + + die(); + } + + /****************** + * Internal methods + ******************/ + + /** + * Small utility function for fatal error pages. + * Used by self::fatal() and self::fatalLang(). + * + * @param string $error The error + */ + protected function logOnline(string $error, array $sprintf = []): void + { + // Don't bother if Who's Online is disabled. + if (empty(Config::$modSettings['who_enabled'])) { + return; + } + + // Maybe they came from SSI or similar where sessions are not recorded? + if (SMF == 'SSI' || SMF == 'BACKGROUND') { + return; + } + + $session_id = !empty(User::$me->is_guest) ? 'ip' . User::$me->ip : session_id(); + + // First, we have to get the online log, because we need to break apart the serialized string. + $request = Db::$db->query( + 'SELECT url + FROM {db_prefix}log_online + WHERE session = {string:session}', + [ + 'session' => $session_id, + ], + ); + + if (Db::$db->num_rows($request) != 0) { + list($url) = Db::$db->fetch_row($request); + + $url = Utils::jsonDecode($url, true); + $url['error'] = $error; + + // Url field got a max length of 1024 in db + if (\strlen($url['error']) > 500) { + $url['error'] = substr($url['error'], 0, 500); + } + + if (!empty($sprintf)) { + $url['error_params'] = $sprintf; + } + + Db::$db->query( + 'UPDATE {db_prefix}log_online + SET url = {string:url} + WHERE session = {string:session}', + [ + 'url' => Utils::jsonEncode($url), + 'session' => $session_id, + ], + ); + } + Db::$db->free_result($request); + } + + /** + * Small utility function for fatal error pages. + * + * Used by self::displayMaintenanceMessage(), self::displayDbError(), and + * self::displayLoadAvgError(). + */ + protected function setFatalHeaders(): void + { + if (headers_sent()) { + return; + } + + // Don't cache this page! + header('expires: Mon, 26 Jul 1997 05:00:00 GMT'); + header('last-modified: ' . gmdate('D, d M Y H:i:s') . ' GMT'); + header('cache-control: no-cache'); + + // Send the right error codes. + $this->sendHttpStatus(503, 'Service Temporarily Unavailable'); + header('status: 503 Service Temporarily Unavailable'); + header('retry-after: 3600'); + } + + /** + * It is called by self::fatal() and self::fatalLang(). + * + * @uses template_fatal_error() + * + * @param string $error_message The error message + * @param null|string $error_code An error code + */ + protected function setupFatalContext(string $error_message, ?string $error_code = null): void + { + static $level = 0; + + if ( + // Don't get caught in a recursive loop. + ++$level > 1 + // If we hit a fatal error during install, don't try to load the theme. + || \defined('SMF_INSTALLING') + ) { + die($error_message); + } + + // Maybe they came from dlattach or similar? + if (SMF != 'SSI' && SMF != 'BACKGROUND' && empty(Utils::$context['theme_loaded'])) { + Theme::load(); + } + + // Don't bother indexing errors mate... + Utils::$context['robot_no_index'] = true; + + if (!isset(Utils::$context['error_title'])) { + Utils::$context['error_title'] = Lang::getTxt('error_occured', file: 'General'); + } + + Utils::$context['error_message'] = Utils::$context['error_message'] ?? $error_message; + + Utils::$context['error_code'] = isset($error_code) ? 'id="' . $error_code . '" ' : ''; + + Utils::$context['error_link'] = Utils::$context['error_link'] ?? 'javascript:document.location=document.referrer'; + + if (empty(Utils::$context['page_title'])) { + Utils::$context['page_title'] = Utils::$context['error_title']; + } + + Theme::loadTemplate('Errors'); + Utils::$context['sub_template'] = 'fatal_error'; + + // If this is SSI, what do they want us to do? + if (SMF == 'SSI') { + if (!empty(SSI::$on_error_method) && SSI::$on_error_method !== true && \is_callable(SSI::$on_error_method)) { + \call_user_func(SSI::$on_error_method); + } elseif (empty(SSI::$on_error_method) || SSI::$on_error_method !== true) { + Theme::loadSubTemplate('fatal_error'); + } + + // No layers? + if (empty(SSI::$on_error_method) || SSI::$on_error_method !== true) { + exit; + } + } + // Alternatively from the cron call? + elseif (SMF == 'BACKGROUND') { + // We can't rely on even having language files available. + if (\defined('FROM_CLI') && FROM_CLI) { + echo 'cron error: ', Utils::$context['error_message']; + } else { + echo 'An error occurred. More information may be available in your logs.'; + } + + exit; + } + + // We want whatever for the header, and a footer. (footer includes sub template!) + Utils::obExit(null, true, false, true); + + /* DO NOT IGNORE: + If you are creating a bridge to SMF or modifying this function, you MUST + make ABSOLUTELY SURE that this function quits and DOES NOT RETURN TO NORMAL + PROGRAM FLOW. Otherwise, security error messages will not be shown, and + your forum will be in a very easily hackable state. + */ + die('No direct access...'); + } + + /** + * Logs the last database error into a file. + * Attempts to use the backup file first, to store the last database error + * and only update db_last_error.php if the first was successful. + * + * @return bool true if successfully able to write the last database error. + */ + protected function logLastDatabaseError(): bool + { + // Make a note of the last modified time in case someone does this before us + $last_db_error_change = @filemtime(Config::$cachedir . '/db_last_error.php'); + + // save the old file before we do anything + $file = Config::$cachedir . '/db_last_error.php'; + $dberror_backup_fail = !@is_writable(Config::$cachedir . '/db_last_error_bak.php') || !@copy($file, Config::$cachedir . '/db_last_error_bak.php'); + $dberror_backup_fail = !$dberror_backup_fail ? (!file_exists(Config::$cachedir . '/db_last_error_bak.php') || filesize(Config::$cachedir . '/db_last_error_bak.php') === 0) : $dberror_backup_fail; + + clearstatcache(); + + if (filemtime(Config::$cachedir . '/db_last_error.php') === $last_db_error_change) { + // Write the change + $write_db_change = '<' . '?' . "php\n" . '$db_last_error = ' . time() . ';' . "\n"; + $written_bytes = file_put_contents(Config::$cachedir . '/db_last_error.php', $write_db_change, LOCK_EX); + + // survey says ... + if ($written_bytes !== \strlen($write_db_change) && !$dberror_backup_fail) { + // Oops. maybe we have no more disk space left, or some other troubles, troubles... + // Copy the file back and run for your life! + @copy(Config::$cachedir . '/db_last_error_bak.php', Config::$cachedir . '/db_last_error.php'); + } else { + return true; + } + } + + return false; + } + + /** + * Sends an HTTP status header. + * + * @param int $code The HTTP status code. + * @param string $message The HTTP status message. + */ + protected function sendHttpStatus(int $code, string $message = ''): void + { + if (\function_exists('http_response_code')) { + http_response_code($code); + } else { + header('HTTP/1.1 ' . $code . ' ' . $message); + } + } +} diff --git a/Sources/Services/index.php b/Sources/Services/index.php new file mode 100644 index 0000000000..2844a3b9e7 --- /dev/null +++ b/Sources/Services/index.php @@ -0,0 +1,8 @@ +setPsr4('SMF\\', $sourcedir); // Initialize the container. - SMF\Container::init(); + SMF\Infrastructure\Container::init(); // Ensure $db_last_error is set, too. SMF\Config::getDbLastError();