diff --git a/Languages/en_US/Admin.php b/Languages/en_US/Admin.php index d50cd2913e..87117c4600 100644 --- a/Languages/en_US/Admin.php +++ b/Languages/en_US/Admin.php @@ -760,6 +760,8 @@ $txt['hooks_field_function'] = 'Function: {real_function}'; $txt['hooks_field_included_file'] = 'Included file: {included_file}'; $txt['hooks_field_file_name'] = 'File Name'; +$txt['hooks_field_package_name'] = 'Added By'; +$txt['hooks_field_package_name_none'] = 'Not from a package'; $txt['hooks_field_hook_exists'] = 'Status'; $txt['hooks_active'] = 'Exists'; $txt['hooks_disabled'] = 'Disabled'; diff --git a/Sources/Actions/Admin/Maintenance.php b/Sources/Actions/Admin/Maintenance.php index 7f56010ccd..bdc533bca1 100644 --- a/Sources/Actions/Admin/Maintenance.php +++ b/Sources/Actions/Admin/Maintenance.php @@ -30,6 +30,7 @@ use SMF\Lang; use SMF\Logging; use SMF\Menu; +use SMF\PackageManager\PackageUtils; use SMF\Sapi; use SMF\SecurityToken; use SMF\TaskRunner; @@ -1882,6 +1883,22 @@ function ($accumulator, $functions) { 'reverse' => 'file_name DESC', ], ], + 'package_name' => [ + 'header' => [ + 'value' => Lang::getTxt('hooks_field_package_name', file: 'Admin'), + ], + 'data' => [ + 'function' => function ($data) { + // Hooks that a mod added from its own code, and SMF's own, have no package. + return $data['package_name'] !== '' ? $data['package_name'] : Lang::getTxt('hooks_field_package_name_none', file: 'Admin'); + }, + 'class' => 'word_break', + ], + 'sort' => [ + 'default' => 'package_name', + 'reverse' => 'package_name DESC', + ], + ], 'status' => [ 'header' => [ 'value' => Lang::getTxt('hooks_field_hook_exists', file: 'Admin'), @@ -1987,6 +2004,7 @@ public static function getIntegrationHooksData($start, $per_page, $sort, $filter { $function_list = $sort_array = $temp_data = []; $files = self::getFileRecursive($normalized_sourcedir); + $hook_owners = PackageUtils::getHookOwners(); foreach ($files as $currentFile => $fileInfo) { $function_list += self::getDefinedFunctionsInFile($currentFile); @@ -1999,6 +2017,8 @@ public static function getIntegrationHooksData($start, $per_page, $sort, $filter 'function_name DESC' => ['function_name', SORT_DESC], 'file_name' => ['file_name', SORT_ASC], 'file_name DESC' => ['file_name', SORT_DESC], + 'package_name' => ['package_name', SORT_ASC], + 'package_name DESC' => ['package_name', SORT_DESC], 'status' => ['status', SORT_ASC], 'status DESC' => ['status', SORT_DESC], ]; @@ -2023,6 +2043,8 @@ public static function getIntegrationHooksData($start, $per_page, $sort, $filter 'included_file' => $hookParsedData['hookFile'], 'file_name' => strtr($hookParsedData['absPath'] ?: ($function_list[$hookParsedData['call']] ?? ''), [$normalized_boarddir => '.']), 'instance' => $hookParsedData['object'], + // A disabled hook is stored with a trailing '!', which the package never asked for. + 'package_name' => $hook_owners[$hook][rtrim($hookParsedData['rawData'], '!')] ?? '', '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'), diff --git a/Sources/PackageManager/PackageUtils.php b/Sources/PackageManager/PackageUtils.php index e26e725ae3..aebd361f97 100644 --- a/Sources/PackageManager/PackageUtils.php +++ b/Sources/PackageManager/PackageUtils.php @@ -487,6 +487,90 @@ public static function urlExists(string $url): bool return preg_match('~^HTTP/.+\s+(20[01]|30[127])~i', $head) == 1; } + /** + * Finds the package that registered each integration hook. + * + * Hooks are stored as a flat list of function names, with nothing in them + * to say where each one came from, so the answer comes from the packages: + * every installed package is asked which hooks its package-info.xml + * registers. A hook that a package added in its own code rather than in + * its package-info.xml has no owner here. + * + * @return array Package names, keyed by hook name and then by the entry + * that was stored for the hook. + */ + public static function getHookOwners(): array + { + static $owners; + + if (isset($owners)) { + return $owners; + } + + $owners = []; + + foreach (self::loadInstalledPackages() as $package) { + $info = self::getPackageInfo($package['filename']); + + // The package file is gone, or is no longer readable as a package. + if (!\is_array($info) || !isset($info['xml'])) { + continue; + } + + foreach (self::getPackageHooks($info['xml']) as $hook) { + $owners[$hook['hook']][$hook['call']] = $package['name']; + } + } + + return $owners; + } + + /** + * Gets the hooks that a package registers in its package-info.xml. + * + * Every install and upgrade block is read, whichever version of SMF or of + * the package it is for. A hook belongs to the package that ships it no + * matter which of its blocks put it there, and an entry for a block that + * never ran simply matches no hook. + * + * @param XmlArray $package_xml The package-info.xml of a package. + * @return array Each hook's name, and the entry that is stored for it. + */ + public static function getPackageHooks(XmlArray $package_xml): array + { + $hooks = []; + + foreach (['install', 'upgrade'] as $method) { + if (!$package_xml->exists($method)) { + continue; + } + + foreach ($package_xml->set($method) as $block) { + foreach ($block->set('hook') as $hook) { + // A reverse hook takes one away instead of adding it. + if ($hook->exists('@reverse') && $hook->fetch('@reverse') == 'true') { + continue; + } + + $function = $hook->exists('@function') ? $hook->fetch('@function') : ''; + $file = $hook->exists('@file') ? $hook->fetch('@file') : ''; + + if ($hook->exists('@object') && $hook->fetch('@object') == 'true') { + $function .= '#'; + } + + $hooks[] = [ + 'hook' => $hook->exists('@hook') ? $hook->fetch('@hook') : $hook->fetch('.'), + // The same shape that IntegrationHook::add() stores. + 'call' => $file === '' ? $function : $file . ($function === '' ? '' : '|' . $function), + ]; + } + } + } + + return $hooks; + } + /** * Loads and returns an array of installed packages. * diff --git a/tests/Unit/PackageUtilsTest.php b/tests/Unit/PackageUtilsTest.php new file mode 100644 index 0000000000..d0ae4a5fe8 --- /dev/null +++ b/tests/Unit/PackageUtilsTest.php @@ -0,0 +1,123 @@ +packageXml(' + + + ')); + + $this->assertSame( + [['hook' => 'integrate_load_theme', 'call' => '$sourcedir/MyMod.php|my_mod_load_theme']], + $hooks, + ); + } + + public function testItBuildsTheEntryTheHookIsStoredAs(): void + { + $hooks = PackageUtils::getPackageHooks($this->packageXml(' + + + + + ')); + + $this->assertSame( + [ + 'plain_function', + '$sourcedir/MyMod.php|MyMod\Integration::run#', + '$sourcedir/MyMod.php', + ], + array_column($hooks, 'call'), + ); + } + + public function testItLeavesOutHooksThatAPackageRemoves(): void + { + $hooks = PackageUtils::getPackageHooks($this->packageXml(' + + + + ')); + + $this->assertSame(['integrate_added'], array_column($hooks, 'hook')); + } + + public function testItCountsHooksAddedByAnUpgrade(): void + { + $hooks = PackageUtils::getPackageHooks($this->packageXml(' + + + + + + ')); + + $this->assertSame(['integrate_first', 'integrate_second'], array_column($hooks, 'hook')); + } + + /** + * Packages written before a version of SMF existed keep a block per + * version, and which one ran depends on the forum it was installed on. + */ + public function testItReadsEveryInstallBlockWhicheverVersionItIsFor(): void + { + $hooks = PackageUtils::getPackageHooks($this->packageXml(' + + + + + + ')); + + $this->assertSame(['integrate_old', 'integrate_current'], array_column($hooks, 'hook')); + } + + public function testItFindsNothingInAPackageThatHooksNothing(): void + { + $hooks = PackageUtils::getPackageHooks($this->packageXml(' + + + ')); + + $this->assertSame([], $hooks); + } + + /****************** + * Internal methods + ******************/ + + /** + * Wraps package-info.xml content the way a real package file has it. + * + * @param string $content The install and upgrade blocks of the package. + * @return XmlArray The package-info element, as getPackageInfo() returns it. + */ + protected function packageXml(string $content): XmlArray + { + $xml = new XmlArray(' + + test:my_mod + My Mod + 1.0' . $content . ' + '); + + return $xml->path('package-info[0]'); + } +}