Completed
Push — master ( 9a2cc5...f9cdb9 )
by Joas
38:29 queued 10:02
created
lib/private/Accounts/AccountManager.php 1 patch
Indentation   +785 added lines, -785 removed lines patch added patch discarded remove patch
@@ -52,789 +52,789 @@
 block discarded – undo
52 52
  * @package OC\Accounts
53 53
  */
54 54
 class AccountManager implements IAccountManager {
55
-	use TAccountsHelper;
56
-
57
-	use TProfileHelper;
58
-
59
-	private string $table = 'accounts';
60
-	private string $dataTable = 'accounts_data';
61
-	private ?IL10N $l10n = null;
62
-	private CappedMemoryCache $internalCache;
63
-
64
-	/**
65
-	 * The list of default scopes for each property.
66
-	 */
67
-	public const DEFAULT_SCOPES = [
68
-		self::PROPERTY_ADDRESS => self::SCOPE_LOCAL,
69
-		self::PROPERTY_AVATAR => self::SCOPE_FEDERATED,
70
-		self::PROPERTY_BIOGRAPHY => self::SCOPE_LOCAL,
71
-		self::PROPERTY_BIRTHDATE => self::SCOPE_LOCAL,
72
-		self::PROPERTY_DISPLAYNAME => self::SCOPE_FEDERATED,
73
-		self::PROPERTY_EMAIL => self::SCOPE_FEDERATED,
74
-		self::PROPERTY_FEDIVERSE => self::SCOPE_LOCAL,
75
-		self::PROPERTY_HEADLINE => self::SCOPE_LOCAL,
76
-		self::PROPERTY_ORGANISATION => self::SCOPE_LOCAL,
77
-		self::PROPERTY_PHONE => self::SCOPE_LOCAL,
78
-		self::PROPERTY_PRONOUNS => self::SCOPE_FEDERATED,
79
-		self::PROPERTY_ROLE => self::SCOPE_LOCAL,
80
-		self::PROPERTY_TWITTER => self::SCOPE_LOCAL,
81
-		self::PROPERTY_WEBSITE => self::SCOPE_LOCAL,
82
-	];
83
-
84
-	public function __construct(
85
-		private IDBConnection $connection,
86
-		private IConfig $config,
87
-		private IEventDispatcher $dispatcher,
88
-		private IJobList $jobList,
89
-		private LoggerInterface $logger,
90
-		private IVerificationToken $verificationToken,
91
-		private IMailer $mailer,
92
-		private Defaults $defaults,
93
-		private IFactory $l10nFactory,
94
-		private IURLGenerator $urlGenerator,
95
-		private ICrypto $crypto,
96
-		private IPhoneNumberUtil $phoneNumberUtil,
97
-		private IClientService $clientService,
98
-	) {
99
-		$this->internalCache = new CappedMemoryCache();
100
-	}
101
-
102
-	/**
103
-	 * @param IAccountProperty[] $properties
104
-	 */
105
-	protected function testValueLengths(array $properties, bool $throwOnData = false): void {
106
-		foreach ($properties as $property) {
107
-			if (strlen($property->getValue()) > 2048) {
108
-				if ($throwOnData) {
109
-					throw new InvalidArgumentException($property->getName());
110
-				} else {
111
-					$property->setValue('');
112
-				}
113
-			}
114
-		}
115
-	}
116
-
117
-	protected function testPropertyScope(IAccountProperty $property, array $allowedScopes, bool $throwOnData): void {
118
-		if ($throwOnData && !in_array($property->getScope(), $allowedScopes, true)) {
119
-			throw new InvalidArgumentException('scope');
120
-		}
121
-
122
-		if (
123
-			$property->getScope() === self::SCOPE_PRIVATE
124
-			&& in_array($property->getName(), [self::PROPERTY_DISPLAYNAME, self::PROPERTY_EMAIL])
125
-		) {
126
-			if ($throwOnData) {
127
-				// v2-private is not available for these fields
128
-				throw new InvalidArgumentException('scope');
129
-			} else {
130
-				// default to local
131
-				$property->setScope(self::SCOPE_LOCAL);
132
-			}
133
-		} else {
134
-			$property->setScope($property->getScope());
135
-		}
136
-	}
137
-
138
-	protected function updateUser(IUser $user, array $data, ?array $oldUserData, bool $throwOnData = false): array {
139
-		if ($oldUserData === null) {
140
-			$oldUserData = $this->getUser($user, false);
141
-		}
142
-
143
-		$updated = true;
144
-
145
-		if ($oldUserData !== $data) {
146
-			$this->updateExistingUser($user, $data, $oldUserData);
147
-		} else {
148
-			// nothing needs to be done if new and old data set are the same
149
-			$updated = false;
150
-		}
151
-
152
-		if ($updated) {
153
-			$this->dispatcher->dispatchTyped(new UserUpdatedEvent(
154
-				$user,
155
-				$data,
156
-			));
157
-		}
158
-
159
-		return $data;
160
-	}
161
-
162
-	/**
163
-	 * delete user from accounts table
164
-	 */
165
-	public function deleteUser(IUser $user): void {
166
-		$uid = $user->getUID();
167
-		$query = $this->connection->getQueryBuilder();
168
-		$query->delete($this->table)
169
-			->where($query->expr()->eq('uid', $query->createNamedParameter($uid)))
170
-			->executeStatement();
171
-
172
-		$this->deleteUserData($user);
173
-	}
174
-
175
-	/**
176
-	 * delete user from accounts table
177
-	 */
178
-	public function deleteUserData(IUser $user): void {
179
-		$uid = $user->getUID();
180
-		$query = $this->connection->getQueryBuilder();
181
-		$query->delete($this->dataTable)
182
-			->where($query->expr()->eq('uid', $query->createNamedParameter($uid)))
183
-			->executeStatement();
184
-	}
185
-
186
-	/**
187
-	 * get stored data from a given user
188
-	 */
189
-	protected function getUser(IUser $user, bool $insertIfNotExists = true): array {
190
-		$uid = $user->getUID();
191
-		$query = $this->connection->getQueryBuilder();
192
-		$query->select('data')
193
-			->from($this->table)
194
-			->where($query->expr()->eq('uid', $query->createParameter('uid')))
195
-			->setParameter('uid', $uid);
196
-		$result = $query->executeQuery();
197
-		$accountData = $result->fetchAll();
198
-		$result->closeCursor();
199
-
200
-		if (empty($accountData)) {
201
-			$userData = $this->buildDefaultUserRecord($user);
202
-			if ($insertIfNotExists) {
203
-				$this->insertNewUser($user, $userData);
204
-			}
205
-			return $userData;
206
-		}
207
-
208
-		$userDataArray = $this->importFromJson($accountData[0]['data'], $uid);
209
-		if ($userDataArray === null || $userDataArray === []) {
210
-			return $this->buildDefaultUserRecord($user);
211
-		}
212
-
213
-		return $this->addMissingDefaultValues($userDataArray, $this->buildDefaultUserRecord($user));
214
-	}
215
-
216
-	public function searchUsers(string $property, array $values): array {
217
-		// the value col is limited to 255 bytes. It is used for searches only.
218
-		$values = array_map(function (string $value) {
219
-			return Util::shortenMultibyteString($value, 255);
220
-		}, $values);
221
-		$chunks = array_chunk($values, 500);
222
-		$query = $this->connection->getQueryBuilder();
223
-		$query->select('*')
224
-			->from($this->dataTable)
225
-			->where($query->expr()->eq('name', $query->createNamedParameter($property)))
226
-			->andWhere($query->expr()->in('value', $query->createParameter('values')));
227
-
228
-		$matches = [];
229
-		foreach ($chunks as $chunk) {
230
-			$query->setParameter('values', $chunk, IQueryBuilder::PARAM_STR_ARRAY);
231
-			$result = $query->executeQuery();
232
-
233
-			while ($row = $result->fetch()) {
234
-				$matches[$row['uid']] = $row['value'];
235
-			}
236
-			$result->closeCursor();
237
-		}
238
-
239
-		$result = array_merge($matches, $this->searchUsersForRelatedCollection($property, $values));
240
-
241
-		return array_flip($result);
242
-	}
243
-
244
-	protected function searchUsersForRelatedCollection(string $property, array $values): array {
245
-		return match ($property) {
246
-			IAccountManager::PROPERTY_EMAIL => array_flip($this->searchUsers(IAccountManager::COLLECTION_EMAIL, $values)),
247
-			default => [],
248
-		};
249
-	}
250
-
251
-	/**
252
-	 * check if we need to ask the server for email verification, if yes we create a cronjob
253
-	 */
254
-	protected function checkEmailVerification(IAccount $updatedAccount, array $oldData): void {
255
-		try {
256
-			$property = $updatedAccount->getProperty(self::PROPERTY_EMAIL);
257
-		} catch (PropertyDoesNotExistException $e) {
258
-			return;
259
-		}
260
-
261
-		$oldMailIndex = array_search(self::PROPERTY_EMAIL, array_column($oldData, 'name'), true);
262
-		$oldMail = $oldMailIndex !== false ? $oldData[$oldMailIndex]['value'] : '';
263
-
264
-		if ($oldMail !== $property->getValue()) {
265
-			$this->jobList->add(
266
-				VerifyUserData::class,
267
-				[
268
-					'verificationCode' => '',
269
-					'data' => $property->getValue(),
270
-					'type' => self::PROPERTY_EMAIL,
271
-					'uid' => $updatedAccount->getUser()->getUID(),
272
-					'try' => 0,
273
-					'lastRun' => time()
274
-				]
275
-			);
276
-
277
-			$property->setVerified(self::VERIFICATION_IN_PROGRESS);
278
-		}
279
-	}
280
-
281
-	protected function checkLocalEmailVerification(IAccount $updatedAccount, array $oldData): void {
282
-		$mailCollection = $updatedAccount->getPropertyCollection(self::COLLECTION_EMAIL);
283
-		foreach ($mailCollection->getProperties() as $property) {
284
-			if ($property->getLocallyVerified() !== self::NOT_VERIFIED) {
285
-				continue;
286
-			}
287
-			if ($this->sendEmailVerificationEmail($updatedAccount->getUser(), $property->getValue())) {
288
-				$property->setLocallyVerified(self::VERIFICATION_IN_PROGRESS);
289
-			}
290
-		}
291
-	}
292
-
293
-	protected function sendEmailVerificationEmail(IUser $user, string $email): bool {
294
-		$ref = \substr(hash('sha256', $email), 0, 8);
295
-		$key = $this->crypto->encrypt($email);
296
-		$token = $this->verificationToken->create($user, 'verifyMail' . $ref, $email);
297
-
298
-		$link = $this->urlGenerator->linkToRouteAbsolute(
299
-			'provisioning_api.Verification.verifyMail',
300
-			[
301
-				'userId' => $user->getUID(),
302
-				'token' => $token,
303
-				'key' => $key
304
-			]
305
-		);
306
-
307
-		$emailTemplate = $this->mailer->createEMailTemplate('core.EmailVerification', [
308
-			'link' => $link,
309
-		]);
310
-
311
-		if (!$this->l10n) {
312
-			$this->l10n = $this->l10nFactory->get('core');
313
-		}
314
-
315
-		$emailTemplate->setSubject($this->l10n->t('%s email verification', [$this->defaults->getName()]));
316
-		$emailTemplate->addHeader();
317
-		$emailTemplate->addHeading($this->l10n->t('Email verification'));
318
-
319
-		$emailTemplate->addBodyText(
320
-			htmlspecialchars($this->l10n->t('Click the following button to confirm your email.')),
321
-			$this->l10n->t('Click the following link to confirm your email.')
322
-		);
323
-
324
-		$emailTemplate->addBodyButton(
325
-			htmlspecialchars($this->l10n->t('Confirm your email')),
326
-			$link,
327
-			false
328
-		);
329
-		$emailTemplate->addFooter();
330
-
331
-		try {
332
-			$message = $this->mailer->createMessage();
333
-			$message->setTo([$email => $user->getDisplayName()]);
334
-			$message->setFrom([Util::getDefaultEmailAddress('verification-noreply') => $this->defaults->getName()]);
335
-			$message->useTemplate($emailTemplate);
336
-			$this->mailer->send($message);
337
-		} catch (Exception $e) {
338
-			// Log the exception and continue
339
-			$this->logger->info('Failed to send verification mail', [
340
-				'app' => 'core',
341
-				'exception' => $e
342
-			]);
343
-			return false;
344
-		}
345
-		return true;
346
-	}
347
-
348
-	/**
349
-	 * Make sure that all expected data are set
350
-	 */
351
-	protected function addMissingDefaultValues(array $userData, array $defaultUserData): array {
352
-		foreach ($defaultUserData as $defaultDataItem) {
353
-			// If property does not exist, initialize it
354
-			$userDataIndex = array_search($defaultDataItem['name'], array_column($userData, 'name'));
355
-			if ($userDataIndex === false) {
356
-				$userData[] = $defaultDataItem;
357
-				continue;
358
-			}
359
-
360
-			// Merge and extend default missing values
361
-			$userData[$userDataIndex] = array_merge($defaultDataItem, $userData[$userDataIndex]);
362
-		}
363
-
364
-		return $userData;
365
-	}
366
-
367
-	protected function updateVerificationStatus(IAccount $updatedAccount, array $oldData): void {
368
-		static $propertiesVerifiableByLookupServer = [
369
-			self::PROPERTY_TWITTER,
370
-			self::PROPERTY_FEDIVERSE,
371
-			self::PROPERTY_WEBSITE,
372
-			self::PROPERTY_EMAIL,
373
-		];
374
-
375
-		foreach ($propertiesVerifiableByLookupServer as $propertyName) {
376
-			try {
377
-				$property = $updatedAccount->getProperty($propertyName);
378
-			} catch (PropertyDoesNotExistException $e) {
379
-				continue;
380
-			}
381
-			$wasVerified = isset($oldData[$propertyName])
382
-				&& isset($oldData[$propertyName]['verified'])
383
-				&& $oldData[$propertyName]['verified'] === self::VERIFIED;
384
-			if ((!isset($oldData[$propertyName])
385
-					|| !isset($oldData[$propertyName]['value'])
386
-					|| $property->getValue() !== $oldData[$propertyName]['value'])
387
-				&& ($property->getVerified() !== self::NOT_VERIFIED
388
-					|| $wasVerified)
389
-			) {
390
-				$property->setVerified(self::NOT_VERIFIED);
391
-			}
392
-		}
393
-	}
394
-
395
-	/**
396
-	 * add new user to accounts table
397
-	 */
398
-	protected function insertNewUser(IUser $user, array $data): void {
399
-		$uid = $user->getUID();
400
-		$jsonEncodedData = $this->prepareJson($data);
401
-		$query = $this->connection->getQueryBuilder();
402
-		$query->insert($this->table)
403
-			->values(
404
-				[
405
-					'uid' => $query->createNamedParameter($uid),
406
-					'data' => $query->createNamedParameter($jsonEncodedData),
407
-				]
408
-			)
409
-			->executeStatement();
410
-
411
-		$this->deleteUserData($user);
412
-		$this->writeUserData($user, $data);
413
-	}
414
-
415
-	protected function prepareJson(array $data): string {
416
-		$preparedData = [];
417
-		foreach ($data as $dataRow) {
418
-			$propertyName = $dataRow['name'];
419
-			unset($dataRow['name']);
420
-
421
-			if (isset($dataRow['locallyVerified']) && $dataRow['locallyVerified'] === self::NOT_VERIFIED) {
422
-				// do not write default value, save DB space
423
-				unset($dataRow['locallyVerified']);
424
-			}
425
-
426
-			if (!$this->isCollection($propertyName)) {
427
-				$preparedData[$propertyName] = $dataRow;
428
-				continue;
429
-			}
430
-			if (!isset($preparedData[$propertyName])) {
431
-				$preparedData[$propertyName] = [];
432
-			}
433
-			$preparedData[$propertyName][] = $dataRow;
434
-		}
435
-		return json_encode($preparedData);
436
-	}
437
-
438
-	protected function importFromJson(string $json, string $userId): ?array {
439
-		$result = [];
440
-		$jsonArray = json_decode($json, true);
441
-		$jsonError = json_last_error();
442
-		if ($jsonError !== JSON_ERROR_NONE) {
443
-			$this->logger->critical(
444
-				'User data of {uid} contained invalid JSON (error {json_error}), hence falling back to a default user record',
445
-				[
446
-					'uid' => $userId,
447
-					'json_error' => $jsonError
448
-				]
449
-			);
450
-			return null;
451
-		}
452
-		foreach ($jsonArray as $propertyName => $row) {
453
-			if (!$this->isCollection($propertyName)) {
454
-				$result[] = array_merge($row, ['name' => $propertyName]);
455
-				continue;
456
-			}
457
-			foreach ($row as $singleRow) {
458
-				$result[] = array_merge($singleRow, ['name' => $propertyName]);
459
-			}
460
-		}
461
-		return $result;
462
-	}
463
-
464
-	/**
465
-	 * Update existing user in accounts table
466
-	 */
467
-	protected function updateExistingUser(IUser $user, array $data, array $oldData): void {
468
-		$uid = $user->getUID();
469
-		$jsonEncodedData = $this->prepareJson($data);
470
-		$query = $this->connection->getQueryBuilder();
471
-		$query->update($this->table)
472
-			->set('data', $query->createNamedParameter($jsonEncodedData))
473
-			->where($query->expr()->eq('uid', $query->createNamedParameter($uid)))
474
-			->executeStatement();
475
-
476
-		$this->deleteUserData($user);
477
-		$this->writeUserData($user, $data);
478
-	}
479
-
480
-	protected function writeUserData(IUser $user, array $data): void {
481
-		$query = $this->connection->getQueryBuilder();
482
-		$query->insert($this->dataTable)
483
-			->values(
484
-				[
485
-					'uid' => $query->createNamedParameter($user->getUID()),
486
-					'name' => $query->createParameter('name'),
487
-					'value' => $query->createParameter('value'),
488
-				]
489
-			);
490
-		$this->writeUserDataProperties($query, $data);
491
-	}
492
-
493
-	protected function writeUserDataProperties(IQueryBuilder $query, array $data): void {
494
-		foreach ($data as $property) {
495
-			if ($property['name'] === self::PROPERTY_AVATAR) {
496
-				continue;
497
-			}
498
-
499
-			// the value col is limited to 255 bytes. It is used for searches only.
500
-			$value = $property['value'] ? Util::shortenMultibyteString($property['value'], 255) : '';
501
-
502
-			$query->setParameter('name', $property['name'])
503
-				->setParameter('value', $value);
504
-			$query->executeStatement();
505
-		}
506
-	}
507
-
508
-	/**
509
-	 * build default user record in case not data set exists yet
510
-	 */
511
-	protected function buildDefaultUserRecord(IUser $user): array {
512
-		$scopes = array_merge(self::DEFAULT_SCOPES, array_filter($this->config->getSystemValue('account_manager.default_property_scope', []), static function (string $scope, string $property) {
513
-			return in_array($property, self::ALLOWED_PROPERTIES, true) && in_array($scope, self::ALLOWED_SCOPES, true);
514
-		}, ARRAY_FILTER_USE_BOTH));
515
-
516
-		return [
517
-			[
518
-				'name' => self::PROPERTY_DISPLAYNAME,
519
-				'value' => $user->getDisplayName(),
520
-				// Display name must be at least SCOPE_LOCAL
521
-				'scope' => $scopes[self::PROPERTY_DISPLAYNAME] === self::SCOPE_PRIVATE ? self::SCOPE_LOCAL : $scopes[self::PROPERTY_DISPLAYNAME],
522
-				'verified' => self::NOT_VERIFIED,
523
-			],
524
-
525
-			[
526
-				'name' => self::PROPERTY_ADDRESS,
527
-				'value' => '',
528
-				'scope' => $scopes[self::PROPERTY_ADDRESS],
529
-				'verified' => self::NOT_VERIFIED,
530
-			],
531
-
532
-			[
533
-				'name' => self::PROPERTY_WEBSITE,
534
-				'value' => '',
535
-				'scope' => $scopes[self::PROPERTY_WEBSITE],
536
-				'verified' => self::NOT_VERIFIED,
537
-			],
538
-
539
-			[
540
-				'name' => self::PROPERTY_EMAIL,
541
-				'value' => $user->getEMailAddress(),
542
-				// Email must be at least SCOPE_LOCAL
543
-				'scope' => $scopes[self::PROPERTY_EMAIL] === self::SCOPE_PRIVATE ? self::SCOPE_LOCAL : $scopes[self::PROPERTY_EMAIL],
544
-				'verified' => self::NOT_VERIFIED,
545
-			],
546
-
547
-			[
548
-				'name' => self::PROPERTY_AVATAR,
549
-				'scope' => $scopes[self::PROPERTY_AVATAR],
550
-			],
551
-
552
-			[
553
-				'name' => self::PROPERTY_PHONE,
554
-				'value' => '',
555
-				'scope' => $scopes[self::PROPERTY_PHONE],
556
-				'verified' => self::NOT_VERIFIED,
557
-			],
558
-
559
-			[
560
-				'name' => self::PROPERTY_TWITTER,
561
-				'value' => '',
562
-				'scope' => $scopes[self::PROPERTY_TWITTER],
563
-				'verified' => self::NOT_VERIFIED,
564
-			],
565
-
566
-			[
567
-				'name' => self::PROPERTY_FEDIVERSE,
568
-				'value' => '',
569
-				'scope' => $scopes[self::PROPERTY_FEDIVERSE],
570
-				'verified' => self::NOT_VERIFIED,
571
-			],
572
-
573
-			[
574
-				'name' => self::PROPERTY_ORGANISATION,
575
-				'value' => '',
576
-				'scope' => $scopes[self::PROPERTY_ORGANISATION],
577
-			],
578
-
579
-			[
580
-				'name' => self::PROPERTY_ROLE,
581
-				'value' => '',
582
-				'scope' => $scopes[self::PROPERTY_ROLE],
583
-			],
584
-
585
-			[
586
-				'name' => self::PROPERTY_HEADLINE,
587
-				'value' => '',
588
-				'scope' => $scopes[self::PROPERTY_HEADLINE],
589
-			],
590
-
591
-			[
592
-				'name' => self::PROPERTY_BIOGRAPHY,
593
-				'value' => '',
594
-				'scope' => $scopes[self::PROPERTY_BIOGRAPHY],
595
-			],
596
-
597
-			[
598
-				'name' => self::PROPERTY_BIRTHDATE,
599
-				'value' => '',
600
-				'scope' => $scopes[self::PROPERTY_BIRTHDATE],
601
-			],
602
-
603
-			[
604
-				'name' => self::PROPERTY_PROFILE_ENABLED,
605
-				'value' => $this->isProfileEnabledByDefault($this->config) ? '1' : '0',
606
-			],
607
-
608
-			[
609
-				'name' => self::PROPERTY_PRONOUNS,
610
-				'value' => '',
611
-				'scope' => $scopes[self::PROPERTY_PRONOUNS],
612
-			],
613
-		];
614
-	}
615
-
616
-	private function arrayDataToCollection(IAccount $account, array $data): IAccountPropertyCollection {
617
-		$collection = $account->getPropertyCollection($data['name']);
618
-
619
-		$p = new AccountProperty(
620
-			$data['name'],
621
-			$data['value'] ?? '',
622
-			$data['scope'] ?? self::SCOPE_LOCAL,
623
-			$data['verified'] ?? self::NOT_VERIFIED,
624
-			''
625
-		);
626
-		$p->setLocallyVerified($data['locallyVerified'] ?? self::NOT_VERIFIED);
627
-		$collection->addProperty($p);
628
-
629
-		return $collection;
630
-	}
631
-
632
-	private function parseAccountData(IUser $user, $data): Account {
633
-		$account = new Account($user);
634
-		foreach ($data as $accountData) {
635
-			if ($this->isCollection($accountData['name'])) {
636
-				$account->setPropertyCollection($this->arrayDataToCollection($account, $accountData));
637
-			} else {
638
-				$account->setProperty($accountData['name'], $accountData['value'] ?? '', $accountData['scope'] ?? self::SCOPE_LOCAL, $accountData['verified'] ?? self::NOT_VERIFIED);
639
-				if (isset($accountData['locallyVerified'])) {
640
-					$property = $account->getProperty($accountData['name']);
641
-					$property->setLocallyVerified($accountData['locallyVerified']);
642
-				}
643
-			}
644
-		}
645
-		return $account;
646
-	}
647
-
648
-	public function getAccount(IUser $user): IAccount {
649
-		$cached = $this->internalCache->get($user->getUID());
650
-		if ($cached !== null) {
651
-			return $cached;
652
-		}
653
-		$account = $this->parseAccountData($user, $this->getUser($user));
654
-		if ($user->getBackend() instanceof IGetDisplayNameBackend) {
655
-			$property = $account->getProperty(self::PROPERTY_DISPLAYNAME);
656
-			$account->setProperty(self::PROPERTY_DISPLAYNAME, $user->getDisplayName(), $property->getScope(), $property->getVerified());
657
-		}
658
-		$this->internalCache->set($user->getUID(), $account);
659
-		return $account;
660
-	}
661
-
662
-	/**
663
-	 * Converts value (phone number) in E.164 format when it was a valid number
664
-	 * @throws InvalidArgumentException When the phone number was invalid or no default region is set and the number doesn't start with a country code
665
-	 */
666
-	protected function sanitizePropertyPhoneNumber(IAccountProperty $property): void {
667
-		$defaultRegion = $this->config->getSystemValueString('default_phone_region', '');
668
-
669
-		if ($defaultRegion === '') {
670
-			// When no default region is set, only +49… numbers are valid
671
-			if (!str_starts_with($property->getValue(), '+')) {
672
-				throw new InvalidArgumentException(self::PROPERTY_PHONE);
673
-			}
674
-
675
-			$defaultRegion = 'EN';
676
-		}
677
-
678
-		$phoneNumber = $this->phoneNumberUtil->convertToStandardFormat($property->getValue(), $defaultRegion);
679
-		if ($phoneNumber === null) {
680
-			throw new InvalidArgumentException(self::PROPERTY_PHONE);
681
-		}
682
-		$property->setValue($phoneNumber);
683
-	}
684
-
685
-	/**
686
-	 * @throws InvalidArgumentException When the website did not have http(s) as protocol or the host name was empty
687
-	 */
688
-	private function sanitizePropertyWebsite(IAccountProperty $property): void {
689
-		$parts = parse_url($property->getValue());
690
-		if (!isset($parts['scheme']) || ($parts['scheme'] !== 'https' && $parts['scheme'] !== 'http')) {
691
-			throw new InvalidArgumentException(self::PROPERTY_WEBSITE);
692
-		}
693
-
694
-		if (!isset($parts['host']) || $parts['host'] === '') {
695
-			throw new InvalidArgumentException(self::PROPERTY_WEBSITE);
696
-		}
697
-	}
698
-
699
-	/**
700
-	 * @throws InvalidArgumentException If the property value is not a valid user handle according to X's rules
701
-	 */
702
-	private function sanitizePropertyTwitter(IAccountProperty $property): void {
703
-		if ($property->getName() === self::PROPERTY_TWITTER) {
704
-			$matches = [];
705
-			// twitter handles only contain alpha numeric characters and the underscore and must not be longer than 15 characters
706
-			if (preg_match('/^@?([a-zA-Z0-9_]{2,15})$/', $property->getValue(), $matches) !== 1) {
707
-				throw new InvalidArgumentException(self::PROPERTY_TWITTER);
708
-			}
709
-
710
-			// drop the leading @ if any to make it the valid handle
711
-			$property->setValue($matches[1]);
712
-
713
-		}
714
-	}
715
-
716
-	/**
717
-	 * @throws InvalidArgumentException If the property value is not a valid fediverse handle (username@instance where instance is a valid domain)
718
-	 */
719
-	private function sanitizePropertyFediverse(IAccountProperty $property): void {
720
-		if ($property->getName() === self::PROPERTY_FEDIVERSE) {
721
-			$matches = [];
722
-			if (preg_match('/^@?([^@\s\/\\\]+)@([^\s\/\\\]+)$/', trim($property->getValue()), $matches) !== 1) {
723
-				throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE);
724
-			}
725
-
726
-			[, $username, $instance] = $matches;
727
-			$validated = filter_var($instance, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME);
728
-			if ($validated !== $instance) {
729
-				throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE);
730
-			}
731
-
732
-			if ($this->config->getSystemValueBool('has_internet_connection', true)) {
733
-				$client = $this->clientService->newClient();
734
-
735
-				try {
736
-					// try the public account lookup API of mastodon
737
-					$response = $client->get("https://{$instance}/.well-known/webfinger?resource=acct:{$username}@{$instance}");
738
-					// should be a json response with account information
739
-					$data = $response->getBody();
740
-					if (is_resource($data)) {
741
-						$data = stream_get_contents($data);
742
-					}
743
-					$decoded = json_decode($data, true);
744
-					// ensure the username is the same the user passed
745
-					// in this case we can assume this is a valid fediverse server and account
746
-					if (!is_array($decoded) || ($decoded['subject'] ?? '') !== "acct:{$username}@{$instance}") {
747
-						throw new InvalidArgumentException();
748
-					}
749
-					// check for activitypub link
750
-					if (is_array($decoded['links']) && isset($decoded['links'])) {
751
-						$found = false;
752
-						foreach ($decoded['links'] as $link) {
753
-							// have application/activity+json or application/ld+json
754
-							if (isset($link['type']) && (
755
-								$link['type'] === 'application/activity+json'
756
-								|| $link['type'] === 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
757
-							)) {
758
-								$found = true;
759
-								break;
760
-							}
761
-						}
762
-						if (!$found) {
763
-							throw new InvalidArgumentException();
764
-						}
765
-					}
766
-				} catch (InvalidArgumentException) {
767
-					throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE);
768
-				} catch (\Exception $error) {
769
-					$this->logger->error('Could not verify fediverse account', ['exception' => $error, 'instance' => $instance]);
770
-					throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE);
771
-				}
772
-			}
773
-
774
-			$property->setValue("$username@$instance");
775
-		}
776
-	}
777
-
778
-	public function updateAccount(IAccount $account): void {
779
-		$this->testValueLengths(iterator_to_array($account->getAllProperties()), true);
780
-		try {
781
-			$property = $account->getProperty(self::PROPERTY_PHONE);
782
-			if ($property->getValue() !== '') {
783
-				$this->sanitizePropertyPhoneNumber($property);
784
-			}
785
-		} catch (PropertyDoesNotExistException $e) {
786
-			//  valid case, nothing to do
787
-		}
788
-
789
-		try {
790
-			$property = $account->getProperty(self::PROPERTY_WEBSITE);
791
-			if ($property->getValue() !== '') {
792
-				$this->sanitizePropertyWebsite($property);
793
-			}
794
-		} catch (PropertyDoesNotExistException $e) {
795
-			//  valid case, nothing to do
796
-		}
797
-
798
-		try {
799
-			$property = $account->getProperty(self::PROPERTY_TWITTER);
800
-			if ($property->getValue() !== '') {
801
-				$this->sanitizePropertyTwitter($property);
802
-			}
803
-		} catch (PropertyDoesNotExistException $e) {
804
-			//  valid case, nothing to do
805
-		}
806
-
807
-		try {
808
-			$property = $account->getProperty(self::PROPERTY_FEDIVERSE);
809
-			if ($property->getValue() !== '') {
810
-				$this->sanitizePropertyFediverse($property);
811
-			}
812
-		} catch (PropertyDoesNotExistException $e) {
813
-			//  valid case, nothing to do
814
-		}
815
-
816
-		foreach ($account->getAllProperties() as $property) {
817
-			$this->testPropertyScope($property, self::ALLOWED_SCOPES, true);
818
-		}
819
-
820
-		$oldData = $this->getUser($account->getUser(), false);
821
-		$this->updateVerificationStatus($account, $oldData);
822
-		$this->checkEmailVerification($account, $oldData);
823
-		$this->checkLocalEmailVerification($account, $oldData);
824
-
825
-		$data = [];
826
-		foreach ($account->getAllProperties() as $property) {
827
-			/** @var IAccountProperty $property */
828
-			$data[] = [
829
-				'name' => $property->getName(),
830
-				'value' => $property->getValue(),
831
-				'scope' => $property->getScope(),
832
-				'verified' => $property->getVerified(),
833
-				'locallyVerified' => $property->getLocallyVerified(),
834
-			];
835
-		}
836
-
837
-		$this->updateUser($account->getUser(), $data, $oldData, true);
838
-		$this->internalCache->set($account->getUser()->getUID(), $account);
839
-	}
55
+    use TAccountsHelper;
56
+
57
+    use TProfileHelper;
58
+
59
+    private string $table = 'accounts';
60
+    private string $dataTable = 'accounts_data';
61
+    private ?IL10N $l10n = null;
62
+    private CappedMemoryCache $internalCache;
63
+
64
+    /**
65
+     * The list of default scopes for each property.
66
+     */
67
+    public const DEFAULT_SCOPES = [
68
+        self::PROPERTY_ADDRESS => self::SCOPE_LOCAL,
69
+        self::PROPERTY_AVATAR => self::SCOPE_FEDERATED,
70
+        self::PROPERTY_BIOGRAPHY => self::SCOPE_LOCAL,
71
+        self::PROPERTY_BIRTHDATE => self::SCOPE_LOCAL,
72
+        self::PROPERTY_DISPLAYNAME => self::SCOPE_FEDERATED,
73
+        self::PROPERTY_EMAIL => self::SCOPE_FEDERATED,
74
+        self::PROPERTY_FEDIVERSE => self::SCOPE_LOCAL,
75
+        self::PROPERTY_HEADLINE => self::SCOPE_LOCAL,
76
+        self::PROPERTY_ORGANISATION => self::SCOPE_LOCAL,
77
+        self::PROPERTY_PHONE => self::SCOPE_LOCAL,
78
+        self::PROPERTY_PRONOUNS => self::SCOPE_FEDERATED,
79
+        self::PROPERTY_ROLE => self::SCOPE_LOCAL,
80
+        self::PROPERTY_TWITTER => self::SCOPE_LOCAL,
81
+        self::PROPERTY_WEBSITE => self::SCOPE_LOCAL,
82
+    ];
83
+
84
+    public function __construct(
85
+        private IDBConnection $connection,
86
+        private IConfig $config,
87
+        private IEventDispatcher $dispatcher,
88
+        private IJobList $jobList,
89
+        private LoggerInterface $logger,
90
+        private IVerificationToken $verificationToken,
91
+        private IMailer $mailer,
92
+        private Defaults $defaults,
93
+        private IFactory $l10nFactory,
94
+        private IURLGenerator $urlGenerator,
95
+        private ICrypto $crypto,
96
+        private IPhoneNumberUtil $phoneNumberUtil,
97
+        private IClientService $clientService,
98
+    ) {
99
+        $this->internalCache = new CappedMemoryCache();
100
+    }
101
+
102
+    /**
103
+     * @param IAccountProperty[] $properties
104
+     */
105
+    protected function testValueLengths(array $properties, bool $throwOnData = false): void {
106
+        foreach ($properties as $property) {
107
+            if (strlen($property->getValue()) > 2048) {
108
+                if ($throwOnData) {
109
+                    throw new InvalidArgumentException($property->getName());
110
+                } else {
111
+                    $property->setValue('');
112
+                }
113
+            }
114
+        }
115
+    }
116
+
117
+    protected function testPropertyScope(IAccountProperty $property, array $allowedScopes, bool $throwOnData): void {
118
+        if ($throwOnData && !in_array($property->getScope(), $allowedScopes, true)) {
119
+            throw new InvalidArgumentException('scope');
120
+        }
121
+
122
+        if (
123
+            $property->getScope() === self::SCOPE_PRIVATE
124
+            && in_array($property->getName(), [self::PROPERTY_DISPLAYNAME, self::PROPERTY_EMAIL])
125
+        ) {
126
+            if ($throwOnData) {
127
+                // v2-private is not available for these fields
128
+                throw new InvalidArgumentException('scope');
129
+            } else {
130
+                // default to local
131
+                $property->setScope(self::SCOPE_LOCAL);
132
+            }
133
+        } else {
134
+            $property->setScope($property->getScope());
135
+        }
136
+    }
137
+
138
+    protected function updateUser(IUser $user, array $data, ?array $oldUserData, bool $throwOnData = false): array {
139
+        if ($oldUserData === null) {
140
+            $oldUserData = $this->getUser($user, false);
141
+        }
142
+
143
+        $updated = true;
144
+
145
+        if ($oldUserData !== $data) {
146
+            $this->updateExistingUser($user, $data, $oldUserData);
147
+        } else {
148
+            // nothing needs to be done if new and old data set are the same
149
+            $updated = false;
150
+        }
151
+
152
+        if ($updated) {
153
+            $this->dispatcher->dispatchTyped(new UserUpdatedEvent(
154
+                $user,
155
+                $data,
156
+            ));
157
+        }
158
+
159
+        return $data;
160
+    }
161
+
162
+    /**
163
+     * delete user from accounts table
164
+     */
165
+    public function deleteUser(IUser $user): void {
166
+        $uid = $user->getUID();
167
+        $query = $this->connection->getQueryBuilder();
168
+        $query->delete($this->table)
169
+            ->where($query->expr()->eq('uid', $query->createNamedParameter($uid)))
170
+            ->executeStatement();
171
+
172
+        $this->deleteUserData($user);
173
+    }
174
+
175
+    /**
176
+     * delete user from accounts table
177
+     */
178
+    public function deleteUserData(IUser $user): void {
179
+        $uid = $user->getUID();
180
+        $query = $this->connection->getQueryBuilder();
181
+        $query->delete($this->dataTable)
182
+            ->where($query->expr()->eq('uid', $query->createNamedParameter($uid)))
183
+            ->executeStatement();
184
+    }
185
+
186
+    /**
187
+     * get stored data from a given user
188
+     */
189
+    protected function getUser(IUser $user, bool $insertIfNotExists = true): array {
190
+        $uid = $user->getUID();
191
+        $query = $this->connection->getQueryBuilder();
192
+        $query->select('data')
193
+            ->from($this->table)
194
+            ->where($query->expr()->eq('uid', $query->createParameter('uid')))
195
+            ->setParameter('uid', $uid);
196
+        $result = $query->executeQuery();
197
+        $accountData = $result->fetchAll();
198
+        $result->closeCursor();
199
+
200
+        if (empty($accountData)) {
201
+            $userData = $this->buildDefaultUserRecord($user);
202
+            if ($insertIfNotExists) {
203
+                $this->insertNewUser($user, $userData);
204
+            }
205
+            return $userData;
206
+        }
207
+
208
+        $userDataArray = $this->importFromJson($accountData[0]['data'], $uid);
209
+        if ($userDataArray === null || $userDataArray === []) {
210
+            return $this->buildDefaultUserRecord($user);
211
+        }
212
+
213
+        return $this->addMissingDefaultValues($userDataArray, $this->buildDefaultUserRecord($user));
214
+    }
215
+
216
+    public function searchUsers(string $property, array $values): array {
217
+        // the value col is limited to 255 bytes. It is used for searches only.
218
+        $values = array_map(function (string $value) {
219
+            return Util::shortenMultibyteString($value, 255);
220
+        }, $values);
221
+        $chunks = array_chunk($values, 500);
222
+        $query = $this->connection->getQueryBuilder();
223
+        $query->select('*')
224
+            ->from($this->dataTable)
225
+            ->where($query->expr()->eq('name', $query->createNamedParameter($property)))
226
+            ->andWhere($query->expr()->in('value', $query->createParameter('values')));
227
+
228
+        $matches = [];
229
+        foreach ($chunks as $chunk) {
230
+            $query->setParameter('values', $chunk, IQueryBuilder::PARAM_STR_ARRAY);
231
+            $result = $query->executeQuery();
232
+
233
+            while ($row = $result->fetch()) {
234
+                $matches[$row['uid']] = $row['value'];
235
+            }
236
+            $result->closeCursor();
237
+        }
238
+
239
+        $result = array_merge($matches, $this->searchUsersForRelatedCollection($property, $values));
240
+
241
+        return array_flip($result);
242
+    }
243
+
244
+    protected function searchUsersForRelatedCollection(string $property, array $values): array {
245
+        return match ($property) {
246
+            IAccountManager::PROPERTY_EMAIL => array_flip($this->searchUsers(IAccountManager::COLLECTION_EMAIL, $values)),
247
+            default => [],
248
+        };
249
+    }
250
+
251
+    /**
252
+     * check if we need to ask the server for email verification, if yes we create a cronjob
253
+     */
254
+    protected function checkEmailVerification(IAccount $updatedAccount, array $oldData): void {
255
+        try {
256
+            $property = $updatedAccount->getProperty(self::PROPERTY_EMAIL);
257
+        } catch (PropertyDoesNotExistException $e) {
258
+            return;
259
+        }
260
+
261
+        $oldMailIndex = array_search(self::PROPERTY_EMAIL, array_column($oldData, 'name'), true);
262
+        $oldMail = $oldMailIndex !== false ? $oldData[$oldMailIndex]['value'] : '';
263
+
264
+        if ($oldMail !== $property->getValue()) {
265
+            $this->jobList->add(
266
+                VerifyUserData::class,
267
+                [
268
+                    'verificationCode' => '',
269
+                    'data' => $property->getValue(),
270
+                    'type' => self::PROPERTY_EMAIL,
271
+                    'uid' => $updatedAccount->getUser()->getUID(),
272
+                    'try' => 0,
273
+                    'lastRun' => time()
274
+                ]
275
+            );
276
+
277
+            $property->setVerified(self::VERIFICATION_IN_PROGRESS);
278
+        }
279
+    }
280
+
281
+    protected function checkLocalEmailVerification(IAccount $updatedAccount, array $oldData): void {
282
+        $mailCollection = $updatedAccount->getPropertyCollection(self::COLLECTION_EMAIL);
283
+        foreach ($mailCollection->getProperties() as $property) {
284
+            if ($property->getLocallyVerified() !== self::NOT_VERIFIED) {
285
+                continue;
286
+            }
287
+            if ($this->sendEmailVerificationEmail($updatedAccount->getUser(), $property->getValue())) {
288
+                $property->setLocallyVerified(self::VERIFICATION_IN_PROGRESS);
289
+            }
290
+        }
291
+    }
292
+
293
+    protected function sendEmailVerificationEmail(IUser $user, string $email): bool {
294
+        $ref = \substr(hash('sha256', $email), 0, 8);
295
+        $key = $this->crypto->encrypt($email);
296
+        $token = $this->verificationToken->create($user, 'verifyMail' . $ref, $email);
297
+
298
+        $link = $this->urlGenerator->linkToRouteAbsolute(
299
+            'provisioning_api.Verification.verifyMail',
300
+            [
301
+                'userId' => $user->getUID(),
302
+                'token' => $token,
303
+                'key' => $key
304
+            ]
305
+        );
306
+
307
+        $emailTemplate = $this->mailer->createEMailTemplate('core.EmailVerification', [
308
+            'link' => $link,
309
+        ]);
310
+
311
+        if (!$this->l10n) {
312
+            $this->l10n = $this->l10nFactory->get('core');
313
+        }
314
+
315
+        $emailTemplate->setSubject($this->l10n->t('%s email verification', [$this->defaults->getName()]));
316
+        $emailTemplate->addHeader();
317
+        $emailTemplate->addHeading($this->l10n->t('Email verification'));
318
+
319
+        $emailTemplate->addBodyText(
320
+            htmlspecialchars($this->l10n->t('Click the following button to confirm your email.')),
321
+            $this->l10n->t('Click the following link to confirm your email.')
322
+        );
323
+
324
+        $emailTemplate->addBodyButton(
325
+            htmlspecialchars($this->l10n->t('Confirm your email')),
326
+            $link,
327
+            false
328
+        );
329
+        $emailTemplate->addFooter();
330
+
331
+        try {
332
+            $message = $this->mailer->createMessage();
333
+            $message->setTo([$email => $user->getDisplayName()]);
334
+            $message->setFrom([Util::getDefaultEmailAddress('verification-noreply') => $this->defaults->getName()]);
335
+            $message->useTemplate($emailTemplate);
336
+            $this->mailer->send($message);
337
+        } catch (Exception $e) {
338
+            // Log the exception and continue
339
+            $this->logger->info('Failed to send verification mail', [
340
+                'app' => 'core',
341
+                'exception' => $e
342
+            ]);
343
+            return false;
344
+        }
345
+        return true;
346
+    }
347
+
348
+    /**
349
+     * Make sure that all expected data are set
350
+     */
351
+    protected function addMissingDefaultValues(array $userData, array $defaultUserData): array {
352
+        foreach ($defaultUserData as $defaultDataItem) {
353
+            // If property does not exist, initialize it
354
+            $userDataIndex = array_search($defaultDataItem['name'], array_column($userData, 'name'));
355
+            if ($userDataIndex === false) {
356
+                $userData[] = $defaultDataItem;
357
+                continue;
358
+            }
359
+
360
+            // Merge and extend default missing values
361
+            $userData[$userDataIndex] = array_merge($defaultDataItem, $userData[$userDataIndex]);
362
+        }
363
+
364
+        return $userData;
365
+    }
366
+
367
+    protected function updateVerificationStatus(IAccount $updatedAccount, array $oldData): void {
368
+        static $propertiesVerifiableByLookupServer = [
369
+            self::PROPERTY_TWITTER,
370
+            self::PROPERTY_FEDIVERSE,
371
+            self::PROPERTY_WEBSITE,
372
+            self::PROPERTY_EMAIL,
373
+        ];
374
+
375
+        foreach ($propertiesVerifiableByLookupServer as $propertyName) {
376
+            try {
377
+                $property = $updatedAccount->getProperty($propertyName);
378
+            } catch (PropertyDoesNotExistException $e) {
379
+                continue;
380
+            }
381
+            $wasVerified = isset($oldData[$propertyName])
382
+                && isset($oldData[$propertyName]['verified'])
383
+                && $oldData[$propertyName]['verified'] === self::VERIFIED;
384
+            if ((!isset($oldData[$propertyName])
385
+                    || !isset($oldData[$propertyName]['value'])
386
+                    || $property->getValue() !== $oldData[$propertyName]['value'])
387
+                && ($property->getVerified() !== self::NOT_VERIFIED
388
+                    || $wasVerified)
389
+            ) {
390
+                $property->setVerified(self::NOT_VERIFIED);
391
+            }
392
+        }
393
+    }
394
+
395
+    /**
396
+     * add new user to accounts table
397
+     */
398
+    protected function insertNewUser(IUser $user, array $data): void {
399
+        $uid = $user->getUID();
400
+        $jsonEncodedData = $this->prepareJson($data);
401
+        $query = $this->connection->getQueryBuilder();
402
+        $query->insert($this->table)
403
+            ->values(
404
+                [
405
+                    'uid' => $query->createNamedParameter($uid),
406
+                    'data' => $query->createNamedParameter($jsonEncodedData),
407
+                ]
408
+            )
409
+            ->executeStatement();
410
+
411
+        $this->deleteUserData($user);
412
+        $this->writeUserData($user, $data);
413
+    }
414
+
415
+    protected function prepareJson(array $data): string {
416
+        $preparedData = [];
417
+        foreach ($data as $dataRow) {
418
+            $propertyName = $dataRow['name'];
419
+            unset($dataRow['name']);
420
+
421
+            if (isset($dataRow['locallyVerified']) && $dataRow['locallyVerified'] === self::NOT_VERIFIED) {
422
+                // do not write default value, save DB space
423
+                unset($dataRow['locallyVerified']);
424
+            }
425
+
426
+            if (!$this->isCollection($propertyName)) {
427
+                $preparedData[$propertyName] = $dataRow;
428
+                continue;
429
+            }
430
+            if (!isset($preparedData[$propertyName])) {
431
+                $preparedData[$propertyName] = [];
432
+            }
433
+            $preparedData[$propertyName][] = $dataRow;
434
+        }
435
+        return json_encode($preparedData);
436
+    }
437
+
438
+    protected function importFromJson(string $json, string $userId): ?array {
439
+        $result = [];
440
+        $jsonArray = json_decode($json, true);
441
+        $jsonError = json_last_error();
442
+        if ($jsonError !== JSON_ERROR_NONE) {
443
+            $this->logger->critical(
444
+                'User data of {uid} contained invalid JSON (error {json_error}), hence falling back to a default user record',
445
+                [
446
+                    'uid' => $userId,
447
+                    'json_error' => $jsonError
448
+                ]
449
+            );
450
+            return null;
451
+        }
452
+        foreach ($jsonArray as $propertyName => $row) {
453
+            if (!$this->isCollection($propertyName)) {
454
+                $result[] = array_merge($row, ['name' => $propertyName]);
455
+                continue;
456
+            }
457
+            foreach ($row as $singleRow) {
458
+                $result[] = array_merge($singleRow, ['name' => $propertyName]);
459
+            }
460
+        }
461
+        return $result;
462
+    }
463
+
464
+    /**
465
+     * Update existing user in accounts table
466
+     */
467
+    protected function updateExistingUser(IUser $user, array $data, array $oldData): void {
468
+        $uid = $user->getUID();
469
+        $jsonEncodedData = $this->prepareJson($data);
470
+        $query = $this->connection->getQueryBuilder();
471
+        $query->update($this->table)
472
+            ->set('data', $query->createNamedParameter($jsonEncodedData))
473
+            ->where($query->expr()->eq('uid', $query->createNamedParameter($uid)))
474
+            ->executeStatement();
475
+
476
+        $this->deleteUserData($user);
477
+        $this->writeUserData($user, $data);
478
+    }
479
+
480
+    protected function writeUserData(IUser $user, array $data): void {
481
+        $query = $this->connection->getQueryBuilder();
482
+        $query->insert($this->dataTable)
483
+            ->values(
484
+                [
485
+                    'uid' => $query->createNamedParameter($user->getUID()),
486
+                    'name' => $query->createParameter('name'),
487
+                    'value' => $query->createParameter('value'),
488
+                ]
489
+            );
490
+        $this->writeUserDataProperties($query, $data);
491
+    }
492
+
493
+    protected function writeUserDataProperties(IQueryBuilder $query, array $data): void {
494
+        foreach ($data as $property) {
495
+            if ($property['name'] === self::PROPERTY_AVATAR) {
496
+                continue;
497
+            }
498
+
499
+            // the value col is limited to 255 bytes. It is used for searches only.
500
+            $value = $property['value'] ? Util::shortenMultibyteString($property['value'], 255) : '';
501
+
502
+            $query->setParameter('name', $property['name'])
503
+                ->setParameter('value', $value);
504
+            $query->executeStatement();
505
+        }
506
+    }
507
+
508
+    /**
509
+     * build default user record in case not data set exists yet
510
+     */
511
+    protected function buildDefaultUserRecord(IUser $user): array {
512
+        $scopes = array_merge(self::DEFAULT_SCOPES, array_filter($this->config->getSystemValue('account_manager.default_property_scope', []), static function (string $scope, string $property) {
513
+            return in_array($property, self::ALLOWED_PROPERTIES, true) && in_array($scope, self::ALLOWED_SCOPES, true);
514
+        }, ARRAY_FILTER_USE_BOTH));
515
+
516
+        return [
517
+            [
518
+                'name' => self::PROPERTY_DISPLAYNAME,
519
+                'value' => $user->getDisplayName(),
520
+                // Display name must be at least SCOPE_LOCAL
521
+                'scope' => $scopes[self::PROPERTY_DISPLAYNAME] === self::SCOPE_PRIVATE ? self::SCOPE_LOCAL : $scopes[self::PROPERTY_DISPLAYNAME],
522
+                'verified' => self::NOT_VERIFIED,
523
+            ],
524
+
525
+            [
526
+                'name' => self::PROPERTY_ADDRESS,
527
+                'value' => '',
528
+                'scope' => $scopes[self::PROPERTY_ADDRESS],
529
+                'verified' => self::NOT_VERIFIED,
530
+            ],
531
+
532
+            [
533
+                'name' => self::PROPERTY_WEBSITE,
534
+                'value' => '',
535
+                'scope' => $scopes[self::PROPERTY_WEBSITE],
536
+                'verified' => self::NOT_VERIFIED,
537
+            ],
538
+
539
+            [
540
+                'name' => self::PROPERTY_EMAIL,
541
+                'value' => $user->getEMailAddress(),
542
+                // Email must be at least SCOPE_LOCAL
543
+                'scope' => $scopes[self::PROPERTY_EMAIL] === self::SCOPE_PRIVATE ? self::SCOPE_LOCAL : $scopes[self::PROPERTY_EMAIL],
544
+                'verified' => self::NOT_VERIFIED,
545
+            ],
546
+
547
+            [
548
+                'name' => self::PROPERTY_AVATAR,
549
+                'scope' => $scopes[self::PROPERTY_AVATAR],
550
+            ],
551
+
552
+            [
553
+                'name' => self::PROPERTY_PHONE,
554
+                'value' => '',
555
+                'scope' => $scopes[self::PROPERTY_PHONE],
556
+                'verified' => self::NOT_VERIFIED,
557
+            ],
558
+
559
+            [
560
+                'name' => self::PROPERTY_TWITTER,
561
+                'value' => '',
562
+                'scope' => $scopes[self::PROPERTY_TWITTER],
563
+                'verified' => self::NOT_VERIFIED,
564
+            ],
565
+
566
+            [
567
+                'name' => self::PROPERTY_FEDIVERSE,
568
+                'value' => '',
569
+                'scope' => $scopes[self::PROPERTY_FEDIVERSE],
570
+                'verified' => self::NOT_VERIFIED,
571
+            ],
572
+
573
+            [
574
+                'name' => self::PROPERTY_ORGANISATION,
575
+                'value' => '',
576
+                'scope' => $scopes[self::PROPERTY_ORGANISATION],
577
+            ],
578
+
579
+            [
580
+                'name' => self::PROPERTY_ROLE,
581
+                'value' => '',
582
+                'scope' => $scopes[self::PROPERTY_ROLE],
583
+            ],
584
+
585
+            [
586
+                'name' => self::PROPERTY_HEADLINE,
587
+                'value' => '',
588
+                'scope' => $scopes[self::PROPERTY_HEADLINE],
589
+            ],
590
+
591
+            [
592
+                'name' => self::PROPERTY_BIOGRAPHY,
593
+                'value' => '',
594
+                'scope' => $scopes[self::PROPERTY_BIOGRAPHY],
595
+            ],
596
+
597
+            [
598
+                'name' => self::PROPERTY_BIRTHDATE,
599
+                'value' => '',
600
+                'scope' => $scopes[self::PROPERTY_BIRTHDATE],
601
+            ],
602
+
603
+            [
604
+                'name' => self::PROPERTY_PROFILE_ENABLED,
605
+                'value' => $this->isProfileEnabledByDefault($this->config) ? '1' : '0',
606
+            ],
607
+
608
+            [
609
+                'name' => self::PROPERTY_PRONOUNS,
610
+                'value' => '',
611
+                'scope' => $scopes[self::PROPERTY_PRONOUNS],
612
+            ],
613
+        ];
614
+    }
615
+
616
+    private function arrayDataToCollection(IAccount $account, array $data): IAccountPropertyCollection {
617
+        $collection = $account->getPropertyCollection($data['name']);
618
+
619
+        $p = new AccountProperty(
620
+            $data['name'],
621
+            $data['value'] ?? '',
622
+            $data['scope'] ?? self::SCOPE_LOCAL,
623
+            $data['verified'] ?? self::NOT_VERIFIED,
624
+            ''
625
+        );
626
+        $p->setLocallyVerified($data['locallyVerified'] ?? self::NOT_VERIFIED);
627
+        $collection->addProperty($p);
628
+
629
+        return $collection;
630
+    }
631
+
632
+    private function parseAccountData(IUser $user, $data): Account {
633
+        $account = new Account($user);
634
+        foreach ($data as $accountData) {
635
+            if ($this->isCollection($accountData['name'])) {
636
+                $account->setPropertyCollection($this->arrayDataToCollection($account, $accountData));
637
+            } else {
638
+                $account->setProperty($accountData['name'], $accountData['value'] ?? '', $accountData['scope'] ?? self::SCOPE_LOCAL, $accountData['verified'] ?? self::NOT_VERIFIED);
639
+                if (isset($accountData['locallyVerified'])) {
640
+                    $property = $account->getProperty($accountData['name']);
641
+                    $property->setLocallyVerified($accountData['locallyVerified']);
642
+                }
643
+            }
644
+        }
645
+        return $account;
646
+    }
647
+
648
+    public function getAccount(IUser $user): IAccount {
649
+        $cached = $this->internalCache->get($user->getUID());
650
+        if ($cached !== null) {
651
+            return $cached;
652
+        }
653
+        $account = $this->parseAccountData($user, $this->getUser($user));
654
+        if ($user->getBackend() instanceof IGetDisplayNameBackend) {
655
+            $property = $account->getProperty(self::PROPERTY_DISPLAYNAME);
656
+            $account->setProperty(self::PROPERTY_DISPLAYNAME, $user->getDisplayName(), $property->getScope(), $property->getVerified());
657
+        }
658
+        $this->internalCache->set($user->getUID(), $account);
659
+        return $account;
660
+    }
661
+
662
+    /**
663
+     * Converts value (phone number) in E.164 format when it was a valid number
664
+     * @throws InvalidArgumentException When the phone number was invalid or no default region is set and the number doesn't start with a country code
665
+     */
666
+    protected function sanitizePropertyPhoneNumber(IAccountProperty $property): void {
667
+        $defaultRegion = $this->config->getSystemValueString('default_phone_region', '');
668
+
669
+        if ($defaultRegion === '') {
670
+            // When no default region is set, only +49… numbers are valid
671
+            if (!str_starts_with($property->getValue(), '+')) {
672
+                throw new InvalidArgumentException(self::PROPERTY_PHONE);
673
+            }
674
+
675
+            $defaultRegion = 'EN';
676
+        }
677
+
678
+        $phoneNumber = $this->phoneNumberUtil->convertToStandardFormat($property->getValue(), $defaultRegion);
679
+        if ($phoneNumber === null) {
680
+            throw new InvalidArgumentException(self::PROPERTY_PHONE);
681
+        }
682
+        $property->setValue($phoneNumber);
683
+    }
684
+
685
+    /**
686
+     * @throws InvalidArgumentException When the website did not have http(s) as protocol or the host name was empty
687
+     */
688
+    private function sanitizePropertyWebsite(IAccountProperty $property): void {
689
+        $parts = parse_url($property->getValue());
690
+        if (!isset($parts['scheme']) || ($parts['scheme'] !== 'https' && $parts['scheme'] !== 'http')) {
691
+            throw new InvalidArgumentException(self::PROPERTY_WEBSITE);
692
+        }
693
+
694
+        if (!isset($parts['host']) || $parts['host'] === '') {
695
+            throw new InvalidArgumentException(self::PROPERTY_WEBSITE);
696
+        }
697
+    }
698
+
699
+    /**
700
+     * @throws InvalidArgumentException If the property value is not a valid user handle according to X's rules
701
+     */
702
+    private function sanitizePropertyTwitter(IAccountProperty $property): void {
703
+        if ($property->getName() === self::PROPERTY_TWITTER) {
704
+            $matches = [];
705
+            // twitter handles only contain alpha numeric characters and the underscore and must not be longer than 15 characters
706
+            if (preg_match('/^@?([a-zA-Z0-9_]{2,15})$/', $property->getValue(), $matches) !== 1) {
707
+                throw new InvalidArgumentException(self::PROPERTY_TWITTER);
708
+            }
709
+
710
+            // drop the leading @ if any to make it the valid handle
711
+            $property->setValue($matches[1]);
712
+
713
+        }
714
+    }
715
+
716
+    /**
717
+     * @throws InvalidArgumentException If the property value is not a valid fediverse handle (username@instance where instance is a valid domain)
718
+     */
719
+    private function sanitizePropertyFediverse(IAccountProperty $property): void {
720
+        if ($property->getName() === self::PROPERTY_FEDIVERSE) {
721
+            $matches = [];
722
+            if (preg_match('/^@?([^@\s\/\\\]+)@([^\s\/\\\]+)$/', trim($property->getValue()), $matches) !== 1) {
723
+                throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE);
724
+            }
725
+
726
+            [, $username, $instance] = $matches;
727
+            $validated = filter_var($instance, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME);
728
+            if ($validated !== $instance) {
729
+                throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE);
730
+            }
731
+
732
+            if ($this->config->getSystemValueBool('has_internet_connection', true)) {
733
+                $client = $this->clientService->newClient();
734
+
735
+                try {
736
+                    // try the public account lookup API of mastodon
737
+                    $response = $client->get("https://{$instance}/.well-known/webfinger?resource=acct:{$username}@{$instance}");
738
+                    // should be a json response with account information
739
+                    $data = $response->getBody();
740
+                    if (is_resource($data)) {
741
+                        $data = stream_get_contents($data);
742
+                    }
743
+                    $decoded = json_decode($data, true);
744
+                    // ensure the username is the same the user passed
745
+                    // in this case we can assume this is a valid fediverse server and account
746
+                    if (!is_array($decoded) || ($decoded['subject'] ?? '') !== "acct:{$username}@{$instance}") {
747
+                        throw new InvalidArgumentException();
748
+                    }
749
+                    // check for activitypub link
750
+                    if (is_array($decoded['links']) && isset($decoded['links'])) {
751
+                        $found = false;
752
+                        foreach ($decoded['links'] as $link) {
753
+                            // have application/activity+json or application/ld+json
754
+                            if (isset($link['type']) && (
755
+                                $link['type'] === 'application/activity+json'
756
+                                || $link['type'] === 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
757
+                            )) {
758
+                                $found = true;
759
+                                break;
760
+                            }
761
+                        }
762
+                        if (!$found) {
763
+                            throw new InvalidArgumentException();
764
+                        }
765
+                    }
766
+                } catch (InvalidArgumentException) {
767
+                    throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE);
768
+                } catch (\Exception $error) {
769
+                    $this->logger->error('Could not verify fediverse account', ['exception' => $error, 'instance' => $instance]);
770
+                    throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE);
771
+                }
772
+            }
773
+
774
+            $property->setValue("$username@$instance");
775
+        }
776
+    }
777
+
778
+    public function updateAccount(IAccount $account): void {
779
+        $this->testValueLengths(iterator_to_array($account->getAllProperties()), true);
780
+        try {
781
+            $property = $account->getProperty(self::PROPERTY_PHONE);
782
+            if ($property->getValue() !== '') {
783
+                $this->sanitizePropertyPhoneNumber($property);
784
+            }
785
+        } catch (PropertyDoesNotExistException $e) {
786
+            //  valid case, nothing to do
787
+        }
788
+
789
+        try {
790
+            $property = $account->getProperty(self::PROPERTY_WEBSITE);
791
+            if ($property->getValue() !== '') {
792
+                $this->sanitizePropertyWebsite($property);
793
+            }
794
+        } catch (PropertyDoesNotExistException $e) {
795
+            //  valid case, nothing to do
796
+        }
797
+
798
+        try {
799
+            $property = $account->getProperty(self::PROPERTY_TWITTER);
800
+            if ($property->getValue() !== '') {
801
+                $this->sanitizePropertyTwitter($property);
802
+            }
803
+        } catch (PropertyDoesNotExistException $e) {
804
+            //  valid case, nothing to do
805
+        }
806
+
807
+        try {
808
+            $property = $account->getProperty(self::PROPERTY_FEDIVERSE);
809
+            if ($property->getValue() !== '') {
810
+                $this->sanitizePropertyFediverse($property);
811
+            }
812
+        } catch (PropertyDoesNotExistException $e) {
813
+            //  valid case, nothing to do
814
+        }
815
+
816
+        foreach ($account->getAllProperties() as $property) {
817
+            $this->testPropertyScope($property, self::ALLOWED_SCOPES, true);
818
+        }
819
+
820
+        $oldData = $this->getUser($account->getUser(), false);
821
+        $this->updateVerificationStatus($account, $oldData);
822
+        $this->checkEmailVerification($account, $oldData);
823
+        $this->checkLocalEmailVerification($account, $oldData);
824
+
825
+        $data = [];
826
+        foreach ($account->getAllProperties() as $property) {
827
+            /** @var IAccountProperty $property */
828
+            $data[] = [
829
+                'name' => $property->getName(),
830
+                'value' => $property->getValue(),
831
+                'scope' => $property->getScope(),
832
+                'verified' => $property->getVerified(),
833
+                'locallyVerified' => $property->getLocallyVerified(),
834
+            ];
835
+        }
836
+
837
+        $this->updateUser($account->getUser(), $data, $oldData, true);
838
+        $this->internalCache->set($account->getUser()->getUID(), $account);
839
+    }
840 840
 }
Please login to merge, or discard this patch.