This commit is contained in:
Artur Weigandt 2025-05-25 18:26:47 +02:00 committed by GitHub
commit 95e490782b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1643 additions and 4 deletions

View file

@ -0,0 +1,50 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Addon;
use Friendica\Addon\Event\AddonStartEvent;
/**
* Interface an Addon has to implement.
*/
interface AddonBootstrap
{
/**
* Returns an array with the FQCN of required services.
*
* Example:
*
* ```php
* return [LoggerInterface::class];
* ```
*/
public function getRequiredDependencies(): array;
/**
* Return an array of events to subscribe to.
*
* The keys MUST be the event name.
* The values MUST be the method of the implementing class to call.
*
* Example:
*
* ```php
* return [Event::NAME => 'onEvent'];
* ```
*
* @return array<string, string>
*/
public function getSubscribedEvents(): array;
/**
* Init the addon with the required dependencies.
*/
public function initAddon(AddonStartEvent $event): void;
}

View file

@ -0,0 +1,26 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Addon;
/**
* Interface that Addon can provide some dependencies and/or strategies.
*/
interface DependencyProvider
{
/**
* Returns an array of Dice rules.
*/
public function provideDependencyRules(): array;
/**
* Returns an array of strategy rules.
*/
public function provideStrategyRules(): array;
}

View file

@ -0,0 +1,30 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Addon\Event;
use Psr\Container\ContainerInterface;
/**
* Start an addon.
*/
final class AddonStartEvent
{
private ContainerInterface $container;
public function __construct(ContainerInterface $container)
{
$this->container = $container;
}
public function getContainer(): ContainerInterface
{
return $this->container;
}
}

View file

@ -0,0 +1,26 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Addon;
/**
* Interface for an Addon that has to be installed.
*/
interface InstallableAddon
{
/**
* Runs after AddonBootstrap::initAddon()
*/
public function install(): void;
/**
* Runs after AddonBootstrap::initAddon()
*/
public function uninstall(): void;
}

View file

@ -44,6 +44,8 @@ use Friendica\Protocol\ATProtocol\DID;
use Friendica\Security\Authentication; use Friendica\Security\Authentication;
use Friendica\Security\ExAuth; use Friendica\Security\ExAuth;
use Friendica\Security\OpenWebAuth; use Friendica\Security\OpenWebAuth;
use Friendica\Service\Addon\AddonContainer;
use Friendica\Service\Addon\AddonManager;
use Friendica\Util\BasePath; use Friendica\Util\BasePath;
use Friendica\Util\DateTimeFormat; use Friendica\Util\DateTimeFormat;
use Friendica\Util\HTTPInputData; use Friendica\Util\HTTPInputData;
@ -134,6 +136,8 @@ class App
*/ */
private $appHelper; private $appHelper;
private AddonManager $addonManager;
private function __construct(Container $container) private function __construct(Container $container)
{ {
$this->container = $container; $this->container = $container;
@ -150,7 +154,7 @@ class App
], ],
]); ]);
$this->setupContainerForAddons(); $this->setupAddons();
$this->setupLogChannel(LogChannel::APP); $this->setupLogChannel(LogChannel::APP);
@ -207,7 +211,7 @@ class App
{ {
$argv = $serverParams['argv'] ?? []; $argv = $serverParams['argv'] ?? [];
$this->setupContainerForAddons(); $this->setupAddons();
$this->setupLogChannel($this->determineLogChannel($argv)); $this->setupLogChannel($this->determineLogChannel($argv));
@ -239,7 +243,7 @@ class App
*/ */
public function processEjabberd(array $serverParams): void public function processEjabberd(array $serverParams): void
{ {
$this->setupContainerForAddons(); $this->setupAddons();
$this->setupLogChannel(LogChannel::AUTH_JABBERED); $this->setupLogChannel(LogChannel::AUTH_JABBERED);
@ -276,7 +280,7 @@ class App
} }
} }
private function setupContainerForAddons(): void private function setupAddons(): void
{ {
/** @var ICanLoadAddons $addonLoader */ /** @var ICanLoadAddons $addonLoader */
$addonLoader = $this->container->create(ICanLoadAddons::class); $addonLoader = $this->container->create(ICanLoadAddons::class);
@ -284,6 +288,27 @@ class App
foreach ($addonLoader->getActiveAddonConfig('dependencies') as $name => $rule) { foreach ($addonLoader->getActiveAddonConfig('dependencies') as $name => $rule) {
$this->container->addRule($name, $rule); $this->container->addRule($name, $rule);
} }
$config = $this->container->create(IManageConfigValues::class);
$this->addonManager = $this->container->create(AddonManager::class);
$this->addonManager->bootstrapAddons($config->get('addons') ?? []);
// At this place we should be careful because addons can change the container
// Maybe we should create a new container especially for the addons
foreach ($this->addonManager->getProvidedDependencyRules() as $name => $rule) {
$this->container->addRule($name, $rule);
}
$containers = [];
foreach ($this->addonManager->getRequiredDependencies() as $addonId => $dependencies) {
// @TODO At this point we can filter or restrict the dependencies of addons
$containers[$addonId] = AddonContainer::fromContainer($this->container, $dependencies);
}
$this->addonManager->initAddons($containers);
} }
private function determineLogChannel(array $argv): string private function determineLogChannel(array $argv): string
@ -330,6 +355,10 @@ class App
foreach (HookEventBridge::getStaticSubscribedEvents() as $eventName => $methodName) { foreach (HookEventBridge::getStaticSubscribedEvents() as $eventName => $methodName) {
$eventDispatcher->addListener($eventName, [HookEventBridge::class, $methodName]); $eventDispatcher->addListener($eventName, [HookEventBridge::class, $methodName]);
} }
foreach ($this->addonManager->getAllSubscribedEvents() as $listener) {
$eventDispatcher->addListener($listener[0], $listener[1]);
}
} }
private function registerTemplateEngine(): void private function registerTemplateEngine(): void

