Completed
Push — master ( 5acb93...0fa396 )
by
unknown
39:23 queued 09:08
created
lib/private/L10N/Factory.php 1 patch
Indentation   +658 added lines, -658 removed lines patch added patch discarded remove patch
@@ -24,662 +24,662 @@
 block discarded – undo
24 24
  * A factory that generates language instances
25 25
  */
26 26
 class Factory implements IFactory {
27
-	/** @var string */
28
-	protected $requestLanguage = '';
29
-
30
-	/**
31
-	 * cached instances
32
-	 * @var array Structure: Lang => App => \OCP\IL10N
33
-	 */
34
-	protected $instances = [];
35
-
36
-	/**
37
-	 * @var array Structure: App => string[]
38
-	 */
39
-	protected $availableLanguages = [];
40
-
41
-	/**
42
-	 * @var array
43
-	 */
44
-	protected $localeCache = [];
45
-
46
-	/**
47
-	 * @var array
48
-	 */
49
-	protected $availableLocales = [];
50
-
51
-	/**
52
-	 * @var array Structure: string => callable
53
-	 */
54
-	protected $pluralFunctions = [];
55
-
56
-	public const COMMON_LANGUAGE_CODES = [
57
-		'en', 'es', 'fr', 'de', 'de_DE', 'ja', 'ar', 'ru', 'nl', 'it',
58
-		'pt_BR', 'pt_PT', 'da', 'fi_FI', 'nb_NO', 'sv', 'tr', 'zh_CN', 'ko'
59
-	];
60
-
61
-	/**
62
-	 * Keep in sync with `build/translation-checker.php`
63
-	 */
64
-	public const RTL_LANGUAGES = [
65
-		'ar', // Arabic
66
-		'fa', // Persian
67
-		'he', // Hebrew
68
-		'ps', // Pashto,
69
-		'ug', // 'Uyghurche / Uyghur
70
-		'ur_PK', // Urdu
71
-	];
72
-
73
-	private ICache $cache;
74
-
75
-	public function __construct(
76
-		protected IConfig $config,
77
-		protected IRequest $request,
78
-		protected IUserSession $userSession,
79
-		ICacheFactory $cacheFactory,
80
-		protected string $serverRoot,
81
-		protected IAppManager $appManager,
82
-	) {
83
-		$this->cache = $cacheFactory->createLocal('L10NFactory');
84
-	}
85
-
86
-	/**
87
-	 * Get a language instance
88
-	 *
89
-	 * @param string $app
90
-	 * @param string|null $lang
91
-	 * @param string|null $locale
92
-	 * @return \OCP\IL10N
93
-	 */
94
-	public function get($app, $lang = null, $locale = null) {
95
-		return new LazyL10N(function () use ($app, $lang, $locale) {
96
-			$app = $this->appManager->cleanAppId($app);
97
-			$lang = $this->cleanLanguage($lang);
98
-			$forceLang = $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValue('force_language', false);
99
-			if (is_string($forceLang)) {
100
-				$lang = $forceLang;
101
-			}
102
-
103
-			$forceLocale = $this->config->getSystemValue('force_locale', false);
104
-			if (is_string($forceLocale)) {
105
-				$locale = $forceLocale;
106
-			}
107
-
108
-			$lang = $this->validateLanguage($app, $lang);
109
-
110
-			if ($locale === null || !$this->localeExists($locale)) {
111
-				$locale = $this->findLocale($lang);
112
-			}
113
-
114
-			if (!isset($this->instances[$lang][$app])) {
115
-				$this->instances[$lang][$app] = new L10N(
116
-					$this,
117
-					$app,
118
-					$lang,
119
-					$locale,
120
-					$this->getL10nFilesForApp($app, $lang)
121
-				);
122
-			}
123
-
124
-			return $this->instances[$lang][$app];
125
-		});
126
-	}
127
-
128
-	/**
129
-	 * Remove some invalid characters before using a string as a language
130
-	 *
131
-	 * @psalm-taint-escape callable
132
-	 * @psalm-taint-escape cookie
133
-	 * @psalm-taint-escape file
134
-	 * @psalm-taint-escape has_quotes
135
-	 * @psalm-taint-escape header
136
-	 * @psalm-taint-escape html
137
-	 * @psalm-taint-escape include
138
-	 * @psalm-taint-escape ldap
139
-	 * @psalm-taint-escape shell
140
-	 * @psalm-taint-escape sql
141
-	 * @psalm-taint-escape unserialize
142
-	 */
143
-	private function cleanLanguage(?string $lang): ?string {
144
-		if ($lang === null) {
145
-			return null;
146
-		}
147
-		$lang = preg_replace('/[^a-zA-Z0-9.;,=-]/', '', $lang);
148
-		return str_replace('..', '', $lang);
149
-	}
150
-
151
-	/**
152
-	 * Check that $lang is an existing language and not null, otherwise return the language to use instead
153
-	 *
154
-	 * @psalm-taint-escape callable
155
-	 * @psalm-taint-escape cookie
156
-	 * @psalm-taint-escape file
157
-	 * @psalm-taint-escape has_quotes
158
-	 * @psalm-taint-escape header
159
-	 * @psalm-taint-escape html
160
-	 * @psalm-taint-escape include
161
-	 * @psalm-taint-escape ldap
162
-	 * @psalm-taint-escape shell
163
-	 * @psalm-taint-escape sql
164
-	 * @psalm-taint-escape unserialize
165
-	 */
166
-	private function validateLanguage(string $app, ?string $lang): string {
167
-		if ($lang === null || !$this->languageExists($app, $lang)) {
168
-			return $this->findLanguage($app);
169
-		} else {
170
-			return $lang;
171
-		}
172
-	}
173
-
174
-	/**
175
-	 * Find the best language
176
-	 *
177
-	 * @param string|null $appId App id or null for core
178
-	 *
179
-	 * @return string language If nothing works it returns 'en'
180
-	 */
181
-	public function findLanguage(?string $appId = null): string {
182
-		// Step 1: Forced language always has precedence over anything else
183
-		$forceLang = $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValue('force_language', false);
184
-		if (is_string($forceLang)) {
185
-			$this->requestLanguage = $forceLang;
186
-		}
187
-
188
-		// Step 2: Return cached language
189
-		if ($this->requestLanguage !== '' && $this->languageExists($appId, $this->requestLanguage)) {
190
-			return $this->requestLanguage;
191
-		}
192
-
193
-		/**
194
-		 * Step 3: At this point Nextcloud might not yet be installed and thus the lookup
195
-		 * in the preferences table might fail. For this reason we need to check
196
-		 * whether the instance has already been installed
197
-		 *
198
-		 * @link https://github.com/owncloud/core/issues/21955
199
-		 */
200
-		if ($this->config->getSystemValueBool('installed', false)) {
201
-			$userId = !is_null($this->userSession->getUser()) ? $this->userSession->getUser()->getUID() :  null;
202
-			if (!is_null($userId)) {
203
-				$userLang = $this->config->getUserValue($userId, 'core', 'lang', null);
204
-			} else {
205
-				$userLang = null;
206
-			}
207
-		} else {
208
-			$userId = null;
209
-			$userLang = null;
210
-		}
211
-		if ($userLang) {
212
-			$this->requestLanguage = $userLang;
213
-			if ($this->languageExists($appId, $userLang)) {
214
-				return $userLang;
215
-			}
216
-		}
217
-
218
-		// Step 4: Check the request headers
219
-		try {
220
-			// Try to get the language from the Request
221
-			$lang = $this->getLanguageFromRequest($appId);
222
-			if ($userId !== null && $appId === null && !$userLang) {
223
-				$this->config->setUserValue($userId, 'core', 'lang', $lang);
224
-			}
225
-			return $lang;
226
-		} catch (LanguageNotFoundException $e) {
227
-			// Finding language from request failed fall back to default language
228
-			$defaultLanguage = $this->config->getSystemValue('default_language', false);
229
-			if ($defaultLanguage !== false && $this->languageExists($appId, $defaultLanguage)) {
230
-				return $defaultLanguage;
231
-			}
232
-		}
233
-
234
-		// Step 5: fall back to English
235
-		return 'en';
236
-	}
237
-
238
-	public function findGenericLanguage(?string $appId = null): string {
239
-		// Step 1: Forced language always has precedence over anything else
240
-		$forcedLanguage = $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValue('force_language', false);
241
-		if ($forcedLanguage !== false) {
242
-			return $forcedLanguage;
243
-		}
244
-
245
-		// Step 2: Check if we have a default language
246
-		$defaultLanguage = $this->config->getSystemValue('default_language', false);
247
-		if ($defaultLanguage !== false && $this->languageExists($appId, $defaultLanguage)) {
248
-			return $defaultLanguage;
249
-		}
250
-
251
-		// Step 3: fall back to English
252
-		return 'en';
253
-	}
254
-
255
-	/**
256
-	 * find the best locale
257
-	 *
258
-	 * @param string $lang
259
-	 * @return null|string
260
-	 */
261
-	public function findLocale($lang = null) {
262
-		$forceLocale = $this->config->getSystemValue('force_locale', false);
263
-		if (is_string($forceLocale) && $this->localeExists($forceLocale)) {
264
-			return $forceLocale;
265
-		}
266
-
267
-		if ($this->config->getSystemValueBool('installed', false)) {
268
-			$userId = $this->userSession->getUser() !== null ? $this->userSession->getUser()->getUID() :  null;
269
-			$userLocale = null;
270
-			if ($userId !== null) {
271
-				$userLocale = $this->config->getUserValue($userId, 'core', 'locale', null);
272
-			}
273
-		} else {
274
-			$userId = null;
275
-			$userLocale = null;
276
-		}
277
-
278
-		if ($userLocale && $this->localeExists($userLocale)) {
279
-			return $userLocale;
280
-		}
281
-
282
-		// Default : use system default locale
283
-		$defaultLocale = $this->config->getSystemValue('default_locale', false);
284
-		if ($defaultLocale !== false && $this->localeExists($defaultLocale)) {
285
-			return $defaultLocale;
286
-		}
287
-
288
-		// If no user locale set, use lang as locale
289
-		if ($lang !== null && $this->localeExists($lang)) {
290
-			return $lang;
291
-		}
292
-
293
-		// At last, return USA
294
-		return 'en_US';
295
-	}
296
-
297
-	/**
298
-	 * find the matching lang from the locale
299
-	 *
300
-	 * @param string $app
301
-	 * @param string $locale
302
-	 * @return null|string
303
-	 */
304
-	public function findLanguageFromLocale(string $app = 'core', ?string $locale = null) {
305
-		if ($this->languageExists($app, $locale)) {
306
-			return $locale;
307
-		}
308
-
309
-		// Try to split e.g: fr_FR => fr
310
-		$locale = explode('_', $locale)[0];
311
-		if ($this->languageExists($app, $locale)) {
312
-			return $locale;
313
-		}
314
-	}
315
-
316
-	/**
317
-	 * Find all available languages for an app
318
-	 *
319
-	 * @param string|null $app App id or null for core
320
-	 * @return string[] an array of available languages
321
-	 */
322
-	public function findAvailableLanguages($app = null): array {
323
-		$key = $app;
324
-		if ($key === null) {
325
-			$key = 'null';
326
-		}
327
-
328
-		if ($availableLanguages = $this->cache->get($key)) {
329
-			$this->availableLanguages[$key] = $availableLanguages;
330
-		}
331
-
332
-		// also works with null as key
333
-		if (!empty($this->availableLanguages[$key])) {
334
-			return $this->availableLanguages[$key];
335
-		}
336
-
337
-		$available = ['en']; //english is always available
338
-		$dir = $this->findL10nDir($app);
339
-		if (is_dir($dir)) {
340
-			$files = scandir($dir);
341
-			if ($files !== false) {
342
-				foreach ($files as $file) {
343
-					if (str_ends_with($file, '.json') && !str_starts_with($file, 'l10n')) {
344
-						$available[] = substr($file, 0, -5);
345
-					}
346
-				}
347
-			}
348
-		}
349
-
350
-		// merge with translations from theme
351
-		$theme = $this->config->getSystemValueString('theme');
352
-		if (!empty($theme)) {
353
-			$themeDir = $this->serverRoot . '/themes/' . $theme . substr($dir, strlen($this->serverRoot));
354
-
355
-			if (is_dir($themeDir)) {
356
-				$files = scandir($themeDir);
357
-				if ($files !== false) {
358
-					foreach ($files as $file) {
359
-						if (str_ends_with($file, '.json') && !str_starts_with($file, 'l10n')) {
360
-							$available[] = substr($file, 0, -5);
361
-						}
362
-					}
363
-				}
364
-			}
365
-		}
366
-
367
-		$this->availableLanguages[$key] = $available;
368
-		$this->cache->set($key, $available, 60);
369
-		return $available;
370
-	}
371
-
372
-	/**
373
-	 * @return array|mixed
374
-	 */
375
-	public function findAvailableLocales() {
376
-		if (!empty($this->availableLocales)) {
377
-			return $this->availableLocales;
378
-		}
379
-
380
-		$localeData = file_get_contents(\OC::$SERVERROOT . '/resources/locales.json');
381
-		$this->availableLocales = \json_decode($localeData, true);
382
-
383
-		return $this->availableLocales;
384
-	}
385
-
386
-	/**
387
-	 * @param string|null $app App id or null for core
388
-	 * @param string $lang
389
-	 * @return bool
390
-	 */
391
-	public function languageExists($app, $lang) {
392
-		if ($lang === 'en') { //english is always available
393
-			return true;
394
-		}
395
-
396
-		$languages = $this->findAvailableLanguages($app);
397
-		return in_array($lang, $languages);
398
-	}
399
-
400
-	public function getLanguageDirection(string $language): string {
401
-		if (in_array($language, self::RTL_LANGUAGES, true)) {
402
-			return 'rtl';
403
-		}
404
-
405
-		return 'ltr';
406
-	}
407
-
408
-	public function getLanguageIterator(?IUser $user = null): ILanguageIterator {
409
-		$user = $user ?? $this->userSession->getUser();
410
-		if ($user === null) {
411
-			throw new \RuntimeException('Failed to get an IUser instance');
412
-		}
413
-		return new LanguageIterator($user, $this->config);
414
-	}
415
-
416
-	/**
417
-	 * Return the language to use when sending something to a user
418
-	 *
419
-	 * @param IUser|null $user
420
-	 * @return string
421
-	 * @since 20.0.0
422
-	 */
423
-	public function getUserLanguage(?IUser $user = null): string {
424
-		$language = $this->config->getSystemValue('force_language', false);
425
-		if ($language !== false) {
426
-			return $language;
427
-		}
428
-
429
-		if ($user instanceof IUser) {
430
-			$language = $this->config->getUserValue($user->getUID(), 'core', 'lang', null);
431
-			if ($language !== null) {
432
-				return $language;
433
-			}
434
-
435
-			$forcedLanguage = $this->cleanLanguage($this->request->getParam('forceLanguage'));
436
-			if ($forcedLanguage !== null) {
437
-				return $forcedLanguage;
438
-			}
439
-
440
-			// Use language from request
441
-			if ($this->userSession->getUser() instanceof IUser
442
-				&& $user->getUID() === $this->userSession->getUser()->getUID()) {
443
-				try {
444
-					return $this->getLanguageFromRequest();
445
-				} catch (LanguageNotFoundException $e) {
446
-				}
447
-			}
448
-		}
449
-
450
-		return $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValueString('default_language', 'en');
451
-	}
452
-
453
-	/**
454
-	 * @param string $locale
455
-	 * @return bool
456
-	 */
457
-	public function localeExists($locale) {
458
-		if ($locale === 'en') { //english is always available
459
-			return true;
460
-		}
461
-
462
-		if ($this->localeCache === []) {
463
-			$locales = $this->findAvailableLocales();
464
-			foreach ($locales as $l) {
465
-				$this->localeCache[$l['code']] = true;
466
-			}
467
-		}
468
-
469
-		return isset($this->localeCache[$locale]);
470
-	}
471
-
472
-	/**
473
-	 * @throws LanguageNotFoundException
474
-	 */
475
-	private function getLanguageFromRequest(?string $app = null): string {
476
-		$header = $this->cleanLanguage($this->request->getHeader('ACCEPT_LANGUAGE'));
477
-		if ($header !== '') {
478
-			$available = $this->findAvailableLanguages($app);
479
-
480
-			// E.g. make sure that 'de' is before 'de_DE'.
481
-			sort($available);
482
-
483
-			$preferences = preg_split('/,\s*/', strtolower($header));
484
-			foreach ($preferences as $preference) {
485
-				[$preferred_language] = explode(';', $preference);
486
-				$preferred_language = str_replace('-', '_', $preferred_language);
487
-
488
-				$preferred_language_parts = explode('_', $preferred_language);
489
-				foreach ($available as $available_language) {
490
-					if ($preferred_language === strtolower($available_language)) {
491
-						return $this->respectDefaultLanguage($app, $available_language);
492
-					}
493
-					if (strtolower($available_language) === $preferred_language_parts[0] . '_' . end($preferred_language_parts)) {
494
-						return $available_language;
495
-					}
496
-				}
497
-
498
-				// Fallback from de_De to de
499
-				foreach ($available as $available_language) {
500
-					if ($preferred_language_parts[0] === $available_language) {
501
-						return $available_language;
502
-					}
503
-				}
504
-			}
505
-		}
506
-
507
-		throw new LanguageNotFoundException();
508
-	}
509
-
510
-	/**
511
-	 * if default language is set to de_DE (formal German) this should be
512
-	 * preferred to 'de' (non-formal German) if possible
513
-	 */
514
-	protected function respectDefaultLanguage(?string $app, string $lang): string {
515
-		$result = $lang;
516
-		$defaultLanguage = $this->config->getSystemValue('default_language', false);
517
-
518
-		// use formal version of german ("Sie" instead of "Du") if the default
519
-		// language is set to 'de_DE' if possible
520
-		if (
521
-			is_string($defaultLanguage)
522
-			&& strtolower($lang) === 'de'
523
-			&& strtolower($defaultLanguage) === 'de_de'
524
-			&& $this->languageExists($app, 'de_DE')
525
-		) {
526
-			$result = 'de_DE';
527
-		}
528
-
529
-		return $result;
530
-	}
531
-
532
-	/**
533
-	 * Checks if $sub is a subdirectory of $parent
534
-	 *
535
-	 * @param string $sub
536
-	 * @param string $parent
537
-	 * @return bool
538
-	 */
539
-	private function isSubDirectory($sub, $parent) {
540
-		// Check whether $sub contains no ".."
541
-		if (str_contains($sub, '..')) {
542
-			return false;
543
-		}
544
-
545
-		// Check whether $sub is a subdirectory of $parent
546
-		if (str_starts_with($sub, $parent)) {
547
-			return true;
548
-		}
549
-
550
-		return false;
551
-	}
552
-
553
-	/**
554
-	 * Get a list of language files that should be loaded
555
-	 *
556
-	 * @return string[]
557
-	 */
558
-	private function getL10nFilesForApp(string $app, string $lang): array {
559
-		$languageFiles = [];
560
-
561
-		$i18nDir = $this->findL10nDir($app);
562
-		$transFile = strip_tags($i18nDir) . strip_tags($lang) . '.json';
563
-
564
-		if (($this->isSubDirectory($transFile, $this->serverRoot . '/core/l10n/')
565
-				|| $this->isSubDirectory($transFile, $this->serverRoot . '/lib/l10n/')
566
-				|| $this->isSubDirectory($transFile, $this->appManager->getAppPath($app) . '/l10n/'))
567
-			&& file_exists($transFile)
568
-		) {
569
-			// load the translations file
570
-			$languageFiles[] = $transFile;
571
-		}
572
-
573
-		// merge with translations from theme
574
-		$theme = $this->config->getSystemValueString('theme');
575
-		if (!empty($theme)) {
576
-			$transFile = $this->serverRoot . '/themes/' . $theme . substr($transFile, strlen($this->serverRoot));
577
-			if (file_exists($transFile)) {
578
-				$languageFiles[] = $transFile;
579
-			}
580
-		}
581
-
582
-		return $languageFiles;
583
-	}
584
-
585
-	/**
586
-	 * find the l10n directory
587
-	 *
588
-	 * @param string $app App id or empty string for core
589
-	 * @return string directory
590
-	 */
591
-	protected function findL10nDir($app = null) {
592
-		if (in_array($app, ['core', 'lib'])) {
593
-			if (file_exists($this->serverRoot . '/' . $app . '/l10n/')) {
594
-				return $this->serverRoot . '/' . $app . '/l10n/';
595
-			}
596
-		} elseif ($app) {
597
-			try {
598
-				return $this->appManager->getAppPath($app) . '/l10n/';
599
-			} catch (AppPathNotFoundException) {
600
-				/* App not found, continue */
601
-			}
602
-		}
603
-		return $this->serverRoot . '/core/l10n/';
604
-	}
605
-
606
-	/**
607
-	 * @inheritDoc
608
-	 */
609
-	public function getLanguages(): array {
610
-		$forceLanguage = $this->config->getSystemValue('force_language', false);
611
-		if ($forceLanguage !== false) {
612
-			$l = $this->get('lib', $forceLanguage);
613
-			$potentialName = $l->t('__language_name__');
614
-
615
-			return [
616
-				'commonLanguages' => [[
617
-					'code' => $forceLanguage,
618
-					'name' => $potentialName,
619
-				]],
620
-				'otherLanguages' => [],
621
-			];
622
-		}
623
-
624
-		$languageCodes = $this->findAvailableLanguages();
625
-		$reduceToLanguages = $this->config->getSystemValue('reduce_to_languages', []);
626
-		if (!empty($reduceToLanguages)) {
627
-			$languageCodes = array_intersect($languageCodes, $reduceToLanguages);
628
-		}
629
-
630
-		$commonLanguages = [];
631
-		$otherLanguages = [];
632
-
633
-		foreach ($languageCodes as $lang) {
634
-			$l = $this->get('lib', $lang);
635
-			// TRANSLATORS this is the language name for the language switcher in the personal settings and should be the localized version
636
-			$potentialName = $l->t('__language_name__');
637
-			if ($l->getLanguageCode() === $lang && $potentialName[0] !== '_') { //first check if the language name is in the translation file
638
-				$ln = [
639
-					'code' => $lang,
640
-					'name' => $potentialName
641
-				];
642
-			} elseif ($lang === 'en') {
643
-				$ln = [
644
-					'code' => $lang,
645
-					'name' => 'English (US)'
646
-				];
647
-			} else { //fallback to language code
648
-				$ln = [
649
-					'code' => $lang,
650
-					'name' => $lang
651
-				];
652
-			}
653
-
654
-			// put appropriate languages into appropriate arrays, to print them sorted
655
-			// common languages -> divider -> other languages
656
-			if (in_array($lang, self::COMMON_LANGUAGE_CODES)) {
657
-				$commonLanguages[array_search($lang, self::COMMON_LANGUAGE_CODES, true)] = $ln;
658
-			} else {
659
-				$otherLanguages[] = $ln;
660
-			}
661
-		}
662
-
663
-		ksort($commonLanguages);
664
-
665
-		// sort now by displayed language not the iso-code
666
-		usort($otherLanguages, function ($a, $b) {
667
-			if ($a['code'] === $a['name'] && $b['code'] !== $b['name']) {
668
-				// If a doesn't have a name, but b does, list b before a
669
-				return 1;
670
-			}
671
-			if ($a['code'] !== $a['name'] && $b['code'] === $b['name']) {
672
-				// If a does have a name, but b doesn't, list a before b
673
-				return -1;
674
-			}
675
-			// Otherwise compare the names
676
-			return strcmp($a['name'], $b['name']);
677
-		});
678
-
679
-		return [
680
-			// reset indexes
681
-			'commonLanguages' => array_values($commonLanguages),
682
-			'otherLanguages' => $otherLanguages
683
-		];
684
-	}
27
+    /** @var string */
28
+    protected $requestLanguage = '';
29
+
30
+    /**
31
+     * cached instances
32
+     * @var array Structure: Lang => App => \OCP\IL10N
33
+     */
34
+    protected $instances = [];
35
+
36
+    /**
37
+     * @var array Structure: App => string[]
38
+     */
39
+    protected $availableLanguages = [];
40
+
41
+    /**
42
+     * @var array
43
+     */
44
+    protected $localeCache = [];
45
+
46
+    /**
47
+     * @var array
48
+     */
49
+    protected $availableLocales = [];
50
+
51
+    /**
52
+     * @var array Structure: string => callable
53
+     */
54
+    protected $pluralFunctions = [];
55
+
56
+    public const COMMON_LANGUAGE_CODES = [
57
+        'en', 'es', 'fr', 'de', 'de_DE', 'ja', 'ar', 'ru', 'nl', 'it',
58
+        'pt_BR', 'pt_PT', 'da', 'fi_FI', 'nb_NO', 'sv', 'tr', 'zh_CN', 'ko'
59
+    ];
60
+
61
+    /**
62
+     * Keep in sync with `build/translation-checker.php`
63
+     */
64
+    public const RTL_LANGUAGES = [
65
+        'ar', // Arabic
66
+        'fa', // Persian
67
+        'he', // Hebrew
68
+        'ps', // Pashto,
69
+        'ug', // 'Uyghurche / Uyghur
70
+        'ur_PK', // Urdu
71
+    ];
72
+
73
+    private ICache $cache;
74
+
75
+    public function __construct(
76
+        protected IConfig $config,
77
+        protected IRequest $request,
78
+        protected IUserSession $userSession,
79
+        ICacheFactory $cacheFactory,
80
+        protected string $serverRoot,
81
+        protected IAppManager $appManager,
82
+    ) {
83
+        $this->cache = $cacheFactory->createLocal('L10NFactory');
84
+    }
85
+
86
+    /**
87
+     * Get a language instance
88
+     *
89
+     * @param string $app
90
+     * @param string|null $lang
91
+     * @param string|null $locale
92
+     * @return \OCP\IL10N
93
+     */
94
+    public function get($app, $lang = null, $locale = null) {
95
+        return new LazyL10N(function () use ($app, $lang, $locale) {
96
+            $app = $this->appManager->cleanAppId($app);
97
+            $lang = $this->cleanLanguage($lang);
98
+            $forceLang = $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValue('force_language', false);
99
+            if (is_string($forceLang)) {
100
+                $lang = $forceLang;
101
+            }
102
+
103
+            $forceLocale = $this->config->getSystemValue('force_locale', false);
104
+            if (is_string($forceLocale)) {
105
+                $locale = $forceLocale;
106
+            }
107
+
108
+            $lang = $this->validateLanguage($app, $lang);
109
+
110
+            if ($locale === null || !$this->localeExists($locale)) {
111
+                $locale = $this->findLocale($lang);
112
+            }
113
+
114
+            if (!isset($this->instances[$lang][$app])) {
115
+                $this->instances[$lang][$app] = new L10N(
116
+                    $this,
117
+                    $app,
118
+                    $lang,
119
+                    $locale,
120
+                    $this->getL10nFilesForApp($app, $lang)
121
+                );
122
+            }
123
+
124
+            return $this->instances[$lang][$app];
125
+        });
126
+    }
127
+
128
+    /**
129
+     * Remove some invalid characters before using a string as a language
130
+     *
131
+     * @psalm-taint-escape callable
132
+     * @psalm-taint-escape cookie
133
+     * @psalm-taint-escape file
134
+     * @psalm-taint-escape has_quotes
135
+     * @psalm-taint-escape header
136
+     * @psalm-taint-escape html
137
+     * @psalm-taint-escape include
138
+     * @psalm-taint-escape ldap
139
+     * @psalm-taint-escape shell
140
+     * @psalm-taint-escape sql
141
+     * @psalm-taint-escape unserialize
142
+     */
143
+    private function cleanLanguage(?string $lang): ?string {
144
+        if ($lang === null) {
145
+            return null;
146
+        }
147
+        $lang = preg_replace('/[^a-zA-Z0-9.;,=-]/', '', $lang);
148
+        return str_replace('..', '', $lang);
149
+    }
150
+
151
+    /**
152
+     * Check that $lang is an existing language and not null, otherwise return the language to use instead
153
+     *
154
+     * @psalm-taint-escape callable
155
+     * @psalm-taint-escape cookie
156
+     * @psalm-taint-escape file
157
+     * @psalm-taint-escape has_quotes
158
+     * @psalm-taint-escape header
159
+     * @psalm-taint-escape html
160
+     * @psalm-taint-escape include
161
+     * @psalm-taint-escape ldap
162
+     * @psalm-taint-escape shell
163
+     * @psalm-taint-escape sql
164
+     * @psalm-taint-escape unserialize
165
+     */
166
+    private function validateLanguage(string $app, ?string $lang): string {
167
+        if ($lang === null || !$this->languageExists($app, $lang)) {
168
+            return $this->findLanguage($app);
169
+        } else {
170
+            return $lang;
171
+        }
172
+    }
173
+
174
+    /**
175
+     * Find the best language
176
+     *
177
+     * @param string|null $appId App id or null for core
178
+     *
179
+     * @return string language If nothing works it returns 'en'
180
+     */
181
+    public function findLanguage(?string $appId = null): string {
182
+        // Step 1: Forced language always has precedence over anything else
183
+        $forceLang = $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValue('force_language', false);
184
+        if (is_string($forceLang)) {
185
+            $this->requestLanguage = $forceLang;
186
+        }
187
+
188
+        // Step 2: Return cached language
189
+        if ($this->requestLanguage !== '' && $this->languageExists($appId, $this->requestLanguage)) {
190
+            return $this->requestLanguage;
191
+        }
192
+
193
+        /**
194
+         * Step 3: At this point Nextcloud might not yet be installed and thus the lookup
195
+         * in the preferences table might fail. For this reason we need to check
196
+         * whether the instance has already been installed
197
+         *
198
+         * @link https://github.com/owncloud/core/issues/21955
199
+         */
200
+        if ($this->config->getSystemValueBool('installed', false)) {
201
+            $userId = !is_null($this->userSession->getUser()) ? $this->userSession->getUser()->getUID() :  null;
202
+            if (!is_null($userId)) {
203
+                $userLang = $this->config->getUserValue($userId, 'core', 'lang', null);
204
+            } else {
205
+                $userLang = null;
206
+            }
207
+        } else {
208
+            $userId = null;
209
+            $userLang = null;
210
+        }
211
+        if ($userLang) {
212
+            $this->requestLanguage = $userLang;
213
+            if ($this->languageExists($appId, $userLang)) {
214
+                return $userLang;
215
+            }
216
+        }
217
+
218
+        // Step 4: Check the request headers
219
+        try {
220
+            // Try to get the language from the Request
221
+            $lang = $this->getLanguageFromRequest($appId);
222
+            if ($userId !== null && $appId === null && !$userLang) {
223
+                $this->config->setUserValue($userId, 'core', 'lang', $lang);
224
+            }
225
+            return $lang;
226
+        } catch (LanguageNotFoundException $e) {
227
+            // Finding language from request failed fall back to default language
228
+            $defaultLanguage = $this->config->getSystemValue('default_language', false);
229
+            if ($defaultLanguage !== false && $this->languageExists($appId, $defaultLanguage)) {
230
+                return $defaultLanguage;
231
+            }
232
+        }
233
+
234
+        // Step 5: fall back to English
235
+        return 'en';
236
+    }
237
+
238
+    public function findGenericLanguage(?string $appId = null): string {
239
+        // Step 1: Forced language always has precedence over anything else
240
+        $forcedLanguage = $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValue('force_language', false);
241
+        if ($forcedLanguage !== false) {
242
+            return $forcedLanguage;
243
+        }
244
+
245
+        // Step 2: Check if we have a default language
246
+        $defaultLanguage = $this->config->getSystemValue('default_language', false);
247
+        if ($defaultLanguage !== false && $this->languageExists($appId, $defaultLanguage)) {
248
+            return $defaultLanguage;
249
+        }
250
+
251
+        // Step 3: fall back to English
252
+        return 'en';
253
+    }
254
+
255
+    /**
256
+     * find the best locale
257
+     *
258
+     * @param string $lang
259
+     * @return null|string
260
+     */
261
+    public function findLocale($lang = null) {
262
+        $forceLocale = $this->config->getSystemValue('force_locale', false);
263
+        if (is_string($forceLocale) && $this->localeExists($forceLocale)) {
264
+            return $forceLocale;
265
+        }
266
+
267
+        if ($this->config->getSystemValueBool('installed', false)) {
268
+            $userId = $this->userSession->getUser() !== null ? $this->userSession->getUser()->getUID() :  null;
269
+            $userLocale = null;
270
+            if ($userId !== null) {
271
+                $userLocale = $this->config->getUserValue($userId, 'core', 'locale', null);
272
+            }
273
+        } else {
274
+            $userId = null;
275
+            $userLocale = null;
276
+        }
277
+
278
+        if ($userLocale && $this->localeExists($userLocale)) {
279
+            return $userLocale;
280
+        }
281
+
282
+        // Default : use system default locale
283
+        $defaultLocale = $this->config->getSystemValue('default_locale', false);
284
+        if ($defaultLocale !== false && $this->localeExists($defaultLocale)) {
285
+            return $defaultLocale;
286
+        }
287
+
288
+        // If no user locale set, use lang as locale
289
+        if ($lang !== null && $this->localeExists($lang)) {
290
+            return $lang;
291
+        }
292
+
293
+        // At last, return USA
294
+        return 'en_US';
295
+    }
296
+
297
+    /**
298
+     * find the matching lang from the locale
299
+     *
300
+     * @param string $app
301
+     * @param string $locale
302
+     * @return null|string
303
+     */
304
+    public function findLanguageFromLocale(string $app = 'core', ?string $locale = null) {
305
+        if ($this->languageExists($app, $locale)) {
306
+            return $locale;
307
+        }
308
+
309
+        // Try to split e.g: fr_FR => fr
310
+        $locale = explode('_', $locale)[0];
311
+        if ($this->languageExists($app, $locale)) {
312
+            return $locale;
313
+        }
314
+    }
315
+
316
+    /**
317
+     * Find all available languages for an app
318
+     *
319
+     * @param string|null $app App id or null for core
320
+     * @return string[] an array of available languages
321
+     */
322
+    public function findAvailableLanguages($app = null): array {
323
+        $key = $app;
324
+        if ($key === null) {
325
+            $key = 'null';
326
+        }
327
+
328
+        if ($availableLanguages = $this->cache->get($key)) {
329
+            $this->availableLanguages[$key] = $availableLanguages;
330
+        }
331
+
332
+        // also works with null as key
333
+        if (!empty($this->availableLanguages[$key])) {
334
+            return $this->availableLanguages[$key];
335
+        }
336
+
337
+        $available = ['en']; //english is always available
338
+        $dir = $this->findL10nDir($app);
339
+        if (is_dir($dir)) {
340
+            $files = scandir($dir);
341
+            if ($files !== false) {
342
+                foreach ($files as $file) {
343
+                    if (str_ends_with($file, '.json') && !str_starts_with($file, 'l10n')) {
344
+                        $available[] = substr($file, 0, -5);
345
+                    }
346
+                }
347
+            }
348
+        }
349
+
350
+        // merge with translations from theme
351
+        $theme = $this->config->getSystemValueString('theme');
352
+        if (!empty($theme)) {
353
+            $themeDir = $this->serverRoot . '/themes/' . $theme . substr($dir, strlen($this->serverRoot));
354
+
355
+            if (is_dir($themeDir)) {
356
+                $files = scandir($themeDir);
357
+                if ($files !== false) {
358
+                    foreach ($files as $file) {
359
+                        if (str_ends_with($file, '.json') && !str_starts_with($file, 'l10n')) {
360
+                            $available[] = substr($file, 0, -5);
361
+                        }
362
+                    }
363
+                }
364
+            }
365
+        }
366
+
367
+        $this->availableLanguages[$key] = $available;
368
+        $this->cache->set($key, $available, 60);
369
+        return $available;
370
+    }
371
+
372
+    /**
373
+     * @return array|mixed
374
+     */
375
+    public function findAvailableLocales() {
376
+        if (!empty($this->availableLocales)) {
377
+            return $this->availableLocales;
378
+        }
379
+
380
+        $localeData = file_get_contents(\OC::$SERVERROOT . '/resources/locales.json');
381
+        $this->availableLocales = \json_decode($localeData, true);
382
+
383
+        return $this->availableLocales;
384
+    }
385
+
386
+    /**
387
+     * @param string|null $app App id or null for core
388
+     * @param string $lang
389
+     * @return bool
390
+     */
391
+    public function languageExists($app, $lang) {
392
+        if ($lang === 'en') { //english is always available
393
+            return true;
394
+        }
395
+
396
+        $languages = $this->findAvailableLanguages($app);
397
+        return in_array($lang, $languages);
398
+    }
399
+
400
+    public function getLanguageDirection(string $language): string {
401
+        if (in_array($language, self::RTL_LANGUAGES, true)) {
402
+            return 'rtl';
403
+        }
404
+
405
+        return 'ltr';
406
+    }
407
+
408
+    public function getLanguageIterator(?IUser $user = null): ILanguageIterator {
409
+        $user = $user ?? $this->userSession->getUser();
410
+        if ($user === null) {
411
+            throw new \RuntimeException('Failed to get an IUser instance');
412
+        }
413
+        return new LanguageIterator($user, $this->config);
414
+    }
415
+
416
+    /**
417
+     * Return the language to use when sending something to a user
418
+     *
419
+     * @param IUser|null $user
420
+     * @return string
421
+     * @since 20.0.0
422
+     */
423
+    public function getUserLanguage(?IUser $user = null): string {
424
+        $language = $this->config->getSystemValue('force_language', false);
425
+        if ($language !== false) {
426
+            return $language;
427
+        }
428
+
429
+        if ($user instanceof IUser) {
430
+            $language = $this->config->getUserValue($user->getUID(), 'core', 'lang', null);
431
+            if ($language !== null) {
432
+                return $language;
433
+            }
434
+
435
+            $forcedLanguage = $this->cleanLanguage($this->request->getParam('forceLanguage'));
436
+            if ($forcedLanguage !== null) {
437
+                return $forcedLanguage;
438
+            }
439
+
440
+            // Use language from request
441
+            if ($this->userSession->getUser() instanceof IUser
442
+                && $user->getUID() === $this->userSession->getUser()->getUID()) {
443
+                try {
444
+                    return $this->getLanguageFromRequest();
445
+                } catch (LanguageNotFoundException $e) {
446
+                }
447
+            }
448
+        }
449
+
450
+        return $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValueString('default_language', 'en');
451
+    }
452
+
453
+    /**
454
+     * @param string $locale
455
+     * @return bool
456
+     */
457
+    public function localeExists($locale) {
458
+        if ($locale === 'en') { //english is always available
459
+            return true;
460
+        }
461
+
462
+        if ($this->localeCache === []) {
463
+            $locales = $this->findAvailableLocales();
464
+            foreach ($locales as $l) {
465
+                $this->localeCache[$l['code']] = true;
466
+            }
467
+        }
468
+
469
+        return isset($this->localeCache[$locale]);
470
+    }
471
+
472
+    /**
473
+     * @throws LanguageNotFoundException
474
+     */
475
+    private function getLanguageFromRequest(?string $app = null): string {
476
+        $header = $this->cleanLanguage($this->request->getHeader('ACCEPT_LANGUAGE'));
477
+        if ($header !== '') {
478
+            $available = $this->findAvailableLanguages($app);
479
+
480
+            // E.g. make sure that 'de' is before 'de_DE'.
481
+            sort($available);
482
+
483
+            $preferences = preg_split('/,\s*/', strtolower($header));
484
+            foreach ($preferences as $preference) {
485
+                [$preferred_language] = explode(';', $preference);
486
+                $preferred_language = str_replace('-', '_', $preferred_language);
487
+
488
+                $preferred_language_parts = explode('_', $preferred_language);
489
+                foreach ($available as $available_language) {
490
+                    if ($preferred_language === strtolower($available_language)) {
491
+                        return $this->respectDefaultLanguage($app, $available_language);
492
+                    }
493
+                    if (strtolower($available_language) === $preferred_language_parts[0] . '_' . end($preferred_language_parts)) {
494
+                        return $available_language;
495
+                    }
496
+                }
497
+
498
+                // Fallback from de_De to de
499
+                foreach ($available as $available_language) {
500
+                    if ($preferred_language_parts[0] === $available_language) {
501
+                        return $available_language;
502
+                    }
503
+                }
504
+            }
505
+        }
506
+
507
+        throw new LanguageNotFoundException();
508
+    }
509
+
510
+    /**
511
+     * if default language is set to de_DE (formal German) this should be
512
+     * preferred to 'de' (non-formal German) if possible
513
+     */
514
+    protected function respectDefaultLanguage(?string $app, string $lang): string {
515
+        $result = $lang;
516
+        $defaultLanguage = $this->config->getSystemValue('default_language', false);
517
+
518
+        // use formal version of german ("Sie" instead of "Du") if the default
519
+        // language is set to 'de_DE' if possible
520
+        if (
521
+            is_string($defaultLanguage)
522
+            && strtolower($lang) === 'de'
523
+            && strtolower($defaultLanguage) === 'de_de'
524
+            && $this->languageExists($app, 'de_DE')
525
+        ) {
526
+            $result = 'de_DE';
527
+        }
528
+
529
+        return $result;
530
+    }
531
+
532
+    /**
533
+     * Checks if $sub is a subdirectory of $parent
534
+     *
535
+     * @param string $sub
536
+     * @param string $parent
537
+     * @return bool
538
+     */
539
+    private function isSubDirectory($sub, $parent) {
540
+        // Check whether $sub contains no ".."
541
+        if (str_contains($sub, '..')) {
542
+            return false;
543
+        }
544
+
545
+        // Check whether $sub is a subdirectory of $parent
546
+        if (str_starts_with($sub, $parent)) {
547
+            return true;
548
+        }
549
+
550
+        return false;
551
+    }
552
+
553
+    /**
554
+     * Get a list of language files that should be loaded
555
+     *
556
+     * @return string[]
557
+     */
558
+    private function getL10nFilesForApp(string $app, string $lang): array {
559
+        $languageFiles = [];
560
+
561
+        $i18nDir = $this->findL10nDir($app);
562
+        $transFile = strip_tags($i18nDir) . strip_tags($lang) . '.json';
563
+
564
+        if (($this->isSubDirectory($transFile, $this->serverRoot . '/core/l10n/')
565
+                || $this->isSubDirectory($transFile, $this->serverRoot . '/lib/l10n/')
566
+                || $this->isSubDirectory($transFile, $this->appManager->getAppPath($app) . '/l10n/'))
567
+            && file_exists($transFile)
568
+        ) {
569
+            // load the translations file
570
+            $languageFiles[] = $transFile;
571
+        }
572
+
573
+        // merge with translations from theme
574
+        $theme = $this->config->getSystemValueString('theme');
575
+        if (!empty($theme)) {
576
+            $transFile = $this->serverRoot . '/themes/' . $theme . substr($transFile, strlen($this->serverRoot));
577
+            if (file_exists($transFile)) {
578
+                $languageFiles[] = $transFile;
579
+            }
580
+        }
581
+
582
+        return $languageFiles;
583
+    }
584
+
585
+    /**
586
+     * find the l10n directory
587
+     *
588
+     * @param string $app App id or empty string for core
589
+     * @return string directory
590
+     */
591
+    protected function findL10nDir($app = null) {
592
+        if (in_array($app, ['core', 'lib'])) {
593
+            if (file_exists($this->serverRoot . '/' . $app . '/l10n/')) {
594
+                return $this->serverRoot . '/' . $app . '/l10n/';
595
+            }
596
+        } elseif ($app) {
597
+            try {
598
+                return $this->appManager->getAppPath($app) . '/l10n/';
599
+            } catch (AppPathNotFoundException) {
600
+                /* App not found, continue */
601
+            }
602
+        }
603
+        return $this->serverRoot . '/core/l10n/';
604
+    }
605
+
606
+    /**
607
+     * @inheritDoc
608
+     */
609
+    public function getLanguages(): array {
610
+        $forceLanguage = $this->config->getSystemValue('force_language', false);
611
+        if ($forceLanguage !== false) {
612
+            $l = $this->get('lib', $forceLanguage);
613
+            $potentialName = $l->t('__language_name__');
614
+
615
+            return [
616
+                'commonLanguages' => [[
617
+                    'code' => $forceLanguage,
618
+                    'name' => $potentialName,
619
+                ]],
620
+                'otherLanguages' => [],
621
+            ];
622
+        }
623
+
624
+        $languageCodes = $this->findAvailableLanguages();
625
+        $reduceToLanguages = $this->config->getSystemValue('reduce_to_languages', []);
626
+        if (!empty($reduceToLanguages)) {
627
+            $languageCodes = array_intersect($languageCodes, $reduceToLanguages);
628
+        }
629
+
630
+        $commonLanguages = [];
631
+        $otherLanguages = [];
632
+
633
+        foreach ($languageCodes as $lang) {
634
+            $l = $this->get('lib', $lang);
635
+            // TRANSLATORS this is the language name for the language switcher in the personal settings and should be the localized version
636
+            $potentialName = $l->t('__language_name__');
637
+            if ($l->getLanguageCode() === $lang && $potentialName[0] !== '_') { //first check if the language name is in the translation file
638
+                $ln = [
639
+                    'code' => $lang,
640
+                    'name' => $potentialName
641
+                ];
642
+            } elseif ($lang === 'en') {
643
+                $ln = [
644
+                    'code' => $lang,
645
+                    'name' => 'English (US)'
646
+                ];
647
+            } else { //fallback to language code
648
+                $ln = [
649
+                    'code' => $lang,
650
+                    'name' => $lang
651
+                ];
652
+            }
653
+
654
+            // put appropriate languages into appropriate arrays, to print them sorted
655
+            // common languages -> divider -> other languages
656
+            if (in_array($lang, self::COMMON_LANGUAGE_CODES)) {
657
+                $commonLanguages[array_search($lang, self::COMMON_LANGUAGE_CODES, true)] = $ln;
658
+            } else {
659
+                $otherLanguages[] = $ln;
660
+            }
661
+        }
662
+
663
+        ksort($commonLanguages);
664
+
665
+        // sort now by displayed language not the iso-code
666
+        usort($otherLanguages, function ($a, $b) {
667
+            if ($a['code'] === $a['name'] && $b['code'] !== $b['name']) {
668
+                // If a doesn't have a name, but b does, list b before a
669
+                return 1;
670
+            }
671
+            if ($a['code'] !== $a['name'] && $b['code'] === $b['name']) {
672
+                // If a does have a name, but b doesn't, list a before b
673
+                return -1;
674
+            }
675
+            // Otherwise compare the names
676
+            return strcmp($a['name'], $b['name']);
677
+        });
678
+
679
+        return [
680
+            // reset indexes
681
+            'commonLanguages' => array_values($commonLanguages),
682
+            'otherLanguages' => $otherLanguages
683
+        ];
684
+    }
685 685
 }
Please login to merge, or discard this patch.
lib/private/Files/Storage/Local.php 1 patch
Indentation   +566 added lines, -566 removed lines patch added patch discarded remove patch
@@ -25,570 +25,570 @@
 block discarded – undo
25 25
  * for local filestore, we only have to map the paths
26 26
  */
27 27
 class Local extends \OC\Files\Storage\Common {
28
-	protected $datadir;
29
-
30
-	protected $dataDirLength;
31
-
32
-	protected $realDataDir;
33
-
34
-	private IConfig $config;
35
-
36
-	private IMimeTypeDetector $mimeTypeDetector;
37
-
38
-	private $defUMask;
39
-
40
-	protected bool $unlinkOnTruncate;
41
-
42
-	protected bool $caseInsensitive = false;
43
-
44
-	public function __construct(array $parameters) {
45
-		if (!isset($parameters['datadir']) || !is_string($parameters['datadir'])) {
46
-			throw new \InvalidArgumentException('No data directory set for local storage');
47
-		}
48
-		$this->datadir = str_replace('//', '/', $parameters['datadir']);
49
-		// some crazy code uses a local storage on root...
50
-		if ($this->datadir === '/') {
51
-			$this->realDataDir = $this->datadir;
52
-		} else {
53
-			$realPath = realpath($this->datadir) ?: $this->datadir;
54
-			$this->realDataDir = rtrim($realPath, '/') . '/';
55
-		}
56
-		if (!str_ends_with($this->datadir, '/')) {
57
-			$this->datadir .= '/';
58
-		}
59
-		$this->dataDirLength = strlen($this->realDataDir);
60
-		$this->config = Server::get(IConfig::class);
61
-		$this->mimeTypeDetector = Server::get(IMimeTypeDetector::class);
62
-		$this->defUMask = $this->config->getSystemValue('localstorage.umask', 0022);
63
-		$this->caseInsensitive = $this->config->getSystemValueBool('localstorage.case_insensitive', false);
64
-
65
-		// support Write-Once-Read-Many file systems
66
-		$this->unlinkOnTruncate = $this->config->getSystemValueBool('localstorage.unlink_on_truncate', false);
67
-
68
-		if (isset($parameters['isExternal']) && $parameters['isExternal'] && !$this->stat('')) {
69
-			// data dir not accessible or available, can happen when using an external storage of type Local
70
-			// on an unmounted system mount point
71
-			throw new StorageNotAvailableException('Local storage path does not exist "' . $this->getSourcePath('') . '"');
72
-		}
73
-	}
74
-
75
-	public function __destruct() {
76
-	}
77
-
78
-	public function getId(): string {
79
-		return 'local::' . $this->datadir;
80
-	}
81
-
82
-	public function mkdir(string $path): bool {
83
-		$sourcePath = $this->getSourcePath($path);
84
-		$oldMask = umask($this->defUMask);
85
-		$result = @mkdir($sourcePath, 0777, true);
86
-		umask($oldMask);
87
-		return $result;
88
-	}
89
-
90
-	public function rmdir(string $path): bool {
91
-		if (!$this->isDeletable($path)) {
92
-			return false;
93
-		}
94
-		try {
95
-			$it = new \RecursiveIteratorIterator(
96
-				new \RecursiveDirectoryIterator($this->getSourcePath($path)),
97
-				\RecursiveIteratorIterator::CHILD_FIRST
98
-			);
99
-			/**
100
-			 * RecursiveDirectoryIterator on an NFS path isn't iterable with foreach
101
-			 * This bug is fixed in PHP 5.5.9 or before
102
-			 * See #8376
103
-			 */
104
-			$it->rewind();
105
-			while ($it->valid()) {
106
-				/**
107
-				 * @var \SplFileInfo $file
108
-				 */
109
-				$file = $it->current();
110
-				clearstatcache(true, $file->getRealPath());
111
-				if (in_array($file->getBasename(), ['.', '..'])) {
112
-					$it->next();
113
-					continue;
114
-				} elseif ($file->isFile() || $file->isLink()) {
115
-					unlink($file->getPathname());
116
-				} elseif ($file->isDir()) {
117
-					rmdir($file->getPathname());
118
-				}
119
-				$it->next();
120
-			}
121
-			unset($it);  // Release iterator and thereby its potential directory lock (e.g. in case of VirtualBox shared folders)
122
-			clearstatcache(true, $this->getSourcePath($path));
123
-			return rmdir($this->getSourcePath($path));
124
-		} catch (\UnexpectedValueException $e) {
125
-			return false;
126
-		}
127
-	}
128
-
129
-	public function opendir(string $path) {
130
-		return opendir($this->getSourcePath($path));
131
-	}
132
-
133
-	public function is_dir(string $path): bool {
134
-		if ($this->caseInsensitive && !$this->file_exists($path)) {
135
-			return false;
136
-		}
137
-		if (str_ends_with($path, '/')) {
138
-			$path = substr($path, 0, -1);
139
-		}
140
-		return is_dir($this->getSourcePath($path));
141
-	}
142
-
143
-	public function is_file(string $path): bool {
144
-		if ($this->caseInsensitive && !$this->file_exists($path)) {
145
-			return false;
146
-		}
147
-		return is_file($this->getSourcePath($path));
148
-	}
149
-
150
-	public function stat(string $path): array|false {
151
-		$fullPath = $this->getSourcePath($path);
152
-		clearstatcache(true, $fullPath);
153
-		if (!file_exists($fullPath)) {
154
-			return false;
155
-		}
156
-		$statResult = @stat($fullPath);
157
-		if (PHP_INT_SIZE === 4 && $statResult && !$this->is_dir($path)) {
158
-			$filesize = $this->filesize($path);
159
-			$statResult['size'] = $filesize;
160
-			$statResult[7] = $filesize;
161
-		}
162
-		if (is_array($statResult)) {
163
-			$statResult['full_path'] = $fullPath;
164
-		}
165
-		return $statResult;
166
-	}
167
-
168
-	public function getMetaData(string $path): ?array {
169
-		try {
170
-			$stat = $this->stat($path);
171
-		} catch (ForbiddenException $e) {
172
-			return null;
173
-		}
174
-		if (!$stat) {
175
-			return null;
176
-		}
177
-
178
-		$permissions = Constants::PERMISSION_SHARE;
179
-		$statPermissions = $stat['mode'];
180
-		$isDir = ($statPermissions & 0x4000) === 0x4000 && !($statPermissions & 0x8000);
181
-		if ($statPermissions & 0x0100) {
182
-			$permissions += Constants::PERMISSION_READ;
183
-		}
184
-		if ($statPermissions & 0x0080) {
185
-			$permissions += Constants::PERMISSION_UPDATE;
186
-			if ($isDir) {
187
-				$permissions += Constants::PERMISSION_CREATE;
188
-			}
189
-		}
190
-
191
-		if (!($path === '' || $path === '/')) { // deletable depends on the parents unix permissions
192
-			$parent = dirname($stat['full_path']);
193
-			if (is_writable($parent)) {
194
-				$permissions += Constants::PERMISSION_DELETE;
195
-			}
196
-		}
197
-
198
-		$data = [];
199
-		$data['mimetype'] = $isDir ? 'httpd/unix-directory' : $this->mimeTypeDetector->detectPath($path);
200
-		$data['mtime'] = $stat['mtime'];
201
-		if ($data['mtime'] === false) {
202
-			$data['mtime'] = time();
203
-		}
204
-		if ($isDir) {
205
-			$data['size'] = -1; //unknown
206
-		} else {
207
-			$data['size'] = $stat['size'];
208
-		}
209
-		$data['etag'] = $this->calculateEtag($path, $stat);
210
-		$data['storage_mtime'] = $data['mtime'];
211
-		$data['permissions'] = $permissions;
212
-		$data['name'] = basename($path);
213
-
214
-		return $data;
215
-	}
216
-
217
-	public function filetype(string $path): string|false {
218
-		$filetype = filetype($this->getSourcePath($path));
219
-		if ($filetype == 'link') {
220
-			$filetype = filetype(realpath($this->getSourcePath($path)));
221
-		}
222
-		return $filetype;
223
-	}
224
-
225
-	public function filesize(string $path): int|float|false {
226
-		if (!$this->is_file($path)) {
227
-			return 0;
228
-		}
229
-		$fullPath = $this->getSourcePath($path);
230
-		if (PHP_INT_SIZE === 4) {
231
-			$helper = new \OC\LargeFileHelper;
232
-			return $helper->getFileSize($fullPath);
233
-		}
234
-		return filesize($fullPath);
235
-	}
236
-
237
-	public function isReadable(string $path): bool {
238
-		return is_readable($this->getSourcePath($path));
239
-	}
240
-
241
-	public function isUpdatable(string $path): bool {
242
-		return is_writable($this->getSourcePath($path));
243
-	}
244
-
245
-	public function file_exists(string $path): bool {
246
-		if ($this->caseInsensitive) {
247
-			$fullPath = $this->getSourcePath($path);
248
-			$parentPath = dirname($fullPath);
249
-			if (!is_dir($parentPath)) {
250
-				return false;
251
-			}
252
-			$content = scandir($parentPath, SCANDIR_SORT_NONE);
253
-			return is_array($content) && array_search(basename($fullPath), $content, true) !== false;
254
-		} else {
255
-			return file_exists($this->getSourcePath($path));
256
-		}
257
-	}
258
-
259
-	public function filemtime(string $path): int|false {
260
-		$fullPath = $this->getSourcePath($path);
261
-		clearstatcache(true, $fullPath);
262
-		if (!$this->file_exists($path)) {
263
-			return false;
264
-		}
265
-		if (PHP_INT_SIZE === 4) {
266
-			$helper = new \OC\LargeFileHelper();
267
-			return $helper->getFileMtime($fullPath);
268
-		}
269
-		return filemtime($fullPath);
270
-	}
271
-
272
-	public function touch(string $path, ?int $mtime = null): bool {
273
-		// sets the modification time of the file to the given value.
274
-		// If mtime is nil the current time is set.
275
-		// note that the access time of the file always changes to the current time.
276
-		if ($this->file_exists($path) && !$this->isUpdatable($path)) {
277
-			return false;
278
-		}
279
-		$oldMask = umask($this->defUMask);
280
-		if (!is_null($mtime)) {
281
-			$result = @touch($this->getSourcePath($path), $mtime);
282
-		} else {
283
-			$result = @touch($this->getSourcePath($path));
284
-		}
285
-		umask($oldMask);
286
-		if ($result) {
287
-			clearstatcache(true, $this->getSourcePath($path));
288
-		}
289
-
290
-		return $result;
291
-	}
292
-
293
-	public function file_get_contents(string $path): string|false {
294
-		return file_get_contents($this->getSourcePath($path));
295
-	}
296
-
297
-	public function file_put_contents(string $path, mixed $data): int|float|false {
298
-		$oldMask = umask($this->defUMask);
299
-		if ($this->unlinkOnTruncate) {
300
-			$this->unlink($path);
301
-		}
302
-		$result = file_put_contents($this->getSourcePath($path), $data);
303
-		umask($oldMask);
304
-		return $result;
305
-	}
306
-
307
-	public function unlink(string $path): bool {
308
-		if ($this->is_dir($path)) {
309
-			return $this->rmdir($path);
310
-		} elseif ($this->is_file($path)) {
311
-			return unlink($this->getSourcePath($path));
312
-		} else {
313
-			return false;
314
-		}
315
-	}
316
-
317
-	private function checkTreeForForbiddenItems(string $path): void {
318
-		$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path));
319
-		foreach ($iterator as $file) {
320
-			/** @var \SplFileInfo $file */
321
-			if (Filesystem::isFileBlacklisted($file->getBasename())) {
322
-				throw new ForbiddenException('Invalid path: ' . $file->getPathname(), false);
323
-			}
324
-		}
325
-	}
326
-
327
-	public function rename(string $source, string $target): bool {
328
-		$srcParent = dirname($source);
329
-		$dstParent = dirname($target);
330
-
331
-		if (!$this->isUpdatable($srcParent)) {
332
-			Server::get(LoggerInterface::class)->error('unable to rename, source directory is not writable : ' . $srcParent, ['app' => 'core']);
333
-			return false;
334
-		}
335
-
336
-		if (!$this->isUpdatable($dstParent)) {
337
-			Server::get(LoggerInterface::class)->error('unable to rename, destination directory is not writable : ' . $dstParent, ['app' => 'core']);
338
-			return false;
339
-		}
340
-
341
-		if (!$this->file_exists($source)) {
342
-			Server::get(LoggerInterface::class)->error('unable to rename, file does not exists : ' . $source, ['app' => 'core']);
343
-			return false;
344
-		}
345
-
346
-		if ($this->file_exists($target)) {
347
-			if ($this->is_dir($target)) {
348
-				$this->rmdir($target);
349
-			} elseif ($this->is_file($target)) {
350
-				$this->unlink($target);
351
-			}
352
-		}
353
-
354
-		if ($this->is_dir($source)) {
355
-			$this->checkTreeForForbiddenItems($this->getSourcePath($source));
356
-		}
357
-
358
-		if (@rename($this->getSourcePath($source), $this->getSourcePath($target))) {
359
-			if ($this->caseInsensitive) {
360
-				if (mb_strtolower($target) === mb_strtolower($source) && !$this->file_exists($target)) {
361
-					return false;
362
-				}
363
-			}
364
-			return true;
365
-		}
366
-
367
-		return $this->copy($source, $target) && $this->unlink($source);
368
-	}
369
-
370
-	public function copy(string $source, string $target): bool {
371
-		if ($this->is_dir($source)) {
372
-			return parent::copy($source, $target);
373
-		} else {
374
-			$oldMask = umask($this->defUMask);
375
-			if ($this->unlinkOnTruncate) {
376
-				$this->unlink($target);
377
-			}
378
-			$result = copy($this->getSourcePath($source), $this->getSourcePath($target));
379
-			umask($oldMask);
380
-			if ($this->caseInsensitive) {
381
-				if (mb_strtolower($target) === mb_strtolower($source) && !$this->file_exists($target)) {
382
-					return false;
383
-				}
384
-			}
385
-			return $result;
386
-		}
387
-	}
388
-
389
-	public function fopen(string $path, string $mode) {
390
-		$sourcePath = $this->getSourcePath($path);
391
-		if (!file_exists($sourcePath) && $mode === 'r') {
392
-			return false;
393
-		}
394
-		$oldMask = umask($this->defUMask);
395
-		if (($mode === 'w' || $mode === 'w+') && $this->unlinkOnTruncate) {
396
-			$this->unlink($path);
397
-		}
398
-		$result = @fopen($sourcePath, $mode);
399
-		umask($oldMask);
400
-		return $result;
401
-	}
402
-
403
-	public function hash(string $type, string $path, bool $raw = false): string|false {
404
-		return hash_file($type, $this->getSourcePath($path), $raw);
405
-	}
406
-
407
-	public function free_space(string $path): int|float|false {
408
-		$sourcePath = $this->getSourcePath($path);
409
-		// using !is_dir because $sourcePath might be a part file or
410
-		// non-existing file, so we'd still want to use the parent dir
411
-		// in such cases
412
-		if (!is_dir($sourcePath)) {
413
-			// disk_free_space doesn't work on files
414
-			$sourcePath = dirname($sourcePath);
415
-		}
416
-		$space = (function_exists('disk_free_space') && is_dir($sourcePath)) ? disk_free_space($sourcePath) : false;
417
-		if ($space === false || is_null($space)) {
418
-			return \OCP\Files\FileInfo::SPACE_UNKNOWN;
419
-		}
420
-		return Util::numericToNumber($space);
421
-	}
422
-
423
-	public function search(string $query): array {
424
-		return $this->searchInDir($query);
425
-	}
426
-
427
-	public function getLocalFile(string $path): string|false {
428
-		return $this->getSourcePath($path);
429
-	}
430
-
431
-	protected function searchInDir(string $query, string $dir = ''): array {
432
-		$files = [];
433
-		$physicalDir = $this->getSourcePath($dir);
434
-		foreach (scandir($physicalDir) as $item) {
435
-			if (\OC\Files\Filesystem::isIgnoredDir($item)) {
436
-				continue;
437
-			}
438
-			$physicalItem = $physicalDir . '/' . $item;
439
-
440
-			if (strstr(strtolower($item), strtolower($query)) !== false) {
441
-				$files[] = $dir . '/' . $item;
442
-			}
443
-			if (is_dir($physicalItem)) {
444
-				$files = array_merge($files, $this->searchInDir($query, $dir . '/' . $item));
445
-			}
446
-		}
447
-		return $files;
448
-	}
449
-
450
-	public function hasUpdated(string $path, int $time): bool {
451
-		if ($this->file_exists($path)) {
452
-			return $this->filemtime($path) > $time;
453
-		} else {
454
-			return true;
455
-		}
456
-	}
457
-
458
-	/**
459
-	 * Get the source path (on disk) of a given path
460
-	 *
461
-	 * @throws ForbiddenException
462
-	 */
463
-	public function getSourcePath(string $path): string {
464
-		if (Filesystem::isFileBlacklisted($path)) {
465
-			throw new ForbiddenException('Invalid path: ' . $path, false);
466
-		}
467
-
468
-		$fullPath = $this->datadir . $path;
469
-		$currentPath = $path;
470
-		$allowSymlinks = $this->config->getSystemValueBool('localstorage.allowsymlinks', false);
471
-		if ($allowSymlinks || $currentPath === '') {
472
-			return $fullPath;
473
-		}
474
-		$pathToResolve = $fullPath;
475
-		$realPath = realpath($pathToResolve);
476
-		while ($realPath === false) { // for non existing files check the parent directory
477
-			$currentPath = dirname($currentPath);
478
-			/** @psalm-suppress TypeDoesNotContainType Let's be extra cautious and still check for empty string */
479
-			if ($currentPath === '' || $currentPath === '.') {
480
-				return $fullPath;
481
-			}
482
-			$realPath = realpath($this->datadir . $currentPath);
483
-		}
484
-		if ($realPath) {
485
-			$realPath = $realPath . '/';
486
-		}
487
-		if (substr($realPath, 0, $this->dataDirLength) === $this->realDataDir) {
488
-			return $fullPath;
489
-		}
490
-
491
-		Server::get(LoggerInterface::class)->error("Following symlinks is not allowed ('$fullPath' -> '$realPath' not inside '{$this->realDataDir}')", ['app' => 'core']);
492
-		throw new ForbiddenException('Following symlinks is not allowed', false);
493
-	}
494
-
495
-	public function isLocal(): bool {
496
-		return true;
497
-	}
498
-
499
-	public function getETag(string $path): string|false {
500
-		return $this->calculateEtag($path, $this->stat($path));
501
-	}
502
-
503
-	private function calculateEtag(string $path, array $stat): string|false {
504
-		if ($stat['mode'] & 0x4000 && !($stat['mode'] & 0x8000)) { // is_dir & not socket
505
-			return parent::getETag($path);
506
-		} else {
507
-			if ($stat === false) {
508
-				return md5('');
509
-			}
510
-
511
-			$toHash = '';
512
-			if (isset($stat['mtime'])) {
513
-				$toHash .= $stat['mtime'];
514
-			}
515
-			if (isset($stat['ino'])) {
516
-				$toHash .= $stat['ino'];
517
-			}
518
-			if (isset($stat['dev'])) {
519
-				$toHash .= $stat['dev'];
520
-			}
521
-			if (isset($stat['size'])) {
522
-				$toHash .= $stat['size'];
523
-			}
524
-
525
-			return md5($toHash);
526
-		}
527
-	}
528
-
529
-	private function canDoCrossStorageMove(IStorage $sourceStorage): bool {
530
-		/** @psalm-suppress UndefinedClass,InvalidArgument */
531
-		return $sourceStorage->instanceOfStorage(Local::class)
532
-			// Don't treat ACLStorageWrapper like local storage where copy can be done directly.
533
-			// Instead, use the slower recursive copying in php from Common::copyFromStorage with
534
-			// more permissions checks.
535
-			&& !$sourceStorage->instanceOfStorage('OCA\GroupFolders\ACL\ACLStorageWrapper')
536
-			// Same for access control
537
-			&& !$sourceStorage->instanceOfStorage(\OCA\FilesAccessControl\StorageWrapper::class)
538
-			// when moving encrypted files we have to handle keys and the target might not be encrypted
539
-			&& !$sourceStorage->instanceOfStorage(Encryption::class);
540
-	}
541
-
542
-	public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath, bool $preserveMtime = false): bool {
543
-		if ($this->canDoCrossStorageMove($sourceStorage)) {
544
-			// resolve any jailed paths
545
-			while ($sourceStorage->instanceOfStorage(Jail::class)) {
546
-				/**
547
-				 * @var \OC\Files\Storage\Wrapper\Jail $sourceStorage
548
-				 */
549
-				$sourceInternalPath = $sourceStorage->getUnjailedPath($sourceInternalPath);
550
-				$sourceStorage = $sourceStorage->getUnjailedStorage();
551
-			}
552
-			/**
553
-			 * @var \OC\Files\Storage\Local $sourceStorage
554
-			 */
555
-			$rootStorage = new Local(['datadir' => '/']);
556
-			return $rootStorage->copy($sourceStorage->getSourcePath($sourceInternalPath), $this->getSourcePath($targetInternalPath));
557
-		} else {
558
-			return parent::copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath);
559
-		}
560
-	}
561
-
562
-	public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool {
563
-		if ($this->canDoCrossStorageMove($sourceStorage)) {
564
-			// resolve any jailed paths
565
-			while ($sourceStorage->instanceOfStorage(Jail::class)) {
566
-				/**
567
-				 * @var \OC\Files\Storage\Wrapper\Jail $sourceStorage
568
-				 */
569
-				$sourceInternalPath = $sourceStorage->getUnjailedPath($sourceInternalPath);
570
-				$sourceStorage = $sourceStorage->getUnjailedStorage();
571
-			}
572
-			/**
573
-			 * @var \OC\Files\Storage\Local $sourceStorage
574
-			 */
575
-			$rootStorage = new Local(['datadir' => '/']);
576
-			return $rootStorage->rename($sourceStorage->getSourcePath($sourceInternalPath), $this->getSourcePath($targetInternalPath));
577
-		} else {
578
-			return parent::moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath);
579
-		}
580
-	}
581
-
582
-	public function writeStream(string $path, $stream, ?int $size = null): int {
583
-		/** @var int|false $result We consider here that returned size will never be a float because we write less than 4GB */
584
-		$result = $this->file_put_contents($path, $stream);
585
-		if (is_resource($stream)) {
586
-			fclose($stream);
587
-		}
588
-		if ($result === false) {
589
-			throw new GenericFileException("Failed write stream to $path");
590
-		} else {
591
-			return $result;
592
-		}
593
-	}
28
+    protected $datadir;
29
+
30
+    protected $dataDirLength;
31
+
32
+    protected $realDataDir;
33
+
34
+    private IConfig $config;
35
+
36
+    private IMimeTypeDetector $mimeTypeDetector;
37
+
38
+    private $defUMask;
39
+
40
+    protected bool $unlinkOnTruncate;
41
+
42
+    protected bool $caseInsensitive = false;
43
+
44
+    public function __construct(array $parameters) {
45
+        if (!isset($parameters['datadir']) || !is_string($parameters['datadir'])) {
46
+            throw new \InvalidArgumentException('No data directory set for local storage');
47
+        }
48
+        $this->datadir = str_replace('//', '/', $parameters['datadir']);
49
+        // some crazy code uses a local storage on root...
50
+        if ($this->datadir === '/') {
51
+            $this->realDataDir = $this->datadir;
52
+        } else {
53
+            $realPath = realpath($this->datadir) ?: $this->datadir;
54
+            $this->realDataDir = rtrim($realPath, '/') . '/';
55
+        }
56
+        if (!str_ends_with($this->datadir, '/')) {
57
+            $this->datadir .= '/';
58
+        }
59
+        $this->dataDirLength = strlen($this->realDataDir);
60
+        $this->config = Server::get(IConfig::class);
61
+        $this->mimeTypeDetector = Server::get(IMimeTypeDetector::class);
62
+        $this->defUMask = $this->config->getSystemValue('localstorage.umask', 0022);
63
+        $this->caseInsensitive = $this->config->getSystemValueBool('localstorage.case_insensitive', false);
64
+
65
+        // support Write-Once-Read-Many file systems
66
+        $this->unlinkOnTruncate = $this->config->getSystemValueBool('localstorage.unlink_on_truncate', false);
67
+
68
+        if (isset($parameters['isExternal']) && $parameters['isExternal'] && !$this->stat('')) {
69
+            // data dir not accessible or available, can happen when using an external storage of type Local
70
+            // on an unmounted system mount point
71
+            throw new StorageNotAvailableException('Local storage path does not exist "' . $this->getSourcePath('') . '"');
72
+        }
73
+    }
74
+
75
+    public function __destruct() {
76
+    }
77
+
78
+    public function getId(): string {
79
+        return 'local::' . $this->datadir;
80
+    }
81
+
82
+    public function mkdir(string $path): bool {
83
+        $sourcePath = $this->getSourcePath($path);
84
+        $oldMask = umask($this->defUMask);
85
+        $result = @mkdir($sourcePath, 0777, true);
86
+        umask($oldMask);
87
+        return $result;
88
+    }
89
+
90
+    public function rmdir(string $path): bool {
91
+        if (!$this->isDeletable($path)) {
92
+            return false;
93
+        }
94
+        try {
95
+            $it = new \RecursiveIteratorIterator(
96
+                new \RecursiveDirectoryIterator($this->getSourcePath($path)),
97
+                \RecursiveIteratorIterator::CHILD_FIRST
98
+            );
99
+            /**
100
+             * RecursiveDirectoryIterator on an NFS path isn't iterable with foreach
101
+             * This bug is fixed in PHP 5.5.9 or before
102
+             * See #8376
103
+             */
104
+            $it->rewind();
105
+            while ($it->valid()) {
106
+                /**
107
+                 * @var \SplFileInfo $file
108
+                 */
109
+                $file = $it->current();
110
+                clearstatcache(true, $file->getRealPath());
111
+                if (in_array($file->getBasename(), ['.', '..'])) {
112
+                    $it->next();
113
+                    continue;
114
+                } elseif ($file->isFile() || $file->isLink()) {
115
+                    unlink($file->getPathname());
116
+                } elseif ($file->isDir()) {
117
+                    rmdir($file->getPathname());
118
+                }
119
+                $it->next();
120
+            }
121
+            unset($it);  // Release iterator and thereby its potential directory lock (e.g. in case of VirtualBox shared folders)
122
+            clearstatcache(true, $this->getSourcePath($path));
123
+            return rmdir($this->getSourcePath($path));
124
+        } catch (\UnexpectedValueException $e) {
125
+            return false;
126
+        }
127
+    }
128
+
129
+    public function opendir(string $path) {
130
+        return opendir($this->getSourcePath($path));
131
+    }
132
+
133
+    public function is_dir(string $path): bool {
134
+        if ($this->caseInsensitive && !$this->file_exists($path)) {
135
+            return false;
136
+        }
137
+        if (str_ends_with($path, '/')) {
138
+            $path = substr($path, 0, -1);
139
+        }
140
+        return is_dir($this->getSourcePath($path));
141
+    }
142
+
143
+    public function is_file(string $path): bool {
144
+        if ($this->caseInsensitive && !$this->file_exists($path)) {
145
+            return false;
146
+        }
147
+        return is_file($this->getSourcePath($path));
148
+    }
149
+
150
+    public function stat(string $path): array|false {
151
+        $fullPath = $this->getSourcePath($path);
152
+        clearstatcache(true, $fullPath);
153
+        if (!file_exists($fullPath)) {
154
+            return false;
155
+        }
156
+        $statResult = @stat($fullPath);
157
+        if (PHP_INT_SIZE === 4 && $statResult && !$this->is_dir($path)) {
158
+            $filesize = $this->filesize($path);
159
+            $statResult['size'] = $filesize;
160
+            $statResult[7] = $filesize;
161
+        }
162
+        if (is_array($statResult)) {
163
+            $statResult['full_path'] = $fullPath;
164
+        }
165
+        return $statResult;
166
+    }
167
+
168
+    public function getMetaData(string $path): ?array {
169
+        try {
170
+            $stat = $this->stat($path);
171
+        } catch (ForbiddenException $e) {
172
+            return null;
173
+        }
174
+        if (!$stat) {
175
+            return null;
176
+        }
177
+
178
+        $permissions = Constants::PERMISSION_SHARE;
179
+        $statPermissions = $stat['mode'];
180
+        $isDir = ($statPermissions & 0x4000) === 0x4000 && !($statPermissions & 0x8000);
181
+        if ($statPermissions & 0x0100) {
182
+            $permissions += Constants::PERMISSION_READ;
183
+        }
184
+        if ($statPermissions & 0x0080) {
185
+            $permissions += Constants::PERMISSION_UPDATE;
186
+            if ($isDir) {
187
+                $permissions += Constants::PERMISSION_CREATE;
188
+            }
189
+        }
190
+
191
+        if (!($path === '' || $path === '/')) { // deletable depends on the parents unix permissions
192
+            $parent = dirname($stat['full_path']);
193
+            if (is_writable($parent)) {
194
+                $permissions += Constants::PERMISSION_DELETE;
195
+            }
196
+        }
197
+
198
+        $data = [];
199
+        $data['mimetype'] = $isDir ? 'httpd/unix-directory' : $this->mimeTypeDetector->detectPath($path);
200
+        $data['mtime'] = $stat['mtime'];
201
+        if ($data['mtime'] === false) {
202
+            $data['mtime'] = time();
203
+        }
204
+        if ($isDir) {
205
+            $data['size'] = -1; //unknown
206
+        } else {
207
+            $data['size'] = $stat['size'];
208
+        }
209
+        $data['etag'] = $this->calculateEtag($path, $stat);
210
+        $data['storage_mtime'] = $data['mtime'];
211
+        $data['permissions'] = $permissions;
212
+        $data['name'] = basename($path);
213
+
214
+        return $data;
215
+    }
216
+
217
+    public function filetype(string $path): string|false {
218
+        $filetype = filetype($this->getSourcePath($path));
219
+        if ($filetype == 'link') {
220
+            $filetype = filetype(realpath($this->getSourcePath($path)));
221
+        }
222
+        return $filetype;
223
+    }
224
+
225
+    public function filesize(string $path): int|float|false {
226
+        if (!$this->is_file($path)) {
227
+            return 0;
228
+        }
229
+        $fullPath = $this->getSourcePath($path);
230
+        if (PHP_INT_SIZE === 4) {
231
+            $helper = new \OC\LargeFileHelper;
232
+            return $helper->getFileSize($fullPath);
233
+        }
234
+        return filesize($fullPath);
235
+    }
236
+
237
+    public function isReadable(string $path): bool {
238
+        return is_readable($this->getSourcePath($path));
239
+    }
240
+
241
+    public function isUpdatable(string $path): bool {
242
+        return is_writable($this->getSourcePath($path));
243
+    }
244
+
245
+    public function file_exists(string $path): bool {
246
+        if ($this->caseInsensitive) {
247
+            $fullPath = $this->getSourcePath($path);
248
+            $parentPath = dirname($fullPath);
249
+            if (!is_dir($parentPath)) {
250
+                return false;
251
+            }
252
+            $content = scandir($parentPath, SCANDIR_SORT_NONE);
253
+            return is_array($content) && array_search(basename($fullPath), $content, true) !== false;
254
+        } else {
255
+            return file_exists($this->getSourcePath($path));
256
+        }
257
+    }
258
+
259
+    public function filemtime(string $path): int|false {
260
+        $fullPath = $this->getSourcePath($path);
261
+        clearstatcache(true, $fullPath);
262
+        if (!$this->file_exists($path)) {
263
+            return false;
264
+        }
265
+        if (PHP_INT_SIZE === 4) {
266
+            $helper = new \OC\LargeFileHelper();
267
+            return $helper->getFileMtime($fullPath);
268
+        }
269
+        return filemtime($fullPath);
270
+    }
271
+
272
+    public function touch(string $path, ?int $mtime = null): bool {
273
+        // sets the modification time of the file to the given value.
274
+        // If mtime is nil the current time is set.
275
+        // note that the access time of the file always changes to the current time.
276
+        if ($this->file_exists($path) && !$this->isUpdatable($path)) {
277
+            return false;
278
+        }
279
+        $oldMask = umask($this->defUMask);
280
+        if (!is_null($mtime)) {
281
+            $result = @touch($this->getSourcePath($path), $mtime);
282
+        } else {
283
+            $result = @touch($this->getSourcePath($path));
284
+        }
285
+        umask($oldMask);
286
+        if ($result) {
287
+            clearstatcache(true, $this->getSourcePath($path));
288
+        }
289
+
290
+        return $result;
291
+    }
292
+
293
+    public function file_get_contents(string $path): string|false {
294
+        return file_get_contents($this->getSourcePath($path));
295
+    }
296
+
297
+    public function file_put_contents(string $path, mixed $data): int|float|false {
298
+        $oldMask = umask($this->defUMask);
299
+        if ($this->unlinkOnTruncate) {
300
+            $this->unlink($path);
301
+        }
302
+        $result = file_put_contents($this->getSourcePath($path), $data);
303
+        umask($oldMask);
304
+        return $result;
305
+    }
306
+
307
+    public function unlink(string $path): bool {
308
+        if ($this->is_dir($path)) {
309
+            return $this->rmdir($path);
310
+        } elseif ($this->is_file($path)) {
311
+            return unlink($this->getSourcePath($path));
312
+        } else {
313
+            return false;
314
+        }
315
+    }
316
+
317
+    private function checkTreeForForbiddenItems(string $path): void {
318
+        $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path));
319
+        foreach ($iterator as $file) {
320
+            /** @var \SplFileInfo $file */
321
+            if (Filesystem::isFileBlacklisted($file->getBasename())) {
322
+                throw new ForbiddenException('Invalid path: ' . $file->getPathname(), false);
323
+            }
324
+        }
325
+    }
326
+
327
+    public function rename(string $source, string $target): bool {
328
+        $srcParent = dirname($source);
329
+        $dstParent = dirname($target);
330
+
331
+        if (!$this->isUpdatable($srcParent)) {
332
+            Server::get(LoggerInterface::class)->error('unable to rename, source directory is not writable : ' . $srcParent, ['app' => 'core']);
333
+            return false;
334
+        }
335
+
336
+        if (!$this->isUpdatable($dstParent)) {
337
+            Server::get(LoggerInterface::class)->error('unable to rename, destination directory is not writable : ' . $dstParent, ['app' => 'core']);
338
+            return false;
339
+        }
340
+
341
+        if (!$this->file_exists($source)) {
342
+            Server::get(LoggerInterface::class)->error('unable to rename, file does not exists : ' . $source, ['app' => 'core']);
343
+            return false;
344
+        }
345
+
346
+        if ($this->file_exists($target)) {
347
+            if ($this->is_dir($target)) {
348
+                $this->rmdir($target);
349
+            } elseif ($this->is_file($target)) {
350
+                $this->unlink($target);
351
+            }
352
+        }
353
+
354
+        if ($this->is_dir($source)) {
355
+            $this->checkTreeForForbiddenItems($this->getSourcePath($source));
356
+        }
357
+
358
+        if (@rename($this->getSourcePath($source), $this->getSourcePath($target))) {
359
+            if ($this->caseInsensitive) {
360
+                if (mb_strtolower($target) === mb_strtolower($source) && !$this->file_exists($target)) {
361
+                    return false;
362
+                }
363
+            }
364
+            return true;
365
+        }
366
+
367
+        return $this->copy($source, $target) && $this->unlink($source);
368
+    }
369
+
370
+    public function copy(string $source, string $target): bool {
371
+        if ($this->is_dir($source)) {
372
+            return parent::copy($source, $target);
373
+        } else {
374
+            $oldMask = umask($this->defUMask);
375
+            if ($this->unlinkOnTruncate) {
376
+                $this->unlink($target);
377
+            }
378
+            $result = copy($this->getSourcePath($source), $this->getSourcePath($target));
379
+            umask($oldMask);
380
+            if ($this->caseInsensitive) {
381
+                if (mb_strtolower($target) === mb_strtolower($source) && !$this->file_exists($target)) {
382
+                    return false;
383
+                }
384
+            }
385
+            return $result;
386
+        }
387
+    }
388
+
389
+    public function fopen(string $path, string $mode) {
390
+        $sourcePath = $this->getSourcePath($path);
391
+        if (!file_exists($sourcePath) && $mode === 'r') {
392
+            return false;
393
+        }
394
+        $oldMask = umask($this->defUMask);
395
+        if (($mode === 'w' || $mode === 'w+') && $this->unlinkOnTruncate) {
396
+            $this->unlink($path);
397
+        }
398
+        $result = @fopen($sourcePath, $mode);
399
+        umask($oldMask);
400
+        return $result;
401
+    }
402
+
403
+    public function hash(string $type, string $path, bool $raw = false): string|false {
404
+        return hash_file($type, $this->getSourcePath($path), $raw);
405
+    }
406
+
407
+    public function free_space(string $path): int|float|false {
408
+        $sourcePath = $this->getSourcePath($path);
409
+        // using !is_dir because $sourcePath might be a part file or
410
+        // non-existing file, so we'd still want to use the parent dir
411
+        // in such cases
412
+        if (!is_dir($sourcePath)) {
413
+            // disk_free_space doesn't work on files
414
+            $sourcePath = dirname($sourcePath);
415
+        }
416
+        $space = (function_exists('disk_free_space') && is_dir($sourcePath)) ? disk_free_space($sourcePath) : false;
417
+        if ($space === false || is_null($space)) {
418
+            return \OCP\Files\FileInfo::SPACE_UNKNOWN;
419
+        }
420
+        return Util::numericToNumber($space);
421
+    }
422
+
423
+    public function search(string $query): array {
424
+        return $this->searchInDir($query);
425
+    }
426
+
427
+    public function getLocalFile(string $path): string|false {
428
+        return $this->getSourcePath($path);
429
+    }
430
+
431
+    protected function searchInDir(string $query, string $dir = ''): array {
432
+        $files = [];
433
+        $physicalDir = $this->getSourcePath($dir);
434
+        foreach (scandir($physicalDir) as $item) {
435
+            if (\OC\Files\Filesystem::isIgnoredDir($item)) {
436
+                continue;
437
+            }
438
+            $physicalItem = $physicalDir . '/' . $item;
439
+
440
+            if (strstr(strtolower($item), strtolower($query)) !== false) {
441
+                $files[] = $dir . '/' . $item;
442
+            }
443
+            if (is_dir($physicalItem)) {
444
+                $files = array_merge($files, $this->searchInDir($query, $dir . '/' . $item));
445
+            }
446
+        }
447
+        return $files;
448
+    }
449
+
450
+    public function hasUpdated(string $path, int $time): bool {
451
+        if ($this->file_exists($path)) {
452
+            return $this->filemtime($path) > $time;
453
+        } else {
454
+            return true;
455
+        }
456
+    }
457
+
458
+    /**
459
+     * Get the source path (on disk) of a given path
460
+     *
461
+     * @throws ForbiddenException
462
+     */
463
+    public function getSourcePath(string $path): string {
464
+        if (Filesystem::isFileBlacklisted($path)) {
465
+            throw new ForbiddenException('Invalid path: ' . $path, false);
466
+        }
467
+
468
+        $fullPath = $this->datadir . $path;
469
+        $currentPath = $path;
470
+        $allowSymlinks = $this->config->getSystemValueBool('localstorage.allowsymlinks', false);
471
+        if ($allowSymlinks || $currentPath === '') {
472
+            return $fullPath;
473
+        }
474
+        $pathToResolve = $fullPath;
475
+        $realPath = realpath($pathToResolve);
476
+        while ($realPath === false) { // for non existing files check the parent directory
477
+            $currentPath = dirname($currentPath);
478
+            /** @psalm-suppress TypeDoesNotContainType Let's be extra cautious and still check for empty string */
479
+            if ($currentPath === '' || $currentPath === '.') {
480
+                return $fullPath;
481
+            }
482
+            $realPath = realpath($this->datadir . $currentPath);
483
+        }
484
+        if ($realPath) {
485
+            $realPath = $realPath . '/';
486
+        }
487
+        if (substr($realPath, 0, $this->dataDirLength) === $this->realDataDir) {
488
+            return $fullPath;
489
+        }
490
+
491
+        Server::get(LoggerInterface::class)->error("Following symlinks is not allowed ('$fullPath' -> '$realPath' not inside '{$this->realDataDir}')", ['app' => 'core']);
492
+        throw new ForbiddenException('Following symlinks is not allowed', false);
493
+    }
494
+
495
+    public function isLocal(): bool {
496
+        return true;
497
+    }
498
+
499
+    public function getETag(string $path): string|false {
500
+        return $this->calculateEtag($path, $this->stat($path));
501
+    }
502
+
503
+    private function calculateEtag(string $path, array $stat): string|false {
504
+        if ($stat['mode'] & 0x4000 && !($stat['mode'] & 0x8000)) { // is_dir & not socket
505
+            return parent::getETag($path);
506
+        } else {
507
+            if ($stat === false) {
508
+                return md5('');
509
+            }
510
+
511
+            $toHash = '';
512
+            if (isset($stat['mtime'])) {
513
+                $toHash .= $stat['mtime'];
514
+            }
515
+            if (isset($stat['ino'])) {
516
+                $toHash .= $stat['ino'];
517
+            }
518
+            if (isset($stat['dev'])) {
519
+                $toHash .= $stat['dev'];
520
+            }
521
+            if (isset($stat['size'])) {
522
+                $toHash .= $stat['size'];
523
+            }
524
+
525
+            return md5($toHash);
526
+        }
527
+    }
528
+
529
+    private function canDoCrossStorageMove(IStorage $sourceStorage): bool {
530
+        /** @psalm-suppress UndefinedClass,InvalidArgument */
531
+        return $sourceStorage->instanceOfStorage(Local::class)
532
+            // Don't treat ACLStorageWrapper like local storage where copy can be done directly.
533
+            // Instead, use the slower recursive copying in php from Common::copyFromStorage with
534
+            // more permissions checks.
535
+            && !$sourceStorage->instanceOfStorage('OCA\GroupFolders\ACL\ACLStorageWrapper')
536
+            // Same for access control
537
+            && !$sourceStorage->instanceOfStorage(\OCA\FilesAccessControl\StorageWrapper::class)
538
+            // when moving encrypted files we have to handle keys and the target might not be encrypted
539
+            && !$sourceStorage->instanceOfStorage(Encryption::class);
540
+    }
541
+
542
+    public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath, bool $preserveMtime = false): bool {
543
+        if ($this->canDoCrossStorageMove($sourceStorage)) {
544
+            // resolve any jailed paths
545
+            while ($sourceStorage->instanceOfStorage(Jail::class)) {
546
+                /**
547
+                 * @var \OC\Files\Storage\Wrapper\Jail $sourceStorage
548
+                 */
549
+                $sourceInternalPath = $sourceStorage->getUnjailedPath($sourceInternalPath);
550
+                $sourceStorage = $sourceStorage->getUnjailedStorage();
551
+            }
552
+            /**
553
+             * @var \OC\Files\Storage\Local $sourceStorage
554
+             */
555
+            $rootStorage = new Local(['datadir' => '/']);
556
+            return $rootStorage->copy($sourceStorage->getSourcePath($sourceInternalPath), $this->getSourcePath($targetInternalPath));
557
+        } else {
558
+            return parent::copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath);
559
+        }
560
+    }
561
+
562
+    public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool {
563
+        if ($this->canDoCrossStorageMove($sourceStorage)) {
564
+            // resolve any jailed paths
565
+            while ($sourceStorage->instanceOfStorage(Jail::class)) {
566
+                /**
567
+                 * @var \OC\Files\Storage\Wrapper\Jail $sourceStorage
568
+                 */
569
+                $sourceInternalPath = $sourceStorage->getUnjailedPath($sourceInternalPath);
570
+                $sourceStorage = $sourceStorage->getUnjailedStorage();
571
+            }
572
+            /**
573
+             * @var \OC\Files\Storage\Local $sourceStorage
574
+             */
575
+            $rootStorage = new Local(['datadir' => '/']);
576
+            return $rootStorage->rename($sourceStorage->getSourcePath($sourceInternalPath), $this->getSourcePath($targetInternalPath));
577
+        } else {
578
+            return parent::moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath);
579
+        }
580
+    }
581
+
582
+    public function writeStream(string $path, $stream, ?int $size = null): int {
583
+        /** @var int|false $result We consider here that returned size will never be a float because we write less than 4GB */
584
+        $result = $this->file_put_contents($path, $stream);
585
+        if (is_resource($stream)) {
586
+            fclose($stream);
587
+        }
588
+        if ($result === false) {
589
+            throw new GenericFileException("Failed write stream to $path");
590
+        } else {
591
+            return $result;
592
+        }
593
+    }
594 594
 }
Please login to merge, or discard this patch.
lib/private/Accounts/AccountManager.php 1 patch
Indentation   +845 added lines, -845 removed lines patch added patch discarded remove patch
@@ -52,849 +52,849 @@
 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_BLUESKY => self::SCOPE_LOCAL,
82
-		self::PROPERTY_WEBSITE => self::SCOPE_LOCAL,
83
-	];
84
-
85
-	public function __construct(
86
-		private IDBConnection $connection,
87
-		private IConfig $config,
88
-		private IEventDispatcher $dispatcher,
89
-		private IJobList $jobList,
90
-		private LoggerInterface $logger,
91
-		private IVerificationToken $verificationToken,
92
-		private IMailer $mailer,
93
-		private Defaults $defaults,
94
-		private IFactory $l10nFactory,
95
-		private IURLGenerator $urlGenerator,
96
-		private ICrypto $crypto,
97
-		private IPhoneNumberUtil $phoneNumberUtil,
98
-		private IClientService $clientService,
99
-	) {
100
-		$this->internalCache = new CappedMemoryCache();
101
-	}
102
-
103
-	/**
104
-	 * @param IAccountProperty[] $properties
105
-	 */
106
-	protected function testValueLengths(array $properties, bool $throwOnData = false): void {
107
-		foreach ($properties as $property) {
108
-			if (strlen($property->getValue()) > 2048) {
109
-				if ($throwOnData) {
110
-					throw new InvalidArgumentException($property->getName());
111
-				} else {
112
-					$property->setValue('');
113
-				}
114
-			}
115
-		}
116
-	}
117
-
118
-	protected function testPropertyScope(IAccountProperty $property, array $allowedScopes, bool $throwOnData): void {
119
-		if ($throwOnData && !in_array($property->getScope(), $allowedScopes, true)) {
120
-			throw new InvalidArgumentException('scope');
121
-		}
122
-
123
-		if (
124
-			$property->getScope() === self::SCOPE_PRIVATE
125
-			&& in_array($property->getName(), [self::PROPERTY_DISPLAYNAME, self::PROPERTY_EMAIL])
126
-		) {
127
-			if ($throwOnData) {
128
-				// v2-private is not available for these fields
129
-				throw new InvalidArgumentException('scope');
130
-			} else {
131
-				// default to local
132
-				$property->setScope(self::SCOPE_LOCAL);
133
-			}
134
-		} else {
135
-			// migrate scope values to the new format
136
-			// invalid scopes are mapped to a default value
137
-			$property->setScope(AccountProperty::mapScopeToV2($property->getScope()));
138
-		}
139
-	}
140
-
141
-	protected function updateUser(IUser $user, array $data, ?array $oldUserData, bool $throwOnData = false): array {
142
-		if ($oldUserData === null) {
143
-			$oldUserData = $this->getUser($user, false);
144
-		}
145
-
146
-		$updated = true;
147
-
148
-		if ($oldUserData !== $data) {
149
-			$this->updateExistingUser($user, $data, $oldUserData);
150
-		} else {
151
-			// nothing needs to be done if new and old data set are the same
152
-			$updated = false;
153
-		}
154
-
155
-		if ($updated) {
156
-			$this->dispatcher->dispatchTyped(new UserUpdatedEvent(
157
-				$user,
158
-				$data,
159
-			));
160
-		}
161
-
162
-		return $data;
163
-	}
164
-
165
-	/**
166
-	 * delete user from accounts table
167
-	 */
168
-	public function deleteUser(IUser $user): void {
169
-		$uid = $user->getUID();
170
-		$query = $this->connection->getQueryBuilder();
171
-		$query->delete($this->table)
172
-			->where($query->expr()->eq('uid', $query->createNamedParameter($uid)))
173
-			->executeStatement();
174
-
175
-		$this->deleteUserData($user);
176
-	}
177
-
178
-	/**
179
-	 * delete user from accounts table
180
-	 */
181
-	public function deleteUserData(IUser $user): void {
182
-		$uid = $user->getUID();
183
-		$query = $this->connection->getQueryBuilder();
184
-		$query->delete($this->dataTable)
185
-			->where($query->expr()->eq('uid', $query->createNamedParameter($uid)))
186
-			->executeStatement();
187
-	}
188
-
189
-	/**
190
-	 * get stored data from a given user
191
-	 */
192
-	protected function getUser(IUser $user, bool $insertIfNotExists = true): array {
193
-		$uid = $user->getUID();
194
-		$query = $this->connection->getQueryBuilder();
195
-		$query->select('data')
196
-			->from($this->table)
197
-			->where($query->expr()->eq('uid', $query->createParameter('uid')))
198
-			->setParameter('uid', $uid);
199
-		$result = $query->executeQuery();
200
-		$accountData = $result->fetchAll();
201
-		$result->closeCursor();
202
-
203
-		if (empty($accountData)) {
204
-			$userData = $this->buildDefaultUserRecord($user);
205
-			if ($insertIfNotExists) {
206
-				$this->insertNewUser($user, $userData);
207
-			}
208
-			return $userData;
209
-		}
210
-
211
-		$userDataArray = $this->importFromJson($accountData[0]['data'], $uid);
212
-		if ($userDataArray === null || $userDataArray === []) {
213
-			return $this->buildDefaultUserRecord($user);
214
-		}
215
-
216
-		return $this->addMissingDefaultValues($userDataArray, $this->buildDefaultUserRecord($user));
217
-	}
218
-
219
-	public function searchUsers(string $property, array $values): array {
220
-		// the value col is limited to 255 bytes. It is used for searches only.
221
-		$values = array_map(function (string $value) {
222
-			return Util::shortenMultibyteString($value, 255);
223
-		}, $values);
224
-		$chunks = array_chunk($values, 500);
225
-		$query = $this->connection->getQueryBuilder();
226
-		$query->select('*')
227
-			->from($this->dataTable)
228
-			->where($query->expr()->eq('name', $query->createNamedParameter($property)))
229
-			->andWhere($query->expr()->in('value', $query->createParameter('values')));
230
-
231
-		$matches = [];
232
-		foreach ($chunks as $chunk) {
233
-			$query->setParameter('values', $chunk, IQueryBuilder::PARAM_STR_ARRAY);
234
-			$result = $query->executeQuery();
235
-
236
-			while ($row = $result->fetch()) {
237
-				$matches[$row['uid']] = $row['value'];
238
-			}
239
-			$result->closeCursor();
240
-		}
241
-
242
-		$result = array_merge($matches, $this->searchUsersForRelatedCollection($property, $values));
243
-
244
-		return array_flip($result);
245
-	}
246
-
247
-	protected function searchUsersForRelatedCollection(string $property, array $values): array {
248
-		return match ($property) {
249
-			IAccountManager::PROPERTY_EMAIL => array_flip($this->searchUsers(IAccountManager::COLLECTION_EMAIL, $values)),
250
-			default => [],
251
-		};
252
-	}
253
-
254
-	/**
255
-	 * check if we need to ask the server for email verification, if yes we create a cronjob
256
-	 */
257
-	protected function checkEmailVerification(IAccount $updatedAccount, array $oldData): void {
258
-		try {
259
-			$property = $updatedAccount->getProperty(self::PROPERTY_EMAIL);
260
-		} catch (PropertyDoesNotExistException $e) {
261
-			return;
262
-		}
263
-
264
-		$oldMailIndex = array_search(self::PROPERTY_EMAIL, array_column($oldData, 'name'), true);
265
-		$oldMail = $oldMailIndex !== false ? $oldData[$oldMailIndex]['value'] : '';
266
-
267
-		if ($oldMail !== $property->getValue()) {
268
-			$this->jobList->add(
269
-				VerifyUserData::class,
270
-				[
271
-					'verificationCode' => '',
272
-					'data' => $property->getValue(),
273
-					'type' => self::PROPERTY_EMAIL,
274
-					'uid' => $updatedAccount->getUser()->getUID(),
275
-					'try' => 0,
276
-					'lastRun' => time()
277
-				]
278
-			);
279
-
280
-			$property->setVerified(self::VERIFICATION_IN_PROGRESS);
281
-		}
282
-	}
283
-
284
-	protected function checkLocalEmailVerification(IAccount $updatedAccount, array $oldData): void {
285
-		$mailCollection = $updatedAccount->getPropertyCollection(self::COLLECTION_EMAIL);
286
-		foreach ($mailCollection->getProperties() as $property) {
287
-			if ($property->getLocallyVerified() !== self::NOT_VERIFIED) {
288
-				continue;
289
-			}
290
-			if ($this->sendEmailVerificationEmail($updatedAccount->getUser(), $property->getValue())) {
291
-				$property->setLocallyVerified(self::VERIFICATION_IN_PROGRESS);
292
-			}
293
-		}
294
-	}
295
-
296
-	protected function sendEmailVerificationEmail(IUser $user, string $email): bool {
297
-		$ref = \substr(hash('sha256', $email), 0, 8);
298
-		$key = $this->crypto->encrypt($email);
299
-		$token = $this->verificationToken->create($user, 'verifyMail' . $ref, $email);
300
-
301
-		$link = $this->urlGenerator->linkToRouteAbsolute(
302
-			'provisioning_api.Verification.verifyMail',
303
-			[
304
-				'userId' => $user->getUID(),
305
-				'token' => $token,
306
-				'key' => $key
307
-			]
308
-		);
309
-
310
-		$emailTemplate = $this->mailer->createEMailTemplate('core.EmailVerification', [
311
-			'link' => $link,
312
-		]);
313
-
314
-		if (!$this->l10n) {
315
-			$this->l10n = $this->l10nFactory->get('core');
316
-		}
317
-
318
-		$emailTemplate->setSubject($this->l10n->t('%s email verification', [$this->defaults->getName()]));
319
-		$emailTemplate->addHeader();
320
-		$emailTemplate->addHeading($this->l10n->t('Email verification'));
321
-
322
-		$emailTemplate->addBodyText(
323
-			htmlspecialchars($this->l10n->t('Click the following button to confirm your email.')),
324
-			$this->l10n->t('Click the following link to confirm your email.')
325
-		);
326
-
327
-		$emailTemplate->addBodyButton(
328
-			htmlspecialchars($this->l10n->t('Confirm your email')),
329
-			$link,
330
-			false
331
-		);
332
-		$emailTemplate->addFooter();
333
-
334
-		try {
335
-			$message = $this->mailer->createMessage();
336
-			$message->setTo([$email => $user->getDisplayName()]);
337
-			$message->setFrom([Util::getDefaultEmailAddress('verification-noreply') => $this->defaults->getName()]);
338
-			$message->useTemplate($emailTemplate);
339
-			$this->mailer->send($message);
340
-		} catch (Exception $e) {
341
-			// Log the exception and continue
342
-			$this->logger->info('Failed to send verification mail', [
343
-				'app' => 'core',
344
-				'exception' => $e
345
-			]);
346
-			return false;
347
-		}
348
-		return true;
349
-	}
350
-
351
-	/**
352
-	 * Make sure that all expected data are set
353
-	 */
354
-	protected function addMissingDefaultValues(array $userData, array $defaultUserData): array {
355
-		foreach ($defaultUserData as $defaultDataItem) {
356
-			// If property does not exist, initialize it
357
-			$userDataIndex = array_search($defaultDataItem['name'], array_column($userData, 'name'), true);
358
-			if ($userDataIndex === false) {
359
-				$userData[] = $defaultDataItem;
360
-				continue;
361
-			}
362
-
363
-			// Merge and extend default missing values
364
-			$userData[$userDataIndex] = array_merge($defaultDataItem, $userData[$userDataIndex]);
365
-		}
366
-
367
-		return $userData;
368
-	}
369
-
370
-	protected function updateVerificationStatus(IAccount $updatedAccount, array $oldData): void {
371
-		static $propertiesVerifiableByLookupServer = [
372
-			self::PROPERTY_TWITTER,
373
-			self::PROPERTY_FEDIVERSE,
374
-			self::PROPERTY_WEBSITE,
375
-			self::PROPERTY_EMAIL,
376
-		];
377
-
378
-		foreach ($propertiesVerifiableByLookupServer as $propertyName) {
379
-			try {
380
-				$property = $updatedAccount->getProperty($propertyName);
381
-			} catch (PropertyDoesNotExistException $e) {
382
-				continue;
383
-			}
384
-			$wasVerified = isset($oldData[$propertyName])
385
-				&& isset($oldData[$propertyName]['verified'])
386
-				&& $oldData[$propertyName]['verified'] === self::VERIFIED;
387
-			if ((!isset($oldData[$propertyName])
388
-					|| !isset($oldData[$propertyName]['value'])
389
-					|| $property->getValue() !== $oldData[$propertyName]['value'])
390
-				&& ($property->getVerified() !== self::NOT_VERIFIED
391
-					|| $wasVerified)
392
-			) {
393
-				$property->setVerified(self::NOT_VERIFIED);
394
-			}
395
-		}
396
-	}
397
-
398
-	/**
399
-	 * add new user to accounts table
400
-	 */
401
-	protected function insertNewUser(IUser $user, array $data): void {
402
-		$uid = $user->getUID();
403
-		$jsonEncodedData = $this->prepareJson($data);
404
-		$query = $this->connection->getQueryBuilder();
405
-		$query->insert($this->table)
406
-			->values(
407
-				[
408
-					'uid' => $query->createNamedParameter($uid),
409
-					'data' => $query->createNamedParameter($jsonEncodedData),
410
-				]
411
-			)
412
-			->executeStatement();
413
-
414
-		$this->deleteUserData($user);
415
-		$this->writeUserData($user, $data);
416
-	}
417
-
418
-	protected function prepareJson(array $data): string {
419
-		$preparedData = [];
420
-		foreach ($data as $dataRow) {
421
-			$propertyName = $dataRow['name'];
422
-			unset($dataRow['name']);
423
-
424
-			if (isset($dataRow['locallyVerified']) && $dataRow['locallyVerified'] === self::NOT_VERIFIED) {
425
-				// do not write default value, save DB space
426
-				unset($dataRow['locallyVerified']);
427
-			}
428
-
429
-			if (!$this->isCollection($propertyName)) {
430
-				$preparedData[$propertyName] = $dataRow;
431
-				continue;
432
-			}
433
-			if (!isset($preparedData[$propertyName])) {
434
-				$preparedData[$propertyName] = [];
435
-			}
436
-			$preparedData[$propertyName][] = $dataRow;
437
-		}
438
-		return json_encode($preparedData);
439
-	}
440
-
441
-	protected function importFromJson(string $json, string $userId): ?array {
442
-		$result = [];
443
-		$jsonArray = json_decode($json, true);
444
-		$jsonError = json_last_error();
445
-		if ($jsonError !== JSON_ERROR_NONE) {
446
-			$this->logger->critical(
447
-				'User data of {uid} contained invalid JSON (error {json_error}), hence falling back to a default user record',
448
-				[
449
-					'uid' => $userId,
450
-					'json_error' => $jsonError
451
-				]
452
-			);
453
-			return null;
454
-		}
455
-		foreach ($jsonArray as $propertyName => $row) {
456
-			if (!$this->isCollection($propertyName)) {
457
-				$result[] = array_merge($row, ['name' => $propertyName]);
458
-				continue;
459
-			}
460
-			foreach ($row as $singleRow) {
461
-				$result[] = array_merge($singleRow, ['name' => $propertyName]);
462
-			}
463
-		}
464
-		return $result;
465
-	}
466
-
467
-	/**
468
-	 * Update existing user in accounts table
469
-	 */
470
-	protected function updateExistingUser(IUser $user, array $data, array $oldData): void {
471
-		$uid = $user->getUID();
472
-		$jsonEncodedData = $this->prepareJson($data);
473
-		$query = $this->connection->getQueryBuilder();
474
-		$query->update($this->table)
475
-			->set('data', $query->createNamedParameter($jsonEncodedData))
476
-			->where($query->expr()->eq('uid', $query->createNamedParameter($uid)))
477
-			->executeStatement();
478
-
479
-		$this->deleteUserData($user);
480
-		$this->writeUserData($user, $data);
481
-	}
482
-
483
-	protected function writeUserData(IUser $user, array $data): void {
484
-		$query = $this->connection->getQueryBuilder();
485
-		$query->insert($this->dataTable)
486
-			->values(
487
-				[
488
-					'uid' => $query->createNamedParameter($user->getUID()),
489
-					'name' => $query->createParameter('name'),
490
-					'value' => $query->createParameter('value'),
491
-				]
492
-			);
493
-		$this->writeUserDataProperties($query, $data);
494
-	}
495
-
496
-	protected function writeUserDataProperties(IQueryBuilder $query, array $data): void {
497
-		foreach ($data as $property) {
498
-			if ($property['name'] === self::PROPERTY_AVATAR) {
499
-				continue;
500
-			}
501
-
502
-			// the value col is limited to 255 bytes. It is used for searches only.
503
-			$value = $property['value'] ? Util::shortenMultibyteString($property['value'], 255) : '';
504
-
505
-			$query->setParameter('name', $property['name'])
506
-				->setParameter('value', $value);
507
-			$query->executeStatement();
508
-		}
509
-	}
510
-
511
-	/**
512
-	 * build default user record in case not data set exists yet
513
-	 */
514
-	protected function buildDefaultUserRecord(IUser $user): array {
515
-		$scopes = array_merge(self::DEFAULT_SCOPES, array_filter($this->config->getSystemValue('account_manager.default_property_scope', []), static function (string $scope, string $property) {
516
-			return in_array($property, self::ALLOWED_PROPERTIES, true) && in_array($scope, self::ALLOWED_SCOPES, true);
517
-		}, ARRAY_FILTER_USE_BOTH));
518
-
519
-		return [
520
-			[
521
-				'name' => self::PROPERTY_DISPLAYNAME,
522
-				'value' => $user->getDisplayName(),
523
-				// Display name must be at least SCOPE_LOCAL
524
-				'scope' => $scopes[self::PROPERTY_DISPLAYNAME] === self::SCOPE_PRIVATE ? self::SCOPE_LOCAL : $scopes[self::PROPERTY_DISPLAYNAME],
525
-				'verified' => self::NOT_VERIFIED,
526
-			],
527
-
528
-			[
529
-				'name' => self::PROPERTY_ADDRESS,
530
-				'value' => '',
531
-				'scope' => $scopes[self::PROPERTY_ADDRESS],
532
-				'verified' => self::NOT_VERIFIED,
533
-			],
534
-
535
-			[
536
-				'name' => self::PROPERTY_WEBSITE,
537
-				'value' => '',
538
-				'scope' => $scopes[self::PROPERTY_WEBSITE],
539
-				'verified' => self::NOT_VERIFIED,
540
-			],
541
-
542
-			[
543
-				'name' => self::PROPERTY_EMAIL,
544
-				'value' => $user->getEMailAddress(),
545
-				// Email must be at least SCOPE_LOCAL
546
-				'scope' => $scopes[self::PROPERTY_EMAIL] === self::SCOPE_PRIVATE ? self::SCOPE_LOCAL : $scopes[self::PROPERTY_EMAIL],
547
-				'verified' => self::NOT_VERIFIED,
548
-			],
549
-
550
-			[
551
-				'name' => self::PROPERTY_AVATAR,
552
-				'scope' => $scopes[self::PROPERTY_AVATAR],
553
-			],
554
-
555
-			[
556
-				'name' => self::PROPERTY_PHONE,
557
-				'value' => '',
558
-				'scope' => $scopes[self::PROPERTY_PHONE],
559
-				'verified' => self::NOT_VERIFIED,
560
-			],
561
-
562
-			[
563
-				'name' => self::PROPERTY_TWITTER,
564
-				'value' => '',
565
-				'scope' => $scopes[self::PROPERTY_TWITTER],
566
-				'verified' => self::NOT_VERIFIED,
567
-			],
568
-
569
-			[
570
-				'name' => self::PROPERTY_BLUESKY,
571
-				'value' => '',
572
-				'scope' => $scopes[self::PROPERTY_BLUESKY],
573
-				'verified' => self::NOT_VERIFIED,
574
-			],
575
-
576
-			[
577
-				'name' => self::PROPERTY_FEDIVERSE,
578
-				'value' => '',
579
-				'scope' => $scopes[self::PROPERTY_FEDIVERSE],
580
-				'verified' => self::NOT_VERIFIED,
581
-			],
582
-
583
-			[
584
-				'name' => self::PROPERTY_ORGANISATION,
585
-				'value' => '',
586
-				'scope' => $scopes[self::PROPERTY_ORGANISATION],
587
-			],
588
-
589
-			[
590
-				'name' => self::PROPERTY_ROLE,
591
-				'value' => '',
592
-				'scope' => $scopes[self::PROPERTY_ROLE],
593
-			],
594
-
595
-			[
596
-				'name' => self::PROPERTY_HEADLINE,
597
-				'value' => '',
598
-				'scope' => $scopes[self::PROPERTY_HEADLINE],
599
-			],
600
-
601
-			[
602
-				'name' => self::PROPERTY_BIOGRAPHY,
603
-				'value' => '',
604
-				'scope' => $scopes[self::PROPERTY_BIOGRAPHY],
605
-			],
606
-
607
-			[
608
-				'name' => self::PROPERTY_BIRTHDATE,
609
-				'value' => '',
610
-				'scope' => $scopes[self::PROPERTY_BIRTHDATE],
611
-			],
612
-
613
-			[
614
-				'name' => self::PROPERTY_PROFILE_ENABLED,
615
-				'value' => $this->isProfileEnabledByDefault($this->config) ? '1' : '0',
616
-			],
617
-
618
-			[
619
-				'name' => self::PROPERTY_PRONOUNS,
620
-				'value' => '',
621
-				'scope' => $scopes[self::PROPERTY_PRONOUNS],
622
-			],
623
-		];
624
-	}
625
-
626
-	private function arrayDataToCollection(IAccount $account, array $data): IAccountPropertyCollection {
627
-		$collection = $account->getPropertyCollection($data['name']);
628
-
629
-		$p = new AccountProperty(
630
-			$data['name'],
631
-			$data['value'] ?? '',
632
-			$data['scope'] ?? self::SCOPE_LOCAL,
633
-			$data['verified'] ?? self::NOT_VERIFIED,
634
-			''
635
-		);
636
-		$p->setLocallyVerified($data['locallyVerified'] ?? self::NOT_VERIFIED);
637
-		$collection->addProperty($p);
638
-
639
-		return $collection;
640
-	}
641
-
642
-	private function parseAccountData(IUser $user, $data): Account {
643
-		$account = new Account($user);
644
-		foreach ($data as $accountData) {
645
-			if ($this->isCollection($accountData['name'])) {
646
-				$account->setPropertyCollection($this->arrayDataToCollection($account, $accountData));
647
-			} else {
648
-				$account->setProperty($accountData['name'], $accountData['value'] ?? '', $accountData['scope'] ?? self::SCOPE_LOCAL, $accountData['verified'] ?? self::NOT_VERIFIED);
649
-				if (isset($accountData['locallyVerified'])) {
650
-					$property = $account->getProperty($accountData['name']);
651
-					$property->setLocallyVerified($accountData['locallyVerified']);
652
-				}
653
-			}
654
-		}
655
-		return $account;
656
-	}
657
-
658
-	public function getAccount(IUser $user): IAccount {
659
-		$cached = $this->internalCache->get($user->getUID());
660
-		if ($cached !== null) {
661
-			return $cached;
662
-		}
663
-		$account = $this->parseAccountData($user, $this->getUser($user));
664
-		if ($user->getBackend() instanceof IGetDisplayNameBackend) {
665
-			$property = $account->getProperty(self::PROPERTY_DISPLAYNAME);
666
-			$account->setProperty(self::PROPERTY_DISPLAYNAME, $user->getDisplayName(), $property->getScope(), $property->getVerified());
667
-		}
668
-		$this->internalCache->set($user->getUID(), $account);
669
-		return $account;
670
-	}
671
-
672
-	/**
673
-	 * Converts value (phone number) in E.164 format when it was a valid number
674
-	 * @throws InvalidArgumentException When the phone number was invalid or no default region is set and the number doesn't start with a country code
675
-	 */
676
-	protected function sanitizePropertyPhoneNumber(IAccountProperty $property): void {
677
-		$defaultRegion = $this->config->getSystemValueString('default_phone_region', '');
678
-
679
-		if ($defaultRegion === '') {
680
-			// When no default region is set, only +49… numbers are valid
681
-			if (!str_starts_with($property->getValue(), '+')) {
682
-				throw new InvalidArgumentException(self::PROPERTY_PHONE);
683
-			}
684
-
685
-			$defaultRegion = 'EN';
686
-		}
687
-
688
-		$phoneNumber = $this->phoneNumberUtil->convertToStandardFormat($property->getValue(), $defaultRegion);
689
-		if ($phoneNumber === null) {
690
-			throw new InvalidArgumentException(self::PROPERTY_PHONE);
691
-		}
692
-		$property->setValue($phoneNumber);
693
-	}
694
-
695
-	/**
696
-	 * @throws InvalidArgumentException When the website did not have http(s) as protocol or the host name was empty
697
-	 */
698
-	private function sanitizePropertyWebsite(IAccountProperty $property): void {
699
-		$parts = parse_url($property->getValue());
700
-		if (!isset($parts['scheme']) || ($parts['scheme'] !== 'https' && $parts['scheme'] !== 'http')) {
701
-			throw new InvalidArgumentException(self::PROPERTY_WEBSITE);
702
-		}
703
-
704
-		if (!isset($parts['host']) || $parts['host'] === '') {
705
-			throw new InvalidArgumentException(self::PROPERTY_WEBSITE);
706
-		}
707
-	}
708
-
709
-	/**
710
-	 * @throws InvalidArgumentException If the property value is not a valid user handle according to X's rules
711
-	 */
712
-	private function sanitizePropertyTwitter(IAccountProperty $property): void {
713
-		if ($property->getName() === self::PROPERTY_TWITTER) {
714
-			$matches = [];
715
-			// twitter handles only contain alpha numeric characters and the underscore and must not be longer than 15 characters
716
-			if (preg_match('/^@?([a-zA-Z0-9_]{2,15})$/', $property->getValue(), $matches) !== 1) {
717
-				throw new InvalidArgumentException(self::PROPERTY_TWITTER);
718
-			}
719
-
720
-			// drop the leading @ if any to make it the valid handle
721
-			$property->setValue($matches[1]);
722
-
723
-		}
724
-	}
725
-
726
-	private function validateBlueSkyHandle(string $text): bool {
727
-		if ($text === '') {
728
-			return true;
729
-		}
730
-
731
-		$lowerText = strtolower($text);
732
-
733
-		if ($lowerText === 'bsky.social') {
734
-			// "bsky.social" itself is not a valid handle
735
-			return false;
736
-		}
737
-
738
-		if (str_ends_with($lowerText, '.bsky.social')) {
739
-			$parts = explode('.', $lowerText);
740
-
741
-			// Must be exactly: username.bsky.social → 3 parts
742
-			if (count($parts) !== 3 || $parts[1] !== 'bsky' || $parts[2] !== 'social') {
743
-				return false;
744
-			}
745
-
746
-			$username = $parts[0];
747
-
748
-			// Must be 3–18 chars, alphanumeric/hyphen, no start/end hyphen
749
-			return preg_match('/^[a-z0-9][a-z0-9-]{2,17}$/', $username) === 1;
750
-		}
751
-
752
-		// Allow custom domains (Bluesky handle via personal domain)
753
-		return filter_var($text, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false;
754
-	}
755
-
756
-
757
-	private function sanitizePropertyBluesky(IAccountProperty $property): void {
758
-		if ($property->getName() === self::PROPERTY_BLUESKY) {
759
-			if (!$this->validateBlueSkyHandle($property->getValue())) {
760
-				throw new InvalidArgumentException(self::PROPERTY_BLUESKY);
761
-			}
762
-
763
-			$property->setValue($property->getValue());
764
-		}
765
-	}
766
-
767
-	/**
768
-	 * @throws InvalidArgumentException If the property value is not a valid fediverse handle (username@instance where instance is a valid domain)
769
-	 */
770
-	private function sanitizePropertyFediverse(IAccountProperty $property): void {
771
-		if ($property->getName() === self::PROPERTY_FEDIVERSE) {
772
-			$matches = [];
773
-			if (preg_match('/^@?([^@\s\/\\\]+)@([^\s\/\\\]+)$/', trim($property->getValue()), $matches) !== 1) {
774
-				throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE);
775
-			}
776
-
777
-			[, $username, $instance] = $matches;
778
-			$validated = filter_var($instance, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME);
779
-			if ($validated !== $instance) {
780
-				throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE);
781
-			}
782
-
783
-			if ($this->config->getSystemValueBool('has_internet_connection', true)) {
784
-				$client = $this->clientService->newClient();
785
-
786
-				try {
787
-					// try the public account lookup API of mastodon
788
-					$response = $client->get("https://{$instance}/.well-known/webfinger?resource=acct:{$username}@{$instance}");
789
-					// should be a json response with account information
790
-					$data = $response->getBody();
791
-					if (is_resource($data)) {
792
-						$data = stream_get_contents($data);
793
-					}
794
-					$decoded = json_decode($data, true);
795
-					// ensure the username is the same the user passed
796
-					// in this case we can assume this is a valid fediverse server and account
797
-					if (!is_array($decoded) || ($decoded['subject'] ?? '') !== "acct:{$username}@{$instance}") {
798
-						throw new InvalidArgumentException();
799
-					}
800
-					// check for activitypub link
801
-					if (is_array($decoded['links']) && isset($decoded['links'])) {
802
-						$found = false;
803
-						foreach ($decoded['links'] as $link) {
804
-							// have application/activity+json or application/ld+json
805
-							if (isset($link['type']) && (
806
-								$link['type'] === 'application/activity+json'
807
-								|| $link['type'] === 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
808
-							)) {
809
-								$found = true;
810
-								break;
811
-							}
812
-						}
813
-						if (!$found) {
814
-							throw new InvalidArgumentException();
815
-						}
816
-					}
817
-				} catch (InvalidArgumentException) {
818
-					throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE);
819
-				} catch (\Exception $error) {
820
-					$this->logger->error('Could not verify fediverse account', ['exception' => $error, 'instance' => $instance]);
821
-					throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE);
822
-				}
823
-			}
824
-
825
-			$property->setValue("$username@$instance");
826
-		}
827
-	}
828
-
829
-	public function updateAccount(IAccount $account): void {
830
-		$this->testValueLengths(iterator_to_array($account->getAllProperties()), true);
831
-		try {
832
-			$property = $account->getProperty(self::PROPERTY_PHONE);
833
-			if ($property->getValue() !== '') {
834
-				$this->sanitizePropertyPhoneNumber($property);
835
-			}
836
-		} catch (PropertyDoesNotExistException $e) {
837
-			//  valid case, nothing to do
838
-		}
839
-
840
-		try {
841
-			$property = $account->getProperty(self::PROPERTY_WEBSITE);
842
-			if ($property->getValue() !== '') {
843
-				$this->sanitizePropertyWebsite($property);
844
-			}
845
-		} catch (PropertyDoesNotExistException $e) {
846
-			//  valid case, nothing to do
847
-		}
848
-
849
-		try {
850
-			$property = $account->getProperty(self::PROPERTY_TWITTER);
851
-			if ($property->getValue() !== '') {
852
-				$this->sanitizePropertyTwitter($property);
853
-			}
854
-		} catch (PropertyDoesNotExistException $e) {
855
-			//  valid case, nothing to do
856
-		}
857
-
858
-		try {
859
-			$property = $account->getProperty(self::PROPERTY_BLUESKY);
860
-			if ($property->getValue() !== '') {
861
-				$this->sanitizePropertyBluesky($property);
862
-			}
863
-		} catch (PropertyDoesNotExistException $e) {
864
-			//  valid case, nothing to do
865
-		}
866
-
867
-		try {
868
-			$property = $account->getProperty(self::PROPERTY_FEDIVERSE);
869
-			if ($property->getValue() !== '') {
870
-				$this->sanitizePropertyFediverse($property);
871
-			}
872
-		} catch (PropertyDoesNotExistException $e) {
873
-			//  valid case, nothing to do
874
-		}
875
-
876
-		foreach ($account->getAllProperties() as $property) {
877
-			$this->testPropertyScope($property, self::ALLOWED_SCOPES, true);
878
-		}
879
-
880
-		$oldData = $this->getUser($account->getUser(), false);
881
-		$this->updateVerificationStatus($account, $oldData);
882
-		$this->checkEmailVerification($account, $oldData);
883
-		$this->checkLocalEmailVerification($account, $oldData);
884
-
885
-		$data = [];
886
-		foreach ($account->getAllProperties() as $property) {
887
-			/** @var IAccountProperty $property */
888
-			$data[] = [
889
-				'name' => $property->getName(),
890
-				'value' => $property->getValue(),
891
-				'scope' => $property->getScope(),
892
-				'verified' => $property->getVerified(),
893
-				'locallyVerified' => $property->getLocallyVerified(),
894
-			];
895
-		}
896
-
897
-		$this->updateUser($account->getUser(), $data, $oldData, true);
898
-		$this->internalCache->set($account->getUser()->getUID(), $account);
899
-	}
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_BLUESKY => self::SCOPE_LOCAL,
82
+        self::PROPERTY_WEBSITE => self::SCOPE_LOCAL,
83
+    ];
84
+
85
+    public function __construct(
86
+        private IDBConnection $connection,
87
+        private IConfig $config,
88
+        private IEventDispatcher $dispatcher,
89
+        private IJobList $jobList,
90
+        private LoggerInterface $logger,
91
+        private IVerificationToken $verificationToken,
92
+        private IMailer $mailer,
93
+        private Defaults $defaults,
94
+        private IFactory $l10nFactory,
95
+        private IURLGenerator $urlGenerator,
96
+        private ICrypto $crypto,
97
+        private IPhoneNumberUtil $phoneNumberUtil,
98
+        private IClientService $clientService,
99
+    ) {
100
+        $this->internalCache = new CappedMemoryCache();
101
+    }
102
+
103
+    /**
104
+     * @param IAccountProperty[] $properties
105
+     */
106
+    protected function testValueLengths(array $properties, bool $throwOnData = false): void {
107
+        foreach ($properties as $property) {
108
+            if (strlen($property->getValue()) > 2048) {
109
+                if ($throwOnData) {
110
+                    throw new InvalidArgumentException($property->getName());
111
+                } else {
112
+                    $property->setValue('');
113
+                }
114
+            }
115
+        }
116
+    }
117
+
118
+    protected function testPropertyScope(IAccountProperty $property, array $allowedScopes, bool $throwOnData): void {
119
+        if ($throwOnData && !in_array($property->getScope(), $allowedScopes, true)) {
120
+            throw new InvalidArgumentException('scope');
121
+        }
122
+
123
+        if (
124
+            $property->getScope() === self::SCOPE_PRIVATE
125
+            && in_array($property->getName(), [self::PROPERTY_DISPLAYNAME, self::PROPERTY_EMAIL])
126
+        ) {
127
+            if ($throwOnData) {
128
+                // v2-private is not available for these fields
129
+                throw new InvalidArgumentException('scope');
130
+            } else {
131
+                // default to local
132
+                $property->setScope(self::SCOPE_LOCAL);
133
+            }
134
+        } else {
135
+            // migrate scope values to the new format
136
+            // invalid scopes are mapped to a default value
137
+            $property->setScope(AccountProperty::mapScopeToV2($property->getScope()));
138
+        }
139
+    }
140
+
141
+    protected function updateUser(IUser $user, array $data, ?array $oldUserData, bool $throwOnData = false): array {
142
+        if ($oldUserData === null) {
143
+            $oldUserData = $this->getUser($user, false);
144
+        }
145
+
146
+        $updated = true;
147
+
148
+        if ($oldUserData !== $data) {
149
+            $this->updateExistingUser($user, $data, $oldUserData);
150
+        } else {
151
+            // nothing needs to be done if new and old data set are the same
152
+            $updated = false;
153
+        }
154
+
155
+        if ($updated) {
156
+            $this->dispatcher->dispatchTyped(new UserUpdatedEvent(
157
+                $user,
158
+                $data,
159
+            ));
160
+        }
161
+
162
+        return $data;
163
+    }
164
+
165
+    /**
166
+     * delete user from accounts table
167
+     */
168
+    public function deleteUser(IUser $user): void {
169
+        $uid = $user->getUID();
170
+        $query = $this->connection->getQueryBuilder();
171
+        $query->delete($this->table)
172
+            ->where($query->expr()->eq('uid', $query->createNamedParameter($uid)))
173
+            ->executeStatement();
174
+
175
+        $this->deleteUserData($user);
176
+    }
177
+
178
+    /**
179
+     * delete user from accounts table
180
+     */
181
+    public function deleteUserData(IUser $user): void {
182
+        $uid = $user->getUID();
183
+        $query = $this->connection->getQueryBuilder();
184
+        $query->delete($this->dataTable)
185
+            ->where($query->expr()->eq('uid', $query->createNamedParameter($uid)))
186
+            ->executeStatement();
187
+    }
188
+
189
+    /**
190
+     * get stored data from a given user
191
+     */
192
+    protected function getUser(IUser $user, bool $insertIfNotExists = true): array {
193
+        $uid = $user->getUID();
194
+        $query = $this->connection->getQueryBuilder();
195
+        $query->select('data')
196
+            ->from($this->table)
197
+            ->where($query->expr()->eq('uid', $query->createParameter('uid')))
198
+            ->setParameter('uid', $uid);
199
+        $result = $query->executeQuery();
200
+        $accountData = $result->fetchAll();
201
+        $result->closeCursor();
202
+
203
+        if (empty($accountData)) {
204
+            $userData = $this->buildDefaultUserRecord($user);
205
+            if ($insertIfNotExists) {
206
+                $this->insertNewUser($user, $userData);
207
+            }
208
+            return $userData;
209
+        }
210
+
211
+        $userDataArray = $this->importFromJson($accountData[0]['data'], $uid);
212
+        if ($userDataArray === null || $userDataArray === []) {
213
+            return $this->buildDefaultUserRecord($user);
214
+        }
215
+
216
+        return $this->addMissingDefaultValues($userDataArray, $this->buildDefaultUserRecord($user));
217
+    }
218
+
219
+    public function searchUsers(string $property, array $values): array {
220
+        // the value col is limited to 255 bytes. It is used for searches only.
221
+        $values = array_map(function (string $value) {
222
+            return Util::shortenMultibyteString($value, 255);
223
+        }, $values);
224
+        $chunks = array_chunk($values, 500);
225
+        $query = $this->connection->getQueryBuilder();
226
+        $query->select('*')
227
+            ->from($this->dataTable)
228
+            ->where($query->expr()->eq('name', $query->createNamedParameter($property)))
229
+            ->andWhere($query->expr()->in('value', $query->createParameter('values')));
230
+
231
+        $matches = [];
232
+        foreach ($chunks as $chunk) {
233
+            $query->setParameter('values', $chunk, IQueryBuilder::PARAM_STR_ARRAY);
234
+            $result = $query->executeQuery();
235
+
236
+            while ($row = $result->fetch()) {
237
+                $matches[$row['uid']] = $row['value'];
238
+            }
239
+            $result->closeCursor();
240
+        }
241
+
242
+        $result = array_merge($matches, $this->searchUsersForRelatedCollection($property, $values));
243
+
244
+        return array_flip($result);
245
+    }
246
+
247
+    protected function searchUsersForRelatedCollection(string $property, array $values): array {
248
+        return match ($property) {
249
+            IAccountManager::PROPERTY_EMAIL => array_flip($this->searchUsers(IAccountManager::COLLECTION_EMAIL, $values)),
250
+            default => [],
251
+        };
252
+    }
253
+
254
+    /**
255
+     * check if we need to ask the server for email verification, if yes we create a cronjob
256
+     */
257
+    protected function checkEmailVerification(IAccount $updatedAccount, array $oldData): void {
258
+        try {
259
+            $property = $updatedAccount->getProperty(self::PROPERTY_EMAIL);
260
+        } catch (PropertyDoesNotExistException $e) {
261
+            return;
262
+        }
263
+
264
+        $oldMailIndex = array_search(self::PROPERTY_EMAIL, array_column($oldData, 'name'), true);
265
+        $oldMail = $oldMailIndex !== false ? $oldData[$oldMailIndex]['value'] : '';
266
+
267
+        if ($oldMail !== $property->getValue()) {
268
+            $this->jobList->add(
269
+                VerifyUserData::class,
270
+                [
271
+                    'verificationCode' => '',
272
+                    'data' => $property->getValue(),
273
+                    'type' => self::PROPERTY_EMAIL,
274
+                    'uid' => $updatedAccount->getUser()->getUID(),
275
+                    'try' => 0,
276
+                    'lastRun' => time()
277
+                ]
278
+            );
279
+
280
+            $property->setVerified(self::VERIFICATION_IN_PROGRESS);
281
+        }
282
+    }
283
+
284
+    protected function checkLocalEmailVerification(IAccount $updatedAccount, array $oldData): void {
285
+        $mailCollection = $updatedAccount->getPropertyCollection(self::COLLECTION_EMAIL);
286
+        foreach ($mailCollection->getProperties() as $property) {
287
+            if ($property->getLocallyVerified() !== self::NOT_VERIFIED) {
288
+                continue;
289
+            }
290
+            if ($this->sendEmailVerificationEmail($updatedAccount->getUser(), $property->getValue())) {
291
+                $property->setLocallyVerified(self::VERIFICATION_IN_PROGRESS);
292
+            }
293
+        }
294
+    }
295
+
296
+    protected function sendEmailVerificationEmail(IUser $user, string $email): bool {
297
+        $ref = \substr(hash('sha256', $email), 0, 8);
298
+        $key = $this->crypto->encrypt($email);
299
+        $token = $this->verificationToken->create($user, 'verifyMail' . $ref, $email);
300
+
301
+        $link = $this->urlGenerator->linkToRouteAbsolute(
302
+            'provisioning_api.Verification.verifyMail',
303
+            [
304
+                'userId' => $user->getUID(),
305
+                'token' => $token,
306
+                'key' => $key
307
+            ]
308
+        );
309
+
310
+        $emailTemplate = $this->mailer->createEMailTemplate('core.EmailVerification', [
311
+            'link' => $link,
312
+        ]);
313
+
314
+        if (!$this->l10n) {
315
+            $this->l10n = $this->l10nFactory->get('core');
316
+        }
317
+
318
+        $emailTemplate->setSubject($this->l10n->t('%s email verification', [$this->defaults->getName()]));
319
+        $emailTemplate->addHeader();
320
+        $emailTemplate->addHeading($this->l10n->t('Email verification'));
321
+
322
+        $emailTemplate->addBodyText(
323
+            htmlspecialchars($this->l10n->t('Click the following button to confirm your email.')),
324
+            $this->l10n->t('Click the following link to confirm your email.')
325
+        );
326
+
327
+        $emailTemplate->addBodyButton(
328
+            htmlspecialchars($this->l10n->t('Confirm your email')),
329
+            $link,
330
+            false
331
+        );
332
+        $emailTemplate->addFooter();
333
+
334
+        try {
335
+            $message = $this->mailer->createMessage();
336
+            $message->setTo([$email => $user->getDisplayName()]);
337
+            $message->setFrom([Util::getDefaultEmailAddress('verification-noreply') => $this->defaults->getName()]);
338
+            $message->useTemplate($emailTemplate);
339
+            $this->mailer->send($message);
340
+        } catch (Exception $e) {
341
+            // Log the exception and continue
342
+            $this->logger->info('Failed to send verification mail', [
343
+                'app' => 'core',
344
+                'exception' => $e
345
+            ]);
346
+            return false;
347
+        }
348
+        return true;
349
+    }
350
+
351
+    /**
352
+     * Make sure that all expected data are set
353
+     */
354
+    protected function addMissingDefaultValues(array $userData, array $defaultUserData): array {
355
+        foreach ($defaultUserData as $defaultDataItem) {
356
+            // If property does not exist, initialize it
357
+            $userDataIndex = array_search($defaultDataItem['name'], array_column($userData, 'name'), true);
358
+            if ($userDataIndex === false) {
359
+                $userData[] = $defaultDataItem;
360
+                continue;
361
+            }
362
+
363
+            // Merge and extend default missing values
364
+            $userData[$userDataIndex] = array_merge($defaultDataItem, $userData[$userDataIndex]);
365
+        }
366
+
367
+        return $userData;
368
+    }
369
+
370
+    protected function updateVerificationStatus(IAccount $updatedAccount, array $oldData): void {
371
+        static $propertiesVerifiableByLookupServer = [
372
+            self::PROPERTY_TWITTER,
373
+            self::PROPERTY_FEDIVERSE,
374
+            self::PROPERTY_WEBSITE,
375
+            self::PROPERTY_EMAIL,
376
+        ];
377
+
378
+        foreach ($propertiesVerifiableByLookupServer as $propertyName) {
379
+            try {
380
+                $property = $updatedAccount->getProperty($propertyName);
381
+            } catch (PropertyDoesNotExistException $e) {
382
+                continue;
383
+            }
384
+            $wasVerified = isset($oldData[$propertyName])
385
+                && isset($oldData[$propertyName]['verified'])
386
+                && $oldData[$propertyName]['verified'] === self::VERIFIED;
387
+            if ((!isset($oldData[$propertyName])
388
+                    || !isset($oldData[$propertyName]['value'])
389
+                    || $property->getValue() !== $oldData[$propertyName]['value'])
390
+                && ($property->getVerified() !== self::NOT_VERIFIED
391
+                    || $wasVerified)
392
+            ) {
393
+                $property->setVerified(self::NOT_VERIFIED);
394
+            }
395
+        }
396
+    }
397
+
398
+    /**
399
+     * add new user to accounts table
400
+     */
401
+    protected function insertNewUser(IUser $user, array $data): void {
402
+        $uid = $user->getUID();
403
+        $jsonEncodedData = $this->prepareJson($data);
404
+        $query = $this->connection->getQueryBuilder();
405
+        $query->insert($this->table)
406
+            ->values(
407
+                [
408
+                    'uid' => $query->createNamedParameter($uid),
409
+                    'data' => $query->createNamedParameter($jsonEncodedData),
410
+                ]
411
+            )
412
+            ->executeStatement();
413
+
414
+        $this->deleteUserData($user);
415
+        $this->writeUserData($user, $data);
416
+    }
417
+
418
+    protected function prepareJson(array $data): string {
419
+        $preparedData = [];
420
+        foreach ($data as $dataRow) {
421
+            $propertyName = $dataRow['name'];
422
+            unset($dataRow['name']);
423
+
424
+            if (isset($dataRow['locallyVerified']) && $dataRow['locallyVerified'] === self::NOT_VERIFIED) {
425
+                // do not write default value, save DB space
426
+                unset($dataRow['locallyVerified']);
427
+            }
428
+
429
+            if (!$this->isCollection($propertyName)) {
430
+                $preparedData[$propertyName] = $dataRow;
431
+                continue;
432
+            }
433
+            if (!isset($preparedData[$propertyName])) {
434
+                $preparedData[$propertyName] = [];
435
+            }
436
+            $preparedData[$propertyName][] = $dataRow;
437
+        }
438
+        return json_encode($preparedData);
439
+    }
440
+
441
+    protected function importFromJson(string $json, string $userId): ?array {
442
+        $result = [];
443
+        $jsonArray = json_decode($json, true);
444
+        $jsonError = json_last_error();
445
+        if ($jsonError !== JSON_ERROR_NONE) {
446
+            $this->logger->critical(
447
+                'User data of {uid} contained invalid JSON (error {json_error}), hence falling back to a default user record',
448
+                [
449
+                    'uid' => $userId,
450
+                    'json_error' => $jsonError
451
+                ]
452
+            );
453
+            return null;
454
+        }
455
+        foreach ($jsonArray as $propertyName => $row) {
456
+            if (!$this->isCollection($propertyName)) {
457
+                $result[] = array_merge($row, ['name' => $propertyName]);
458
+                continue;
459
+            }
460
+            foreach ($row as $singleRow) {
461
+                $result[] = array_merge($singleRow, ['name' => $propertyName]);
462
+            }
463
+        }
464
+        return $result;
465
+    }
466
+
467
+    /**
468
+     * Update existing user in accounts table
469
+     */
470
+    protected function updateExistingUser(IUser $user, array $data, array $oldData): void {
471
+        $uid = $user->getUID();
472
+        $jsonEncodedData = $this->prepareJson($data);
473
+        $query = $this->connection->getQueryBuilder();
474
+        $query->update($this->table)
475
+            ->set('data', $query->createNamedParameter($jsonEncodedData))
476
+            ->where($query->expr()->eq('uid', $query->createNamedParameter($uid)))
477
+            ->executeStatement();
478
+
479
+        $this->deleteUserData($user);
480
+        $this->writeUserData($user, $data);
481
+    }
482
+
483
+    protected function writeUserData(IUser $user, array $data): void {
484
+        $query = $this->connection->getQueryBuilder();
485
+        $query->insert($this->dataTable)
486
+            ->values(
487
+                [
488
+                    'uid' => $query->createNamedParameter($user->getUID()),
489
+                    'name' => $query->createParameter('name'),
490
+                    'value' => $query->createParameter('value'),
491
+                ]
492
+            );
493
+        $this->writeUserDataProperties($query, $data);
494
+    }
495
+
496
+    protected function writeUserDataProperties(IQueryBuilder $query, array $data): void {
497
+        foreach ($data as $property) {
498
+            if ($property['name'] === self::PROPERTY_AVATAR) {
499
+                continue;
500
+            }
501
+
502
+            // the value col is limited to 255 bytes. It is used for searches only.
503
+            $value = $property['value'] ? Util::shortenMultibyteString($property['value'], 255) : '';
504
+
505
+            $query->setParameter('name', $property['name'])
506
+                ->setParameter('value', $value);
507
+            $query->executeStatement();
508
+        }
509
+    }
510
+
511
+    /**
512
+     * build default user record in case not data set exists yet
513
+     */
514
+    protected function buildDefaultUserRecord(IUser $user): array {
515
+        $scopes = array_merge(self::DEFAULT_SCOPES, array_filter($this->config->getSystemValue('account_manager.default_property_scope', []), static function (string $scope, string $property) {
516
+            return in_array($property, self::ALLOWED_PROPERTIES, true) && in_array($scope, self::ALLOWED_SCOPES, true);
517
+        }, ARRAY_FILTER_USE_BOTH));
518
+
519
+        return [
520
+            [
521
+                'name' => self::PROPERTY_DISPLAYNAME,
522
+                'value' => $user->getDisplayName(),
523
+                // Display name must be at least SCOPE_LOCAL
524
+                'scope' => $scopes[self::PROPERTY_DISPLAYNAME] === self::SCOPE_PRIVATE ? self::SCOPE_LOCAL : $scopes[self::PROPERTY_DISPLAYNAME],
525
+                'verified' => self::NOT_VERIFIED,
526
+            ],
527
+
528
+            [
529
+                'name' => self::PROPERTY_ADDRESS,
530
+                'value' => '',
531
+                'scope' => $scopes[self::PROPERTY_ADDRESS],
532
+                'verified' => self::NOT_VERIFIED,
533
+            ],
534
+
535
+            [
536
+                'name' => self::PROPERTY_WEBSITE,
537
+                'value' => '',
538
+                'scope' => $scopes[self::PROPERTY_WEBSITE],
539
+                'verified' => self::NOT_VERIFIED,
540
+            ],
541
+
542
+            [
543
+                'name' => self::PROPERTY_EMAIL,
544
+                'value' => $user->getEMailAddress(),
545
+                // Email must be at least SCOPE_LOCAL
546
+                'scope' => $scopes[self::PROPERTY_EMAIL] === self::SCOPE_PRIVATE ? self::SCOPE_LOCAL : $scopes[self::PROPERTY_EMAIL],
547
+                'verified' => self::NOT_VERIFIED,
548
+            ],
549
+
550
+            [
551
+                'name' => self::PROPERTY_AVATAR,
552
+                'scope' => $scopes[self::PROPERTY_AVATAR],
553
+            ],
554
+
555
+            [
556
+                'name' => self::PROPERTY_PHONE,
557
+                'value' => '',
558
+                'scope' => $scopes[self::PROPERTY_PHONE],
559
+                'verified' => self::NOT_VERIFIED,
560
+            ],
561
+
562
+            [
563
+                'name' => self::PROPERTY_TWITTER,
564
+                'value' => '',
565
+                'scope' => $scopes[self::PROPERTY_TWITTER],
566
+                'verified' => self::NOT_VERIFIED,
567
+            ],
568
+
569
+            [
570
+                'name' => self::PROPERTY_BLUESKY,
571
+                'value' => '',
572
+                'scope' => $scopes[self::PROPERTY_BLUESKY],
573
+                'verified' => self::NOT_VERIFIED,
574
+            ],
575
+
576
+            [
577
+                'name' => self::PROPERTY_FEDIVERSE,
578
+                'value' => '',
579
+                'scope' => $scopes[self::PROPERTY_FEDIVERSE],
580
+                'verified' => self::NOT_VERIFIED,
581
+            ],
582
+
583
+            [
584
+                'name' => self::PROPERTY_ORGANISATION,
585
+                'value' => '',
586
+                'scope' => $scopes[self::PROPERTY_ORGANISATION],
587
+            ],
588
+
589
+            [
590
+                'name' => self::PROPERTY_ROLE,
591
+                'value' => '',
592
+                'scope' => $scopes[self::PROPERTY_ROLE],
593
+            ],
594
+
595
+            [
596
+                'name' => self::PROPERTY_HEADLINE,
597
+                'value' => '',
598
+                'scope' => $scopes[self::PROPERTY_HEADLINE],
599
+            ],
600
+
601
+            [
602
+                'name' => self::PROPERTY_BIOGRAPHY,
603
+                'value' => '',
604
+                'scope' => $scopes[self::PROPERTY_BIOGRAPHY],
605
+            ],
606
+
607
+            [
608
+                'name' => self::PROPERTY_BIRTHDATE,
609
+                'value' => '',
610
+                'scope' => $scopes[self::PROPERTY_BIRTHDATE],
611
+            ],
612
+
613
+            [
614
+                'name' => self::PROPERTY_PROFILE_ENABLED,
615
+                'value' => $this->isProfileEnabledByDefault($this->config) ? '1' : '0',
616
+            ],
617
+
618
+            [
619
+                'name' => self::PROPERTY_PRONOUNS,
620
+                'value' => '',
621
+                'scope' => $scopes[self::PROPERTY_PRONOUNS],
622
+            ],
623
+        ];
624
+    }
625
+
626
+    private function arrayDataToCollection(IAccount $account, array $data): IAccountPropertyCollection {
627
+        $collection = $account->getPropertyCollection($data['name']);
628
+
629
+        $p = new AccountProperty(
630
+            $data['name'],
631
+            $data['value'] ?? '',
632
+            $data['scope'] ?? self::SCOPE_LOCAL,
633
+            $data['verified'] ?? self::NOT_VERIFIED,
634
+            ''
635
+        );
636
+        $p->setLocallyVerified($data['locallyVerified'] ?? self::NOT_VERIFIED);
637
+        $collection->addProperty($p);
638
+
639
+        return $collection;
640
+    }
641
+
642
+    private function parseAccountData(IUser $user, $data): Account {
643
+        $account = new Account($user);
644
+        foreach ($data as $accountData) {
645
+            if ($this->isCollection($accountData['name'])) {
646
+                $account->setPropertyCollection($this->arrayDataToCollection($account, $accountData));
647
+            } else {
648
+                $account->setProperty($accountData['name'], $accountData['value'] ?? '', $accountData['scope'] ?? self::SCOPE_LOCAL, $accountData['verified'] ?? self::NOT_VERIFIED);
649
+                if (isset($accountData['locallyVerified'])) {
650
+                    $property = $account->getProperty($accountData['name']);
651
+                    $property->setLocallyVerified($accountData['locallyVerified']);
652
+                }
653
+            }
654
+        }
655
+        return $account;
656
+    }
657
+
658
+    public function getAccount(IUser $user): IAccount {
659
+        $cached = $this->internalCache->get($user->getUID());
660
+        if ($cached !== null) {
661
+            return $cached;
662
+        }
663
+        $account = $this->parseAccountData($user, $this->getUser($user));
664
+        if ($user->getBackend() instanceof IGetDisplayNameBackend) {
665
+            $property = $account->getProperty(self::PROPERTY_DISPLAYNAME);
666
+            $account->setProperty(self::PROPERTY_DISPLAYNAME, $user->getDisplayName(), $property->getScope(), $property->getVerified());
667
+        }
668
+        $this->internalCache->set($user->getUID(), $account);
669
+        return $account;
670
+    }
671
+
672
+    /**
673
+     * Converts value (phone number) in E.164 format when it was a valid number
674
+     * @throws InvalidArgumentException When the phone number was invalid or no default region is set and the number doesn't start with a country code
675
+     */
676
+    protected function sanitizePropertyPhoneNumber(IAccountProperty $property): void {
677
+        $defaultRegion = $this->config->getSystemValueString('default_phone_region', '');
678
+
679
+        if ($defaultRegion === '') {
680
+            // When no default region is set, only +49… numbers are valid
681
+            if (!str_starts_with($property->getValue(), '+')) {
682
+                throw new InvalidArgumentException(self::PROPERTY_PHONE);
683
+            }
684
+
685
+            $defaultRegion = 'EN';
686
+        }
687
+
688
+        $phoneNumber = $this->phoneNumberUtil->convertToStandardFormat($property->getValue(), $defaultRegion);
689
+        if ($phoneNumber === null) {
690
+            throw new InvalidArgumentException(self::PROPERTY_PHONE);
691
+        }
692
+        $property->setValue($phoneNumber);
693
+    }
694
+
695
+    /**
696
+     * @throws InvalidArgumentException When the website did not have http(s) as protocol or the host name was empty
697
+     */
698
+    private function sanitizePropertyWebsite(IAccountProperty $property): void {
699
+        $parts = parse_url($property->getValue());
700
+        if (!isset($parts['scheme']) || ($parts['scheme'] !== 'https' && $parts['scheme'] !== 'http')) {
701
+            throw new InvalidArgumentException(self::PROPERTY_WEBSITE);
702
+        }
703
+
704
+        if (!isset($parts['host']) || $parts['host'] === '') {
705
+            throw new InvalidArgumentException(self::PROPERTY_WEBSITE);
706
+        }
707
+    }
708
+
709
+    /**
710
+     * @throws InvalidArgumentException If the property value is not a valid user handle according to X's rules
711
+     */
712
+    private function sanitizePropertyTwitter(IAccountProperty $property): void {
713
+        if ($property->getName() === self::PROPERTY_TWITTER) {
714
+            $matches = [];
715
+            // twitter handles only contain alpha numeric characters and the underscore and must not be longer than 15 characters
716
+            if (preg_match('/^@?([a-zA-Z0-9_]{2,15})$/', $property->getValue(), $matches) !== 1) {
717
+                throw new InvalidArgumentException(self::PROPERTY_TWITTER);
718
+            }
719
+
720
+            // drop the leading @ if any to make it the valid handle
721
+            $property->setValue($matches[1]);
722
+
723
+        }
724
+    }
725
+
726
+    private function validateBlueSkyHandle(string $text): bool {
727
+        if ($text === '') {
728
+            return true;
729
+        }
730
+
731
+        $lowerText = strtolower($text);
732
+
733
+        if ($lowerText === 'bsky.social') {
734
+            // "bsky.social" itself is not a valid handle
735
+            return false;
736
+        }
737
+
738
+        if (str_ends_with($lowerText, '.bsky.social')) {
739
+            $parts = explode('.', $lowerText);
740
+
741
+            // Must be exactly: username.bsky.social → 3 parts
742
+            if (count($parts) !== 3 || $parts[1] !== 'bsky' || $parts[2] !== 'social') {
743
+                return false;
744
+            }
745
+
746
+            $username = $parts[0];
747
+
748
+            // Must be 3–18 chars, alphanumeric/hyphen, no start/end hyphen
749
+            return preg_match('/^[a-z0-9][a-z0-9-]{2,17}$/', $username) === 1;
750
+        }
751
+
752
+        // Allow custom domains (Bluesky handle via personal domain)
753
+        return filter_var($text, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false;
754
+    }
755
+
756
+
757
+    private function sanitizePropertyBluesky(IAccountProperty $property): void {
758
+        if ($property->getName() === self::PROPERTY_BLUESKY) {
759
+            if (!$this->validateBlueSkyHandle($property->getValue())) {
760
+                throw new InvalidArgumentException(self::PROPERTY_BLUESKY);
761
+            }
762
+
763
+            $property->setValue($property->getValue());
764
+        }
765
+    }
766
+
767
+    /**
768
+     * @throws InvalidArgumentException If the property value is not a valid fediverse handle (username@instance where instance is a valid domain)
769
+     */
770
+    private function sanitizePropertyFediverse(IAccountProperty $property): void {
771
+        if ($property->getName() === self::PROPERTY_FEDIVERSE) {
772
+            $matches = [];
773
+            if (preg_match('/^@?([^@\s\/\\\]+)@([^\s\/\\\]+)$/', trim($property->getValue()), $matches) !== 1) {
774
+                throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE);
775
+            }
776
+
777
+            [, $username, $instance] = $matches;
778
+            $validated = filter_var($instance, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME);
779
+            if ($validated !== $instance) {
780
+                throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE);
781
+            }
782
+
783
+            if ($this->config->getSystemValueBool('has_internet_connection', true)) {
784
+                $client = $this->clientService->newClient();
785
+
786
+                try {
787
+                    // try the public account lookup API of mastodon
788
+                    $response = $client->get("https://{$instance}/.well-known/webfinger?resource=acct:{$username}@{$instance}");
789
+                    // should be a json response with account information
790
+                    $data = $response->getBody();
791
+                    if (is_resource($data)) {
792
+                        $data = stream_get_contents($data);
793
+                    }
794
+                    $decoded = json_decode($data, true);
795
+                    // ensure the username is the same the user passed
796
+                    // in this case we can assume this is a valid fediverse server and account
797
+                    if (!is_array($decoded) || ($decoded['subject'] ?? '') !== "acct:{$username}@{$instance}") {
798
+                        throw new InvalidArgumentException();
799
+                    }
800
+                    // check for activitypub link
801
+                    if (is_array($decoded['links']) && isset($decoded['links'])) {
802
+                        $found = false;
803
+                        foreach ($decoded['links'] as $link) {
804
+                            // have application/activity+json or application/ld+json
805
+                            if (isset($link['type']) && (
806
+                                $link['type'] === 'application/activity+json'
807
+                                || $link['type'] === 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
808
+                            )) {
809
+                                $found = true;
810
+                                break;
811
+                            }
812
+                        }
813
+                        if (!$found) {
814
+                            throw new InvalidArgumentException();
815
+                        }
816
+                    }
817
+                } catch (InvalidArgumentException) {
818
+                    throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE);
819
+                } catch (\Exception $error) {
820
+                    $this->logger->error('Could not verify fediverse account', ['exception' => $error, 'instance' => $instance]);
821
+                    throw new InvalidArgumentException(self::PROPERTY_FEDIVERSE);
822
+                }
823
+            }
824
+
825
+            $property->setValue("$username@$instance");
826
+        }
827
+    }
828
+
829
+    public function updateAccount(IAccount $account): void {
830
+        $this->testValueLengths(iterator_to_array($account->getAllProperties()), true);
831
+        try {
832
+            $property = $account->getProperty(self::PROPERTY_PHONE);
833
+            if ($property->getValue() !== '') {
834
+                $this->sanitizePropertyPhoneNumber($property);
835
+            }
836
+        } catch (PropertyDoesNotExistException $e) {
837
+            //  valid case, nothing to do
838
+        }
839
+
840
+        try {
841
+            $property = $account->getProperty(self::PROPERTY_WEBSITE);
842
+            if ($property->getValue() !== '') {
843
+                $this->sanitizePropertyWebsite($property);
844
+            }
845
+        } catch (PropertyDoesNotExistException $e) {
846
+            //  valid case, nothing to do
847
+        }
848
+
849
+        try {
850
+            $property = $account->getProperty(self::PROPERTY_TWITTER);
851
+            if ($property->getValue() !== '') {
852
+                $this->sanitizePropertyTwitter($property);
853
+            }
854
+        } catch (PropertyDoesNotExistException $e) {
855
+            //  valid case, nothing to do
856
+        }
857
+
858
+        try {
859
+            $property = $account->getProperty(self::PROPERTY_BLUESKY);
860
+            if ($property->getValue() !== '') {
861
+                $this->sanitizePropertyBluesky($property);
862
+            }
863
+        } catch (PropertyDoesNotExistException $e) {
864
+            //  valid case, nothing to do
865
+        }
866
+
867
+        try {
868
+            $property = $account->getProperty(self::PROPERTY_FEDIVERSE);
869
+            if ($property->getValue() !== '') {
870
+                $this->sanitizePropertyFediverse($property);
871
+            }
872
+        } catch (PropertyDoesNotExistException $e) {
873
+            //  valid case, nothing to do
874
+        }
875
+
876
+        foreach ($account->getAllProperties() as $property) {
877
+            $this->testPropertyScope($property, self::ALLOWED_SCOPES, true);
878
+        }
879
+
880
+        $oldData = $this->getUser($account->getUser(), false);
881
+        $this->updateVerificationStatus($account, $oldData);
882
+        $this->checkEmailVerification($account, $oldData);
883
+        $this->checkLocalEmailVerification($account, $oldData);
884
+
885
+        $data = [];
886
+        foreach ($account->getAllProperties() as $property) {
887
+            /** @var IAccountProperty $property */
888
+            $data[] = [
889
+                'name' => $property->getName(),
890
+                'value' => $property->getValue(),
891
+                'scope' => $property->getScope(),
892
+                'verified' => $property->getVerified(),
893
+                'locallyVerified' => $property->getLocallyVerified(),
894
+            ];
895
+        }
896
+
897
+        $this->updateUser($account->getUser(), $data, $oldData, true);
898
+        $this->internalCache->set($account->getUser()->getUID(), $account);
899
+    }
900 900
 }
Please login to merge, or discard this patch.
lib/private/Tags.php 2 patches
Indentation   +679 added lines, -679 removed lines patch added patch discarded remove patch
@@ -22,683 +22,683 @@
 block discarded – undo
22 22
 use Psr\Log\LoggerInterface;
23 23
 
24 24
 class Tags implements ITags {
25
-	/**
26
-	 * Used for storing objectid/categoryname pairs while rescanning.
27
-	 */
28
-	private static array $relations = [];
29
-	private array $tags = [];
30
-
31
-	/**
32
-	 * Are we including tags for shared items?
33
-	 */
34
-	private bool $includeShared = false;
35
-
36
-	/**
37
-	 * The current user, plus any owners of the items shared with the current
38
-	 * user, if $this->includeShared === true.
39
-	 */
40
-	private array $owners = [];
41
-
42
-	/**
43
-	 * The sharing backend for objects of $this->type. Required if
44
-	 * $this->includeShared === true to determine ownership of items.
45
-	 */
46
-	private ?Share_Backend $backend = null;
47
-
48
-	public const TAG_TABLE = 'vcategory';
49
-	public const RELATION_TABLE = 'vcategory_to_object';
50
-
51
-	/**
52
-	 * Constructor.
53
-	 *
54
-	 * @param TagMapper $mapper Instance of the TagMapper abstraction layer.
55
-	 * @param string $user The user whose data the object will operate on.
56
-	 * @param string $type The type of items for which tags will be loaded.
57
-	 * @param array $defaultTags Tags that should be created at construction.
58
-	 *
59
-	 * since 20.0.0 $includeShared isn't used anymore
60
-	 */
61
-	public function __construct(
62
-		private TagMapper $mapper,
63
-		private string $user,
64
-		private string $type,
65
-		private LoggerInterface $logger,
66
-		private IDBConnection $db,
67
-		private IEventDispatcher $dispatcher,
68
-		private IUserSession $userSession,
69
-		private Folder $userFolder,
70
-		array $defaultTags = [],
71
-	) {
72
-		$this->owners = [$this->user];
73
-		$this->tags = $this->mapper->loadTags($this->owners, $this->type);
74
-
75
-		if (count($defaultTags) > 0 && count($this->tags) === 0) {
76
-			$this->addMultiple($defaultTags, true);
77
-		}
78
-	}
79
-
80
-	/**
81
-	 * Check if any tags are saved for this type and user.
82
-	 *
83
-	 * @return boolean
84
-	 */
85
-	public function isEmpty(): bool {
86
-		return count($this->tags) === 0;
87
-	}
88
-
89
-	/**
90
-	 * Returns an array mapping a given tag's properties to its values:
91
-	 * ['id' => 0, 'name' = 'Tag', 'owner' = 'User', 'type' => 'tagtype']
92
-	 *
93
-	 * @param string $id The ID of the tag that is going to be mapped
94
-	 * @return array|false
95
-	 */
96
-	public function getTag(string $id) {
97
-		$key = $this->getTagById($id);
98
-		if ($key !== false) {
99
-			return $this->tagMap($this->tags[$key]);
100
-		}
101
-		return false;
102
-	}
103
-
104
-	/**
105
-	 * Get the tags for a specific user.
106
-	 *
107
-	 * This returns an array with maps containing each tag's properties:
108
-	 * [
109
-	 * 	['id' => 0, 'name' = 'First tag', 'owner' = 'User', 'type' => 'tagtype'],
110
-	 * 	['id' => 1, 'name' = 'Shared tag', 'owner' = 'Other user', 'type' => 'tagtype'],
111
-	 * ]
112
-	 *
113
-	 * @return array<array-key, array{id: int, name: string}>
114
-	 */
115
-	public function getTags(): array {
116
-		if (!count($this->tags)) {
117
-			return [];
118
-		}
119
-
120
-		usort($this->tags, function ($a, $b) {
121
-			return strnatcasecmp($a->getName(), $b->getName());
122
-		});
123
-		$tagMap = [];
124
-
125
-		foreach ($this->tags as $tag) {
126
-			if ($tag->getName() !== ITags::TAG_FAVORITE) {
127
-				$tagMap[] = $this->tagMap($tag);
128
-			}
129
-		}
130
-		return $tagMap;
131
-	}
132
-
133
-	/**
134
-	 * Return only the tags owned by the given user, omitting any tags shared
135
-	 * by other users.
136
-	 *
137
-	 * @param string $user The user whose tags are to be checked.
138
-	 * @return array An array of Tag objects.
139
-	 */
140
-	public function getTagsForUser(string $user): array {
141
-		return array_filter($this->tags,
142
-			function ($tag) use ($user) {
143
-				return $tag->getOwner() === $user;
144
-			}
145
-		);
146
-	}
147
-
148
-	/**
149
-	 * Get the list of tags for the given ids.
150
-	 *
151
-	 * @param list<int> $objIds array of object ids
152
-	 * @return array<int, list<string>>|false of tags id as key to array of tag names
153
-	 *                                        or false if an error occurred
154
-	 */
155
-	public function getTagsForObjects(array $objIds) {
156
-		$entries = [];
157
-
158
-		try {
159
-			$chunks = array_chunk($objIds, 900, false);
160
-			$qb = $this->db->getQueryBuilder();
161
-			$qb->select('category', 'categoryid', 'objid')
162
-				->from(self::RELATION_TABLE, 'r')
163
-				->join('r', self::TAG_TABLE, 't', $qb->expr()->eq('r.categoryid', 't.id'))
164
-				->where($qb->expr()->eq('uid', $qb->createParameter('uid')))
165
-				->andWhere($qb->expr()->eq('r.type', $qb->createParameter('type')))
166
-				->andWhere($qb->expr()->in('objid', $qb->createParameter('chunk')));
167
-			foreach ($chunks as $chunk) {
168
-				$qb->setParameter('uid', $this->user, IQueryBuilder::PARAM_STR);
169
-				$qb->setParameter('type', $this->type, IQueryBuilder::PARAM_STR);
170
-				$qb->setParameter('chunk', $chunk, IQueryBuilder::PARAM_INT_ARRAY);
171
-				$result = $qb->executeQuery();
172
-				while ($row = $result->fetch()) {
173
-					$objId = (int)$row['objid'];
174
-					if (!isset($entries[$objId])) {
175
-						$entries[$objId] = [];
176
-					}
177
-					$entries[$objId][] = $row['category'];
178
-				}
179
-				$result->closeCursor();
180
-			}
181
-		} catch (\Exception $e) {
182
-			$this->logger->error($e->getMessage(), [
183
-				'exception' => $e,
184
-				'app' => 'core',
185
-			]);
186
-			return false;
187
-		}
188
-
189
-		return $entries;
190
-	}
191
-
192
-	/**
193
-	 * Get the a list if items tagged with $tag.
194
-	 *
195
-	 * Throws an exception if the tag could not be found.
196
-	 *
197
-	 * @param string $tag Tag id or name.
198
-	 * @return int[]|false An array of object ids or false on error.
199
-	 * @throws \Exception
200
-	 */
201
-	public function getIdsForTag($tag) {
202
-		$tagId = false;
203
-		if (is_numeric($tag)) {
204
-			$tagId = $tag;
205
-		} elseif (is_string($tag)) {
206
-			$tag = trim($tag);
207
-			if ($tag === '') {
208
-				$this->logger->debug(__METHOD__ . ' Cannot use empty tag names', ['app' => 'core']);
209
-				return false;
210
-			}
211
-			$tagId = $this->getTagId($tag);
212
-		}
213
-
214
-		if ($tagId === false) {
215
-			$l10n = \OCP\Util::getL10N('core');
216
-			throw new \Exception(
217
-				$l10n->t('Could not find category "%s"', [$tag])
218
-			);
219
-		}
220
-
221
-		$ids = [];
222
-		try {
223
-			$qb = $this->db->getQueryBuilder();
224
-			$qb->select('objid')
225
-				->from(self::RELATION_TABLE)
226
-				->where($qb->expr()->eq('categoryid', $qb->createNamedParameter($tagId, IQueryBuilder::PARAM_STR)));
227
-			$result = $qb->executeQuery();
228
-		} catch (Exception $e) {
229
-			$this->logger->error($e->getMessage(), [
230
-				'app' => 'core',
231
-				'exception' => $e,
232
-			]);
233
-			return false;
234
-		}
235
-
236
-		while ($row = $result->fetch()) {
237
-			$ids[] = (int)$row['objid'];
238
-		}
239
-		$result->closeCursor();
240
-
241
-		return $ids;
242
-	}
243
-
244
-	/**
245
-	 * Checks whether a tag is saved for the given user,
246
-	 * disregarding the ones shared with them.
247
-	 *
248
-	 * @param string $name The tag name to check for.
249
-	 * @param string $user The user whose tags are to be checked.
250
-	 */
251
-	public function userHasTag(string $name, string $user): bool {
252
-		return $this->array_searchi($name, $this->getTagsForUser($user)) !== false;
253
-	}
254
-
255
-	/**
256
-	 * Checks whether a tag is saved for or shared with the current user.
257
-	 *
258
-	 * @param string $name The tag name to check for.
259
-	 */
260
-	public function hasTag(string $name): bool {
261
-		return $this->getTagId($name) !== false;
262
-	}
263
-
264
-	/**
265
-	 * Add a new tag.
266
-	 *
267
-	 * @param string $name A string with a name of the tag
268
-	 * @return false|int the id of the added tag or false on error.
269
-	 */
270
-	public function add(string $name) {
271
-		$name = trim($name);
272
-
273
-		if ($name === '') {
274
-			$this->logger->debug(__METHOD__ . ' Cannot add an empty tag', ['app' => 'core']);
275
-			return false;
276
-		}
277
-		if ($this->userHasTag($name, $this->user)) {
278
-			$this->logger->debug(__METHOD__ . ' Tag with name already exists', ['app' => 'core']);
279
-			return false;
280
-		}
281
-		try {
282
-			$tag = new Tag($this->user, $this->type, $name);
283
-			$tag = $this->mapper->insert($tag);
284
-			$this->tags[] = $tag;
285
-		} catch (\Exception $e) {
286
-			$this->logger->error($e->getMessage(), [
287
-				'exception' => $e,
288
-				'app' => 'core',
289
-			]);
290
-			return false;
291
-		}
292
-		$this->logger->debug(__METHOD__ . ' Added an tag with ' . $tag->getId(), ['app' => 'core']);
293
-		return $tag->getId() ?? false;
294
-	}
295
-
296
-	/**
297
-	 * Rename tag.
298
-	 *
299
-	 * @param string|integer $from The name or ID of the existing tag
300
-	 * @param string $to The new name of the tag.
301
-	 * @return bool
302
-	 */
303
-	public function rename($from, string $to): bool {
304
-		$from = trim($from);
305
-		$to = trim($to);
306
-
307
-		if ($to === '' || $from === '') {
308
-			$this->logger->debug(__METHOD__ . 'Cannot use an empty tag names', ['app' => 'core']);
309
-			return false;
310
-		}
311
-
312
-		if (is_numeric($from)) {
313
-			$key = $this->getTagById($from);
314
-		} else {
315
-			$key = $this->getTagByName($from);
316
-		}
317
-		if ($key === false) {
318
-			$this->logger->debug(__METHOD__ . 'Tag ' . $from . 'does not exist', ['app' => 'core']);
319
-			return false;
320
-		}
321
-		$tag = $this->tags[$key];
322
-
323
-		if ($this->userHasTag($to, $tag->getOwner())) {
324
-			$this->logger->debug(__METHOD__ . 'A tag named' . $to . 'already exists for user' . $tag->getOwner(), ['app' => 'core']);
325
-			return false;
326
-		}
327
-
328
-		try {
329
-			$tag->setName($to);
330
-			$this->tags[$key] = $this->mapper->update($tag);
331
-		} catch (\Exception $e) {
332
-			$this->logger->error($e->getMessage(), [
333
-				'exception' => $e,
334
-				'app' => 'core',
335
-			]);
336
-			return false;
337
-		}
338
-		return true;
339
-	}
340
-
341
-	/**
342
-	 * Add a list of new tags.
343
-	 *
344
-	 * @param string|string[] $names A string with a name or an array of strings containing
345
-	 *                               the name(s) of the tag(s) to add.
346
-	 * @param bool $sync When true, save the tags
347
-	 * @param int|null $id int Optional object id to add to this|these tag(s)
348
-	 * @return bool Returns false on error.
349
-	 */
350
-	public function addMultiple($names, bool $sync = false, ?int $id = null): bool {
351
-		if (!is_array($names)) {
352
-			$names = [$names];
353
-		}
354
-		$names = array_map('trim', $names);
355
-		array_filter($names);
356
-
357
-		$newones = [];
358
-		foreach ($names as $name) {
359
-			if (!$this->hasTag($name) && $name !== '') {
360
-				$newones[] = new Tag($this->user, $this->type, $name);
361
-			}
362
-			if (!is_null($id)) {
363
-				// Insert $objectid, $categoryid  pairs if not exist.
364
-				self::$relations[] = ['objid' => $id, 'tag' => $name];
365
-			}
366
-		}
367
-		$this->tags = array_merge($this->tags, $newones);
368
-		if ($sync === true) {
369
-			$this->save();
370
-		}
371
-
372
-		return true;
373
-	}
374
-
375
-	/**
376
-	 * Save the list of tags and their object relations
377
-	 */
378
-	protected function save(): void {
379
-		foreach ($this->tags as $tag) {
380
-			try {
381
-				if (!$this->mapper->tagExists($tag)) {
382
-					$this->mapper->insert($tag);
383
-				}
384
-			} catch (\Exception $e) {
385
-				$this->logger->error($e->getMessage(), [
386
-					'exception' => $e,
387
-					'app' => 'core',
388
-				]);
389
-			}
390
-		}
391
-
392
-		// reload tags to get the proper ids.
393
-		$this->tags = $this->mapper->loadTags($this->owners, $this->type);
394
-		$this->logger->debug(__METHOD__ . 'tags' . print_r($this->tags, true), ['app' => 'core']);
395
-		// Loop through temporarily cached objectid/tagname pairs
396
-		// and save relations.
397
-		$tags = $this->tags;
398
-		// For some reason this is needed or array_search(i) will return 0..?
399
-		ksort($tags);
400
-		foreach (self::$relations as $relation) {
401
-			$tagId = $this->getTagId($relation['tag']);
402
-			$this->logger->debug(__METHOD__ . 'catid ' . $relation['tag'] . ' ' . $tagId, ['app' => 'core']);
403
-			if ($tagId) {
404
-				$qb = $this->db->getQueryBuilder();
405
-				$qb->insert(self::RELATION_TABLE)
406
-					->values([
407
-						'objid' => $qb->createNamedParameter($relation['objid'], IQueryBuilder::PARAM_INT),
408
-						'categoryid' => $qb->createNamedParameter($tagId, IQueryBuilder::PARAM_INT),
409
-						'type' => $qb->createNamedParameter($this->type),
410
-					]);
411
-				try {
412
-					$qb->executeStatement();
413
-				} catch (Exception $e) {
414
-					$this->logger->error($e->getMessage(), [
415
-						'exception' => $e,
416
-						'app' => 'core',
417
-					]);
418
-				}
419
-			}
420
-		}
421
-		self::$relations = []; // reset
422
-	}
423
-
424
-	/**
425
-	 * Delete tag/object relations from the db
426
-	 *
427
-	 * @param array $ids The ids of the objects
428
-	 * @return boolean Returns false on error.
429
-	 */
430
-	public function purgeObjects(array $ids): bool {
431
-		if (count($ids) === 0) {
432
-			// job done ;)
433
-			return true;
434
-		}
435
-		$updates = $ids;
436
-		$qb = $this->db->getQueryBuilder();
437
-		$qb->delete(self::RELATION_TABLE)
438
-			->where($qb->expr()->in('objid', $qb->createNamedParameter($ids)));
439
-		try {
440
-			$qb->executeStatement();
441
-		} catch (Exception $e) {
442
-			$this->logger->error($e->getMessage(), [
443
-				'app' => 'core',
444
-				'exception' => $e,
445
-			]);
446
-			return false;
447
-		}
448
-		return true;
449
-	}
450
-
451
-	/**
452
-	 * Get favorites for an object type
453
-	 *
454
-	 * @return array|false An array of object ids.
455
-	 */
456
-	public function getFavorites() {
457
-		if (!$this->userHasTag(ITags::TAG_FAVORITE, $this->user)) {
458
-			return [];
459
-		}
460
-
461
-		try {
462
-			return $this->getIdsForTag(ITags::TAG_FAVORITE);
463
-		} catch (\Exception $e) {
464
-			\OCP\Server::get(LoggerInterface::class)->error(
465
-				$e->getMessage(),
466
-				[
467
-					'app' => 'core',
468
-					'exception' => $e,
469
-				]
470
-			);
471
-			return [];
472
-		}
473
-	}
474
-
475
-	/**
476
-	 * Add an object to favorites
477
-	 *
478
-	 * @param int $objid The id of the object
479
-	 * @return boolean
480
-	 */
481
-	public function addToFavorites($objid) {
482
-		if (!$this->userHasTag(ITags::TAG_FAVORITE, $this->user)) {
483
-			$this->add(ITags::TAG_FAVORITE);
484
-		}
485
-		return $this->tagAs($objid, ITags::TAG_FAVORITE);
486
-	}
487
-
488
-	/**
489
-	 * Remove an object from favorites
490
-	 *
491
-	 * @param int $objid The id of the object
492
-	 * @return boolean
493
-	 */
494
-	public function removeFromFavorites($objid) {
495
-		return $this->unTag($objid, ITags::TAG_FAVORITE);
496
-	}
497
-
498
-	/**
499
-	 * Creates a tag/object relation.
500
-	 */
501
-	public function tagAs($objid, $tag, ?string $path = null) {
502
-		if (is_string($tag) && !is_numeric($tag)) {
503
-			$tag = trim($tag);
504
-			if ($tag === '') {
505
-				$this->logger->debug(__METHOD__ . ', Cannot add an empty tag');
506
-				return false;
507
-			}
508
-			if (!$this->hasTag($tag)) {
509
-				$this->add($tag);
510
-			}
511
-			$tagId = $this->getTagId($tag);
512
-		} else {
513
-			$tagId = $tag;
514
-		}
515
-		$qb = $this->db->getQueryBuilder();
516
-		$qb->insert(self::RELATION_TABLE)
517
-			->values([
518
-				'objid' => $qb->createNamedParameter($objid, IQueryBuilder::PARAM_INT),
519
-				'categoryid' => $qb->createNamedParameter($tagId, IQueryBuilder::PARAM_INT),
520
-				'type' => $qb->createNamedParameter($this->type, IQueryBuilder::PARAM_STR),
521
-			]);
522
-		try {
523
-			$qb->executeStatement();
524
-		} catch (\Exception $e) {
525
-			\OCP\Server::get(LoggerInterface::class)->error($e->getMessage(), [
526
-				'app' => 'core',
527
-				'exception' => $e,
528
-			]);
529
-			return false;
530
-		}
531
-		if ($tag === ITags::TAG_FAVORITE) {
532
-			if ($path === null) {
533
-				$node = $this->userFolder->getFirstNodeById($objid);
534
-				if ($node !== null) {
535
-					$path = $node->getPath();
536
-				} else {
537
-					throw new Exception('Failed to favorite: node with id ' . $objid . ' not found');
538
-				}
539
-			}
540
-
541
-			$this->dispatcher->dispatchTyped(new NodeAddedToFavorite($this->userSession->getUser(), $objid, $path));
542
-		}
543
-		return true;
544
-	}
545
-
546
-	/**
547
-	 * Delete single tag/object relation from the db
548
-	 */
549
-	public function unTag($objid, $tag, ?string $path = null) {
550
-		if (is_string($tag) && !is_numeric($tag)) {
551
-			$tag = trim($tag);
552
-			if ($tag === '') {
553
-				$this->logger->debug(__METHOD__ . ', Tag name is empty');
554
-				return false;
555
-			}
556
-			$tagId = $this->getTagId($tag);
557
-		} else {
558
-			$tagId = $tag;
559
-		}
560
-
561
-		try {
562
-			$qb = $this->db->getQueryBuilder();
563
-			$qb->delete(self::RELATION_TABLE)
564
-				->where($qb->expr()->andX(
565
-					$qb->expr()->eq('objid', $qb->createNamedParameter($objid)),
566
-					$qb->expr()->eq('categoryid', $qb->createNamedParameter($tagId)),
567
-					$qb->expr()->eq('type', $qb->createNamedParameter($this->type)),
568
-				))->executeStatement();
569
-		} catch (\Exception $e) {
570
-			$this->logger->error($e->getMessage(), [
571
-				'app' => 'core',
572
-				'exception' => $e,
573
-			]);
574
-			return false;
575
-		}
576
-		if ($tag === ITags::TAG_FAVORITE) {
577
-			if ($path === null) {
578
-				$node = $this->userFolder->getFirstNodeById($objid);
579
-				if ($node !== null) {
580
-					$path = $node->getPath();
581
-				} else {
582
-					throw new Exception('Failed to unfavorite: node with id ' . $objid . ' not found');
583
-				}
584
-			}
585
-
586
-			$this->dispatcher->dispatchTyped(new NodeRemovedFromFavorite($this->userSession->getUser(), $objid, $path));
587
-		}
588
-		return true;
589
-	}
590
-
591
-	/**
592
-	 * Delete tags from the database.
593
-	 *
594
-	 * @param string[]|integer[] $names An array of tags (names or IDs) to delete
595
-	 * @return bool Returns false on error
596
-	 */
597
-	public function delete($names) {
598
-		if (!is_array($names)) {
599
-			$names = [$names];
600
-		}
601
-
602
-		$names = array_map('trim', $names);
603
-		array_filter($names);
604
-
605
-		$this->logger->debug(__METHOD__ . ', before: ' . print_r($this->tags, true));
606
-		foreach ($names as $name) {
607
-			$id = null;
608
-
609
-			if (is_numeric($name)) {
610
-				$key = $this->getTagById($name);
611
-			} else {
612
-				$key = $this->getTagByName($name);
613
-			}
614
-			if ($key !== false) {
615
-				$tag = $this->tags[$key];
616
-				$id = $tag->getId();
617
-				unset($this->tags[$key]);
618
-				$this->mapper->delete($tag);
619
-			} else {
620
-				$this->logger->error(__METHOD__ . 'Cannot delete tag ' . $name . ': not found.');
621
-			}
622
-			if (!is_null($id) && $id !== false) {
623
-				try {
624
-					$qb = $this->db->getQueryBuilder();
625
-					$qb->delete(self::RELATION_TABLE)
626
-						->where($qb->expr()->eq('categoryid', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)))
627
-						->executeStatement();
628
-				} catch (\Exception $e) {
629
-					$this->logger->error($e->getMessage(), [
630
-						'app' => 'core',
631
-						'exception' => $e,
632
-					]);
633
-					return false;
634
-				}
635
-			}
636
-		}
637
-		return true;
638
-	}
639
-
640
-	// case-insensitive array_search
641
-	protected function array_searchi($needle, $haystack, $mem = 'getName') {
642
-		if (!is_array($haystack)) {
643
-			return false;
644
-		}
645
-		return array_search(strtolower($needle), array_map(
646
-			function ($tag) use ($mem) {
647
-				return strtolower(call_user_func([$tag, $mem]));
648
-			}, $haystack),
649
-			true
650
-		);
651
-	}
652
-
653
-	/**
654
-	 * Get a tag's ID.
655
-	 *
656
-	 * @param string $name The tag name to look for.
657
-	 * @return string|bool The tag's id or false if no matching tag is found.
658
-	 */
659
-	private function getTagId($name) {
660
-		$key = $this->array_searchi($name, $this->tags);
661
-		if ($key !== false) {
662
-			return $this->tags[$key]->getId();
663
-		}
664
-		return false;
665
-	}
666
-
667
-	/**
668
-	 * Get a tag by its name.
669
-	 *
670
-	 * @param string $name The tag name.
671
-	 * @return integer|bool The tag object's offset within the $this->tags
672
-	 *                      array or false if it doesn't exist.
673
-	 */
674
-	private function getTagByName($name) {
675
-		return $this->array_searchi($name, $this->tags, 'getName');
676
-	}
677
-
678
-	/**
679
-	 * Get a tag by its ID.
680
-	 *
681
-	 * @param string $id The tag ID to look for.
682
-	 * @return integer|bool The tag object's offset within the $this->tags
683
-	 *                      array or false if it doesn't exist.
684
-	 */
685
-	private function getTagById($id) {
686
-		return $this->array_searchi($id, $this->tags, 'getId');
687
-	}
688
-
689
-	/**
690
-	 * Returns an array mapping a given tag's properties to its values:
691
-	 * ['id' => 0, 'name' = 'Tag', 'owner' = 'User', 'type' => 'tagtype']
692
-	 *
693
-	 * @param Tag $tag The tag that is going to be mapped
694
-	 * @return array
695
-	 */
696
-	private function tagMap(Tag $tag) {
697
-		return [
698
-			'id' => $tag->getId(),
699
-			'name' => $tag->getName(),
700
-			'owner' => $tag->getOwner(),
701
-			'type' => $tag->getType()
702
-		];
703
-	}
25
+    /**
26
+     * Used for storing objectid/categoryname pairs while rescanning.
27
+     */
28
+    private static array $relations = [];
29
+    private array $tags = [];
30
+
31
+    /**
32
+     * Are we including tags for shared items?
33
+     */
34
+    private bool $includeShared = false;
35
+
36
+    /**
37
+     * The current user, plus any owners of the items shared with the current
38
+     * user, if $this->includeShared === true.
39
+     */
40
+    private array $owners = [];
41
+
42
+    /**
43
+     * The sharing backend for objects of $this->type. Required if
44
+     * $this->includeShared === true to determine ownership of items.
45
+     */
46
+    private ?Share_Backend $backend = null;
47
+
48
+    public const TAG_TABLE = 'vcategory';
49
+    public const RELATION_TABLE = 'vcategory_to_object';
50
+
51
+    /**
52
+     * Constructor.
53
+     *
54
+     * @param TagMapper $mapper Instance of the TagMapper abstraction layer.
55
+     * @param string $user The user whose data the object will operate on.
56
+     * @param string $type The type of items for which tags will be loaded.
57
+     * @param array $defaultTags Tags that should be created at construction.
58
+     *
59
+     * since 20.0.0 $includeShared isn't used anymore
60
+     */
61
+    public function __construct(
62
+        private TagMapper $mapper,
63
+        private string $user,
64
+        private string $type,
65
+        private LoggerInterface $logger,
66
+        private IDBConnection $db,
67
+        private IEventDispatcher $dispatcher,
68
+        private IUserSession $userSession,
69
+        private Folder $userFolder,
70
+        array $defaultTags = [],
71
+    ) {
72
+        $this->owners = [$this->user];
73
+        $this->tags = $this->mapper->loadTags($this->owners, $this->type);
74
+
75
+        if (count($defaultTags) > 0 && count($this->tags) === 0) {
76
+            $this->addMultiple($defaultTags, true);
77
+        }
78
+    }
79
+
80
+    /**
81
+     * Check if any tags are saved for this type and user.
82
+     *
83
+     * @return boolean
84
+     */
85
+    public function isEmpty(): bool {
86
+        return count($this->tags) === 0;
87
+    }
88
+
89
+    /**
90
+     * Returns an array mapping a given tag's properties to its values:
91
+     * ['id' => 0, 'name' = 'Tag', 'owner' = 'User', 'type' => 'tagtype']
92
+     *
93
+     * @param string $id The ID of the tag that is going to be mapped
94
+     * @return array|false
95
+     */
96
+    public function getTag(string $id) {
97
+        $key = $this->getTagById($id);
98
+        if ($key !== false) {
99
+            return $this->tagMap($this->tags[$key]);
100
+        }
101
+        return false;
102
+    }
103
+
104
+    /**
105
+     * Get the tags for a specific user.
106
+     *
107
+     * This returns an array with maps containing each tag's properties:
108
+     * [
109
+     * 	['id' => 0, 'name' = 'First tag', 'owner' = 'User', 'type' => 'tagtype'],
110
+     * 	['id' => 1, 'name' = 'Shared tag', 'owner' = 'Other user', 'type' => 'tagtype'],
111
+     * ]
112
+     *
113
+     * @return array<array-key, array{id: int, name: string}>
114
+     */
115
+    public function getTags(): array {
116
+        if (!count($this->tags)) {
117
+            return [];
118
+        }
119
+
120
+        usort($this->tags, function ($a, $b) {
121
+            return strnatcasecmp($a->getName(), $b->getName());
122
+        });
123
+        $tagMap = [];
124
+
125
+        foreach ($this->tags as $tag) {
126
+            if ($tag->getName() !== ITags::TAG_FAVORITE) {
127
+                $tagMap[] = $this->tagMap($tag);
128
+            }
129
+        }
130
+        return $tagMap;
131
+    }
132
+
133
+    /**
134
+     * Return only the tags owned by the given user, omitting any tags shared
135
+     * by other users.
136
+     *
137
+     * @param string $user The user whose tags are to be checked.
138
+     * @return array An array of Tag objects.
139
+     */
140
+    public function getTagsForUser(string $user): array {
141
+        return array_filter($this->tags,
142
+            function ($tag) use ($user) {
143
+                return $tag->getOwner() === $user;
144
+            }
145
+        );
146
+    }
147
+
148
+    /**
149
+     * Get the list of tags for the given ids.
150
+     *
151
+     * @param list<int> $objIds array of object ids
152
+     * @return array<int, list<string>>|false of tags id as key to array of tag names
153
+     *                                        or false if an error occurred
154
+     */
155
+    public function getTagsForObjects(array $objIds) {
156
+        $entries = [];
157
+
158
+        try {
159
+            $chunks = array_chunk($objIds, 900, false);
160
+            $qb = $this->db->getQueryBuilder();
161
+            $qb->select('category', 'categoryid', 'objid')
162
+                ->from(self::RELATION_TABLE, 'r')
163
+                ->join('r', self::TAG_TABLE, 't', $qb->expr()->eq('r.categoryid', 't.id'))
164
+                ->where($qb->expr()->eq('uid', $qb->createParameter('uid')))
165
+                ->andWhere($qb->expr()->eq('r.type', $qb->createParameter('type')))
166
+                ->andWhere($qb->expr()->in('objid', $qb->createParameter('chunk')));
167
+            foreach ($chunks as $chunk) {
168
+                $qb->setParameter('uid', $this->user, IQueryBuilder::PARAM_STR);
169
+                $qb->setParameter('type', $this->type, IQueryBuilder::PARAM_STR);
170
+                $qb->setParameter('chunk', $chunk, IQueryBuilder::PARAM_INT_ARRAY);
171
+                $result = $qb->executeQuery();
172
+                while ($row = $result->fetch()) {
173
+                    $objId = (int)$row['objid'];
174
+                    if (!isset($entries[$objId])) {
175
+                        $entries[$objId] = [];
176
+                    }
177
+                    $entries[$objId][] = $row['category'];
178
+                }
179
+                $result->closeCursor();
180
+            }
181
+        } catch (\Exception $e) {
182
+            $this->logger->error($e->getMessage(), [
183
+                'exception' => $e,
184
+                'app' => 'core',
185
+            ]);
186
+            return false;
187
+        }
188
+
189
+        return $entries;
190
+    }
191
+
192
+    /**
193
+     * Get the a list if items tagged with $tag.
194
+     *
195
+     * Throws an exception if the tag could not be found.
196
+     *
197
+     * @param string $tag Tag id or name.
198
+     * @return int[]|false An array of object ids or false on error.
199
+     * @throws \Exception
200
+     */
201
+    public function getIdsForTag($tag) {
202
+        $tagId = false;
203
+        if (is_numeric($tag)) {
204
+            $tagId = $tag;
205
+        } elseif (is_string($tag)) {
206
+            $tag = trim($tag);
207
+            if ($tag === '') {
208
+                $this->logger->debug(__METHOD__ . ' Cannot use empty tag names', ['app' => 'core']);
209
+                return false;
210
+            }
211
+            $tagId = $this->getTagId($tag);
212
+        }
213
+
214
+        if ($tagId === false) {
215
+            $l10n = \OCP\Util::getL10N('core');
216
+            throw new \Exception(
217
+                $l10n->t('Could not find category "%s"', [$tag])
218
+            );
219
+        }
220
+
221
+        $ids = [];
222
+        try {
223
+            $qb = $this->db->getQueryBuilder();
224
+            $qb->select('objid')
225
+                ->from(self::RELATION_TABLE)
226
+                ->where($qb->expr()->eq('categoryid', $qb->createNamedParameter($tagId, IQueryBuilder::PARAM_STR)));
227
+            $result = $qb->executeQuery();
228
+        } catch (Exception $e) {
229
+            $this->logger->error($e->getMessage(), [
230
+                'app' => 'core',
231
+                'exception' => $e,
232
+            ]);
233
+            return false;
234
+        }
235
+
236
+        while ($row = $result->fetch()) {
237
+            $ids[] = (int)$row['objid'];
238
+        }
239
+        $result->closeCursor();
240
+
241
+        return $ids;
242
+    }
243
+
244
+    /**
245
+     * Checks whether a tag is saved for the given user,
246
+     * disregarding the ones shared with them.
247
+     *
248
+     * @param string $name The tag name to check for.
249
+     * @param string $user The user whose tags are to be checked.
250
+     */
251
+    public function userHasTag(string $name, string $user): bool {
252
+        return $this->array_searchi($name, $this->getTagsForUser($user)) !== false;
253
+    }
254
+
255
+    /**
256
+     * Checks whether a tag is saved for or shared with the current user.
257
+     *
258
+     * @param string $name The tag name to check for.
259
+     */
260
+    public function hasTag(string $name): bool {
261
+        return $this->getTagId($name) !== false;
262
+    }
263
+
264
+    /**
265
+     * Add a new tag.
266
+     *
267
+     * @param string $name A string with a name of the tag
268
+     * @return false|int the id of the added tag or false on error.
269
+     */
270
+    public function add(string $name) {
271
+        $name = trim($name);
272
+
273
+        if ($name === '') {
274
+            $this->logger->debug(__METHOD__ . ' Cannot add an empty tag', ['app' => 'core']);
275
+            return false;
276
+        }
277
+        if ($this->userHasTag($name, $this->user)) {
278
+            $this->logger->debug(__METHOD__ . ' Tag with name already exists', ['app' => 'core']);
279
+            return false;
280
+        }
281
+        try {
282
+            $tag = new Tag($this->user, $this->type, $name);
283
+            $tag = $this->mapper->insert($tag);
284
+            $this->tags[] = $tag;
285
+        } catch (\Exception $e) {
286
+            $this->logger->error($e->getMessage(), [
287
+                'exception' => $e,
288
+                'app' => 'core',
289
+            ]);
290
+            return false;
291
+        }
292
+        $this->logger->debug(__METHOD__ . ' Added an tag with ' . $tag->getId(), ['app' => 'core']);
293
+        return $tag->getId() ?? false;
294
+    }
295
+
296
+    /**
297
+     * Rename tag.
298
+     *
299
+     * @param string|integer $from The name or ID of the existing tag
300
+     * @param string $to The new name of the tag.
301
+     * @return bool
302
+     */
303
+    public function rename($from, string $to): bool {
304
+        $from = trim($from);
305
+        $to = trim($to);
306
+
307
+        if ($to === '' || $from === '') {
308
+            $this->logger->debug(__METHOD__ . 'Cannot use an empty tag names', ['app' => 'core']);
309
+            return false;
310
+        }
311
+
312
+        if (is_numeric($from)) {
313
+            $key = $this->getTagById($from);
314
+        } else {
315
+            $key = $this->getTagByName($from);
316
+        }
317
+        if ($key === false) {
318
+            $this->logger->debug(__METHOD__ . 'Tag ' . $from . 'does not exist', ['app' => 'core']);
319
+            return false;
320
+        }
321
+        $tag = $this->tags[$key];
322
+
323
+        if ($this->userHasTag($to, $tag->getOwner())) {
324
+            $this->logger->debug(__METHOD__ . 'A tag named' . $to . 'already exists for user' . $tag->getOwner(), ['app' => 'core']);
325
+            return false;
326
+        }
327
+
328
+        try {
329
+            $tag->setName($to);
330
+            $this->tags[$key] = $this->mapper->update($tag);
331
+        } catch (\Exception $e) {
332
+            $this->logger->error($e->getMessage(), [
333
+                'exception' => $e,
334
+                'app' => 'core',
335
+            ]);
336
+            return false;
337
+        }
338
+        return true;
339
+    }
340
+
341
+    /**
342
+     * Add a list of new tags.
343
+     *
344
+     * @param string|string[] $names A string with a name or an array of strings containing
345
+     *                               the name(s) of the tag(s) to add.
346
+     * @param bool $sync When true, save the tags
347
+     * @param int|null $id int Optional object id to add to this|these tag(s)
348
+     * @return bool Returns false on error.
349
+     */
350
+    public function addMultiple($names, bool $sync = false, ?int $id = null): bool {
351
+        if (!is_array($names)) {
352
+            $names = [$names];
353
+        }
354
+        $names = array_map('trim', $names);
355
+        array_filter($names);
356
+
357
+        $newones = [];
358
+        foreach ($names as $name) {
359
+            if (!$this->hasTag($name) && $name !== '') {
360
+                $newones[] = new Tag($this->user, $this->type, $name);
361
+            }
362
+            if (!is_null($id)) {
363
+                // Insert $objectid, $categoryid  pairs if not exist.
364
+                self::$relations[] = ['objid' => $id, 'tag' => $name];
365
+            }
366
+        }
367
+        $this->tags = array_merge($this->tags, $newones);
368
+        if ($sync === true) {
369
+            $this->save();
370
+        }
371
+
372
+        return true;
373
+    }
374
+
375
+    /**
376
+     * Save the list of tags and their object relations
377
+     */
378
+    protected function save(): void {
379
+        foreach ($this->tags as $tag) {
380
+            try {
381
+                if (!$this->mapper->tagExists($tag)) {
382
+                    $this->mapper->insert($tag);
383
+                }
384
+            } catch (\Exception $e) {
385
+                $this->logger->error($e->getMessage(), [
386
+                    'exception' => $e,
387
+                    'app' => 'core',
388
+                ]);
389
+            }
390
+        }
391
+
392
+        // reload tags to get the proper ids.
393
+        $this->tags = $this->mapper->loadTags($this->owners, $this->type);
394
+        $this->logger->debug(__METHOD__ . 'tags' . print_r($this->tags, true), ['app' => 'core']);
395
+        // Loop through temporarily cached objectid/tagname pairs
396
+        // and save relations.
397
+        $tags = $this->tags;
398
+        // For some reason this is needed or array_search(i) will return 0..?
399
+        ksort($tags);
400
+        foreach (self::$relations as $relation) {
401
+            $tagId = $this->getTagId($relation['tag']);
402
+            $this->logger->debug(__METHOD__ . 'catid ' . $relation['tag'] . ' ' . $tagId, ['app' => 'core']);
403
+            if ($tagId) {
404
+                $qb = $this->db->getQueryBuilder();
405
+                $qb->insert(self::RELATION_TABLE)
406
+                    ->values([
407
+                        'objid' => $qb->createNamedParameter($relation['objid'], IQueryBuilder::PARAM_INT),
408
+                        'categoryid' => $qb->createNamedParameter($tagId, IQueryBuilder::PARAM_INT),
409
+                        'type' => $qb->createNamedParameter($this->type),
410
+                    ]);
411
+                try {
412
+                    $qb->executeStatement();
413
+                } catch (Exception $e) {
414
+                    $this->logger->error($e->getMessage(), [
415
+                        'exception' => $e,
416
+                        'app' => 'core',
417
+                    ]);
418
+                }
419
+            }
420
+        }
421
+        self::$relations = []; // reset
422
+    }
423
+
424
+    /**
425
+     * Delete tag/object relations from the db
426
+     *
427
+     * @param array $ids The ids of the objects
428
+     * @return boolean Returns false on error.
429
+     */
430
+    public function purgeObjects(array $ids): bool {
431
+        if (count($ids) === 0) {
432
+            // job done ;)
433
+            return true;
434
+        }
435
+        $updates = $ids;
436
+        $qb = $this->db->getQueryBuilder();
437
+        $qb->delete(self::RELATION_TABLE)
438
+            ->where($qb->expr()->in('objid', $qb->createNamedParameter($ids)));
439
+        try {
440
+            $qb->executeStatement();
441
+        } catch (Exception $e) {
442
+            $this->logger->error($e->getMessage(), [
443
+                'app' => 'core',
444
+                'exception' => $e,
445
+            ]);
446
+            return false;
447
+        }
448
+        return true;
449
+    }
450
+
451
+    /**
452
+     * Get favorites for an object type
453
+     *
454
+     * @return array|false An array of object ids.
455
+     */
456
+    public function getFavorites() {
457
+        if (!$this->userHasTag(ITags::TAG_FAVORITE, $this->user)) {
458
+            return [];
459
+        }
460
+
461
+        try {
462
+            return $this->getIdsForTag(ITags::TAG_FAVORITE);
463
+        } catch (\Exception $e) {
464
+            \OCP\Server::get(LoggerInterface::class)->error(
465
+                $e->getMessage(),
466
+                [
467
+                    'app' => 'core',
468
+                    'exception' => $e,
469
+                ]
470
+            );
471
+            return [];
472
+        }
473
+    }
474
+
475
+    /**
476
+     * Add an object to favorites
477
+     *
478
+     * @param int $objid The id of the object
479
+     * @return boolean
480
+     */
481
+    public function addToFavorites($objid) {
482
+        if (!$this->userHasTag(ITags::TAG_FAVORITE, $this->user)) {
483
+            $this->add(ITags::TAG_FAVORITE);
484
+        }
485
+        return $this->tagAs($objid, ITags::TAG_FAVORITE);
486
+    }
487
+
488
+    /**
489
+     * Remove an object from favorites
490
+     *
491
+     * @param int $objid The id of the object
492
+     * @return boolean
493
+     */
494
+    public function removeFromFavorites($objid) {
495
+        return $this->unTag($objid, ITags::TAG_FAVORITE);
496
+    }
497
+
498
+    /**
499
+     * Creates a tag/object relation.
500
+     */
501
+    public function tagAs($objid, $tag, ?string $path = null) {
502
+        if (is_string($tag) && !is_numeric($tag)) {
503
+            $tag = trim($tag);
504
+            if ($tag === '') {
505
+                $this->logger->debug(__METHOD__ . ', Cannot add an empty tag');
506
+                return false;
507
+            }
508
+            if (!$this->hasTag($tag)) {
509
+                $this->add($tag);
510
+            }
511
+            $tagId = $this->getTagId($tag);
512
+        } else {
513
+            $tagId = $tag;
514
+        }
515
+        $qb = $this->db->getQueryBuilder();
516
+        $qb->insert(self::RELATION_TABLE)
517
+            ->values([
518
+                'objid' => $qb->createNamedParameter($objid, IQueryBuilder::PARAM_INT),
519
+                'categoryid' => $qb->createNamedParameter($tagId, IQueryBuilder::PARAM_INT),
520
+                'type' => $qb->createNamedParameter($this->type, IQueryBuilder::PARAM_STR),
521
+            ]);
522
+        try {
523
+            $qb->executeStatement();
524
+        } catch (\Exception $e) {
525
+            \OCP\Server::get(LoggerInterface::class)->error($e->getMessage(), [
526
+                'app' => 'core',
527
+                'exception' => $e,
528
+            ]);
529
+            return false;
530
+        }
531
+        if ($tag === ITags::TAG_FAVORITE) {
532
+            if ($path === null) {
533
+                $node = $this->userFolder->getFirstNodeById($objid);
534
+                if ($node !== null) {
535
+                    $path = $node->getPath();
536
+                } else {
537
+                    throw new Exception('Failed to favorite: node with id ' . $objid . ' not found');
538
+                }
539
+            }
540
+
541
+            $this->dispatcher->dispatchTyped(new NodeAddedToFavorite($this->userSession->getUser(), $objid, $path));
542
+        }
543
+        return true;
544
+    }
545
+
546
+    /**
547
+     * Delete single tag/object relation from the db
548
+     */
549
+    public function unTag($objid, $tag, ?string $path = null) {
550
+        if (is_string($tag) && !is_numeric($tag)) {
551
+            $tag = trim($tag);
552
+            if ($tag === '') {
553
+                $this->logger->debug(__METHOD__ . ', Tag name is empty');
554
+                return false;
555
+            }
556
+            $tagId = $this->getTagId($tag);
557
+        } else {
558
+            $tagId = $tag;
559
+        }
560
+
561
+        try {
562
+            $qb = $this->db->getQueryBuilder();
563
+            $qb->delete(self::RELATION_TABLE)
564
+                ->where($qb->expr()->andX(
565
+                    $qb->expr()->eq('objid', $qb->createNamedParameter($objid)),
566
+                    $qb->expr()->eq('categoryid', $qb->createNamedParameter($tagId)),
567
+                    $qb->expr()->eq('type', $qb->createNamedParameter($this->type)),
568
+                ))->executeStatement();
569
+        } catch (\Exception $e) {
570
+            $this->logger->error($e->getMessage(), [
571
+                'app' => 'core',
572
+                'exception' => $e,
573
+            ]);
574
+            return false;
575
+        }
576
+        if ($tag === ITags::TAG_FAVORITE) {
577
+            if ($path === null) {
578
+                $node = $this->userFolder->getFirstNodeById($objid);
579
+                if ($node !== null) {
580
+                    $path = $node->getPath();
581
+                } else {
582
+                    throw new Exception('Failed to unfavorite: node with id ' . $objid . ' not found');
583
+                }
584
+            }
585
+
586
+            $this->dispatcher->dispatchTyped(new NodeRemovedFromFavorite($this->userSession->getUser(), $objid, $path));
587
+        }
588
+        return true;
589
+    }
590
+
591
+    /**
592
+     * Delete tags from the database.
593
+     *
594
+     * @param string[]|integer[] $names An array of tags (names or IDs) to delete
595
+     * @return bool Returns false on error
596
+     */
597
+    public function delete($names) {
598
+        if (!is_array($names)) {
599
+            $names = [$names];
600
+        }
601
+
602
+        $names = array_map('trim', $names);
603
+        array_filter($names);
604
+
605
+        $this->logger->debug(__METHOD__ . ', before: ' . print_r($this->tags, true));
606
+        foreach ($names as $name) {
607
+            $id = null;
608
+
609
+            if (is_numeric($name)) {
610
+                $key = $this->getTagById($name);
611
+            } else {
612
+                $key = $this->getTagByName($name);
613
+            }
614
+            if ($key !== false) {
615
+                $tag = $this->tags[$key];
616
+                $id = $tag->getId();
617
+                unset($this->tags[$key]);
618
+                $this->mapper->delete($tag);
619
+            } else {
620
+                $this->logger->error(__METHOD__ . 'Cannot delete tag ' . $name . ': not found.');
621
+            }
622
+            if (!is_null($id) && $id !== false) {
623
+                try {
624
+                    $qb = $this->db->getQueryBuilder();
625
+                    $qb->delete(self::RELATION_TABLE)
626
+                        ->where($qb->expr()->eq('categoryid', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)))
627
+                        ->executeStatement();
628
+                } catch (\Exception $e) {
629
+                    $this->logger->error($e->getMessage(), [
630
+                        'app' => 'core',
631
+                        'exception' => $e,
632
+                    ]);
633
+                    return false;
634
+                }
635
+            }
636
+        }
637
+        return true;
638
+    }
639
+
640
+    // case-insensitive array_search
641
+    protected function array_searchi($needle, $haystack, $mem = 'getName') {
642
+        if (!is_array($haystack)) {
643
+            return false;
644
+        }
645
+        return array_search(strtolower($needle), array_map(
646
+            function ($tag) use ($mem) {
647
+                return strtolower(call_user_func([$tag, $mem]));
648
+            }, $haystack),
649
+            true
650
+        );
651
+    }
652
+
653
+    /**
654
+     * Get a tag's ID.
655
+     *
656
+     * @param string $name The tag name to look for.
657
+     * @return string|bool The tag's id or false if no matching tag is found.
658
+     */
659
+    private function getTagId($name) {
660
+        $key = $this->array_searchi($name, $this->tags);
661
+        if ($key !== false) {
662
+            return $this->tags[$key]->getId();
663
+        }
664
+        return false;
665
+    }
666
+
667
+    /**
668
+     * Get a tag by its name.
669
+     *
670
+     * @param string $name The tag name.
671
+     * @return integer|bool The tag object's offset within the $this->tags
672
+     *                      array or false if it doesn't exist.
673
+     */
674
+    private function getTagByName($name) {
675
+        return $this->array_searchi($name, $this->tags, 'getName');
676
+    }
677
+
678
+    /**
679
+     * Get a tag by its ID.
680
+     *
681
+     * @param string $id The tag ID to look for.
682
+     * @return integer|bool The tag object's offset within the $this->tags
683
+     *                      array or false if it doesn't exist.
684
+     */
685
+    private function getTagById($id) {
686
+        return $this->array_searchi($id, $this->tags, 'getId');
687
+    }
688
+
689
+    /**
690
+     * Returns an array mapping a given tag's properties to its values:
691
+     * ['id' => 0, 'name' = 'Tag', 'owner' = 'User', 'type' => 'tagtype']
692
+     *
693
+     * @param Tag $tag The tag that is going to be mapped
694
+     * @return array
695
+     */
696
+    private function tagMap(Tag $tag) {
697
+        return [
698
+            'id' => $tag->getId(),
699
+            'name' => $tag->getName(),
700
+            'owner' => $tag->getOwner(),
701
+            'type' => $tag->getType()
702
+        ];
703
+    }
704 704
 }
Please login to merge, or discard this patch.
Spacing   +20 added lines, -20 removed lines patch added patch discarded remove patch
@@ -117,7 +117,7 @@  discard block
 block discarded – undo
117 117
 			return [];
118 118
 		}
119 119
 
120
-		usort($this->tags, function ($a, $b) {
120
+		usort($this->tags, function($a, $b) {
121 121
 			return strnatcasecmp($a->getName(), $b->getName());
122 122
 		});
123 123
 		$tagMap = [];
@@ -139,7 +139,7 @@  discard block
 block discarded – undo
139 139
 	 */
140 140
 	public function getTagsForUser(string $user): array {
141 141
 		return array_filter($this->tags,
142
-			function ($tag) use ($user) {
142
+			function($tag) use ($user) {
143 143
 				return $tag->getOwner() === $user;
144 144
 			}
145 145
 		);
@@ -170,7 +170,7 @@  discard block
 block discarded – undo
170 170
 				$qb->setParameter('chunk', $chunk, IQueryBuilder::PARAM_INT_ARRAY);
171 171
 				$result = $qb->executeQuery();
172 172
 				while ($row = $result->fetch()) {
173
-					$objId = (int)$row['objid'];
173
+					$objId = (int) $row['objid'];
174 174
 					if (!isset($entries[$objId])) {
175 175
 						$entries[$objId] = [];
176 176
 					}
@@ -205,7 +205,7 @@  discard block
 block discarded – undo
205 205
 		} elseif (is_string($tag)) {
206 206
 			$tag = trim($tag);
207 207
 			if ($tag === '') {
208
-				$this->logger->debug(__METHOD__ . ' Cannot use empty tag names', ['app' => 'core']);
208
+				$this->logger->debug(__METHOD__.' Cannot use empty tag names', ['app' => 'core']);
209 209
 				return false;
210 210
 			}
211 211
 			$tagId = $this->getTagId($tag);
@@ -234,7 +234,7 @@  discard block
 block discarded – undo
234 234
 		}
235 235
 
236 236
 		while ($row = $result->fetch()) {
237
-			$ids[] = (int)$row['objid'];
237
+			$ids[] = (int) $row['objid'];
238 238
 		}
239 239
 		$result->closeCursor();
240 240
 
@@ -271,11 +271,11 @@  discard block
 block discarded – undo
271 271
 		$name = trim($name);
272 272
 
273 273
 		if ($name === '') {
274
-			$this->logger->debug(__METHOD__ . ' Cannot add an empty tag', ['app' => 'core']);
274
+			$this->logger->debug(__METHOD__.' Cannot add an empty tag', ['app' => 'core']);
275 275
 			return false;
276 276
 		}
277 277
 		if ($this->userHasTag($name, $this->user)) {
278
-			$this->logger->debug(__METHOD__ . ' Tag with name already exists', ['app' => 'core']);
278
+			$this->logger->debug(__METHOD__.' Tag with name already exists', ['app' => 'core']);
279 279
 			return false;
280 280
 		}
281 281
 		try {
@@ -289,7 +289,7 @@  discard block
 block discarded – undo
289 289
 			]);
290 290
 			return false;
291 291
 		}
292
-		$this->logger->debug(__METHOD__ . ' Added an tag with ' . $tag->getId(), ['app' => 'core']);
292
+		$this->logger->debug(__METHOD__.' Added an tag with '.$tag->getId(), ['app' => 'core']);
293 293
 		return $tag->getId() ?? false;
294 294
 	}
295 295
 
@@ -305,7 +305,7 @@  discard block
 block discarded – undo
305 305
 		$to = trim($to);
306 306
 
307 307
 		if ($to === '' || $from === '') {
308
-			$this->logger->debug(__METHOD__ . 'Cannot use an empty tag names', ['app' => 'core']);
308
+			$this->logger->debug(__METHOD__.'Cannot use an empty tag names', ['app' => 'core']);
309 309
 			return false;
310 310
 		}
311 311
 
@@ -315,13 +315,13 @@  discard block
 block discarded – undo
315 315
 			$key = $this->getTagByName($from);
316 316
 		}
317 317
 		if ($key === false) {
318
-			$this->logger->debug(__METHOD__ . 'Tag ' . $from . 'does not exist', ['app' => 'core']);
318
+			$this->logger->debug(__METHOD__.'Tag '.$from.'does not exist', ['app' => 'core']);
319 319
 			return false;
320 320
 		}
321 321
 		$tag = $this->tags[$key];
322 322
 
323 323
 		if ($this->userHasTag($to, $tag->getOwner())) {
324
-			$this->logger->debug(__METHOD__ . 'A tag named' . $to . 'already exists for user' . $tag->getOwner(), ['app' => 'core']);
324
+			$this->logger->debug(__METHOD__.'A tag named'.$to.'already exists for user'.$tag->getOwner(), ['app' => 'core']);
325 325
 			return false;
326 326
 		}
327 327
 
@@ -391,7 +391,7 @@  discard block
 block discarded – undo
391 391
 
392 392
 		// reload tags to get the proper ids.
393 393
 		$this->tags = $this->mapper->loadTags($this->owners, $this->type);
394
-		$this->logger->debug(__METHOD__ . 'tags' . print_r($this->tags, true), ['app' => 'core']);
394
+		$this->logger->debug(__METHOD__.'tags'.print_r($this->tags, true), ['app' => 'core']);
395 395
 		// Loop through temporarily cached objectid/tagname pairs
396 396
 		// and save relations.
397 397
 		$tags = $this->tags;
@@ -399,7 +399,7 @@  discard block
 block discarded – undo
399 399
 		ksort($tags);
400 400
 		foreach (self::$relations as $relation) {
401 401
 			$tagId = $this->getTagId($relation['tag']);
402
-			$this->logger->debug(__METHOD__ . 'catid ' . $relation['tag'] . ' ' . $tagId, ['app' => 'core']);
402
+			$this->logger->debug(__METHOD__.'catid '.$relation['tag'].' '.$tagId, ['app' => 'core']);
403 403
 			if ($tagId) {
404 404
 				$qb = $this->db->getQueryBuilder();
405 405
 				$qb->insert(self::RELATION_TABLE)
@@ -502,7 +502,7 @@  discard block
 block discarded – undo
502 502
 		if (is_string($tag) && !is_numeric($tag)) {
503 503
 			$tag = trim($tag);
504 504
 			if ($tag === '') {
505
-				$this->logger->debug(__METHOD__ . ', Cannot add an empty tag');
505
+				$this->logger->debug(__METHOD__.', Cannot add an empty tag');
506 506
 				return false;
507 507
 			}
508 508
 			if (!$this->hasTag($tag)) {
@@ -534,7 +534,7 @@  discard block
 block discarded – undo
534 534
 				if ($node !== null) {
535 535
 					$path = $node->getPath();
536 536
 				} else {
537
-					throw new Exception('Failed to favorite: node with id ' . $objid . ' not found');
537
+					throw new Exception('Failed to favorite: node with id '.$objid.' not found');
538 538
 				}
539 539
 			}
540 540
 
@@ -550,7 +550,7 @@  discard block
 block discarded – undo
550 550
 		if (is_string($tag) && !is_numeric($tag)) {
551 551
 			$tag = trim($tag);
552 552
 			if ($tag === '') {
553
-				$this->logger->debug(__METHOD__ . ', Tag name is empty');
553
+				$this->logger->debug(__METHOD__.', Tag name is empty');
554 554
 				return false;
555 555
 			}
556 556
 			$tagId = $this->getTagId($tag);
@@ -579,7 +579,7 @@  discard block
 block discarded – undo
579 579
 				if ($node !== null) {
580 580
 					$path = $node->getPath();
581 581
 				} else {
582
-					throw new Exception('Failed to unfavorite: node with id ' . $objid . ' not found');
582
+					throw new Exception('Failed to unfavorite: node with id '.$objid.' not found');
583 583
 				}
584 584
 			}
585 585
 
@@ -602,7 +602,7 @@  discard block
 block discarded – undo
602 602
 		$names = array_map('trim', $names);
603 603
 		array_filter($names);
604 604
 
605
-		$this->logger->debug(__METHOD__ . ', before: ' . print_r($this->tags, true));
605
+		$this->logger->debug(__METHOD__.', before: '.print_r($this->tags, true));
606 606
 		foreach ($names as $name) {
607 607
 			$id = null;
608 608
 
@@ -617,7 +617,7 @@  discard block
 block discarded – undo
617 617
 				unset($this->tags[$key]);
618 618
 				$this->mapper->delete($tag);
619 619
 			} else {
620
-				$this->logger->error(__METHOD__ . 'Cannot delete tag ' . $name . ': not found.');
620
+				$this->logger->error(__METHOD__.'Cannot delete tag '.$name.': not found.');
621 621
 			}
622 622
 			if (!is_null($id) && $id !== false) {
623 623
 				try {
@@ -643,7 +643,7 @@  discard block
 block discarded – undo
643 643
 			return false;
644 644
 		}
645 645
 		return array_search(strtolower($needle), array_map(
646
-			function ($tag) use ($mem) {
646
+			function($tag) use ($mem) {
647 647
 				return strtolower(call_user_func([$tag, $mem]));
648 648
 			}, $haystack),
649 649
 			true
Please login to merge, or discard this patch.
lib/base.php 1 patch
Indentation   +1182 added lines, -1182 removed lines patch added patch discarded remove patch
@@ -40,1188 +40,1188 @@
 block discarded – undo
40 40
  * OC_autoload!
41 41
  */
42 42
 class OC {
43
-	/**
44
-	 * The installation path for Nextcloud  on the server (e.g. /srv/http/nextcloud)
45
-	 */
46
-	public static string $SERVERROOT = '';
47
-	/**
48
-	 * the current request path relative to the Nextcloud root (e.g. files/index.php)
49
-	 */
50
-	private static string $SUBURI = '';
51
-	/**
52
-	 * the Nextcloud root path for http requests (e.g. /nextcloud)
53
-	 */
54
-	public static string $WEBROOT = '';
55
-	/**
56
-	 * The installation path array of the apps folder on the server (e.g. /srv/http/nextcloud) 'path' and
57
-	 * web path in 'url'
58
-	 */
59
-	public static array $APPSROOTS = [];
60
-
61
-	public static string $configDir;
62
-
63
-	/**
64
-	 * requested app
65
-	 */
66
-	public static string $REQUESTEDAPP = '';
67
-
68
-	/**
69
-	 * check if Nextcloud runs in cli mode
70
-	 */
71
-	public static bool $CLI = false;
72
-
73
-	public static \Composer\Autoload\ClassLoader $composerAutoloader;
74
-
75
-	public static \OC\Server $server;
76
-
77
-	private static \OC\Config $config;
78
-
79
-	/**
80
-	 * @throws \RuntimeException when the 3rdparty directory is missing or
81
-	 *                           the app path list is empty or contains an invalid path
82
-	 */
83
-	public static function initPaths(): void {
84
-		if (defined('PHPUNIT_CONFIG_DIR')) {
85
-			self::$configDir = OC::$SERVERROOT . '/' . PHPUNIT_CONFIG_DIR . '/';
86
-		} elseif (defined('PHPUNIT_RUN') && PHPUNIT_RUN && is_dir(OC::$SERVERROOT . '/tests/config/')) {
87
-			self::$configDir = OC::$SERVERROOT . '/tests/config/';
88
-		} elseif ($dir = getenv('NEXTCLOUD_CONFIG_DIR')) {
89
-			self::$configDir = rtrim($dir, '/') . '/';
90
-		} else {
91
-			self::$configDir = OC::$SERVERROOT . '/config/';
92
-		}
93
-		self::$config = new \OC\Config(self::$configDir);
94
-
95
-		OC::$SUBURI = str_replace('\\', '/', substr(realpath($_SERVER['SCRIPT_FILENAME'] ?? ''), strlen(OC::$SERVERROOT)));
96
-		/**
97
-		 * FIXME: The following lines are required because we can't yet instantiate
98
-		 *        Server::get(\OCP\IRequest::class) since \OC::$server does not yet exist.
99
-		 */
100
-		$params = [
101
-			'server' => [
102
-				'SCRIPT_NAME' => $_SERVER['SCRIPT_NAME'] ?? null,
103
-				'SCRIPT_FILENAME' => $_SERVER['SCRIPT_FILENAME'] ?? null,
104
-			],
105
-		];
106
-		if (isset($_SERVER['REMOTE_ADDR'])) {
107
-			$params['server']['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'];
108
-		}
109
-		$fakeRequest = new \OC\AppFramework\Http\Request(
110
-			$params,
111
-			new \OC\AppFramework\Http\RequestId($_SERVER['UNIQUE_ID'] ?? '', new \OC\Security\SecureRandom()),
112
-			new \OC\AllConfig(new \OC\SystemConfig(self::$config))
113
-		);
114
-		$scriptName = $fakeRequest->getScriptName();
115
-		if (substr($scriptName, -1) == '/') {
116
-			$scriptName .= 'index.php';
117
-			//make sure suburi follows the same rules as scriptName
118
-			if (substr(OC::$SUBURI, -9) != 'index.php') {
119
-				if (substr(OC::$SUBURI, -1) != '/') {
120
-					OC::$SUBURI = OC::$SUBURI . '/';
121
-				}
122
-				OC::$SUBURI = OC::$SUBURI . 'index.php';
123
-			}
124
-		}
125
-
126
-		if (OC::$CLI) {
127
-			OC::$WEBROOT = self::$config->getValue('overwritewebroot', '');
128
-		} else {
129
-			if (substr($scriptName, 0 - strlen(OC::$SUBURI)) === OC::$SUBURI) {
130
-				OC::$WEBROOT = substr($scriptName, 0, 0 - strlen(OC::$SUBURI));
131
-
132
-				if (OC::$WEBROOT != '' && OC::$WEBROOT[0] !== '/') {
133
-					OC::$WEBROOT = '/' . OC::$WEBROOT;
134
-				}
135
-			} else {
136
-				// The scriptName is not ending with OC::$SUBURI
137
-				// This most likely means that we are calling from CLI.
138
-				// However some cron jobs still need to generate
139
-				// a web URL, so we use overwritewebroot as a fallback.
140
-				OC::$WEBROOT = self::$config->getValue('overwritewebroot', '');
141
-			}
142
-
143
-			// Resolve /nextcloud to /nextcloud/ to ensure to always have a trailing
144
-			// slash which is required by URL generation.
145
-			if (isset($_SERVER['REQUEST_URI']) && $_SERVER['REQUEST_URI'] === \OC::$WEBROOT
146
-					&& substr($_SERVER['REQUEST_URI'], -1) !== '/') {
147
-				header('Location: ' . \OC::$WEBROOT . '/');
148
-				exit();
149
-			}
150
-		}
151
-
152
-		// search the apps folder
153
-		$config_paths = self::$config->getValue('apps_paths', []);
154
-		if (!empty($config_paths)) {
155
-			foreach ($config_paths as $paths) {
156
-				if (isset($paths['url']) && isset($paths['path'])) {
157
-					$paths['url'] = rtrim($paths['url'], '/');
158
-					$paths['path'] = rtrim($paths['path'], '/');
159
-					OC::$APPSROOTS[] = $paths;
160
-				}
161
-			}
162
-		} elseif (file_exists(OC::$SERVERROOT . '/apps')) {
163
-			OC::$APPSROOTS[] = ['path' => OC::$SERVERROOT . '/apps', 'url' => '/apps', 'writable' => true];
164
-		}
165
-
166
-		if (empty(OC::$APPSROOTS)) {
167
-			throw new \RuntimeException('apps directory not found! Please put the Nextcloud apps folder in the Nextcloud folder'
168
-				. '. You can also configure the location in the config.php file.');
169
-		}
170
-		$paths = [];
171
-		foreach (OC::$APPSROOTS as $path) {
172
-			$paths[] = $path['path'];
173
-			if (!is_dir($path['path'])) {
174
-				throw new \RuntimeException(sprintf('App directory "%s" not found! Please put the Nextcloud apps folder in the'
175
-					. ' Nextcloud folder. You can also configure the location in the config.php file.', $path['path']));
176
-			}
177
-		}
178
-
179
-		// set the right include path
180
-		set_include_path(
181
-			implode(PATH_SEPARATOR, $paths)
182
-		);
183
-	}
184
-
185
-	public static function checkConfig(): void {
186
-		// Create config if it does not already exist
187
-		$configFilePath = self::$configDir . '/config.php';
188
-		if (!file_exists($configFilePath)) {
189
-			@touch($configFilePath);
190
-		}
191
-
192
-		// Check if config is writable
193
-		$configFileWritable = is_writable($configFilePath);
194
-		$configReadOnly = Server::get(IConfig::class)->getSystemValueBool('config_is_read_only');
195
-		if (!$configFileWritable && !$configReadOnly
196
-			|| !$configFileWritable && \OCP\Util::needUpgrade()) {
197
-			$urlGenerator = Server::get(IURLGenerator::class);
198
-			$l = Server::get(\OCP\L10N\IFactory::class)->get('lib');
199
-
200
-			if (self::$CLI) {
201
-				echo $l->t('Cannot write into "config" directory!') . "\n";
202
-				echo $l->t('This can usually be fixed by giving the web server write access to the config directory.') . "\n";
203
-				echo "\n";
204
-				echo $l->t('But, if you prefer to keep config.php file read only, set the option "config_is_read_only" to true in it.') . "\n";
205
-				echo $l->t('See %s', [ $urlGenerator->linkToDocs('admin-config') ]) . "\n";
206
-				exit;
207
-			} else {
208
-				Server::get(ITemplateManager::class)->printErrorPage(
209
-					$l->t('Cannot write into "config" directory!'),
210
-					$l->t('This can usually be fixed by giving the web server write access to the config directory.') . ' '
211
-					. $l->t('But, if you prefer to keep config.php file read only, set the option "config_is_read_only" to true in it.') . ' '
212
-					. $l->t('See %s', [ $urlGenerator->linkToDocs('admin-config') ]),
213
-					503
214
-				);
215
-			}
216
-		}
217
-	}
218
-
219
-	public static function checkInstalled(\OC\SystemConfig $systemConfig): void {
220
-		if (defined('OC_CONSOLE')) {
221
-			return;
222
-		}
223
-		// Redirect to installer if not installed
224
-		if (!$systemConfig->getValue('installed', false) && OC::$SUBURI !== '/index.php' && OC::$SUBURI !== '/status.php') {
225
-			if (OC::$CLI) {
226
-				throw new Exception('Not installed');
227
-			} else {
228
-				$url = OC::$WEBROOT . '/index.php';
229
-				header('Location: ' . $url);
230
-			}
231
-			exit();
232
-		}
233
-	}
234
-
235
-	public static function checkMaintenanceMode(\OC\SystemConfig $systemConfig): void {
236
-		// Allow ajax update script to execute without being stopped
237
-		if (((bool)$systemConfig->getValue('maintenance', false)) && OC::$SUBURI != '/core/ajax/update.php') {
238
-			// send http status 503
239
-			http_response_code(503);
240
-			header('X-Nextcloud-Maintenance-Mode: 1');
241
-			header('Retry-After: 120');
242
-
243
-			// render error page
244
-			$template = Server::get(ITemplateManager::class)->getTemplate('', 'update.user', 'guest');
245
-			\OCP\Util::addScript('core', 'maintenance');
246
-			\OCP\Util::addScript('core', 'common');
247
-			\OCP\Util::addStyle('core', 'guest');
248
-			$template->printPage();
249
-			die();
250
-		}
251
-	}
252
-
253
-	/**
254
-	 * Prints the upgrade page
255
-	 */
256
-	private static function printUpgradePage(\OC\SystemConfig $systemConfig): void {
257
-		$cliUpgradeLink = $systemConfig->getValue('upgrade.cli-upgrade-link', '');
258
-		$disableWebUpdater = $systemConfig->getValue('upgrade.disable-web', false);
259
-		$tooBig = false;
260
-		if (!$disableWebUpdater) {
261
-			$apps = Server::get(\OCP\App\IAppManager::class);
262
-			if ($apps->isEnabledForAnyone('user_ldap')) {
263
-				$qb = Server::get(\OCP\IDBConnection::class)->getQueryBuilder();
264
-
265
-				$result = $qb->select($qb->func()->count('*', 'user_count'))
266
-					->from('ldap_user_mapping')
267
-					->executeQuery();
268
-				$row = $result->fetch();
269
-				$result->closeCursor();
270
-
271
-				$tooBig = ($row['user_count'] > 50);
272
-			}
273
-			if (!$tooBig && $apps->isEnabledForAnyone('user_saml')) {
274
-				$qb = Server::get(\OCP\IDBConnection::class)->getQueryBuilder();
275
-
276
-				$result = $qb->select($qb->func()->count('*', 'user_count'))
277
-					->from('user_saml_users')
278
-					->executeQuery();
279
-				$row = $result->fetch();
280
-				$result->closeCursor();
281
-
282
-				$tooBig = ($row['user_count'] > 50);
283
-			}
284
-			if (!$tooBig) {
285
-				// count users
286
-				$totalUsers = Server::get(\OCP\IUserManager::class)->countUsersTotal(51);
287
-				$tooBig = ($totalUsers > 50);
288
-			}
289
-		}
290
-		$ignoreTooBigWarning = isset($_GET['IKnowThatThisIsABigInstanceAndTheUpdateRequestCouldRunIntoATimeoutAndHowToRestoreABackup'])
291
-			&& $_GET['IKnowThatThisIsABigInstanceAndTheUpdateRequestCouldRunIntoATimeoutAndHowToRestoreABackup'] === 'IAmSuperSureToDoThis';
292
-
293
-		if ($disableWebUpdater || ($tooBig && !$ignoreTooBigWarning)) {
294
-			// send http status 503
295
-			http_response_code(503);
296
-			header('Retry-After: 120');
297
-
298
-			$serverVersion = \OCP\Server::get(\OCP\ServerVersion::class);
299
-
300
-			// render error page
301
-			$template = Server::get(ITemplateManager::class)->getTemplate('', 'update.use-cli', 'guest');
302
-			$template->assign('productName', 'nextcloud'); // for now
303
-			$template->assign('version', $serverVersion->getVersionString());
304
-			$template->assign('tooBig', $tooBig);
305
-			$template->assign('cliUpgradeLink', $cliUpgradeLink);
306
-
307
-			$template->printPage();
308
-			die();
309
-		}
310
-
311
-		// check whether this is a core update or apps update
312
-		$installedVersion = $systemConfig->getValue('version', '0.0.0');
313
-		$currentVersion = implode('.', \OCP\Util::getVersion());
314
-
315
-		// if not a core upgrade, then it's apps upgrade
316
-		$isAppsOnlyUpgrade = version_compare($currentVersion, $installedVersion, '=');
317
-
318
-		$oldTheme = $systemConfig->getValue('theme');
319
-		$systemConfig->setValue('theme', '');
320
-		\OCP\Util::addScript('core', 'common');
321
-		\OCP\Util::addScript('core', 'main');
322
-		\OCP\Util::addTranslations('core');
323
-		\OCP\Util::addScript('core', 'update');
324
-
325
-		/** @var \OC\App\AppManager $appManager */
326
-		$appManager = Server::get(\OCP\App\IAppManager::class);
327
-
328
-		$tmpl = Server::get(ITemplateManager::class)->getTemplate('', 'update.admin', 'guest');
329
-		$tmpl->assign('version', \OCP\Server::get(\OCP\ServerVersion::class)->getVersionString());
330
-		$tmpl->assign('isAppsOnlyUpgrade', $isAppsOnlyUpgrade);
331
-
332
-		// get third party apps
333
-		$ocVersion = \OCP\Util::getVersion();
334
-		$ocVersion = implode('.', $ocVersion);
335
-		$incompatibleApps = $appManager->getIncompatibleApps($ocVersion);
336
-		$incompatibleOverwrites = $systemConfig->getValue('app_install_overwrite', []);
337
-		$incompatibleShippedApps = [];
338
-		$incompatibleDisabledApps = [];
339
-		foreach ($incompatibleApps as $appInfo) {
340
-			if ($appManager->isShipped($appInfo['id'])) {
341
-				$incompatibleShippedApps[] = $appInfo['name'] . ' (' . $appInfo['id'] . ')';
342
-			}
343
-			if (!in_array($appInfo['id'], $incompatibleOverwrites)) {
344
-				$incompatibleDisabledApps[] = $appInfo;
345
-			}
346
-		}
347
-
348
-		if (!empty($incompatibleShippedApps)) {
349
-			$l = Server::get(\OCP\L10N\IFactory::class)->get('core');
350
-			$hint = $l->t('Application %1$s is not present or has a non-compatible version with this server. Please check the apps directory.', [implode(', ', $incompatibleShippedApps)]);
351
-			throw new \OCP\HintException('Application ' . implode(', ', $incompatibleShippedApps) . ' is not present or has a non-compatible version with this server. Please check the apps directory.', $hint);
352
-		}
353
-
354
-		$tmpl->assign('appsToUpgrade', $appManager->getAppsNeedingUpgrade($ocVersion));
355
-		$tmpl->assign('incompatibleAppsList', $incompatibleDisabledApps);
356
-		try {
357
-			$defaults = new \OC_Defaults();
358
-			$tmpl->assign('productName', $defaults->getName());
359
-		} catch (Throwable $error) {
360
-			$tmpl->assign('productName', 'Nextcloud');
361
-		}
362
-		$tmpl->assign('oldTheme', $oldTheme);
363
-		$tmpl->printPage();
364
-	}
365
-
366
-	public static function initSession(): void {
367
-		$request = Server::get(IRequest::class);
368
-
369
-		// TODO: Temporary disabled again to solve issues with CalDAV/CardDAV clients like DAVx5 that use cookies
370
-		// TODO: See https://github.com/nextcloud/server/issues/37277#issuecomment-1476366147 and the other comments
371
-		// TODO: for further information.
372
-		// $isDavRequest = strpos($request->getRequestUri(), '/remote.php/dav') === 0 || strpos($request->getRequestUri(), '/remote.php/webdav') === 0;
373
-		// if ($request->getHeader('Authorization') !== '' && is_null($request->getCookie('cookie_test')) && $isDavRequest && !isset($_COOKIE['nc_session_id'])) {
374
-		// setcookie('cookie_test', 'test', time() + 3600);
375
-		// // Do not initialize the session if a request is authenticated directly
376
-		// // unless there is a session cookie already sent along
377
-		// return;
378
-		// }
379
-
380
-		if ($request->getServerProtocol() === 'https') {
381
-			ini_set('session.cookie_secure', 'true');
382
-		}
383
-
384
-		// prevents javascript from accessing php session cookies
385
-		ini_set('session.cookie_httponly', 'true');
386
-
387
-		// set the cookie path to the Nextcloud directory
388
-		$cookie_path = OC::$WEBROOT ? : '/';
389
-		ini_set('session.cookie_path', $cookie_path);
390
-
391
-		// set the cookie domain to the Nextcloud domain
392
-		$cookie_domain = self::$config->getValue('cookie_domain', '');
393
-		if ($cookie_domain) {
394
-			ini_set('session.cookie_domain', $cookie_domain);
395
-		}
396
-
397
-		// Do not initialize sessions for 'status.php' requests
398
-		// Monitoring endpoints can quickly flood session handlers
399
-		// and 'status.php' doesn't require sessions anyway
400
-		// We still need to run the ini_set above so that same-site cookies use the correct configuration.
401
-		if (str_ends_with($request->getScriptName(), '/status.php')) {
402
-			return;
403
-		}
404
-
405
-		// Let the session name be changed in the initSession Hook
406
-		$sessionName = OC_Util::getInstanceId();
407
-
408
-		try {
409
-			$logger = null;
410
-			if (Server::get(\OC\SystemConfig::class)->getValue('installed', false)) {
411
-				$logger = logger('core');
412
-			}
413
-
414
-			// set the session name to the instance id - which is unique
415
-			$session = new \OC\Session\Internal(
416
-				$sessionName,
417
-				$logger,
418
-			);
419
-
420
-			$cryptoWrapper = Server::get(\OC\Session\CryptoWrapper::class);
421
-			$session = $cryptoWrapper->wrapSession($session);
422
-			self::$server->setSession($session);
423
-
424
-			// if session can't be started break with http 500 error
425
-		} catch (Exception $e) {
426
-			Server::get(LoggerInterface::class)->error($e->getMessage(), ['app' => 'base','exception' => $e]);
427
-			//show the user a detailed error page
428
-			Server::get(ITemplateManager::class)->printExceptionErrorPage($e, 500);
429
-			die();
430
-		}
431
-
432
-		//try to set the session lifetime
433
-		$sessionLifeTime = self::getSessionLifeTime();
434
-
435
-		// session timeout
436
-		if ($session->exists('LAST_ACTIVITY') && (time() - $session->get('LAST_ACTIVITY') > $sessionLifeTime)) {
437
-			if (isset($_COOKIE[session_name()])) {
438
-				setcookie(session_name(), '', -1, self::$WEBROOT ? : '/');
439
-			}
440
-			Server::get(IUserSession::class)->logout();
441
-		}
442
-
443
-		if (!self::hasSessionRelaxedExpiry()) {
444
-			$session->set('LAST_ACTIVITY', time());
445
-		}
446
-		$session->close();
447
-	}
448
-
449
-	private static function getSessionLifeTime(): int {
450
-		return Server::get(IConfig::class)->getSystemValueInt('session_lifetime', 60 * 60 * 24);
451
-	}
452
-
453
-	/**
454
-	 * @return bool true if the session expiry should only be done by gc instead of an explicit timeout
455
-	 */
456
-	public static function hasSessionRelaxedExpiry(): bool {
457
-		return Server::get(IConfig::class)->getSystemValueBool('session_relaxed_expiry', false);
458
-	}
459
-
460
-	/**
461
-	 * Try to set some values to the required Nextcloud default
462
-	 */
463
-	public static function setRequiredIniValues(): void {
464
-		// Don't display errors and log them
465
-		@ini_set('display_errors', '0');
466
-		@ini_set('log_errors', '1');
467
-
468
-		// Try to configure php to enable big file uploads.
469
-		// This doesn't work always depending on the webserver and php configuration.
470
-		// Let's try to overwrite some defaults if they are smaller than 1 hour
471
-
472
-		if (intval(@ini_get('max_execution_time') ?: 0) < 3600) {
473
-			@ini_set('max_execution_time', strval(3600));
474
-		}
475
-
476
-		if (intval(@ini_get('max_input_time') ?: 0) < 3600) {
477
-			@ini_set('max_input_time', strval(3600));
478
-		}
479
-
480
-		// Try to set the maximum execution time to the largest time limit we have
481
-		if (strpos(@ini_get('disable_functions'), 'set_time_limit') === false) {
482
-			@set_time_limit(max(intval(@ini_get('max_execution_time')), intval(@ini_get('max_input_time'))));
483
-		}
484
-
485
-		@ini_set('default_charset', 'UTF-8');
486
-		@ini_set('gd.jpeg_ignore_warning', '1');
487
-	}
488
-
489
-	/**
490
-	 * Send the same site cookies
491
-	 */
492
-	private static function sendSameSiteCookies(): void {
493
-		$cookieParams = session_get_cookie_params();
494
-		$secureCookie = ($cookieParams['secure'] === true) ? 'secure; ' : '';
495
-		$policies = [
496
-			'lax',
497
-			'strict',
498
-		];
499
-
500
-		// Append __Host to the cookie if it meets the requirements
501
-		$cookiePrefix = '';
502
-		if ($cookieParams['secure'] === true && $cookieParams['path'] === '/') {
503
-			$cookiePrefix = '__Host-';
504
-		}
505
-
506
-		foreach ($policies as $policy) {
507
-			header(
508
-				sprintf(
509
-					'Set-Cookie: %snc_sameSiteCookie%s=true; path=%s; httponly;' . $secureCookie . 'expires=Fri, 31-Dec-2100 23:59:59 GMT; SameSite=%s',
510
-					$cookiePrefix,
511
-					$policy,
512
-					$cookieParams['path'],
513
-					$policy
514
-				),
515
-				false
516
-			);
517
-		}
518
-	}
519
-
520
-	/**
521
-	 * Same Site cookie to further mitigate CSRF attacks. This cookie has to
522
-	 * be set in every request if cookies are sent to add a second level of
523
-	 * defense against CSRF.
524
-	 *
525
-	 * If the cookie is not sent this will set the cookie and reload the page.
526
-	 * We use an additional cookie since we want to protect logout CSRF and
527
-	 * also we can't directly interfere with PHP's session mechanism.
528
-	 */
529
-	private static function performSameSiteCookieProtection(IConfig $config): void {
530
-		$request = Server::get(IRequest::class);
531
-
532
-		// Some user agents are notorious and don't really properly follow HTTP
533
-		// specifications. For those, have an automated opt-out. Since the protection
534
-		// for remote.php is applied in base.php as starting point we need to opt out
535
-		// here.
536
-		$incompatibleUserAgents = $config->getSystemValue('csrf.optout');
537
-
538
-		// Fallback, if csrf.optout is unset
539
-		if (!is_array($incompatibleUserAgents)) {
540
-			$incompatibleUserAgents = [
541
-				// OS X Finder
542
-				'/^WebDAVFS/',
543
-				// Windows webdav drive
544
-				'/^Microsoft-WebDAV-MiniRedir/',
545
-			];
546
-		}
547
-
548
-		if ($request->isUserAgent($incompatibleUserAgents)) {
549
-			return;
550
-		}
551
-
552
-		if (count($_COOKIE) > 0) {
553
-			$requestUri = $request->getScriptName();
554
-			$processingScript = explode('/', $requestUri);
555
-			$processingScript = $processingScript[count($processingScript) - 1];
556
-
557
-			if ($processingScript === 'index.php' // index.php routes are handled in the middleware
558
-				|| $processingScript === 'cron.php' // and cron.php does not need any authentication at all
559
-				|| $processingScript === 'public.php' // For public.php, auth for password protected shares is done in the PublicAuth plugin
560
-			) {
561
-				return;
562
-			}
563
-
564
-			// All other endpoints require the lax and the strict cookie
565
-			if (!$request->passesStrictCookieCheck()) {
566
-				logger('core')->warning('Request does not pass strict cookie check');
567
-				self::sendSameSiteCookies();
568
-				// Debug mode gets access to the resources without strict cookie
569
-				// due to the fact that the SabreDAV browser also lives there.
570
-				if (!$config->getSystemValueBool('debug', false)) {
571
-					http_response_code(\OCP\AppFramework\Http::STATUS_PRECONDITION_FAILED);
572
-					header('Content-Type: application/json');
573
-					echo json_encode(['error' => 'Strict Cookie has not been found in request']);
574
-					exit();
575
-				}
576
-			}
577
-		} elseif (!isset($_COOKIE['nc_sameSiteCookielax']) || !isset($_COOKIE['nc_sameSiteCookiestrict'])) {
578
-			self::sendSameSiteCookies();
579
-		}
580
-	}
581
-
582
-	/**
583
-	 * This function adds some security related headers to all requests served via base.php
584
-	 * The implementation of this function has to happen here to ensure that all third-party
585
-	 * components (e.g. SabreDAV) also benefit from this headers.
586
-	 */
587
-	private static function addSecurityHeaders(): void {
588
-		/**
589
-		 * FIXME: Content Security Policy for legacy components. This
590
-		 * can be removed once \OCP\AppFramework\Http\Response from the AppFramework
591
-		 * is used everywhere.
592
-		 * @see \OCP\AppFramework\Http\Response::getHeaders
593
-		 */
594
-		$policy = 'default-src \'self\'; '
595
-			. 'script-src \'self\' \'nonce-' . \OC::$server->getContentSecurityPolicyNonceManager()->getNonce() . '\'; '
596
-			. 'style-src \'self\' \'unsafe-inline\'; '
597
-			. 'frame-src *; '
598
-			. 'img-src * data: blob:; '
599
-			. 'font-src \'self\' data:; '
600
-			. 'media-src *; '
601
-			. 'connect-src *; '
602
-			. 'object-src \'none\'; '
603
-			. 'base-uri \'self\'; ';
604
-		header('Content-Security-Policy:' . $policy);
605
-
606
-		// Send fallback headers for installations that don't have the possibility to send
607
-		// custom headers on the webserver side
608
-		if (getenv('modHeadersAvailable') !== 'true') {
609
-			header('Referrer-Policy: no-referrer'); // https://www.w3.org/TR/referrer-policy/
610
-			header('X-Content-Type-Options: nosniff'); // Disable sniffing the content type for IE
611
-			header('X-Frame-Options: SAMEORIGIN'); // Disallow iFraming from other domains
612
-			header('X-Permitted-Cross-Domain-Policies: none'); // https://www.adobe.com/devnet/adobe-media-server/articles/cross-domain-xml-for-streaming.html
613
-			header('X-Robots-Tag: noindex, nofollow'); // https://developers.google.com/webmasters/control-crawl-index/docs/robots_meta_tag
614
-		}
615
-	}
616
-
617
-	public static function init(): void {
618
-		// First handle PHP configuration and copy auth headers to the expected
619
-		// $_SERVER variable before doing anything Server object related
620
-		self::setRequiredIniValues();
621
-		self::handleAuthHeaders();
622
-
623
-		// prevent any XML processing from loading external entities
624
-		libxml_set_external_entity_loader(static function () {
625
-			return null;
626
-		});
627
-
628
-		// Set default timezone before the Server object is booted
629
-		if (!date_default_timezone_set('UTC')) {
630
-			throw new \RuntimeException('Could not set timezone to UTC');
631
-		}
632
-
633
-		// calculate the root directories
634
-		OC::$SERVERROOT = str_replace('\\', '/', substr(__DIR__, 0, -4));
635
-
636
-		// register autoloader
637
-		$loaderStart = microtime(true);
638
-
639
-		self::$CLI = (php_sapi_name() == 'cli');
640
-
641
-		// Add default composer PSR-4 autoloader, ensure apcu to be disabled
642
-		self::$composerAutoloader = require_once OC::$SERVERROOT . '/lib/composer/autoload.php';
643
-		self::$composerAutoloader->setApcuPrefix(null);
644
-
645
-
646
-		try {
647
-			self::initPaths();
648
-			// setup 3rdparty autoloader
649
-			$vendorAutoLoad = OC::$SERVERROOT . '/3rdparty/autoload.php';
650
-			if (!file_exists($vendorAutoLoad)) {
651
-				throw new \RuntimeException('Composer autoloader not found, unable to continue. Check the folder "3rdparty". Running "git submodule update --init" will initialize the git submodule that handles the subfolder "3rdparty".');
652
-			}
653
-			require_once $vendorAutoLoad;
654
-		} catch (\RuntimeException $e) {
655
-			if (!self::$CLI) {
656
-				http_response_code(503);
657
-			}
658
-			// we can't use the template error page here, because this needs the
659
-			// DI container which isn't available yet
660
-			print($e->getMessage());
661
-			exit();
662
-		}
663
-		$loaderEnd = microtime(true);
664
-
665
-		// Enable lazy loading if activated
666
-		\OC\AppFramework\Utility\SimpleContainer::$useLazyObjects = (bool)self::$config->getValue('enable_lazy_objects', true);
667
-
668
-		// setup the basic server
669
-		self::$server = new \OC\Server(\OC::$WEBROOT, self::$config);
670
-		self::$server->boot();
671
-
672
-		try {
673
-			$profiler = new BuiltInProfiler(
674
-				Server::get(IConfig::class),
675
-				Server::get(IRequest::class),
676
-			);
677
-			$profiler->start();
678
-		} catch (\Throwable $e) {
679
-			logger('core')->error('Failed to start profiler: ' . $e->getMessage(), ['app' => 'base']);
680
-		}
681
-
682
-		if (self::$CLI && in_array('--' . \OCP\Console\ReservedOptions::DEBUG_LOG, $_SERVER['argv'])) {
683
-			\OC\Core\Listener\BeforeMessageLoggedEventListener::setup();
684
-		}
685
-
686
-		$eventLogger = Server::get(\OCP\Diagnostics\IEventLogger::class);
687
-		$eventLogger->log('autoloader', 'Autoloader', $loaderStart, $loaderEnd);
688
-		$eventLogger->start('boot', 'Initialize');
689
-
690
-		// Override php.ini and log everything if we're troubleshooting
691
-		if (self::$config->getValue('loglevel') === ILogger::DEBUG) {
692
-			error_reporting(E_ALL);
693
-		}
694
-
695
-		// initialize intl fallback if necessary
696
-		OC_Util::isSetLocaleWorking();
697
-
698
-		$config = Server::get(IConfig::class);
699
-		if (!defined('PHPUNIT_RUN')) {
700
-			$errorHandler = new OC\Log\ErrorHandler(
701
-				\OCP\Server::get(\Psr\Log\LoggerInterface::class),
702
-			);
703
-			$exceptionHandler = [$errorHandler, 'onException'];
704
-			if ($config->getSystemValueBool('debug', false)) {
705
-				set_error_handler([$errorHandler, 'onAll'], E_ALL);
706
-				if (\OC::$CLI) {
707
-					$exceptionHandler = [Server::get(ITemplateManager::class), 'printExceptionErrorPage'];
708
-				}
709
-			} else {
710
-				set_error_handler([$errorHandler, 'onError']);
711
-			}
712
-			register_shutdown_function([$errorHandler, 'onShutdown']);
713
-			set_exception_handler($exceptionHandler);
714
-		}
715
-
716
-		/** @var \OC\AppFramework\Bootstrap\Coordinator $bootstrapCoordinator */
717
-		$bootstrapCoordinator = Server::get(\OC\AppFramework\Bootstrap\Coordinator::class);
718
-		$bootstrapCoordinator->runInitialRegistration();
719
-
720
-		$eventLogger->start('init_session', 'Initialize session');
721
-
722
-		// Check for PHP SimpleXML extension earlier since we need it before our other checks and want to provide a useful hint for web users
723
-		// see https://github.com/nextcloud/server/pull/2619
724
-		if (!function_exists('simplexml_load_file')) {
725
-			throw new \OCP\HintException('The PHP SimpleXML/PHP-XML extension is not installed.', 'Install the extension or make sure it is enabled.');
726
-		}
727
-
728
-		$systemConfig = Server::get(\OC\SystemConfig::class);
729
-		$appManager = Server::get(\OCP\App\IAppManager::class);
730
-		if ($systemConfig->getValue('installed', false)) {
731
-			$appManager->loadApps(['session']);
732
-		}
733
-		if (!self::$CLI) {
734
-			self::initSession();
735
-		}
736
-		$eventLogger->end('init_session');
737
-		self::checkConfig();
738
-		self::checkInstalled($systemConfig);
739
-
740
-		self::addSecurityHeaders();
741
-
742
-		self::performSameSiteCookieProtection($config);
743
-
744
-		if (!defined('OC_CONSOLE')) {
745
-			$eventLogger->start('check_server', 'Run a few configuration checks');
746
-			$errors = OC_Util::checkServer($systemConfig);
747
-			if (count($errors) > 0) {
748
-				if (!self::$CLI) {
749
-					http_response_code(503);
750
-					Util::addStyle('guest');
751
-					try {
752
-						Server::get(ITemplateManager::class)->printGuestPage('', 'error', ['errors' => $errors]);
753
-						exit;
754
-					} catch (\Exception $e) {
755
-						// In case any error happens when showing the error page, we simply fall back to posting the text.
756
-						// This might be the case when e.g. the data directory is broken and we can not load/write SCSS to/from it.
757
-					}
758
-				}
759
-
760
-				// Convert l10n string into regular string for usage in database
761
-				$staticErrors = [];
762
-				foreach ($errors as $error) {
763
-					echo $error['error'] . "\n";
764
-					echo $error['hint'] . "\n\n";
765
-					$staticErrors[] = [
766
-						'error' => (string)$error['error'],
767
-						'hint' => (string)$error['hint'],
768
-					];
769
-				}
770
-
771
-				try {
772
-					$config->setAppValue('core', 'cronErrors', json_encode($staticErrors));
773
-				} catch (\Exception $e) {
774
-					echo('Writing to database failed');
775
-				}
776
-				exit(1);
777
-			} elseif (self::$CLI && $config->getSystemValueBool('installed', false)) {
778
-				$config->deleteAppValue('core', 'cronErrors');
779
-			}
780
-			$eventLogger->end('check_server');
781
-		}
782
-
783
-		// User and Groups
784
-		if (!$systemConfig->getValue('installed', false)) {
785
-			self::$server->getSession()->set('user_id', '');
786
-		}
787
-
788
-		$eventLogger->start('setup_backends', 'Setup group and user backends');
789
-		Server::get(\OCP\IUserManager::class)->registerBackend(new \OC\User\Database());
790
-		Server::get(\OCP\IGroupManager::class)->addBackend(new \OC\Group\Database());
791
-
792
-		// Subscribe to the hook
793
-		\OCP\Util::connectHook(
794
-			'\OCA\Files_Sharing\API\Server2Server',
795
-			'preLoginNameUsedAsUserName',
796
-			'\OC\User\Database',
797
-			'preLoginNameUsedAsUserName'
798
-		);
799
-
800
-		//setup extra user backends
801
-		if (!\OCP\Util::needUpgrade()) {
802
-			OC_User::setupBackends();
803
-		} else {
804
-			// Run upgrades in incognito mode
805
-			OC_User::setIncognitoMode(true);
806
-		}
807
-		$eventLogger->end('setup_backends');
808
-
809
-		self::registerCleanupHooks($systemConfig);
810
-		self::registerShareHooks($systemConfig);
811
-		self::registerEncryptionWrapperAndHooks();
812
-		self::registerAccountHooks();
813
-		self::registerResourceCollectionHooks();
814
-		self::registerFileReferenceEventListener();
815
-		self::registerRenderReferenceEventListener();
816
-		self::registerAppRestrictionsHooks();
817
-
818
-		// Make sure that the application class is not loaded before the database is setup
819
-		if ($systemConfig->getValue('installed', false)) {
820
-			$appManager->loadApp('settings');
821
-		}
822
-
823
-		//make sure temporary files are cleaned up
824
-		$tmpManager = Server::get(\OCP\ITempManager::class);
825
-		register_shutdown_function([$tmpManager, 'clean']);
826
-		$lockProvider = Server::get(\OCP\Lock\ILockingProvider::class);
827
-		register_shutdown_function([$lockProvider, 'releaseAll']);
828
-
829
-		// Check whether the sample configuration has been copied
830
-		if ($systemConfig->getValue('copied_sample_config', false)) {
831
-			$l = Server::get(\OCP\L10N\IFactory::class)->get('lib');
832
-			Server::get(ITemplateManager::class)->printErrorPage(
833
-				$l->t('Sample configuration detected'),
834
-				$l->t('It has been detected that the sample configuration has been copied. This can break your installation and is unsupported. Please read the documentation before performing changes on config.php'),
835
-				503
836
-			);
837
-			return;
838
-		}
839
-
840
-		$request = Server::get(IRequest::class);
841
-		$host = $request->getInsecureServerHost();
842
-		/**
843
-		 * if the host passed in headers isn't trusted
844
-		 * FIXME: Should not be in here at all :see_no_evil:
845
-		 */
846
-		if (!OC::$CLI
847
-			&& !Server::get(\OC\Security\TrustedDomainHelper::class)->isTrustedDomain($host)
848
-			&& $config->getSystemValueBool('installed', false)
849
-		) {
850
-			// Allow access to CSS resources
851
-			$isScssRequest = false;
852
-			if (strpos($request->getPathInfo() ?: '', '/css/') === 0) {
853
-				$isScssRequest = true;
854
-			}
855
-
856
-			if (substr($request->getRequestUri(), -11) === '/status.php') {
857
-				http_response_code(400);
858
-				header('Content-Type: application/json');
859
-				echo '{"error": "Trusted domain error.", "code": 15}';
860
-				exit();
861
-			}
862
-
863
-			if (!$isScssRequest) {
864
-				http_response_code(400);
865
-				Server::get(LoggerInterface::class)->info(
866
-					'Trusted domain error. "{remoteAddress}" tried to access using "{host}" as host.',
867
-					[
868
-						'app' => 'core',
869
-						'remoteAddress' => $request->getRemoteAddress(),
870
-						'host' => $host,
871
-					]
872
-				);
873
-
874
-				$tmpl = Server::get(ITemplateManager::class)->getTemplate('core', 'untrustedDomain', 'guest');
875
-				$tmpl->assign('docUrl', Server::get(IURLGenerator::class)->linkToDocs('admin-trusted-domains'));
876
-				$tmpl->printPage();
877
-
878
-				exit();
879
-			}
880
-		}
881
-		$eventLogger->end('boot');
882
-		$eventLogger->log('init', 'OC::init', $loaderStart, microtime(true));
883
-		$eventLogger->start('runtime', 'Runtime');
884
-		$eventLogger->start('request', 'Full request after boot');
885
-		register_shutdown_function(function () use ($eventLogger) {
886
-			$eventLogger->end('request');
887
-		});
888
-
889
-		register_shutdown_function(function () {
890
-			$memoryPeak = memory_get_peak_usage();
891
-			$logLevel = match (true) {
892
-				$memoryPeak > 500_000_000 => ILogger::FATAL,
893
-				$memoryPeak > 400_000_000 => ILogger::ERROR,
894
-				$memoryPeak > 300_000_000 => ILogger::WARN,
895
-				default => null,
896
-			};
897
-			if ($logLevel !== null) {
898
-				$message = 'Request used more than 300 MB of RAM: ' . Util::humanFileSize($memoryPeak);
899
-				$logger = Server::get(LoggerInterface::class);
900
-				$logger->log($logLevel, $message, ['app' => 'core']);
901
-			}
902
-		});
903
-	}
904
-
905
-	/**
906
-	 * register hooks for the cleanup of cache and bruteforce protection
907
-	 */
908
-	public static function registerCleanupHooks(\OC\SystemConfig $systemConfig): void {
909
-		//don't try to do this before we are properly setup
910
-		if ($systemConfig->getValue('installed', false) && !\OCP\Util::needUpgrade()) {
911
-			// NOTE: This will be replaced to use OCP
912
-			$userSession = Server::get(\OC\User\Session::class);
913
-			$userSession->listen('\OC\User', 'postLogin', function () use ($userSession) {
914
-				if (!defined('PHPUNIT_RUN') && $userSession->isLoggedIn()) {
915
-					// reset brute force delay for this IP address and username
916
-					$uid = $userSession->getUser()->getUID();
917
-					$request = Server::get(IRequest::class);
918
-					$throttler = Server::get(IThrottler::class);
919
-					$throttler->resetDelay($request->getRemoteAddress(), 'login', ['user' => $uid]);
920
-				}
921
-
922
-				try {
923
-					$cache = new \OC\Cache\File();
924
-					$cache->gc();
925
-				} catch (\OC\ServerNotAvailableException $e) {
926
-					// not a GC exception, pass it on
927
-					throw $e;
928
-				} catch (\OC\ForbiddenException $e) {
929
-					// filesystem blocked for this request, ignore
930
-				} catch (\Exception $e) {
931
-					// a GC exception should not prevent users from using OC,
932
-					// so log the exception
933
-					Server::get(LoggerInterface::class)->warning('Exception when running cache gc.', [
934
-						'app' => 'core',
935
-						'exception' => $e,
936
-					]);
937
-				}
938
-			});
939
-		}
940
-	}
941
-
942
-	private static function registerEncryptionWrapperAndHooks(): void {
943
-		/** @var \OC\Encryption\Manager */
944
-		$manager = Server::get(\OCP\Encryption\IManager::class);
945
-		Server::get(IEventDispatcher::class)->addListener(
946
-			BeforeFileSystemSetupEvent::class,
947
-			$manager->setupStorage(...),
948
-		);
949
-
950
-		$enabled = $manager->isEnabled();
951
-		if ($enabled) {
952
-			\OC\Encryption\EncryptionEventListener::register(Server::get(IEventDispatcher::class));
953
-		}
954
-	}
955
-
956
-	private static function registerAccountHooks(): void {
957
-		/** @var IEventDispatcher $dispatcher */
958
-		$dispatcher = Server::get(IEventDispatcher::class);
959
-		$dispatcher->addServiceListener(UserChangedEvent::class, \OC\Accounts\Hooks::class);
960
-	}
961
-
962
-	private static function registerAppRestrictionsHooks(): void {
963
-		/** @var \OC\Group\Manager $groupManager */
964
-		$groupManager = Server::get(\OCP\IGroupManager::class);
965
-		$groupManager->listen('\OC\Group', 'postDelete', function (\OCP\IGroup $group) {
966
-			$appManager = Server::get(\OCP\App\IAppManager::class);
967
-			$apps = $appManager->getEnabledAppsForGroup($group);
968
-			foreach ($apps as $appId) {
969
-				$restrictions = $appManager->getAppRestriction($appId);
970
-				if (empty($restrictions)) {
971
-					continue;
972
-				}
973
-				$key = array_search($group->getGID(), $restrictions, true);
974
-				unset($restrictions[$key]);
975
-				$restrictions = array_values($restrictions);
976
-				if (empty($restrictions)) {
977
-					$appManager->disableApp($appId);
978
-				} else {
979
-					$appManager->enableAppForGroups($appId, $restrictions);
980
-				}
981
-			}
982
-		});
983
-	}
984
-
985
-	private static function registerResourceCollectionHooks(): void {
986
-		\OC\Collaboration\Resources\Listener::register(Server::get(IEventDispatcher::class));
987
-	}
988
-
989
-	private static function registerFileReferenceEventListener(): void {
990
-		\OC\Collaboration\Reference\File\FileReferenceEventListener::register(Server::get(IEventDispatcher::class));
991
-	}
992
-
993
-	private static function registerRenderReferenceEventListener() {
994
-		\OC\Collaboration\Reference\RenderReferenceEventListener::register(Server::get(IEventDispatcher::class));
995
-	}
996
-
997
-	/**
998
-	 * register hooks for sharing
999
-	 */
1000
-	public static function registerShareHooks(\OC\SystemConfig $systemConfig): void {
1001
-		if ($systemConfig->getValue('installed')) {
1002
-
1003
-			$dispatcher = Server::get(IEventDispatcher::class);
1004
-			$dispatcher->addServiceListener(UserRemovedEvent::class, UserRemovedListener::class);
1005
-			$dispatcher->addServiceListener(GroupDeletedEvent::class, GroupDeletedListener::class);
1006
-			$dispatcher->addServiceListener(UserDeletedEvent::class, UserDeletedListener::class);
1007
-		}
1008
-	}
1009
-
1010
-	/**
1011
-	 * Handle the request
1012
-	 */
1013
-	public static function handleRequest(): void {
1014
-		Server::get(\OCP\Diagnostics\IEventLogger::class)->start('handle_request', 'Handle request');
1015
-		$systemConfig = Server::get(\OC\SystemConfig::class);
1016
-
1017
-		// Check if Nextcloud is installed or in maintenance (update) mode
1018
-		if (!$systemConfig->getValue('installed', false)) {
1019
-			\OC::$server->getSession()->clear();
1020
-			$controller = Server::get(\OC\Core\Controller\SetupController::class);
1021
-			$controller->run($_POST);
1022
-			exit();
1023
-		}
1024
-
1025
-		$request = Server::get(IRequest::class);
1026
-		$request->throwDecodingExceptionIfAny();
1027
-		$requestPath = $request->getRawPathInfo();
1028
-		if ($requestPath === '/heartbeat') {
1029
-			return;
1030
-		}
1031
-		if (substr($requestPath, -3) !== '.js') { // we need these files during the upgrade
1032
-			self::checkMaintenanceMode($systemConfig);
1033
-
1034
-			if (\OCP\Util::needUpgrade()) {
1035
-				if (function_exists('opcache_reset')) {
1036
-					opcache_reset();
1037
-				}
1038
-				if (!((bool)$systemConfig->getValue('maintenance', false))) {
1039
-					self::printUpgradePage($systemConfig);
1040
-					exit();
1041
-				}
1042
-			}
1043
-		}
1044
-
1045
-		$appManager = Server::get(\OCP\App\IAppManager::class);
1046
-
1047
-		// Always load authentication apps
1048
-		$appManager->loadApps(['authentication']);
1049
-		$appManager->loadApps(['extended_authentication']);
1050
-
1051
-		// Load minimum set of apps
1052
-		if (!\OCP\Util::needUpgrade()
1053
-			&& !((bool)$systemConfig->getValue('maintenance', false))) {
1054
-			// For logged-in users: Load everything
1055
-			if (Server::get(IUserSession::class)->isLoggedIn()) {
1056
-				$appManager->loadApps();
1057
-			} else {
1058
-				// For guests: Load only filesystem and logging
1059
-				$appManager->loadApps(['filesystem', 'logging']);
1060
-
1061
-				// Don't try to login when a client is trying to get a OAuth token.
1062
-				// OAuth needs to support basic auth too, so the login is not valid
1063
-				// inside Nextcloud and the Login exception would ruin it.
1064
-				if ($request->getRawPathInfo() !== '/apps/oauth2/api/v1/token') {
1065
-					try {
1066
-						self::handleLogin($request);
1067
-					} catch (DisabledUserException $e) {
1068
-						// Disabled users would not be seen as logged in and
1069
-						// trying to log them in would fail, so the login
1070
-						// exception is ignored for the themed stylesheets and
1071
-						// images.
1072
-						if ($request->getRawPathInfo() !== '/apps/theming/theme/default.css'
1073
-							&& $request->getRawPathInfo() !== '/apps/theming/theme/light.css'
1074
-							&& $request->getRawPathInfo() !== '/apps/theming/theme/dark.css'
1075
-							&& $request->getRawPathInfo() !== '/apps/theming/theme/light-highcontrast.css'
1076
-							&& $request->getRawPathInfo() !== '/apps/theming/theme/dark-highcontrast.css'
1077
-							&& $request->getRawPathInfo() !== '/apps/theming/theme/opendyslexic.css'
1078
-							&& $request->getRawPathInfo() !== '/apps/theming/image/background'
1079
-							&& $request->getRawPathInfo() !== '/apps/theming/image/logo'
1080
-							&& $request->getRawPathInfo() !== '/apps/theming/image/logoheader'
1081
-							&& !str_starts_with($request->getRawPathInfo(), '/apps/theming/favicon')
1082
-							&& !str_starts_with($request->getRawPathInfo(), '/apps/theming/icon')) {
1083
-							throw $e;
1084
-						}
1085
-					}
1086
-				}
1087
-			}
1088
-		}
1089
-
1090
-		if (!self::$CLI) {
1091
-			try {
1092
-				if (!\OCP\Util::needUpgrade()) {
1093
-					$appManager->loadApps(['filesystem', 'logging']);
1094
-					$appManager->loadApps();
1095
-				}
1096
-				Server::get(\OC\Route\Router::class)->match($request->getRawPathInfo());
1097
-				return;
1098
-			} catch (Symfony\Component\Routing\Exception\ResourceNotFoundException $e) {
1099
-				//header('HTTP/1.0 404 Not Found');
1100
-			} catch (Symfony\Component\Routing\Exception\MethodNotAllowedException $e) {
1101
-				http_response_code(405);
1102
-				return;
1103
-			}
1104
-		}
1105
-
1106
-		// Handle WebDAV
1107
-		if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'PROPFIND') {
1108
-			// not allowed any more to prevent people
1109
-			// mounting this root directly.
1110
-			// Users need to mount remote.php/webdav instead.
1111
-			http_response_code(405);
1112
-			return;
1113
-		}
1114
-
1115
-		// Handle requests for JSON or XML
1116
-		$acceptHeader = $request->getHeader('Accept');
1117
-		if (in_array($acceptHeader, ['application/json', 'application/xml'], true)) {
1118
-			http_response_code(404);
1119
-			return;
1120
-		}
1121
-
1122
-		// Handle resources that can't be found
1123
-		// This prevents browsers from redirecting to the default page and then
1124
-		// attempting to parse HTML as CSS and similar.
1125
-		$destinationHeader = $request->getHeader('Sec-Fetch-Dest');
1126
-		if (in_array($destinationHeader, ['font', 'script', 'style'])) {
1127
-			http_response_code(404);
1128
-			return;
1129
-		}
1130
-
1131
-		// Redirect to the default app or login only as an entry point
1132
-		if ($requestPath === '') {
1133
-			// Someone is logged in
1134
-			if (Server::get(IUserSession::class)->isLoggedIn()) {
1135
-				header('Location: ' . Server::get(IURLGenerator::class)->linkToDefaultPageUrl());
1136
-			} else {
1137
-				// Not handled and not logged in
1138
-				header('Location: ' . Server::get(IURLGenerator::class)->linkToRouteAbsolute('core.login.showLoginForm'));
1139
-			}
1140
-			return;
1141
-		}
1142
-
1143
-		try {
1144
-			Server::get(\OC\Route\Router::class)->match('/error/404');
1145
-		} catch (\Exception $e) {
1146
-			if (!$e instanceof MethodNotAllowedException) {
1147
-				logger('core')->emergency($e->getMessage(), ['exception' => $e]);
1148
-			}
1149
-			$l = Server::get(\OCP\L10N\IFactory::class)->get('lib');
1150
-			Server::get(ITemplateManager::class)->printErrorPage(
1151
-				'404',
1152
-				$l->t('The page could not be found on the server.'),
1153
-				404
1154
-			);
1155
-		}
1156
-	}
1157
-
1158
-	/**
1159
-	 * Check login: apache auth, auth token, basic auth
1160
-	 */
1161
-	public static function handleLogin(OCP\IRequest $request): bool {
1162
-		if ($request->getHeader('X-Nextcloud-Federation')) {
1163
-			return false;
1164
-		}
1165
-		$userSession = Server::get(\OC\User\Session::class);
1166
-		if (OC_User::handleApacheAuth()) {
1167
-			return true;
1168
-		}
1169
-		if (self::tryAppAPILogin($request)) {
1170
-			return true;
1171
-		}
1172
-		if ($userSession->tryTokenLogin($request)) {
1173
-			return true;
1174
-		}
1175
-		if (isset($_COOKIE['nc_username'])
1176
-			&& isset($_COOKIE['nc_token'])
1177
-			&& isset($_COOKIE['nc_session_id'])
1178
-			&& $userSession->loginWithCookie($_COOKIE['nc_username'], $_COOKIE['nc_token'], $_COOKIE['nc_session_id'])) {
1179
-			return true;
1180
-		}
1181
-		if ($userSession->tryBasicAuthLogin($request, Server::get(IThrottler::class))) {
1182
-			return true;
1183
-		}
1184
-		return false;
1185
-	}
1186
-
1187
-	protected static function handleAuthHeaders(): void {
1188
-		//copy http auth headers for apache+php-fcgid work around
1189
-		if (isset($_SERVER['HTTP_XAUTHORIZATION']) && !isset($_SERVER['HTTP_AUTHORIZATION'])) {
1190
-			$_SERVER['HTTP_AUTHORIZATION'] = $_SERVER['HTTP_XAUTHORIZATION'];
1191
-		}
1192
-
1193
-		// Extract PHP_AUTH_USER/PHP_AUTH_PW from other headers if necessary.
1194
-		$vars = [
1195
-			'HTTP_AUTHORIZATION', // apache+php-cgi work around
1196
-			'REDIRECT_HTTP_AUTHORIZATION', // apache+php-cgi alternative
1197
-		];
1198
-		foreach ($vars as $var) {
1199
-			if (isset($_SERVER[$var]) && is_string($_SERVER[$var]) && preg_match('/Basic\s+(.*)$/i', $_SERVER[$var], $matches)) {
1200
-				$credentials = explode(':', base64_decode($matches[1]), 2);
1201
-				if (count($credentials) === 2) {
1202
-					$_SERVER['PHP_AUTH_USER'] = $credentials[0];
1203
-					$_SERVER['PHP_AUTH_PW'] = $credentials[1];
1204
-					break;
1205
-				}
1206
-			}
1207
-		}
1208
-	}
1209
-
1210
-	protected static function tryAppAPILogin(OCP\IRequest $request): bool {
1211
-		if (!$request->getHeader('AUTHORIZATION-APP-API')) {
1212
-			return false;
1213
-		}
1214
-		$appManager = Server::get(OCP\App\IAppManager::class);
1215
-		if (!$appManager->isEnabledForAnyone('app_api')) {
1216
-			return false;
1217
-		}
1218
-		try {
1219
-			$appAPIService = Server::get(OCA\AppAPI\Service\AppAPIService::class);
1220
-			return $appAPIService->validateExAppRequestToNC($request);
1221
-		} catch (\Psr\Container\NotFoundExceptionInterface|\Psr\Container\ContainerExceptionInterface $e) {
1222
-			return false;
1223
-		}
1224
-	}
43
+    /**
44
+     * The installation path for Nextcloud  on the server (e.g. /srv/http/nextcloud)
45
+     */
46
+    public static string $SERVERROOT = '';
47
+    /**
48
+     * the current request path relative to the Nextcloud root (e.g. files/index.php)
49
+     */
50
+    private static string $SUBURI = '';
51
+    /**
52
+     * the Nextcloud root path for http requests (e.g. /nextcloud)
53
+     */
54
+    public static string $WEBROOT = '';
55
+    /**
56
+     * The installation path array of the apps folder on the server (e.g. /srv/http/nextcloud) 'path' and
57
+     * web path in 'url'
58
+     */
59
+    public static array $APPSROOTS = [];
60
+
61
+    public static string $configDir;
62
+
63
+    /**
64
+     * requested app
65
+     */
66
+    public static string $REQUESTEDAPP = '';
67
+
68
+    /**
69
+     * check if Nextcloud runs in cli mode
70
+     */
71
+    public static bool $CLI = false;
72
+
73
+    public static \Composer\Autoload\ClassLoader $composerAutoloader;
74
+
75
+    public static \OC\Server $server;
76
+
77
+    private static \OC\Config $config;
78
+
79
+    /**
80
+     * @throws \RuntimeException when the 3rdparty directory is missing or
81
+     *                           the app path list is empty or contains an invalid path
82
+     */
83
+    public static function initPaths(): void {
84
+        if (defined('PHPUNIT_CONFIG_DIR')) {
85
+            self::$configDir = OC::$SERVERROOT . '/' . PHPUNIT_CONFIG_DIR . '/';
86
+        } elseif (defined('PHPUNIT_RUN') && PHPUNIT_RUN && is_dir(OC::$SERVERROOT . '/tests/config/')) {
87
+            self::$configDir = OC::$SERVERROOT . '/tests/config/';
88
+        } elseif ($dir = getenv('NEXTCLOUD_CONFIG_DIR')) {
89
+            self::$configDir = rtrim($dir, '/') . '/';
90
+        } else {
91
+            self::$configDir = OC::$SERVERROOT . '/config/';
92
+        }
93
+        self::$config = new \OC\Config(self::$configDir);
94
+
95
+        OC::$SUBURI = str_replace('\\', '/', substr(realpath($_SERVER['SCRIPT_FILENAME'] ?? ''), strlen(OC::$SERVERROOT)));
96
+        /**
97
+         * FIXME: The following lines are required because we can't yet instantiate
98
+         *        Server::get(\OCP\IRequest::class) since \OC::$server does not yet exist.
99
+         */
100
+        $params = [
101
+            'server' => [
102
+                'SCRIPT_NAME' => $_SERVER['SCRIPT_NAME'] ?? null,
103
+                'SCRIPT_FILENAME' => $_SERVER['SCRIPT_FILENAME'] ?? null,
104
+            ],
105
+        ];
106
+        if (isset($_SERVER['REMOTE_ADDR'])) {
107
+            $params['server']['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'];
108
+        }
109
+        $fakeRequest = new \OC\AppFramework\Http\Request(
110
+            $params,
111
+            new \OC\AppFramework\Http\RequestId($_SERVER['UNIQUE_ID'] ?? '', new \OC\Security\SecureRandom()),
112
+            new \OC\AllConfig(new \OC\SystemConfig(self::$config))
113
+        );
114
+        $scriptName = $fakeRequest->getScriptName();
115
+        if (substr($scriptName, -1) == '/') {
116
+            $scriptName .= 'index.php';
117
+            //make sure suburi follows the same rules as scriptName
118
+            if (substr(OC::$SUBURI, -9) != 'index.php') {
119
+                if (substr(OC::$SUBURI, -1) != '/') {
120
+                    OC::$SUBURI = OC::$SUBURI . '/';
121
+                }
122
+                OC::$SUBURI = OC::$SUBURI . 'index.php';
123
+            }
124
+        }
125
+
126
+        if (OC::$CLI) {
127
+            OC::$WEBROOT = self::$config->getValue('overwritewebroot', '');
128
+        } else {
129
+            if (substr($scriptName, 0 - strlen(OC::$SUBURI)) === OC::$SUBURI) {
130
+                OC::$WEBROOT = substr($scriptName, 0, 0 - strlen(OC::$SUBURI));
131
+
132
+                if (OC::$WEBROOT != '' && OC::$WEBROOT[0] !== '/') {
133
+                    OC::$WEBROOT = '/' . OC::$WEBROOT;
134
+                }
135
+            } else {
136
+                // The scriptName is not ending with OC::$SUBURI
137
+                // This most likely means that we are calling from CLI.
138
+                // However some cron jobs still need to generate
139
+                // a web URL, so we use overwritewebroot as a fallback.
140
+                OC::$WEBROOT = self::$config->getValue('overwritewebroot', '');
141
+            }
142
+
143
+            // Resolve /nextcloud to /nextcloud/ to ensure to always have a trailing
144
+            // slash which is required by URL generation.
145
+            if (isset($_SERVER['REQUEST_URI']) && $_SERVER['REQUEST_URI'] === \OC::$WEBROOT
146
+                    && substr($_SERVER['REQUEST_URI'], -1) !== '/') {
147
+                header('Location: ' . \OC::$WEBROOT . '/');
148
+                exit();
149
+            }
150
+        }
151
+
152
+        // search the apps folder
153
+        $config_paths = self::$config->getValue('apps_paths', []);
154
+        if (!empty($config_paths)) {
155
+            foreach ($config_paths as $paths) {
156
+                if (isset($paths['url']) && isset($paths['path'])) {
157
+                    $paths['url'] = rtrim($paths['url'], '/');
158
+                    $paths['path'] = rtrim($paths['path'], '/');
159
+                    OC::$APPSROOTS[] = $paths;
160
+                }
161
+            }
162
+        } elseif (file_exists(OC::$SERVERROOT . '/apps')) {
163
+            OC::$APPSROOTS[] = ['path' => OC::$SERVERROOT . '/apps', 'url' => '/apps', 'writable' => true];
164
+        }
165
+
166
+        if (empty(OC::$APPSROOTS)) {
167
+            throw new \RuntimeException('apps directory not found! Please put the Nextcloud apps folder in the Nextcloud folder'
168
+                . '. You can also configure the location in the config.php file.');
169
+        }
170
+        $paths = [];
171
+        foreach (OC::$APPSROOTS as $path) {
172
+            $paths[] = $path['path'];
173
+            if (!is_dir($path['path'])) {
174
+                throw new \RuntimeException(sprintf('App directory "%s" not found! Please put the Nextcloud apps folder in the'
175
+                    . ' Nextcloud folder. You can also configure the location in the config.php file.', $path['path']));
176
+            }
177
+        }
178
+
179
+        // set the right include path
180
+        set_include_path(
181
+            implode(PATH_SEPARATOR, $paths)
182
+        );
183
+    }
184
+
185
+    public static function checkConfig(): void {
186
+        // Create config if it does not already exist
187
+        $configFilePath = self::$configDir . '/config.php';
188
+        if (!file_exists($configFilePath)) {
189
+            @touch($configFilePath);
190
+        }
191
+
192
+        // Check if config is writable
193
+        $configFileWritable = is_writable($configFilePath);
194
+        $configReadOnly = Server::get(IConfig::class)->getSystemValueBool('config_is_read_only');
195
+        if (!$configFileWritable && !$configReadOnly
196
+            || !$configFileWritable && \OCP\Util::needUpgrade()) {
197
+            $urlGenerator = Server::get(IURLGenerator::class);
198
+            $l = Server::get(\OCP\L10N\IFactory::class)->get('lib');
199
+
200
+            if (self::$CLI) {
201
+                echo $l->t('Cannot write into "config" directory!') . "\n";
202
+                echo $l->t('This can usually be fixed by giving the web server write access to the config directory.') . "\n";
203
+                echo "\n";
204
+                echo $l->t('But, if you prefer to keep config.php file read only, set the option "config_is_read_only" to true in it.') . "\n";
205
+                echo $l->t('See %s', [ $urlGenerator->linkToDocs('admin-config') ]) . "\n";
206
+                exit;
207
+            } else {
208
+                Server::get(ITemplateManager::class)->printErrorPage(
209
+                    $l->t('Cannot write into "config" directory!'),
210
+                    $l->t('This can usually be fixed by giving the web server write access to the config directory.') . ' '
211
+                    . $l->t('But, if you prefer to keep config.php file read only, set the option "config_is_read_only" to true in it.') . ' '
212
+                    . $l->t('See %s', [ $urlGenerator->linkToDocs('admin-config') ]),
213
+                    503
214
+                );
215
+            }
216
+        }
217
+    }
218
+
219
+    public static function checkInstalled(\OC\SystemConfig $systemConfig): void {
220
+        if (defined('OC_CONSOLE')) {
221
+            return;
222
+        }
223
+        // Redirect to installer if not installed
224
+        if (!$systemConfig->getValue('installed', false) && OC::$SUBURI !== '/index.php' && OC::$SUBURI !== '/status.php') {
225
+            if (OC::$CLI) {
226
+                throw new Exception('Not installed');
227
+            } else {
228
+                $url = OC::$WEBROOT . '/index.php';
229
+                header('Location: ' . $url);
230
+            }
231
+            exit();
232
+        }
233
+    }
234
+
235
+    public static function checkMaintenanceMode(\OC\SystemConfig $systemConfig): void {
236
+        // Allow ajax update script to execute without being stopped
237
+        if (((bool)$systemConfig->getValue('maintenance', false)) && OC::$SUBURI != '/core/ajax/update.php') {
238
+            // send http status 503
239
+            http_response_code(503);
240
+            header('X-Nextcloud-Maintenance-Mode: 1');
241
+            header('Retry-After: 120');
242
+
243
+            // render error page
244
+            $template = Server::get(ITemplateManager::class)->getTemplate('', 'update.user', 'guest');
245
+            \OCP\Util::addScript('core', 'maintenance');
246
+            \OCP\Util::addScript('core', 'common');
247
+            \OCP\Util::addStyle('core', 'guest');
248
+            $template->printPage();
249
+            die();
250
+        }
251
+    }
252
+
253
+    /**
254
+     * Prints the upgrade page
255
+     */
256
+    private static function printUpgradePage(\OC\SystemConfig $systemConfig): void {
257
+        $cliUpgradeLink = $systemConfig->getValue('upgrade.cli-upgrade-link', '');
258
+        $disableWebUpdater = $systemConfig->getValue('upgrade.disable-web', false);
259
+        $tooBig = false;
260
+        if (!$disableWebUpdater) {
261
+            $apps = Server::get(\OCP\App\IAppManager::class);
262
+            if ($apps->isEnabledForAnyone('user_ldap')) {
263
+                $qb = Server::get(\OCP\IDBConnection::class)->getQueryBuilder();
264
+
265
+                $result = $qb->select($qb->func()->count('*', 'user_count'))
266
+                    ->from('ldap_user_mapping')
267
+                    ->executeQuery();
268
+                $row = $result->fetch();
269
+                $result->closeCursor();
270
+
271
+                $tooBig = ($row['user_count'] > 50);
272
+            }
273
+            if (!$tooBig && $apps->isEnabledForAnyone('user_saml')) {
274
+                $qb = Server::get(\OCP\IDBConnection::class)->getQueryBuilder();
275
+
276
+                $result = $qb->select($qb->func()->count('*', 'user_count'))
277
+                    ->from('user_saml_users')
278
+                    ->executeQuery();
279
+                $row = $result->fetch();
280
+                $result->closeCursor();
281
+
282
+                $tooBig = ($row['user_count'] > 50);
283
+            }
284
+            if (!$tooBig) {
285
+                // count users
286
+                $totalUsers = Server::get(\OCP\IUserManager::class)->countUsersTotal(51);
287
+                $tooBig = ($totalUsers > 50);
288
+            }
289
+        }
290
+        $ignoreTooBigWarning = isset($_GET['IKnowThatThisIsABigInstanceAndTheUpdateRequestCouldRunIntoATimeoutAndHowToRestoreABackup'])
291
+            && $_GET['IKnowThatThisIsABigInstanceAndTheUpdateRequestCouldRunIntoATimeoutAndHowToRestoreABackup'] === 'IAmSuperSureToDoThis';
292
+
293
+        if ($disableWebUpdater || ($tooBig && !$ignoreTooBigWarning)) {
294
+            // send http status 503
295
+            http_response_code(503);
296
+            header('Retry-After: 120');
297
+
298
+            $serverVersion = \OCP\Server::get(\OCP\ServerVersion::class);
299
+
300
+            // render error page
301
+            $template = Server::get(ITemplateManager::class)->getTemplate('', 'update.use-cli', 'guest');
302
+            $template->assign('productName', 'nextcloud'); // for now
303
+            $template->assign('version', $serverVersion->getVersionString());
304
+            $template->assign('tooBig', $tooBig);
305
+            $template->assign('cliUpgradeLink', $cliUpgradeLink);
306
+
307
+            $template->printPage();
308
+            die();
309
+        }
310
+
311
+        // check whether this is a core update or apps update
312
+        $installedVersion = $systemConfig->getValue('version', '0.0.0');
313
+        $currentVersion = implode('.', \OCP\Util::getVersion());
314
+
315
+        // if not a core upgrade, then it's apps upgrade
316
+        $isAppsOnlyUpgrade = version_compare($currentVersion, $installedVersion, '=');
317
+
318
+        $oldTheme = $systemConfig->getValue('theme');
319
+        $systemConfig->setValue('theme', '');
320
+        \OCP\Util::addScript('core', 'common');
321
+        \OCP\Util::addScript('core', 'main');
322
+        \OCP\Util::addTranslations('core');
323
+        \OCP\Util::addScript('core', 'update');
324
+
325
+        /** @var \OC\App\AppManager $appManager */
326
+        $appManager = Server::get(\OCP\App\IAppManager::class);
327
+
328
+        $tmpl = Server::get(ITemplateManager::class)->getTemplate('', 'update.admin', 'guest');
329
+        $tmpl->assign('version', \OCP\Server::get(\OCP\ServerVersion::class)->getVersionString());
330
+        $tmpl->assign('isAppsOnlyUpgrade', $isAppsOnlyUpgrade);
331
+
332
+        // get third party apps
333
+        $ocVersion = \OCP\Util::getVersion();
334
+        $ocVersion = implode('.', $ocVersion);
335
+        $incompatibleApps = $appManager->getIncompatibleApps($ocVersion);
336
+        $incompatibleOverwrites = $systemConfig->getValue('app_install_overwrite', []);
337
+        $incompatibleShippedApps = [];
338
+        $incompatibleDisabledApps = [];
339
+        foreach ($incompatibleApps as $appInfo) {
340
+            if ($appManager->isShipped($appInfo['id'])) {
341
+                $incompatibleShippedApps[] = $appInfo['name'] . ' (' . $appInfo['id'] . ')';
342
+            }
343
+            if (!in_array($appInfo['id'], $incompatibleOverwrites)) {
344
+                $incompatibleDisabledApps[] = $appInfo;
345
+            }
346
+        }
347
+
348
+        if (!empty($incompatibleShippedApps)) {
349
+            $l = Server::get(\OCP\L10N\IFactory::class)->get('core');
350
+            $hint = $l->t('Application %1$s is not present or has a non-compatible version with this server. Please check the apps directory.', [implode(', ', $incompatibleShippedApps)]);
351
+            throw new \OCP\HintException('Application ' . implode(', ', $incompatibleShippedApps) . ' is not present or has a non-compatible version with this server. Please check the apps directory.', $hint);
352
+        }
353
+
354
+        $tmpl->assign('appsToUpgrade', $appManager->getAppsNeedingUpgrade($ocVersion));
355
+        $tmpl->assign('incompatibleAppsList', $incompatibleDisabledApps);
356
+        try {
357
+            $defaults = new \OC_Defaults();
358
+            $tmpl->assign('productName', $defaults->getName());
359
+        } catch (Throwable $error) {
360
+            $tmpl->assign('productName', 'Nextcloud');
361
+        }
362
+        $tmpl->assign('oldTheme', $oldTheme);
363
+        $tmpl->printPage();
364
+    }
365
+
366
+    public static function initSession(): void {
367
+        $request = Server::get(IRequest::class);
368
+
369
+        // TODO: Temporary disabled again to solve issues with CalDAV/CardDAV clients like DAVx5 that use cookies
370
+        // TODO: See https://github.com/nextcloud/server/issues/37277#issuecomment-1476366147 and the other comments
371
+        // TODO: for further information.
372
+        // $isDavRequest = strpos($request->getRequestUri(), '/remote.php/dav') === 0 || strpos($request->getRequestUri(), '/remote.php/webdav') === 0;
373
+        // if ($request->getHeader('Authorization') !== '' && is_null($request->getCookie('cookie_test')) && $isDavRequest && !isset($_COOKIE['nc_session_id'])) {
374
+        // setcookie('cookie_test', 'test', time() + 3600);
375
+        // // Do not initialize the session if a request is authenticated directly
376
+        // // unless there is a session cookie already sent along
377
+        // return;
378
+        // }
379
+
380
+        if ($request->getServerProtocol() === 'https') {
381
+            ini_set('session.cookie_secure', 'true');
382
+        }
383
+
384
+        // prevents javascript from accessing php session cookies
385
+        ini_set('session.cookie_httponly', 'true');
386
+
387
+        // set the cookie path to the Nextcloud directory
388
+        $cookie_path = OC::$WEBROOT ? : '/';
389
+        ini_set('session.cookie_path', $cookie_path);
390
+
391
+        // set the cookie domain to the Nextcloud domain
392
+        $cookie_domain = self::$config->getValue('cookie_domain', '');
393
+        if ($cookie_domain) {
394
+            ini_set('session.cookie_domain', $cookie_domain);
395
+        }
396
+
397
+        // Do not initialize sessions for 'status.php' requests
398
+        // Monitoring endpoints can quickly flood session handlers
399
+        // and 'status.php' doesn't require sessions anyway
400
+        // We still need to run the ini_set above so that same-site cookies use the correct configuration.
401
+        if (str_ends_with($request->getScriptName(), '/status.php')) {
402
+            return;
403
+        }
404
+
405
+        // Let the session name be changed in the initSession Hook
406
+        $sessionName = OC_Util::getInstanceId();
407
+
408
+        try {
409
+            $logger = null;
410
+            if (Server::get(\OC\SystemConfig::class)->getValue('installed', false)) {
411
+                $logger = logger('core');
412
+            }
413
+
414
+            // set the session name to the instance id - which is unique
415
+            $session = new \OC\Session\Internal(
416
+                $sessionName,
417
+                $logger,
418
+            );
419
+
420
+            $cryptoWrapper = Server::get(\OC\Session\CryptoWrapper::class);
421
+            $session = $cryptoWrapper->wrapSession($session);
422
+            self::$server->setSession($session);
423
+
424
+            // if session can't be started break with http 500 error
425
+        } catch (Exception $e) {
426
+            Server::get(LoggerInterface::class)->error($e->getMessage(), ['app' => 'base','exception' => $e]);
427
+            //show the user a detailed error page
428
+            Server::get(ITemplateManager::class)->printExceptionErrorPage($e, 500);
429
+            die();
430
+        }
431
+
432
+        //try to set the session lifetime
433
+        $sessionLifeTime = self::getSessionLifeTime();
434
+
435
+        // session timeout
436
+        if ($session->exists('LAST_ACTIVITY') && (time() - $session->get('LAST_ACTIVITY') > $sessionLifeTime)) {
437
+            if (isset($_COOKIE[session_name()])) {
438
+                setcookie(session_name(), '', -1, self::$WEBROOT ? : '/');
439
+            }
440
+            Server::get(IUserSession::class)->logout();
441
+        }
442
+
443
+        if (!self::hasSessionRelaxedExpiry()) {
444
+            $session->set('LAST_ACTIVITY', time());
445
+        }
446
+        $session->close();
447
+    }
448
+
449
+    private static function getSessionLifeTime(): int {
450
+        return Server::get(IConfig::class)->getSystemValueInt('session_lifetime', 60 * 60 * 24);
451
+    }
452
+
453
+    /**
454
+     * @return bool true if the session expiry should only be done by gc instead of an explicit timeout
455
+     */
456
+    public static function hasSessionRelaxedExpiry(): bool {
457
+        return Server::get(IConfig::class)->getSystemValueBool('session_relaxed_expiry', false);
458
+    }
459
+
460
+    /**
461
+     * Try to set some values to the required Nextcloud default
462
+     */
463
+    public static function setRequiredIniValues(): void {
464
+        // Don't display errors and log them
465
+        @ini_set('display_errors', '0');
466
+        @ini_set('log_errors', '1');
467
+
468
+        // Try to configure php to enable big file uploads.
469
+        // This doesn't work always depending on the webserver and php configuration.
470
+        // Let's try to overwrite some defaults if they are smaller than 1 hour
471
+
472
+        if (intval(@ini_get('max_execution_time') ?: 0) < 3600) {
473
+            @ini_set('max_execution_time', strval(3600));
474
+        }
475
+
476
+        if (intval(@ini_get('max_input_time') ?: 0) < 3600) {
477
+            @ini_set('max_input_time', strval(3600));
478
+        }
479
+
480
+        // Try to set the maximum execution time to the largest time limit we have
481
+        if (strpos(@ini_get('disable_functions'), 'set_time_limit') === false) {
482
+            @set_time_limit(max(intval(@ini_get('max_execution_time')), intval(@ini_get('max_input_time'))));
483
+        }
484
+
485
+        @ini_set('default_charset', 'UTF-8');
486
+        @ini_set('gd.jpeg_ignore_warning', '1');
487
+    }
488
+
489
+    /**
490
+     * Send the same site cookies
491
+     */
492
+    private static function sendSameSiteCookies(): void {
493
+        $cookieParams = session_get_cookie_params();
494
+        $secureCookie = ($cookieParams['secure'] === true) ? 'secure; ' : '';
495
+        $policies = [
496
+            'lax',
497
+            'strict',
498
+        ];
499
+
500
+        // Append __Host to the cookie if it meets the requirements
501
+        $cookiePrefix = '';
502
+        if ($cookieParams['secure'] === true && $cookieParams['path'] === '/') {
503
+            $cookiePrefix = '__Host-';
504
+        }
505
+
506
+        foreach ($policies as $policy) {
507
+            header(
508
+                sprintf(
509
+                    'Set-Cookie: %snc_sameSiteCookie%s=true; path=%s; httponly;' . $secureCookie . 'expires=Fri, 31-Dec-2100 23:59:59 GMT; SameSite=%s',
510
+                    $cookiePrefix,
511
+                    $policy,
512
+                    $cookieParams['path'],
513
+                    $policy
514
+                ),
515
+                false
516
+            );
517
+        }
518
+    }
519
+
520
+    /**
521
+     * Same Site cookie to further mitigate CSRF attacks. This cookie has to
522
+     * be set in every request if cookies are sent to add a second level of
523
+     * defense against CSRF.
524
+     *
525
+     * If the cookie is not sent this will set the cookie and reload the page.
526
+     * We use an additional cookie since we want to protect logout CSRF and
527
+     * also we can't directly interfere with PHP's session mechanism.
528
+     */
529
+    private static function performSameSiteCookieProtection(IConfig $config): void {
530
+        $request = Server::get(IRequest::class);
531
+
532
+        // Some user agents are notorious and don't really properly follow HTTP
533
+        // specifications. For those, have an automated opt-out. Since the protection
534
+        // for remote.php is applied in base.php as starting point we need to opt out
535
+        // here.
536
+        $incompatibleUserAgents = $config->getSystemValue('csrf.optout');
537
+
538
+        // Fallback, if csrf.optout is unset
539
+        if (!is_array($incompatibleUserAgents)) {
540
+            $incompatibleUserAgents = [
541
+                // OS X Finder
542
+                '/^WebDAVFS/',
543
+                // Windows webdav drive
544
+                '/^Microsoft-WebDAV-MiniRedir/',
545
+            ];
546
+        }
547
+
548
+        if ($request->isUserAgent($incompatibleUserAgents)) {
549
+            return;
550
+        }
551
+
552
+        if (count($_COOKIE) > 0) {
553
+            $requestUri = $request->getScriptName();
554
+            $processingScript = explode('/', $requestUri);
555
+            $processingScript = $processingScript[count($processingScript) - 1];
556
+
557
+            if ($processingScript === 'index.php' // index.php routes are handled in the middleware
558
+                || $processingScript === 'cron.php' // and cron.php does not need any authentication at all
559
+                || $processingScript === 'public.php' // For public.php, auth for password protected shares is done in the PublicAuth plugin
560
+            ) {
561
+                return;
562
+            }
563
+
564
+            // All other endpoints require the lax and the strict cookie
565
+            if (!$request->passesStrictCookieCheck()) {
566
+                logger('core')->warning('Request does not pass strict cookie check');
567
+                self::sendSameSiteCookies();
568
+                // Debug mode gets access to the resources without strict cookie
569
+                // due to the fact that the SabreDAV browser also lives there.
570
+                if (!$config->getSystemValueBool('debug', false)) {
571
+                    http_response_code(\OCP\AppFramework\Http::STATUS_PRECONDITION_FAILED);
572
+                    header('Content-Type: application/json');
573
+                    echo json_encode(['error' => 'Strict Cookie has not been found in request']);
574
+                    exit();
575
+                }
576
+            }
577
+        } elseif (!isset($_COOKIE['nc_sameSiteCookielax']) || !isset($_COOKIE['nc_sameSiteCookiestrict'])) {
578
+            self::sendSameSiteCookies();
579
+        }
580
+    }
581
+
582
+    /**
583
+     * This function adds some security related headers to all requests served via base.php
584
+     * The implementation of this function has to happen here to ensure that all third-party
585
+     * components (e.g. SabreDAV) also benefit from this headers.
586
+     */
587
+    private static function addSecurityHeaders(): void {
588
+        /**
589
+         * FIXME: Content Security Policy for legacy components. This
590
+         * can be removed once \OCP\AppFramework\Http\Response from the AppFramework
591
+         * is used everywhere.
592
+         * @see \OCP\AppFramework\Http\Response::getHeaders
593
+         */
594
+        $policy = 'default-src \'self\'; '
595
+            . 'script-src \'self\' \'nonce-' . \OC::$server->getContentSecurityPolicyNonceManager()->getNonce() . '\'; '
596
+            . 'style-src \'self\' \'unsafe-inline\'; '
597
+            . 'frame-src *; '
598
+            . 'img-src * data: blob:; '
599
+            . 'font-src \'self\' data:; '
600
+            . 'media-src *; '
601
+            . 'connect-src *; '
602
+            . 'object-src \'none\'; '
603
+            . 'base-uri \'self\'; ';
604
+        header('Content-Security-Policy:' . $policy);
605
+
606
+        // Send fallback headers for installations that don't have the possibility to send
607
+        // custom headers on the webserver side
608
+        if (getenv('modHeadersAvailable') !== 'true') {
609
+            header('Referrer-Policy: no-referrer'); // https://www.w3.org/TR/referrer-policy/
610
+            header('X-Content-Type-Options: nosniff'); // Disable sniffing the content type for IE
611
+            header('X-Frame-Options: SAMEORIGIN'); // Disallow iFraming from other domains
612
+            header('X-Permitted-Cross-Domain-Policies: none'); // https://www.adobe.com/devnet/adobe-media-server/articles/cross-domain-xml-for-streaming.html
613
+            header('X-Robots-Tag: noindex, nofollow'); // https://developers.google.com/webmasters/control-crawl-index/docs/robots_meta_tag
614
+        }
615
+    }
616
+
617
+    public static function init(): void {
618
+        // First handle PHP configuration and copy auth headers to the expected
619
+        // $_SERVER variable before doing anything Server object related
620
+        self::setRequiredIniValues();
621
+        self::handleAuthHeaders();
622
+
623
+        // prevent any XML processing from loading external entities
624
+        libxml_set_external_entity_loader(static function () {
625
+            return null;
626
+        });
627
+
628
+        // Set default timezone before the Server object is booted
629
+        if (!date_default_timezone_set('UTC')) {
630
+            throw new \RuntimeException('Could not set timezone to UTC');
631
+        }
632
+
633
+        // calculate the root directories
634
+        OC::$SERVERROOT = str_replace('\\', '/', substr(__DIR__, 0, -4));
635
+
636
+        // register autoloader
637
+        $loaderStart = microtime(true);
638
+
639
+        self::$CLI = (php_sapi_name() == 'cli');
640
+
641
+        // Add default composer PSR-4 autoloader, ensure apcu to be disabled
642
+        self::$composerAutoloader = require_once OC::$SERVERROOT . '/lib/composer/autoload.php';
643
+        self::$composerAutoloader->setApcuPrefix(null);
644
+
645
+
646
+        try {
647
+            self::initPaths();
648
+            // setup 3rdparty autoloader
649
+            $vendorAutoLoad = OC::$SERVERROOT . '/3rdparty/autoload.php';
650
+            if (!file_exists($vendorAutoLoad)) {
651
+                throw new \RuntimeException('Composer autoloader not found, unable to continue. Check the folder "3rdparty". Running "git submodule update --init" will initialize the git submodule that handles the subfolder "3rdparty".');
652
+            }
653
+            require_once $vendorAutoLoad;
654
+        } catch (\RuntimeException $e) {
655
+            if (!self::$CLI) {
656
+                http_response_code(503);
657
+            }
658
+            // we can't use the template error page here, because this needs the
659
+            // DI container which isn't available yet
660
+            print($e->getMessage());
661
+            exit();
662
+        }
663
+        $loaderEnd = microtime(true);
664
+
665
+        // Enable lazy loading if activated
666
+        \OC\AppFramework\Utility\SimpleContainer::$useLazyObjects = (bool)self::$config->getValue('enable_lazy_objects', true);
667
+
668
+        // setup the basic server
669
+        self::$server = new \OC\Server(\OC::$WEBROOT, self::$config);
670
+        self::$server->boot();
671
+
672
+        try {
673
+            $profiler = new BuiltInProfiler(
674
+                Server::get(IConfig::class),
675
+                Server::get(IRequest::class),
676
+            );
677
+            $profiler->start();
678
+        } catch (\Throwable $e) {
679
+            logger('core')->error('Failed to start profiler: ' . $e->getMessage(), ['app' => 'base']);
680
+        }
681
+
682
+        if (self::$CLI && in_array('--' . \OCP\Console\ReservedOptions::DEBUG_LOG, $_SERVER['argv'])) {
683
+            \OC\Core\Listener\BeforeMessageLoggedEventListener::setup();
684
+        }
685
+
686
+        $eventLogger = Server::get(\OCP\Diagnostics\IEventLogger::class);
687
+        $eventLogger->log('autoloader', 'Autoloader', $loaderStart, $loaderEnd);
688
+        $eventLogger->start('boot', 'Initialize');
689
+
690
+        // Override php.ini and log everything if we're troubleshooting
691
+        if (self::$config->getValue('loglevel') === ILogger::DEBUG) {
692
+            error_reporting(E_ALL);
693
+        }
694
+
695
+        // initialize intl fallback if necessary
696
+        OC_Util::isSetLocaleWorking();
697
+
698
+        $config = Server::get(IConfig::class);
699
+        if (!defined('PHPUNIT_RUN')) {
700
+            $errorHandler = new OC\Log\ErrorHandler(
701
+                \OCP\Server::get(\Psr\Log\LoggerInterface::class),
702
+            );
703
+            $exceptionHandler = [$errorHandler, 'onException'];
704
+            if ($config->getSystemValueBool('debug', false)) {
705
+                set_error_handler([$errorHandler, 'onAll'], E_ALL);
706
+                if (\OC::$CLI) {
707
+                    $exceptionHandler = [Server::get(ITemplateManager::class), 'printExceptionErrorPage'];
708
+                }
709
+            } else {
710
+                set_error_handler([$errorHandler, 'onError']);
711
+            }
712
+            register_shutdown_function([$errorHandler, 'onShutdown']);
713
+            set_exception_handler($exceptionHandler);
714
+        }
715
+
716
+        /** @var \OC\AppFramework\Bootstrap\Coordinator $bootstrapCoordinator */
717
+        $bootstrapCoordinator = Server::get(\OC\AppFramework\Bootstrap\Coordinator::class);
718
+        $bootstrapCoordinator->runInitialRegistration();
719
+
720
+        $eventLogger->start('init_session', 'Initialize session');
721
+
722
+        // Check for PHP SimpleXML extension earlier since we need it before our other checks and want to provide a useful hint for web users
723
+        // see https://github.com/nextcloud/server/pull/2619
724
+        if (!function_exists('simplexml_load_file')) {
725
+            throw new \OCP\HintException('The PHP SimpleXML/PHP-XML extension is not installed.', 'Install the extension or make sure it is enabled.');
726
+        }
727
+
728
+        $systemConfig = Server::get(\OC\SystemConfig::class);
729
+        $appManager = Server::get(\OCP\App\IAppManager::class);
730
+        if ($systemConfig->getValue('installed', false)) {
731
+            $appManager->loadApps(['session']);
732
+        }
733
+        if (!self::$CLI) {
734
+            self::initSession();
735
+        }
736
+        $eventLogger->end('init_session');
737
+        self::checkConfig();
738
+        self::checkInstalled($systemConfig);
739
+
740
+        self::addSecurityHeaders();
741
+
742
+        self::performSameSiteCookieProtection($config);
743
+
744
+        if (!defined('OC_CONSOLE')) {
745
+            $eventLogger->start('check_server', 'Run a few configuration checks');
746
+            $errors = OC_Util::checkServer($systemConfig);
747
+            if (count($errors) > 0) {
748
+                if (!self::$CLI) {
749
+                    http_response_code(503);
750
+                    Util::addStyle('guest');
751
+                    try {
752
+                        Server::get(ITemplateManager::class)->printGuestPage('', 'error', ['errors' => $errors]);
753
+                        exit;
754
+                    } catch (\Exception $e) {
755
+                        // In case any error happens when showing the error page, we simply fall back to posting the text.
756
+                        // This might be the case when e.g. the data directory is broken and we can not load/write SCSS to/from it.
757
+                    }
758
+                }
759
+
760
+                // Convert l10n string into regular string for usage in database
761
+                $staticErrors = [];
762
+                foreach ($errors as $error) {
763
+                    echo $error['error'] . "\n";
764
+                    echo $error['hint'] . "\n\n";
765
+                    $staticErrors[] = [
766
+                        'error' => (string)$error['error'],
767
+                        'hint' => (string)$error['hint'],
768
+                    ];
769
+                }
770
+
771
+                try {
772
+                    $config->setAppValue('core', 'cronErrors', json_encode($staticErrors));
773
+                } catch (\Exception $e) {
774
+                    echo('Writing to database failed');
775
+                }
776
+                exit(1);
777
+            } elseif (self::$CLI && $config->getSystemValueBool('installed', false)) {
778
+                $config->deleteAppValue('core', 'cronErrors');
779
+            }
780
+            $eventLogger->end('check_server');
781
+        }
782
+
783
+        // User and Groups
784
+        if (!$systemConfig->getValue('installed', false)) {
785
+            self::$server->getSession()->set('user_id', '');
786
+        }
787
+
788
+        $eventLogger->start('setup_backends', 'Setup group and user backends');
789
+        Server::get(\OCP\IUserManager::class)->registerBackend(new \OC\User\Database());
790
+        Server::get(\OCP\IGroupManager::class)->addBackend(new \OC\Group\Database());
791
+
792
+        // Subscribe to the hook
793
+        \OCP\Util::connectHook(
794
+            '\OCA\Files_Sharing\API\Server2Server',
795
+            'preLoginNameUsedAsUserName',
796
+            '\OC\User\Database',
797
+            'preLoginNameUsedAsUserName'
798
+        );
799
+
800
+        //setup extra user backends
801
+        if (!\OCP\Util::needUpgrade()) {
802
+            OC_User::setupBackends();
803
+        } else {
804
+            // Run upgrades in incognito mode
805
+            OC_User::setIncognitoMode(true);
806
+        }
807
+        $eventLogger->end('setup_backends');
808
+
809
+        self::registerCleanupHooks($systemConfig);
810
+        self::registerShareHooks($systemConfig);
811
+        self::registerEncryptionWrapperAndHooks();
812
+        self::registerAccountHooks();
813
+        self::registerResourceCollectionHooks();
814
+        self::registerFileReferenceEventListener();
815
+        self::registerRenderReferenceEventListener();
816
+        self::registerAppRestrictionsHooks();
817
+
818
+        // Make sure that the application class is not loaded before the database is setup
819
+        if ($systemConfig->getValue('installed', false)) {
820
+            $appManager->loadApp('settings');
821
+        }
822
+
823
+        //make sure temporary files are cleaned up
824
+        $tmpManager = Server::get(\OCP\ITempManager::class);
825
+        register_shutdown_function([$tmpManager, 'clean']);
826
+        $lockProvider = Server::get(\OCP\Lock\ILockingProvider::class);
827
+        register_shutdown_function([$lockProvider, 'releaseAll']);
828
+
829
+        // Check whether the sample configuration has been copied
830
+        if ($systemConfig->getValue('copied_sample_config', false)) {
831
+            $l = Server::get(\OCP\L10N\IFactory::class)->get('lib');
832
+            Server::get(ITemplateManager::class)->printErrorPage(
833
+                $l->t('Sample configuration detected'),
834
+                $l->t('It has been detected that the sample configuration has been copied. This can break your installation and is unsupported. Please read the documentation before performing changes on config.php'),
835
+                503
836
+            );
837
+            return;
838
+        }
839
+
840
+        $request = Server::get(IRequest::class);
841
+        $host = $request->getInsecureServerHost();
842
+        /**
843
+         * if the host passed in headers isn't trusted
844
+         * FIXME: Should not be in here at all :see_no_evil:
845
+         */
846
+        if (!OC::$CLI
847
+            && !Server::get(\OC\Security\TrustedDomainHelper::class)->isTrustedDomain($host)
848
+            && $config->getSystemValueBool('installed', false)
849
+        ) {
850
+            // Allow access to CSS resources
851
+            $isScssRequest = false;
852
+            if (strpos($request->getPathInfo() ?: '', '/css/') === 0) {
853
+                $isScssRequest = true;
854
+            }
855
+
856
+            if (substr($request->getRequestUri(), -11) === '/status.php') {
857
+                http_response_code(400);
858
+                header('Content-Type: application/json');
859
+                echo '{"error": "Trusted domain error.", "code": 15}';
860
+                exit();
861
+            }
862
+
863
+            if (!$isScssRequest) {
864
+                http_response_code(400);
865
+                Server::get(LoggerInterface::class)->info(
866
+                    'Trusted domain error. "{remoteAddress}" tried to access using "{host}" as host.',
867
+                    [
868
+                        'app' => 'core',
869
+                        'remoteAddress' => $request->getRemoteAddress(),
870
+                        'host' => $host,
871
+                    ]
872
+                );
873
+
874
+                $tmpl = Server::get(ITemplateManager::class)->getTemplate('core', 'untrustedDomain', 'guest');
875
+                $tmpl->assign('docUrl', Server::get(IURLGenerator::class)->linkToDocs('admin-trusted-domains'));
876
+                $tmpl->printPage();
877
+
878
+                exit();
879
+            }
880
+        }
881
+        $eventLogger->end('boot');
882
+        $eventLogger->log('init', 'OC::init', $loaderStart, microtime(true));
883
+        $eventLogger->start('runtime', 'Runtime');
884
+        $eventLogger->start('request', 'Full request after boot');
885
+        register_shutdown_function(function () use ($eventLogger) {
886
+            $eventLogger->end('request');
887
+        });
888
+
889
+        register_shutdown_function(function () {
890
+            $memoryPeak = memory_get_peak_usage();
891
+            $logLevel = match (true) {
892
+                $memoryPeak > 500_000_000 => ILogger::FATAL,
893
+                $memoryPeak > 400_000_000 => ILogger::ERROR,
894
+                $memoryPeak > 300_000_000 => ILogger::WARN,
895
+                default => null,
896
+            };
897
+            if ($logLevel !== null) {
898
+                $message = 'Request used more than 300 MB of RAM: ' . Util::humanFileSize($memoryPeak);
899
+                $logger = Server::get(LoggerInterface::class);
900
+                $logger->log($logLevel, $message, ['app' => 'core']);
901
+            }
902
+        });
903
+    }
904
+
905
+    /**
906
+     * register hooks for the cleanup of cache and bruteforce protection
907
+     */
908
+    public static function registerCleanupHooks(\OC\SystemConfig $systemConfig): void {
909
+        //don't try to do this before we are properly setup
910
+        if ($systemConfig->getValue('installed', false) && !\OCP\Util::needUpgrade()) {
911
+            // NOTE: This will be replaced to use OCP
912
+            $userSession = Server::get(\OC\User\Session::class);
913
+            $userSession->listen('\OC\User', 'postLogin', function () use ($userSession) {
914
+                if (!defined('PHPUNIT_RUN') && $userSession->isLoggedIn()) {
915
+                    // reset brute force delay for this IP address and username
916
+                    $uid = $userSession->getUser()->getUID();
917
+                    $request = Server::get(IRequest::class);
918
+                    $throttler = Server::get(IThrottler::class);
919
+                    $throttler->resetDelay($request->getRemoteAddress(), 'login', ['user' => $uid]);
920
+                }
921
+
922
+                try {
923
+                    $cache = new \OC\Cache\File();
924
+                    $cache->gc();
925
+                } catch (\OC\ServerNotAvailableException $e) {
926
+                    // not a GC exception, pass it on
927
+                    throw $e;
928
+                } catch (\OC\ForbiddenException $e) {
929
+                    // filesystem blocked for this request, ignore
930
+                } catch (\Exception $e) {
931
+                    // a GC exception should not prevent users from using OC,
932
+                    // so log the exception
933
+                    Server::get(LoggerInterface::class)->warning('Exception when running cache gc.', [
934
+                        'app' => 'core',
935
+                        'exception' => $e,
936
+                    ]);
937
+                }
938
+            });
939
+        }
940
+    }
941
+
942
+    private static function registerEncryptionWrapperAndHooks(): void {
943
+        /** @var \OC\Encryption\Manager */
944
+        $manager = Server::get(\OCP\Encryption\IManager::class);
945
+        Server::get(IEventDispatcher::class)->addListener(
946
+            BeforeFileSystemSetupEvent::class,
947
+            $manager->setupStorage(...),
948
+        );
949
+
950
+        $enabled = $manager->isEnabled();
951
+        if ($enabled) {
952
+            \OC\Encryption\EncryptionEventListener::register(Server::get(IEventDispatcher::class));
953
+        }
954
+    }
955
+
956
+    private static function registerAccountHooks(): void {
957
+        /** @var IEventDispatcher $dispatcher */
958
+        $dispatcher = Server::get(IEventDispatcher::class);
959
+        $dispatcher->addServiceListener(UserChangedEvent::class, \OC\Accounts\Hooks::class);
960
+    }
961
+
962
+    private static function registerAppRestrictionsHooks(): void {
963
+        /** @var \OC\Group\Manager $groupManager */
964
+        $groupManager = Server::get(\OCP\IGroupManager::class);
965
+        $groupManager->listen('\OC\Group', 'postDelete', function (\OCP\IGroup $group) {
966
+            $appManager = Server::get(\OCP\App\IAppManager::class);
967
+            $apps = $appManager->getEnabledAppsForGroup($group);
968
+            foreach ($apps as $appId) {
969
+                $restrictions = $appManager->getAppRestriction($appId);
970
+                if (empty($restrictions)) {
971
+                    continue;
972
+                }
973
+                $key = array_search($group->getGID(), $restrictions, true);
974
+                unset($restrictions[$key]);
975
+                $restrictions = array_values($restrictions);
976
+                if (empty($restrictions)) {
977
+                    $appManager->disableApp($appId);
978
+                } else {
979
+                    $appManager->enableAppForGroups($appId, $restrictions);
980
+                }
981
+            }
982
+        });
983
+    }
984
+
985
+    private static function registerResourceCollectionHooks(): void {
986
+        \OC\Collaboration\Resources\Listener::register(Server::get(IEventDispatcher::class));
987
+    }
988
+
989
+    private static function registerFileReferenceEventListener(): void {
990
+        \OC\Collaboration\Reference\File\FileReferenceEventListener::register(Server::get(IEventDispatcher::class));
991
+    }
992
+
993
+    private static function registerRenderReferenceEventListener() {
994
+        \OC\Collaboration\Reference\RenderReferenceEventListener::register(Server::get(IEventDispatcher::class));
995
+    }
996
+
997
+    /**
998
+     * register hooks for sharing
999
+     */
1000
+    public static function registerShareHooks(\OC\SystemConfig $systemConfig): void {
1001
+        if ($systemConfig->getValue('installed')) {
1002
+
1003
+            $dispatcher = Server::get(IEventDispatcher::class);
1004
+            $dispatcher->addServiceListener(UserRemovedEvent::class, UserRemovedListener::class);
1005
+            $dispatcher->addServiceListener(GroupDeletedEvent::class, GroupDeletedListener::class);
1006
+            $dispatcher->addServiceListener(UserDeletedEvent::class, UserDeletedListener::class);
1007
+        }
1008
+    }
1009
+
1010
+    /**
1011
+     * Handle the request
1012
+     */
1013
+    public static function handleRequest(): void {
1014
+        Server::get(\OCP\Diagnostics\IEventLogger::class)->start('handle_request', 'Handle request');
1015
+        $systemConfig = Server::get(\OC\SystemConfig::class);
1016
+
1017
+        // Check if Nextcloud is installed or in maintenance (update) mode
1018
+        if (!$systemConfig->getValue('installed', false)) {
1019
+            \OC::$server->getSession()->clear();
1020
+            $controller = Server::get(\OC\Core\Controller\SetupController::class);
1021
+            $controller->run($_POST);
1022
+            exit();
1023
+        }
1024
+
1025
+        $request = Server::get(IRequest::class);
1026
+        $request->throwDecodingExceptionIfAny();
1027
+        $requestPath = $request->getRawPathInfo();
1028
+        if ($requestPath === '/heartbeat') {
1029
+            return;
1030
+        }
1031
+        if (substr($requestPath, -3) !== '.js') { // we need these files during the upgrade
1032
+            self::checkMaintenanceMode($systemConfig);
1033
+
1034
+            if (\OCP\Util::needUpgrade()) {
1035
+                if (function_exists('opcache_reset')) {
1036
+                    opcache_reset();
1037
+                }
1038
+                if (!((bool)$systemConfig->getValue('maintenance', false))) {
1039
+                    self::printUpgradePage($systemConfig);
1040
+                    exit();
1041
+                }
1042
+            }
1043
+        }
1044
+
1045
+        $appManager = Server::get(\OCP\App\IAppManager::class);
1046
+
1047
+        // Always load authentication apps
1048
+        $appManager->loadApps(['authentication']);
1049
+        $appManager->loadApps(['extended_authentication']);
1050
+
1051
+        // Load minimum set of apps
1052
+        if (!\OCP\Util::needUpgrade()
1053
+            && !((bool)$systemConfig->getValue('maintenance', false))) {
1054
+            // For logged-in users: Load everything
1055
+            if (Server::get(IUserSession::class)->isLoggedIn()) {
1056
+                $appManager->loadApps();
1057
+            } else {
1058
+                // For guests: Load only filesystem and logging
1059
+                $appManager->loadApps(['filesystem', 'logging']);
1060
+
1061
+                // Don't try to login when a client is trying to get a OAuth token.
1062
+                // OAuth needs to support basic auth too, so the login is not valid
1063
+                // inside Nextcloud and the Login exception would ruin it.
1064
+                if ($request->getRawPathInfo() !== '/apps/oauth2/api/v1/token') {
1065
+                    try {
1066
+                        self::handleLogin($request);
1067
+                    } catch (DisabledUserException $e) {
1068
+                        // Disabled users would not be seen as logged in and
1069
+                        // trying to log them in would fail, so the login
1070
+                        // exception is ignored for the themed stylesheets and
1071
+                        // images.
1072
+                        if ($request->getRawPathInfo() !== '/apps/theming/theme/default.css'
1073
+                            && $request->getRawPathInfo() !== '/apps/theming/theme/light.css'
1074
+                            && $request->getRawPathInfo() !== '/apps/theming/theme/dark.css'
1075
+                            && $request->getRawPathInfo() !== '/apps/theming/theme/light-highcontrast.css'
1076
+                            && $request->getRawPathInfo() !== '/apps/theming/theme/dark-highcontrast.css'
1077
+                            && $request->getRawPathInfo() !== '/apps/theming/theme/opendyslexic.css'
1078
+                            && $request->getRawPathInfo() !== '/apps/theming/image/background'
1079
+                            && $request->getRawPathInfo() !== '/apps/theming/image/logo'
1080
+                            && $request->getRawPathInfo() !== '/apps/theming/image/logoheader'
1081
+                            && !str_starts_with($request->getRawPathInfo(), '/apps/theming/favicon')
1082
+                            && !str_starts_with($request->getRawPathInfo(), '/apps/theming/icon')) {
1083
+                            throw $e;
1084
+                        }
1085
+                    }
1086
+                }
1087
+            }
1088
+        }
1089
+
1090
+        if (!self::$CLI) {
1091
+            try {
1092
+                if (!\OCP\Util::needUpgrade()) {
1093
+                    $appManager->loadApps(['filesystem', 'logging']);
1094
+                    $appManager->loadApps();
1095
+                }
1096
+                Server::get(\OC\Route\Router::class)->match($request->getRawPathInfo());
1097
+                return;
1098
+            } catch (Symfony\Component\Routing\Exception\ResourceNotFoundException $e) {
1099
+                //header('HTTP/1.0 404 Not Found');
1100
+            } catch (Symfony\Component\Routing\Exception\MethodNotAllowedException $e) {
1101
+                http_response_code(405);
1102
+                return;
1103
+            }
1104
+        }
1105
+
1106
+        // Handle WebDAV
1107
+        if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'PROPFIND') {
1108
+            // not allowed any more to prevent people
1109
+            // mounting this root directly.
1110
+            // Users need to mount remote.php/webdav instead.
1111
+            http_response_code(405);
1112
+            return;
1113
+        }
1114
+
1115
+        // Handle requests for JSON or XML
1116
+        $acceptHeader = $request->getHeader('Accept');
1117
+        if (in_array($acceptHeader, ['application/json', 'application/xml'], true)) {
1118
+            http_response_code(404);
1119
+            return;
1120
+        }
1121
+
1122
+        // Handle resources that can't be found
1123
+        // This prevents browsers from redirecting to the default page and then
1124
+        // attempting to parse HTML as CSS and similar.
1125
+        $destinationHeader = $request->getHeader('Sec-Fetch-Dest');
1126
+        if (in_array($destinationHeader, ['font', 'script', 'style'])) {
1127
+            http_response_code(404);
1128
+            return;
1129
+        }
1130
+
1131
+        // Redirect to the default app or login only as an entry point
1132
+        if ($requestPath === '') {
1133
+            // Someone is logged in
1134
+            if (Server::get(IUserSession::class)->isLoggedIn()) {
1135
+                header('Location: ' . Server::get(IURLGenerator::class)->linkToDefaultPageUrl());
1136
+            } else {
1137
+                // Not handled and not logged in
1138
+                header('Location: ' . Server::get(IURLGenerator::class)->linkToRouteAbsolute('core.login.showLoginForm'));
1139
+            }
1140
+            return;
1141
+        }
1142
+
1143
+        try {
1144
+            Server::get(\OC\Route\Router::class)->match('/error/404');
1145
+        } catch (\Exception $e) {
1146
+            if (!$e instanceof MethodNotAllowedException) {
1147
+                logger('core')->emergency($e->getMessage(), ['exception' => $e]);
1148
+            }
1149
+            $l = Server::get(\OCP\L10N\IFactory::class)->get('lib');
1150
+            Server::get(ITemplateManager::class)->printErrorPage(
1151
+                '404',
1152
+                $l->t('The page could not be found on the server.'),
1153
+                404
1154
+            );
1155
+        }
1156
+    }
1157
+
1158
+    /**
1159
+     * Check login: apache auth, auth token, basic auth
1160
+     */
1161
+    public static function handleLogin(OCP\IRequest $request): bool {
1162
+        if ($request->getHeader('X-Nextcloud-Federation')) {
1163
+            return false;
1164
+        }
1165
+        $userSession = Server::get(\OC\User\Session::class);
1166
+        if (OC_User::handleApacheAuth()) {
1167
+            return true;
1168
+        }
1169
+        if (self::tryAppAPILogin($request)) {
1170
+            return true;
1171
+        }
1172
+        if ($userSession->tryTokenLogin($request)) {
1173
+            return true;
1174
+        }
1175
+        if (isset($_COOKIE['nc_username'])
1176
+            && isset($_COOKIE['nc_token'])
1177
+            && isset($_COOKIE['nc_session_id'])
1178
+            && $userSession->loginWithCookie($_COOKIE['nc_username'], $_COOKIE['nc_token'], $_COOKIE['nc_session_id'])) {
1179
+            return true;
1180
+        }
1181
+        if ($userSession->tryBasicAuthLogin($request, Server::get(IThrottler::class))) {
1182
+            return true;
1183
+        }
1184
+        return false;
1185
+    }
1186
+
1187
+    protected static function handleAuthHeaders(): void {
1188
+        //copy http auth headers for apache+php-fcgid work around
1189
+        if (isset($_SERVER['HTTP_XAUTHORIZATION']) && !isset($_SERVER['HTTP_AUTHORIZATION'])) {
1190
+            $_SERVER['HTTP_AUTHORIZATION'] = $_SERVER['HTTP_XAUTHORIZATION'];
1191
+        }
1192
+
1193
+        // Extract PHP_AUTH_USER/PHP_AUTH_PW from other headers if necessary.
1194
+        $vars = [
1195
+            'HTTP_AUTHORIZATION', // apache+php-cgi work around
1196
+            'REDIRECT_HTTP_AUTHORIZATION', // apache+php-cgi alternative
1197
+        ];
1198
+        foreach ($vars as $var) {
1199
+            if (isset($_SERVER[$var]) && is_string($_SERVER[$var]) && preg_match('/Basic\s+(.*)$/i', $_SERVER[$var], $matches)) {
1200
+                $credentials = explode(':', base64_decode($matches[1]), 2);
1201
+                if (count($credentials) === 2) {
1202
+                    $_SERVER['PHP_AUTH_USER'] = $credentials[0];
1203
+                    $_SERVER['PHP_AUTH_PW'] = $credentials[1];
1204
+                    break;
1205
+                }
1206
+            }
1207
+        }
1208
+    }
1209
+
1210
+    protected static function tryAppAPILogin(OCP\IRequest $request): bool {
1211
+        if (!$request->getHeader('AUTHORIZATION-APP-API')) {
1212
+            return false;
1213
+        }
1214
+        $appManager = Server::get(OCP\App\IAppManager::class);
1215
+        if (!$appManager->isEnabledForAnyone('app_api')) {
1216
+            return false;
1217
+        }
1218
+        try {
1219
+            $appAPIService = Server::get(OCA\AppAPI\Service\AppAPIService::class);
1220
+            return $appAPIService->validateExAppRequestToNC($request);
1221
+        } catch (\Psr\Container\NotFoundExceptionInterface|\Psr\Container\ContainerExceptionInterface $e) {
1222
+            return false;
1223
+        }
1224
+    }
1225 1225
 }
1226 1226
 
1227 1227
 OC::init();
Please login to merge, or discard this patch.
apps/settings/lib/Settings/Personal/PersonalInfo.php 1 patch
Indentation   +285 added lines, -285 removed lines patch added patch discarded remove patch
@@ -33,289 +33,289 @@
 block discarded – undo
33 33
 
34 34
 class PersonalInfo implements ISettings {
35 35
 
36
-	/** @var ProfileManager */
37
-	private $profileManager;
38
-
39
-	public function __construct(
40
-		private IConfig $config,
41
-		private IUserManager $userManager,
42
-		private IGroupManager $groupManager,
43
-		private IAccountManager $accountManager,
44
-		ProfileManager $profileManager,
45
-		private IAppManager $appManager,
46
-		private IFactory $l10nFactory,
47
-		private IL10N $l,
48
-		private IInitialState $initialStateService,
49
-		private IManager $manager,
50
-	) {
51
-		$this->profileManager = $profileManager;
52
-	}
53
-
54
-	public function getForm(): TemplateResponse {
55
-		$federationEnabled = $this->appManager->isEnabledForUser('federation');
56
-		$federatedFileSharingEnabled = $this->appManager->isEnabledForUser('federatedfilesharing');
57
-		$lookupServerUploadEnabled = false;
58
-		if ($federatedFileSharingEnabled) {
59
-			/** @var FederatedShareProvider $shareProvider */
60
-			$shareProvider = Server::get(FederatedShareProvider::class);
61
-			$lookupServerUploadEnabled = $shareProvider->isLookupServerUploadEnabled();
62
-		}
63
-
64
-		$uid = \OC_User::getUser();
65
-		$user = $this->userManager->get($uid);
66
-		$account = $this->accountManager->getAccount($user);
67
-
68
-		// make sure FS is setup before querying storage related stuff...
69
-		\OC_Util::setupFS($user->getUID());
70
-
71
-		$storageInfo = \OC_Helper::getStorageInfo('/');
72
-		if ($storageInfo['quota'] === FileInfo::SPACE_UNLIMITED) {
73
-			$totalSpace = $this->l->t('Unlimited');
74
-		} else {
75
-			$totalSpace = Util::humanFileSize($storageInfo['total']);
76
-		}
77
-
78
-		$messageParameters = $this->getMessageParameters($account);
79
-
80
-		$parameters = [
81
-			'lookupServerUploadEnabled' => $lookupServerUploadEnabled,
82
-			'isFairUseOfFreePushService' => $this->isFairUseOfFreePushService(),
83
-			'profileEnabledGlobally' => $this->profileManager->isProfileEnabled(),
84
-		] + $messageParameters;
85
-
86
-		$personalInfoParameters = [
87
-			'userId' => $uid,
88
-			'avatar' => $this->getProperty($account, IAccountManager::PROPERTY_AVATAR),
89
-			'groups' => $this->getGroups($user),
90
-			'quota' => $storageInfo['quota'],
91
-			'totalSpace' => $totalSpace,
92
-			'usage' => Util::humanFileSize($storageInfo['used']),
93
-			'usageRelative' => round($storageInfo['relative']),
94
-			'displayName' => $this->getProperty($account, IAccountManager::PROPERTY_DISPLAYNAME),
95
-			'emailMap' => $this->getEmailMap($account),
96
-			'phone' => $this->getProperty($account, IAccountManager::PROPERTY_PHONE),
97
-			'defaultPhoneRegion' => $this->config->getSystemValueString('default_phone_region'),
98
-			'location' => $this->getProperty($account, IAccountManager::PROPERTY_ADDRESS),
99
-			'website' => $this->getProperty($account, IAccountManager::PROPERTY_WEBSITE),
100
-			'twitter' => $this->getProperty($account, IAccountManager::PROPERTY_TWITTER),
101
-			'bluesky' => $this->getProperty($account, IAccountManager::PROPERTY_BLUESKY),
102
-			'fediverse' => $this->getProperty($account, IAccountManager::PROPERTY_FEDIVERSE),
103
-			'languageMap' => $this->getLanguageMap($user),
104
-			'localeMap' => $this->getLocaleMap($user),
105
-			'profileEnabledGlobally' => $this->profileManager->isProfileEnabled(),
106
-			'profileEnabled' => $this->profileManager->isProfileEnabled($user),
107
-			'organisation' => $this->getProperty($account, IAccountManager::PROPERTY_ORGANISATION),
108
-			'role' => $this->getProperty($account, IAccountManager::PROPERTY_ROLE),
109
-			'headline' => $this->getProperty($account, IAccountManager::PROPERTY_HEADLINE),
110
-			'biography' => $this->getProperty($account, IAccountManager::PROPERTY_BIOGRAPHY),
111
-			'birthdate' => $this->getProperty($account, IAccountManager::PROPERTY_BIRTHDATE),
112
-			'firstDayOfWeek' => $this->config->getUserValue($uid, 'core', AUserDataOCSController::USER_FIELD_FIRST_DAY_OF_WEEK),
113
-			'timezone' => $this->config->getUserValue($uid, 'core', 'timezone', ''),
114
-			'pronouns' => $this->getProperty($account, IAccountManager::PROPERTY_PRONOUNS),
115
-		];
116
-
117
-		$accountParameters = [
118
-			'avatarChangeSupported' => $user->canChangeAvatar(),
119
-			'displayNameChangeSupported' => $user->canChangeDisplayName(),
120
-			'emailChangeSupported' => $user->canChangeEmail(),
121
-			'federationEnabled' => $federationEnabled,
122
-			'lookupServerUploadEnabled' => $lookupServerUploadEnabled,
123
-		];
124
-
125
-		$profileParameters = [
126
-			'profileConfig' => $this->profileManager->getProfileConfigWithMetadata($user, $user),
127
-		];
128
-
129
-		$this->initialStateService->provideInitialState('profileEnabledGlobally', $this->profileManager->isProfileEnabled());
130
-		$this->initialStateService->provideInitialState('personalInfoParameters', $personalInfoParameters);
131
-		$this->initialStateService->provideInitialState('accountParameters', $accountParameters);
132
-		$this->initialStateService->provideInitialState('profileParameters', $profileParameters);
133
-
134
-		return new TemplateResponse('settings', 'settings/personal/personal.info', $parameters, '');
135
-	}
136
-
137
-	/**
138
-	 * Check if is fair use of free push service
139
-	 * @return boolean
140
-	 */
141
-	private function isFairUseOfFreePushService(): bool {
142
-		return $this->manager->isFairUseOfFreePushService();
143
-	}
144
-
145
-	/**
146
-	 * returns the property data in an
147
-	 * associative array
148
-	 */
149
-	private function getProperty(IAccount $account, string $property): array {
150
-		$property = [
151
-			'name' => $account->getProperty($property)->getName(),
152
-			'value' => $account->getProperty($property)->getValue(),
153
-			'scope' => $account->getProperty($property)->getScope(),
154
-			'verified' => $account->getProperty($property)->getVerified(),
155
-		];
156
-
157
-		return $property;
158
-	}
159
-
160
-	/**
161
-	 * returns the section ID string, e.g. 'sharing'
162
-	 * @since 9.1
163
-	 */
164
-	public function getSection(): string {
165
-		return 'personal-info';
166
-	}
167
-
168
-	/**
169
-	 * @return int whether the form should be rather on the top or bottom of
170
-	 *             the admin section. The forms are arranged in ascending order of the
171
-	 *             priority values. It is required to return a value between 0 and 100.
172
-	 *
173
-	 * E.g.: 70
174
-	 * @since 9.1
175
-	 */
176
-	public function getPriority(): int {
177
-		return 10;
178
-	}
179
-
180
-	/**
181
-	 * returns a sorted list of the user's group GIDs
182
-	 */
183
-	private function getGroups(IUser $user): array {
184
-		$groups = array_map(
185
-			static function (IGroup $group) {
186
-				return $group->getDisplayName();
187
-			},
188
-			$this->groupManager->getUserGroups($user)
189
-		);
190
-		sort($groups);
191
-
192
-		return $groups;
193
-	}
194
-
195
-	/**
196
-	 * returns the primary email and additional emails in an
197
-	 * associative array
198
-	 */
199
-	private function getEmailMap(IAccount $account): array {
200
-		$systemEmail = [
201
-			'name' => $account->getProperty(IAccountManager::PROPERTY_EMAIL)->getName(),
202
-			'value' => $account->getProperty(IAccountManager::PROPERTY_EMAIL)->getValue(),
203
-			'scope' => $account->getProperty(IAccountManager::PROPERTY_EMAIL)->getScope(),
204
-			'verified' => $account->getProperty(IAccountManager::PROPERTY_EMAIL)->getVerified(),
205
-		];
206
-
207
-		$additionalEmails = array_map(
208
-			function (IAccountProperty $property) {
209
-				return [
210
-					'name' => $property->getName(),
211
-					'value' => $property->getValue(),
212
-					'scope' => $property->getScope(),
213
-					'verified' => $property->getVerified(),
214
-					'locallyVerified' => $property->getLocallyVerified(),
215
-				];
216
-			},
217
-			$account->getPropertyCollection(IAccountManager::COLLECTION_EMAIL)->getProperties(),
218
-		);
219
-
220
-		$emailMap = [
221
-			'primaryEmail' => $systemEmail,
222
-			'additionalEmails' => $additionalEmails,
223
-			'notificationEmail' => (string)$account->getUser()->getPrimaryEMailAddress(),
224
-		];
225
-
226
-		return $emailMap;
227
-	}
228
-
229
-	/**
230
-	 * returns the user's active language, common languages, and other languages in an
231
-	 * associative array
232
-	 */
233
-	private function getLanguageMap(IUser $user): array {
234
-		$forceLanguage = $this->config->getSystemValue('force_language', false);
235
-		if ($forceLanguage !== false) {
236
-			return [];
237
-		}
238
-
239
-		$uid = $user->getUID();
240
-
241
-		$userConfLang = $this->config->getUserValue($uid, 'core', 'lang', $this->l10nFactory->findLanguage());
242
-		$languages = $this->l10nFactory->getLanguages();
243
-
244
-		// associate the user language with the proper array
245
-		$userLangIndex = array_search($userConfLang, array_column($languages['commonLanguages'], 'code'), true);
246
-		$userLang = $languages['commonLanguages'][$userLangIndex];
247
-		// search in the other languages
248
-		if ($userLangIndex === false) {
249
-			$userLangIndex = array_search($userConfLang, array_column($languages['otherLanguages'], 'code'), true);
250
-			$userLang = $languages['otherLanguages'][$userLangIndex];
251
-		}
252
-		// if user language is not available but set somehow: show the actual code as name
253
-		if (!is_array($userLang)) {
254
-			$userLang = [
255
-				'code' => $userConfLang,
256
-				'name' => $userConfLang,
257
-			];
258
-		}
259
-
260
-		return array_merge(
261
-			['activeLanguage' => $userLang],
262
-			$languages
263
-		);
264
-	}
265
-
266
-	private function getLocaleMap(IUser $user): array {
267
-		$forceLanguage = $this->config->getSystemValue('force_locale', false);
268
-		if ($forceLanguage !== false) {
269
-			return [];
270
-		}
271
-
272
-		$uid = $user->getUID();
273
-		$userLang = $this->config->getUserValue($uid, 'core', 'lang', $this->l10nFactory->findLanguage());
274
-		$userLocaleString = $this->config->getUserValue($uid, 'core', 'locale', $this->l10nFactory->findLocale($userLang));
275
-		$localeCodes = $this->l10nFactory->findAvailableLocales();
276
-		$userLocale = array_filter($localeCodes, fn ($value) => $userLocaleString === $value['code']);
277
-
278
-		if (!empty($userLocale)) {
279
-			$userLocale = reset($userLocale);
280
-		}
281
-
282
-		$localesForLanguage = array_values(array_filter($localeCodes, fn ($localeCode) => str_starts_with($localeCode['code'], $userLang)));
283
-		$otherLocales = array_values(array_filter($localeCodes, fn ($localeCode) => !str_starts_with($localeCode['code'], $userLang)));
284
-
285
-		if (!$userLocale) {
286
-			$userLocale = [
287
-				'code' => 'en',
288
-				'name' => 'English'
289
-			];
290
-		}
291
-
292
-		return [
293
-			'activeLocaleLang' => $userLocaleString,
294
-			'activeLocale' => $userLocale,
295
-			'localesForLanguage' => $localesForLanguage,
296
-			'otherLocales' => $otherLocales,
297
-		];
298
-	}
299
-
300
-	/**
301
-	 * returns the message parameters
302
-	 */
303
-	private function getMessageParameters(IAccount $account): array {
304
-		$needVerifyMessage = [IAccountManager::PROPERTY_EMAIL, IAccountManager::PROPERTY_WEBSITE, IAccountManager::PROPERTY_TWITTER];
305
-		$messageParameters = [];
306
-		foreach ($needVerifyMessage as $property) {
307
-			switch ($account->getProperty($property)->getVerified()) {
308
-				case IAccountManager::VERIFIED:
309
-					$message = $this->l->t('Verifying');
310
-					break;
311
-				case IAccountManager::VERIFICATION_IN_PROGRESS:
312
-					$message = $this->l->t('Verifying …');
313
-					break;
314
-				default:
315
-					$message = $this->l->t('Verify');
316
-			}
317
-			$messageParameters[$property . 'Message'] = $message;
318
-		}
319
-		return $messageParameters;
320
-	}
36
+    /** @var ProfileManager */
37
+    private $profileManager;
38
+
39
+    public function __construct(
40
+        private IConfig $config,
41
+        private IUserManager $userManager,
42
+        private IGroupManager $groupManager,
43
+        private IAccountManager $accountManager,
44
+        ProfileManager $profileManager,
45
+        private IAppManager $appManager,
46
+        private IFactory $l10nFactory,
47
+        private IL10N $l,
48
+        private IInitialState $initialStateService,
49
+        private IManager $manager,
50
+    ) {
51
+        $this->profileManager = $profileManager;
52
+    }
53
+
54
+    public function getForm(): TemplateResponse {
55
+        $federationEnabled = $this->appManager->isEnabledForUser('federation');
56
+        $federatedFileSharingEnabled = $this->appManager->isEnabledForUser('federatedfilesharing');
57
+        $lookupServerUploadEnabled = false;
58
+        if ($federatedFileSharingEnabled) {
59
+            /** @var FederatedShareProvider $shareProvider */
60
+            $shareProvider = Server::get(FederatedShareProvider::class);
61
+            $lookupServerUploadEnabled = $shareProvider->isLookupServerUploadEnabled();
62
+        }
63
+
64
+        $uid = \OC_User::getUser();
65
+        $user = $this->userManager->get($uid);
66
+        $account = $this->accountManager->getAccount($user);
67
+
68
+        // make sure FS is setup before querying storage related stuff...
69
+        \OC_Util::setupFS($user->getUID());
70
+
71
+        $storageInfo = \OC_Helper::getStorageInfo('/');
72
+        if ($storageInfo['quota'] === FileInfo::SPACE_UNLIMITED) {
73
+            $totalSpace = $this->l->t('Unlimited');
74
+        } else {
75
+            $totalSpace = Util::humanFileSize($storageInfo['total']);
76
+        }
77
+
78
+        $messageParameters = $this->getMessageParameters($account);
79
+
80
+        $parameters = [
81
+            'lookupServerUploadEnabled' => $lookupServerUploadEnabled,
82
+            'isFairUseOfFreePushService' => $this->isFairUseOfFreePushService(),
83
+            'profileEnabledGlobally' => $this->profileManager->isProfileEnabled(),
84
+        ] + $messageParameters;
85
+
86
+        $personalInfoParameters = [
87
+            'userId' => $uid,
88
+            'avatar' => $this->getProperty($account, IAccountManager::PROPERTY_AVATAR),
89
+            'groups' => $this->getGroups($user),
90
+            'quota' => $storageInfo['quota'],
91
+            'totalSpace' => $totalSpace,
92
+            'usage' => Util::humanFileSize($storageInfo['used']),
93
+            'usageRelative' => round($storageInfo['relative']),
94
+            'displayName' => $this->getProperty($account, IAccountManager::PROPERTY_DISPLAYNAME),
95
+            'emailMap' => $this->getEmailMap($account),
96
+            'phone' => $this->getProperty($account, IAccountManager::PROPERTY_PHONE),
97
+            'defaultPhoneRegion' => $this->config->getSystemValueString('default_phone_region'),
98
+            'location' => $this->getProperty($account, IAccountManager::PROPERTY_ADDRESS),
99
+            'website' => $this->getProperty($account, IAccountManager::PROPERTY_WEBSITE),
100
+            'twitter' => $this->getProperty($account, IAccountManager::PROPERTY_TWITTER),
101
+            'bluesky' => $this->getProperty($account, IAccountManager::PROPERTY_BLUESKY),
102
+            'fediverse' => $this->getProperty($account, IAccountManager::PROPERTY_FEDIVERSE),
103
+            'languageMap' => $this->getLanguageMap($user),
104
+            'localeMap' => $this->getLocaleMap($user),
105
+            'profileEnabledGlobally' => $this->profileManager->isProfileEnabled(),
106
+            'profileEnabled' => $this->profileManager->isProfileEnabled($user),
107
+            'organisation' => $this->getProperty($account, IAccountManager::PROPERTY_ORGANISATION),
108
+            'role' => $this->getProperty($account, IAccountManager::PROPERTY_ROLE),
109
+            'headline' => $this->getProperty($account, IAccountManager::PROPERTY_HEADLINE),
110
+            'biography' => $this->getProperty($account, IAccountManager::PROPERTY_BIOGRAPHY),
111
+            'birthdate' => $this->getProperty($account, IAccountManager::PROPERTY_BIRTHDATE),
112
+            'firstDayOfWeek' => $this->config->getUserValue($uid, 'core', AUserDataOCSController::USER_FIELD_FIRST_DAY_OF_WEEK),
113
+            'timezone' => $this->config->getUserValue($uid, 'core', 'timezone', ''),
114
+            'pronouns' => $this->getProperty($account, IAccountManager::PROPERTY_PRONOUNS),
115
+        ];
116
+
117
+        $accountParameters = [
118
+            'avatarChangeSupported' => $user->canChangeAvatar(),
119
+            'displayNameChangeSupported' => $user->canChangeDisplayName(),
120
+            'emailChangeSupported' => $user->canChangeEmail(),
121
+            'federationEnabled' => $federationEnabled,
122
+            'lookupServerUploadEnabled' => $lookupServerUploadEnabled,
123
+        ];
124
+
125
+        $profileParameters = [
126
+            'profileConfig' => $this->profileManager->getProfileConfigWithMetadata($user, $user),
127
+        ];
128
+
129
+        $this->initialStateService->provideInitialState('profileEnabledGlobally', $this->profileManager->isProfileEnabled());
130
+        $this->initialStateService->provideInitialState('personalInfoParameters', $personalInfoParameters);
131
+        $this->initialStateService->provideInitialState('accountParameters', $accountParameters);
132
+        $this->initialStateService->provideInitialState('profileParameters', $profileParameters);
133
+
134
+        return new TemplateResponse('settings', 'settings/personal/personal.info', $parameters, '');
135
+    }
136
+
137
+    /**
138
+     * Check if is fair use of free push service
139
+     * @return boolean
140
+     */
141
+    private function isFairUseOfFreePushService(): bool {
142
+        return $this->manager->isFairUseOfFreePushService();
143
+    }
144
+
145
+    /**
146
+     * returns the property data in an
147
+     * associative array
148
+     */
149
+    private function getProperty(IAccount $account, string $property): array {
150
+        $property = [
151
+            'name' => $account->getProperty($property)->getName(),
152
+            'value' => $account->getProperty($property)->getValue(),
153
+            'scope' => $account->getProperty($property)->getScope(),
154
+            'verified' => $account->getProperty($property)->getVerified(),
155
+        ];
156
+
157
+        return $property;
158
+    }
159
+
160
+    /**
161
+     * returns the section ID string, e.g. 'sharing'
162
+     * @since 9.1
163
+     */
164
+    public function getSection(): string {
165
+        return 'personal-info';
166
+    }
167
+
168
+    /**
169
+     * @return int whether the form should be rather on the top or bottom of
170
+     *             the admin section. The forms are arranged in ascending order of the
171
+     *             priority values. It is required to return a value between 0 and 100.
172
+     *
173
+     * E.g.: 70
174
+     * @since 9.1
175
+     */
176
+    public function getPriority(): int {
177
+        return 10;
178
+    }
179
+
180
+    /**
181
+     * returns a sorted list of the user's group GIDs
182
+     */
183
+    private function getGroups(IUser $user): array {
184
+        $groups = array_map(
185
+            static function (IGroup $group) {
186
+                return $group->getDisplayName();
187
+            },
188
+            $this->groupManager->getUserGroups($user)
189
+        );
190
+        sort($groups);
191
+
192
+        return $groups;
193
+    }
194
+
195
+    /**
196
+     * returns the primary email and additional emails in an
197
+     * associative array
198
+     */
199
+    private function getEmailMap(IAccount $account): array {
200
+        $systemEmail = [
201
+            'name' => $account->getProperty(IAccountManager::PROPERTY_EMAIL)->getName(),
202
+            'value' => $account->getProperty(IAccountManager::PROPERTY_EMAIL)->getValue(),
203
+            'scope' => $account->getProperty(IAccountManager::PROPERTY_EMAIL)->getScope(),
204
+            'verified' => $account->getProperty(IAccountManager::PROPERTY_EMAIL)->getVerified(),
205
+        ];
206
+
207
+        $additionalEmails = array_map(
208
+            function (IAccountProperty $property) {
209
+                return [
210
+                    'name' => $property->getName(),
211
+                    'value' => $property->getValue(),
212
+                    'scope' => $property->getScope(),
213
+                    'verified' => $property->getVerified(),
214
+                    'locallyVerified' => $property->getLocallyVerified(),
215
+                ];
216
+            },
217
+            $account->getPropertyCollection(IAccountManager::COLLECTION_EMAIL)->getProperties(),
218
+        );
219
+
220
+        $emailMap = [
221
+            'primaryEmail' => $systemEmail,
222
+            'additionalEmails' => $additionalEmails,
223
+            'notificationEmail' => (string)$account->getUser()->getPrimaryEMailAddress(),
224
+        ];
225
+
226
+        return $emailMap;
227
+    }
228
+
229
+    /**
230
+     * returns the user's active language, common languages, and other languages in an
231
+     * associative array
232
+     */
233
+    private function getLanguageMap(IUser $user): array {
234
+        $forceLanguage = $this->config->getSystemValue('force_language', false);
235
+        if ($forceLanguage !== false) {
236
+            return [];
237
+        }
238
+
239
+        $uid = $user->getUID();
240
+
241
+        $userConfLang = $this->config->getUserValue($uid, 'core', 'lang', $this->l10nFactory->findLanguage());
242
+        $languages = $this->l10nFactory->getLanguages();
243
+
244
+        // associate the user language with the proper array
245
+        $userLangIndex = array_search($userConfLang, array_column($languages['commonLanguages'], 'code'), true);
246
+        $userLang = $languages['commonLanguages'][$userLangIndex];
247
+        // search in the other languages
248
+        if ($userLangIndex === false) {
249
+            $userLangIndex = array_search($userConfLang, array_column($languages['otherLanguages'], 'code'), true);
250
+            $userLang = $languages['otherLanguages'][$userLangIndex];
251
+        }
252
+        // if user language is not available but set somehow: show the actual code as name
253
+        if (!is_array($userLang)) {
254
+            $userLang = [
255
+                'code' => $userConfLang,
256
+                'name' => $userConfLang,
257
+            ];
258
+        }
259
+
260
+        return array_merge(
261
+            ['activeLanguage' => $userLang],
262
+            $languages
263
+        );
264
+    }
265
+
266
+    private function getLocaleMap(IUser $user): array {
267
+        $forceLanguage = $this->config->getSystemValue('force_locale', false);
268
+        if ($forceLanguage !== false) {
269
+            return [];
270
+        }
271
+
272
+        $uid = $user->getUID();
273
+        $userLang = $this->config->getUserValue($uid, 'core', 'lang', $this->l10nFactory->findLanguage());
274
+        $userLocaleString = $this->config->getUserValue($uid, 'core', 'locale', $this->l10nFactory->findLocale($userLang));
275
+        $localeCodes = $this->l10nFactory->findAvailableLocales();
276
+        $userLocale = array_filter($localeCodes, fn ($value) => $userLocaleString === $value['code']);
277
+
278
+        if (!empty($userLocale)) {
279
+            $userLocale = reset($userLocale);
280
+        }
281
+
282
+        $localesForLanguage = array_values(array_filter($localeCodes, fn ($localeCode) => str_starts_with($localeCode['code'], $userLang)));
283
+        $otherLocales = array_values(array_filter($localeCodes, fn ($localeCode) => !str_starts_with($localeCode['code'], $userLang)));
284
+
285
+        if (!$userLocale) {
286
+            $userLocale = [
287
+                'code' => 'en',
288
+                'name' => 'English'
289
+            ];
290
+        }
291
+
292
+        return [
293
+            'activeLocaleLang' => $userLocaleString,
294
+            'activeLocale' => $userLocale,
295
+            'localesForLanguage' => $localesForLanguage,
296
+            'otherLocales' => $otherLocales,
297
+        ];
298
+    }
299
+
300
+    /**
301
+     * returns the message parameters
302
+     */
303
+    private function getMessageParameters(IAccount $account): array {
304
+        $needVerifyMessage = [IAccountManager::PROPERTY_EMAIL, IAccountManager::PROPERTY_WEBSITE, IAccountManager::PROPERTY_TWITTER];
305
+        $messageParameters = [];
306
+        foreach ($needVerifyMessage as $property) {
307
+            switch ($account->getProperty($property)->getVerified()) {
308
+                case IAccountManager::VERIFIED:
309
+                    $message = $this->l->t('Verifying');
310
+                    break;
311
+                case IAccountManager::VERIFICATION_IN_PROGRESS:
312
+                    $message = $this->l->t('Verifying …');
313
+                    break;
314
+                default:
315
+                    $message = $this->l->t('Verify');
316
+            }
317
+            $messageParameters[$property . 'Message'] = $message;
318
+        }
319
+        return $messageParameters;
320
+    }
321 321
 }
Please login to merge, or discard this patch.
apps/user_status/lib/Listener/UserLiveStatusListener.php 1 patch
Indentation   +73 added lines, -73 removed lines patch added patch discarded remove patch
@@ -29,87 +29,87 @@
 block discarded – undo
29 29
  * @template-implements IEventListener<UserLiveStatusEvent>
30 30
  */
31 31
 class UserLiveStatusListener implements IEventListener {
32
-	public function __construct(
33
-		private UserStatusMapper $mapper,
34
-		private StatusService $statusService,
35
-		private ITimeFactory $timeFactory,
36
-		private CalendarStatusService $calendarStatusService,
37
-		private LoggerInterface $logger,
38
-	) {
39
-	}
32
+    public function __construct(
33
+        private UserStatusMapper $mapper,
34
+        private StatusService $statusService,
35
+        private ITimeFactory $timeFactory,
36
+        private CalendarStatusService $calendarStatusService,
37
+        private LoggerInterface $logger,
38
+    ) {
39
+    }
40 40
 
41
-	/**
42
-	 * @inheritDoc
43
-	 */
44
-	public function handle(Event $event): void {
45
-		if (!($event instanceof UserLiveStatusEvent)) {
46
-			// Unrelated
47
-			return;
48
-		}
41
+    /**
42
+     * @inheritDoc
43
+     */
44
+    public function handle(Event $event): void {
45
+        if (!($event instanceof UserLiveStatusEvent)) {
46
+            // Unrelated
47
+            return;
48
+        }
49 49
 
50
-		$user = $event->getUser();
51
-		try {
52
-			$this->calendarStatusService->processCalendarStatus($user->getUID());
53
-			$userStatus = $this->statusService->findByUserId($user->getUID());
54
-		} catch (DoesNotExistException $ex) {
55
-			$userStatus = new UserStatus();
56
-			$userStatus->setUserId($user->getUID());
57
-			$userStatus->setStatus(IUserStatus::OFFLINE);
58
-			$userStatus->setStatusTimestamp(0);
59
-			$userStatus->setIsUserDefined(false);
60
-		}
50
+        $user = $event->getUser();
51
+        try {
52
+            $this->calendarStatusService->processCalendarStatus($user->getUID());
53
+            $userStatus = $this->statusService->findByUserId($user->getUID());
54
+        } catch (DoesNotExistException $ex) {
55
+            $userStatus = new UserStatus();
56
+            $userStatus->setUserId($user->getUID());
57
+            $userStatus->setStatus(IUserStatus::OFFLINE);
58
+            $userStatus->setStatusTimestamp(0);
59
+            $userStatus->setIsUserDefined(false);
60
+        }
61 61
 
62
-		// If the status is user-defined and one of the persistent status, we
63
-		// will not override it.
64
-		if ($userStatus->getIsUserDefined()
65
-			&& \in_array($userStatus->getStatus(), StatusService::PERSISTENT_STATUSES, true)) {
66
-			return;
67
-		}
62
+        // If the status is user-defined and one of the persistent status, we
63
+        // will not override it.
64
+        if ($userStatus->getIsUserDefined()
65
+            && \in_array($userStatus->getStatus(), StatusService::PERSISTENT_STATUSES, true)) {
66
+            return;
67
+        }
68 68
 
69
-		// Don't overwrite the "away" calendar status if it's set
70
-		if ($userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY) {
71
-			$event->setUserStatus(new ConnectorUserStatus($userStatus));
72
-			return;
73
-		}
69
+        // Don't overwrite the "away" calendar status if it's set
70
+        if ($userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY) {
71
+            $event->setUserStatus(new ConnectorUserStatus($userStatus));
72
+            return;
73
+        }
74 74
 
75
-		$needsUpdate = false;
75
+        $needsUpdate = false;
76 76
 
77
-		// If the current status is older than 5 minutes,
78
-		// treat it as outdated and update
79
-		if ($userStatus->getStatusTimestamp() < ($this->timeFactory->getTime() - StatusService::INVALIDATE_STATUS_THRESHOLD)) {
80
-			$needsUpdate = true;
81
-		}
77
+        // If the current status is older than 5 minutes,
78
+        // treat it as outdated and update
79
+        if ($userStatus->getStatusTimestamp() < ($this->timeFactory->getTime() - StatusService::INVALIDATE_STATUS_THRESHOLD)) {
80
+            $needsUpdate = true;
81
+        }
82 82
 
83
-		// If the emitted status is more important than the current status
84
-		// treat it as outdated and update
85
-		if (array_search($event->getStatus(), StatusService::PRIORITY_ORDERED_STATUSES, true) < array_search($userStatus->getStatus(), StatusService::PRIORITY_ORDERED_STATUSES, true)) {
86
-			$needsUpdate = true;
87
-		}
83
+        // If the emitted status is more important than the current status
84
+        // treat it as outdated and update
85
+        if (array_search($event->getStatus(), StatusService::PRIORITY_ORDERED_STATUSES, true) < array_search($userStatus->getStatus(), StatusService::PRIORITY_ORDERED_STATUSES, true)) {
86
+            $needsUpdate = true;
87
+        }
88 88
 
89
-		if ($needsUpdate) {
90
-			$userStatus->setStatus($event->getStatus());
91
-			$userStatus->setStatusTimestamp($event->getTimestamp());
92
-			$userStatus->setIsUserDefined(false);
89
+        if ($needsUpdate) {
90
+            $userStatus->setStatus($event->getStatus());
91
+            $userStatus->setStatusTimestamp($event->getTimestamp());
92
+            $userStatus->setIsUserDefined(false);
93 93
 
94
-			if ($userStatus->getId() === null) {
95
-				try {
96
-					$this->mapper->insert($userStatus);
97
-				} catch (Exception $e) {
98
-					if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
99
-						// A different process might have written another status
100
-						// update to the DB while we're processing our stuff.
101
-						// We can safely ignore it as we're only changing between AWAY and ONLINE
102
-						// and not doing anything with the message or icon.
103
-						$this->logger->debug('Unique constraint violation for live user status', ['exception' => $e]);
104
-						return;
105
-					}
106
-					throw $e;
107
-				}
108
-			} else {
109
-				$this->mapper->update($userStatus);
110
-			}
111
-		}
94
+            if ($userStatus->getId() === null) {
95
+                try {
96
+                    $this->mapper->insert($userStatus);
97
+                } catch (Exception $e) {
98
+                    if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
99
+                        // A different process might have written another status
100
+                        // update to the DB while we're processing our stuff.
101
+                        // We can safely ignore it as we're only changing between AWAY and ONLINE
102
+                        // and not doing anything with the message or icon.
103
+                        $this->logger->debug('Unique constraint violation for live user status', ['exception' => $e]);
104
+                        return;
105
+                    }
106
+                    throw $e;
107
+                }
108
+            } else {
109
+                $this->mapper->update($userStatus);
110
+            }
111
+        }
112 112
 
113
-		$event->setUserStatus(new ConnectorUserStatus($userStatus));
114
-	}
113
+        $event->setUserStatus(new ConnectorUserStatus($userStatus));
114
+    }
115 115
 }
Please login to merge, or discard this patch.
apps/dav/lib/Connector/Sabre/TagsPlugin.php 1 patch
Indentation   +238 added lines, -238 removed lines patch added patch discarded remove patch
@@ -37,267 +37,267 @@
 block discarded – undo
37 37
 
38 38
 class TagsPlugin extends \Sabre\DAV\ServerPlugin {
39 39
 
40
-	// namespace
41
-	public const NS_OWNCLOUD = 'http://owncloud.org/ns';
42
-	public const TAGS_PROPERTYNAME = '{http://owncloud.org/ns}tags';
43
-	public const FAVORITE_PROPERTYNAME = '{http://owncloud.org/ns}favorite';
44
-	public const TAG_FAVORITE = '_$!<Favorite>!$_';
40
+    // namespace
41
+    public const NS_OWNCLOUD = 'http://owncloud.org/ns';
42
+    public const TAGS_PROPERTYNAME = '{http://owncloud.org/ns}tags';
43
+    public const FAVORITE_PROPERTYNAME = '{http://owncloud.org/ns}favorite';
44
+    public const TAG_FAVORITE = '_$!<Favorite>!$_';
45 45
 
46
-	/**
47
-	 * Reference to main server object
48
-	 *
49
-	 * @var \Sabre\DAV\Server
50
-	 */
51
-	private $server;
46
+    /**
47
+     * Reference to main server object
48
+     *
49
+     * @var \Sabre\DAV\Server
50
+     */
51
+    private $server;
52 52
 
53
-	/**
54
-	 * @var ITags
55
-	 */
56
-	private $tagger;
53
+    /**
54
+     * @var ITags
55
+     */
56
+    private $tagger;
57 57
 
58
-	/**
59
-	 * Array of file id to tags array
60
-	 * The null value means the cache wasn't initialized.
61
-	 *
62
-	 * @var array
63
-	 */
64
-	private $cachedTags;
65
-	private array $cachedDirectories;
58
+    /**
59
+     * Array of file id to tags array
60
+     * The null value means the cache wasn't initialized.
61
+     *
62
+     * @var array
63
+     */
64
+    private $cachedTags;
65
+    private array $cachedDirectories;
66 66
 
67
-	/**
68
-	 * @param \Sabre\DAV\Tree $tree tree
69
-	 * @param ITagManager $tagManager tag manager
70
-	 */
71
-	public function __construct(
72
-		private \Sabre\DAV\Tree $tree,
73
-		private ITagManager $tagManager,
74
-		private IEventDispatcher $eventDispatcher,
75
-		private IUserSession $userSession,
76
-	) {
77
-		$this->tagger = null;
78
-		$this->cachedTags = [];
79
-	}
67
+    /**
68
+     * @param \Sabre\DAV\Tree $tree tree
69
+     * @param ITagManager $tagManager tag manager
70
+     */
71
+    public function __construct(
72
+        private \Sabre\DAV\Tree $tree,
73
+        private ITagManager $tagManager,
74
+        private IEventDispatcher $eventDispatcher,
75
+        private IUserSession $userSession,
76
+    ) {
77
+        $this->tagger = null;
78
+        $this->cachedTags = [];
79
+    }
80 80
 
81
-	/**
82
-	 * This initializes the plugin.
83
-	 *
84
-	 * This function is called by \Sabre\DAV\Server, after
85
-	 * addPlugin is called.
86
-	 *
87
-	 * This method should set up the required event subscriptions.
88
-	 *
89
-	 * @param \Sabre\DAV\Server $server
90
-	 * @return void
91
-	 */
92
-	public function initialize(\Sabre\DAV\Server $server) {
93
-		$server->xml->namespaceMap[self::NS_OWNCLOUD] = 'oc';
94
-		$server->xml->elementMap[self::TAGS_PROPERTYNAME] = TagList::class;
81
+    /**
82
+     * This initializes the plugin.
83
+     *
84
+     * This function is called by \Sabre\DAV\Server, after
85
+     * addPlugin is called.
86
+     *
87
+     * This method should set up the required event subscriptions.
88
+     *
89
+     * @param \Sabre\DAV\Server $server
90
+     * @return void
91
+     */
92
+    public function initialize(\Sabre\DAV\Server $server) {
93
+        $server->xml->namespaceMap[self::NS_OWNCLOUD] = 'oc';
94
+        $server->xml->elementMap[self::TAGS_PROPERTYNAME] = TagList::class;
95 95
 
96
-		$this->server = $server;
97
-		$this->server->on('preloadCollection', $this->preloadCollection(...));
98
-		$this->server->on('propFind', [$this, 'handleGetProperties']);
99
-		$this->server->on('propPatch', [$this, 'handleUpdateProperties']);
100
-		$this->server->on('preloadProperties', [$this, 'handlePreloadProperties']);
101
-	}
96
+        $this->server = $server;
97
+        $this->server->on('preloadCollection', $this->preloadCollection(...));
98
+        $this->server->on('propFind', [$this, 'handleGetProperties']);
99
+        $this->server->on('propPatch', [$this, 'handleUpdateProperties']);
100
+        $this->server->on('preloadProperties', [$this, 'handlePreloadProperties']);
101
+    }
102 102
 
103
-	/**
104
-	 * Returns the tagger
105
-	 *
106
-	 * @return ITags tagger
107
-	 */
108
-	private function getTagger() {
109
-		if (!$this->tagger) {
110
-			$this->tagger = $this->tagManager->load('files');
111
-		}
112
-		return $this->tagger;
113
-	}
103
+    /**
104
+     * Returns the tagger
105
+     *
106
+     * @return ITags tagger
107
+     */
108
+    private function getTagger() {
109
+        if (!$this->tagger) {
110
+            $this->tagger = $this->tagManager->load('files');
111
+        }
112
+        return $this->tagger;
113
+    }
114 114
 
115
-	/**
116
-	 * Returns tags and favorites.
117
-	 *
118
-	 * @param integer $fileId file id
119
-	 * @return array list($tags, $favorite) with $tags as tag array
120
-	 *               and $favorite is a boolean whether the file was favorited
121
-	 */
122
-	private function getTagsAndFav($fileId) {
123
-		$isFav = false;
124
-		$tags = $this->getTags($fileId);
125
-		if ($tags) {
126
-			$favPos = array_search(self::TAG_FAVORITE, $tags, true);
127
-			if ($favPos !== false) {
128
-				$isFav = true;
129
-				unset($tags[$favPos]);
130
-			}
131
-		}
132
-		return [$tags, $isFav];
133
-	}
115
+    /**
116
+     * Returns tags and favorites.
117
+     *
118
+     * @param integer $fileId file id
119
+     * @return array list($tags, $favorite) with $tags as tag array
120
+     *               and $favorite is a boolean whether the file was favorited
121
+     */
122
+    private function getTagsAndFav($fileId) {
123
+        $isFav = false;
124
+        $tags = $this->getTags($fileId);
125
+        if ($tags) {
126
+            $favPos = array_search(self::TAG_FAVORITE, $tags, true);
127
+            if ($favPos !== false) {
128
+                $isFav = true;
129
+                unset($tags[$favPos]);
130
+            }
131
+        }
132
+        return [$tags, $isFav];
133
+    }
134 134
 
135
-	/**
136
-	 * Returns tags for the given file id
137
-	 *
138
-	 * @param integer $fileId file id
139
-	 * @return array list of tags for that file
140
-	 */
141
-	private function getTags($fileId) {
142
-		if (isset($this->cachedTags[$fileId])) {
143
-			return $this->cachedTags[$fileId];
144
-		} else {
145
-			$tags = $this->getTagger()->getTagsForObjects([$fileId]);
146
-			if ($tags !== false) {
147
-				if (empty($tags)) {
148
-					return [];
149
-				}
150
-				return current($tags);
151
-			}
152
-		}
153
-		return null;
154
-	}
135
+    /**
136
+     * Returns tags for the given file id
137
+     *
138
+     * @param integer $fileId file id
139
+     * @return array list of tags for that file
140
+     */
141
+    private function getTags($fileId) {
142
+        if (isset($this->cachedTags[$fileId])) {
143
+            return $this->cachedTags[$fileId];
144
+        } else {
145
+            $tags = $this->getTagger()->getTagsForObjects([$fileId]);
146
+            if ($tags !== false) {
147
+                if (empty($tags)) {
148
+                    return [];
149
+                }
150
+                return current($tags);
151
+            }
152
+        }
153
+        return null;
154
+    }
155 155
 
156
-	/**
157
-	 * Prefetches tags for a list of file IDs and caches the results
158
-	 *
159
-	 * @param array $fileIds List of file IDs to prefetch tags for
160
-	 * @return void
161
-	 */
162
-	private function prefetchTagsForFileIds(array $fileIds) {
163
-		$tags = $this->getTagger()->getTagsForObjects($fileIds);
164
-		if ($tags === false) {
165
-			// the tags API returns false on error...
166
-			$tags = [];
167
-		}
156
+    /**
157
+     * Prefetches tags for a list of file IDs and caches the results
158
+     *
159
+     * @param array $fileIds List of file IDs to prefetch tags for
160
+     * @return void
161
+     */
162
+    private function prefetchTagsForFileIds(array $fileIds) {
163
+        $tags = $this->getTagger()->getTagsForObjects($fileIds);
164
+        if ($tags === false) {
165
+            // the tags API returns false on error...
166
+            $tags = [];
167
+        }
168 168
 
169
-		foreach ($fileIds as $fileId) {
170
-			$this->cachedTags[$fileId] = $tags[$fileId] ?? [];
171
-		}
172
-	}
169
+        foreach ($fileIds as $fileId) {
170
+            $this->cachedTags[$fileId] = $tags[$fileId] ?? [];
171
+        }
172
+    }
173 173
 
174
-	/**
175
-	 * Updates the tags of the given file id
176
-	 *
177
-	 * @param int $fileId
178
-	 * @param array $tags array of tag strings
179
-	 * @param string $path path of the file
180
-	 */
181
-	private function updateTags($fileId, $tags, string $path) {
182
-		$tagger = $this->getTagger();
183
-		$currentTags = $this->getTags($fileId);
174
+    /**
175
+     * Updates the tags of the given file id
176
+     *
177
+     * @param int $fileId
178
+     * @param array $tags array of tag strings
179
+     * @param string $path path of the file
180
+     */
181
+    private function updateTags($fileId, $tags, string $path) {
182
+        $tagger = $this->getTagger();
183
+        $currentTags = $this->getTags($fileId);
184 184
 
185
-		$newTags = array_diff($tags, $currentTags);
186
-		foreach ($newTags as $tag) {
187
-			if ($tag === self::TAG_FAVORITE) {
188
-				continue;
189
-			}
190
-			$tagger->tagAs($fileId, $tag, $path);
191
-		}
192
-		$deletedTags = array_diff($currentTags, $tags);
193
-		foreach ($deletedTags as $tag) {
194
-			if ($tag === self::TAG_FAVORITE) {
195
-				continue;
196
-			}
197
-			$tagger->unTag($fileId, $tag, $path);
198
-		}
199
-	}
185
+        $newTags = array_diff($tags, $currentTags);
186
+        foreach ($newTags as $tag) {
187
+            if ($tag === self::TAG_FAVORITE) {
188
+                continue;
189
+            }
190
+            $tagger->tagAs($fileId, $tag, $path);
191
+        }
192
+        $deletedTags = array_diff($currentTags, $tags);
193
+        foreach ($deletedTags as $tag) {
194
+            if ($tag === self::TAG_FAVORITE) {
195
+                continue;
196
+            }
197
+            $tagger->unTag($fileId, $tag, $path);
198
+        }
199
+    }
200 200
 
201
-	private function preloadCollection(PropFind $propFind, ICollection $collection):
202
-	void {
203
-		if (!($collection instanceof Node)) {
204
-			return;
205
-		}
201
+    private function preloadCollection(PropFind $propFind, ICollection $collection):
202
+    void {
203
+        if (!($collection instanceof Node)) {
204
+            return;
205
+        }
206 206
 
207
-		// need prefetch ?
208
-		if ($collection instanceof Directory
209
-			&& !isset($this->cachedDirectories[$collection->getPath()])
210
-			&& (!is_null($propFind->getStatus(self::TAGS_PROPERTYNAME))
211
-				|| !is_null($propFind->getStatus(self::FAVORITE_PROPERTYNAME))
212
-			)) {
213
-			// note: pre-fetching only supported for depth <= 1
214
-			$folderContent = $collection->getChildren();
215
-			$fileIds = [(int)$collection->getId()];
216
-			foreach ($folderContent as $info) {
217
-				$fileIds[] = (int)$info->getId();
218
-			}
219
-			$this->prefetchTagsForFileIds($fileIds);
220
-			$this->cachedDirectories[$collection->getPath()] = true;
221
-		}
222
-	}
207
+        // need prefetch ?
208
+        if ($collection instanceof Directory
209
+            && !isset($this->cachedDirectories[$collection->getPath()])
210
+            && (!is_null($propFind->getStatus(self::TAGS_PROPERTYNAME))
211
+                || !is_null($propFind->getStatus(self::FAVORITE_PROPERTYNAME))
212
+            )) {
213
+            // note: pre-fetching only supported for depth <= 1
214
+            $folderContent = $collection->getChildren();
215
+            $fileIds = [(int)$collection->getId()];
216
+            foreach ($folderContent as $info) {
217
+                $fileIds[] = (int)$info->getId();
218
+            }
219
+            $this->prefetchTagsForFileIds($fileIds);
220
+            $this->cachedDirectories[$collection->getPath()] = true;
221
+        }
222
+    }
223 223
 
224
-	/**
225
-	 * Adds tags and favorites properties to the response,
226
-	 * if requested.
227
-	 *
228
-	 * @param PropFind $propFind
229
-	 * @param \Sabre\DAV\INode $node
230
-	 * @return void
231
-	 */
232
-	public function handleGetProperties(
233
-		PropFind $propFind,
234
-		\Sabre\DAV\INode $node,
235
-	) {
236
-		if (!($node instanceof Node)) {
237
-			return;
238
-		}
224
+    /**
225
+     * Adds tags and favorites properties to the response,
226
+     * if requested.
227
+     *
228
+     * @param PropFind $propFind
229
+     * @param \Sabre\DAV\INode $node
230
+     * @return void
231
+     */
232
+    public function handleGetProperties(
233
+        PropFind $propFind,
234
+        \Sabre\DAV\INode $node,
235
+    ) {
236
+        if (!($node instanceof Node)) {
237
+            return;
238
+        }
239 239
 
240
-		$isFav = null;
240
+        $isFav = null;
241 241
 
242
-		$propFind->handle(self::TAGS_PROPERTYNAME, function () use (&$isFav, $node) {
243
-			[$tags, $isFav] = $this->getTagsAndFav($node->getId());
244
-			return new TagList($tags);
245
-		});
242
+        $propFind->handle(self::TAGS_PROPERTYNAME, function () use (&$isFav, $node) {
243
+            [$tags, $isFav] = $this->getTagsAndFav($node->getId());
244
+            return new TagList($tags);
245
+        });
246 246
 
247
-		$propFind->handle(self::FAVORITE_PROPERTYNAME, function () use ($isFav, $node) {
248
-			if (is_null($isFav)) {
249
-				[, $isFav] = $this->getTagsAndFav($node->getId());
250
-			}
251
-			if ($isFav) {
252
-				return 1;
253
-			} else {
254
-				return 0;
255
-			}
256
-		});
257
-	}
247
+        $propFind->handle(self::FAVORITE_PROPERTYNAME, function () use ($isFav, $node) {
248
+            if (is_null($isFav)) {
249
+                [, $isFav] = $this->getTagsAndFav($node->getId());
250
+            }
251
+            if ($isFav) {
252
+                return 1;
253
+            } else {
254
+                return 0;
255
+            }
256
+        });
257
+    }
258 258
 
259
-	/**
260
-	 * Updates tags and favorites properties, if applicable.
261
-	 *
262
-	 * @param string $path
263
-	 * @param PropPatch $propPatch
264
-	 *
265
-	 * @return void
266
-	 */
267
-	public function handleUpdateProperties($path, PropPatch $propPatch) {
268
-		$node = $this->tree->getNodeForPath($path);
269
-		if (!($node instanceof Node)) {
270
-			return;
271
-		}
259
+    /**
260
+     * Updates tags and favorites properties, if applicable.
261
+     *
262
+     * @param string $path
263
+     * @param PropPatch $propPatch
264
+     *
265
+     * @return void
266
+     */
267
+    public function handleUpdateProperties($path, PropPatch $propPatch) {
268
+        $node = $this->tree->getNodeForPath($path);
269
+        if (!($node instanceof Node)) {
270
+            return;
271
+        }
272 272
 
273
-		$propPatch->handle(self::TAGS_PROPERTYNAME, function ($tagList) use ($node, $path) {
274
-			$this->updateTags($node->getId(), $tagList->getTags(), $path);
275
-			return true;
276
-		});
273
+        $propPatch->handle(self::TAGS_PROPERTYNAME, function ($tagList) use ($node, $path) {
274
+            $this->updateTags($node->getId(), $tagList->getTags(), $path);
275
+            return true;
276
+        });
277 277
 
278
-		$propPatch->handle(self::FAVORITE_PROPERTYNAME, function ($favState) use ($node, $path) {
279
-			if ((int)$favState === 1 || $favState === 'true') {
280
-				$this->getTagger()->tagAs($node->getId(), self::TAG_FAVORITE, $path);
281
-			} else {
282
-				$this->getTagger()->unTag($node->getId(), self::TAG_FAVORITE, $path);
283
-			}
278
+        $propPatch->handle(self::FAVORITE_PROPERTYNAME, function ($favState) use ($node, $path) {
279
+            if ((int)$favState === 1 || $favState === 'true') {
280
+                $this->getTagger()->tagAs($node->getId(), self::TAG_FAVORITE, $path);
281
+            } else {
282
+                $this->getTagger()->unTag($node->getId(), self::TAG_FAVORITE, $path);
283
+            }
284 284
 
285
-			if (is_null($favState)) {
286
-				// confirm deletion
287
-				return 204;
288
-			}
285
+            if (is_null($favState)) {
286
+                // confirm deletion
287
+                return 204;
288
+            }
289 289
 
290
-			return 200;
291
-		});
292
-	}
290
+            return 200;
291
+        });
292
+    }
293 293
 
294
-	public function handlePreloadProperties(array $nodes, array $requestProperties): void {
295
-		if (
296
-			!in_array(self::FAVORITE_PROPERTYNAME, $requestProperties, true)
297
-			&& !in_array(self::TAGS_PROPERTYNAME, $requestProperties, true)
298
-		) {
299
-			return;
300
-		}
301
-		$this->prefetchTagsForFileIds(array_map(fn ($node) => $node->getId(), $nodes));
302
-	}
294
+    public function handlePreloadProperties(array $nodes, array $requestProperties): void {
295
+        if (
296
+            !in_array(self::FAVORITE_PROPERTYNAME, $requestProperties, true)
297
+            && !in_array(self::TAGS_PROPERTYNAME, $requestProperties, true)
298
+        ) {
299
+            return;
300
+        }
301
+        $this->prefetchTagsForFileIds(array_map(fn ($node) => $node->getId(), $nodes));
302
+    }
303 303
 }
Please login to merge, or discard this patch.
apps/user_ldap/lib/Helper.php 1 patch
Indentation   +286 added lines, -286 removed lines patch added patch discarded remove patch
@@ -14,290 +14,290 @@
 block discarded – undo
14 14
 use OCP\Server;
15 15
 
16 16
 class Helper {
17
-	/** @var CappedMemoryCache<string> */
18
-	protected CappedMemoryCache $sanitizeDnCache;
19
-
20
-	public function __construct(
21
-		private IAppConfig $appConfig,
22
-		private IDBConnection $connection,
23
-	) {
24
-		$this->sanitizeDnCache = new CappedMemoryCache(10000);
25
-	}
26
-
27
-	/**
28
-	 * returns prefixes for each saved LDAP/AD server configuration.
29
-	 *
30
-	 * @param bool $activeConfigurations optional, whether only active configuration shall be
31
-	 *                                   retrieved, defaults to false
32
-	 * @return array with a list of the available prefixes
33
-	 *
34
-	 * Configuration prefixes are used to set up configurations for n LDAP or
35
-	 * AD servers. Since configuration is stored in the database, table
36
-	 * appconfig under appid user_ldap, the common identifiers in column
37
-	 * 'configkey' have a prefix. The prefix for the very first server
38
-	 * configuration is empty.
39
-	 * Configkey Examples:
40
-	 * Server 1: ldap_login_filter
41
-	 * Server 2: s1_ldap_login_filter
42
-	 * Server 3: s2_ldap_login_filter
43
-	 *
44
-	 * The prefix needs to be passed to the constructor of Connection class,
45
-	 * except the default (first) server shall be connected to.
46
-	 *
47
-	 */
48
-	public function getServerConfigurationPrefixes(bool $activeConfigurations = false): array {
49
-		$all = $this->getAllServerConfigurationPrefixes();
50
-		if (!$activeConfigurations) {
51
-			return $all;
52
-		}
53
-		return array_values(array_filter(
54
-			$all,
55
-			fn (string $prefix): bool => ($this->appConfig->getValueString('user_ldap', $prefix . 'ldap_configuration_active') === '1')
56
-		));
57
-	}
58
-
59
-	protected function getAllServerConfigurationPrefixes(): array {
60
-		$unfilled = ['UNFILLED'];
61
-		$prefixes = $this->appConfig->getValueArray('user_ldap', 'configuration_prefixes', $unfilled);
62
-		if ($prefixes !== $unfilled) {
63
-			return $prefixes;
64
-		}
65
-
66
-		/* Fallback to browsing key for migration from Nextcloud<32 */
67
-		$referenceConfigkey = 'ldap_configuration_active';
68
-
69
-		$keys = $this->getServersConfig($referenceConfigkey);
70
-
71
-		$prefixes = [];
72
-		foreach ($keys as $key) {
73
-			$len = strlen($key) - strlen($referenceConfigkey);
74
-			$prefixes[] = substr($key, 0, $len);
75
-		}
76
-		sort($prefixes);
77
-
78
-		$this->appConfig->setValueArray('user_ldap', 'configuration_prefixes', $prefixes);
79
-
80
-		return $prefixes;
81
-	}
82
-
83
-	/**
84
-	 *
85
-	 * determines the host for every configured connection
86
-	 *
87
-	 * @return array<string,string> an array with configprefix as keys
88
-	 *
89
-	 */
90
-	public function getServerConfigurationHosts(): array {
91
-		$prefixes = $this->getServerConfigurationPrefixes();
92
-
93
-		$referenceConfigkey = 'ldap_host';
94
-		$result = [];
95
-		foreach ($prefixes as $prefix) {
96
-			$result[$prefix] = $this->appConfig->getValueString('user_ldap', $prefix . $referenceConfigkey);
97
-		}
98
-
99
-		return $result;
100
-	}
101
-
102
-	/**
103
-	 * return the next available configuration prefix and register it as used
104
-	 */
105
-	public function getNextServerConfigurationPrefix(): string {
106
-		$prefixes = $this->getServerConfigurationPrefixes();
107
-
108
-		if (count($prefixes) === 0) {
109
-			$prefix = 's01';
110
-		} else {
111
-			sort($prefixes);
112
-			$lastKey = end($prefixes);
113
-			$lastNumber = (int)str_replace('s', '', $lastKey);
114
-			$prefix = 's' . str_pad((string)($lastNumber + 1), 2, '0', STR_PAD_LEFT);
115
-		}
116
-
117
-		$prefixes[] = $prefix;
118
-		$this->appConfig->setValueArray('user_ldap', 'configuration_prefixes', $prefixes);
119
-		return $prefix;
120
-	}
121
-
122
-	private function getServersConfig(string $value): array {
123
-		$regex = '/' . $value . '$/S';
124
-
125
-		$keys = $this->appConfig->getKeys('user_ldap');
126
-		$result = [];
127
-		foreach ($keys as $key) {
128
-			if (preg_match($regex, $key) === 1) {
129
-				$result[] = $key;
130
-			}
131
-		}
132
-
133
-		return $result;
134
-	}
135
-
136
-	/**
137
-	 * deletes a given saved LDAP/AD server configuration.
138
-	 *
139
-	 * @param string $prefix the configuration prefix of the config to delete
140
-	 * @return bool true on success, false otherwise
141
-	 */
142
-	public function deleteServerConfiguration($prefix) {
143
-		$prefixes = $this->getServerConfigurationPrefixes();
144
-		$index = array_search($prefix, $prefixes, true);
145
-		if ($index === false) {
146
-			return false;
147
-		}
148
-
149
-		$query = $this->connection->getQueryBuilder();
150
-		$query->delete('appconfig')
151
-			->where($query->expr()->eq('appid', $query->createNamedParameter('user_ldap')))
152
-			->andWhere($query->expr()->like('configkey', $query->createNamedParameter((string)$prefix . '%')))
153
-			->andWhere($query->expr()->notIn('configkey', $query->createNamedParameter([
154
-				'enabled',
155
-				'installed_version',
156
-				'types',
157
-				'bgjUpdateGroupsLastRun',
158
-			], IQueryBuilder::PARAM_STR_ARRAY)));
159
-
160
-		if (empty($prefix)) {
161
-			$query->andWhere($query->expr()->notLike('configkey', $query->createNamedParameter('s%')));
162
-		}
163
-
164
-		$deletedRows = $query->executeStatement();
165
-
166
-		unset($prefixes[$index]);
167
-		$this->appConfig->setValueArray('user_ldap', 'configuration_prefixes', array_values($prefixes));
168
-
169
-		return $deletedRows !== 0;
170
-	}
171
-
172
-	/**
173
-	 * checks whether there is one or more disabled LDAP configurations
174
-	 */
175
-	public function haveDisabledConfigurations(): bool {
176
-		$all = $this->getServerConfigurationPrefixes();
177
-		foreach ($all as $prefix) {
178
-			if ($this->appConfig->getValueString('user_ldap', $prefix . 'ldap_configuration_active') !== '1') {
179
-				return true;
180
-			}
181
-		}
182
-		return false;
183
-	}
184
-
185
-	/**
186
-	 * extracts the domain from a given URL
187
-	 *
188
-	 * @param string $url the URL
189
-	 * @return string|false domain as string on success, false otherwise
190
-	 */
191
-	public function getDomainFromURL($url) {
192
-		$uinfo = parse_url($url);
193
-		if (!is_array($uinfo)) {
194
-			return false;
195
-		}
196
-
197
-		$domain = false;
198
-		if (isset($uinfo['host'])) {
199
-			$domain = $uinfo['host'];
200
-		} elseif (isset($uinfo['path'])) {
201
-			$domain = $uinfo['path'];
202
-		}
203
-
204
-		return $domain;
205
-	}
206
-
207
-	/**
208
-	 * sanitizes a DN received from the LDAP server
209
-	 *
210
-	 * This is used and done to have a stable format of DNs that can be compared
211
-	 * and identified again. The input DN value is modified as following:
212
-	 *
213
-	 * 1) whitespaces after commas are removed
214
-	 * 2) the DN is turned to lower-case
215
-	 * 3) the DN is escaped according to RFC 2253
216
-	 *
217
-	 * When a future DN is supposed to be used as a base parameter, it has to be
218
-	 * run through DNasBaseParameter() first, to recode \5c into a backslash
219
-	 * again, otherwise the search or read operation will fail with LDAP error
220
-	 * 32, NO_SUCH_OBJECT. Regular usage in LDAP filters requires the backslash
221
-	 * being escaped, however.
222
-	 *
223
-	 * Internally, DNs are stored in their sanitized form.
224
-	 *
225
-	 * @param array|string $dn the DN in question
226
-	 * @return array|string the sanitized DN
227
-	 */
228
-	public function sanitizeDN($dn) {
229
-		//treating multiple base DNs
230
-		if (is_array($dn)) {
231
-			$result = [];
232
-			foreach ($dn as $singleDN) {
233
-				$result[] = $this->sanitizeDN($singleDN);
234
-			}
235
-			return $result;
236
-		}
237
-
238
-		if (!is_string($dn)) {
239
-			throw new \LogicException('String expected ' . \gettype($dn) . ' given');
240
-		}
241
-
242
-		if (($sanitizedDn = $this->sanitizeDnCache->get($dn)) !== null) {
243
-			return $sanitizedDn;
244
-		}
245
-
246
-		//OID sometimes gives back DNs with whitespace after the comma
247
-		// a la "uid=foo, cn=bar, dn=..." We need to tackle this!
248
-		$sanitizedDn = preg_replace('/([^\\\]),(\s+)/u', '\1,', $dn);
249
-
250
-		//make comparisons and everything work
251
-		$sanitizedDn = mb_strtolower($sanitizedDn, 'UTF-8');
252
-
253
-		//escape DN values according to RFC 2253 – this is already done by ldap_explode_dn
254
-		//to use the DN in search filters, \ needs to be escaped to \5c additionally
255
-		//to use them in bases, we convert them back to simple backslashes in readAttribute()
256
-		$replacements = [
257
-			'\,' => '\5c2C',
258
-			'\=' => '\5c3D',
259
-			'\+' => '\5c2B',
260
-			'\<' => '\5c3C',
261
-			'\>' => '\5c3E',
262
-			'\;' => '\5c3B',
263
-			'\"' => '\5c22',
264
-			'\#' => '\5c23',
265
-			'(' => '\28',
266
-			')' => '\29',
267
-			'*' => '\2A',
268
-		];
269
-		$sanitizedDn = str_replace(array_keys($replacements), array_values($replacements), $sanitizedDn);
270
-		$this->sanitizeDnCache->set($dn, $sanitizedDn);
271
-
272
-		return $sanitizedDn;
273
-	}
274
-
275
-	/**
276
-	 * converts a stored DN so it can be used as base parameter for LDAP queries, internally we store them for usage in LDAP filters
277
-	 *
278
-	 * @param string $dn the DN
279
-	 * @return string
280
-	 */
281
-	public function DNasBaseParameter($dn) {
282
-		return str_ireplace('\\5c', '\\', $dn);
283
-	}
284
-
285
-	/**
286
-	 * listens to a hook thrown by server2server sharing and replaces the given
287
-	 * login name by a username, if it matches an LDAP user.
288
-	 *
289
-	 * @param array $param contains a reference to a $uid var under 'uid' key
290
-	 * @throws \Exception
291
-	 */
292
-	public static function loginName2UserName($param): void {
293
-		if (!isset($param['uid'])) {
294
-			throw new \Exception('key uid is expected to be set in $param');
295
-		}
296
-
297
-		$userBackend = Server::get(User_Proxy::class);
298
-		$uid = $userBackend->loginName2UserName($param['uid']);
299
-		if ($uid !== false) {
300
-			$param['uid'] = $uid;
301
-		}
302
-	}
17
+    /** @var CappedMemoryCache<string> */
18
+    protected CappedMemoryCache $sanitizeDnCache;
19
+
20
+    public function __construct(
21
+        private IAppConfig $appConfig,
22
+        private IDBConnection $connection,
23
+    ) {
24
+        $this->sanitizeDnCache = new CappedMemoryCache(10000);
25
+    }
26
+
27
+    /**
28
+     * returns prefixes for each saved LDAP/AD server configuration.
29
+     *
30
+     * @param bool $activeConfigurations optional, whether only active configuration shall be
31
+     *                                   retrieved, defaults to false
32
+     * @return array with a list of the available prefixes
33
+     *
34
+     * Configuration prefixes are used to set up configurations for n LDAP or
35
+     * AD servers. Since configuration is stored in the database, table
36
+     * appconfig under appid user_ldap, the common identifiers in column
37
+     * 'configkey' have a prefix. The prefix for the very first server
38
+     * configuration is empty.
39
+     * Configkey Examples:
40
+     * Server 1: ldap_login_filter
41
+     * Server 2: s1_ldap_login_filter
42
+     * Server 3: s2_ldap_login_filter
43
+     *
44
+     * The prefix needs to be passed to the constructor of Connection class,
45
+     * except the default (first) server shall be connected to.
46
+     *
47
+     */
48
+    public function getServerConfigurationPrefixes(bool $activeConfigurations = false): array {
49
+        $all = $this->getAllServerConfigurationPrefixes();
50
+        if (!$activeConfigurations) {
51
+            return $all;
52
+        }
53
+        return array_values(array_filter(
54
+            $all,
55
+            fn (string $prefix): bool => ($this->appConfig->getValueString('user_ldap', $prefix . 'ldap_configuration_active') === '1')
56
+        ));
57
+    }
58
+
59
+    protected function getAllServerConfigurationPrefixes(): array {
60
+        $unfilled = ['UNFILLED'];
61
+        $prefixes = $this->appConfig->getValueArray('user_ldap', 'configuration_prefixes', $unfilled);
62
+        if ($prefixes !== $unfilled) {
63
+            return $prefixes;
64
+        }
65
+
66
+        /* Fallback to browsing key for migration from Nextcloud<32 */
67
+        $referenceConfigkey = 'ldap_configuration_active';
68
+
69
+        $keys = $this->getServersConfig($referenceConfigkey);
70
+
71
+        $prefixes = [];
72
+        foreach ($keys as $key) {
73
+            $len = strlen($key) - strlen($referenceConfigkey);
74
+            $prefixes[] = substr($key, 0, $len);
75
+        }
76
+        sort($prefixes);
77
+
78
+        $this->appConfig->setValueArray('user_ldap', 'configuration_prefixes', $prefixes);
79
+
80
+        return $prefixes;
81
+    }
82
+
83
+    /**
84
+     *
85
+     * determines the host for every configured connection
86
+     *
87
+     * @return array<string,string> an array with configprefix as keys
88
+     *
89
+     */
90
+    public function getServerConfigurationHosts(): array {
91
+        $prefixes = $this->getServerConfigurationPrefixes();
92
+
93
+        $referenceConfigkey = 'ldap_host';
94
+        $result = [];
95
+        foreach ($prefixes as $prefix) {
96
+            $result[$prefix] = $this->appConfig->getValueString('user_ldap', $prefix . $referenceConfigkey);
97
+        }
98
+
99
+        return $result;
100
+    }
101
+
102
+    /**
103
+     * return the next available configuration prefix and register it as used
104
+     */
105
+    public function getNextServerConfigurationPrefix(): string {
106
+        $prefixes = $this->getServerConfigurationPrefixes();
107
+
108
+        if (count($prefixes) === 0) {
109
+            $prefix = 's01';
110
+        } else {
111
+            sort($prefixes);
112
+            $lastKey = end($prefixes);
113
+            $lastNumber = (int)str_replace('s', '', $lastKey);
114
+            $prefix = 's' . str_pad((string)($lastNumber + 1), 2, '0', STR_PAD_LEFT);
115
+        }
116
+
117
+        $prefixes[] = $prefix;
118
+        $this->appConfig->setValueArray('user_ldap', 'configuration_prefixes', $prefixes);
119
+        return $prefix;
120
+    }
121
+
122
+    private function getServersConfig(string $value): array {
123
+        $regex = '/' . $value . '$/S';
124
+
125
+        $keys = $this->appConfig->getKeys('user_ldap');
126
+        $result = [];
127
+        foreach ($keys as $key) {
128
+            if (preg_match($regex, $key) === 1) {
129
+                $result[] = $key;
130
+            }
131
+        }
132
+
133
+        return $result;
134
+    }
135
+
136
+    /**
137
+     * deletes a given saved LDAP/AD server configuration.
138
+     *
139
+     * @param string $prefix the configuration prefix of the config to delete
140
+     * @return bool true on success, false otherwise
141
+     */
142
+    public function deleteServerConfiguration($prefix) {
143
+        $prefixes = $this->getServerConfigurationPrefixes();
144
+        $index = array_search($prefix, $prefixes, true);
145
+        if ($index === false) {
146
+            return false;
147
+        }
148
+
149
+        $query = $this->connection->getQueryBuilder();
150
+        $query->delete('appconfig')
151
+            ->where($query->expr()->eq('appid', $query->createNamedParameter('user_ldap')))
152
+            ->andWhere($query->expr()->like('configkey', $query->createNamedParameter((string)$prefix . '%')))
153
+            ->andWhere($query->expr()->notIn('configkey', $query->createNamedParameter([
154
+                'enabled',
155
+                'installed_version',
156
+                'types',
157
+                'bgjUpdateGroupsLastRun',
158
+            ], IQueryBuilder::PARAM_STR_ARRAY)));
159
+
160
+        if (empty($prefix)) {
161
+            $query->andWhere($query->expr()->notLike('configkey', $query->createNamedParameter('s%')));
162
+        }
163
+
164
+        $deletedRows = $query->executeStatement();
165
+
166
+        unset($prefixes[$index]);
167
+        $this->appConfig->setValueArray('user_ldap', 'configuration_prefixes', array_values($prefixes));
168
+
169
+        return $deletedRows !== 0;
170
+    }
171
+
172
+    /**
173
+     * checks whether there is one or more disabled LDAP configurations
174
+     */
175
+    public function haveDisabledConfigurations(): bool {
176
+        $all = $this->getServerConfigurationPrefixes();
177
+        foreach ($all as $prefix) {
178
+            if ($this->appConfig->getValueString('user_ldap', $prefix . 'ldap_configuration_active') !== '1') {
179
+                return true;
180
+            }
181
+        }
182
+        return false;
183
+    }
184
+
185
+    /**
186
+     * extracts the domain from a given URL
187
+     *
188
+     * @param string $url the URL
189
+     * @return string|false domain as string on success, false otherwise
190
+     */
191
+    public function getDomainFromURL($url) {
192
+        $uinfo = parse_url($url);
193
+        if (!is_array($uinfo)) {
194
+            return false;
195
+        }
196
+
197
+        $domain = false;
198
+        if (isset($uinfo['host'])) {
199
+            $domain = $uinfo['host'];
200
+        } elseif (isset($uinfo['path'])) {
201
+            $domain = $uinfo['path'];
202
+        }
203
+
204
+        return $domain;
205
+    }
206
+
207
+    /**
208
+     * sanitizes a DN received from the LDAP server
209
+     *
210
+     * This is used and done to have a stable format of DNs that can be compared
211
+     * and identified again. The input DN value is modified as following:
212
+     *
213
+     * 1) whitespaces after commas are removed
214
+     * 2) the DN is turned to lower-case
215
+     * 3) the DN is escaped according to RFC 2253
216
+     *
217
+     * When a future DN is supposed to be used as a base parameter, it has to be
218
+     * run through DNasBaseParameter() first, to recode \5c into a backslash
219
+     * again, otherwise the search or read operation will fail with LDAP error
220
+     * 32, NO_SUCH_OBJECT. Regular usage in LDAP filters requires the backslash
221
+     * being escaped, however.
222
+     *
223
+     * Internally, DNs are stored in their sanitized form.
224
+     *
225
+     * @param array|string $dn the DN in question
226
+     * @return array|string the sanitized DN
227
+     */
228
+    public function sanitizeDN($dn) {
229
+        //treating multiple base DNs
230
+        if (is_array($dn)) {
231
+            $result = [];
232
+            foreach ($dn as $singleDN) {
233
+                $result[] = $this->sanitizeDN($singleDN);
234
+            }
235
+            return $result;
236
+        }
237
+
238
+        if (!is_string($dn)) {
239
+            throw new \LogicException('String expected ' . \gettype($dn) . ' given');
240
+        }
241
+
242
+        if (($sanitizedDn = $this->sanitizeDnCache->get($dn)) !== null) {
243
+            return $sanitizedDn;
244
+        }
245
+
246
+        //OID sometimes gives back DNs with whitespace after the comma
247
+        // a la "uid=foo, cn=bar, dn=..." We need to tackle this!
248
+        $sanitizedDn = preg_replace('/([^\\\]),(\s+)/u', '\1,', $dn);
249
+
250
+        //make comparisons and everything work
251
+        $sanitizedDn = mb_strtolower($sanitizedDn, 'UTF-8');
252
+
253
+        //escape DN values according to RFC 2253 – this is already done by ldap_explode_dn
254
+        //to use the DN in search filters, \ needs to be escaped to \5c additionally
255
+        //to use them in bases, we convert them back to simple backslashes in readAttribute()
256
+        $replacements = [
257
+            '\,' => '\5c2C',
258
+            '\=' => '\5c3D',
259
+            '\+' => '\5c2B',
260
+            '\<' => '\5c3C',
261
+            '\>' => '\5c3E',
262
+            '\;' => '\5c3B',
263
+            '\"' => '\5c22',
264
+            '\#' => '\5c23',
265
+            '(' => '\28',
266
+            ')' => '\29',
267
+            '*' => '\2A',
268
+        ];
269
+        $sanitizedDn = str_replace(array_keys($replacements), array_values($replacements), $sanitizedDn);
270
+        $this->sanitizeDnCache->set($dn, $sanitizedDn);
271
+
272
+        return $sanitizedDn;
273
+    }
274
+
275
+    /**
276
+     * converts a stored DN so it can be used as base parameter for LDAP queries, internally we store them for usage in LDAP filters
277
+     *
278
+     * @param string $dn the DN
279
+     * @return string
280
+     */
281
+    public function DNasBaseParameter($dn) {
282
+        return str_ireplace('\\5c', '\\', $dn);
283
+    }
284
+
285
+    /**
286
+     * listens to a hook thrown by server2server sharing and replaces the given
287
+     * login name by a username, if it matches an LDAP user.
288
+     *
289
+     * @param array $param contains a reference to a $uid var under 'uid' key
290
+     * @throws \Exception
291
+     */
292
+    public static function loginName2UserName($param): void {
293
+        if (!isset($param['uid'])) {
294
+            throw new \Exception('key uid is expected to be set in $param');
295
+        }
296
+
297
+        $userBackend = Server::get(User_Proxy::class);
298
+        $uid = $userBackend->loginName2UserName($param['uid']);
299
+        if ($uid !== false) {
300
+            $param['uid'] = $uid;
301
+        }
302
+    }
303 303
 }
Please login to merge, or discard this patch.