owncloud/core

View on GitHub
lib/private/User/SyncService.php

Summary

Maintainability
C
1 day
Test Coverage
<?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);
        }
    }
}