diff --git a/src/Core/Addon.php b/src/Core/Addon.php index f798021252..96f3fbc6f5 100644 --- a/src/Core/Addon.php +++ b/src/Core/Addon.php @@ -12,6 +12,8 @@ use Friendica\Util\Strings; /** * Some functions to handle addons + * + * @deprecated 2025.02 Use implementation of `Friendica\Core\Addon\AddonHelper` instead */ class Addon { @@ -43,6 +45,8 @@ class Addon */ public static function getAvailableList(): array { + @trigger_error('Class `' . __CLASS__ . '` is deprecated since 2025.02 and will be removed after 5 months, use implementation of `Friendica\Core\Addon\AddonHelper` instead.', E_USER_DEPRECATED); + $addons = []; $files = glob('addon/*/'); if (is_array($files)) { @@ -75,6 +79,8 @@ class Addon */ public static function getAdminList(): array { + @trigger_error('Class `' . __CLASS__ . '` is deprecated since 2025.02 and will be removed after 5 months, use implementation of `Friendica\Core\Addon\AddonHelper` instead.', E_USER_DEPRECATED); + $addons_admin = []; $addons = array_filter(DI::config()->get('addons') ?? []); @@ -109,6 +115,8 @@ class Addon */ public static function loadAddons() { + @trigger_error('Class `' . __CLASS__ . '` is deprecated since 2025.02 and will be removed after 5 months, use implementation of `Friendica\Core\Addon\AddonHelper` instead.', E_USER_DEPRECATED); + self::$addons = array_keys(array_filter(DI::config()->get('addons') ?? [])); } @@ -123,6 +131,8 @@ class Addon */ public static function uninstall(string $addon) { + @trigger_error('Class `' . __CLASS__ . '` is deprecated since 2025.02 and will be removed after 5 months, use implementation of `Friendica\Core\Addon\AddonHelper` instead.', E_USER_DEPRECATED); + $addon = Strings::sanitizeFilePathItem($addon); DI::logger()->debug("Addon {addon}: {action}", ['action' => 'uninstall', 'addon' => $addon]); @@ -151,6 +161,8 @@ class Addon */ public static function install(string $addon): bool { + @trigger_error('Class `' . __CLASS__ . '` is deprecated since 2025.02 and will be removed after 5 months, use implementation of `Friendica\Core\Addon\AddonHelper` instead.', E_USER_DEPRECATED); + $addon = Strings::sanitizeFilePathItem($addon); $addon_file_path = 'addon/' . $addon . '/' . $addon . '.php'; @@ -191,6 +203,8 @@ class Addon */ public static function reload() { + @trigger_error('Class `' . __CLASS__ . '` is deprecated since 2025.02 and will be removed after 5 months, use implementation of `Friendica\Core\Addon\AddonHelper` instead.', E_USER_DEPRECATED); + $addons = array_filter(DI::config()->get('addons') ?? []); foreach ($addons as $name => $data) { @@ -230,6 +244,8 @@ class Addon */ public static function getInfo(string $addon): array { + @trigger_error('Class `' . __CLASS__ . '` is deprecated since 2025.02 and will be removed after 5 months, use implementation of `Friendica\Core\Addon\AddonHelper` instead.', E_USER_DEPRECATED); + $addon = Strings::sanitizeFilePathItem($addon); $info = [ @@ -291,6 +307,8 @@ class Addon */ public static function isEnabled(string $addon): bool { + @trigger_error('Class `' . __CLASS__ . '` is deprecated since 2025.02 and will be removed after 5 months, use implementation of `Friendica\Core\Addon\AddonHelper` instead.', E_USER_DEPRECATED); + return in_array($addon, self::$addons); } @@ -303,6 +321,8 @@ class Addon */ public static function getEnabledList(): array { + @trigger_error('Class `' . __CLASS__ . '` is deprecated since 2025.02 and will be removed after 5 months, use implementation of `Friendica\Core\Addon\AddonHelper` instead.', E_USER_DEPRECATED); + return self::$addons; } @@ -316,6 +336,8 @@ class Addon */ public static function getVisibleList(): array { + @trigger_error('Class `' . __CLASS__ . '` is deprecated since 2025.02 and will be removed after 5 months, use implementation of `Friendica\Core\Addon\AddonHelper` instead.', E_USER_DEPRECATED); + $visible_addons = []; $addons = array_filter(DI::config()->get('addons') ?? []); diff --git a/src/Core/Addon/AddonHelper.php b/src/Core/Addon/AddonHelper.php index 84b0c5f89d..500bf66f1d 100644 --- a/src/Core/Addon/AddonHelper.php +++ b/src/Core/Addon/AddonHelper.php @@ -55,12 +55,14 @@ interface AddonHelper public function loadAddons(): void; /** - * Reload (uninstall and install) all updated addons. + * Reload (uninstall and install) all installed and modified addons. */ public function reloadAddons(): void; /** * Get the comment block of an addon as value object. + * + * @throws \Friendica\Core\Addon\Exception\InvalidAddonException if there is an error with the addon file */ public function getAddonInfo(string $addonId): AddonInfo; diff --git a/src/Core/Addon/AddonInfo.php b/src/Core/Addon/AddonInfo.php index ce5f07fc0f..d0b46f11c6 100644 --- a/src/Core/Addon/AddonInfo.php +++ b/src/Core/Addon/AddonInfo.php @@ -14,6 +14,74 @@ namespace Friendica\Core\Addon; */ final class AddonInfo { + /** + * Parse addon comment in search of addon infos. + * + * like + * \code + * * Name: addon + * * Description: An addon which plugs in + * . * Version: 1.2.3 + * * Author: John + * * Author: Jane + * * Maintainer: Jess without link + * * Maintainer: Robin + * * Status: in development + * \endcode + * + * @internal Never create this object by yourself, use `Friendica\Core\Addon\AddonHelper::getAddonInfo()` instead. + * @see Friendica\Core\Addon\AddonHelper::getAddonInfo() + * + * @param string $addonId the name of the addon + * @param string $raw The raw file content + */ + public static function fromString(string $addonId, string $raw): self + { + $data = [ + 'id' => $addonId, + ]; + + $ll = explode("\n", $raw); + + foreach ($ll as $l) { + $l = trim($l, "\t\n\r */"); + if ($l !== '') { + $addon_info = array_map('trim', explode(":", $l, 2)); + if (count($addon_info) < 2) { + continue; + } + + list($type, $v) = $addon_info; + $type = strtolower($type); + + if ($type === 'author' || $type === 'maintainer') { + $r = preg_match("|([^<]+)<([^>]+)>|", $v, $m); + if ($r === false || $r === 0) { + $data[$type][] = ['name' => trim($v)]; + } else { + $data[$type][] = ['name' => trim($m[1]), 'link' => $m[2]]; + } + } else { + $data[$type] = $v; + } + } + } + + // rename author to authors + if (array_key_exists('author', $data)) { + $data['authors'] = $data['author']; + unset($data['author']); + } + + // rename maintainer to maintainers + if (array_key_exists('maintainer', $data)) { + $data['maintainers'] = $data['maintainer']; + unset($data['maintainer']); + } + + return self::fromArray($data); + } + /** * @internal Never create this object by yourself, use `Friendica\Core\Addon\AddonHelper::getAddonInfo()` instead. * @@ -21,25 +89,21 @@ final class AddonInfo */ public static function fromArray(array $info): self { - $id = array_key_exists('id', $info) ? (string) $info['id'] : ''; - $name = array_key_exists('name', $info) ? (string) $info['name'] : ''; - $description = array_key_exists('description', $info) ? (string) $info['description'] : ''; - $authors = array_key_exists('authors', $info) ? self::parseContributors($info['authors']) : []; - $maintainers = array_key_exists('maintainers', $info) ? self::parseContributors($info['maintainers']) : []; - $version = array_key_exists('version', $info) ? (string) $info['version'] : ''; - $status = array_key_exists('status', $info) ? (string) $info['status'] : ''; + $addonInfo = new self(); + $addonInfo->id = array_key_exists('id', $info) ? (string) $info['id'] : ''; + $addonInfo->name = array_key_exists('name', $info) ? (string) $info['name'] : ''; + $addonInfo->description = array_key_exists('description', $info) ? (string) $info['description'] : ''; + $addonInfo->authors = array_key_exists('authors', $info) ? self::parseContributors($info['authors']) : []; + $addonInfo->maintainers = array_key_exists('maintainers', $info) ? self::parseContributors($info['maintainers']) : []; + $addonInfo->version = array_key_exists('version', $info) ? (string) $info['version'] : ''; + $addonInfo->status = array_key_exists('status', $info) ? (string) $info['status'] : ''; - return new self( - $id, - $name, - $description, - $authors, - $maintainers, - $version, - $status - ); + return $addonInfo; } + /** + * @param mixed $entries + */ private static function parseContributors($entries): array { if (!is_array($entries)) { @@ -85,22 +149,8 @@ final class AddonInfo private string $status = ''; - private function __construct( - string $id, - string $name, - string $description, - array $authors, - array $maintainers, - string $version, - string $status - ) { - $this->id = $id; - $this->name = $name; - $this->description = $description; - $this->authors = $authors; - $this->maintainers = $maintainers; - $this->version = $version; - $this->status = $status; + private function __construct() + { } public function getId(): string diff --git a/src/Core/Addon/AddonManagerHelper.php b/src/Core/Addon/AddonManagerHelper.php new file mode 100644 index 0000000000..9c888f55da --- /dev/null +++ b/src/Core/Addon/AddonManagerHelper.php @@ -0,0 +1,328 @@ +addonPath = $addonPath; + $this->database = $database; + $this->config = $config; + $this->cache = $cache; + $this->logger = $logger; + $this->profiler = $profiler; + } + /** + * Returns the absolute path to the addon folder + * + * e.g. `/var/www/html/addon` + */ + public function getAddonPath(): string + { + return $this->addonPath; + } + + /** + * Returns the list of available addons. + * + * This list is made from scanning the addon/ folder. + * Unsupported addons are excluded unless they already are enabled or system.show_unsupported_addon is set. + * + * @return string[] + */ + public function getAvailableAddons(): array + { + $dirs = scandir($this->getAddonPath()); + + if (!is_array($dirs)) { + return []; + } + + $files = []; + + foreach ($dirs as $dirname) { + // ignore hidden files and folders + // @TODO: Replace with str_starts_with() when PHP 8.0 is the minimum version + if (strncmp($dirname, '.', 1) === 0) { + continue; + } + + if (!is_dir($this->getAddonPath() . '/' . $dirname)) { + continue; + } + + $files[] = $dirname; + } + + $addons = []; + + foreach ($files as $addonId) { + try { + $addonInfo = $this->getAddonInfo($addonId); + } catch (InvalidAddonException $th) { + $this->logger->error('Invalid addon found: ' . $addonId, ['exception' => $th]); + + // skip invalid addons + continue; + } + + if ( + $this->config->get('system', 'show_unsupported_addons') + || strtolower($addonInfo->getStatus()) !== 'unsupported' + || $this->isAddonEnabled($addonId) + ) { + $addons[] = $addonId; + } + } + + return $addons; + } + + /** + * Installs an addon. + * + * @param string $addonId name of the addon + * + * @return bool true on success or false on failure + */ + public function installAddon(string $addonId): bool + { + $addonId = Strings::sanitizeFilePathItem($addonId); + + $addon_file_path = $this->getAddonPath() . '/' . $addonId . '/' . $addonId . '.php'; + + // silently fail if addon was removed or if $addonId is funky + if (!file_exists($addon_file_path)) { + return false; + } + + $this->logger->debug("Addon {addon}: {action}", ['action' => 'install', 'addon' => $addonId]); + + $timestamp = @filemtime($addon_file_path); + + @include_once($addon_file_path); + + if (function_exists($addonId . '_install')) { + $func = $addonId . '_install'; + $func(); + } + + $this->config->set('addons', $addonId, [ + 'last_update' => $timestamp, + 'admin' => function_exists($addonId . '_addon_admin'), + ]); + + if (!$this->isAddonEnabled($addonId)) { + $this->addons[] = $addonId; + } + + return true; + } + + /** + * Uninstalls an addon. + * + * @param string $addonId name of the addon + */ + public function uninstallAddon(string $addonId): void + { + $addonId = Strings::sanitizeFilePathItem($addonId); + + $this->logger->debug("Addon {addon}: {action}", ['action' => 'uninstall', 'addon' => $addonId]); + $this->config->delete('addons', $addonId); + + $addon_file_path = $this->getAddonPath() . '/' . $addonId . '/' . $addonId . '.php'; + + @include_once($addon_file_path); + + if (function_exists($addonId . '_uninstall')) { + $func = $addonId . '_uninstall'; + $func(); + } + + // Remove registered hooks for the addon + // Handles both relative and absolute file paths + $condition = ['`file` LIKE ?', "%/$addonId/$addonId.php"]; + + $result = $this->database->delete('hook', $condition); + + if ($result) { + $this->cache->delete('routerDispatchData'); + } + + unset($this->addons[array_search($addonId, $this->addons)]); + } + + /** + * Load addons. + * + * @internal + */ + public function loadAddons(): void + { + $this->addons = array_keys(array_filter($this->config->get('addons') ?? [])); + } + + /** + * Reload (uninstall and install) all installed and modified addons. + */ + public function reloadAddons(): void + { + $addons = array_filter($this->config->get('addons') ?? []); + + foreach ($addons as $addonName => $data) { + $addonId = Strings::sanitizeFilePathItem(trim($addonName)); + + $addon_file_path = $this->getAddonPath() . '/' . $addonId . '/' . $addonId . '.php'; + + if (!file_exists($addon_file_path)) { + continue; + } + + if (array_key_exists('last_update', $data) && intval($data['last_update']) === filemtime($addon_file_path)) { + // Addon unmodified, skipping + continue; + } + + $this->logger->debug("Addon {addon}: {action}", ['action' => 'reload', 'addon' => $addonId]); + + $this->uninstallAddon($addonId); + $this->installAddon($addonId); + } + } + + /** + * Get the comment block of an addon as value object. + * + * @throws \Friendica\Core\Addon\Exception\InvalidAddonException if there is an error with the addon file + */ + public function getAddonInfo(string $addonId): AddonInfo + { + $default = [ + 'id' => $addonId, + 'name' => $addonId, + ]; + + $addonFile = $this->getAddonPath() . "/$addonId/$addonId.php"; + + if (!is_file($addonFile)) { + return AddonInfo::fromArray($default); + } + + $this->profiler->startRecording('file'); + + $raw = file_get_contents($addonFile); + + $this->profiler->stopRecording(); + + if ($raw === false) { + throw new InvalidAddonException('Could not read addon file: ' . $addonFile); + } + + $result = preg_match("|/\*.*\*/|msU", $raw, $matches); + + if ($result === false || $result === 0 || !is_array($matches) || count($matches) < 1) { + throw new InvalidAddonException('Could not find valid comment block in addon file: ' . $addonFile); + } + + return AddonInfo::fromString($addonId, $matches[0]); + } + + /** + * Checks if the provided addon is enabled + */ + public function isAddonEnabled(string $addonId): bool + { + return in_array($addonId, $this->addons); + } + + /** + * Returns a list with the IDs of the enabled addons + * + * @return string[] + */ + public function getEnabledAddons(): array + { + return $this->addons; + } + + /** + * Returns a list with the IDs of the non-hidden enabled addons + * + * @return string[] + */ + public function getVisibleEnabledAddons(): array + { + $visible_addons = []; + $addons = array_filter($this->config->get('addons') ?? []); + + foreach ($addons as $name => $data) { + $visible_addons[] = $name; + } + + return $visible_addons; + } + + /** + * Returns a list with the IDs of the enabled addons that provides admin settings. + * + * @return string[] + */ + public function getEnabledAddonsWithAdminSettings(): array + { + $addons_admin = []; + $addons = array_filter($this->config->get('addons') ?? []); + + ksort($addons); + + foreach ($addons as $name => $data) { + if (array_key_exists('admin', $data) && $data['admin'] === true) { + $addons_admin[] = $name; + } + } + + return $addons_admin; + } +} diff --git a/src/Core/Addon/AddonProxy.php b/src/Core/Addon/AddonProxy.php deleted file mode 100644 index c3f7e583d8..0000000000 --- a/src/Core/Addon/AddonProxy.php +++ /dev/null @@ -1,154 +0,0 @@ -addonPath = $addonPath; - } - - /** - * Returns the absolute path to the addon folder - * - * e.g. `/var/www/html/addon` - */ - public function getAddonPath(): string - { - return $this->addonPath; - } - - /** - * Returns the list of available addons. - * - * This list is made from scanning the addon/ folder. - * Unsupported addons are excluded unless they already are enabled or system.show_unsupported_addon is set. - * - * @return string[] - */ - public function getAvailableAddons(): array - { - return array_map( - function (array $item) { - return $item[0]; - }, - Addon::getAvailableList() - ); - } - - /** - * Installs an addon. - * - * @param string $addonId name of the addon - * - * @return bool true on success or false on failure - */ - public function installAddon(string $addonId): bool - { - return Addon::install($addonId); - } - - /** - * Uninstalls an addon. - * - * @param string $addonId name of the addon - */ - public function uninstallAddon(string $addonId): void - { - Addon::uninstall($addonId); - } - - /** - * Load addons. - * - * @internal - */ - public function loadAddons(): void - { - Addon::loadAddons(); - } - - /** - * Reload (uninstall and install) all updated addons. - */ - public function reloadAddons(): void - { - Addon::reload(); - } - - /** - * Get the comment block of an addon as value object. - */ - public function getAddonInfo(string $addonId): AddonInfo - { - $data = Addon::getInfo($addonId); - - // add addon ID - $data['id'] = $addonId; - - // rename author to authors - $data['authors'] = $data['author']; - unset($data['author']); - - // rename maintainer to maintainers - $data['maintainers'] = $data['maintainer']; - unset($data['maintainer']); - - return AddonInfo::fromArray($data); - } - - /** - * Checks if the provided addon is enabled - */ - public function isAddonEnabled(string $addonId): bool - { - return Addon::isEnabled($addonId); - } - - /** - * Returns a list with the IDs of the enabled addons - * - * @return string[] - */ - public function getEnabledAddons(): array - { - return Addon::getEnabledList(); - } - - /** - * Returns a list with the IDs of the non-hidden enabled addons - * - * @return string[] - */ - public function getVisibleEnabledAddons(): array - { - return Addon::getVisibleList(); - } - - /** - * Returns a list with the IDs of the enabled addons that provides admin settings. - * - * @return string[] - */ - public function getEnabledAddonsWithAdminSettings(): array - { - return array_keys(Addon::getAdminList()); - } -} diff --git a/src/Core/Addon/Exception/InvalidAddonException.php b/src/Core/Addon/Exception/InvalidAddonException.php new file mode 100644 index 0000000000..28079bb1e1 --- /dev/null +++ b/src/Core/Addon/Exception/InvalidAddonException.php @@ -0,0 +1,17 @@ +parameters['addon']); + if (!is_file("addon/$addon/$addon.php")) { DI::sysmsg()->addNotice(DI::l10n()->t('Addon not found.')); $addonHelper->uninstallAddon($addon); @@ -91,12 +94,18 @@ class Details extends BaseAdmin $func($admin_form); } - $addonInfo = $addonHelper->getAddonInfo($addon); + try { + $addonInfo = $addonHelper->getAddonInfo($addon); + } catch (InvalidAddonException $th) { + $this->logger->error('Invalid addon found: ' . $addon, ['exception' => $th]); + DI::sysmsg()->addNotice(DI::l10n()->t('Invalid Addon found.')); + + $addonInfo = AddonInfo::fromArray(['id' => $addon, 'name' => $addon]); + } $addonAuthors = []; foreach ($addonInfo->getAuthors() as $addonAuthor) { - $addonAuthor['link'] = 'foo@bar.com'; if (array_key_exists('link', $addonAuthor) && empty(parse_url($addonAuthor['link'], PHP_URL_SCHEME))) { $contact = Contact::getByURL($addonAuthor['link'], false); diff --git a/src/Module/Admin/Addons/Index.php b/src/Module/Admin/Addons/Index.php index 6038373018..6cbd472c8c 100644 --- a/src/Module/Admin/Addons/Index.php +++ b/src/Module/Admin/Addons/Index.php @@ -7,6 +7,7 @@ namespace Friendica\Module\Admin\Addons; +use Friendica\Core\Addon\Exception\InvalidAddonException; use Friendica\Core\Renderer; use Friendica\DI; use Friendica\Module\BaseAdmin; @@ -57,7 +58,12 @@ class Index extends BaseAdmin $addons = []; foreach ($addonHelper->getAvailableAddons() as $addonId) { - $addonInfo = $addonHelper->getAddonInfo($addonId); + try { + $addonInfo = $addonHelper->getAddonInfo($addonId); + } catch (InvalidAddonException $th) { + $this->logger->error('Invalid addon found: ' . $addonId, ['exception' => $th]); + continue; + } $info = [ 'name' => $addonInfo->getName(), diff --git a/static/dependencies.config.php b/static/dependencies.config.php index 644cb5f765..04e2dd2aee 100644 --- a/static/dependencies.config.php +++ b/static/dependencies.config.php @@ -43,7 +43,7 @@ return (function(string $basepath, array $getVars, array $serverVars, array $coo ], ], \Friendica\Core\Addon\AddonHelper::class => [ - 'instanceOf' => \Friendica\Core\Addon\AddonProxy::class, + 'instanceOf' => \Friendica\Core\Addon\AddonManagerHelper::class, 'constructParams' => [ $basepath . '/addon', ], diff --git a/tests/Unit/Core/Addon/AddonInfoTest.php b/tests/Unit/Core/Addon/AddonInfoTest.php index a8e007b50a..7a342bc059 100644 --- a/tests/Unit/Core/Addon/AddonInfoTest.php +++ b/tests/Unit/Core/Addon/AddonInfoTest.php @@ -14,19 +14,111 @@ use PHPUnit\Framework\TestCase; class AddonInfoTest extends TestCase { + public function testFromStringCreatesObject(): void + { + $this->assertInstanceOf(AddonInfo::class, AddonInfo::fromString('addonId', '')); + } + + public static function getStringData(): array + { + return [ + 'minimal' => [ + 'test', + '', + ['id' => 'test'], + ], + 'without-author' => [ + 'test', + << 'test', + 'name' => 'Test Addon', + 'description' => 'adds awesome features to friendica', + + 'maintainers' => [ + ['name' => 'Robin'], + ], + 'version' => '100.4.50-beta.5', + 'status' => 'beta', + ], + ], + 'without-maintainer' => [ + 'test', + << 'test', + 'name' => 'Test Addon', + 'description' => 'adds awesome features to friendica', + 'authors' => [ + ['name' => 'Sam'], + ], + 'version' => '100.4.50-beta.5', + 'status' => 'beta', + ], + ], + 'complete' => [ + 'test', + << + * Maintainer: Robin + * Maintainer: Robin With Profile + * Status: beta + * Ignore: The "ignore" key is unsupported and will be ignored + */ + TEXT, + [ + 'id' => 'test', + 'name' => 'Test Addon', + 'description' => 'adds awesome features to friendica', + 'authors' => [ + ['name' => 'Sam'], + ['name' => 'Sam With Mail', 'link' => 'mail@example.org'], + ], + 'maintainers' => [ + ['name' => 'Robin'], + ['name' => 'Robin With Profile', 'link' => 'https://example.org/profile/robin'], + ], + 'version' => '100.4.50-beta.5', + 'status' => 'beta', + ], + ], + ]; + } + + /** + * @dataProvider getStringData + */ + public function testFromStringReturnsCorrectValues(string $addonId, string $raw, array $expected): void + { + $this->assertAddonInfoData($expected, AddonInfo::fromString($addonId, $raw)); + } + public function testFromArrayCreatesObject(): void { - $data = [ - 'id' => '', - 'name' => '', - 'description' => '', - 'authors' => [], - 'maintainers' => [], - 'version' => '', - 'status' => '', - ]; - - $this->assertInstanceOf(AddonInfo::class, AddonInfo::fromArray($data)); + $this->assertInstanceOf(AddonInfo::class, AddonInfo::fromArray([])); } public function testGetterReturningCorrectValues(): void @@ -41,15 +133,34 @@ class AddonInfoTest extends TestCase 'status' => 'In Development', ]; - $info = AddonInfo::fromArray($data); + $this->assertAddonInfoData($data, AddonInfo::fromArray($data)); + } - $this->assertSame($data['id'], $info->getId()); - $this->assertSame($data['name'], $info->getName()); - $this->assertSame($data['description'], $info->getDescription()); - $this->assertSame($data['description'], $info->getDescription()); - $this->assertSame($data['authors'], $info->getAuthors()); - $this->assertSame($data['maintainers'], $info->getMaintainers()); - $this->assertSame($data['version'], $info->getVersion()); - $this->assertSame($data['status'], $info->getStatus()); + private function assertAddonInfoData(array $expected, AddonInfo $info): void + { + $expected = array_merge( + [ + 'id' => '', + 'name' => '', + 'description' => '', + 'authors' => [], + 'maintainers' => [], + 'version' => '', + 'status' => '', + ], + $expected + ); + + $data = [ + 'id' => $info->getId(), + 'name' => $info->getName(), + 'description' => $info->getDescription(), + 'authors' => $info->getAuthors(), + 'maintainers' => $info->getMaintainers(), + 'version' => $info->getVersion(), + 'status' => $info->getStatus(), + ]; + + $this->assertSame($expected, $data); } } diff --git a/tests/Unit/Core/Addon/AddonManagerHelperTest.php b/tests/Unit/Core/Addon/AddonManagerHelperTest.php new file mode 100644 index 0000000000..882082ceb0 --- /dev/null +++ b/tests/Unit/Core/Addon/AddonManagerHelperTest.php @@ -0,0 +1,472 @@ + [ + 'helloaddon.php' => << + */ + PHP, + ] + ]); + + $addonManagerHelper = new AddonManagerHelper( + $root->url(), + $this->createStub(Database::class), + $this->createStub(IManageConfigValues::class), + $this->createStub(ICanCache::class), + $this->createStub(LoggerInterface::class), + $this->createStub(Profiler::class) + ); + + $info = $addonManagerHelper->getAddonInfo('helloaddon'); + + $this->assertInstanceOf(AddonInfo::class, $info); + + $this->assertEquals('Hello Addon', $info->getName()); + } + + public function testGetAddonInfoThrowsInvalidAddonException(): void + { + $root = vfsStream::setup(__FUNCTION__ . '_addons', 0777, [ + 'helloaddon' => [ + 'helloaddon.php' => <<url(), + $this->createStub(Database::class), + $this->createStub(IManageConfigValues::class), + $this->createStub(ICanCache::class), + $this->createStub(LoggerInterface::class), + $this->createStub(Profiler::class) + ); + + $this->expectException(InvalidAddonException::class); + $this->expectExceptionMessage('Could not find valid comment block in addon file:'); + + $addonManagerHelper->getAddonInfo('helloaddon'); + } + + public function testEnabledAddons(): void + { + $config = $this->createStub(IManageConfigValues::class); + $config->method('get')->willReturn([ + 'helloaddon' => [ + 'last_update' => 1738760499, + 'admin' => false, + ], + ]); + + $root = vfsStream::setup(__FUNCTION__ . '_addons', 0777, []); + + $addonManagerHelper = new AddonManagerHelper( + $root->url(), + $this->createStub(Database::class), + $config, + $this->createStub(ICanCache::class), + $this->createStub(LoggerInterface::class), + $this->createStub(Profiler::class) + ); + + $this->assertSame([], $addonManagerHelper->getEnabledAddons()); + $this->assertFalse($addonManagerHelper->isAddonEnabled('helloaddon')); + + $addonManagerHelper->loadAddons(); + + $this->assertSame(['helloaddon'], $addonManagerHelper->getEnabledAddons()); + $this->assertTrue($addonManagerHelper->isAddonEnabled('helloaddon')); + } + + public function testGetVisibleEnabledAddons(): void + { + $config = $this->createStub(IManageConfigValues::class); + $config->method('get')->willReturn([ + 'helloaddon' => [ + 'last_update' => 1738760499, + 'admin' => false, + ], + ]); + + $root = vfsStream::setup(__FUNCTION__ . '_addons', 0777, []); + + $addonManagerHelper = new AddonManagerHelper( + $root->url(), + $this->createStub(Database::class), + $config, + $this->createStub(ICanCache::class), + $this->createStub(LoggerInterface::class), + $this->createStub(Profiler::class) + ); + + $this->assertSame(['helloaddon'], $addonManagerHelper->getVisibleEnabledAddons()); + } + + public function testGetEnabledAddonsWithAdminSettings(): void + { + $config = $this->createStub(IManageConfigValues::class); + $config->method('get')->willReturn([ + 'helloaddon' => [ + 'last_update' => 1738760499, + 'admin' => false, + ], + 'addonwithadminsettings' => [ + 'last_update' => 1738760499, + 'admin' => true, + ], + ]); + + $root = vfsStream::setup(__FUNCTION__ . '_addons', 0777, []); + + $addonManagerHelper = new AddonManagerHelper( + $root->url(), + $this->createStub(Database::class), + $config, + $this->createStub(ICanCache::class), + $this->createStub(LoggerInterface::class), + $this->createStub(Profiler::class) + ); + + $this->assertSame(['addonwithadminsettings'], $addonManagerHelper->getEnabledAddonsWithAdminSettings()); + } + + public function testGetAvailableAddons(): void + { + $root = vfsStream::setup(__FUNCTION__ . '_addons', 0777, [ + 'helloaddon' => [ + 'helloaddon.php' => << + */ + PHP, + ], + 'invalidaddon' => [ + 'invalidaddon.php' => 'This addon should not be loaded, because it does not contain a valid comment section.', + ], + '.hidden' => [ + '.hidden.php' => 'This folder should be ignored', + ] + ]); + + $addonManagerHelper = new AddonManagerHelper( + $root->url(), + $this->createStub(Database::class), + $this->createStub(IManageConfigValues::class), + $this->createStub(ICanCache::class), + $this->createStub(LoggerInterface::class), + $this->createStub(Profiler::class) + ); + + $this->assertSame(['helloaddon'], $addonManagerHelper->getAvailableAddons()); + } + + public function testInstallAddonIncludesAddonFile(): void + { + $root = vfsStream::setup(__FUNCTION__ . '_addons', 0777, [ + 'helloaddon' => [ + 'helloaddon.php' => 'url(), + $this->createStub(Database::class), + $this->createStub(IManageConfigValues::class), + $this->createStub(ICanCache::class), + $this->createStub(LoggerInterface::class), + $this->createStub(Profiler::class) + ); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Addon file loaded'); + + $addonManagerHelper->installAddon('helloaddon'); + } + + public function testInstallAddonCallsInstallFunction(): void + { + // We need a unique name for the addon to avoid conflicts + // with other tests that may define the same install function. + $addonName = __FUNCTION__; + + $root = vfsStream::setup(__FUNCTION__ . '_addons', 0777, [ + $addonName => [ + $addonName . '.php' => <<url(), + $this->createStub(Database::class), + $this->createStub(IManageConfigValues::class), + $this->createStub(ICanCache::class), + $this->createStub(LoggerInterface::class), + $this->createStub(Profiler::class) + ); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Addon installed'); + + $addonManagerHelper->installAddon($addonName); + } + + public function testInstallAddonUpdatesConfig(): void + { + $root = vfsStream::setup(__FUNCTION__ . '_addons', 0777, [ + 'helloaddon' => [ + 'helloaddon.php' => 'getChild('helloaddon/helloaddon.php')->lastModified(1234567890); + + $config = $this->createMock(IManageConfigValues::class); + $config->expects($this->once())->method('set')->with( + 'addons', + 'helloaddon', + ['last_update' => 1234567890, 'admin' => false] + ); + + $addonManagerHelper = new AddonManagerHelper( + $root->url(), + $this->createStub(Database::class), + $config, + $this->createStub(ICanCache::class), + $this->createStub(LoggerInterface::class), + $this->createStub(Profiler::class) + ); + + $addonManagerHelper->installAddon('helloaddon'); + } + + public function testInstallAddonEnablesAddon(): void + { + $root = vfsStream::setup(__FUNCTION__ . '_addons', 0777, [ + 'helloaddon' => [ + 'helloaddon.php' => 'url(), + $this->createStub(Database::class), + $this->createStub(IManageConfigValues::class), + $this->createStub(ICanCache::class), + $this->createStub(LoggerInterface::class), + $this->createStub(Profiler::class) + ); + + $this->assertSame([], $addonManagerHelper->getEnabledAddons()); + + $this->assertTrue($addonManagerHelper->installAddon('helloaddon')); + + $this->assertSame(['helloaddon'], $addonManagerHelper->getEnabledAddons()); + } + public function testUninstallAddonIncludesAddonFile(): void + { + $root = vfsStream::setup(__FUNCTION__ . '_addons', 0777, [ + 'helloaddon' => [ + 'helloaddon.php' => 'url(), + $this->createStub(Database::class), + $this->createStub(IManageConfigValues::class), + $this->createStub(ICanCache::class), + $this->createStub(LoggerInterface::class), + $this->createStub(Profiler::class) + ); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Addon file loaded'); + + $addonManagerHelper->uninstallAddon('helloaddon'); + } + + public function testUninstallAddonCallsUninstallFunction(): void + { + // We need a unique name for the addon to avoid conflicts + // with other tests that may define the same install function. + $addonName = __FUNCTION__; + + $root = vfsStream::setup(__FUNCTION__ . '_addons', 0777, [ + $addonName => [ + $addonName . '.php' => <<url(), + $this->createStub(Database::class), + $this->createStub(IManageConfigValues::class), + $this->createStub(ICanCache::class), + $this->createStub(LoggerInterface::class), + $this->createStub(Profiler::class) + ); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Addon uninstalled'); + + $addonManagerHelper->uninstallAddon($addonName); + } + + public function testUninstallAddonRemovesHooksFromDatabase(): void + { + $root = vfsStream::setup(__FUNCTION__ . '_addons', 0777, [ + 'helloaddon' => [ + 'helloaddon.php' => 'createMock(Database::class); + $database->expects($this->once()) + ->method('delete') + ->with( + 'hook', + ['`file` LIKE ?', '%/helloaddon/helloaddon.php'] + ); + + $addonManagerHelper = new AddonManagerHelper( + $root->url(), + $database, + $this->createStub(IManageConfigValues::class), + $this->createStub(ICanCache::class), + $this->createStub(LoggerInterface::class), + $this->createStub(Profiler::class) + ); + + $addonManagerHelper->uninstallAddon('helloaddon'); + } + + public function testUninstallAddonDisablesAddon(): void + { + $root = vfsStream::setup(__FUNCTION__ . '_addons', 0777, [ + 'helloaddon' => [ + 'helloaddon.php' => 'createStub(IManageConfigValues::class); + $config->method('get')->willReturn([ + 'helloaddon' => [ + 'last_update' => 1234567890, + 'admin' => false, + ], + ]); + + $addonManagerHelper = new AddonManagerHelper( + $root->url(), + $this->createStub(Database::class), + $config, + $this->createStub(ICanCache::class), + $this->createStub(LoggerInterface::class), + $this->createStub(Profiler::class) + ); + + $addonManagerHelper->loadAddons(); + + $this->assertSame(['helloaddon'], $addonManagerHelper->getEnabledAddons()); + + $addonManagerHelper->uninstallAddon('helloaddon'); + + $this->assertSame([], $addonManagerHelper->getEnabledAddons()); + } + + public function testReloadAddonsInstallsAddon(): void + { + // We need a unique name for the addon to avoid conflicts + // with other tests that may define the same install function. + $addonName = __FUNCTION__; + + $root = vfsStream::setup(__FUNCTION__ . '_addons', 0777, [ + $addonName => [ + $addonName . '.php' => <<getChild($addonName . '/' . $addonName . '.php')->lastModified(1234567890); + + $config = $this->createStub(IManageConfigValues::class); + $config->method('get')->willReturn([ + $addonName => [ + 'last_update' => 0, + 'admin' => false, + ], + ]); + + $addonManagerHelper = new AddonManagerHelper( + $root->url(), + $this->createStub(Database::class), + $config, + $this->createStub(ICanCache::class), + $this->createStub(LoggerInterface::class), + $this->createStub(Profiler::class) + ); + + $addonManagerHelper->loadAddons(); + + $this->assertSame([$addonName], $addonManagerHelper->getEnabledAddons()); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Addon reinstalled'); + + $addonManagerHelper->reloadAddons(); + } +} diff --git a/view/lang/C/messages.po b/view/lang/C/messages.po index 6b0e5e433d..ea269e5c6f 100644 --- a/view/lang/C/messages.po +++ b/view/lang/C/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: 2025.02-dev\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-06-03 20:17+0200\n" +"POT-Creation-Date: 2025-06-04 09:41+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -2171,7 +2171,7 @@ msgstr "" msgid "Manage other pages" msgstr "" -#: src/Content/Nav.php:323 src/Module/Admin/Addons/Details.php:131 +#: src/Content/Nav.php:323 src/Module/Admin/Addons/Details.php:140 #: src/Module/Admin/Themes/Details.php:85 src/Module/BaseSettings.php:177 #: src/Module/Welcome.php:38 view/theme/frio/theme.php:231 msgid "Settings" @@ -3861,31 +3861,35 @@ msgstr "" msgid "User with delegates can't be removed, please remove delegate users first" msgstr "" -#: src/Module/Admin/Addons/Details.php:49 +#: src/Module/Admin/Addons/Details.php:52 msgid "Addon not found." msgstr "" -#: src/Module/Admin/Addons/Details.php:60 src/Module/Admin/Addons/Index.php:43 +#: src/Module/Admin/Addons/Details.php:63 src/Module/Admin/Addons/Index.php:44 #, php-format msgid "Addon %s disabled." msgstr "" -#: src/Module/Admin/Addons/Details.php:63 src/Module/Admin/Addons/Index.php:45 +#: src/Module/Admin/Addons/Details.php:66 src/Module/Admin/Addons/Index.php:46 #, php-format msgid "Addon %s enabled." msgstr "" -#: src/Module/Admin/Addons/Details.php:72 +#: src/Module/Admin/Addons/Details.php:75 #: src/Module/Admin/Themes/Details.php:38 msgid "Disable" msgstr "" -#: src/Module/Admin/Addons/Details.php:75 +#: src/Module/Admin/Addons/Details.php:78 #: src/Module/Admin/Themes/Details.php:41 src/Module/Settings/Display.php:341 msgid "Enable" msgstr "" -#: src/Module/Admin/Addons/Details.php:128 src/Module/Admin/Addons/Index.php:77 +#: src/Module/Admin/Addons/Details.php:101 +msgid "Invalid Addon found." +msgstr "" + +#: src/Module/Admin/Addons/Details.php:137 src/Module/Admin/Addons/Index.php:83 #: src/Module/Admin/Federation.php:213 src/Module/Admin/Logs/Settings.php:74 #: src/Module/Admin/Logs/View.php:71 src/Module/Admin/Queue.php:59 #: src/Module/Admin/Site.php:446 src/Module/Admin/Storage.php:124 @@ -3896,36 +3900,36 @@ msgstr "" msgid "Administration" msgstr "" -#: src/Module/Admin/Addons/Details.php:129 src/Module/Admin/Addons/Index.php:78 +#: src/Module/Admin/Addons/Details.php:138 src/Module/Admin/Addons/Index.php:84 #: src/Module/BaseAdmin.php:77 src/Module/BaseSettings.php:127 msgid "Addons" msgstr "" -#: src/Module/Admin/Addons/Details.php:130 +#: src/Module/Admin/Addons/Details.php:139 #: src/Module/Admin/Themes/Details.php:84 msgid "Toggle" msgstr "" -#: src/Module/Admin/Addons/Details.php:143 +#: src/Module/Admin/Addons/Details.php:152 #: src/Module/Admin/Themes/Details.php:92 msgid "Author: " msgstr "" -#: src/Module/Admin/Addons/Details.php:144 +#: src/Module/Admin/Addons/Details.php:153 #: src/Module/Admin/Themes/Details.php:93 msgid "Maintainer: " msgstr "" -#: src/Module/Admin/Addons/Index.php:35 +#: src/Module/Admin/Addons/Index.php:36 msgid "Addons reloaded" msgstr "" -#: src/Module/Admin/Addons/Index.php:47 +#: src/Module/Admin/Addons/Index.php:48 #, php-format msgid "Addon %s failed to install." msgstr "" -#: src/Module/Admin/Addons/Index.php:79 src/Module/Admin/Features.php:69 +#: src/Module/Admin/Addons/Index.php:85 src/Module/Admin/Features.php:69 #: src/Module/Admin/Logs/Settings.php:76 src/Module/Admin/Site.php:449 #: src/Module/Admin/Themes/Index.php:105 src/Module/Admin/Tos.php:72 #: src/Module/Settings/Account.php:507 src/Module/Settings/Addons.php:64 @@ -3937,11 +3941,11 @@ msgstr "" msgid "Save Settings" msgstr "" -#: src/Module/Admin/Addons/Index.php:80 +#: src/Module/Admin/Addons/Index.php:86 msgid "Reload active addons" msgstr "" -#: src/Module/Admin/Addons/Index.php:84 +#: src/Module/Admin/Addons/Index.php:90 #, php-format msgid "There are currently no addons available on your node. You can find the official addon repository at %1$s." msgstr ""