From 6f135303ea223864035f72ac39072886766e0737 Mon Sep 17 00:00:00 2001 From: Michel Mendiola Date: Mon, 9 Feb 2026 09:49:26 -0600 Subject: [PATCH 1/9] feat: first attempt at creating a serviceProvider and serviceList files for services --- Sources/{ => Infrastructure}/Container.php | 3 ++- Sources/Infrastructure/ServiceProvider.php | 30 ++++++++++++++++++++++ Sources/Infrastructure/ServicesList.php | 11 ++++++++ index.php | 2 +- 4 files changed, 44 insertions(+), 2 deletions(-) rename Sources/{ => Infrastructure}/Container.php (92%) create mode 100644 Sources/Infrastructure/ServiceProvider.php create mode 100644 Sources/Infrastructure/ServicesList.php diff --git a/Sources/Container.php b/Sources/Infrastructure/Container.php similarity index 92% rename from Sources/Container.php rename to Sources/Infrastructure/Container.php index aa21c5fe06..4622fba489 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 { diff --git a/Sources/Infrastructure/ServiceProvider.php b/Sources/Infrastructure/ServiceProvider.php new file mode 100644 index 0000000000..cbe68ffba9 --- /dev/null +++ b/Sources/Infrastructure/ServiceProvider.php @@ -0,0 +1,30 @@ +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..7abcb1cf46 --- /dev/null +++ b/Sources/Infrastructure/ServicesList.php @@ -0,0 +1,11 @@ + [ +// 'arguments' => [$db_server, $db_user], +// 'shared' => true // false will create a new instance everytime +//], +return [ + +]; diff --git a/index.php b/index.php index c176a7e14a..4f9a7fb554 100644 --- a/index.php +++ b/index.php @@ -133,7 +133,7 @@ SMF\Config::$loader->setPsr4('SMF\\', $sourcedir); // Initialize the container. - SMF\Container::init(); + \SMF\Infrastructure\Container::init(); // Ensure $db_last_error is set, too. SMF\Config::getDbLastError(); From 66c111dc6eb431d9664ccbfff2255b4b3fad2204 Mon Sep 17 00:00:00 2001 From: Michel Mendiola Date: Mon, 9 Feb 2026 11:27:55 -0600 Subject: [PATCH 2/9] feat: add ServiceProvider to temp Container static call --- Sources/Infrastructure/Container.php | 1 + Sources/Infrastructure/ServiceProvider.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Infrastructure/Container.php b/Sources/Infrastructure/Container.php index 4622fba489..4bd19b59cd 100644 --- a/Sources/Infrastructure/Container.php +++ b/Sources/Infrastructure/Container.php @@ -43,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 index cbe68ffba9..13d9ba5e26 100644 --- a/Sources/Infrastructure/ServiceProvider.php +++ b/Sources/Infrastructure/ServiceProvider.php @@ -9,7 +9,7 @@ class ServiceProvider extends AbstractServiceProvider public function __construct(array $services = []) { - $coreServices = require 'ServicesList.php'; + $coreServices = require __DIR__ . '/ServicesList.php'; $this->services = array_filter(array_merge($coreServices, $services)); } public function provides(string $id): bool From a665bca93edff508e830f42983d95bdb8b9c2059 Mon Sep 17 00:00:00 2001 From: Michel Mendiola Date: Tue, 10 Feb 2026 11:11:29 -0600 Subject: [PATCH 3/9] feat: Migrating ErrorHandler to a service using a face pattern to keep backward compatibility --- Sources/ErrorHandler.php | 923 ++++++----------------- Sources/Infrastructure/ServicesList.php | 5 +- Sources/Services/ErrorHandlerService.php | 740 ++++++++++++++++++ 3 files changed, 956 insertions(+), 712 deletions(-) create mode 100644 Sources/Services/ErrorHandlerService.php diff --git a/Sources/ErrorHandler.php b/Sources/ErrorHandler.php index 78f3aa5055..83d462e5dc 100644 --- a/Sources/ErrorHandler.php +++ b/Sources/ErrorHandler.php @@ -1,726 +1,227 @@ 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...'); - } - } - - /*********************** - * Public static methods - ***********************/ - - /** - * 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 static function call(int $error_level, string $error_string, string $file, int $line): void - { - new self($error_level, $error_string, $file, $line); - } - - /** - * Generic handler for uncaught exceptions. - * - * Always ends execution. - * - * @param \Throwable $e The uncaught exception. - */ - 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); - } - - /** - * Log an error, if the error logging is enabled. - * - * $file and $line should be __FILE__ and __LINE__, respectively. - * - * Example use: - * die(ErrorHandler::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 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; - } - - /** - * 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 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); - } - - /** - * 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 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); - } - - /** - * 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 static function displayMaintenanceMessage(): void - { - self::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 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(); - } - - /** - * 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 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(); - } - - /************************* - * Internal static methods - *************************/ - - /** - * 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(). - * - * @uses template_fatal_error() - * - * @param string $error_message The error message - * @param null|string $error_code An error code - */ - protected static 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 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; - } - } - - return false; - } + /** + * @var array + * + * What types of categories do we have for logging errors? + * + * @deprecated Use ErrorHandlerService::$known_error_types instead. + * Kept for backward compatibility. + */ + public static array $known_error_types = [ + 'general', + 'critical', + 'database', + 'undefined_vars', + 'user', + 'ban', + 'template', + 'debug', + 'cron', + 'paidsubs', + 'backup', + 'login', + ]; + + /** + * @var ErrorHandlerService|null + * + * Cached service instance. + */ + protected static ?ErrorHandlerService $service = null; + + /** + * Constructor. + * + * @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 __construct(int $error_level, string $error_string, string $file, int $line) + { + self::getService()->handleError($error_level, $error_string, $file, $line); + } + + /** + * 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 static function call(int $error_level, string $error_string, string $file, int $line): void + { + self::getService()->call($error_level, $error_string, $file, $line); + } + + /** + * Generic handler for uncaught exceptions. + * + * Always ends execution. + * + * @param \Throwable $e The uncaught exception. + */ + public static function catch(\Throwable $e): void + { + self::getService()->catch($e); + } + + /** + * Log an error, if the error logging is enabled. + * + * $file and $line should be __FILE__ and __LINE__, respectively. + * + * Example use: + * die(ErrorHandler::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 static function log(string $error_message, string|bool $error_type = 'general', string $file = '', int $line = 0, ?array $backtrace = null): string + { + return self::getService()->log($error_message, $error_type, $file, $line, $backtrace); + } + + /** + * 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 static function fatal(string $error, string|bool $log = 'general', int $status = 500): void + { + self::getService()->fatal($error, $log, $status); + } + + /** + * 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 static function fatalLang(string $error, string|bool $log = 'general', array $sprintf = [], int $status = 403, string $file = 'Errors'): void + { + self::getService()->fatalLang($error, $log, $sprintf, $status, $file); + } + + /** + * 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. + */ + public static function displayMaintenanceMessage(): void + { + self::getService()->displayMaintenanceMessage(); + } + + /** + * 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. + */ + public static function displayDbError(): void + { + self::getService()->displayDbError(); + } + + /** + * 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. + */ + public static function displayLoadAvgError(): void + { + self::getService()->displayLoadAvgError(); + } + + /** + * Get the ErrorHandlerService instance from the container. + * + * 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). + * + * @return ErrorHandlerService The error handler service instance. + */ + protected static function getService(): ErrorHandlerService + { + // Return cached instance if available + if (self::$service !== null) { + return self::$service; + } + + // Try to get the service from the container + try { + // Check if container is initialized + if (class_exists(Container::class)) { + $container = Container::getInstance(); + + // Check if the service is registered in the container + if ($container->has(ErrorHandlerService::class)) { + self::$service = $container->get(ErrorHandlerService::class); + return self::$service; + } + } + } catch (\Throwable $e) { + // Container not available or service not registered + // Fall through to manual instantiation + } + + // 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 self::$service; + } } diff --git a/Sources/Infrastructure/ServicesList.php b/Sources/Infrastructure/ServicesList.php index 7abcb1cf46..1fd56bea76 100644 --- a/Sources/Infrastructure/ServicesList.php +++ b/Sources/Infrastructure/ServicesList.php @@ -1,5 +1,6 @@ [ @@ -7,5 +8,7 @@ // 'shared' => true // false will create a new instance everytime //], return [ - + ErrorHandlerService::class => [ + 'shared' => true, + ], ]; diff --git a/Sources/Services/ErrorHandlerService.php b/Sources/Services/ErrorHandlerService.php new file mode 100644 index 0000000000..9483f2eb7a --- /dev/null +++ b/Sources/Services/ErrorHandlerService.php @@ -0,0 +1,740 @@ +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...'); + } + } + + /*********************** + * Public static methods + ***********************/ + + /** + * 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 static 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); + } + } +} From d2dfa9d5af29518645bc9f31c5bf59f63286c878 Mon Sep 17 00:00:00 2001 From: Michel Mendiola Date: Tue, 10 Feb 2026 11:21:41 -0600 Subject: [PATCH 4/9] feat: use Container::get to simplify calling the Service --- Sources/ErrorHandler.php | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Sources/ErrorHandler.php b/Sources/ErrorHandler.php index 83d462e5dc..09c44b09ae 100644 --- a/Sources/ErrorHandler.php +++ b/Sources/ErrorHandler.php @@ -202,16 +202,8 @@ protected static function getService(): ErrorHandlerService // Try to get the service from the container try { - // Check if container is initialized - if (class_exists(Container::class)) { - $container = Container::getInstance(); - - // Check if the service is registered in the container - if ($container->has(ErrorHandlerService::class)) { - self::$service = $container->get(ErrorHandlerService::class); - return self::$service; - } - } + self::$service = Container::get(ErrorHandlerService::class); + return self::$service; } catch (\Throwable $e) { // Container not available or service not registered // Fall through to manual instantiation From 8d196232f249c63ff71f1562528a02b04474546c Mon Sep 17 00:00:00 2001 From: Michel Mendiola Date: Tue, 10 Feb 2026 11:35:41 -0600 Subject: [PATCH 5/9] chore: Add index.php to Infra folder --- Sources/Infrastructure/index.php | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 Sources/Infrastructure/index.php 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 @@ + Date: Tue, 10 Feb 2026 11:37:53 -0600 Subject: [PATCH 6/9] chore: Also add index.php to Services folder --- Sources/Services/index.php | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 Sources/Services/index.php 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 @@ + Date: Wed, 18 Feb 2026 11:03:48 -0600 Subject: [PATCH 7/9] fix: remove backslash --- index.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.php b/index.php index 4f9a7fb554..4b05167f25 100644 --- a/index.php +++ b/index.php @@ -133,7 +133,7 @@ SMF\Config::$loader->setPsr4('SMF\\', $sourcedir); // Initialize the container. - \SMF\Infrastructure\Container::init(); + SMF\Infrastructure\Container::init(); // Ensure $db_last_error is set, too. SMF\Config::getDbLastError(); From 054d2e7dabefc5993add825321bba033ae0e7439 Mon Sep 17 00:00:00 2001 From: Michel Mendiola Date: Wed, 18 Feb 2026 11:38:43 -0600 Subject: [PATCH 8/9] chore: Apply linting to modified files --- Sources/ErrorHandler.php | 415 +++++++++++---------- Sources/Infrastructure/ServiceProvider.php | 13 +- Sources/Infrastructure/ServicesList.php | 6 +- Sources/Services/ErrorHandlerService.php | 22 +- 4 files changed, 242 insertions(+), 214 deletions(-) diff --git a/Sources/ErrorHandler.php b/Sources/ErrorHandler.php index 09c44b09ae..029a861ede 100644 --- a/Sources/ErrorHandler.php +++ b/Sources/ErrorHandler.php @@ -19,201 +19,222 @@ */ class ErrorHandler { - /** - * @var array - * - * What types of categories do we have for logging errors? - * - * @deprecated Use ErrorHandlerService::$known_error_types instead. - * Kept for backward compatibility. - */ - public static array $known_error_types = [ - 'general', - 'critical', - 'database', - 'undefined_vars', - 'user', - 'ban', - 'template', - 'debug', - 'cron', - 'paidsubs', - 'backup', - 'login', - ]; - - /** - * @var ErrorHandlerService|null - * - * Cached service instance. - */ - protected static ?ErrorHandlerService $service = null; - - /** - * Constructor. - * - * @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 __construct(int $error_level, string $error_string, string $file, int $line) - { - self::getService()->handleError($error_level, $error_string, $file, $line); - } - - /** - * 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 static function call(int $error_level, string $error_string, string $file, int $line): void - { - self::getService()->call($error_level, $error_string, $file, $line); - } - - /** - * Generic handler for uncaught exceptions. - * - * Always ends execution. - * - * @param \Throwable $e The uncaught exception. - */ - public static function catch(\Throwable $e): void - { - self::getService()->catch($e); - } - - /** - * Log an error, if the error logging is enabled. - * - * $file and $line should be __FILE__ and __LINE__, respectively. - * - * Example use: - * die(ErrorHandler::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 static function log(string $error_message, string|bool $error_type = 'general', string $file = '', int $line = 0, ?array $backtrace = null): string - { - return self::getService()->log($error_message, $error_type, $file, $line, $backtrace); - } - - /** - * 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 static function fatal(string $error, string|bool $log = 'general', int $status = 500): void - { - self::getService()->fatal($error, $log, $status); - } - - /** - * 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 static function fatalLang(string $error, string|bool $log = 'general', array $sprintf = [], int $status = 403, string $file = 'Errors'): void - { - self::getService()->fatalLang($error, $log, $sprintf, $status, $file); - } - - /** - * 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. - */ - public static function displayMaintenanceMessage(): void - { - self::getService()->displayMaintenanceMessage(); - } - - /** - * 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. - */ - public static function displayDbError(): void - { - self::getService()->displayDbError(); - } - - /** - * 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. - */ - public static function displayLoadAvgError(): void - { - self::getService()->displayLoadAvgError(); - } - - /** - * Get the ErrorHandlerService instance from the container. - * - * 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). - * - * @return ErrorHandlerService The error handler service instance. - */ - protected static function getService(): ErrorHandlerService - { - // Return cached instance if available - if (self::$service !== null) { - return self::$service; - } - - // Try to get the service from the container - try { - self::$service = Container::get(ErrorHandlerService::class); - return self::$service; - } catch (\Throwable $e) { - // Container not available or service not registered - // Fall through to manual instantiation - } - - // 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 self::$service; - } + /************************** + * Public static properties + **************************/ + + /** + * @var array + * + * What types of categories do we have for logging errors? + * + * @deprecated Use ErrorHandlerService::$known_error_types instead. + * Kept for backward compatibility. + */ + public static array $known_error_types = [ + 'general', + 'critical', + 'database', + 'undefined_vars', + 'user', + 'ban', + 'template', + 'debug', + 'cron', + 'paidsubs', + 'backup', + 'login', + ]; + + /**************************** + * Internal static properties + ****************************/ + + /** + * @var ErrorHandlerService|null + * + * Cached service instance. + */ + protected static ?ErrorHandlerService $service = null; + + /**************** + * Public methods + ****************/ + + /** + * Constructor. + * + * @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 __construct(int $error_level, string $error_string, string $file, int $line) + { + self::getService()->handleError($error_level, $error_string, $file, $line); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * 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 static function call(int $error_level, string $error_string, string $file, int $line): void + { + self::getService()->call($error_level, $error_string, $file, $line); + } + + /** + * Generic handler for uncaught exceptions. + * + * Always ends execution. + * + * @param \Throwable $e The uncaught exception. + */ + public static function catch(\Throwable $e): void + { + self::getService()->catch($e); + } + + /** + * Log an error, if the error logging is enabled. + * + * $file and $line should be __FILE__ and __LINE__, respectively. + * + * Example use: + * die(ErrorHandler::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 static function log(string $error_message, string|bool $error_type = 'general', string $file = '', int $line = 0, ?array $backtrace = null): string + { + return self::getService()->log($error_message, $error_type, $file, $line, $backtrace); + } + + /** + * 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 static function fatal(string $error, string|bool $log = 'general', int $status = 500): void + { + self::getService()->fatal($error, $log, $status); + } + + /** + * 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 static function fatalLang(string $error, string|bool $log = 'general', array $sprintf = [], int $status = 403, string $file = 'Errors'): void + { + self::getService()->fatalLang($error, $log, $sprintf, $status, $file); + } + + /** + * 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. + */ + public static function displayMaintenanceMessage(): void + { + self::getService()->displayMaintenanceMessage(); + } + + /** + * 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. + */ + public static function displayDbError(): void + { + self::getService()->displayDbError(); + } + + /** + * 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. + */ + public static function displayLoadAvgError(): void + { + self::getService()->displayLoadAvgError(); + } + + /************************* + * Internal static methods + *************************/ + + /** + * Get the ErrorHandlerService instance from the container. + * + * 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). + * + * @return ErrorHandlerService The error handler service instance. + */ + protected static function getService(): ErrorHandlerService + { + // Return cached instance if available + if (self::$service !== null) { + return self::$service; + } + + // Try to get the service from the container + try { + self::$service = Container::get(ErrorHandlerService::class); + + return self::$service; + } catch (\Throwable $e) { + // Container not available or service not registered + // Fall through to manual instantiation + } + + // 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 self::$service; + } } diff --git a/Sources/Infrastructure/ServiceProvider.php b/Sources/Infrastructure/ServiceProvider.php index 13d9ba5e26..777e70f4a1 100644 --- a/Sources/Infrastructure/ServiceProvider.php +++ b/Sources/Infrastructure/ServiceProvider.php @@ -1,25 +1,36 @@ services = array_filter(array_merge($coreServices, $services)); } + public function provides(string $id): bool { - return array_key_exists($id, $this->services); + 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'; diff --git a/Sources/Infrastructure/ServicesList.php b/Sources/Infrastructure/ServicesList.php index 1fd56bea76..3e5e4e79bc 100644 --- a/Sources/Infrastructure/ServicesList.php +++ b/Sources/Infrastructure/ServicesList.php @@ -8,7 +8,7 @@ // 'shared' => true // false will create a new instance everytime //], return [ - ErrorHandlerService::class => [ - 'shared' => true, - ], + ErrorHandlerService::class => [ + 'shared' => true, + ], ]; diff --git a/Sources/Services/ErrorHandlerService.php b/Sources/Services/ErrorHandlerService.php index 9483f2eb7a..ad74a2104c 100644 --- a/Sources/Services/ErrorHandlerService.php +++ b/Sources/Services/ErrorHandlerService.php @@ -23,9 +23,9 @@ */ class ErrorHandlerService { - /************************** - * Public static properties - **************************/ + /******************* + * Public properties + *******************/ /** * @var array @@ -54,9 +54,7 @@ class ErrorHandlerService /** * Constructor. */ - public function __construct() - { - } + public function __construct() {} /** * Error handler. @@ -148,9 +146,7 @@ public function handleError(int $error_level, string $error_string, string $file } } - /*********************** - * Public static methods - ***********************/ + /** * Convenience method to create an instance of this class. @@ -518,9 +514,9 @@ public function displayLoadAvgError(): void die(); } - /************************* - * Internal static methods - *************************/ + /****************** + * Internal methods + ******************/ /** * Small utility function for fatal error pages. @@ -731,7 +727,7 @@ protected function logLastDatabaseError(): bool */ protected function sendHttpStatus(int $code, string $message = ''): void { - if (function_exists('http_response_code')) { + if (\function_exists('http_response_code')) { http_response_code($code); } else { header('HTTP/1.1 ' . $code . ' ' . $message); From 129944eaa585a92037b65b0cec2639d915709e24 Mon Sep 17 00:00:00 2001 From: Michel Mendiola Date: Wed, 18 Feb 2026 11:48:23 -0600 Subject: [PATCH 9/9] chore: manually remove black lines --- Sources/Services/ErrorHandlerService.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/Services/ErrorHandlerService.php b/Sources/Services/ErrorHandlerService.php index ad74a2104c..9ba160893e 100644 --- a/Sources/Services/ErrorHandlerService.php +++ b/Sources/Services/ErrorHandlerService.php @@ -146,8 +146,6 @@ public function handleError(int $error_level, string $error_string, string $file } } - - /** * Convenience method to create an instance of this class. *