1<?php
2/**
3 * @author Jörn Friedrich Dreyer <jfd@butonic.de>
4 * @author Thomas Müller <thomas.mueller@tmit.eu>
5 *
6 * @copyright Copyright (c) 2018, ownCloud GmbH
7 * @license AGPL-3.0
8 *
9 * This code is free software: you can redistribute it and/or modify
10 * it under the terms of the GNU Affero General Public License, version 3,
11 * as published by the Free Software Foundation.
12 *
13 * This program is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 * GNU Affero General Public License for more details.
17 *
18 * You should have received a copy of the GNU Affero General Public License, version 3,
19 * along with this program.  If not, see <http://www.gnu.org/licenses/>
20 *
21 */
22namespace OC\User;
23
24use OCP\AppFramework\Db\DoesNotExistException;
25use OCP\IConfig;
26use OCP\ILogger;
27use OCP\PreConditionNotMetException;
28use OCP\User\IProvidesDisplayNameBackend;
29use OCP\User\IProvidesEMailBackend;
30use OCP\User\IProvidesExtendedSearchBackend;
31use OCP\User\IProvidesHomeBackend;
32use OCP\User\IProvidesQuotaBackend;
33use OCP\User\IProvidesUserNameBackend;
34use OCP\UserInterface;
35use OCP\AppFramework\Db\MultipleObjectsReturnedException;
36
37/**
38 * Class SyncService
39 *
40 * All users in a user backend are transferred into the account table.
41 * In case a user is know all preferences will be transferred from the table
42 * oc_preferences into the account table.
43 *
44 * @package OC\User
45 */
46class SyncService {
47
48	/** @var IConfig */
49	private $config;
50	/** @var ILogger */
51	private $logger;
52	/** @var AccountMapper */
53	private $mapper;
54
55	/**
56	 * SyncService constructor.
57	 *
58	 * @param IConfig $config
59	 * @param ILogger $logger
60	 * @param AccountMapper $mapper
61	 */
62	public function __construct(
63		IConfig $config,
64		ILogger $logger,
65		AccountMapper $mapper
66	) {
67		$this->config = $config;
68		$this->logger = $logger;
69		$this->mapper = $mapper;
70	}
71
72	/**
73	 * For unit tests
74	 * @param AccountMapper $mapper
75	 */
76	public function setAccountMapper(AccountMapper $mapper) {
77		$this->mapper = $mapper;
78	}
79
80	/**
81	 * @param UserInterface $backend the backend to check
82	 * @param \Closure $callback is called for every user to allow progress display
83	 * @return array[] the first array contains a uid => account map of users that were removed in the external backend
84	 *                 the second array contains a uid => account map of users that are not enabled in oc, but are available in the external backend
85	 */
86	public function analyzeExistingUsers(UserInterface $backend, \Closure $callback) {
87		$removed = [];
88		$reappeared = [];
89		$backendClass = \get_class($backend);
90		$this->mapper->callForUsers(function (Account $a) use (&$removed, &$reappeared, $backend, $backendClass, $callback) {
91			// Check if the backend matches handles this user
92			$this->checkIfAccountReappeared($a, $removed, $reappeared, $backend, $backendClass);
93			$callback($a);
94		}, '', false, null, null);
95		return [$removed, $reappeared];
96	}
97
98	/**
99	 * Checks a backend to see if a user reappeared relative to the accounts table
100	 * @param Account $a
101	 * @param array $removed
102	 * @param array $reappeared
103	 * @param UserInterface $backend
104	 * @param $backendClass
105	 * @return void
106	 */
107	private function checkIfAccountReappeared(Account $a, array &$removed, array &$reappeared, UserInterface $backend, $backendClass) {
108		if ($a->getBackend() === $backendClass) {
109			// Does the backend have this user still
110			if ($backend->userExists($a->getUserId())) {
111				// Is the user not enabled currently?
112				if ($a->getState() !== Account::STATE_ENABLED) {
113					$reappeared[$a->getUserId()] = $a;
114				}
115			} else {
116				// The backend no longer has this user
117				$removed[$a->getUserId()] = $a;
118			}
119		}
120	}
121
122	/**
123	 * @param UserInterface $backend to sync
124	 * @param \Traversable $userIds of users
125	 * @param \Closure $callback is called for every user to progress display
126	 */
127	public function run(UserInterface $backend, \Traversable $userIds, \Closure $callback = null) {
128		// update existing and insert new users
129		foreach ($userIds as $uid) {
130			try {
131				$account = $this->createOrSyncAccount($uid, $backend);
132				$uid = $account->getUserId(); // get correct case
133				// clean the user's preferences
134				$this->cleanPreferences($uid);
135				if (\is_callable($callback)) {
136					$callback($uid, null);
137				}
138			} catch (\Exception $e) {
139				// Error syncing this user
140				$backendClass = \get_class($backend);
141				$this->logger->logException($e, ['message' => "Error syncing user with uid: $uid and backend: $backendClass"]);
142				if (\is_callable($callback)) {
143					$callback($uid, $e);
144				}
145			}
146		}
147	}
148
149	/**
150	 * @param Account $a
151	 */
152	private function syncState(Account $a) {
153		$uid = $a->getUserId();
154		list($hasKey, $value) = $this->readUserConfig($uid, 'core', 'enabled');
155		if ($hasKey) {
156			if ($value === 'true') {
157				$a->setState(Account::STATE_ENABLED);
158			} else {
159				$a->setState(Account::STATE_DISABLED);
160			}
161			if (\array_key_exists('state', $a->getUpdatedFields())) {
162				if ($value === 'true') {
163					$this->logger->debug(
164						"Enabling <$uid>",
165						['app' => self::class]
166					);
167				} else {
168					$this->logger->debug(
169						"Disabling <$uid>",
170						['app' => self::class]
171					);
172				}
173			}
174		}
175	}
176
177	/**
178	 * @param Account $a
179	 */
180	private function syncLastLogin(Account $a) {
181		$uid = $a->getUserId();
182		list($hasKey, $value) = $this->readUserConfig($uid, 'login', 'lastLogin');
183		if ($hasKey) {
184			$a->setLastLogin($value);
185			if (\array_key_exists('lastLogin', $a->getUpdatedFields())) {
186				$this->logger->debug(
187					"Setting lastLogin for <$uid> to <$value>",
188					['app' => self::class]
189				);
190			}
191		}
192	}
193
194	/**
195	 * @param Account $a
196	 * @param UserInterface $backend
197	 */
198	private function syncEmail(Account $a, UserInterface $backend) {
199		$uid = $a->getUserId();
200		$email = null;
201		if ($backend instanceof IProvidesEMailBackend) {
202			$email = $backend->getEMailAddress($uid);
203			$a->setEmail($email);
204		} else {
205			list($hasKey, $email) = $this->readUserConfig($uid, 'settings', 'email');
206			if ($hasKey) {
207				$a->setEmail($email);
208			}
209		}
210		if (\array_key_exists('email', $a->getUpdatedFields())) {
211			$this->logger->debug(
212				"Setting email for <$uid> to <$email>",
213				['app' => self::class]
214			);
215		}
216	}
217
218	/**
219	 * @param Account $a
220	 * @param UserInterface $backend
221	 */
222	private function syncQuota(Account $a, UserInterface $backend) {
223		$uid = $a->getUserId();
224		$quota = null;
225		if ($backend instanceof IProvidesQuotaBackend) {
226			$quota = $backend->getQuota($uid);
227			if ($quota !== null) {
228				$a->setQuota($quota);
229			}
230		}
231		if ($quota === null) {
232			list($hasKey, $quota) = $this->readUserConfig($uid, 'files', 'quota');
233			if ($hasKey) {
234				$a->setQuota($quota);
235			}
236		}
237		if (\array_key_exists('quota', $a->getUpdatedFields())) {
238			$this->logger->debug(
239				"Setting quota for <$uid> to <$quota>",
240				['app' => self::class]
241			);
242		}
243	}
244
245	/**
246	 * @param Account $a
247	 * @param UserInterface $backend
248	 */
249	private function syncHome(Account $a, UserInterface $backend) {
250		// Fallback for backends that dont yet use the new interfaces
251		$proividesHome = $backend instanceof IProvidesHomeBackend || $backend->implementsActions(\OC_User_Backend::GET_HOME);
252		$uid = $a->getUserId();
253		// Log when the backend returns a string that is a different home to the current value
254		if ($proividesHome && \is_string($backend->getHome($uid)) && $a->getHome() !== $backend->getHome($uid)) {
255			$existing = $a->getHome();
256			$backendHome = $backend->getHome($uid);
257			$class = \get_class($backend);
258			if ($existing !== '') {
259				$this->logger->error("User backend $class is returning home: $backendHome for user: $uid which differs from existing value: $existing");
260			}
261		}
262		// Home is handled differently, it should only be set on account creation, when there is no home already set
263		// Otherwise it could change on a sync and result in a new user folder being created
264		if ($a->getHome() === '') {
265			$home = false;
266			if ($proividesHome) {
267				$home = $backend->getHome($uid);
268			}
269			if (!\is_string($home) || $home[0] !== '/') {
270				$home = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') . "/$uid";
271				$this->logger->debug(
272					'User backend ' .\get_class($backend)." provided no home for <$uid>",
273					['app' => self::class]
274				);
275			}
276			// This will set the home if not provided by the backend
277			$a->setHome($home);
278			if (\array_key_exists('home', $a->getUpdatedFields())) {
279				$this->logger->debug(
280					"Setting home for <$uid> to <$home>",
281					['app' => self::class]
282				);
283			}
284		}
285	}
286
287	/**
288	 * @param Account $a
289	 * @param UserInterface $backend
290	 */
291	private function syncDisplayName(Account $a, UserInterface $backend) {
292		$uid = $a->getUserId();
293		if ($backend instanceof IProvidesDisplayNameBackend || $backend->implementsActions(\OC_User_Backend::GET_DISPLAYNAME)) {
294			$displayName = $backend->getDisplayName($uid);
295			$a->setDisplayName($displayName);
296			if (\array_key_exists('displayName', $a->getUpdatedFields())) {
297				$this->logger->debug(
298					"Setting displayName for <$uid> to <$displayName>",
299					['app' => self::class]
300				);
301			}
302		}
303	}
304
305	/**
306	 * TODO store username in account table instead of user preferences
307	 *
308	 * @param Account $a
309	 * @param UserInterface $backend
310	 */
311	private function syncUserName(Account $a, UserInterface $backend) {
312		$uid = $a->getUserId();
313		if ($backend instanceof IProvidesUserNameBackend) {
314			$userName = $backend->getUserName($uid);
315			$currentUserName = $this->config->getUserValue($uid, 'core', 'username', null);
316			if ($userName !== $currentUserName) {
317				try {
318					$this->config->setUserValue($uid, 'core', 'username', $userName);
319				} catch (PreConditionNotMetException $e) {
320					// ignore, because precondition is empty
321				}
322				$this->logger->debug(
323					"Setting userName for <$uid> from <$currentUserName> to <$userName>",
324					['app' => self::class]
325				);
326			}
327		}
328	}
329
330	/**
331	 * @param Account $a
332	 * @param UserInterface $backend
333	 */
334	private function syncSearchTerms(Account $a, UserInterface $backend) {
335		$uid = $a->getUserId();
336		if ($backend instanceof IProvidesExtendedSearchBackend) {
337			$searchTerms = $backend->getSearchTerms($uid);
338			$a->setSearchTerms($searchTerms);
339			if ($a->haveTermsChanged()) {
340				$logTerms = \implode('|', $searchTerms);
341				$this->logger->debug(
342					"Setting searchTerms for <$uid> to <$logTerms>",
343					['app' => self::class]
344				);
345			}
346		}
347	}
348
349	/**
350	 * @param Account $a
351	 * @param UserInterface $backend of the user
352	 * @return Account
353	 */
354	public function syncAccount(Account $a, UserInterface $backend) {
355		$this->syncState($a);
356		$this->syncLastLogin($a);
357		$this->syncEmail($a, $backend);
358		$this->syncQuota($a, $backend);
359		$this->syncHome($a, $backend);
360		$this->syncDisplayName($a, $backend);
361		$this->syncUserName($a, $backend);
362		$this->syncSearchTerms($a, $backend);
363		return $a;
364	}
365
366	/**
367	 * @param $uid
368	 * @param UserInterface $backend
369	 * @return Account
370	 * @throws \Exception
371	 * @throws \InvalidArgumentException if you try to sync with a backend
372	 * that doesnt match an existing account
373	 */
374	public function createOrSyncAccount($uid, UserInterface $backend) {
375		// Try to find the account based on the uid
376		try {
377			$account = $this->mapper->getByUid($uid);
378			// Check the backend matches
379			$existingAccountBackend = \get_class($backend);
380			if ($account->getBackend() !== $existingAccountBackend) {
381				$this->logger->warning(
382					"User <$uid> already provided by another backend({$account->getBackend()} !== $existingAccountBackend), skipping.",
383					['app' => self::class]
384				);
385				throw new \InvalidArgumentException('Returned account has different backend to the requested backend for sync');
386			}
387		} catch (DoesNotExistException $e) {
388			// Create a new account for this uid and backend pairing and sync
389			$account = $this->createNewAccount(\get_class($backend), $uid);
390		} catch (MultipleObjectsReturnedException $e) {
391			throw new \Exception("The database returned multiple accounts for this uid: $uid");
392		}
393
394		// The account exists, sync
395		$account = $this->syncAccount($account, $backend);
396		if ($account->getId() === null) {
397			// New account, insert
398			$this->mapper->insert($account);
399		} else {
400			$this->mapper->update($account);
401		}
402		return $account;
403	}
404
405	/**
406	 * @param string $backend of the user
407	 * @param string $uid of the user
408	 * @return Account
409	 */
410	public function createNewAccount($backend, $uid) {
411		$this->logger->info("Creating new account with UID $uid and backend $backend");
412		$a = new Account();
413		$a->setUserId($uid);
414		$a->setState(Account::STATE_ENABLED);
415		$a->setBackend($backend);
416		return $a;
417	}
418
419	/**
420	 * @param string $uid
421	 * @param string $app
422	 * @param string $key
423	 * @return array
424	 */
425	private function readUserConfig($uid, $app, $key) {
426		$keys = $this->config->getUserKeys($uid, $app);
427		if (\in_array($key, $keys, true)) {
428			$enabled = $this->config->getUserValue($uid, $app, $key);
429			return [true, $enabled];
430		}
431		return [false, null];
432	}
433
434	/**
435	 * @param string $uid
436	 */
437	private function cleanPreferences($uid) {
438		$this->deletePreferenceIfExists($uid, 'core', 'enabled');
439		$this->deletePreferenceIfExists($uid, 'login', 'lastLogin');
440		$this->deletePreferenceIfExists($uid, 'settings', 'email');
441		$this->deletePreferenceIfExists($uid, 'files', 'quota');
442	}
443
444	private function deletePreferenceIfExists($uid, $app, $key) {
445		if ($this->config->getUserValue($uid, $app, $key, null) !== null) {
446			$this->config->deleteUserValue($uid, $app, $key);
447		}
448	}
449}
450