View file

@ -0,0 +1,113 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\EventSubscriber;
use Friendica\Core\Hook;
use Friendica\Event\ArrayFilterEvent;
use Friendica\Event\ConfigLoadedEvent;
use Friendica\Event\Event;
use Friendica\Event\HtmlFilterEvent;
use Friendica\Event\NamedEvent;
/**
* Bridge between the EventDispatcher and the Hook class.
*
* @internal Provides BC
*/
final class HookEventBridge
{
/**
* @internal This allows us to mock the Hook call in tests.
*
* @var \Closure|null
*/
private static $mockedCallHook = null;
/**
* This maps the new event names to the legacy Hook names.
*/
private static array $eventMapper = [
Event::INIT => 'init_1',
ConfigLoadedEvent::CONFIG_LOADED => 'load_config',
ArrayFilterEvent::APP_MENU => 'app_menu',
ArrayFilterEvent::NAV_INFO => 'nav_info',
ArrayFilterEvent::FEATURE_ENABLED => 'isEnabled',
ArrayFilterEvent::FEATURE_GET => 'get',
HtmlFilterEvent::HEAD => 'head',
HtmlFilterEvent::FOOTER => 'footer',
HtmlFilterEvent::PAGE_HEADER => 'page_header',
HtmlFilterEvent::PAGE_CONTENT_TOP => 'page_content_top',
HtmlFilterEvent::PAGE_END => 'page_end',
];
/**
* @return array<string, string>
*/
public static function getStaticSubscribedEvents(): array
{
return [
Event::INIT => 'onNamedEvent',
ConfigLoadedEvent::CONFIG_LOADED => 'onConfigLoadedEvent',
ArrayFilterEvent::APP_MENU => 'onArrayFilterEvent',
ArrayFilterEvent::NAV_INFO => 'onArrayFilterEvent',
ArrayFilterEvent::FEATURE_ENABLED => 'onArrayFilterEvent',
ArrayFilterEvent::FEATURE_GET => 'onArrayFilterEvent',
HtmlFilterEvent::HEAD => 'onHtmlFilterEvent',
HtmlFilterEvent::FOOTER => 'onHtmlFilterEvent',
HtmlFilterEvent::PAGE_HEADER => 'onHtmlFilterEvent',
HtmlFilterEvent::PAGE_CONTENT_TOP => 'onHtmlFilterEvent',
HtmlFilterEvent::PAGE_END => 'onHtmlFilterEvent',
];
}
public static function onNamedEvent(NamedEvent $event): void
{
static::callHook($event->getName(), '');
}
public static function onConfigLoadedEvent(ConfigLoadedEvent $event): void
{
static::callHook($event->getName(), $event->getConfig());
}
public static function onArrayFilterEvent(ArrayFilterEvent $event): void
{
$event->setArray(
static::callHook($event->getName(), $event->getArray())
);
}
public static function onHtmlFilterEvent(HtmlFilterEvent $event): void
{
$event->setHtml(
static::callHook($event->getName(), $event->getHtml())
);
}
/**
* @param string|array|object $data
*
* @return string|array|object
*/
private static function callHook(string $name, $data)
{
// If possible, map the event name to the legacy Hook name
$name = static::$eventMapper[$name] ?? $name;
// Little hack to allow mocking the Hook call in tests.
if (static::$mockedCallHook instanceof \Closure) {
return (static::$mockedCallHook)->__invoke($name, $data);
}
Hook::callAll($name, $data);
return $data;
}
}

View file

@ -0,0 +1,32 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Service\Addon;
use Psr\Container\ContainerInterface;
/**
* Interface to communicate with an addon.
*/
interface Addon
{
public function getId(): string;
public function getRequiredDependencies(): array;
public function getSubscribedEvents(): array;
public function getProvidedDependencyRules(): array;
public function initAddon(ContainerInterface $container): void;
public function installAddon(): void;
public function uninstallAddon(): void;
}

View file

