diff options
Diffstat (limited to 'lib/private/Accounts')
-rw-r--r-- | lib/private/Accounts/Account.php | 139 | ||||
-rw-r--r-- | lib/private/Accounts/AccountManager.php | 967 | ||||
-rw-r--r-- | lib/private/Accounts/AccountProperty.php | 102 | ||||
-rw-r--r-- | lib/private/Accounts/AccountPropertyCollection.php | 90 | ||||
-rw-r--r-- | lib/private/Accounts/Hooks.php | 104 | ||||
-rw-r--r-- | lib/private/Accounts/TAccountsHelper.php | 25 |
6 files changed, 996 insertions, 431 deletions
diff --git a/lib/private/Accounts/Account.php b/lib/private/Accounts/Account.php index e7aeb6fa1e9..946168d7755 100644 --- a/lib/private/Accounts/Account.php +++ b/lib/private/Accounts/Account.php @@ -3,80 +3,139 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Julius Härtl <jus@bitgrid.net> - * - * @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 <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Accounts; +use Generator; use OCP\Accounts\IAccount; use OCP\Accounts\IAccountProperty; +use OCP\Accounts\IAccountPropertyCollection; use OCP\Accounts\PropertyDoesNotExistException; use OCP\IUser; +use RuntimeException; class Account implements IAccount { + use TAccountsHelper; - /** @var IAccountProperty[] */ - private $properties = []; - - /** @var IUser */ - private $user; + /** @var IAccountPropertyCollection[]|IAccountProperty[] */ + private array $properties = []; - public function __construct(IUser $user) { - $this->user = $user; + public function __construct( + private IUser $user, + ) { } - public function setProperty(string $property, string $value, string $scope, string $verified): IAccount { - $this->properties[$property] = new AccountProperty($property, $value, $scope, $verified); + public function setProperty(string $property, string $value, string $scope, string $verified, string $verificationData = ''): IAccount { + if ($this->isCollection($property)) { + throw new \InvalidArgumentException('setProperty cannot set an IAccountsPropertyCollection'); + } + $this->properties[$property] = new AccountProperty($property, $value, $scope, $verified, $verificationData); return $this; } public function getProperty(string $property): IAccountProperty { - if (!array_key_exists($property, $this->properties)) { + if ($this->isCollection($property)) { + throw new \InvalidArgumentException('getProperty cannot retrieve an IAccountsPropertyCollection'); + } + if (!array_key_exists($property, $this->properties) || !$this->properties[$property] instanceof IAccountProperty) { throw new PropertyDoesNotExistException($property); } return $this->properties[$property]; } public function getProperties(): array { - return $this->properties; + return array_filter($this->properties, function ($obj) { + return $obj instanceof IAccountProperty; + }); + } + + public function setAllPropertiesFromJson(array $properties): IAccount { + foreach ($properties as $propertyName => $propertyObject) { + if ($this->isCollection($propertyName)) { + $collection = new AccountPropertyCollection($propertyName); + /** @var array<int, IAccountProperty> $collectionProperties */ + $collectionProperties = []; + /** @var array<int, array<string, string>> $propertyObject */ + foreach ($propertyObject as ['value' => $value, 'scope' => $scope, 'verified' => $verified, 'verificationData' => $verificationData]) { + $collectionProperties[] = new AccountProperty($collection->getName(), $value, $scope, $verified, $verificationData); + } + $collection->setProperties($collectionProperties); + $this->setPropertyCollection($collection); + } else { + /** @var array<string, string> $propertyObject */ + ['value' => $value, 'scope' => $scope, 'verified' => $verified, 'verificationData' => $verificationData] = $propertyObject; + $this->setProperty($propertyName, $value, $scope, $verified, $verificationData); + } + } + + return $this; + } + + public function getAllProperties(): Generator { + foreach ($this->properties as $propertyObject) { + if ($propertyObject instanceof IAccountProperty) { + yield $propertyObject; + } elseif ($propertyObject instanceof IAccountPropertyCollection) { + foreach ($propertyObject->getProperties() as $property) { + yield $property; + } + } + } } - public function getFilteredProperties(string $scope = null, string $verified = null): array { - return \array_filter($this->properties, function (IAccountProperty $obj) use ($scope, $verified) { + public function getFilteredProperties(?string $scope = null, ?string $verified = null): array { + $result = $incrementals = []; + /** @var IAccountProperty $obj */ + foreach ($this->getAllProperties() as $obj) { if ($scope !== null && $scope !== $obj->getScope()) { - return false; + continue; } if ($verified !== null && $verified !== $obj->getVerified()) { - return false; + continue; } - return true; - }); + $index = $obj->getName(); + if ($this->isCollection($index)) { + $incrementals[$index] = ($incrementals[$index] ?? -1) + 1; + $index .= '#' . $incrementals[$index]; + } + $result[$index] = $obj; + } + return $result; } - public function jsonSerialize() { - return $this->properties; + /** @return array<string, IAccountProperty|array<int, IAccountProperty>> */ + public function jsonSerialize(): array { + $properties = $this->properties; + foreach ($properties as $propertyName => $propertyObject) { + if ($propertyObject instanceof IAccountPropertyCollection) { + // Override collection serialization to discard duplicate name + $properties[$propertyName] = $propertyObject->jsonSerialize()[$propertyName]; + } + } + return $properties; } public function getUser(): IUser { return $this->user; } + + public function setPropertyCollection(IAccountPropertyCollection $propertyCollection): IAccount { + $this->properties[$propertyCollection->getName()] = $propertyCollection; + return $this; + } + + public function getPropertyCollection(string $propertyCollectionName): IAccountPropertyCollection { + if (!$this->isCollection($propertyCollectionName)) { + throw new PropertyDoesNotExistException($propertyCollectionName); + } + if (!array_key_exists($propertyCollectionName, $this->properties)) { + $this->properties[$propertyCollectionName] = new AccountPropertyCollection($propertyCollectionName); + } + if (!$this->properties[$propertyCollectionName] instanceof IAccountPropertyCollection) { + throw new RuntimeException('Requested collection is not an IAccountPropertyCollection'); + } + return $this->properties[$propertyCollectionName]; + } } diff --git a/lib/private/Accounts/AccountManager.php b/lib/private/Accounts/AccountManager.php index 05feaf87b8f..d00b1d2e9a3 100644 --- a/lib/private/Accounts/AccountManager.php +++ b/lib/private/Accounts/AccountManager.php @@ -1,51 +1,46 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2016, Björn Schießle - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Accounts; -use libphonenumber\NumberParseException; -use libphonenumber\PhoneNumber; -use libphonenumber\PhoneNumberFormat; -use libphonenumber\PhoneNumberUtil; +use Exception; +use InvalidArgumentException; +use OC\Profile\TProfileHelper; use OCA\Settings\BackgroundJobs\VerifyUserData; use OCP\Accounts\IAccount; use OCP\Accounts\IAccountManager; +use OCP\Accounts\IAccountProperty; +use OCP\Accounts\IAccountPropertyCollection; +use OCP\Accounts\PropertyDoesNotExistException; +use OCP\Accounts\UserUpdatedEvent; use OCP\BackgroundJob\IJobList; +use OCP\Cache\CappedMemoryCache; use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Defaults; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Http\Client\IClientService; use OCP\IConfig; use OCP\IDBConnection; +use OCP\IL10N; +use OCP\IPhoneNumberUtil; +use OCP\IURLGenerator; use OCP\IUser; +use OCP\L10N\IFactory; +use OCP\Mail\IMailer; +use OCP\Security\ICrypto; +use OCP\Security\VerificationToken\IVerificationToken; +use OCP\User\Backend\IGetDisplayNameBackend; +use OCP\Util; use Psr\Log\LoggerInterface; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\EventDispatcher\GenericEvent; +use function array_flip; +use function iterator_to_array; use function json_decode; +use function json_encode; use function json_last_error; /** @@ -57,109 +52,109 @@ use function json_last_error; * @package OC\Accounts */ class AccountManager implements IAccountManager { + use TAccountsHelper; - /** @var IDBConnection database connection */ - private $connection; + use TProfileHelper; - /** @var IConfig */ - private $config; + private string $table = 'accounts'; + private string $dataTable = 'accounts_data'; + private ?IL10N $l10n = null; + private CappedMemoryCache $internalCache; - /** @var string table name */ - private $table = 'accounts'; - - /** @var string table name */ - private $dataTable = 'accounts_data'; - - /** @var EventDispatcherInterface */ - private $eventDispatcher; - - /** @var IJobList */ - private $jobList; - - /** @var LoggerInterface */ - private $logger; - - public function __construct(IDBConnection $connection, - IConfig $config, - EventDispatcherInterface $eventDispatcher, - IJobList $jobList, - LoggerInterface $logger) { - $this->connection = $connection; - $this->config = $config; - $this->eventDispatcher = $eventDispatcher; - $this->jobList = $jobList; - $this->logger = $logger; + /** + * The list of default scopes for each property. + */ + public const DEFAULT_SCOPES = [ + self::PROPERTY_ADDRESS => self::SCOPE_LOCAL, + self::PROPERTY_AVATAR => self::SCOPE_FEDERATED, + self::PROPERTY_BIOGRAPHY => self::SCOPE_LOCAL, + self::PROPERTY_BIRTHDATE => self::SCOPE_LOCAL, + self::PROPERTY_DISPLAYNAME => self::SCOPE_FEDERATED, + self::PROPERTY_EMAIL => self::SCOPE_FEDERATED, + self::PROPERTY_FEDIVERSE => self::SCOPE_LOCAL, + self::PROPERTY_HEADLINE => self::SCOPE_LOCAL, + self::PROPERTY_ORGANISATION => self::SCOPE_LOCAL, + self::PROPERTY_PHONE => self::SCOPE_LOCAL, + self::PROPERTY_PRONOUNS => self::SCOPE_FEDERATED, + self::PROPERTY_ROLE => self::SCOPE_LOCAL, + self::PROPERTY_TWITTER => self::SCOPE_LOCAL, + self::PROPERTY_BLUESKY => self::SCOPE_LOCAL, + self::PROPERTY_WEBSITE => self::SCOPE_LOCAL, + ]; + + public function __construct( + private IDBConnection $connection, + private IConfig $config, + private IEventDispatcher $dispatcher, + private IJobList $jobList, + private LoggerInterface $logger, + private IVerificationToken $verificationToken, + private IMailer $mailer, + private Defaults $defaults, + private IFactory $l10nFactory, + private IURLGenerator $urlGenerator, + private ICrypto $crypto, + private IPhoneNumberUtil $phoneNumberUtil, + private IClientService $clientService, + ) { + $this->internalCache = new CappedMemoryCache(); } /** - * @param string $input - * @return string Provided phone number in E.164 format when it was a valid number - * @throws \InvalidArgumentException When the phone number was invalid or no default region is set and the number doesn't start with a country code + * @param IAccountProperty[] $properties */ - protected function parsePhoneNumber(string $input): string { - $defaultRegion = $this->config->getSystemValueString('default_phone_region', ''); - - if ($defaultRegion === '') { - // When no default region is set, only +49… numbers are valid - if (strpos($input, '+') !== 0) { - throw new \InvalidArgumentException(self::PROPERTY_PHONE); + protected function testValueLengths(array $properties, bool $throwOnData = false): void { + foreach ($properties as $property) { + if (strlen($property->getValue()) > 2048) { + if ($throwOnData) { + throw new InvalidArgumentException($property->getName()); + } else { + $property->setValue(''); + } } + } + } - $defaultRegion = 'EN'; + protected function testPropertyScope(IAccountProperty $property, array $allowedScopes, bool $throwOnData): void { + if ($throwOnData && !in_array($property->getScope(), $allowedScopes, true)) { + throw new InvalidArgumentException('scope'); } - $phoneUtil = PhoneNumberUtil::getInstance(); - try { - $phoneNumber = $phoneUtil->parse($input, $defaultRegion); - if ($phoneNumber instanceof PhoneNumber && $phoneUtil->isValidNumber($phoneNumber)) { - return $phoneUtil->format($phoneNumber, PhoneNumberFormat::E164); + if ( + $property->getScope() === self::SCOPE_PRIVATE + && in_array($property->getName(), [self::PROPERTY_DISPLAYNAME, self::PROPERTY_EMAIL]) + ) { + if ($throwOnData) { + // v2-private is not available for these fields + throw new InvalidArgumentException('scope'); + } else { + // default to local + $property->setScope(self::SCOPE_LOCAL); } - } catch (NumberParseException $e) { + } else { + $property->setScope($property->getScope()); } - - throw new \InvalidArgumentException(self::PROPERTY_PHONE); } - /** - * update user record - * - * @param IUser $user - * @param array $data - * @param bool $throwOnData Set to true if you can inform the user about invalid data - * @return array The potentially modified data (e.g. phone numbers are converted to E.164 format) - * @throws \InvalidArgumentException Message is the property that was invalid - */ - public function updateUser(IUser $user, array $data, bool $throwOnData = false): array { - $userData = $this->getUser($user); - $updated = true; - - if (isset($data[self::PROPERTY_PHONE]) && $data[self::PROPERTY_PHONE]['value'] !== '') { - try { - $data[self::PROPERTY_PHONE]['value'] = $this->parsePhoneNumber($data[self::PROPERTY_PHONE]['value']); - } catch (\InvalidArgumentException $e) { - if ($throwOnData) { - throw $e; - } - $data[self::PROPERTY_PHONE]['value'] = ''; - } + protected function updateUser(IUser $user, array $data, ?array $oldUserData, bool $throwOnData = false): array { + if ($oldUserData === null) { + $oldUserData = $this->getUser($user, false); } - if (empty($userData)) { - $this->insertNewUser($user, $data); - } elseif ($userData !== $data) { - $data = $this->checkEmailVerification($userData, $data, $user); - $data = $this->updateVerifyStatus($userData, $data); - $this->updateExistingUser($user, $data); + $updated = true; + + if ($oldUserData !== $data) { + $this->updateExistingUser($user, $data, $oldUserData); } else { // nothing needs to be done if new and old data set are the same $updated = false; } if ($updated) { - $this->eventDispatcher->dispatch( - 'OC\AccountManager::userUpdated', - new GenericEvent($user, $data) - ); + $this->dispatcher->dispatchTyped(new UserUpdatedEvent( + $user, + $data, + )); } return $data; @@ -167,191 +162,243 @@ class AccountManager implements IAccountManager { /** * delete user from accounts table - * - * @param IUser $user */ - public function deleteUser(IUser $user) { + public function deleteUser(IUser $user): void { $uid = $user->getUID(); $query = $this->connection->getQueryBuilder(); $query->delete($this->table) ->where($query->expr()->eq('uid', $query->createNamedParameter($uid))) - ->execute(); + ->executeStatement(); $this->deleteUserData($user); } /** * delete user from accounts table - * - * @param IUser $user */ public function deleteUserData(IUser $user): void { $uid = $user->getUID(); $query = $this->connection->getQueryBuilder(); $query->delete($this->dataTable) ->where($query->expr()->eq('uid', $query->createNamedParameter($uid))) - ->execute(); + ->executeStatement(); } /** * get stored data from a given user - * - * @param IUser $user - * @return array */ - public function getUser(IUser $user) { + protected function getUser(IUser $user, bool $insertIfNotExists = true): array { $uid = $user->getUID(); $query = $this->connection->getQueryBuilder(); $query->select('data') ->from($this->table) ->where($query->expr()->eq('uid', $query->createParameter('uid'))) ->setParameter('uid', $uid); - $result = $query->execute(); + $result = $query->executeQuery(); $accountData = $result->fetchAll(); $result->closeCursor(); if (empty($accountData)) { $userData = $this->buildDefaultUserRecord($user); - $this->insertNewUser($user, $userData); + if ($insertIfNotExists) { + $this->insertNewUser($user, $userData); + } return $userData; } - $userDataArray = json_decode($accountData[0]['data'], true); - $jsonError = json_last_error(); - if ($userDataArray === null || $userDataArray === [] || $jsonError !== JSON_ERROR_NONE) { - $this->logger->critical("User data of $uid contained invalid JSON (error $jsonError), hence falling back to a default user record"); + $userDataArray = $this->importFromJson($accountData[0]['data'], $uid); + if ($userDataArray === null || $userDataArray === []) { return $this->buildDefaultUserRecord($user); } - $userDataArray = $this->addMissingDefaultValues($userDataArray); - - return $userDataArray; + return $this->addMissingDefaultValues($userDataArray, $this->buildDefaultUserRecord($user)); } public function searchUsers(string $property, array $values): array { + // the value col is limited to 255 bytes. It is used for searches only. + $values = array_map(function (string $value) { + return Util::shortenMultibyteString($value, 255); + }, $values); + $chunks = array_chunk($values, 500); $query = $this->connection->getQueryBuilder(); $query->select('*') ->from($this->dataTable) ->where($query->expr()->eq('name', $query->createNamedParameter($property))) - ->andWhere($query->expr()->in('value', $query->createNamedParameter($values, IQueryBuilder::PARAM_STR_ARRAY))); + ->andWhere($query->expr()->in('value', $query->createParameter('values'))); - $result = $query->execute(); $matches = []; + foreach ($chunks as $chunk) { + $query->setParameter('values', $chunk, IQueryBuilder::PARAM_STR_ARRAY); + $result = $query->executeQuery(); - while ($row = $result->fetch()) { - $matches[$row['value']] = $row['uid']; + while ($row = $result->fetch()) { + $matches[$row['uid']] = $row['value']; + } + $result->closeCursor(); } - $result->closeCursor(); - return $matches; + $result = array_merge($matches, $this->searchUsersForRelatedCollection($property, $values)); + + return array_flip($result); + } + + protected function searchUsersForRelatedCollection(string $property, array $values): array { + return match ($property) { + IAccountManager::PROPERTY_EMAIL => array_flip($this->searchUsers(IAccountManager::COLLECTION_EMAIL, $values)), + default => [], + }; } /** * check if we need to ask the server for email verification, if yes we create a cronjob - * - * @param $oldData - * @param $newData - * @param IUser $user - * @return array */ - protected function checkEmailVerification($oldData, $newData, IUser $user) { - if ($oldData[self::PROPERTY_EMAIL]['value'] !== $newData[self::PROPERTY_EMAIL]['value']) { - $this->jobList->add(VerifyUserData::class, + protected function checkEmailVerification(IAccount $updatedAccount, array $oldData): void { + try { + $property = $updatedAccount->getProperty(self::PROPERTY_EMAIL); + } catch (PropertyDoesNotExistException $e) { + return; + } + + $oldMailIndex = array_search(self::PROPERTY_EMAIL, array_column($oldData, 'name'), true); + $oldMail = $oldMailIndex !== false ? $oldData[$oldMailIndex]['value'] : ''; + + if ($oldMail !== $property->getValue()) { + $this->jobList->add( + VerifyUserData::class, [ 'verificationCode' => '', - 'data' => $newData[self::PROPERTY_EMAIL]['value'], + 'data' => $property->getValue(), 'type' => self::PROPERTY_EMAIL, - 'uid' => $user->getUID(), + 'uid' => $updatedAccount->getUser()->getUID(), 'try' => 0, 'lastRun' => time() ] ); - $newData[self::PROPERTY_EMAIL]['verified'] = self::VERIFICATION_IN_PROGRESS; - } - return $newData; + $property->setVerified(self::VERIFICATION_IN_PROGRESS); + } } - /** - * make sure that all expected data are set - * - * @param array $userData - * @return array - */ - protected function addMissingDefaultValues(array $userData) { - foreach ($userData as $key => $value) { - if (!isset($userData[$key]['verified'])) { - $userData[$key]['verified'] = self::NOT_VERIFIED; + protected function checkLocalEmailVerification(IAccount $updatedAccount, array $oldData): void { + $mailCollection = $updatedAccount->getPropertyCollection(self::COLLECTION_EMAIL); + foreach ($mailCollection->getProperties() as $property) { + if ($property->getLocallyVerified() !== self::NOT_VERIFIED) { + continue; + } + if ($this->sendEmailVerificationEmail($updatedAccount->getUser(), $property->getValue())) { + $property->setLocallyVerified(self::VERIFICATION_IN_PROGRESS); } } - - return $userData; } - /** - * reset verification status if personal data changed - * - * @param array $oldData - * @param array $newData - * @return array - */ - protected function updateVerifyStatus($oldData, $newData) { + protected function sendEmailVerificationEmail(IUser $user, string $email): bool { + $ref = \substr(hash('sha256', $email), 0, 8); + $key = $this->crypto->encrypt($email); + $token = $this->verificationToken->create($user, 'verifyMail' . $ref, $email); + + $link = $this->urlGenerator->linkToRouteAbsolute( + 'provisioning_api.Verification.verifyMail', + [ + 'userId' => $user->getUID(), + 'token' => $token, + 'key' => $key + ] + ); + + $emailTemplate = $this->mailer->createEMailTemplate('core.EmailVerification', [ + 'link' => $link, + ]); + + if (!$this->l10n) { + $this->l10n = $this->l10nFactory->get('core'); + } - // which account was already verified successfully? - $twitterVerified = isset($oldData[self::PROPERTY_TWITTER]['verified']) && $oldData[self::PROPERTY_TWITTER]['verified'] === self::VERIFIED; - $websiteVerified = isset($oldData[self::PROPERTY_WEBSITE]['verified']) && $oldData[self::PROPERTY_WEBSITE]['verified'] === self::VERIFIED; - $emailVerified = isset($oldData[self::PROPERTY_EMAIL]['verified']) && $oldData[self::PROPERTY_EMAIL]['verified'] === self::VERIFIED; + $emailTemplate->setSubject($this->l10n->t('%s email verification', [$this->defaults->getName()])); + $emailTemplate->addHeader(); + $emailTemplate->addHeading($this->l10n->t('Email verification')); - // keep old verification status if we don't have a new one - if (!isset($newData[self::PROPERTY_TWITTER]['verified'])) { - // keep old verification status if value didn't changed and an old value exists - $keepOldStatus = $newData[self::PROPERTY_TWITTER]['value'] === $oldData[self::PROPERTY_TWITTER]['value'] && isset($oldData[self::PROPERTY_TWITTER]['verified']); - $newData[self::PROPERTY_TWITTER]['verified'] = $keepOldStatus ? $oldData[self::PROPERTY_TWITTER]['verified'] : self::NOT_VERIFIED; - } + $emailTemplate->addBodyText( + htmlspecialchars($this->l10n->t('Click the following button to confirm your email.')), + $this->l10n->t('Click the following link to confirm your email.') + ); - if (!isset($newData[self::PROPERTY_WEBSITE]['verified'])) { - // keep old verification status if value didn't changed and an old value exists - $keepOldStatus = $newData[self::PROPERTY_WEBSITE]['value'] === $oldData[self::PROPERTY_WEBSITE]['value'] && isset($oldData[self::PROPERTY_WEBSITE]['verified']); - $newData[self::PROPERTY_WEBSITE]['verified'] = $keepOldStatus ? $oldData[self::PROPERTY_WEBSITE]['verified'] : self::NOT_VERIFIED; - } + $emailTemplate->addBodyButton( + htmlspecialchars($this->l10n->t('Confirm your email')), + $link, + false + ); + $emailTemplate->addFooter(); - if (!isset($newData[self::PROPERTY_EMAIL]['verified'])) { - // keep old verification status if value didn't changed and an old value exists - $keepOldStatus = $newData[self::PROPERTY_EMAIL]['value'] === $oldData[self::PROPERTY_EMAIL]['value'] && isset($oldData[self::PROPERTY_EMAIL]['verified']); - $newData[self::PROPERTY_EMAIL]['verified'] = $keepOldStatus ? $oldData[self::PROPERTY_EMAIL]['verified'] : self::VERIFICATION_IN_PROGRESS; + try { + $message = $this->mailer->createMessage(); + $message->setTo([$email => $user->getDisplayName()]); + $message->setFrom([Util::getDefaultEmailAddress('verification-noreply') => $this->defaults->getName()]); + $message->useTemplate($emailTemplate); + $this->mailer->send($message); + } catch (Exception $e) { + // Log the exception and continue + $this->logger->info('Failed to send verification mail', [ + 'app' => 'core', + 'exception' => $e + ]); + return false; } + return true; + } - // reset verification status if a value from a previously verified data was changed - if ($twitterVerified && - $oldData[self::PROPERTY_TWITTER]['value'] !== $newData[self::PROPERTY_TWITTER]['value'] - ) { - $newData[self::PROPERTY_TWITTER]['verified'] = self::NOT_VERIFIED; - } + /** + * Make sure that all expected data are set + */ + protected function addMissingDefaultValues(array $userData, array $defaultUserData): array { + foreach ($defaultUserData as $defaultDataItem) { + // If property does not exist, initialize it + $userDataIndex = array_search($defaultDataItem['name'], array_column($userData, 'name')); + if ($userDataIndex === false) { + $userData[] = $defaultDataItem; + continue; + } - if ($websiteVerified && - $oldData[self::PROPERTY_WEBSITE]['value'] !== $newData[self::PROPERTY_WEBSITE]['value'] - ) { - $newData[self::PROPERTY_WEBSITE]['verified'] = self::NOT_VERIFIED; + // Merge and extend default missing values + $userData[$userDataIndex] = array_merge($defaultDataItem, $userData[$userDataIndex]); } - if ($emailVerified && - $oldData[self::PROPERTY_EMAIL]['value'] !== $newData[self::PROPERTY_EMAIL]['value'] - ) { - $newData[self::PROPERTY_EMAIL]['verified'] = self::NOT_VERIFIED; - } + return $userData; + } - return $newData; + protected function updateVerificationStatus(IAccount $updatedAccount, array $oldData): void { + static $propertiesVerifiableByLookupServer = [ + self::PROPERTY_TWITTER, + self::PROPERTY_FEDIVERSE, + self::PROPERTY_WEBSITE, + self::PROPERTY_EMAIL, + ]; + + foreach ($propertiesVerifiableByLookupServer as $propertyName) { + try { + $property = $updatedAccount->getProperty($propertyName); + } catch (PropertyDoesNotExistException $e) { + continue; + } + $wasVerified = isset($oldData[$propertyName]) + && isset($oldData[$propertyName]['verified']) + && $oldData[$propertyName]['verified'] === self::VERIFIED; + if ((!isset($oldData[$propertyName]) + || !isset($oldData[$propertyName]['value']) + || $property->getValue() !== $oldData[$propertyName]['value']) + && ($property->getVerified() !== self::NOT_VERIFIED + || $wasVerified) + ) { + $property->setVerified(self::NOT_VERIFIED); + } + } } /** * add new user to accounts table - * - * @param IUser $user - * @param array $data */ protected function insertNewUser(IUser $user, array $data): void { $uid = $user->getUID(); - $jsonEncodedData = json_encode($data); + $jsonEncodedData = $this->prepareJson($data); $query = $this->connection->getQueryBuilder(); $query->insert($this->table) ->values( @@ -360,26 +407,72 @@ class AccountManager implements IAccountManager { 'data' => $query->createNamedParameter($jsonEncodedData), ] ) - ->execute(); + ->executeStatement(); $this->deleteUserData($user); $this->writeUserData($user, $data); } + protected function prepareJson(array $data): string { + $preparedData = []; + foreach ($data as $dataRow) { + $propertyName = $dataRow['name']; + unset($dataRow['name']); + + if (isset($dataRow['locallyVerified']) && $dataRow['locallyVerified'] === self::NOT_VERIFIED) { + // do not write default value, save DB space + unset($dataRow['locallyVerified']); + } + + if (!$this->isCollection($propertyName)) { + $preparedData[$propertyName] = $dataRow; + continue; + } + if (!isset($preparedData[$propertyName])) { + $preparedData[$propertyName] = []; + } + $preparedData[$propertyName][] = $dataRow; + } + return json_encode($preparedData); + } + + protected function importFromJson(string $json, string $userId): ?array { + $result = []; + $jsonArray = json_decode($json, true); + $jsonError = json_last_error(); + if ($jsonError !== JSON_ERROR_NONE) { + $this->logger->critical( + 'User data of {uid} contained invalid JSON (error {json_error}), hence falling back to a default user record', + [ + 'uid' => $userId, + 'json_error' => $jsonError + ] + ); + return null; + } + foreach ($jsonArray as $propertyName => $row) { + if (!$this->isCollection($propertyName)) { + $result[] = array_merge($row, ['name' => $propertyName]); + continue; + } + foreach ($row as $singleRow) { + $result[] = array_merge($singleRow, ['name' => $propertyName]); + } + } + return $result; + } + /** - * update existing user in accounts table - * - * @param IUser $user - * @param array $data + * Update existing user in accounts table */ - protected function updateExistingUser(IUser $user, array $data): void { + protected function updateExistingUser(IUser $user, array $data, array $oldData): void { $uid = $user->getUID(); - $jsonEncodedData = json_encode($data); + $jsonEncodedData = $this->prepareJson($data); $query = $this->connection->getQueryBuilder(); $query->update($this->table) ->set('data', $query->createNamedParameter($jsonEncodedData)) ->where($query->expr()->eq('uid', $query->createNamedParameter($uid))) - ->execute(); + ->executeStatement(); $this->deleteUserData($user); $this->writeUserData($user, $data); @@ -395,77 +488,411 @@ class AccountManager implements IAccountManager { 'value' => $query->createParameter('value'), ] ); - foreach ($data as $propertyName => $property) { - if ($propertyName === self::PROPERTY_AVATAR) { + $this->writeUserDataProperties($query, $data); + } + + protected function writeUserDataProperties(IQueryBuilder $query, array $data): void { + foreach ($data as $property) { + if ($property['name'] === self::PROPERTY_AVATAR) { continue; } - $query->setParameter('name', $propertyName) - ->setParameter('value', $property['value']); - $query->execute(); + // the value col is limited to 255 bytes. It is used for searches only. + $value = $property['value'] ? Util::shortenMultibyteString($property['value'], 255) : ''; + + $query->setParameter('name', $property['name']) + ->setParameter('value', $value); + $query->executeStatement(); } } /** * build default user record in case not data set exists yet - * - * @param IUser $user - * @return array */ - protected function buildDefaultUserRecord(IUser $user) { + protected function buildDefaultUserRecord(IUser $user): array { + $scopes = array_merge(self::DEFAULT_SCOPES, array_filter($this->config->getSystemValue('account_manager.default_property_scope', []), static function (string $scope, string $property) { + return in_array($property, self::ALLOWED_PROPERTIES, true) && in_array($scope, self::ALLOWED_SCOPES, true); + }, ARRAY_FILTER_USE_BOTH)); + return [ - self::PROPERTY_DISPLAYNAME => - [ - 'value' => $user->getDisplayName(), - 'scope' => self::VISIBILITY_CONTACTS_ONLY, - 'verified' => self::NOT_VERIFIED, - ], - self::PROPERTY_ADDRESS => - [ - 'value' => '', - 'scope' => self::VISIBILITY_PRIVATE, - 'verified' => self::NOT_VERIFIED, - ], - self::PROPERTY_WEBSITE => - [ - 'value' => '', - 'scope' => self::VISIBILITY_PRIVATE, - 'verified' => self::NOT_VERIFIED, - ], - self::PROPERTY_EMAIL => - [ - 'value' => $user->getEMailAddress(), - 'scope' => self::VISIBILITY_CONTACTS_ONLY, - 'verified' => self::NOT_VERIFIED, - ], - self::PROPERTY_AVATAR => - [ - 'scope' => self::VISIBILITY_CONTACTS_ONLY - ], - self::PROPERTY_PHONE => - [ - 'value' => '', - 'scope' => self::VISIBILITY_PRIVATE, - 'verified' => self::NOT_VERIFIED, - ], - self::PROPERTY_TWITTER => - [ - 'value' => '', - 'scope' => self::VISIBILITY_PRIVATE, - 'verified' => self::NOT_VERIFIED, - ], + [ + 'name' => self::PROPERTY_DISPLAYNAME, + 'value' => $user->getDisplayName(), + // Display name must be at least SCOPE_LOCAL + 'scope' => $scopes[self::PROPERTY_DISPLAYNAME] === self::SCOPE_PRIVATE ? self::SCOPE_LOCAL : $scopes[self::PROPERTY_DISPLAYNAME], + 'verified' => self::NOT_VERIFIED, + ], + + [ + 'name' => self::PROPERTY_ADDRESS, + 'value' => '', + 'scope' => $scopes[self::PROPERTY_ADDRESS], + 'verified' => self::NOT_VERIFIED, + ], + + [ + 'name' => self::PROPERTY_WEBSITE, + 'value' => '', + 'scope' => $scopes[self::PROPERTY_WEBSITE], + 'verified' => self::NOT_VERIFIED, + ], + + [ + 'name' => self::PROPERTY_EMAIL, + 'value' => $user->getEMailAddress(), + // Email must be at least SCOPE_LOCAL + 'scope' => $scopes[self::PROPERTY_EMAIL] === self::SCOPE_PRIVATE ? self::SCOPE_LOCAL : $scopes[self::PROPERTY_EMAIL], + 'verified' => self::NOT_VERIFIED, + ], + + [ + 'name' => self::PROPERTY_AVATAR, + 'scope' => $scopes[self::PROPERTY_AVATAR], + ], + + [ + 'name' => self::PROPERTY_PHONE, + 'value' => '', + 'scope' => $scopes[self::PROPERTY_PHONE], + 'verified' => self::NOT_VERIFIED, + ], + + [ + 'name' => self::PROPERTY_TWITTER, + 'value' => '', + 'scope' => $scopes[self::PROPERTY_TWITTER], + 'verified' => self::NOT_VERIFIED, + ], + + [ + 'name' => self::PROPERTY_BLUESKY, + 'value' => '', + 'scope' => $scopes[self::PROPERTY_BLUESKY], + 'verified' => self::NOT_VERIFIED, + ], + + [ + 'name' => self::PROPERTY_FEDIVERSE, + 'value' => '', + 'scope' => $scopes[self::PROPERTY_FEDIVERSE], + 'verified' => self::NOT_VERIFIED, + ], + + [ + 'name' => self::PROPERTY_ORGANISATION, + 'value' => '', + 'scope' => $scopes[self::PROPERTY_ORGANISATION], + ], + + [ + 'name' => self::PROPERTY_ROLE, + 'value' => '', + 'scope' => $scopes[self::PROPERTY_ROLE], + ], + + [ + 'name' => self::PROPERTY_HEADLINE, + 'value' => '', + 'scope' => $scopes[self::PROPERTY_HEADLINE], + ], + + [ + 'name' => self::PROPERTY_BIOGRAPHY, + 'value' => '', + 'scope' => $scopes[self::PROPERTY_BIOGRAPHY], + ], + + [ + 'name' => self::PROPERTY_BIRTHDATE, + 'value' => '', + 'scope' => $scopes[self::PROPERTY_BIRTHDATE], + ], + + [ + 'name' => self::PROPERTY_PROFILE_ENABLED, + 'value' => $this->isProfileEnabledByDefault($this->config) ? '1' : '0', + ], + + [ + 'name' => self::PROPERTY_PRONOUNS, + 'value' => '', + 'scope' => $scopes[self::PROPERTY_PRONOUNS], + ], ]; } + private function arrayDataToCollection(IAccount $account, array $data): IAccountPropertyCollection { + $collection = $account->getPropertyCollection($data['name']); + + $p = new AccountProperty( + $data['name'], + $data['value'] ?? '', + $data['scope'] ?? self::SCOPE_LOCAL, + $data['verified'] ?? self::NOT_VERIFIED, + '' + ); + $p->setLocallyVerified($data['locallyVerified'] ?? self::NOT_VERIFIED); + $collection->addProperty($p); + + return $collection; + } + private function parseAccountData(IUser $user, $data): Account { $account = new Account($user); - foreach ($data as $property => $accountData) { - $account->setProperty($property, $accountData['value'] ?? '', $accountData['scope'] ?? self::VISIBILITY_PRIVATE, $accountData['verified'] ?? self::NOT_VERIFIED); + foreach ($data as $accountData) { + if ($this->isCollection($accountData['name'])) { + $account->setPropertyCollection($this->arrayDataToCollection($account, $accountData)); + } else { + $account->setProperty($accountData['name'], $accountData['value'] ?? '', $accountData['scope'] ?? self::SCOPE_LOCAL, $accountData['verified'] ?? self::NOT_VERIFIED); + if (isset($accountData['locallyVerified'])) { + $property = $account->getProperty($accountData['name']); + $property->setLocallyVerified($accountData['locallyVerified']); + } + } } return $account; } public function getAccount(IUser $user): IAccount { - return $this->parseAccountData($user, $this->getUser($user)); + $cached = $this->internalCache->get($user->getUID()); + if ($cached !== null) { + return $cached; + } + $account = $this->parseAccountData($user, $this->getUser($user)); + if ($user->getBackend() instanceof IGetDisplayNameBackend) { + $property = $account->getProperty(self::PROPERTY_DISPLAYNAME); + $account->setProperty(self::PROPERTY_DISPLAYNAME, $user->getDisplayName(), $property->getScope(), $property->getVerified()); + } + $this->internalCache->set($user->getUID(), $account); + return $account; + } + + /** + * Converts value (phone number) in E.164 format when it was a valid number + * @throws InvalidArgumentException When the phone number was invalid or no default region is set and the number doesn't start with a country code + */ + protected function sanitizePropertyPhoneNumber(IAccountProperty $property): void { + $defaultRegion = $this->config->getSystemValueString('default_phone_region', ''); + + if ($defaultRegion === '') { + // When no default region is set, only +49… numbers are valid + if (!str_starts_with($property->getValue(), '+')) { + throw new InvalidArgumentException(self::PROPERTY_PHONE); + } + + $defaultRegion = 'EN'; + } + + $phoneNumber = $this->phoneNumberUtil->convertToStandardFormat($property->getValue(), $defaultRegion); + if ($phoneNumber === null) { + throw new InvalidArgumentException(self::PROPERTY_PHONE); + } + $property->setValue($phoneNumber); + } + + /** + * @throws InvalidArgumentException When the website did not have http(s) as protocol or the host name was empty + */ + private function sanitizePropertyWebsite(IAccountProperty $property): void { + $parts = parse_url($property->getValue()); + if (!isset($parts['scheme']) || ($parts['scheme'] !== 'https' && $parts['scheme'] !== 'http')) { + throw new InvalidArgumentException(self::PROPERTY_WEBSITE); + } + + if (!isset($parts['host']) || $parts['host'] === '') { + throw new InvalidArgumentException(self::PROPERTY_WEBSITE); + } + } + + /** + * @throws InvalidArgumentException If the property value is not a valid user handle according to X's rules + */ + private function sanitizePropertyTwitter(IAccountProperty $property): void { + if ($property->getName() === self::PROPERTY_TWITTER) { + $matches = []; + // twitter handles only contain alpha numeric characters and the underscore and must not be longer than 15 characters + if (preg_match('/^@?([a-zA-Z0-9_]{2,15})$/', $property->getValue(), $matches) !== 1) { + throw new InvalidArgumentException(self::PROPERTY_TWITTER); + } + + // drop the leading @ if any to make it the valid handle + $property->setValue($matches[1]); + + } + } + + private function validateBlueSkyHandle(string $text): bool { + if ($text === '') { + return true; + } + + $lowerText = strtolower($text); + + if ($lowerText === 'bsky.social') { + // "bsky.social" itself is not a valid handle + return false; + } + + if (str_ends_with($lowerText, '.bsky.social')) { + $parts = explode('.', $lowerText); + + // Must be exactly: username.bsky.social → 3 parts + if (count($parts) !== 3 || $parts[1] !== 'bsky' || $parts[2] !== 'social') { + return false; + } + + $username = $parts[0]; + + // Must be 3–18 chars, alphanumeric/hyphen, no start/end hyphen + return preg_match('/^[a-z0-9][a-z0-9-]{2,17}$/', $username) === 1; + } + + // Allow custom domains (Bluesky handle via personal domain) + return filter_var($text, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; + } + + + private function sanitizePropertyBluesky(IAccountProperty $property): void { + if ($property->getName() === self::PROPERTY_BLUESKY) { + if (!$this->validateBlueSkyHandle($property->getValue())) { + throw new InvalidArgumentException(self::PROPERTY_BLUESKY); + } + + $property->setValue($property->getValue()); + } + } + + /** + * @throws InvalidArgumentException If the property value is not a valid fediverse handle (username@instance where instance is a valid domain) + */ + private function sanitizePropertyFediverse(IAccountProperty $property): void { + if ($property->getName() === self::PROPERTY_FEDIVERSE) { + $matches = []; + if (preg_match('/^@?([^@\s\/\\\]+)@([^\s\/\\\]+)$/', trim($property->getValue()), $matches) !== 1) { + throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE); + } + + [, $username, $instance] = $matches; + $validated = filter_var($instance, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME); + if ($validated !== $instance) { + throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE); + } + + if ($this->config->getSystemValueBool('has_internet_connection', true)) { + $client = $this->clientService->newClient(); + + try { + // try the public account lookup API of mastodon + $response = $client->get("https://{$instance}/.well-known/webfinger?resource=acct:{$username}@{$instance}"); + // should be a json response with account information + $data = $response->getBody(); + if (is_resource($data)) { + $data = stream_get_contents($data); + } + $decoded = json_decode($data, true); + // ensure the username is the same the user passed + // in this case we can assume this is a valid fediverse server and account + if (!is_array($decoded) || ($decoded['subject'] ?? '') !== "acct:{$username}@{$instance}") { + throw new InvalidArgumentException(); + } + // check for activitypub link + if (is_array($decoded['links']) && isset($decoded['links'])) { + $found = false; + foreach ($decoded['links'] as $link) { + // have application/activity+json or application/ld+json + if (isset($link['type']) && ( + $link['type'] === 'application/activity+json' + || $link['type'] === 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' + )) { + $found = true; + break; + } + } + if (!$found) { + throw new InvalidArgumentException(); + } + } + } catch (InvalidArgumentException) { + throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE); + } catch (\Exception $error) { + $this->logger->error('Could not verify fediverse account', ['exception' => $error, 'instance' => $instance]); + throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE); + } + } + + $property->setValue("$username@$instance"); + } + } + + public function updateAccount(IAccount $account): void { + $this->testValueLengths(iterator_to_array($account->getAllProperties()), true); + try { + $property = $account->getProperty(self::PROPERTY_PHONE); + if ($property->getValue() !== '') { + $this->sanitizePropertyPhoneNumber($property); + } + } catch (PropertyDoesNotExistException $e) { + // valid case, nothing to do + } + + try { + $property = $account->getProperty(self::PROPERTY_WEBSITE); + if ($property->getValue() !== '') { + $this->sanitizePropertyWebsite($property); + } + } catch (PropertyDoesNotExistException $e) { + // valid case, nothing to do + } + + try { + $property = $account->getProperty(self::PROPERTY_TWITTER); + if ($property->getValue() !== '') { + $this->sanitizePropertyTwitter($property); + } + } catch (PropertyDoesNotExistException $e) { + // valid case, nothing to do + } + + try { + $property = $account->getProperty(self::PROPERTY_BLUESKY); + if ($property->getValue() !== '') { + $this->sanitizePropertyBluesky($property); + } + } catch (PropertyDoesNotExistException $e) { + // valid case, nothing to do + } + + try { + $property = $account->getProperty(self::PROPERTY_FEDIVERSE); + if ($property->getValue() !== '') { + $this->sanitizePropertyFediverse($property); + } + } catch (PropertyDoesNotExistException $e) { + // valid case, nothing to do + } + + foreach ($account->getAllProperties() as $property) { + $this->testPropertyScope($property, self::ALLOWED_SCOPES, true); + } + + $oldData = $this->getUser($account->getUser(), false); + $this->updateVerificationStatus($account, $oldData); + $this->checkEmailVerification($account, $oldData); + $this->checkLocalEmailVerification($account, $oldData); + + $data = []; + foreach ($account->getAllProperties() as $property) { + /** @var IAccountProperty $property */ + $data[] = [ + 'name' => $property->getName(), + 'value' => $property->getValue(), + 'scope' => $property->getScope(), + 'verified' => $property->getVerified(), + 'locallyVerified' => $property->getLocallyVerified(), + ]; + } + + $this->updateUser($account->getUser(), $data, $oldData, true); + $this->internalCache->set($account->getUser()->getUID(), $account); } } diff --git a/lib/private/Accounts/AccountProperty.php b/lib/private/Accounts/AccountProperty.php index 97f9b1c356f..3a89e9bbc7a 100644 --- a/lib/private/Accounts/AccountProperty.php +++ b/lib/private/Accounts/AccountProperty.php @@ -3,55 +3,39 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @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 <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Accounts; +use InvalidArgumentException; +use OCP\Accounts\IAccountManager; use OCP\Accounts\IAccountProperty; class AccountProperty implements IAccountProperty { + /** + * @var IAccountManager::SCOPE_* + */ + private string $scope; + private string $locallyVerified = IAccountManager::NOT_VERIFIED; - /** @var string */ - private $name; - /** @var string */ - private $value; - /** @var string */ - private $scope; - /** @var string */ - private $verified; - - public function __construct(string $name, string $value, string $scope, string $verified) { - $this->name = $name; - $this->value = $value; - $this->scope = $scope; - $this->verified = $verified; + public function __construct( + private string $name, + private string $value, + string $scope, + private string $verified, + private string $verificationData, + ) { + $this->setScope($scope); } - public function jsonSerialize() { + public function jsonSerialize(): array { return [ 'name' => $this->getName(), 'value' => $this->getValue(), 'scope' => $this->getScope(), - 'verified' => $this->getVerified() + 'verified' => $this->getVerified(), + 'verificationData' => $this->getVerificationData(), ]; } @@ -59,9 +43,6 @@ class AccountProperty implements IAccountProperty { * Set the value of a property * * @since 15.0.0 - * - * @param string $value - * @return IAccountProperty */ public function setValue(string $value): IAccountProperty { $this->value = $value; @@ -72,11 +53,12 @@ class AccountProperty implements IAccountProperty { * Set the scope of a property * * @since 15.0.0 - * - * @param string $scope - * @return IAccountProperty */ public function setScope(string $scope): IAccountProperty { + if (!in_array($scope, IAccountManager::ALLOWED_SCOPES, )) { + throw new InvalidArgumentException('Invalid scope'); + } + /** @var IAccountManager::SCOPE_* $scope */ $this->scope = $scope; return $this; } @@ -85,9 +67,6 @@ class AccountProperty implements IAccountProperty { * Set the verification status of a property * * @since 15.0.0 - * - * @param string $verified - * @return IAccountProperty */ public function setVerified(string $verified): IAccountProperty { $this->verified = $verified; @@ -98,8 +77,6 @@ class AccountProperty implements IAccountProperty { * Get the name of a property * * @since 15.0.0 - * - * @return string */ public function getName(): string { return $this->name; @@ -109,8 +86,6 @@ class AccountProperty implements IAccountProperty { * Get the value of a property * * @since 15.0.0 - * - * @return string */ public function getValue(): string { return $this->value; @@ -120,8 +95,6 @@ class AccountProperty implements IAccountProperty { * Get the scope of a property * * @since 15.0.0 - * - * @return string */ public function getScope(): string { return $this->scope; @@ -131,10 +104,33 @@ class AccountProperty implements IAccountProperty { * Get the verification status of a property * * @since 15.0.0 - * - * @return string */ public function getVerified(): string { return $this->verified; } + + public function setVerificationData(string $verificationData): IAccountProperty { + $this->verificationData = $verificationData; + return $this; + } + + public function getVerificationData(): string { + return $this->verificationData; + } + + public function setLocallyVerified(string $verified): IAccountProperty { + if (!in_array($verified, [ + IAccountManager::NOT_VERIFIED, + IAccountManager::VERIFICATION_IN_PROGRESS, + IAccountManager::VERIFIED, + ])) { + throw new InvalidArgumentException('Provided verification value is invalid'); + } + $this->locallyVerified = $verified; + return $this; + } + + public function getLocallyVerified(): string { + return $this->locallyVerified; + } } diff --git a/lib/private/Accounts/AccountPropertyCollection.php b/lib/private/Accounts/AccountPropertyCollection.php new file mode 100644 index 00000000000..75eea76e686 --- /dev/null +++ b/lib/private/Accounts/AccountPropertyCollection.php @@ -0,0 +1,90 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Accounts; + +use InvalidArgumentException; +use OCP\Accounts\IAccountManager; +use OCP\Accounts\IAccountProperty; +use OCP\Accounts\IAccountPropertyCollection; + +class AccountPropertyCollection implements IAccountPropertyCollection { + /** @var IAccountProperty[] */ + protected array $properties = []; + + public function __construct( + protected string $collectionName, + ) { + } + + public function setProperties(array $properties): IAccountPropertyCollection { + /** @var IAccountProperty $property */ + $this->properties = []; + foreach ($properties as $property) { + $this->addProperty($property); + } + return $this; + } + + public function getProperties(): array { + return $this->properties; + } + + public function addProperty(IAccountProperty $property): IAccountPropertyCollection { + if ($property->getName() !== $this->collectionName) { + throw new InvalidArgumentException('Provided property does not match collection name'); + } + $this->properties[] = $property; + return $this; + } + + public function addPropertyWithDefaults(string $value): IAccountPropertyCollection { + $property = new AccountProperty( + $this->collectionName, + $value, + IAccountManager::SCOPE_LOCAL, + IAccountManager::NOT_VERIFIED, + '' + ); + $this->addProperty($property); + return $this; + } + + public function removeProperty(IAccountProperty $property): IAccountPropertyCollection { + $ref = array_search($property, $this->properties, true); + if ($ref !== false) { + unset($this->properties[$ref]); + } + return $this; + } + + public function getPropertyByValue(string $value): ?IAccountProperty { + foreach ($this->properties as $i => $property) { + if ($property->getValue() === $value) { + return $property; + } + } + return null; + } + + public function removePropertyByValue(string $value): IAccountPropertyCollection { + foreach ($this->properties as $i => $property) { + if ($property->getValue() === $value) { + unset($this->properties[$i]); + } + } + return $this; + } + + public function jsonSerialize(): array { + return [$this->collectionName => $this->properties]; + } + + public function getName(): string { + return $this->collectionName; + } +} diff --git a/lib/private/Accounts/Hooks.php b/lib/private/Accounts/Hooks.php index d4e9637dfee..12f2b4777f8 100644 --- a/lib/private/Accounts/Hooks.php +++ b/lib/private/Accounts/Hooks.php @@ -1,91 +1,59 @@ <?php + /** - * @copyright Copyright (c) 2016 Bjoern Schiessle <bjoern@schiessle.org> - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * - * @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 <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Accounts; use OCP\Accounts\IAccountManager; +use OCP\Accounts\PropertyDoesNotExistException; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; use OCP\IUser; +use OCP\User\Events\UserChangedEvent; use Psr\Log\LoggerInterface; -class Hooks { - - /** @var AccountManager|null */ - private $accountManager; - - /** @var LoggerInterface */ - private $logger; - - public function __construct(LoggerInterface $logger) { - $this->logger = $logger; +/** + * @template-implements IEventListener<UserChangedEvent> + */ +class Hooks implements IEventListener { + public function __construct( + private LoggerInterface $logger, + private IAccountManager $accountManager, + ) { } /** * update accounts table if email address or display name was changed from outside - * - * @param array $params */ - public function changeUserHook($params) { - $accountManager = $this->getAccountManager(); - - /** @var IUser $user */ - $user = isset($params['user']) ? $params['user'] : null; - $feature = isset($params['feature']) ? $params['feature'] : null; - $newValue = isset($params['value']) ? $params['value'] : null; - - if (is_null($user) || is_null($feature) || is_null($newValue)) { - $this->logger->warning('Missing expected parameters in change user hook'); + public function changeUserHook(IUser $user, string $feature, $newValue): void { + $account = $this->accountManager->getAccount($user); + + try { + switch ($feature) { + case 'eMailAddress': + $property = $account->getProperty(IAccountManager::PROPERTY_EMAIL); + break; + case 'displayName': + $property = $account->getProperty(IAccountManager::PROPERTY_DISPLAYNAME); + break; + } + } catch (PropertyDoesNotExistException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); return; } - $accountData = $accountManager->getUser($user); - - switch ($feature) { - case 'eMailAddress': - if ($accountData[IAccountManager::PROPERTY_EMAIL]['value'] !== $newValue) { - $accountData[IAccountManager::PROPERTY_EMAIL]['value'] = $newValue; - $accountManager->updateUser($user, $accountData); - } - break; - case 'displayName': - if ($accountData[IAccountManager::PROPERTY_DISPLAYNAME]['value'] !== $newValue) { - $accountData[IAccountManager::PROPERTY_DISPLAYNAME]['value'] = $newValue; - $accountManager->updateUser($user, $accountData); - } - break; + if (isset($property) && $property->getValue() !== (string)$newValue) { + $property->setValue($newValue); + $this->accountManager->updateAccount($account); } } - /** - * return instance of accountManager - * - * @return AccountManager - */ - protected function getAccountManager(): AccountManager { - if ($this->accountManager === null) { - $this->accountManager = \OC::$server->query(AccountManager::class); + public function handle(Event $event): void { + if (!$event instanceof UserChangedEvent) { + return; } - return $this->accountManager; + $this->changeUserHook($event->getUser(), $event->getFeature(), $event->getValue()); } } diff --git a/lib/private/Accounts/TAccountsHelper.php b/lib/private/Accounts/TAccountsHelper.php new file mode 100644 index 00000000000..69d25d9a933 --- /dev/null +++ b/lib/private/Accounts/TAccountsHelper.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Accounts; + +use OCP\Accounts\IAccountManager; + +trait TAccountsHelper { + /** + * returns whether the property is a collection + */ + protected function isCollection(string $propertyName): bool { + return in_array( + $propertyName, + [ + IAccountManager::COLLECTION_EMAIL, + ], + true + ); + } +} |