diff --git a/src/Core/Cache/Type/DatabaseCache.php b/src/Core/Cache/Type/DatabaseCache.php index efb9a8d03e..8f5b554606 100644 --- a/src/Core/Cache/Type/DatabaseCache.php +++ b/src/Core/Cache/Type/DatabaseCache.php @@ -11,6 +11,8 @@ use Friendica\Core\Cache\Capability\ICanCache; use Friendica\Core\Cache\Enum; use Friendica\Core\Cache\Exception\CachePersistenceException; use Friendica\Database\Database; +use Friendica\DI; +use Friendica\Repository\CacheRepository; use Friendica\Util\DateTimeFormat; /** @@ -25,11 +27,16 @@ class DatabaseCache extends AbstractCache implements ICanCache */ private $dba; + private CacheRepository $cacheRepo; + public function __construct(string $hostname, Database $dba) { parent::__construct($hostname); $this->dba = $dba; + + // #TODO: Replace this with constuctor injection + $this->cacheRepo = DI::databaseService()->getCacheRepository(); } /** @@ -41,27 +48,14 @@ class DatabaseCache extends AbstractCache implements ICanCache { try { if (empty($prefix)) { - $where = ['`expires` >= ?', DateTimeFormat::utcNow()]; + $keys = $this->cacheRepo->getAllKeysValidUntil(DateTimeFormat::utcNow()); } else { - $where = ['`expires` >= ? AND `k` LIKE CONCAT(?, \'%\')', DateTimeFormat::utcNow(), $prefix]; + $keys = $this->cacheRepo->getAllKeysValidUntilWithPrefix(DateTimeFormat::utcNow(), $prefix); } - - $stmt = $this->dba->select('cache', ['k'], $where); } catch (\Exception $exception) { throw new CachePersistenceException(sprintf('Cannot fetch all keys with prefix %s', $prefix), $exception); } - try { - $keys = []; - while ($key = $this->dba->fetch($stmt)) { - array_push($keys, $key['k']); - } - } catch (\Exception $exception) { - $this->dba->close($stmt); - throw new CachePersistenceException(sprintf('Cannot fetch all keys with prefix %s', $prefix), $exception); - } - - $this->dba->close($stmt); return $keys; } diff --git a/src/DI.php b/src/DI.php index bcbc17651b..fa9dfbda78 100644 --- a/src/DI.php +++ b/src/DI.php @@ -80,6 +80,11 @@ abstract class DI return self::$dice->create(AppHelper::class); } + public static function databaseService(): Database\DatabaseService + { + return self::$dice->create(Database\DatabaseService::class); + } + /** * @return Database\Database */ diff --git a/src/Database/Database.php b/src/Database/Database.php index 88aa636fd3..a2200d9340 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -62,15 +62,15 @@ class Database protected $server_info = ''; /** @var PDO|mysqli|null */ protected $connection; - protected $driver = ''; - protected $pdo_emulate_prepares = false; - private $error = ''; - private $errorno = 0; - private $affected_rows = 0; - protected $in_transaction = false; - protected $in_retrial = false; - protected $testmode = false; - private $relation = []; + protected $driver = ''; + protected $pdo_emulate_prepares = false; + private $error = ''; + private $errorno = 0; + private $affected_rows = 0; + protected $in_transaction = false; + protected $in_retrial = false; + private bool $throwExceptionsOnErrors = false; + private $relation = []; /** @var DbaDefinition */ protected $dbaDefinition; /** @var ViewDefinition */ @@ -205,9 +205,18 @@ class Database return $this->connected; } - public function setTestmode(bool $test) + /** + * Should errors throwns as exceptions? + * + * @return bool returns the previous value + */ + public function throwExceptionsOnErrors(bool $throwExceptions): bool { - $this->testmode = $test; + $prev = $this->throwExceptionsOnErrors; + + $this->throwExceptionsOnErrors = $throwExceptions; + + return $prev; } /** @@ -672,7 +681,7 @@ class Database $error = $this->error; $errorno = $this->errorno; - if ($this->testmode) { + if ($this->throwExceptionsOnErrors) { throw new DatabaseException($error, $errorno, $this->replaceParameters($sql, $args)); } @@ -779,7 +788,7 @@ class Database $error = $this->error; $errorno = $this->errorno; - if ($this->testmode) { + if ($this->throwExceptionsOnErrors) { throw new DatabaseException($error, $errorno, $this->replaceParameters($sql, $params)); } diff --git a/src/Database/DatabaseService.php b/src/Database/DatabaseService.php new file mode 100644 index 0000000000..eafa7cbf4a --- /dev/null +++ b/src/Database/DatabaseService.php @@ -0,0 +1,36 @@ +database = $database; + } + + public function getDeletedUserRepository(): DeletedUserRepository + { + return new UserdTableRepository($this->database); + } + + public function getCacheRepository(): CacheRepository + { + return new CacheTableRepository($this->database); + } +} diff --git a/src/Database/Model/CacheModel.php b/src/Database/Model/CacheModel.php new file mode 100644 index 0000000000..9987645616 --- /dev/null +++ b/src/Database/Model/CacheModel.php @@ -0,0 +1,83 @@ +k = (string) $data['k']; + $entity->v = $rawValue; + $entity->value = $value; + array_key_exists('expired', $data) ?? $entity->expired = (string) $data['expired']; + array_key_exists('updated', $data) ?? $entity->updated = (string) $data['updated']; + + return $entity; + } + + /** + * cache key + */ + private string $k = ''; + + /** + * cached serialized value + */ + private string $v = ''; + + /** + * + * @var mixed $value cached unserialized value + */ + private $value; + + /** + * datetime of cache expiration + */ + private string $expired = DBA::NULL_DATETIME; + + /** + * datetime of cache insertion + */ + private string $updated = DBA::NULL_DATETIME; + + private function __construct() {} + + /** + * @return mixed + */ + public function getValue() + { + return $this->value; + } +} diff --git a/src/Database/Repository/CacheTableRepository.php b/src/Database/Repository/CacheTableRepository.php new file mode 100644 index 0000000000..1f0b278ec3 --- /dev/null +++ b/src/Database/Repository/CacheTableRepository.php @@ -0,0 +1,135 @@ +database = $database; + } + + /** + * @throws DatabaseException + * + * @return array + */ + public function getAllKeysValidUntil(string $expires): array + { + $throw = $this->database->throwExceptionsOnErrors(true); + + try { + return $this->getAllKeys($expires, null); + } catch (Throwable $th) { + if (! $th instanceof DatabaseException) { + $th = new DatabaseException('Cannot fetch all keys without prefix', 0, '', $th); + } + + throw $th; + } finally { + $this->database->throwExceptionsOnErrors($throw); + } + } + + /** + * @throws DatabaseException + * + * @return array + */ + public function getAllKeysValidUntilWithPrefix(string $expires, string $prefix): array + { + $throw = $this->database->throwExceptionsOnErrors(true); + + try { + return $this->getAllKeys($expires, $prefix); + } catch (Throwable $th) { + if (! $th instanceof DatabaseException) { + $th = new DatabaseException(sprintf('Cannot fetch all keys with prefix `%s`', $prefix), 0, '', $th); + } + + throw $th; + } finally { + $this->database->throwExceptionsOnErrors($throw); + } + } + + /** + * @throws DatabaseException + * + * @return CacheEntity|null + */ + public function findOneByKeyValidUntil(string $key, string $expires) + { + $throw = $this->database->throwExceptionsOnErrors(true); + + try { + $cacheArray = $this->database->selectFirst( + 'cache', + ['v'], + ['`k` = ? AND (`expires` >= ? OR `expires` = -1)', $key, $expires] + ); + + if (!$this->database->isResult($cacheArray)) { + return null; + } + } catch (Throwable $th) { + if (! $th instanceof DatabaseException) { + $th = new DatabaseException(sprintf('Cannot get cache entry with key `%s`', $key), 0, '', $th); + } + + throw $th; + } finally { + $this->database->throwExceptionsOnErrors($throw); + } + + try { + $entity = CacheModel::createFromArray($cacheArray); + } catch (Throwable $th) { + return null; + } + + return $entity; + } + + private function getAllKeys(string $expires, ?string $prefix = null): array + { + if ($prefix === null) { + $where = ['`expires` >= ?', $expires]; + } else { + $where = ['`expires` >= ? AND `k` LIKE CONCAT(?, \'%\')', $expires, $prefix]; + } + + $stmt = $this->database->select('cache', ['k'], $where); + + $keys = []; + + try { + while ($key = $this->database->fetch($stmt)) { + array_push($keys, $key['k']); + } + } finally { + $this->database->close($stmt); + } + + return $keys; + } +} diff --git a/src/Database/Repository/UserdTableRepository.php b/src/Database/Repository/UserdTableRepository.php new file mode 100644 index 0000000000..9095bbfaaa --- /dev/null +++ b/src/Database/Repository/UserdTableRepository.php @@ -0,0 +1,54 @@ +database = $database; + } + + /** + * Insert a deleted user by username. + * + * @throws DatabaseException If the username could not be inserted + */ + public function insertByUsername(string $username): void + { + $throw = $this->database->throwExceptionsOnErrors(true); + + try { + $this->database->insert('userd', ['username' => $username]); + } finally { + $this->database->throwExceptionsOnErrors($throw); + } + } + + /** + * Check if a deleted username exists. + * + * @throws \Exception + */ + public function existsByUsername(string $username): bool + { + return $this->database->exists('userd', ['username' => $username]); + } +} diff --git a/src/Entity/CacheEntity.php b/src/Entity/CacheEntity.php new file mode 100644 index 0000000000..d32baee105 --- /dev/null +++ b/src/Entity/CacheEntity.php @@ -0,0 +1,21 @@ +getDeletedUserRepository(); + // List of possible actor names $possible_accounts = ['friendica', 'actor', 'system', 'internal']; foreach ($possible_accounts as $name) { - if (!DBA::exists('user', ['nickname' => $name]) && !DBA::exists('userd', ['username' => $name])) { + if (!DBA::exists('user', ['nickname' => $name]) && !$userDeletedRepository->existsByUsername($name)) { DI::config()->set('system', 'actor_name', $name); return $name; } @@ -1299,10 +1301,12 @@ class User throw new Exception(DI::l10n()->t('Your nickname can only contain a-z, 0-9 and _.')); } + $userDeletedRepository = DI::databaseService()->getDeletedUserRepository(); + // Check existing and deleted accounts for this nickname. if ( DBA::exists('user', ['nickname' => $nickname]) - || DBA::exists('userd', ['username' => $nickname]) + || $userDeletedRepository->existsByUsername($nickname) ) { throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.')); } @@ -1812,9 +1816,15 @@ class User $user = $hook_data['user'] ?? $user; + $userDeletedRepository = DI::databaseService()->getDeletedUserRepository(); + // save username (actually the nickname as it is guaranteed // unique), so it cannot be re-registered in the future. - DBA::insert('userd', ['username' => $user['nickname']]); + try { + $userDeletedRepository->insertByUsername($user['nickname']); + } catch (\Throwable $th) { + DI::logger()->error('Error while inserting username of deleted user.', ['username' => $user['nickname'], 'exception' => $th]); + } // Remove all personal settings, especially connector settings DBA::delete('pconfig', ['uid' => $uid]); diff --git a/src/Module/Profile/Profile.php b/src/Module/Profile/Profile.php index 58f829907e..d437f0579d 100644 --- a/src/Module/Profile/Profile.php +++ b/src/Module/Profile/Profile.php @@ -35,6 +35,7 @@ use Friendica\Network\HTTPException; use Friendica\Network\HTTPException\InternalServerErrorException; use Friendica\Profile\ProfileField\Repository\ProfileField; use Friendica\Protocol\ActivityPub; +use Friendica\Repository\DeletedUserRepository; use Friendica\Util\DateTimeFormat; use Friendica\Util\Network; use Friendica\Util\Profiler; @@ -47,6 +48,7 @@ class Profile extends BaseProfile { /** @var Database */ private $database; + private DeletedUserRepository $deletedUserRepository; /** @var AppHelper */ private $appHelper; /** @var IHandleUserSessions */ @@ -66,6 +68,7 @@ class Profile extends BaseProfile IHandleUserSessions $session, AppHelper $appHelper, Database $database, + DeletedUserRepository $deletedUserRepository, EventDispatcherInterface $eventDispatcher, L10n $l10n, BaseURL $baseUrl, @@ -78,13 +81,14 @@ class Profile extends BaseProfile ) { parent::__construct($l10n, $baseUrl, $args, $logger, $profiler, $response, $server, $parameters); - $this->database = $database; - $this->appHelper = $appHelper; - $this->session = $session; - $this->config = $config; - $this->page = $page; - $this->profileField = $profileField; - $this->eventDispatcher = $eventDispatcher; + $this->database = $database; + $this->deletedUserRepository = $deletedUserRepository; + $this->appHelper = $appHelper; + $this->session = $session; + $this->config = $config; + $this->page = $page; + $this->profileField = $profileField; + $this->eventDispatcher = $eventDispatcher; } protected function rawContent(array $request = []) @@ -102,7 +106,7 @@ class Profile extends BaseProfile } } - if ($this->database->exists('userd', ['username' => $this->parameters['nickname']])) { + if ($this->deletedUserRepository->existsByUsername($this->parameters['nickname'])) { // Known deleted user $data = ActivityPub\Transmitter::getDeletedUser($this->parameters['nickname']); diff --git a/src/Module/User/Import.php b/src/Module/User/Import.php index 74f2f9b6ad..a3ff223084 100644 --- a/src/Module/User/Import.php +++ b/src/Module/User/Import.php @@ -7,7 +7,8 @@ namespace Friendica\Module\User; -use Friendica\App; +use Friendica\App\Arguments; +use Friendica\App\BaseURL; use Friendica\Core\Config\Capability\IManageConfigValues; use Friendica\Core\L10n; use Friendica\Core\PConfig\Capability\IManagePersonalConfigValues; @@ -26,6 +27,7 @@ use Friendica\Navigation\SystemMessages; use Friendica\Network\HTTPException; use Friendica\Object\Image; use Friendica\Protocol\Delivery; +use Friendica\Repository\DeletedUserRepository; use Friendica\Security\PermissionSet\Repository\PermissionSet; use Friendica\Util\Profiler; use Friendica\Util\Strings; @@ -47,20 +49,38 @@ class Import extends \Friendica\BaseModule /** @var Database */ private $database; + private DeletedUserRepository $deletedUserRepository; + /** @var PermissionSet */ private $permissionSet; /** @var UserSession */ private $session; - public function __construct(UserSession $session, PermissionSet $permissionSet, IManagePersonalConfigValues $pconfig, Database $database, SystemMessages $systemMessages, IManageConfigValues $config, L10n $l10n, App\BaseURL $baseUrl, App\Arguments $args, LoggerInterface $logger, Profiler $profiler, Response $response, array $server, array $parameters = []) - { + public function __construct( + UserSession $session, + PermissionSet $permissionSet, + IManagePersonalConfigValues $pconfig, + Database $database, + DeletedUserRepository $deletedUserRepository, + SystemMessages $systemMessages, + IManageConfigValues $config, + L10n $l10n, + BaseURL $baseUrl, + Arguments $args, + LoggerInterface $logger, + Profiler $profiler, + Response $response, + array $server, + array $parameters = [] + ) { parent::__construct($l10n, $baseUrl, $args, $logger, $profiler, $response, $server, $parameters); $this->config = $config; $this->pconfig = $pconfig; $this->systemMessages = $systemMessages; $this->database = $database; + $this->deletedUserRepository = $deletedUserRepository; $this->permissionSet = $permissionSet; $this->session = $session; } @@ -213,7 +233,7 @@ class Import extends \Friendica\BaseModule // check for username // check if username matches deleted account if ($this->database->exists('user', ['nickname' => $account['user']['nickname']]) - || $this->database->exists('userd', ['username' => $account['user']['nickname']])) { + || $this->deletedUserRepository->existsByUsername($account['user']['nickname'])) { $this->systemMessages->addNotice($this->t("User '%s' already exists on this server!", $account['user']['nickname'])); return; } diff --git a/src/Repository/CacheRepository.php b/src/Repository/CacheRepository.php new file mode 100644 index 0000000000..6da86e7540 --- /dev/null +++ b/src/Repository/CacheRepository.php @@ -0,0 +1,40 @@ + + */ + public function getAllKeysValidUntil(string $expires): array; + + /** + * @throws DatabaseException + * + * @return array + */ + public function getAllKeysValidUntilWithPrefix(string $expires, string $prefix): array; + + /** + * @throws DatabaseException + * + * @return CacheEntity|null + */ + public function findOneByKeyValidUntil(string $key, string $expires); +} diff --git a/src/Repository/DeletedUserRepository.php b/src/Repository/DeletedUserRepository.php new file mode 100644 index 0000000000..54b8a36fbc --- /dev/null +++ b/src/Repository/DeletedUserRepository.php @@ -0,0 +1,33 @@ +dice->create(Database::class); - $dba->setTestmode(true); + $dba->throwExceptionsOnErrors(true); DBStructure::checkInitialValues(); diff --git a/tests/Unit/Database/Repository/CacheTableRepositoryTest.php b/tests/Unit/Database/Repository/CacheTableRepositoryTest.php new file mode 100644 index 0000000000..cb710b2a59 --- /dev/null +++ b/tests/Unit/Database/Repository/CacheTableRepositoryTest.php @@ -0,0 +1,97 @@ +createStub(Database::class); + $database->method('select')->willReturnMap([ + ['cache', ['k'], ['`expires` >= ?', '2025-04-16 10:12:01'], [], $stmt], + ]); + $database->method('fetch')->willReturnOnConsecutiveCalls( + ['k' => 'value1'], + ['k' => 'value2'], + ['k' => 'value3'], + false + ); + + $repo = new CacheTableRepository($database); + + $this->assertSame( + [ + 'value1', + 'value2', + 'value3', + ], + $repo->getAllKeysValidUntil('2025-04-16 10:12:01') + ); + } + + public function testGetAllKeysValidUntilThrowsException(): void + { + $database = $this->createStub(Database::class); + $database->method('select')->willThrowException($this->createStub(Throwable::class)); + + $repo = new CacheTableRepository($database); + + $this->expectException(DatabaseException::class); + + $repo->getAllKeysValidUntil('2025-04-16 10:12:01'); + } + + public function testGetAllKeysValidUntilWithPrefixReturnsArray(): void + { + $stmt = new \stdClass; + + $database = $this->createStub(Database::class); + $database->method('select')->willReturnMap([ + ['cache', ['k'], ['`expires` >= ?', '2025-04-16 10:12:01'], [], $stmt], + ]); + $database->method('fetch')->willReturnOnConsecutiveCalls( + ['k' => 'value1'], + ['k' => 'value2'], + ['k' => 'value3'], + false + ); + + $repo = new CacheTableRepository($database); + + $this->assertSame( + [ + 'value1', + 'value2', + 'value3', + ], + $repo->getAllKeysValidUntilWithPrefix('2025-04-16 10:12:01', 'prefix') + ); + } + + public function testGetAllKeysValidUntilWithPrefixThrowsException(): void + { + $database = $this->createStub(Database::class); + $database->method('select')->willThrowException($this->createStub(Throwable::class)); + + $repo = new CacheTableRepository($database); + + $this->expectException(DatabaseException::class); + + $repo->getAllKeysValidUntilWithPrefix('2025-04-16 10:12:01', 'prefix'); + } +} diff --git a/tests/Unit/Database/Repository/UserdTableRepositoryTest.php b/tests/Unit/Database/Repository/UserdTableRepositoryTest.php new file mode 100644 index 0000000000..ff08ab5b5e --- /dev/null +++ b/tests/Unit/Database/Repository/UserdTableRepositoryTest.php @@ -0,0 +1,77 @@ +createMock(Database::class)); + + $this->assertInstanceOf(DeletedUserRepository::class, $repo); + } + + public function testInsertByUsernameCallsDatabase(): void + { + $database = $this->createMock(Database::class); + $database->expects($this->once())->method('insert')->willReturnMap([ + ['userd', ['username' => 'test'], 0, true], + ]); + + $repo = new UserdTableRepository($database); + + $repo->insertByUsername('test'); + } + + public function testInsertByUsernameThrowsException(): void + { + $database = $this->createMock(Database::class); + $database->expects($this->exactly(2))->method('throwExceptionsOnErrors'); + $database->expects($this->once())->method('insert')->willThrowException( + new DatabaseException('An error occured.', 0, 'SQL query') + ); + + $repo = new UserdTableRepository($database); + + $this->expectException(DatabaseException::class); + + $repo->insertByUsername('test'); + } + + public function testExistsByUsernameReturnsTrue(): void + { + $database = $this->createStub(Database::class); + $database->method('exists')->willReturnMap([ + ['userd', ['username' => 'test'], true], + ]); + + $repo = new UserdTableRepository($database); + + $this->assertTrue($repo->existsByUsername('test')); + } + + public function testExistsByUsernameReturnsFalse(): void + { + $database = $this->createStub(Database::class); + $database->method('exists')->willReturnMap([ + ['userd', ['username' => 'test'], false], + ]); + + $repo = new UserdTableRepository($database); + + $this->assertFalse($repo->existsByUsername('test')); + } +} diff --git a/tests/Util/CreateDatabaseTrait.php b/tests/Util/CreateDatabaseTrait.php index 05d3d6c0ca..c6ad09ef94 100644 --- a/tests/Util/CreateDatabaseTrait.php +++ b/tests/Util/CreateDatabaseTrait.php @@ -43,7 +43,7 @@ trait CreateDatabaseTrait ])); $database = new StaticDatabase($config, (new DbaDefinition($this->root->url()))->load(), (new ViewDefinition($this->root->url()))->load()); - $database->setTestmode(true); + $database->throwExceptionsOnErrors(true); return $database; }