@ -0,0 +1,75 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Service\Addon;
use Friendica\Core\Container;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* Subset of the Container for an addon.
*/
final class AddonContainer implements ContainerInterface
{
public static function fromContainer(Container $container, array $allowedServices): self
{
return new self($container, $allowedServices);
}
private Container $container;
private array $allowedServices;
private function __construct(Container $container, array $allowedServices)
{
$this->container = $container;
$this->allowedServices = $allowedServices;
}
/**
* Finds an entry of the container by its identifier and returns it.
*
* @param string $id Identifier of the entry to look for.
*
* @throws \Psr\Container\NotFoundExceptionInterface No entry was found for **this** identifier.
* @throws \Psr\Container\ContainerExceptionInterface Error while retrieving the entry.
*
* @return mixed Entry.
*/
public function get(string $id)
{
if ($this->has($id)) {
return $this->container->create($id);
}
$message = sprintf(
'No entry was found for "%s"',
$id,
);
throw new class ($message) extends \RuntimeException implements NotFoundExceptionInterface {};
}
/**
* Returns true if the container can return an entry for the given identifier.
* Returns false otherwise.
*
* `has($id)` returning true does not mean that `get($id)` will not throw an exception.
* It does however mean that `get($id)` will not throw a `NotFoundExceptionInterface`.
*
* @param string $id Identifier of the entry to look for.
*
* @return bool
*/
public function has(string $id): bool
{
return in_array($id, $this->allowedServices);
}
}

View file

@ -0,0 +1,74 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Service\Addon;
use Friendica\Addon\AddonBootstrap;
use Psr\Log\LoggerInterface;
/**
* Factory for all addons.
*/
final class AddonFactory implements AddonLoader
{
private string $addonPath;
private LoggerInterface $logger;
/** @var Addon[] */
private array $addons = [];
public function __construct(string $addonPath, LoggerInterface $logger)
{
$this->addonPath = $addonPath;
$this->logger = $logger;
}
/**
* @return Addon[] Returns an array of Addon instances.
*/
public function getAddons(array $addonNames): array
{
foreach ($addonNames as $addonName => $addonDetails) {
try {
$this->addons[$addonName] = $this->bootstrapAddon($addonName);
} catch (\Throwable $th) {
// @TODO Here we can check if we have a Legacy addon and try to load it
// throw $th;
}
}
return $this->addons;
}
private function bootstrapAddon(string $addonName): Addon
{
$bootstrapFile = sprintf('%s/%s/bootstrap.php', $this->addonPath, $addonName);
if (!file_exists($bootstrapFile)) {
throw new \RuntimeException(sprintf('Bootstrap file for addon "%s" not found.', $addonName));
}
try {
$bootstrap = require $bootstrapFile;
} catch (\Throwable $th) {
throw new \RuntimeException(sprintf('Something went wrong loading the Bootstrap file for addon "%s".', $addonName), $th->getCode(), $th);
}
if (!($bootstrap instanceof AddonBootstrap)) {
throw new \RuntimeException(sprintf('Bootstrap file for addon "%s" MUST return an instance of AddonBootstrap.', $addonName));
}
$addon = new AddonProxy($addonName, $bootstrap);
$this->logger->info(sprintf('Addon "%s" loaded.', $addonName));
return $addon;
}
}

View file

@ -0,0 +1,21 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Service\Addon;
/**
* Interface for an addon loader.
*/
interface AddonLoader
{
/**
* @return Addon[] Returns an array of Addon instances.
*/
public function getAddons(array $addonNames): array;
}

View file

@ -0,0 +1,84 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Service\Addon;
use Psr\Container\ContainerInterface;
/**
* Manager for all addons.
*/
final class AddonManager
{
private AddonLoader $addonFactory;
/** @var Addon[] */
private array $addons = [];
public function __construct(AddonLoader $addonFactory)
{
$this->addonFactory = $addonFactory;
}
public function bootstrapAddons(array $addonNames): void
{
$this->addons = $this->addonFactory->getAddons($addonNames);
}
public function getRequiredDependencies(): array
{
$dependencies = [];
foreach ($this->addons as $addon) {
// @TODO Here we can filter or deny dependencies from addons
$dependencies[$addon->getId()] = $addon->getRequiredDependencies();
}
return $dependencies;
}
public function getProvidedDependencyRules(): array
{
$dependencyRules = [];
foreach ($this->addons as $addon) {
// @TODO At this point we can handle duplicate rules and handle possible conflicts
$dependencyRules = array_merge($dependencyRules, $addon->getProvidedDependencyRules());
}
return $dependencyRules;
}
public function getAllSubscribedEvents(): array
{
$events = [];
foreach ($this->addons as $addon) {
$events = array_merge($events, $addon->getSubscribedEvents());
}
return $events;
}
/**
* @param ContainerInterface[] $containers
*/
public function initAddons(array $containers): void
{
foreach ($this->addons as $addon) {
$container = $containers[$addon->getId()] ?? null;
if ($container === null) {
throw new \RuntimeException(sprintf('Container for addon "%s" is missing.', $addon->getId()));
}
$addon->initAddon($container);
}
}
}

View file

