diff --git a/Languages/en_US/Admin.php b/Languages/en_US/Admin.php index a7314dae06..d5925ab75f 100644 --- a/Languages/en_US/Admin.php +++ b/Languages/en_US/Admin.php @@ -757,8 +757,8 @@ $txt['hooks_field_hook_name'] = 'Hook Name'; $txt['hooks_field_function_name'] = 'Function Name'; $txt['hooks_field_function_method'] = 'Function is a method and its class is instantiated'; -$txt['hooks_field_function'] = 'Function: {real_function}'; -$txt['hooks_field_included_file'] = 'Included file: {included_file}'; +$txt['hooks_field_function'] = 'Function: {function}'; +$txt['hooks_field_included_file'] = 'Included file: {file}'; $txt['hooks_field_file_name'] = 'File Name'; $txt['hooks_field_hook_exists'] = 'Status'; $txt['hooks_active'] = 'Exists'; diff --git a/Sources/Actions/Admin/Maintenance.php b/Sources/Actions/Admin/Maintenance.php index 275edd82fd..87aa861498 100644 --- a/Sources/Actions/Admin/Maintenance.php +++ b/Sources/Actions/Admin/Maintenance.php @@ -1760,23 +1760,16 @@ public function hooks(): void { $filter_url = ''; $current_filter = ''; - $hooks = $this->getIntegrationHooks(); + $defined_hooks = array_keys(array_filter(IntegrationHook::get(), fn($val) => \count($val) > 0)); + $hooks_filters = []; - if (isset($_GET['filter'], $hooks[$_GET['filter']])) { + if (isset($_GET['filter'], $defined_hooks[$_GET['filter']])) { $filter_url = ';filter=' . $_GET['filter']; $current_filter = $_GET['filter']; } - $filtered_hooks = array_filter( - $hooks, - function ($hook) use ($current_filter) { - return $current_filter == '' || $current_filter == $hook; - }, - ARRAY_FILTER_USE_KEY, - ); - ksort($hooks); - foreach ($hooks as $hook => $functions) { + foreach ($defined_hooks as $hook) { $hooks_filters[] = '' . $hook . ''; } @@ -1807,21 +1800,16 @@ function ($hook) use ($current_filter) { 'base_href' => Config::$scripturl . '?action=admin;area=maintain;sa=hooks' . $filter_url . ';' . Utils::$context['session_var'] . '=' . Utils::$context['session_id'], 'default_sort_col' => 'hook_name', 'get_items' => [ - 'function' => __CLASS__ . '::getIntegrationHooksData', + 'function' => __CLASS__ . '::list_getHooks', 'params' => [ - $filtered_hooks, - strtr(Config::$boarddir, '\\', '/'), - strtr(Config::$sourcedir, '\\', '/'), + $current_filter, ], ], 'get_count' => [ - 'value' => array_reduce( - $filtered_hooks, - function ($accumulator, $functions) { - return $accumulator + \count($functions); - }, - 0, - ), + 'function' => __CLASS__ . '::list_getNumHooks', + 'params' => [ + $current_filter, + ], ], 'no_items_label' => Lang::getTxt('hooks_no_hooks', file: 'Admin'), 'columns' => [ @@ -1837,39 +1825,39 @@ function ($accumulator, $functions) { 'reverse' => 'hook_name DESC', ], ], - 'function_name' => [ + 'func' => [ 'header' => [ 'value' => Lang::getTxt('hooks_field_function_name', file: 'Admin'), ], 'data' => [ 'function' => function ($data) { // Show a nice icon to indicate this is an instance. - $instance = (!empty($data['instance']) ? ' ' : ''); + $instance = ($data['is_object'] ? ' ' : ''); - if (!empty($data['included_file']) && !empty($data['real_function'])) { + if (!empty($data['file']) && !empty($data['function'])) { return $instance . Lang::getTxt('hooks_field_function', $data, file: 'Admin') . '
' . Lang::getTxt('hooks_field_included_file', $data, file: 'Admin'); } - return $instance . $data['real_function']; + return $instance . $data['function']; }, 'class' => 'word_break', ], 'sort' => [ - 'default' => 'function_name', - 'reverse' => 'function_name DESC', + 'default' => 'func', + 'reverse' => 'func DESC', ], ], - 'file_name' => [ + 'file' => [ 'header' => [ 'value' => Lang::getTxt('hooks_field_file_name', file: 'Admin'), ], 'data' => [ - 'db' => 'file_name', + 'db' => 'file', 'class' => 'word_break', ], 'sort' => [ - 'default' => 'file_name', - 'reverse' => 'file_name DESC', + 'default' => 'file', + 'reverse' => 'file DESC', ], ], 'status' => [ @@ -1880,15 +1868,15 @@ function ($accumulator, $functions) { 'data' => [ 'function' => function ($data) use ($filter_url) { // Cannot update temp hooks in any way, really. Just show the appropriate icon. - if ($data['status'] == 'temp') { - return ''; + if ($data['is_temp']) { + return ''; } $change_status = ['before' => '', 'after' => '']; // Can only enable/disable if it exists... - if ($data['hook_exists']) { - $change_status['before'] = ''; + if ($data['exists']) { + $change_status['before'] = ''; $change_status['after'] = ''; } @@ -1897,8 +1885,8 @@ function ($accumulator, $functions) { 'class' => 'centertext', ], 'sort' => [ - 'default' => 'status', - 'reverse' => 'status DESC', + 'default' => 'is_enabled', + 'reverse' => 'is_enabled DESC', ], ], ], @@ -1935,7 +1923,7 @@ function ($accumulator, $functions) { 'data' => [ 'function' => function ($data) use ($filter_url) { // Note: Cannot remove temp hooks via the UI... - if (!$data['hook_exists'] && $data['status'] != 'temp') { + if (!$data['exists'] && $data['status'] != 'temp') { return ' @@ -1968,64 +1956,109 @@ function ($accumulator, $functions) { * @param int $start The item to start with (for pagination purposes) * @param int $per_page How many items to display on each page * @param string $sort A string indicating how to sort things - * @param object|array $filtered_hooks + * @param string $filter hook name to filter by. * @param string $normalized_boarddir * @param string $normalized_sourcedir * @return array An array of information about the integration hooks */ - public static function getIntegrationHooksData($start, $per_page, $sort, $filtered_hooks, $normalized_boarddir, $normalized_sourcedir): array + public static function list_getHooks(int $start, int $per_page, string $sort, string $filter = ''): array { - $function_list = $sort_array = $temp_data = []; - $files = self::getFileRecursive($normalized_sourcedir); - - foreach ($files as $currentFile => $fileInfo) { - $function_list += self::getDefinedFunctionsInFile($currentFile); - } + $ret = []; - $sort_types = [ - 'hook_name' => ['hook_name', SORT_ASC], - 'hook_name DESC' => ['hook_name', SORT_DESC], - 'function_name' => ['function_name', SORT_ASC], - 'function_name DESC' => ['function_name', SORT_DESC], - 'file_name' => ['file_name', SORT_ASC], - 'file_name DESC' => ['file_name', SORT_DESC], - 'status' => ['status', SORT_ASC], - 'status DESC' => ['status', SORT_DESC], - ]; + $request = Db::$db->query( + 'SELECT id_hook, is_enabled, hook_name, func, file, class, is_object, package_id + FROM {db_prefix}hooks' . (!empty($filter) ? ' + WHERE hook_name = {string:filter}' : '') . ' + ORDER BY {raw:sort} + LIMIT {int:start}, {int:per_page}', + [ + 'filter' => $filter, + 'sort' => $sort, + 'start' => $start, + 'per_page' => $per_page, + ], + ); - foreach ($filtered_hooks as $hook => $functions) { - foreach ($functions as $rawFunc) { - $hookParsedData = self::parseIntegrationHook($hook, $rawFunc); + foreach (Db::$db->fetch_all($request) as $row) { + $hooks[$row['hook_name']] ??= IntegrationHook::get($row['hook_name']); + $hook = array_find($hooks[$row['hook_name']], fn($val) => $val['id_hook'] == $row['id_hook']); + + $ret[(int) $row['id_hook']] = [ + 'id_hook' => (int) $row['id_hook'], + 'is_enabled' => $row['is_enabled'] === '1' ? true : false, + 'hook_name' => trim($row['hook_name'] ?? ''), + 'function' => trim($row['func'] ?? ''), + 'file' => trim($row['file'] ?? ''), + 'class' => trim($row['class'] ?? ''), + 'is_object' => $row['is_object'] == '1' ? true : false, + 'is_temp' => $hook !== null && $hook['is_temp'], + 'package_id' => $row['package_id'] ?? null, + 'exists' => $hook !== null, + 'status' => $hook !== null ? ($hook['is_enabled'] ? 'allow' : 'moderate') : 'deny', + 'img_text' => Lang::getTxt('hooks_' . ($hook !== null ? ($row['is_enabled'] === '1' ? 'active' : 'disabled') : 'missing'), file: 'Admin'), + ]; + } + Db::$db->free_result($request); - // Handle hooks pointing outside the sources directory. - $absPath_clean = rtrim($hookParsedData['absPath'], '!'); + // load up any temp hooks. + foreach (IntegrationHook::get() as $name => $hooks) { + foreach ($hooks as $id => $row) { + if (empty($row['is_temp'])) { + continue; + } - if ($absPath_clean != '' && !isset($files[$absPath_clean]) && file_exists($absPath_clean)) { - $function_list += self::getDefinedFunctionsInFile($absPath_clean); + if (!empty($filter) && $row['hook_name'] !== $filter) { + continue; } - $hook_exists = isset($function_list[$hookParsedData['call']]) || (str_ends_with($hook, '_include') && isset($files[$absPath_clean])); - $hook_temp = !empty(Utils::$context['integration_hooks_temporary'][$hook][$hookParsedData['rawData']]); - $temp = [ - 'hook_name' => $hook, - 'function_name' => $hookParsedData['rawData'], - 'real_function' => $hookParsedData['call'], - 'included_file' => $hookParsedData['hookFile'], - 'file_name' => strtr($hookParsedData['absPath'] ?: ($function_list[$hookParsedData['call']] ?? ''), [$normalized_boarddir => '.']), - 'instance' => $hookParsedData['object'], - 'hook_exists' => $hook_exists, - 'status' => ($hook_temp ? 'temp' : ($hook_exists ? ($hookParsedData['enabled'] ? 'allow' : 'moderate') : 'deny')), - 'img_text' => Lang::getTxt('hooks_' . ($hook_exists ? ($hook_temp ? 'temp' : ($hookParsedData['enabled'] ? 'active' : 'disabled')) : 'missing'), file: 'Admin'), - 'enabled' => $hookParsedData['enabled'], + $ret[(int) $row['id_hook']] = [ + 'id_hook' => (int) $row['id_hook'], + 'is_enabled' => $row['is_enabled'] === '1' ? true : false, + 'hook_name' => trim($row['hook_name'] ?? ''), + 'function' => trim($row['function'] ?? ''), + 'file' => trim($row['file'] ?? ''), + 'class' => trim($row['class'] ?? ''), + 'is_object' => $row['is_object'] == '1' ? true : false, + 'is_temp' => $row['is_temp'], + 'package_id' => $row['package_id'] ?? null, + 'exists' => $hook !== null, + 'status' => 'temp', + 'img_text' => Lang::getTxt('hooks_temp', file: 'Admin'), ]; - $sort_array[] = $temp[$sort_types[$sort][0]]; - $temp_data[] = $temp; + } } - array_multisort($sort_array, $sort_types[$sort][1], $temp_data); + return $ret; + } - return \array_slice($temp_data, $start, $per_page, true); + /** + * Return the number of hooks of the specified type recorded in the database. + * (the specified type being attachments or avatars). + * + * @param string $filter hook name to filter by. + * @return int The number of hooks + */ + public static function list_getNumHooks(string $filter = ''): int + { + $request = Db::$db->query( + 'SELECT COUNT(*) + FROM {db_prefix}hooks' . (!empty($filter) ? ' + WHERE hook_name = {string:filter}' : ''), + [ + 'filter' => $filter, + ], + ); + + list($num_hooks) = Db::$db->fetch_row($request); + Db::$db->free_result($request); + $num_hooks = (int) $num_hooks; + + if (!empty($filter)) { + return $num_hooks + \count(IntegrationHook::get($filter)); + } + + return (int) $num_hooks + array_sum(array_map('count', IntegrationHook::get())); } /** @@ -2183,142 +2216,4 @@ protected function __construct() $this->activity = $_REQUEST['activity']; } } - - /** - * Parses modSettings to create integration hook array - * - * @return array An array of information about the integration hooks - */ - protected function getIntegrationHooks(): array - { - static $integration_hooks; - - if (!isset($integration_hooks)) { - $integration_hooks = []; - - foreach (Config::$modSettings as $key => $value) { - if (!empty($value) && substr($key, 0, 10) === 'integrate_') { - $integration_hooks[$key] = explode(',', $value); - } - } - } - - return $integration_hooks; - } - - /************************* - * Internal static methods - *************************/ - - /** - * Gets all of the files in a directory and its children directories - * - * @param string $dirname The path to the directory - * @return array An array containing information about the files found in the specified directory and its children - */ - protected static function getFileRecursive(string $dirname): array - { - return iterator_to_array( - new \RecursiveIteratorIterator( - new \RecursiveCallbackFilterIterator( - new \RecursiveDirectoryIterator($dirname, \FilesystemIterator::UNIX_PATHS), - function ($fileInfo, $currentFile, $iterator) { - // Allow recursion - if ($iterator->hasChildren()) { - return true; - } - - return $fileInfo->getExtension() == 'php'; - }, - ), - ), - ); - } - - /** - * Parses each hook data and returns an array. - * - * @param string $hook - * @param string $rawData A string as it was saved to the DB. - * @return array everything found in the string itself - */ - protected static function parseIntegrationHook(string $hook, string $rawData): array - { - // A single string can hold tons of info! - $hookData = [ - 'object' => false, - 'enabled' => true, - 'absPath' => '', - 'hookFile' => '', - 'pureFunc' => '', - 'method' => '', - 'class' => '', - 'call' => '', - 'rawData' => $rawData, - ]; - - // Meh... - if (empty($rawData)) { - return $hookData; - } - - $modFunc = $rawData; - - // Any files? - if (str_ends_with($hook, '_include')) { - $modFunc = $modFunc . '|'; - } - - if (str_contains($modFunc, '|')) { - list($hookData['hookFile'], $modFunc) = explode('|', $modFunc); - $hookData['absPath'] = strtr(strtr(trim($hookData['hookFile']), ['$boarddir' => Config::$boarddir, '$sourcedir' => Config::$sourcedir, '$themedir' => Theme::$current->settings['theme_dir'] ?? '']), '\\', '/'); - } - - // Hook is an instance. - if (str_contains($modFunc, '#')) { - $modFunc = str_replace('#', '', $modFunc); - $hookData['object'] = true; - } - - // Hook is "disabled" - // May need to inspect $rawData here for includes... - if ((str_contains($modFunc, '!')) || (empty($modFunc) && (str_contains($rawData, '!')))) { - $modFunc = str_replace('!', '', $modFunc); - $hookData['enabled'] = false; - } - - // Handling methods? - if (str_contains($modFunc, '::')) { - list($hookData['class'], $hookData['method']) = explode('::', $modFunc); - $hookData['pureFunc'] = $hookData['method']; - $hookData['call'] = $modFunc; - } else { - $hookData['call'] = $hookData['pureFunc'] = $modFunc; - } - - return $hookData; - } - - protected static function getDefinedFunctionsInFile(string $file): array - { - $source = file_get_contents($file); - // token_get_all() is too slow so use a nice little regex instead. - preg_match_all('/\bnamespace\s++((?P>label)(?:\\\(?P>label))*+)\s*+;|\bclass\s++((?P>label))[\w\s]*+{|\bfunction\s++((?P>label))\s*+\(.*\)[:\|\w\s]*+{(?(DEFINE)(?More information.'); } + // Load up any hooks. + IntegrationHook::load(); + self::$modSettings['cache_enable'] = Cache\CacheApi::$enable; // Used to force browsers to download fresh CSS and JavaScript when necessary @@ -1179,22 +1182,24 @@ public static function reloadModSettings(): void $integration_settings = Utils::jsonDecode(SMF_INTEGRATION_SETTINGS, true); foreach ($integration_settings as $hook => $function) { - IntegrationHook::add($hook, $function, false); + if (\is_string($function)) { + IntegrationHook::add($hook, $function, false); + } else { + IntegrationHook::register( + name: $hook, + function: $function['function'], + file: $function['file'], + class: $function['class'], + is_object: !empty($function['is_object']), + is_enabled: true, + permanent: false, + ); + } } } // Any files to pre include? - if (!empty(self::$modSettings['integrate_pre_include'])) { - $pre_includes = explode(',', self::$modSettings['integrate_pre_include']); - - foreach ($pre_includes as $include) { - $include = strtr(trim($include), ['$boarddir' => self::$boarddir, '$sourcedir' => self::$sourcedir]); - - if (file_exists($include)) { - require_once self::canonicalPath($include); - } - } - } + IntegrationHook::call('integrate_pre_include'); Utils::load(); @@ -1251,6 +1256,12 @@ public static function updateModSettings(array $change_array, bool $update = fal // In some cases, this may be better and faster, but for large sets we don't want so many UPDATEs. if ($update) { foreach ($change_array as $variable => $value) { + // If this is a integration setting, we need to step in. + if (Config::$backward_compatibility && str_starts_with($variable, 'integrate_')) { + IntegrationHook::processUpdateModSettings($variable, $value); + continue; + } + Db\DatabaseApi::$db->query( 'UPDATE {db_prefix}settings SET value = {' . ($value === false || $value === true ? 'raw' : 'string') . ':value} diff --git a/Sources/Db/Schema/v3_0/Hooks.php b/Sources/Db/Schema/v3_0/Hooks.php new file mode 100644 index 0000000000..443c3cc7af --- /dev/null +++ b/Sources/Db/Schema/v3_0/Hooks.php @@ -0,0 +1,120 @@ +name = 'hooks'; + + $this->columns = [ + 'id_hook' => new Column( + name: 'id_hook', + type: 'int', + unsigned: true, + not_null: true, + auto: true, + ), + 'is_enabled' => new Column( + name: 'is_enabled', + type: 'tinyint', + unsigned: true, + not_null: true, + default: 0, + ), + 'hook_name' => new Column( + name: 'hook_name', + type: 'varchar', + size: 255, + not_null: true, + default: '', + ), + 'func' => new Column( + name: 'func', + type: 'varchar', + size: 255, + not_null: true, + default: '', + ), + 'file' => new Column( + name: 'file', + type: 'varchar', + size: 255, + not_null: true, + default: '', + ), + 'class' => new Column( + name: 'class', + type: 'varchar', + size: 255, + not_null: false, + ), + 'is_object' => new Column( + name: 'is_object', + type: 'tinyint', + unsigned: true, + not_null: true, + default: 0, + ), + 'id_package' => new Column( + name: 'id_package', + type: 'int', + unsigned: true, + not_null: true, + default: 0, + ), + ]; + + $this->indexes = [ + 'primary' => new DbIndex( + type: 'primary', + columns: [ + [ + 'name' => 'id_hook', + ], + ], + ), + 'idx_hook_name' => new DbIndex( + name: 'idx_hook_name', + columns: [ + [ + 'name' => 'hook_name', + ], + [ + 'name' => 'is_enabled', + ], + ], + ), + ]; + + parent::__construct(); + } +} diff --git a/Sources/IntegrationHook.php b/Sources/IntegrationHook.php index ef5426a343..0719f4bc2a 100644 --- a/Sources/IntegrationHook.php +++ b/Sources/IntegrationHook.php @@ -15,6 +15,7 @@ namespace SMF; +use Exception; use SMF\Db\DatabaseApi as Db; use SMF\Debug\DebugUtils; @@ -59,6 +60,36 @@ class IntegrationHook */ private array $callables = []; + /**************************** + * Internal static properties + ****************************/ + + /** + * Big array of hooks. + * Key holds the hook name. + * Value holds an array of Hook data. + * + * @var array + */ + private static array $hooks = []; + + /** + * These hooks did not use the intergate_ prefix. + * @var array + */ + private static array $no_integrate_names = [ + 'pre_cache_quick_get', + 'post_cache_quick_get', + 'cache_put_data', + 'cache_get_data', + 'mention_insert_quote', + 'mention_insert_msg', + 'before_profile_save_avatar', + 'after_profile_save_avatar', + 'who_allowed', + 'whos_online_after', + ]; + /**************** * Public methods ****************/ @@ -84,20 +115,32 @@ public function __construct(string $name, ?bool $ignore_errors = null) DebugUtils::addDebugSource('hooks', $this->name); } - if (empty(Config::$modSettings[$this->name])) { + if (empty(self::$hooks) || empty(self::$hooks[$name])) { return; } - $func_strings = explode(',', Config::$modSettings[$this->name]); - // Loop through each one to get the callable for it. - foreach ($func_strings as $func_string) { + foreach (self::$hooks[$name] as $hook) { // Hook has been marked as disabled. Skip it! - if (str_contains($func_string, '!')) { + if (!$hook['is_enabled']) { + continue; + } + + // Old "include" hooks would dump the content into the function list. + if (str_ends_with($name, '_include') && empty($hook['file']) && !empty($hook['function'])) { + $hook['file'] = $hook['function']; + $hook['function'] = ''; + } + + // Attempt to load the file, only if succesful do we attempt to prepare the callable. + if (!empty($hook['file']) && !self::loadFile($hook['file'], $name === 'pre_include')) { continue; } - $this->callables[$func_string] = Utils::getCallable($func_string); + // Special include hooks don't generate callables. + if (!str_ends_with($name, '_include')) { + $this->callables[$hook['id_hook']] = self::getCallable($hook['function'], $hook['class'], $hook['is_object']); + } } } @@ -115,10 +158,10 @@ public function execute(array $parameters = []): array } // Loop through each callable. - foreach ($this->callables as $func_string => $callable) { + foreach ($this->callables as $id_hook => $callable) { // Is it valid? if (\is_callable($callable)) { - $this->results[$func_string] = \call_user_func_array($callable, $parameters); + $this->results[$id_hook] = \call_user_func_array($callable, $parameters); } // This failed, but we want to do so silently. elseif ($this->ignore_errors) { @@ -127,27 +170,27 @@ public function execute(array $parameters = []): array } // Whatever it was supposed to call, it failed :( else { - // Get a full path to show on error. - if (str_contains($func_string, '|')) { - list($file, $func) = explode('|', $func_string); + $hook = self::$hooks[$id_hook]; + $hook_call = (!empty($hook['is_object']) ? '#' : '') . (!empty($hook['class']) ? $hook['class'] . ':' : '') . $hook['function']; + + // Assume the file resides on Config::$boarddir somewhere... + $file = Config::$boarddir; - $path = strtr($file, [ + // Get a full path to show on error. + if (!empty($hook['file'])) { + $file = strtr($hook['file'], [ '$boarddir' => Config::$boarddir, '$sourcedir' => Config::$sourcedir, ]); - if (str_contains($path, '$themedir') && class_exists('SMF\\Theme', false) && !empty(Theme::$current->settings['theme_dir'])) { - $path = strtr($path, [ + if (str_contains($file, '$themedir') && class_exists('SMF\\Theme', false) && !empty(Theme::$current->settings['theme_dir'])) { + $file = strtr($file, [ '$themedir' => Theme::$current->settings['theme_dir'], ]); } - - ErrorHandler::log(Lang::getTxt('hook_fail_call_to', [$func, $path], file: 'Errors'), 'general'); - } - // Assume the file resides on Config::$boarddir somewhere... - else { - ErrorHandler::log(Lang::getTxt('hook_fail_call_to', [$func_string, Config::$boarddir], file: 'Errors'), 'general'); } + + ErrorHandler::log(Lang::getTxt('hook_fail_call_to', [$hook_call, $file], file: 'Errors'), 'general'); } } @@ -167,100 +210,339 @@ public function execute(array $parameters = []): array */ public static function call(string $name, array $parameters = []): array { + $name = self::cleanHookName($name); $hook = new self($name); return $hook->execute($parameters); } /** - * Adds a function or method to an integration hook. + * Convenience method to fetch all hooks loaded. * - * Does nothing if the function is already added. - * Cleans up enabled/disabled variants before taking requested action. + * @param ?string $name If provided, only returns hooks from a single hook + * @return array The results returned. + */ + public static function get(?string $name = null): array + { + if (!empty($name)) { + return self::$hooks[$name]; + } + + return self::$hooks; + } + + /** + * Load up all our hook data from the database. + * Sends off for the compatiblity layer with SMF 2.1 calls. * - * @param string $name The complete hook name. - * @param string $function The function name. Can be a call to a method via - * Class::method. - * @param bool $permanent If true, updates the value in settings table. - * @param string $file The filename. Must include one of the following - * wildcards: $boarddir, $sourcedir, $themedir. - * Example: $sourcedir/Test.php - * @param bool $object Indicates if your class will be instantiated when its - * respective hook is called. If true, your function must be a method. */ - public static function add(string $name, string $function, bool $permanent = true, string $file = '', bool $object = false): void + final public static function load() + { + try { + $request = Db::$db->query( + 'SELECT id_hook, is_enabled, hook_name, func, file, class, is_object, package_id + FROM {db_prefix}hooks', + [ + ], + ); + + foreach (Db::$db->fetch_all($request) as $row) { + self::$hooks[$row['hook_name']] ??= []; + + self::$hooks[$row['hook_name']][] = [ + 'id_hook' => (int) $row['id_hook'], + 'is_enabled' => $row['is_enabled'] === '1' ? true : false, + 'hook_name' => trim($row['hook_name'] ?? ''), + 'function' => trim($row['func'] ?? ''), + 'file' => trim($row['file'] ?? ''), + 'class' => trim($row['class'] ?? ''), + 'is_object' => $row['is_object'] == '1' ? true : false, + 'package_id' => $row['package_id'] ?? null, + 'is_temp' => false, + ]; + + if (Config::$backward_compatibility) { + self::updateModSettings($row['hook_name'], self::buildBcString($row)); + } + } + Db::$db->free_result($request); + } catch (Exception $e) { + return; + } + } + + /** + * Parses the given input to determine if is a callable entity or can be + * turned into one, and then returns either a callable or false. + * + * If $input is already a callable entity, it will simply be returned. + * Otherwise, this method will attempt to turn it into one. + * + * @param string|callable $function Function to parse as a callable. + * @param string|null $class (Optional) Class we would be loading. + * @param ?bool $is_object (Optional) Initialize the object. + * @param ?bool $ignore_errors Optional. Whether to suppress errors if the + * callable is invalid. If null, falls back to the current value of + * Utils::$context['ignore_hook_errors']. Default: null. + * @return callable|false Either a valid callable or false on failure. + */ + final public static function getCallable(string|callable $function, ?string $class = null, bool $is_object = false, ?bool $ignore_errors = null): callable|false { - // Any objects? - if ($object) { - $function = $function . '#'; + if (!\is_string($function)) { + return \is_callable($function) ? $function : false; } - // Any files to load? - if (!empty($file) && \is_string($file)) { - $function = $file . (!empty($function) ? '|' . $function : ''); + // Abort if file loading fails. + if (empty($function)) { + return false; } - // Get the correct string. - $integration_call = $function; - $enabled_call = rtrim($function, '!'); - $disabled_call = $enabled_call . '!'; + $callable = false; + $callable_name = (!empty($class) ? $class . '::' : '') . $function; + + // Process the instances. + if ($is_object && \is_string($class)) { + Utils::$context['instances'] ??= []; + + if (!isset(Utils::$context['instances'][$class]) || !(Utils::$context['instances'][$class] instanceof $class)) { + Utils::$context['instances'][$class] = new $class(); + + // Optionally track instance creation for debugging. + if (!empty(Config::$db_show_debug)) { + Utils::$context['debug']['instances'][$class] = $class; + } + } + + $callable = [Utils::$context['instances'][$class], $function]; + } elseif (!empty($class)) { + // Static method reference. + $callable = [$class, $function]; + } else { + // Treat as a plain function. + $callable = $function; + } + + // Validate the callable. + if (!\is_callable($callable, false, $callable_name)) { + $ignore_errors ??= !empty(Utils::$context['ignore_hook_errors']); + + if ($ignore_errors) { + return false; + } + + // Log error for invalid callables. + ErrorHandler::log((string) Lang::getTxt('sub_action_fail', [$callable_name], file: 'Errors'), 'general'); + + return false; + } + + return $callable; + } + + /** + * Register a hook. + * + * @param string $name Calls self::cleanHookName($name) to remote integrate_ + * @param string $function Name of the function to call + * @param string $file (Optional) Load a file prior to calling the function + * @param string $class (Optional) Class of the function, supports both static and object + * @param bool $is_object (Optional) If class should be intialized as an object + * @param bool $is_enabled (Optional) if False, the hook is added, but not called + * @param ?string $package_id (Optional) If defined, add the package_id that added this hook + * @param bool $permanent (Optional) if true, we will add to the database, otherwise just held in memory. + * @return int id of the hook. + */ + public static function register(string $name, string $function, string $file = '', string $class = '', bool $is_object = false, bool $is_enabled = false, ?string $package_id = null, bool $permanent = true): int + { + $name = self::cleanHookName($name); - // Is it going to be permanent? if ($permanent) { - $request = Db::$db->query( - 'SELECT value - FROM {db_prefix}settings - WHERE variable = {string:variable}', + $id_hook = Db::$db->insert( + '', + '{db_prefix}hooks', + [ + 'is_enabled' => 'int', + 'hook_name' => 'string-255', + 'func' => 'string-255', + 'file' => 'string-255', + 'class' => 'string-255', + 'is_object' => 'int', + 'package_id' => 'string-255', + ], [ - 'variable' => $name, + [ + $is_enabled ? 1 : 0, + $name, + $function, + $file, + $class, + $is_object ? 1 : 0, + $package_id, + ], ], + [ + 'id_hook', + ], + 1, ); - list($current_functions) = Db::$db->fetch_row($request); - Db::$db->free_result($request); + } else { + $id_hook = rand(10000, 90000); + } - if (!empty($current_functions)) { - $current_functions = explode(',', $current_functions); + $hook = [ + 'id_hook' => $id_hook, + 'is_enabled' => $is_enabled, + 'hook_name' => $name, + 'function' => $function, + 'file' => $file, + 'class' => $class, + 'is_object' => $is_object, + 'package_id' => $package_id, + 'is_temp' => !$permanent, + ]; + + self::$hooks[$name] ??= []; + self::$hooks[$name][] = $hook; + + if (Config::$backward_compatibility) { + self::updateModSettings($name, self::buildBcString($hook)); + } - // Cleanup enabled/disabled variants before taking action. - $current_functions = array_diff($current_functions, [$enabled_call, $disabled_call]); + return $id_hook; + } - $permanent_functions = array_unique(array_merge($current_functions, [$integration_call])); - } else { - $permanent_functions = [$integration_call]; - } + /** + * Remove a hook from the system. + * + * @param string $name Calls self::cleanHookName($name) to remote integrate_ + * @param int $id_hook Id of hook. + * @param string $function (Optional) Name of the function to call + * @param string $file (Optional) Load a file prior to calling the function + * @param string $class (Optional) Class of the function, supports both static and object + * @param bool $is_object (Optional) If class should be intialized as an object + * @param bool $is_enabled (Optional) if False, the hook is added, but not called + * @param bool $permanent If true, removes from database. + */ + public static function unregister(string $name, int $id_hook, string $function = '', string $file = '', string $class = '', bool $is_object = false, bool $is_enabled = false, bool $permanent = true): void + { + $name = self::cleanHookName($name); + self::$hooks[$name] ??= []; - Config::updateModSettings([$name => implode(',', $permanent_functions)]); + if (empty(self::$hooks[$name])) { + return; } - // Make current function list usable. - $functions = empty(Config::$modSettings[$name]) ? [] : explode(',', Config::$modSettings[$name]); + // If we don't have a hook id, we have to search. + $key = null; + + if (empty($id_hook)) { + $key = array_find_key(self::$hooks[$name], function ($val) use ($function, $file, $class, $is_object) { + return + $val['function'] === $function + && $val['class'] === $class + && $val['file'] === $file + && $val['is_object'] === $is_object; + }); + } else { + $key = array_find_key(self::$hooks[$name], fn($val) => $val['id_hook'] === $id_hook); + } - // Cleanup enabled/disabled variants before taking action. - $functions = array_diff($functions, [$enabled_call, $disabled_call]); - $functions = array_unique(array_merge($functions, [$integration_call])); + if ($key !== null) { + if (Config::$backward_compatibility) { + self::updateModSettings($name, self::buildBcString(self::$hooks[$name][$key])); + } - Config::$modSettings[$name] = implode(',', $functions); + unset(self::$hooks[$name][$key]); + } - // It is handy to be able to know which hooks are temporary... - if ($permanent !== true) { - if (!isset(Utils::$context['integration_hooks_temporary'])) { - Utils::$context['integration_hooks_temporary'] = []; + if ($permanent) { + Db::$db->query( + 'DELETE FROM {db_prefix}hooks + WHERE id_hook = {int:hook}', + [ + 'hook' => $id_hook, + ], + ); + } + } + + /** + * Find all instances of hooks related to the package id and remove them. + * + * @param string $package_id + */ + public static function uninstallPackage(string $package_id): void + { + foreach (self::$hooks as $name => &$hooks) { + foreach ($hooks as $key => $calls) { + if ($calls['package_id'] !== $package_id) { + continue; + } + + unset($hooks[$key]); } + } + + Db::$db->query( + 'DELETE FROM {db_prefix}hooks + WHERE package_id = {string:package_id}', + [ + 'package_id' => $package_id, + ], + ); + } + /** + * Adds a function or method to an integration hook. + * + * For use with SMF 2.1 compatbility layer. + * + * Does nothing if the function is already added. + * Cleans up enabled/disabled variants before taking requested action. + * + * @param string $name The complete hook name. Calls self::cleanHookName($name) to remote integrate_ + * @param string $function The function name. Can be a call to a method via + * Class::method. + * @param bool $permanent If true, updates the value in settings table. + * @param string $file The filename. Must include one of the following + * wildcards: $boarddir, $sourcedir, $themedir. + * Example: $sourcedir/Test.php + * @param bool $object Indicates if your class will be instantiated when its + * respective hook is called. If true, your function must be a method. + */ + public static function add(string $name, string $function, bool $permanent = true, string $file = '', bool $object = false): void + { + $name = self::cleanHookName($name); + $hook = self::parseBcString($name, $function, $permanent); + + if ($permanent) { + self::register($name, $hook['function'], $hook['file'], $hook['class'], $hook['is_object'], $hook['is_enabled']); + } else { + self::$hooks[$hook['hook_name']] ??= []; + self::$hooks[$hook['hook_name']][] = $hook; + + // It is handy to be able to know which hooks are temporary... + Utils::$context['integration_hooks_temporary'] ??= []; Utils::$context['integration_hooks_temporary'][$name][$function] = true; + + if (Config::$backward_compatibility) { + self::updateModSettings($name, $function); + } } } /** * Removes an integration hook function. * + * For use with SMF 2.1 compatbility layer. + * * Removes the given function from the given hook. * Does nothing if the function is not available. * Cleans up enabled/disabled variants before taking requested action. * * @see IntegrationHook::add * - * @param string $name The complete hook name. + * @param string $name The complete hook name. Calls self::cleanHookName($name) to remote integrate_ * @param string $function The function name. Can be a call to a method via * Class::method. * @param bool $permanent Irrelevant for the function itself but need to @@ -273,48 +555,268 @@ public static function add(string $name, string $function, bool $permanent = tru */ public static function remove(string $name, string $function, bool $permanent = true, string $file = '', bool $object = false): void { - // Any objects? - if ($object) { - $function = $function . '#'; + $name = self::cleanHookName($name); + $tmpHook = self::parseBcString($name, $function, $permanent); + + $key = array_find(self::$hooks[$name], function ($val) use ($tmpHook) { + return + $val['function'] === $tmpHook['function'] + && $val['class'] === $tmpHook['class'] + && $val['file'] === $tmpHook['file'] + && $val['is_object'] === $val['is_object']; + }); + + if ($permanent) { + self::unregister($name, self::$hooks[$name][$key]['id_hook']); + } else { + self::$hooks[$name] ??= []; + unset(self::$hooks[$name][$key]); + + if (Config::$backward_compatibility) { + self::updateModSettings($name, $function, true); + } + } + } + + /** + * Calls to Config::updateModSettings get interupted and redirected here. + * Process the incoming string of data, break out existing matching hooks + * and then determine if we added or removed a hook. + * + * For use with SMF 2.1 compatbility layer. + * + * Note, A danger exists (since hooks where added in 2.x), that we assume + * modSettings has the hook data we want, but we also added temporary hooks + * and if a call to a permanent hook is made after, it could result in the + * being saved to the database. + * + * @param mixed $name Calls self::cleanHookName($name) to remote integrate_ + * @param mixed $hooks + */ + final public static function processUpdateModSettings($name, $hooks): void + { + $name = self::cleanHookName($name); + + // This is easy. + if (empty($hooks)) { + Config::$modSettings[$name] = ''; + + return; } - // Any files to load? - if (!empty($file) && \is_string($file)) { - $function = $file . '|' . $function; + $tmp_data = self::$hooks[$name]; + $tmp_hooks = explode(',', $hooks); + + foreach ($tmp_data as $key1 => $tmp) { + $hook_string = self::buildBcString($tmp); + + $key2 = array_search($hook_string, $tmp_hooks); + + // We found the key, nothing to do. + if ($key2 !== false) { + unset($tmp_hooks[$key2], $tmp_data[$key1]); + + } + // Hook needs deleted. + else { + self::remove($name, $hook_string); + } + } + + // If the hook still exists, it needs added. + foreach ($tmp_hooks as $tmp) { + self::add($name, $tmp); } + } - // Get the correct string. - $integration_call = $function; - $enabled_call = rtrim($function, '!'); - $disabled_call = $enabled_call . '!'; + /************************* + * Internal static methods + *************************/ - // Get the permanent functions. - $request = Db::$db->query( - 'SELECT value - FROM {db_prefix}settings - WHERE variable = {string:variable}', - [ - 'variable' => $name, - ], - ); - list($current_functions) = Db::$db->fetch_row($request); - Db::$db->free_result($request); + /** + * Receives a filename and tries to loads the file. + * + * You can use the following wildcards in the path: + * - $boarddir + * - $sourcedir + * - $themedir (only works if SMF\Theme has already been initialized) + * + * @param string $file The string containing a valid format. + * @param bool $silent Should we be silent about the failure? + * @return bool False if we failed, true if we loaded. + */ + final protected static function loadFile(string $file, bool $silent = false): bool + { + if (empty($file)) { + return false; + } + + $path = strtr($file, [ + '$boarddir' => Config::$boarddir, + '$sourcedir' => Config::$sourcedir, + ]); - if (!empty($current_functions)) { - $current_functions = explode(',', $current_functions); + if (str_contains($path, '$themedir') && class_exists(Theme::class, false) && !empty(Theme::$current->settings['theme_dir'])) { + $path = strtr($path, [ + '$themedir' => Theme::$current->settings['theme_dir'], + ]); + } + + $path = Config::canonicalPath($path); + + // Load the file if it can be loaded. + if (is_file($path)) { + require_once $path; + } + // No? Try a fallback to Config::$sourcedir. + else { + $path = Config::canonicalPath(Config::$sourcedir . '/' . $file); - // Cleanup enabled and disabled variants. - $current_functions = array_unique(array_diff($current_functions, [$enabled_call, $disabled_call])); + if (is_file($path)) { + require_once $path; + } + // Sorry, can't do much for you at this point. + elseif (empty(Utils::$context['uninstalling'])) { + if (!$silent) { + ErrorHandler::log(Lang::getTxt('hook_fail_loading_file', [$path], file: 'Errors'), 'general'); + } + + // File couldn't be loaded. + return false; + } + } + + return true; + } + + /** + * Parses the given input to determine and returns a compatible hook array. + * + * For use with SMF 2.1 compatbility layer. + * + * Two special syntaxes can be used with string input, as follows: + * + * - Instructions to load a specific file can be given by prepending a file + * path followed by a `|` character to the $input string. This amounts to + * a form of autoloading for callables that are not class-based. These + * file paths support the wildcards $boarddir, $sourcedir, and $themedir. + * + * Example: '$sourcedir/foo.php|func_name' will load ./Sources/foo.php + * and then return 'func_name'. + * + * - If a class method is specified with a "#" character appended to it, an + * instance of that class will be automatically created and added to + * Utils::$context['instances'], and the returned value from this method + * will be a callable array that will call the specified method on that + * instance. Note, however, that there is no way to pass arguments to the + * class's constructor when using this syntax. For that reason, it is + * usually better to construct the object directly rather than using this + * syntax to do the job for you. + * + * Example: 'SMF\Foo::methodName#' will create an instance of SMF\Foo and + * then return an array containing the instantiated object and the string + * 'methodName'. + * + * @param string $hook_name The name of the hook + * @param string|callable $function Function to parse as a callable. + * @param bool $permanent If true, updates the value in settings table. + * @return array Either a valid callable or false on failure. + */ + private static function parseBcString(string $hook_name, string $function, bool $permanent = true): array + { + $file = ''; + $class = ''; + + if (str_contains($function, '|')) { + [$file, $function] = explode('|', $function); + } - Config::updateModSettings([$name => implode(',', $current_functions)]); + if (str_contains($function, '::')) { + [$class, $function] = explode('::', $function); } - // Turn the function list into something usable. - $functions = empty(Config::$modSettings[$name]) ? [] : explode(',', Config::$modSettings[$name]); + $hook = [ + 'id_hook' => rand(100000, 900000), + 'is_enabled' => !str_starts_with('!', $function) ? true : false, + 'hook_name' => self::cleanHookName($hook_name), + 'function' => $function, + 'file' => $file, + 'class' => $class, + 'is_object' => false, + 'package_id' => null, + 'is_temp' => !$permanent, + ]; + + return $hook; + } - // Cleanup enabled and disabled variants. - $functions = array_unique(array_diff($functions, [$enabled_call, $disabled_call])); + /** + * Builds a string compatible with SMF 2.1 hook system. + * + * @param array $hook Data from our hook system. + * @return string + */ + private static function buildBcString(array $hook): string + { + return + (!empty($hook['is_enabled']) ? '!' : '') + . (!empty($hook['file']) ? $hook['file'] . '|' : '') + . (!empty($hook['class']) ? $hook['class'] . ':' : '') + . ($hook['function'] ?? '') + . (!empty($hook['is_object']) ? '#' : ''); + } - Config::$modSettings[$name] = implode(',', $functions); + /** + * Registers with modSettings our hook data. + * + * For use with SMF 2.1 compatbility layer. + * + * Note, A danger exists (since hooks where added in 2.x), that we assume + * modSettings has the hook data we want, but we also added temporary hooks + * and if a call to a permanent hook is made after, it could result in the + * being saved to the database. + * + * @param mixed $hook + */ + private static function updateModSettings(string $name, string $function, bool $remove = false) + { + $name = self::prepareLegacyName($name); + Config::$modSettings[$name] ??= ''; + + if ($remove) { + $tmps = explode(',', Config::$modSettings[$name]); + + $key = array_search($function, $tmps); + unset($tmps[$key]); + Config::$modSettings[$name] = implode(',', $tmps); + } else { + Config::$modSettings[$name] .= (!empty(Config::$modSettings[$name]) ? ',' : '') . $function; + } + } + + /** + * Wrapper to remove integrate_ from the hook name. + * + * For use with SMF 2.1 compatbility layer. + * + * @param string $name + * @return string Name cleansed of integrate_ + */ + private static function cleanHookName(string $name): string + { + return str_starts_with($name, 'integrate_') ? substr($name, 10) : $name; + } + + /** + * Wrapper to prepare the right prefix of the legacy hook name. + * + * For use with SMF 2.1 compatbility layer. + * + * @param string $name + * @return string + */ + private static function prepareLegacyName(string $name): string + { + return \in_array($name, self::$no_integrate_names) ? $name : 'integrate_' . $name; } } diff --git a/Sources/Maintenance/Migration/CleanHooks.php b/Sources/Maintenance/Migration/CleanHooks.php new file mode 100644 index 0000000000..5b59506a0c --- /dev/null +++ b/Sources/Maintenance/Migration/CleanHooks.php @@ -0,0 +1,62 @@ +query( + 'DELETE FROM {db_prefix}settings + WHERE variable LIKE {string:integration}', + [ + 'integration' => 'integration_%', + ], + ); + + $tables = Db::$db->list_tables(); + + if (\in_array(Config::$db_prefix . 'hooks', $tables)) { + $this->query( + 'TRUNCATE TABLE {db_prefix}hooks', + ); + } + + return true; + } +} diff --git a/Sources/Maintenance/Tools/Upgrade.php b/Sources/Maintenance/Tools/Upgrade.php index d226a9221c..bed2cbcd9c 100644 --- a/Sources/Maintenance/Tools/Upgrade.php +++ b/Sources/Maintenance/Tools/Upgrade.php @@ -162,6 +162,7 @@ class Upgrade extends ToolsBase implements ToolsInterface ], // Migration steps for 2.1 -> 3.0 'v3_0' => [ + Migration\CleanHooks::class, Migration\v3_0\ConvertToInnoDb::class, Migration\v3_0\LanguageDirectory::class, Migration\v3_0\ErrorLogSession::class, diff --git a/Sources/PackageManager/PackageManager.php b/Sources/PackageManager/PackageManager.php index 4bf93c4ff6..f792ca953f 100644 --- a/Sources/PackageManager/PackageManager.php +++ b/Sources/PackageManager/PackageManager.php @@ -1147,15 +1147,22 @@ public function install(): void 'title' => $action['title'], ]; } elseif ($action['type'] == 'hook' && isset($action['hook'], $action['function'])) { + $action['is_enabled'] ??= str_starts_with($action['function'], '!'); + $action['class'] ??= ''; + + if (empty($action['class']) && str_contains($action['function'], '::')) { + [$action['class'], $action['function']] = explode('::', $action['function']); + } + // Set the system to ignore hooks, but only if it wasn't changed before. if (!isset(Utils::$context['ignore_hook_errors'])) { Utils::$context['ignore_hook_errors'] = true; } if ($action['reverse']) { - IntegrationHook::remove($action['hook'], $action['function'], true, $action['include_file'], $action['object']); + IntegrationHook::unregister($action['hook'], 0, $action['function'], true, $action['include_file'], $action['object']); } else { - IntegrationHook::add($action['hook'], $action['function'], true, $action['include_file'], $action['object']); + IntegrationHook::register($action['hook'], $action['function'], $action['include_file'], $action['class'], $action['object'], $action['is_enabled'], $packageInfo['id']); } } // Only do the database changes on uninstall if requested. @@ -1375,6 +1382,9 @@ public function install(): void ], ['id_install'], ); + } else { + // When uninstalling, ensure that we removed all hooks related to this. + IntegrationHook::uninstallPackage($packageInfo['id']); } Db::$db->free_result($request);