Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Languages/en_US/Admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
22 changes: 22 additions & 0 deletions Sources/Actions/Admin/Maintenance.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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);
Expand All @@ -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],
];
Expand All @@ -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'),
Expand Down
84 changes: 84 additions & 0 deletions Sources/PackageManager/PackageUtils.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
123 changes: 123 additions & 0 deletions tests/Unit/PackageUtilsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

declare(strict_types=1);

namespace SMF\Tests\Unit;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use SMF\PackageManager\PackageUtils;
use SMF\PackageManager\XmlArray;

#[CoversClass(PackageUtils::class)]
class PackageUtilsTest extends TestCase
{
/****************
* Public methods
****************/

public function testItReadsTheHooksAPackageInstalls(): void
{
$hooks = PackageUtils::getPackageHooks($this->packageXml('
<install for="3.0 - 3.0.99">
<hook hook="integrate_load_theme" function="my_mod_load_theme" file="$sourcedir/MyMod.php" />
</install>'));

$this->assertSame(
[['hook' => 'integrate_load_theme', 'call' => '$sourcedir/MyMod.php|my_mod_load_theme']],
$hooks,
);
}

public function testItBuildsTheEntryTheHookIsStoredAs(): void
{
$hooks = PackageUtils::getPackageHooks($this->packageXml('
<install for="3.0 - 3.0.99">
<hook hook="integrate_plain" function="plain_function" />
<hook hook="integrate_method" function="MyMod\Integration::run" file="$sourcedir/MyMod.php" object="true" />
<hook hook="integrate_pre_include" file="$sourcedir/MyMod.php" />
</install>'));

$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('
<install for="3.0 - 3.0.99">
<hook hook="integrate_added" function="added_function" />
<hook hook="integrate_removed" function="removed_function" reverse="true" />
</install>'));

$this->assertSame(['integrate_added'], array_column($hooks, 'hook'));
}

public function testItCountsHooksAddedByAnUpgrade(): void
{
$hooks = PackageUtils::getPackageHooks($this->packageXml('
<install for="3.0 - 3.0.99">
<hook hook="integrate_first" function="first_function" />
</install>
<upgrade for="3.0 - 3.0.99" from="1.0">
<hook hook="integrate_second" function="second_function" />
</upgrade>'));

$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('
<install for="2.1.*">
<hook hook="integrate_old" function="old_function" />
</install>
<install for="3.0 - 3.0.99">
<hook hook="integrate_current" function="current_function" />
</install>'));

$this->assertSame(['integrate_old', 'integrate_current'], array_column($hooks, 'hook'));
}

public function testItFindsNothingInAPackageThatHooksNothing(): void
{
$hooks = PackageUtils::getPackageHooks($this->packageXml('
<install for="3.0 - 3.0.99">
<require-file name="MyMod.php" destination="$sourcedir" />
</install>'));

$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('<?xml version="1.0"?>
<package-info xmlns="http://www.simplemachines.org/xml/package-info">
<id>test:my_mod</id>
<name>My Mod</name>
<version>1.0</version>' . $content . '
</package-info>');

return $xml->path('package-info[0]');
}
}
Loading