@ -0,0 +1,91 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Service\Addon;
use Friendica\Addon\AddonBootstrap;
use Friendica\Addon\DependencyProvider;
use Friendica\Addon\Event\AddonStartEvent;
use Friendica\Addon\InstallableAddon;
use Psr\Container\ContainerInterface;
/**
* Proxy object for an addon.
*/
final class AddonProxy implements Addon
{
private string $id;
private AddonBootstrap $bootstrap;
private bool $isInit = false;
public function __construct(string $id, AddonBootstrap $bootstrap)
{
$this->id = $id;
$this->bootstrap = $bootstrap;
}
public function getId(): string
{
return $this->id;
}
public function getRequiredDependencies(): array
{
return $this->bootstrap->getRequiredDependencies();
}
public function getSubscribedEvents(): array
{
$events = [];
foreach ($this->bootstrap->getSubscribedEvents() as $eventName => $methodName) {
$events[] = [$eventName, [$this->bootstrap, $methodName]];
}
return $events;
}
public function getProvidedDependencyRules(): array
{
if ($this->bootstrap instanceof DependencyProvider) {
return $this->bootstrap->provideDependencyRules();
}
return [];
}
public function initAddon(ContainerInterface $container): void
{
if ($this->isInit) {
return;
}
$this->isInit = true;
$event = new AddonStartEvent($container);
$this->bootstrap->initAddon($event);
}
public function installAddon(): void
{
if ($this->bootstrap instanceof InstallableAddon) {
$this->bootstrap->install();
}
}
public function uninstallAddon(): void
{
if ($this->bootstrap instanceof InstallableAddon) {
$this->bootstrap->uninstall();
}
}
}

View file

@ -0,0 +1,81 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Service\Addon;
use Psr\Container\ContainerInterface;
/**
* Proxy object for a legacy addon.
*/
final class LegacyAddonProxy implements Addon
{
private string $id;
private string $path;
private bool $isInit = false;
public function __construct(string $id, string $path)
{
$this->id = $id;
$this->path = $path;
}
public function getId(): string
{
return $this->id;
}
public function getRequiredDependencies(): array
{
return [];
}
public function getSubscribedEvents(): array
{
return [];
}
public function getProvidedDependencyRules(): array
{
$fileName = $this->path . '/' . $this->id . '/static/dependencies.config.php';
if (is_file($fileName)) {
return include_once($fileName);
}
return [];
}
public function initAddon(ContainerInterface $container): void
{
if ($this->isInit) {
return;
}
$this->isInit = true;
include_once($this->path . '/' . $this->id . '/' . $this->id . '.php');
}
public function installAddon(): void
{
if (function_exists($this->id . '_install')) {
call_user_func($this->id . '_install');
}
}
public function uninstallAddon(): void
{
if (function_exists($this->id . '_uninstall')) {
call_user_func($this->id . '_uninstall');
}
}
}

View file

