lib/private/User/SyncService.php
<?php
/**
* @author Jörn Friedrich Dreyer <jfd@butonic.de>
* @author Thomas Müller <thomas.mueller@tmit.eu>
*
* @copyright Copyright (c) 2018, ownCloud GmbH
* @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/>
*
*/
namespace OC\User;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IConfig;
use OCP\ILogger;
use OCP\PreConditionNotMetException;
use OCP\User\IProvidesDisplayNameBackend;
use OCP\User\IProvidesEMailBackend;
use OCP\User\IProvidesExtendedSearchBackend;
use OCP\User\IProvidesHomeBackend;
use OCP\User\IProvidesQuotaBackend;
use OCP\User\IProvidesUserNameBackend;
use OCP\UserInterface;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
/**
* Class SyncService
*
* All users in a user backend are transferred into the account table.
* In case a user is know all preferences will be transferred from the table
* oc_preferences into the account table.
*
* @package OC\User
*/
class SyncService {
/** @var IConfig */
private $config;
/** @var ILogger */
private $logger;
/** @var AccountMapper */
private $mapper;
/**
* SyncService constructor.
*
* @param IConfig $config
* @param ILogger $logger
* @param AccountMapper $mapper
*/
public function __construct(
IConfig $config,
ILogger $logger,
AccountMapper $mapper
) {
$this->config = $config;
$this->logger = $logger;
$this->mapper = $mapper;
}
/**
* For unit tests
* @param AccountMapper $mapper
*/
public function setAccountMapper(AccountMapper $mapper) {
$this->mapper = $mapper;
}
/**
* @param UserInterface $backend the backend to check
* @param \Closure $callback is called for every user to allow progress display
* @return array[] the first array contains a uid => account map of users that were removed in the external backend
* the second array contains a uid => account map of users that are not enabled in oc, but are available in the external backend
*/
public function analyzeExistingUsers(UserInterface $backend, \Closure $callback) {
$removed = [];
$reappeared = [];
$backendClass = \get_class($backend);
$this->mapper->callForUsers(function (Account $a) use (&$removed, &$reappeared, $backend, $backendClass, $callback) {
// Check if the backend matches handles this user
$this->checkIfAccountReappeared($a, $removed, $reappeared, $backend, $backendClass);
$callback($a);
}, '', false, null, null);
return [$removed, $reappeared];
}
/**
* Checks a backend to see if a user reappeared relative to the accounts table
* @param Account $a
* @param array $removed
* @param array $reappeared
* @param UserInterface $backend
* @param $backendClass
* @return void
*/
private function checkIfAccountReappeared(Account $a, array &$removed, array &$reappeared, UserInterface $backend, $backendClass) {
if ($a->getBackend() === $backendClass) {
// Does the backend have this user still
if ($backend->userExists($a->getUserId())) {
// Is the user not enabled currently?
if ($a->getState() !== Account::STATE_ENABLED) {
$reappeared[$a->getUserId()] = $a;
}
} else {
// The backend no longer has this user
$removed[$a->getUserId()] = $a;
}
}
}
/**
* @param UserInterface $backend to sync
* @param \Traversable $userIds of users
* @param \Closure $callback is called for every user to progress display
*/
public function run(UserInterface $backend, \Traversable $userIds, \Closure $callback = null) {
// update existing and insert new users
foreach ($userIds as $uid) {
try {
$account = $this->createOrSyncAccount($uid, $backend);
$uid = $account->getUserId(); // get correct case
// clean the user's preferences
$this->cleanPreferences($uid);
if (\is_callable($callback)) {
$callback($uid, null);
}
} catch (\Exception $e) {
// Error syncing this user
$backendClass = \get_class($backend);
$this->logger->logException($e, ['message' => "Error syncing user with uid: $uid and backend: $backendClass"]);
if (\is_callable($callback)) {
$callback($uid, $e);
}
}
}
}
/**
* @param Account $a
*/
private function syncState(Account $a) {
$uid = $a->getUserId();
list($hasKey, $value) = $this->readUserConfig($uid, 'core', 'enabled');
if ($hasKey) {
if ($value === 'true') {
$a->setState(Account::STATE_ENABLED);
} else {
$a->setState(Account::STATE_DISABLED);
}
if (\array_key_exists('state', $a->getUpdatedFields())) {
if ($value === 'true') {
$this->logger->debug(
"Enabling <$uid>",
['app' => self::class]
);
} else {
$this->logger->debug(
"Disabling <$uid>",
['app' => self::class]
);
}
}
}
}
/**
* @param Account $a
*/
private function syncLastLogin(Account $a) {
$uid = $a->getUserId();
list($hasKey, $value) = $this->readUserConfig($uid, 'login', 'lastLogin');
if ($hasKey) {
$a->setLastLogin($value);
if (\array_key_exists('lastLogin', $a->getUpdatedFields())) {
$this->logger->debug(
"Setting lastLogin for <$uid> to <$value>",
['app' => self::class]
);
}
}
}
/**
* @param Account $a
* @param UserInterface $backend
*/
private function syncEmail(Account $a, UserInterface $backend) {
$uid = $a->getUserId();
$email = null;
if ($backend instanceof IProvidesEMailBackend) {
$email = $backend->getEMailAddress($uid);
$a->setEmail($email);
} else {
list($hasKey, $email) = $this->readUserConfig($uid, 'settings', 'email');
if ($hasKey) {
$a->setEmail($email);
}
}
if (\array_key_exists('email', $a->getUpdatedFields())) {
$this->logger->debug(
"Setting email for <$uid> to <$email>",
['app' => self::class]
);
}
}
/**
* @param Account $a
* @param UserInterface $backend
*/
private function syncQuota(Account $a, UserInterface $backend) {
$uid = $a->getUserId();
$quota = null;
if ($backend instanceof IProvidesQuotaBackend) {
$quota = $backend->getQuota($uid);
if ($quota !== null) {
$a->setQuota($quota);
}
}
if ($quota === null) {
list($hasKey, $quota) = $this->readUserConfig($uid, 'files', 'quota');
if ($hasKey) {
$a->setQuota($quota);
}
}
if (\array_key_exists('quota', $a->getUpdatedFields())) {
$this->logger->debug(
"Setting quota for <$uid> to <$quota>",
['app' => self::class]
);
}
}
/**
* @param Account $a
* @param UserInterface $backend
*/
private function syncHome(Account $a, UserInterface $backend) {
// Fallback for backends that dont yet use the new interfaces
$providesHome = $backend instanceof IProvidesHomeBackend || $backend->implementsActions(\OC_User_Backend::GET_HOME);
$uid = $a->getUserId();
// Log when the backend returns a string that is a different home to the current value
if ($providesHome && \is_string($backend->getHome($uid)) && $a->getHome() !== $backend->getHome($uid)) {
$existing = $a->getHome();
$backendHome = $backend->getHome($uid);
$class = \get_class($backend);
if ($existing !== '') {
$this->logger->error("User backend $class is returning home: $backendHome for user: $uid which differs from existing value: $existing");
}
}
// Home is handled differently, it should only be set on account creation, when there is no home already set
// Otherwise it could change on a sync and result in a new user folder being created
if ($a->getHome() === '') {
$home = false;
if ($providesHome) {
$home = $backend->getHome($uid);
}
if (!\is_string($home) || $home[0] !== '/') {
$home = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') . "/$uid";
$this->logger->debug(
'User backend ' .\get_class($backend)." provided no home for <$uid>",
['app' => self::class]
);
}
// This will set the home if not provided by the backend
$a->setHome($home);
if (\array_key_exists('home', $a->getUpdatedFields())) {
$this->logger->debug(
"Setting home for <$uid> to <$home>",
['app' => self::class]
);
}
}
}
/**
* @param Account $a
* @param UserInterface $backend
*/
private function syncDisplayName(Account $a, UserInterface $backend) {
$uid = $a->getUserId();
if ($backend instanceof IProvidesDisplayNameBackend || $backend->implementsActions(\OC_User_Backend::GET_DISPLAYNAME)) {
$displayName = $backend->getDisplayName($uid);
$a->setDisplayName($displayName);
if (\array_key_exists('displayName', $a->getUpdatedFields())) {
$this->logger->debug(
"Setting displayName for <$uid> to <$displayName>",
['app' => self::class]
);
}
}
}
/**
* TODO store username in account table instead of user preferences
*
* @param Account $a
* @param UserInterface $backend
*/
private function syncUserName(Account $a, UserInterface $backend) {
$uid = $a->getUserId();
if ($backend instanceof IProvidesUserNameBackend) {
$userName = $backend->getUserName($uid);
$currentUserName = $this->config->getUserValue($uid, 'core', 'username', null);
if ($userName !== $currentUserName) {
try {
$this->config->setUserValue($uid, 'core', 'username', $userName);
} catch (PreConditionNotMetException $e) {
// ignore, because precondition is empty
}
$this->logger->debug(
"Setting userName for <$uid> from <$currentUserName> to <$userName>",
['app' => self::class]
);
}
}
}
/**
* @param Account $a
* @param UserInterface $backend
*/
private function syncSearchTerms(Account $a, UserInterface $backend) {
$uid = $a->getUserId();
if ($backend instanceof IProvidesExtendedSearchBackend) {
$searchTerms = $backend->getSearchTerms($uid);
$a->setSearchTerms($searchTerms);
if ($a->haveTermsChanged()) {
$logTerms = \implode('|', $searchTerms);
$this->logger->debug(
"Setting searchTerms for <$uid> to <$logTerms>",
['app' => self::class]
);
}
}
}
/**
* @param Account $a
* @param UserInterface $backend of the user
* @return Account
*/
public function syncAccount(Account $a, UserInterface $backend) {
$this->syncState($a);
$this->syncLastLogin($a);
$this->syncEmail($a, $backend);
$this->syncQuota($a, $backend);
$this->syncHome($a, $backend);
$this->syncDisplayName($a, $backend);
$this->syncUserName($a, $backend);
$this->syncSearchTerms($a, $backend);
return $a;
}
/**
* @param $uid
* @param UserInterface $backend
* @return Account
* @throws \Exception
* @throws \InvalidArgumentException if you try to sync with a backend
* that doesnt match an existing account
*/
public function createOrSyncAccount($uid, UserInterface $backend) {
// Try to find the account based on the uid
try {
$account = $this->mapper->getByUid($uid);
// Check the backend matches
$existingAccountBackend = \get_class($backend);
if ($account->getBackend() !== $existingAccountBackend) {
$this->logger->warning(
"User <$uid> already provided by another backend({$account->getBackend()} !== $existingAccountBackend), skipping.",
['app' => self::class]
);
throw new \InvalidArgumentException('Returned account has different backend to the requested backend for sync');
}
} catch (DoesNotExistException $e) {
// Create a new account for this uid and backend pairing and sync
$account = $this->createNewAccount(\get_class($backend), $uid);
} catch (MultipleObjectsReturnedException $e) {
throw new \Exception("The database returned multiple accounts for this uid: $uid");
}
// The account exists, sync
$account = $this->syncAccount($account, $backend);
if ($account->getId() === null) {
// New account, insert
$this->mapper->insert($account);
} else {
$this->mapper->update($account);
}
return $account;
}
/**
* @param string $backend of the user
* @param string $uid of the user
* @return Account
*/
public function createNewAccount($backend, $uid) {
$this->logger->info("Creating new account with UID $uid and backend $backend");
$a = new Account();
$a->setUserId($uid);
$a->setState(Account::STATE_ENABLED);
$a->setBackend($backend);
return $a;
}
/**
* @param string $uid
* @param string $app
* @param string $key
* @return array
*/
private function readUserConfig($uid, $app, $key) {
$keys = $this->config->getUserKeys($uid, $app);
if (\in_array($key, $keys, true)) {
$enabled = $this->config->getUserValue($uid, $app, $key);
return [true, $enabled];
}
return [false, null];
}
/**
* @param string $uid
*/
private function cleanPreferences($uid) {
$this->deletePreferenceIfExists($uid, 'core', 'enabled');
$this->deletePreferenceIfExists($uid, 'login', 'lastLogin');
$this->deletePreferenceIfExists($uid, 'settings', 'email');
$this->deletePreferenceIfExists($uid, 'files', 'quota');
}
private function deletePreferenceIfExists($uid, $app, $key) {
if ($this->config->getUserValue($uid, $app, $key, null) !== null) {
$this->config->deleteUserValue($uid, $app, $key);
}
}
}