diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index 5606bb2c97..fd6cfd7841 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -5098,8 +5098,9 @@ - + getSupportedApps + countUsers diff --git a/core/Application.php b/core/Application.php index d9d6b92a2a..bda271c41f 100644 --- a/core/Application.php +++ b/core/Application.php @@ -39,7 +39,7 @@ use OC\Authentication\Listeners\RemoteWipeNotificationsListener; use OC\Authentication\Listeners\UserDeletedStoreCleanupListener; use OC\Authentication\Listeners\UserDeletedTokenCleanupListener; use OC\Authentication\Notifications\Notifier as AuthenticationNotifier; -use OC\Core\Notification\RemoveLinkSharesNotifier; +use OC\Core\Notification\CoreNotifier; use OC\DB\MissingColumnInformation; use OC\DB\MissingIndexInformation; use OC\DB\MissingPrimaryKeyInformation; @@ -71,7 +71,7 @@ class Application extends App { $eventDispatcher = $server->query(IEventDispatcher::class); $notificationManager = $server->getNotificationManager(); - $notificationManager->registerNotifierService(RemoveLinkSharesNotifier::class); + $notificationManager->registerNotifierService(CoreNotifier::class); $notificationManager->registerNotifierService(AuthenticationNotifier::class); $oldEventDispatcher = $server->getEventDispatcher(); diff --git a/core/Notification/RemoveLinkSharesNotifier.php b/core/Notification/CoreNotifier.php similarity index 81% rename from core/Notification/RemoveLinkSharesNotifier.php rename to core/Notification/CoreNotifier.php index 52a71fced2..dd362dac8c 100644 --- a/core/Notification/RemoveLinkSharesNotifier.php +++ b/core/Notification/CoreNotifier.php @@ -7,6 +7,7 @@ declare(strict_types=1); * * @author Christoph Wurst * @author Joas Schilling + * @author Morris Jobke * @author Roeland Jago Douma * * @license GNU AGPL version 3 or any later version @@ -32,7 +33,7 @@ use OCP\L10N\IFactory; use OCP\Notification\INotification; use OCP\Notification\INotifier; -class RemoveLinkSharesNotifier implements INotifier { +class CoreNotifier implements INotifier { /** @var IFactory */ private $l10nFactory; @@ -73,6 +74,13 @@ class RemoveLinkSharesNotifier implements INotifier { return $notification; } + if ($notification->getSubject() === 'user_limit_reached') { + $notification->setParsedSubject($l->t('The user limit of this instance is reached.')); + $notification->setParsedMessage($l->t('Add a subscription key to increase the user limit of this instance. For more information have a look at the Enterprise subscription page.')); + $notification->setLink('https://nextcloud.com/enterprise/order/'); + return $notification; + } + throw new \InvalidArgumentException('Invalid subject'); } } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index f841ef0755..fe5abfb475 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -927,7 +927,7 @@ return array( 'OC\\Core\\Migrations\\Version20000Date20201109081918' => $baseDir . '/core/Migrations/Version20000Date20201109081918.php', 'OC\\Core\\Migrations\\Version20000Date20201109081919' => $baseDir . '/core/Migrations/Version20000Date20201109081919.php', 'OC\\Core\\Migrations\\Version20000Date20201111081915' => $baseDir . '/core/Migrations/Version20000Date20201111081915.php', - 'OC\\Core\\Notification\\RemoveLinkSharesNotifier' => $baseDir . '/core/Notification/RemoveLinkSharesNotifier.php', + 'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php', 'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php', 'OC\\DB\\Adapter' => $baseDir . '/lib/private/DB/Adapter.php', 'OC\\DB\\AdapterMySQL' => $baseDir . '/lib/private/DB/AdapterMySQL.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index c587a7505f..664ab007a9 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -956,7 +956,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OC\\Core\\Migrations\\Version20000Date20201109081918' => __DIR__ . '/../../..' . '/core/Migrations/Version20000Date20201109081918.php', 'OC\\Core\\Migrations\\Version20000Date20201109081919' => __DIR__ . '/../../..' . '/core/Migrations/Version20000Date20201109081919.php', 'OC\\Core\\Migrations\\Version20000Date20201111081915' => __DIR__ . '/../../..' . '/core/Migrations/Version20000Date20201111081915.php', - 'OC\\Core\\Notification\\RemoveLinkSharesNotifier' => __DIR__ . '/../../..' . '/core/Notification/RemoveLinkSharesNotifier.php', + 'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php', 'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php', 'OC\\DB\\Adapter' => __DIR__ . '/../../..' . '/lib/private/DB/Adapter.php', 'OC\\DB\\AdapterMySQL' => __DIR__ . '/../../..' . '/lib/private/DB/AdapterMySQL.php', diff --git a/lib/private/Support/Subscription/Registry.php b/lib/private/Support/Subscription/Registry.php index 3d6a9b09d8..72bae2adc8 100644 --- a/lib/private/Support/Subscription/Registry.php +++ b/lib/private/Support/Subscription/Registry.php @@ -28,13 +28,18 @@ declare(strict_types=1); namespace OC\Support\Subscription; +use OC\User\Backend; use OCP\AppFramework\QueryException; use OCP\IConfig; +use OCP\IGroupManager; use OCP\IServerContainer; +use OCP\IUserManager; +use OCP\Notification\IManager; use OCP\Support\Subscription\Exception\AlreadyRegisteredException; use OCP\Support\Subscription\IRegistry; use OCP\Support\Subscription\ISubscription; use OCP\Support\Subscription\ISupportedApps; +use Psr\Log\LoggerInterface; class Registry implements IRegistry { @@ -49,10 +54,27 @@ class Registry implements IRegistry { /** @var IServerContainer */ private $container; + /** @var IUserManager */ + private $userManager; + /** @var IGroupManager */ + private $groupManager; + /** @var LoggerInterface */ + private $logger; + /** @var IManager */ + private $notificationManager; - public function __construct(IConfig $config, IServerContainer $container) { + public function __construct(IConfig $config, + IServerContainer $container, + IUserManager $userManager, + IGroupManager $groupManager, + LoggerInterface $logger, + IManager $notificationManager) { $this->config = $config; $this->container = $container; + $this->userManager = $userManager; + $this->groupManager = $groupManager; + $this->logger = $logger; + $this->notificationManager = $notificationManager; } private function getSubscription(): ?ISubscription { @@ -127,9 +149,87 @@ class Registry implements IRegistry { * @since 17.0.0 */ public function delegateHasExtendedSupport(): bool { - if ($this->getSubscription() instanceof ISubscription && method_exists($this->subscription, 'hasExtendedSupport')) { + if ($this->getSubscription() instanceof ISubscription) { return $this->getSubscription()->hasExtendedSupport(); } return false; } + + + /** + * Indicates if a hard user limit is reached and no new users should be created + * + * @since 21.0.0 + */ + public function delegateIsHardUserLimitReached(): bool { + $subscription = $this->getSubscription(); + if ($subscription instanceof ISubscription && + $subscription->hasValidSubscription()) { + $userLimitReached = $subscription->isHardUserLimitReached(); + if ($userLimitReached) { + $this->notifyAboutReachedUserLimit(); + } + return $userLimitReached; + } + + $isOneClickInstance = $this->config->getSystemValueBool('one-click-instance', false); + + if (!$isOneClickInstance) { + return false; + } + + $userCount = $this->getUserCount(); + $hardUserLimit = $this->config->getSystemValue('one-click-instance.user-limit', 50); + + $userLimitReached = $userCount >= $hardUserLimit; + if ($userLimitReached) { + $this->notifyAboutReachedUserLimit(); + } + return $userLimitReached; + } + + private function getUserCount(): int { + $userCount = 0; + $backends = $this->userManager->getBackends(); + foreach ($backends as $backend) { + if ($backend->implementsActions(Backend::COUNT_USERS)) { + $backendUsers = $backend->countUsers(); + if ($backendUsers !== false) { + $userCount += $backendUsers; + } else { + // TODO what if the user count can't be determined? + $this->logger->warning('Can not determine user count for ' . get_class($backend), ['app' => 'lib']); + } + } + } + + $disabledUsers = $this->config->getUsersForUserValue('core', 'enabled', 'false'); + $disabledUsersCount = count($disabledUsers); + $userCount = $userCount - $disabledUsersCount; + + if ($userCount < 0) { + $userCount = 0; + + // this should never happen + $this->logger->warning("Total user count was negative (users: $userCount, disabled: $disabledUsersCount)", ['app' => 'lib']); + } + + return $userCount; + } + + private function notifyAboutReachedUserLimit() { + $admins = $this->groupManager->get('admin')->getUsers(); + foreach ($admins as $admin) { + $notification = $this->notificationManager->createNotification(); + + $notification->setApp('core') + ->setUser($admin->getUID()) + ->setDateTime(new \DateTime()) + ->setObject('user_limit_reached', '1') + ->setSubject('user_limit_reached'); + $this->notificationManager->notify($notification); + } + + $this->logger->warning('The user limit was reached and the new user was not created', ['app' => 'lib']); + } } diff --git a/lib/private/User/Manager.php b/lib/private/User/Manager.php index 1201a456ce..1d58c68268 100644 --- a/lib/private/User/Manager.php +++ b/lib/private/User/Manager.php @@ -34,6 +34,7 @@ namespace OC\User; +use OC\HintException; use OC\Hooks\PublicEmitter; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; @@ -42,6 +43,7 @@ use OCP\IGroup; use OCP\IUser; use OCP\IUserBackend; use OCP\IUserManager; +use OCP\Support\Subscription\IRegistry; use OCP\User\Backend\IGetRealUIDBackend; use OCP\User\Events\BeforeUserCreatedEvent; use OCP\User\Events\UserCreatedEvent; @@ -297,6 +299,12 @@ class Manager extends PublicEmitter implements IUserManager { * @return bool|IUser the created user or false */ public function createUser($uid, $password) { + // DI injection is not used here as IRegistry needs the user manager itself for user count and thus it would create a cyclic dependency + if (\OC::$server->get(IRegistry::class)->delegateIsHardUserLimitReached()) { + $l = \OC::$server->getL10N('lib'); + throw new HintException($l->t('The user limit has been reached and the user was not created.')); + } + $localBackends = []; foreach ($this->backends as $backend) { if ($backend instanceof Database) { diff --git a/lib/public/Support/Subscription/IRegistry.php b/lib/public/Support/Subscription/IRegistry.php index f3d6cf2f92..ab1d00fa82 100644 --- a/lib/public/Support/Subscription/IRegistry.php +++ b/lib/public/Support/Subscription/IRegistry.php @@ -78,4 +78,11 @@ interface IRegistry { * @since 17.0.0 */ public function delegateHasExtendedSupport(): bool; + + /** + * Indicates if a hard user limit is reached and no new users should be created + * + * @since 21.0.0 + */ + public function delegateIsHardUserLimitReached(): bool; } diff --git a/lib/public/Support/Subscription/ISubscription.php b/lib/public/Support/Subscription/ISubscription.php index 83b7509b9f..9614c0ed77 100644 --- a/lib/public/Support/Subscription/ISubscription.php +++ b/lib/public/Support/Subscription/ISubscription.php @@ -45,4 +45,11 @@ interface ISubscription { * @since 17.0.0 */ public function hasExtendedSupport(): bool; + + /** + * Indicates if a hard user limit is reached and no new users should be created + * + * @since 21.0.0 + */ + public function isHardUserLimitReached(): bool; } diff --git a/tests/lib/Support/Subscription/DummySubscription.php b/tests/lib/Support/Subscription/DummySubscription.php index e1f7f5c6b6..f4e7e3484b 100644 --- a/tests/lib/Support/Subscription/DummySubscription.php +++ b/tests/lib/Support/Subscription/DummySubscription.php @@ -30,6 +30,8 @@ class DummySubscription implements \OCP\Support\Subscription\ISubscription { private $hasValidSubscription; /** @var bool */ private $hasExtendedSupport; + /** @var bool */ + private $isHardUserLimitReached; /** * DummySubscription constructor. @@ -37,9 +39,10 @@ class DummySubscription implements \OCP\Support\Subscription\ISubscription { * @param bool $hasValidSubscription * @param bool $hasExtendedSupport */ - public function __construct(bool $hasValidSubscription, bool $hasExtendedSupport) { + public function __construct(bool $hasValidSubscription, bool $hasExtendedSupport, bool $isHardUserLimitReached) { $this->hasValidSubscription = $hasValidSubscription; $this->hasExtendedSupport = $hasExtendedSupport; + $this->isHardUserLimitReached = $isHardUserLimitReached; } /** @@ -55,4 +58,8 @@ class DummySubscription implements \OCP\Support\Subscription\ISubscription { public function hasExtendedSupport(): bool { return $this->hasExtendedSupport; } + + public function isHardUserLimitReached(): bool { + return $this->isHardUserLimitReached; + } } diff --git a/tests/lib/Support/Subscription/RegistryTest.php b/tests/lib/Support/Subscription/RegistryTest.php index c070f69ae6..95759e0954 100644 --- a/tests/lib/Support/Subscription/RegistryTest.php +++ b/tests/lib/Support/Subscription/RegistryTest.php @@ -24,10 +24,15 @@ namespace Test\Support\Subscription; use OC\Support\Subscription\Registry; use OCP\IConfig; +use OCP\IGroup; +use OCP\IGroupManager; use OCP\IServerContainer; +use OCP\IUserManager; +use OCP\Notification\IManager; use OCP\Support\Subscription\ISubscription; use OCP\Support\Subscription\ISupportedApps; use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; use Test\TestCase; class RegistryTest extends TestCase { @@ -41,12 +46,35 @@ class RegistryTest extends TestCase { /** @var MockObject|IServerContainer */ private $serverContainer; + /** @var MockObject|IUserManager */ + private $userManager; + + /** @var MockObject|IGroupManager */ + private $groupManager; + + /** @var MockObject|LoggerInterface */ + private $logger; + + /** @var MockObject|IManager */ + private $notificationManager; + protected function setUp(): void { parent::setUp(); $this->config = $this->createMock(IConfig::class); $this->serverContainer = $this->createMock(IServerContainer::class); - $this->registry = new Registry($this->config, $this->serverContainer); + $this->userManager = $this->createMock(IUserManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->notificationManager = $this->createMock(IManager::class); + $this->registry = new Registry( + $this->config, + $this->serverContainer, + $this->userManager, + $this->groupManager, + $this->logger, + $this->notificationManager + ); } /** @@ -121,10 +149,91 @@ class RegistryTest extends TestCase { public function testSubscriptionService() { $this->serverContainer->method('query') ->with(DummySubscription::class) - ->willReturn(new DummySubscription(true, false)); + ->willReturn(new DummySubscription(true, false, false)); $this->registry->registerService(DummySubscription::class); $this->assertTrue($this->registry->delegateHasValidSubscription()); $this->assertFalse($this->registry->delegateHasExtendedSupport()); } + + public function testDelegateIsHardUserLimitReached() { + /* @var ISubscription|\PHPUnit\Framework\MockObject\MockObject $subscription */ + $subscription = $this->createMock(ISubscription::class); + $subscription->expects($this->once()) + ->method('hasValidSubscription') + ->willReturn(true); + $subscription->expects($this->once()) + ->method('isHardUserLimitReached') + ->willReturn(true); + $this->registry->register($subscription); + $dummyGroup = $this->createMock(IGroup::class); + $dummyGroup->expects($this->once()) + ->method('getUsers') + ->willReturn([]); + $this->groupManager->expects($this->once()) + ->method('get') + ->willReturn($dummyGroup); + + $this->assertSame(true, $this->registry->delegateIsHardUserLimitReached()); + } + + public function testDelegateIsHardUserLimitReachedWithoutSupportApp() { + $this->config->expects($this->once()) + ->method('getSystemValueBool') + ->with('one-click-instance') + ->willReturn(false); + + $this->assertSame(false, $this->registry->delegateIsHardUserLimitReached()); + } + + public function dataForUserLimitCheck() { + return [ + // $userLimit, $userCount, $disabledUsers, $expectedResult + [35, 15, 2, false], + [35, 45, 15, false], + [35, 45, 5, true], + [35, 45, 55, false], + ]; + } + + /** + * @dataProvider dataForUserLimitCheck + */ + public function testDelegateIsHardUserLimitReachedWithoutSupportAppAndUserCount($userLimit, $userCount, $disabledUsers, $expectedResult) { + $this->config->expects($this->once()) + ->method('getSystemValueBool') + ->with('one-click-instance') + ->willReturn(true); + $this->config->expects($this->once()) + ->method('getSystemValue') + ->with('one-click-instance.user-limit') + ->willReturn($userLimit); + $this->config->expects($this->once()) + ->method('getUsersForUserValue') + ->with('core', 'enabled', 'false') + ->willReturn(array_fill(0, $disabledUsers, '')); + /* @var UserInterface|\PHPUnit\Framework\MockObject\MockObject $dummyBackend */ + $dummyBackend = $this->createMock(UserInterface::class); + $dummyBackend->expects($this->once()) + ->method('implementsActions') + ->willReturn(true); + $dummyBackend->expects($this->once()) + ->method('countUsers') + ->willReturn($userCount); + $this->userManager->expects($this->once()) + ->method('getBackends') + ->willReturn([$dummyBackend]); + + if ($expectedResult) { + $dummyGroup = $this->createMock(IGroup::class); + $dummyGroup->expects($this->once()) + ->method('getUsers') + ->willReturn([]); + $this->groupManager->expects($this->once()) + ->method('get') + ->willReturn($dummyGroup); + } + + $this->assertSame($expectedResult, $this->registry->delegateIsHardUserLimitReached()); + } }