@ -48,6 +48,12 @@ return (function(string $basepath, array $getVars, array $serverVars, array $coo
$basepath . '/addon', $basepath . '/addon',
], ],
], ],
\Friendica\Service\Addon\AddonLoader::class => [
'instanceOf' => \Friendica\Service\Addon\AddonFactory::class,
'constructParams' => [
$basepath . '/addon',
],
],
\Friendica\Util\BasePath::class => [ \Friendica\Util\BasePath::class => [
'constructParams' => [ 'constructParams' => [
$basepath, $basepath,

View file

@ -0,0 +1,176 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Test\Unit\EventSubscriber;
use Friendica\Core\Config\Util\ConfigFileManager;
use Friendica\Event\ArrayFilterEvent;
use Friendica\Event\ConfigLoadedEvent;
use Friendica\Event\Event;
use Friendica\Event\HtmlFilterEvent;
use Friendica\EventSubscriber\HookEventBridge;
use PHPUnit\Framework\TestCase;
class HookEventBridgeTest extends TestCase
{
public function testGetStaticSubscribedEventsReturnsStaticMethods(): void
{
$expected = [
Event::INIT => 'onNamedEvent',
ConfigLoadedEvent::CONFIG_LOADED => 'onConfigLoadedEvent',
ArrayFilterEvent::APP_MENU => 'onArrayFilterEvent',
ArrayFilterEvent::NAV_INFO => 'onArrayFilterEvent',
ArrayFilterEvent::FEATURE_ENABLED => 'onArrayFilterEvent',
ArrayFilterEvent::FEATURE_GET => 'onArrayFilterEvent',
HtmlFilterEvent::HEAD => 'onHtmlFilterEvent',
HtmlFilterEvent::FOOTER => 'onHtmlFilterEvent',
HtmlFilterEvent::PAGE_HEADER => 'onHtmlFilterEvent',
HtmlFilterEvent::PAGE_CONTENT_TOP => 'onHtmlFilterEvent',
HtmlFilterEvent::PAGE_END => 'onHtmlFilterEvent',
];
$this->assertSame(
$expected,
HookEventBridge::getStaticSubscribedEvents()
);
foreach ($expected as $methodName) {
$this->assertTrue(
method_exists(HookEventBridge::class, $methodName),
$methodName . '() is not defined'
);
$this->assertTrue(
(new \ReflectionMethod(HookEventBridge::class, $methodName))->isStatic(),
$methodName . '() is not static'
);
}
}
public static function getNamedEventData(): array
{
return [
['test', 'test'],
[Event::INIT, 'init_1'],
];
}
/**
* @dataProvider getNamedEventData
*/
public function testOnNamedEventCallsHook($name, $expected): void
{
$event = new Event($name);
$reflectionProperty = new \ReflectionProperty(HookEventBridge::class, 'mockedCallHook');
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue(null, function (string $name, $data) use ($expected) {
$this->assertSame($expected, $name);
$this->assertSame('', $data);
return $data;
});
HookEventBridge::onNamedEvent($event);
}
public static function getConfigLoadedEventData(): array
{
return [
['test', 'test'],
[ConfigLoadedEvent::CONFIG_LOADED, 'load_config'],
];
}
/**
* @dataProvider getConfigLoadedEventData
*/
public function testOnConfigLoadedEventCallsHookWithCorrectValue($name, $expected): void
{
$config = $this->createStub(ConfigFileManager::class);
$event = new ConfigLoadedEvent($name, $config);
$reflectionProperty = new \ReflectionProperty(HookEventBridge::class, 'mockedCallHook');
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue(null, function (string $name, $data) use ($expected, $config) {
$this->assertSame($expected, $name);
$this->assertSame($config, $data);
return $data;
});
HookEventBridge::onConfigLoadedEvent($event);
}
public static function getArrayFilterEventData(): array
{
return [
['test', 'test'],
[ArrayFilterEvent::APP_MENU, 'app_menu'],
[ArrayFilterEvent::NAV_INFO, 'nav_info'],
[ArrayFilterEvent::FEATURE_ENABLED, 'isEnabled'],
[ArrayFilterEvent::FEATURE_GET, 'get'],
];
}
/**
* @dataProvider getArrayFilterEventData
*/
public function testOnArrayFilterEventCallsHookWithCorrectValue($name, $expected): void
{
$event = new ArrayFilterEvent($name, ['original']);
$reflectionProperty = new \ReflectionProperty(HookEventBridge::class, 'mockedCallHook');
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue(null, function (string $name, $data) use ($expected) {
$this->assertSame($expected, $name);
$this->assertSame(['original'], $data);
return $data;
});
HookEventBridge::onArrayFilterEvent($event);
}
public static function getHtmlFilterEventData(): array
{
return [
['test', 'test'],
[HtmlFilterEvent::HEAD, 'head'],
[HtmlFilterEvent::FOOTER, 'footer'],
[HtmlFilterEvent::PAGE_HEADER, 'page_header'],
[HtmlFilterEvent::PAGE_CONTENT_TOP, 'page_content_top'],
[HtmlFilterEvent::PAGE_END, 'page_end'],
];
}
/**
* @dataProvider getHtmlFilterEventData
*/
public function testOnHtmlFilterEventCallsHookWithCorrectValue($name, $expected): void
{
$event = new HtmlFilterEvent($name, 'original');
$reflectionProperty = new \ReflectionProperty(HookEventBridge::class, 'mockedCallHook');
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue(null, function (string $name, $data) use ($expected) {
$this->assertSame($expected, $name);
$this->assertSame('original', $data);
return $data;
});
HookEventBridge::onHtmlFilterEvent($event);
}
}

View file

@ -0,0 +1,77 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Test\Unit\Service\Addon;
use Friendica\Core\Container;
use Friendica\Service\Addon\AddonContainer;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
class AddonContainerTest extends TestCase
{
public function testAddonContainerImplementsContainerInterface(): void
{
$container = AddonContainer::fromContainer(
$this->createStub(Container::class),
[]
);
$this->assertInstanceOf(ContainerInterface::class, $container);
}
public function testHasReturnsFalse(): void
{
$container = AddonContainer::fromContainer(
$this->createStub(Container::class),
[]
);
$this->assertFalse($container->has('foo'));
}
public function testHasReturnsTrue(): void
{
$container = AddonContainer::fromContainer(
$this->createStub(Container::class),
['foo']
);
$this->assertTrue($container->has('foo'));
}
public function testGetReturnsEntry(): void
{
$object = new \stdClass();
$parent = $this->createMock(Container::class);
$parent->expects($this->once())->method('create')->with('foo')->willReturn($object);
$container = AddonContainer::fromContainer(
$parent,
['foo']
);
$this->assertSame($object, $container->get('foo'));
}
public function testGetThrowsNotFoundExceptionInterface(): void
{
$container = AddonContainer::fromContainer(
$this->createStub(Container::class),
[]
);
$this->expectException(NotFoundExceptionInterface::class);
$this->expectExceptionMessage('No entry was found for "foo"');
$container->get('foo');
}
}

View file

@ -0,0 +1,45 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Test\Unit\Service\Addon;
use Friendica\Service\Addon\Addon;
use Friendica\Service\Addon\AddonFactory;
use Friendica\Service\Addon\AddonLoader;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class AddonFactoryTest extends TestCase
{
public function testAddonFactoryImplementsAddonLoaderInterface(): void
{
$factory = new AddonFactory(
dirname(__DIR__, 3) . '/Util',
$this->createStub(LoggerInterface::class)
);
$this->assertInstanceOf(AddonLoader::class, $factory);
}
public function testLoadAddonsLoadsTheAddon(): void
{
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info')->with('Addon "helloaddon" loaded.');
$factory = new AddonFactory(
dirname(__DIR__, 3) . '/Util',
$logger
);
$addons = $factory->getAddons(['helloaddon' => []]);
$this->assertArrayHasKey('helloaddon', $addons);
$this->assertInstanceOf(Addon::class, $addons['helloaddon']);
}
}

View file

@ -0,0 +1,66 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Test\Unit\Service\Addon;
use Friendica\Event\HtmlFilterEvent;
use Friendica\Service\Addon\Addon;
use Friendica\Service\Addon\AddonLoader;
use Friendica\Service\Addon\AddonManager;
use PHPUnit\Framework\TestCase;
class AddonManagerTest extends TestCase
{
public function testBootstrapAddonsLoadsTheAddon(): void
{
$loader = $this->createMock(AddonLoader::class);
$loader->expects($this->once())->method('getAddons')->willReturn(['helloaddon' => $this->createMock(Addon::class)]);
$manager = new AddonManager($loader);
$manager->bootstrapAddons(['helloaddon' => []]);
}
public function testGetAllSubscribedEventsReturnsEvents(): void
{
$addon = $this->createStub(Addon::class);
$addon->method('getSubscribedEvents')->willReturn([[HtmlFilterEvent::PAGE_END, [Addon::class, 'onPageEnd']]]);
$loader = $this->createStub(AddonLoader::class);
$loader->method('getAddons')->willReturn(['helloaddon' => $addon]);
$manager = new AddonManager($loader);
$manager->bootstrapAddons(['helloaddon' => []]);
$this->assertSame(
[[HtmlFilterEvent::PAGE_END, [Addon::class, 'onPageEnd']]],
$manager->getAllSubscribedEvents()
);
}
public function testGetRequiredDependenciesReturnsArray(): void
{
$addon = $this->createStub(Addon::class);
$addon->method('getId')->willReturn('helloaddon');
$addon->method('getRequiredDependencies')->willReturn(['foo', 'bar']);
$loader = $this->createStub(AddonLoader::class);
$loader->method('getAddons')->willReturn(['helloaddon' => $addon]);
$manager = new AddonManager($loader);
$manager->bootstrapAddons(['helloaddon' => []]);
$this->assertSame(
['helloaddon' => ['foo', 'bar']],
$manager->getRequiredDependencies()
);
}
}

View file

@ -0,0 +1,151 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Test\Unit\Service\Addon;
use Friendica\Addon\AddonBootstrap;
use Friendica\Addon\DependencyProvider;
use Friendica\Addon\Event\AddonStartEvent;
use Friendica\Addon\InstallableAddon;
use Friendica\Service\Addon\Addon;
use Friendica\Service\Addon\AddonProxy;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
/**
* Helper interface to combine AddonBootstrap and DependencyProvider.
*/
interface CombinedAddonBootstrapDependencyProvider extends AddonBootstrap, DependencyProvider
{
}
/**
* Helper interface to combine AddonBootstrap and InstallableAddon.
*/
interface CombinedAddonBootstrapInstallableAddon extends AddonBootstrap, InstallableAddon
{
}
class AddonProxyTest extends TestCase
{
public function testCreateWithAddonBootstrap(): void
{
$bootstrap = $this->createStub(AddonBootstrap::class);
$addon = new AddonProxy('id', $bootstrap);
$this->assertInstanceOf(Addon::class, $addon);
}
public function testGetIdReturnsId(): void
{
$bootstrap = $this->createStub(AddonBootstrap::class);
$addon = new AddonProxy('id', $bootstrap);
$this->assertSame('id', $addon->getId());
}
public function testGetRequiredDependenciesCallsBootstrap(): void
{
$bootstrap = $this->createMock(AddonBootstrap::class);
$bootstrap->expects($this->once())->method('getRequiredDependencies')->willReturn([]);
$addon = new AddonProxy('id', $bootstrap);
$addon->getRequiredDependencies();
}
public function testGetProvidedDependencyRulesCallsBootstrap(): void
{
$bootstrap = $this->createMock(CombinedAddonBootstrapDependencyProvider::class);
$bootstrap->expects($this->once())->method('provideDependencyRules')->willReturn([]);
$addon = new AddonProxy('id', $bootstrap);
$addon->getProvidedDependencyRules();
}
public function testGetSubscribedEventsCallsBootstrap(): void
{
$bootstrap = $this->createMock(AddonBootstrap::class);
$bootstrap->expects($this->once())->method('getSubscribedEvents')->willReturn(['foo' => 'bar']);
$addon = new AddonProxy('id', $bootstrap);
$this->assertSame(
[
['foo', [$bootstrap, 'bar']],
],
$addon->getSubscribedEvents()
);
}
public function testInitAddonCallsBootstrap(): void
{
$bootstrap = $this->createMock(AddonBootstrap::class);
$bootstrap->expects($this->once())->method('initAddon')->willReturnCallback(function ($event) {
$this->assertInstanceOf(AddonStartEvent::class, $event);
});
$addon = new AddonProxy('id', $bootstrap);
$addon->initAddon($this->createStub(ContainerInterface::class));
}
public function testInitAddonMultipleTimesWillCallBootstrapOnce(): void
{
$bootstrap = $this->createMock(AddonBootstrap::class);
$bootstrap->expects($this->once())->method('initAddon')->willReturnCallback(function ($event) {
$this->assertInstanceOf(AddonStartEvent::class, $event);
});
$addon = new AddonProxy('id', $bootstrap);
$addon->initAddon($this->createStub(ContainerInterface::class));
$addon->initAddon($this->createStub(ContainerInterface::class));
}
public function testInitAddonCallsBootstrapWithDependencies(): void
{
$container = $this->createStub(ContainerInterface::class);
$bootstrap = $this->createMock(AddonBootstrap::class);
$bootstrap->expects($this->once())->method('initAddon')->willReturnCallback(function (AddonStartEvent $event) use ($container) {
$this->assertSame($container, $event->getContainer());
});
$addon = new AddonProxy('id', $bootstrap);
$addon->initAddon($container);
}
public function testInstallAddonCallsBootstrap(): void
{
$bootstrap = $this->createMock(CombinedAddonBootstrapInstallableAddon::class);
$bootstrap->expects($this->once())->method('install');
$addon = new AddonProxy('id', $bootstrap);
$addon->initAddon($this->createStub(ContainerInterface::class));
$addon->installAddon();
}
public function testUninstallAddonCallsBootstrap(): void
{
$bootstrap = $this->createMock(CombinedAddonBootstrapInstallableAddon::class);
$bootstrap->expects($this->once())->method('uninstall');
$addon = new AddonProxy('id', $bootstrap);
$addon->initAddon($this->createStub(ContainerInterface::class));
$addon->uninstallAddon();
}
}

View file

@ -0,0 +1,162 @@
<?php
// Copyright (C) 2010-2024, the Friendica project
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
namespace Friendica\Test\Unit\Service\Addon;
use Friendica\Service\Addon\Addon;
use Friendica\Service\Addon\LegacyAddonProxy;
use org\bovigo\vfs\vfsStream;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
class LegacyAddonProxyTest extends TestCase
{
public function testCreateWithIdAndPath(): void
{
$root = vfsStream::setup('addons', 0777, ['helloaddon' => []]);
$addon = new LegacyAddonProxy('helloaddon', $root->url());
$this->assertInstanceOf(Addon::class, $addon);
}
public function testGetIdReturnsId(): void
{
$root = vfsStream::setup('addons', 0777, ['helloaddon' => []]);
$addon = new LegacyAddonProxy('helloaddon', $root->url());
$this->assertSame('helloaddon', $addon->getId());
}
public function testGetRequiredDependenciesReturnsEmptyArray(): void
{
$root = vfsStream::setup('addons', 0777, ['helloaddon' => []]);
$addon = new LegacyAddonProxy('helloaddon', $root->url());
$this->assertSame(
[],
$addon->getRequiredDependencies()
);
}
public function testGetProvidedDependencyIncludesConfigFile(): void
{
$root = vfsStream::setup('addons_4', 0777, ['helloaddon' => ['static' => []]]);
vfsStream::newFile('dependencies.config.php')
->at($root->getChild('helloaddon/static'))
->setContent('<?php return [\'name\' => []];');
$addon = new LegacyAddonProxy('helloaddon', $root->url());
$this->assertSame(
['name' => []],
$addon->getProvidedDependencyRules()
);
}
public function testGetSubscribedEventsReturnsEmptyArray(): void
{
$root = vfsStream::setup('addons', 0777, ['helloaddon' => []]);
$addon = new LegacyAddonProxy('helloaddon', $root->url());
$this->assertSame(
[],
$addon->getSubscribedEvents()
);
}
public function testInitAddonIncludesAddonFile(): void
{
$root = vfsStream::setup('addons_1', 0777, ['helloaddon' => []]);
vfsStream::newFile('helloaddon.php')
->at($root->getChild('helloaddon'))
->setContent('<?php throw new \Exception("Addon loaded");');
$addon = new LegacyAddonProxy('helloaddon', $root->url());
try {
$addon->initAddon($this->createStub(ContainerInterface::class));
} catch (\Throwable $th) {
$this->assertSame(
'Addon loaded',
$th->getMessage()
);
}
}
public function testInitAddonMultipleTimesWillIncludeFileOnlyOnce(): void
{
$root = vfsStream::setup('addons_2', 0777, ['helloaddon' => []]);
vfsStream::newFile('helloaddon.php')
->at($root->getChild('helloaddon'))
->setContent('<?php throw new \Exception("Addon loaded");');
$addon = new LegacyAddonProxy('helloaddon', $root->url());
try {
$addon->initAddon($this->createStub(ContainerInterface::class));
} catch (\Exception $th) {
$this->assertSame(
'Addon loaded',
$th->getMessage()
);
}
$addon->initAddon($this->createStub(ContainerInterface::class));
$addon->initAddon($this->createStub(ContainerInterface::class));
}
public function testInstallAddonWillCallInstallFunction(): void
{
$root = vfsStream::setup('addons_3', 0777, ['helloaddon' => []]);
vfsStream::newFile('helloaddon.php')
->at($root->getChild('helloaddon'))
->setContent('<?php function helloaddon_install() { throw new \Exception("Addon installed"); }');
$addon = new LegacyAddonProxy('helloaddon', $root->url());
$addon->initAddon($this->createStub(ContainerInterface::class));
try {
$addon->installAddon();
} catch (\Exception $th) {
$this->assertSame(
'Addon installed',
$th->getMessage()
);
}
}
public function testUninstallAddonWillCallUninstallFunction(): void
{
$root = vfsStream::setup('addons_4', 0777, ['helloaddon' => []]);
vfsStream::newFile('helloaddon.php')
->at($root->getChild('helloaddon'))
->setContent('<?php function helloaddon_uninstall() { throw new \Exception("Addon uninstalled"); }');
$addon = new LegacyAddonProxy('helloaddon', $root->url());
$addon->initAddon($this->createStub(ContainerInterface::class));
try {
$addon->uninstallAddon();
} catch (\Exception $th) {
$this->assertSame(
'Addon uninstalled',
$th->getMessage()
);
}
}
}

View file

@ -0,0 +1,49 @@
<?php
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* Name: Hello Addon
* Description: For testing purpose only
* Version: 1.0
* Author: Artur Weigandt <dont-mail-me@example.com>
*/
declare(strict_types=1);
/**
* PSR-4 autoloader.
*
* @param string $class The fully-qualified class name.
*/
spl_autoload_register(function (string $class): void {
// addon namespace prefix
$prefix = 'FriendicaAddons\\HelloAddon\\';
// base directory for the namespace prefix
$base_dir = __DIR__ . '/src/';
// does the class use the namespace prefix?
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
// no, move to the next registered autoloader
return;
}
// get the relative class name
$relative_class = substr($class, $len);
// replace the namespace prefix with the base directory, replace namespace
// separators with directory separators in the relative class name, append
// with .php
$file = $base_dir . str_replace('\\', '/', $relative_class) . '.php';
// if the file exists, require it
if (file_exists($file)) {
require $file;
}
});
return new \FriendicaAddons\HelloAddon\HelloAddon();

View file

@ -0,0 +1,29 @@
<?php
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* Name: Hello Addon
* Description: For testing purpose only
* Version: 1.0
* Author: Artur Weigandt <dont-mail-me@example.com>
*/
use Friendica\Core\Hook;
function helloaddon_install()
{
Hook::register('page_end', 'addon/helloaddon/helloaddon.php', 'helloaddon_page_end');
}
function helloaddon_uninstall()
{
Hook::unregister('page_end', 'addon/helloaddon/helloaddon.php', 'helloaddon_page_end');
}
function helloaddon_page_end(&$html)
{
$html .= '<p>Hello, World!</p>';
}

View file

@ -0,0 +1,117 @@
<?php
// SPDX-FileCopyrightText: 2010-2024 the Friendica project
//
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* Name: Hello Addon
* Description: For testing purpose only
* Version: 1.0
* Author: Artur Weigandt <dont-mail-me@example.com>
*/
declare(strict_types=1);
namespace FriendicaAddons\HelloAddon;
use Friendica\Addon\AddonBootstrap;
use Friendica\Addon\DependencyProvider;
use Friendica\Addon\Event\AddonStartEvent;
use Friendica\Addon\InstallableAddon;
use Friendica\Event\HtmlFilterEvent;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
class HelloAddon implements AddonBootstrap, DependencyProvider, InstallableAddon
{
private LoggerInterface $logger;
/**
* Returns an array of services that are required by this addon.
*
* The array should contain FQCN of the required services.
*
* The dependencies will be passed as a PSR-11 Container to the initAddon() method via AddonStartEvent::getContainer().
*/
public function getRequiredDependencies(): array
{
return [
LoggerInterface::class,
];
}
/**
* Return an array of events to subscribe to.
*
* The keys MUST be the event name.
* The values MUST be the method of the implementing class to call.
*
* Example:
*
* ```php
* return [Event::NAME => 'onEvent'];
* ```
*
* @return array<string, string>
*/
public function getSubscribedEvents(): array
{
return [
HtmlFilterEvent::PAGE_END => 'onPageEnd',
];
}
/**
* Returns an array of Dice rules.
*/
public function provideDependencyRules(): array
{
// replaces require($path_to_dependencies_file);
return [
LoggerInterface::class => [
'instanceOf' => NullLogger::class,
'call' => null,
],
];
}
/**
* Returns an array of strategy rules.
*/
public function provideStrategyRules(): array
{
// replaces require($path_to_strategies_file);
return [];
}
public function initAddon(AddonStartEvent $event): void
{
$container = $event->getContainer();
$this->logger = $container->get(LoggerInterface::class);
$this->logger->info('Hello from HelloAddon');
}
/**
* Runs after AddonBootstrap::initAddon()
*/
public function install(): void
{
$this->logger->info('HelloAddon installed');
}
/**
* Runs after AddonBootstrap::initAddon()
*/
public function uninstall(): void
{
$this->logger->info('HelloAddon uninstalled');
}
public function onPageEnd(HtmlFilterEvent $event): void
{
$event->setHtml($event->getHtml() . '<p>Hello, World!</p>');
}
}

View file

@ -0,0 +1,29 @@
<?php
/**
* @copyright Copyright (C) 2010-2024, the Friendica project
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
return [
\Psr\Log\LoggerInterface::class => [
'instanceOf' => \Psr\Log\NullLogger::class,
'call' => null,
],
];