Completed
Push — master ( 14f798...42f450 )
by
unknown
31:50 queued 14s
created
apps/dav/lib/Connector/Sabre/ServerFactory.php 1 patch
Indentation   +196 added lines, -196 removed lines patch added patch discarded remove patch
@@ -44,200 +44,200 @@
 block discarded – undo
44 44
 
45 45
 class ServerFactory {
46 46
 
47
-	public function __construct(
48
-		private IConfig $config,
49
-		private LoggerInterface $logger,
50
-		private IDBConnection $databaseConnection,
51
-		private IUserSession $userSession,
52
-		private IMountManager $mountManager,
53
-		private ITagManager $tagManager,
54
-		private IRequest $request,
55
-		private IPreview $previewManager,
56
-		private IEventDispatcher $eventDispatcher,
57
-		private IL10N $l10n,
58
-	) {
59
-	}
60
-
61
-	/**
62
-	 * @param callable $viewCallBack callback that should return the view for the dav endpoint
63
-	 */
64
-	public function createServer(
65
-		bool $isPublicShare,
66
-		string $baseUri,
67
-		string $requestUri,
68
-		Plugin $authPlugin,
69
-		callable $viewCallBack,
70
-	): Server {
71
-		// Fire up server
72
-		if ($isPublicShare) {
73
-			$rootCollection = new SimpleCollection('root');
74
-			$tree = new CachingTree($rootCollection);
75
-		} else {
76
-			$rootCollection = null;
77
-			$tree = new ObjectTree();
78
-		}
79
-		$server = new Server($tree);
80
-		// Set URL explicitly due to reverse-proxy situations
81
-		$server->httpRequest->setUrl($requestUri);
82
-		$server->setBaseUri($baseUri);
83
-
84
-		// Load plugins
85
-		$server->addPlugin(new MaintenancePlugin($this->config, $this->l10n));
86
-		$server->addPlugin(new BlockLegacyClientPlugin(
87
-			$this->config,
88
-			\OCP\Server::get(ThemingDefaults::class),
89
-		));
90
-		$server->addPlugin(new AnonymousOptionsPlugin());
91
-		$server->addPlugin($authPlugin);
92
-		// FIXME: The following line is a workaround for legacy components relying on being able to send a GET to /
93
-		$server->addPlugin(new DummyGetResponsePlugin());
94
-		$server->addPlugin(new ExceptionLoggerPlugin('webdav', $this->logger));
95
-		$server->addPlugin(new LockPlugin());
96
-
97
-		$server->addPlugin(new RequestIdHeaderPlugin($this->request));
98
-
99
-		$server->addPlugin(new ZipFolderPlugin(
100
-			$tree,
101
-			$this->logger,
102
-			$this->eventDispatcher,
103
-		));
104
-
105
-		// Some WebDAV clients do require Class 2 WebDAV support (locking), since
106
-		// we do not provide locking we emulate it using a fake locking plugin.
107
-		if ($this->request->isUserAgent([
108
-			'/WebDAVFS/',
109
-			'/OneNote/',
110
-			'/Microsoft-WebDAV-MiniRedir/',
111
-		])) {
112
-			$server->addPlugin(new FakeLockerPlugin());
113
-		}
114
-
115
-		if (BrowserErrorPagePlugin::isBrowserRequest($this->request)) {
116
-			$server->addPlugin(new BrowserErrorPagePlugin());
117
-		}
118
-
119
-		// wait with registering these until auth is handled and the filesystem is setup
120
-		$server->on('beforeMethod:*', function () use ($server, $tree, $viewCallBack, $isPublicShare, $rootCollection): void {
121
-			// ensure the skeleton is copied
122
-			$userFolder = \OC::$server->getUserFolder();
123
-
124
-			/** @var View $view */
125
-			$view = $viewCallBack($server);
126
-			if ($userFolder instanceof Folder && $userFolder->getPath() === $view->getRoot()) {
127
-				$rootInfo = $userFolder;
128
-			} else {
129
-				$rootInfo = $view->getFileInfo('');
130
-			}
131
-
132
-			// Create Nextcloud Dir
133
-			if ($rootInfo->getType() === 'dir') {
134
-				$root = new Directory($view, $rootInfo, $tree);
135
-			} else {
136
-				$root = new File($view, $rootInfo);
137
-			}
138
-
139
-			if ($isPublicShare) {
140
-				$userPrincipalBackend = new Principal(
141
-					\OCP\Server::get(IUserManager::class),
142
-					\OCP\Server::get(IGroupManager::class),
143
-					\OCP\Server::get(IAccountManager::class),
144
-					\OCP\Server::get(\OCP\Share\IManager::class),
145
-					\OCP\Server::get(IUserSession::class),
146
-					\OCP\Server::get(IAppManager::class),
147
-					\OCP\Server::get(ProxyMapper::class),
148
-					\OCP\Server::get(KnownUserService::class),
149
-					\OCP\Server::get(IConfig::class),
150
-					\OC::$server->getL10NFactory(),
151
-				);
152
-
153
-				// Mount the share collection at /public.php/dav/shares/<share token>
154
-				$rootCollection->addChild(new RootCollection(
155
-					$root,
156
-					$userPrincipalBackend,
157
-					'principals/shares',
158
-				));
159
-
160
-				// Mount the upload collection at /public.php/dav/uploads/<share token>
161
-				$rootCollection->addChild(new \OCA\DAV\Upload\RootCollection(
162
-					$userPrincipalBackend,
163
-					'principals/shares',
164
-					\OCP\Server::get(CleanupService::class),
165
-					\OCP\Server::get(IRootFolder::class),
166
-					\OCP\Server::get(IUserSession::class),
167
-					\OCP\Server::get(\OCP\Share\IManager::class),
168
-				));
169
-			} else {
170
-				/** @var ObjectTree $tree */
171
-				$tree->init($root, $view, $this->mountManager);
172
-			}
173
-
174
-			$server->addPlugin(
175
-				new FilesPlugin(
176
-					$tree,
177
-					$this->config,
178
-					$this->request,
179
-					$this->previewManager,
180
-					$this->userSession,
181
-					\OCP\Server::get(IFilenameValidator::class),
182
-					\OCP\Server::get(IAccountManager::class),
183
-					false,
184
-					!$this->config->getSystemValue('debug', false)
185
-				)
186
-			);
187
-			$server->addPlugin(new QuotaPlugin($view, true));
188
-			$server->addPlugin(new ChecksumUpdatePlugin());
189
-
190
-			// Allow view-only plugin for webdav requests
191
-			$server->addPlugin(new ViewOnlyPlugin(
192
-				$userFolder,
193
-			));
194
-
195
-			if ($this->userSession->isLoggedIn()) {
196
-				$server->addPlugin(new TagsPlugin($tree, $this->tagManager, $this->eventDispatcher, $this->userSession));
197
-				$server->addPlugin(new SharesPlugin(
198
-					$tree,
199
-					$this->userSession,
200
-					$userFolder,
201
-					\OCP\Server::get(\OCP\Share\IManager::class)
202
-				));
203
-				$server->addPlugin(new CommentPropertiesPlugin(\OCP\Server::get(ICommentsManager::class), $this->userSession));
204
-				$server->addPlugin(new FilesReportPlugin(
205
-					$tree,
206
-					$view,
207
-					\OCP\Server::get(ISystemTagManager::class),
208
-					\OCP\Server::get(ISystemTagObjectMapper::class),
209
-					\OCP\Server::get(ITagManager::class),
210
-					$this->userSession,
211
-					\OCP\Server::get(IGroupManager::class),
212
-					$userFolder,
213
-					\OCP\Server::get(IAppManager::class)
214
-				));
215
-				// custom properties plugin must be the last one
216
-				$server->addPlugin(
217
-					new \Sabre\DAV\PropertyStorage\Plugin(
218
-						new CustomPropertiesBackend(
219
-							$server,
220
-							$tree,
221
-							$this->databaseConnection,
222
-							$this->userSession->getUser(),
223
-							\OCP\Server::get(DefaultCalendarValidator::class),
224
-						)
225
-					)
226
-				);
227
-			}
228
-			$server->addPlugin(new CopyEtagHeaderPlugin());
229
-
230
-			// Load dav plugins from apps
231
-			$event = new SabrePluginEvent($server);
232
-			$this->eventDispatcher->dispatchTyped($event);
233
-			$pluginManager = new PluginManager(
234
-				\OC::$server,
235
-				\OCP\Server::get(IAppManager::class)
236
-			);
237
-			foreach ($pluginManager->getAppPlugins() as $appPlugin) {
238
-				$server->addPlugin($appPlugin);
239
-			}
240
-		}, 30); // priority 30: after auth (10) and acl(20), before lock(50) and handling the request
241
-		return $server;
242
-	}
47
+    public function __construct(
48
+        private IConfig $config,
49
+        private LoggerInterface $logger,
50
+        private IDBConnection $databaseConnection,
51
+        private IUserSession $userSession,
52
+        private IMountManager $mountManager,
53
+        private ITagManager $tagManager,
54
+        private IRequest $request,
55
+        private IPreview $previewManager,
56
+        private IEventDispatcher $eventDispatcher,
57
+        private IL10N $l10n,
58
+    ) {
59
+    }
60
+
61
+    /**
62
+     * @param callable $viewCallBack callback that should return the view for the dav endpoint
63
+     */
64
+    public function createServer(
65
+        bool $isPublicShare,
66
+        string $baseUri,
67
+        string $requestUri,
68
+        Plugin $authPlugin,
69
+        callable $viewCallBack,
70
+    ): Server {
71
+        // Fire up server
72
+        if ($isPublicShare) {
73
+            $rootCollection = new SimpleCollection('root');
74
+            $tree = new CachingTree($rootCollection);
75
+        } else {
76
+            $rootCollection = null;
77
+            $tree = new ObjectTree();
78
+        }
79
+        $server = new Server($tree);
80
+        // Set URL explicitly due to reverse-proxy situations
81
+        $server->httpRequest->setUrl($requestUri);
82
+        $server->setBaseUri($baseUri);
83
+
84
+        // Load plugins
85
+        $server->addPlugin(new MaintenancePlugin($this->config, $this->l10n));
86
+        $server->addPlugin(new BlockLegacyClientPlugin(
87
+            $this->config,
88
+            \OCP\Server::get(ThemingDefaults::class),
89
+        ));
90
+        $server->addPlugin(new AnonymousOptionsPlugin());
91
+        $server->addPlugin($authPlugin);
92
+        // FIXME: The following line is a workaround for legacy components relying on being able to send a GET to /
93
+        $server->addPlugin(new DummyGetResponsePlugin());
94
+        $server->addPlugin(new ExceptionLoggerPlugin('webdav', $this->logger));
95
+        $server->addPlugin(new LockPlugin());
96
+
97
+        $server->addPlugin(new RequestIdHeaderPlugin($this->request));
98
+
99
+        $server->addPlugin(new ZipFolderPlugin(
100
+            $tree,
101
+            $this->logger,
102
+            $this->eventDispatcher,
103
+        ));
104
+
105
+        // Some WebDAV clients do require Class 2 WebDAV support (locking), since
106
+        // we do not provide locking we emulate it using a fake locking plugin.
107
+        if ($this->request->isUserAgent([
108
+            '/WebDAVFS/',
109
+            '/OneNote/',
110
+            '/Microsoft-WebDAV-MiniRedir/',
111
+        ])) {
112
+            $server->addPlugin(new FakeLockerPlugin());
113
+        }
114
+
115
+        if (BrowserErrorPagePlugin::isBrowserRequest($this->request)) {
116
+            $server->addPlugin(new BrowserErrorPagePlugin());
117
+        }
118
+
119
+        // wait with registering these until auth is handled and the filesystem is setup
120
+        $server->on('beforeMethod:*', function () use ($server, $tree, $viewCallBack, $isPublicShare, $rootCollection): void {
121
+            // ensure the skeleton is copied
122
+            $userFolder = \OC::$server->getUserFolder();
123
+
124
+            /** @var View $view */
125
+            $view = $viewCallBack($server);
126
+            if ($userFolder instanceof Folder && $userFolder->getPath() === $view->getRoot()) {
127
+                $rootInfo = $userFolder;
128
+            } else {
129
+                $rootInfo = $view->getFileInfo('');
130
+            }
131
+
132
+            // Create Nextcloud Dir
133
+            if ($rootInfo->getType() === 'dir') {
134
+                $root = new Directory($view, $rootInfo, $tree);
135
+            } else {
136
+                $root = new File($view, $rootInfo);
137
+            }
138
+
139
+            if ($isPublicShare) {
140
+                $userPrincipalBackend = new Principal(
141
+                    \OCP\Server::get(IUserManager::class),
142
+                    \OCP\Server::get(IGroupManager::class),
143
+                    \OCP\Server::get(IAccountManager::class),
144
+                    \OCP\Server::get(\OCP\Share\IManager::class),
145
+                    \OCP\Server::get(IUserSession::class),
146
+                    \OCP\Server::get(IAppManager::class),
147
+                    \OCP\Server::get(ProxyMapper::class),
148
+                    \OCP\Server::get(KnownUserService::class),
149
+                    \OCP\Server::get(IConfig::class),
150
+                    \OC::$server->getL10NFactory(),
151
+                );
152
+
153
+                // Mount the share collection at /public.php/dav/shares/<share token>
154
+                $rootCollection->addChild(new RootCollection(
155
+                    $root,
156
+                    $userPrincipalBackend,
157
+                    'principals/shares',
158
+                ));
159
+
160
+                // Mount the upload collection at /public.php/dav/uploads/<share token>
161
+                $rootCollection->addChild(new \OCA\DAV\Upload\RootCollection(
162
+                    $userPrincipalBackend,
163
+                    'principals/shares',
164
+                    \OCP\Server::get(CleanupService::class),
165
+                    \OCP\Server::get(IRootFolder::class),
166
+                    \OCP\Server::get(IUserSession::class),
167
+                    \OCP\Server::get(\OCP\Share\IManager::class),
168
+                ));
169
+            } else {
170
+                /** @var ObjectTree $tree */
171
+                $tree->init($root, $view, $this->mountManager);
172
+            }
173
+
174
+            $server->addPlugin(
175
+                new FilesPlugin(
176
+                    $tree,
177
+                    $this->config,
178
+                    $this->request,
179
+                    $this->previewManager,
180
+                    $this->userSession,
181
+                    \OCP\Server::get(IFilenameValidator::class),
182
+                    \OCP\Server::get(IAccountManager::class),
183
+                    false,
184
+                    !$this->config->getSystemValue('debug', false)
185
+                )
186
+            );
187
+            $server->addPlugin(new QuotaPlugin($view, true));
188
+            $server->addPlugin(new ChecksumUpdatePlugin());
189
+
190
+            // Allow view-only plugin for webdav requests
191
+            $server->addPlugin(new ViewOnlyPlugin(
192
+                $userFolder,
193
+            ));
194
+
195
+            if ($this->userSession->isLoggedIn()) {
196
+                $server->addPlugin(new TagsPlugin($tree, $this->tagManager, $this->eventDispatcher, $this->userSession));
197
+                $server->addPlugin(new SharesPlugin(
198
+                    $tree,
199
+                    $this->userSession,
200
+                    $userFolder,
201
+                    \OCP\Server::get(\OCP\Share\IManager::class)
202
+                ));
203
+                $server->addPlugin(new CommentPropertiesPlugin(\OCP\Server::get(ICommentsManager::class), $this->userSession));
204
+                $server->addPlugin(new FilesReportPlugin(
205
+                    $tree,
206
+                    $view,
207
+                    \OCP\Server::get(ISystemTagManager::class),
208
+                    \OCP\Server::get(ISystemTagObjectMapper::class),
209
+                    \OCP\Server::get(ITagManager::class),
210
+                    $this->userSession,
211
+                    \OCP\Server::get(IGroupManager::class),
212
+                    $userFolder,
213
+                    \OCP\Server::get(IAppManager::class)
214
+                ));
215
+                // custom properties plugin must be the last one
216
+                $server->addPlugin(
217
+                    new \Sabre\DAV\PropertyStorage\Plugin(
218
+                        new CustomPropertiesBackend(
219
+                            $server,
220
+                            $tree,
221
+                            $this->databaseConnection,
222
+                            $this->userSession->getUser(),
223
+                            \OCP\Server::get(DefaultCalendarValidator::class),
224
+                        )
225
+                    )
226
+                );
227
+            }
228
+            $server->addPlugin(new CopyEtagHeaderPlugin());
229
+
230
+            // Load dav plugins from apps
231
+            $event = new SabrePluginEvent($server);
232
+            $this->eventDispatcher->dispatchTyped($event);
233
+            $pluginManager = new PluginManager(
234
+                \OC::$server,
235
+                \OCP\Server::get(IAppManager::class)
236
+            );
237
+            foreach ($pluginManager->getAppPlugins() as $appPlugin) {
238
+                $server->addPlugin($appPlugin);
239
+            }
240
+        }, 30); // priority 30: after auth (10) and acl(20), before lock(50) and handling the request
241
+        return $server;
242
+    }
243 243
 }
Please login to merge, or discard this patch.
apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php 2 patches
Indentation   +465 added lines, -465 removed lines patch added patch discarded remove patch
@@ -25,469 +25,469 @@
 block discarded – undo
25 25
 
26 26
 abstract class AbstractPrincipalBackend implements BackendInterface {
27 27
 
28
-	/** @var string */
29
-	private $dbTableName;
30
-
31
-	/** @var string */
32
-	private $dbMetaDataTableName;
33
-
34
-	/** @var string */
35
-	private $dbForeignKeyName;
36
-
37
-	public function __construct(
38
-		private IDBConnection $db,
39
-		private IUserSession $userSession,
40
-		private IGroupManager $groupManager,
41
-		private LoggerInterface $logger,
42
-		private ProxyMapper $proxyMapper,
43
-		private string $principalPrefix,
44
-		string $dbPrefix,
45
-		private string $cuType,
46
-	) {
47
-		$this->dbTableName = 'calendar_' . $dbPrefix . 's';
48
-		$this->dbMetaDataTableName = $this->dbTableName . '_md';
49
-		$this->dbForeignKeyName = $dbPrefix . '_id';
50
-	}
51
-
52
-	use PrincipalProxyTrait;
53
-
54
-	/**
55
-	 * Returns a list of principals based on a prefix.
56
-	 *
57
-	 * This prefix will often contain something like 'principals'. You are only
58
-	 * expected to return principals that are in this base path.
59
-	 *
60
-	 * You are expected to return at least a 'uri' for every user, you can
61
-	 * return any additional properties if you wish so. Common properties are:
62
-	 *   {DAV:}displayname
63
-	 *
64
-	 * @param string $prefixPath
65
-	 * @return string[]
66
-	 */
67
-	public function getPrincipalsByPrefix($prefixPath): array {
68
-		$principals = [];
69
-
70
-		if ($prefixPath === $this->principalPrefix) {
71
-			$query = $this->db->getQueryBuilder();
72
-			$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
73
-				->from($this->dbTableName);
74
-			$stmt = $query->execute();
75
-
76
-			$metaDataQuery = $this->db->getQueryBuilder();
77
-			$metaDataQuery->select([$this->dbForeignKeyName, 'key', 'value'])
78
-				->from($this->dbMetaDataTableName);
79
-			$metaDataStmt = $metaDataQuery->execute();
80
-			$metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC);
81
-
82
-			$metaDataById = [];
83
-			foreach ($metaDataRows as $metaDataRow) {
84
-				if (!isset($metaDataById[$metaDataRow[$this->dbForeignKeyName]])) {
85
-					$metaDataById[$metaDataRow[$this->dbForeignKeyName]] = [];
86
-				}
87
-
88
-				$metaDataById[$metaDataRow[$this->dbForeignKeyName]][$metaDataRow['key']] =
89
-					$metaDataRow['value'];
90
-			}
91
-
92
-			while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
93
-				$id = $row['id'];
94
-
95
-				if (isset($metaDataById[$id])) {
96
-					$principals[] = $this->rowToPrincipal($row, $metaDataById[$id]);
97
-				} else {
98
-					$principals[] = $this->rowToPrincipal($row);
99
-				}
100
-			}
101
-
102
-			$stmt->closeCursor();
103
-		}
104
-
105
-		return $principals;
106
-	}
107
-
108
-	/**
109
-	 * Returns a specific principal, specified by its path.
110
-	 * The returned structure should be the exact same as from
111
-	 * getPrincipalsByPrefix.
112
-	 *
113
-	 * @param string $prefixPath
114
-	 *
115
-	 * @return array
116
-	 */
117
-	public function getPrincipalByPath($path) {
118
-		if (!str_starts_with($path, $this->principalPrefix)) {
119
-			return null;
120
-		}
121
-		[, $name] = \Sabre\Uri\split($path);
122
-
123
-		[$backendId, $resourceId] = explode('-', $name, 2);
124
-
125
-		$query = $this->db->getQueryBuilder();
126
-		$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
127
-			->from($this->dbTableName)
128
-			->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId)))
129
-			->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId)));
130
-		$stmt = $query->execute();
131
-		$row = $stmt->fetch(\PDO::FETCH_ASSOC);
132
-
133
-		if (!$row) {
134
-			return null;
135
-		}
136
-
137
-		$metaDataQuery = $this->db->getQueryBuilder();
138
-		$metaDataQuery->select(['key', 'value'])
139
-			->from($this->dbMetaDataTableName)
140
-			->where($metaDataQuery->expr()->eq($this->dbForeignKeyName, $metaDataQuery->createNamedParameter($row['id'])));
141
-		$metaDataStmt = $metaDataQuery->execute();
142
-		$metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC);
143
-		$metadata = [];
144
-
145
-		foreach ($metaDataRows as $metaDataRow) {
146
-			$metadata[$metaDataRow['key']] = $metaDataRow['value'];
147
-		}
148
-
149
-		return $this->rowToPrincipal($row, $metadata);
150
-	}
151
-
152
-	/**
153
-	 * @param int $id
154
-	 * @return string[]|null
155
-	 */
156
-	public function getPrincipalById($id): ?array {
157
-		$query = $this->db->getQueryBuilder();
158
-		$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
159
-			->from($this->dbTableName)
160
-			->where($query->expr()->eq('id', $query->createNamedParameter($id)));
161
-		$stmt = $query->execute();
162
-		$row = $stmt->fetch(\PDO::FETCH_ASSOC);
163
-
164
-		if (!$row) {
165
-			return null;
166
-		}
167
-
168
-		$metaDataQuery = $this->db->getQueryBuilder();
169
-		$metaDataQuery->select(['key', 'value'])
170
-			->from($this->dbMetaDataTableName)
171
-			->where($metaDataQuery->expr()->eq($this->dbForeignKeyName, $metaDataQuery->createNamedParameter($row['id'])));
172
-		$metaDataStmt = $metaDataQuery->execute();
173
-		$metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC);
174
-		$metadata = [];
175
-
176
-		foreach ($metaDataRows as $metaDataRow) {
177
-			$metadata[$metaDataRow['key']] = $metaDataRow['value'];
178
-		}
179
-
180
-		return $this->rowToPrincipal($row, $metadata);
181
-	}
182
-
183
-	/**
184
-	 * @param string $path
185
-	 * @param PropPatch $propPatch
186
-	 * @return int
187
-	 */
188
-	public function updatePrincipal($path, PropPatch $propPatch): int {
189
-		return 0;
190
-	}
191
-
192
-	/**
193
-	 * @param string $prefixPath
194
-	 * @param string $test
195
-	 *
196
-	 * @return array
197
-	 */
198
-	public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') {
199
-		$results = [];
200
-		if (\count($searchProperties) === 0) {
201
-			return [];
202
-		}
203
-		if ($prefixPath !== $this->principalPrefix) {
204
-			return [];
205
-		}
206
-
207
-		$user = $this->userSession->getUser();
208
-		if (!$user) {
209
-			return [];
210
-		}
211
-		$usersGroups = $this->groupManager->getUserGroupIds($user);
212
-
213
-		foreach ($searchProperties as $prop => $value) {
214
-			switch ($prop) {
215
-				case '{http://sabredav.org/ns}email-address':
216
-					$query = $this->db->getQueryBuilder();
217
-					$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
218
-						->from($this->dbTableName)
219
-						->where($query->expr()->iLike('email', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
220
-
221
-					$stmt = $query->execute();
222
-					$principals = [];
223
-					while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
224
-						if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
225
-							continue;
226
-						}
227
-						$principals[] = $this->rowToPrincipal($row)['uri'];
228
-					}
229
-					$results[] = $principals;
230
-
231
-					$stmt->closeCursor();
232
-					break;
233
-
234
-				case '{DAV:}displayname':
235
-					$query = $this->db->getQueryBuilder();
236
-					$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
237
-						->from($this->dbTableName)
238
-						->where($query->expr()->iLike('displayname', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
239
-
240
-					$stmt = $query->execute();
241
-					$principals = [];
242
-					while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
243
-						if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
244
-							continue;
245
-						}
246
-						$principals[] = $this->rowToPrincipal($row)['uri'];
247
-					}
248
-					$results[] = $principals;
249
-
250
-					$stmt->closeCursor();
251
-					break;
252
-
253
-				case '{urn:ietf:params:xml:ns:caldav}calendar-user-address-set':
254
-					// If you add support for more search properties that qualify as a user-address,
255
-					// please also add them to the array below
256
-					$results[] = $this->searchPrincipals($this->principalPrefix, [
257
-						'{http://sabredav.org/ns}email-address' => $value,
258
-					], 'anyof');
259
-					break;
260
-
261
-				case IRoomMetadata::FEATURES:
262
-					$results[] = $this->searchPrincipalsByRoomFeature($prop, $value);
263
-					break;
264
-
265
-				case IRoomMetadata::CAPACITY:
266
-				case IResourceMetadata::VEHICLE_SEATING_CAPACITY:
267
-					$results[] = $this->searchPrincipalsByCapacity($prop, $value);
268
-					break;
269
-
270
-				default:
271
-					$results[] = $this->searchPrincipalsByMetadataKey($prop, $value, $usersGroups);
272
-					break;
273
-			}
274
-		}
275
-
276
-		// results is an array of arrays, so this is not the first search result
277
-		// but the results of the first searchProperty
278
-		if (count($results) === 1) {
279
-			return $results[0];
280
-		}
281
-
282
-		switch ($test) {
283
-			case 'anyof':
284
-				return array_values(array_unique(array_merge(...$results)));
285
-
286
-			case 'allof':
287
-			default:
288
-				return array_values(array_intersect(...$results));
289
-		}
290
-	}
291
-
292
-	/**
293
-	 * @param string $key
294
-	 * @return IQueryBuilder
295
-	 */
296
-	private function getMetadataQuery(string $key): IQueryBuilder {
297
-		$query = $this->db->getQueryBuilder();
298
-		$query->select([$this->dbForeignKeyName])
299
-			->from($this->dbMetaDataTableName)
300
-			->where($query->expr()->eq('key', $query->createNamedParameter($key)));
301
-		return $query;
302
-	}
303
-
304
-	/**
305
-	 * Searches principals based on their metadata keys.
306
-	 * This allows to search for all principals with a specific key.
307
-	 * e.g.:
308
-	 * '{http://nextcloud.com/ns}room-building-address' => 'ABC Street 123, ...'
309
-	 *
310
-	 * @param string $key
311
-	 * @param string $value
312
-	 * @param string[] $usersGroups
313
-	 * @return string[]
314
-	 */
315
-	private function searchPrincipalsByMetadataKey(string $key, string $value, array $usersGroups = []): array {
316
-		$query = $this->getMetadataQuery($key);
317
-		$query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
318
-		return $this->getRows($query, $usersGroups);
319
-	}
320
-
321
-	/**
322
-	 * Searches principals based on room features
323
-	 * e.g.:
324
-	 * '{http://nextcloud.com/ns}room-features' => 'TV,PROJECTOR'
325
-	 *
326
-	 * @param string $key
327
-	 * @param string $value
328
-	 * @param string[] $usersGroups
329
-	 * @return string[]
330
-	 */
331
-	private function searchPrincipalsByRoomFeature(string $key, string $value, array $usersGroups = []): array {
332
-		$query = $this->getMetadataQuery($key);
333
-		foreach (explode(',', $value) as $v) {
334
-			$query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($v) . '%')));
335
-		}
336
-		return $this->getRows($query, $usersGroups);
337
-	}
338
-
339
-	/**
340
-	 * Searches principals based on room seating capacity or vehicle capacity
341
-	 * e.g.:
342
-	 * '{http://nextcloud.com/ns}room-seating-capacity' => '100'
343
-	 *
344
-	 * @param string $key
345
-	 * @param string $value
346
-	 * @param string[] $usersGroups
347
-	 * @return string[]
348
-	 */
349
-	private function searchPrincipalsByCapacity(string $key, string $value, array $usersGroups = []): array {
350
-		$query = $this->getMetadataQuery($key);
351
-		$query->andWhere($query->expr()->gte('value', $query->createNamedParameter($value)));
352
-		return $this->getRows($query, $usersGroups);
353
-	}
354
-
355
-	/**
356
-	 * @param IQueryBuilder $query
357
-	 * @param string[] $usersGroups
358
-	 * @return string[]
359
-	 */
360
-	private function getRows(IQueryBuilder $query, array $usersGroups): array {
361
-		try {
362
-			$stmt = $query->executeQuery();
363
-		} catch (Exception $e) {
364
-			$this->logger->error('Could not search resources: ' . $e->getMessage(), ['exception' => $e]);
365
-		}
366
-
367
-		$rows = [];
368
-		while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
369
-			$principalRow = $this->getPrincipalById($row[$this->dbForeignKeyName]);
370
-			if (!$principalRow) {
371
-				continue;
372
-			}
373
-
374
-			$rows[] = $principalRow;
375
-		}
376
-
377
-		$stmt->closeCursor();
378
-
379
-		$filteredRows = array_filter($rows, function ($row) use ($usersGroups) {
380
-			return $this->isAllowedToAccessResource($row, $usersGroups);
381
-		});
382
-
383
-		return array_map(static function ($row): string {
384
-			return $row['uri'];
385
-		}, $filteredRows);
386
-	}
387
-
388
-	/**
389
-	 * @param string $uri
390
-	 * @param string $principalPrefix
391
-	 * @return null|string
392
-	 * @throws Exception
393
-	 */
394
-	public function findByUri($uri, $principalPrefix): ?string {
395
-		$user = $this->userSession->getUser();
396
-		if (!$user) {
397
-			return null;
398
-		}
399
-		$usersGroups = $this->groupManager->getUserGroupIds($user);
400
-
401
-		if (str_starts_with($uri, 'mailto:')) {
402
-			$email = substr($uri, 7);
403
-			$query = $this->db->getQueryBuilder();
404
-			$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
405
-				->from($this->dbTableName)
406
-				->where($query->expr()->eq('email', $query->createNamedParameter($email)));
407
-
408
-			$stmt = $query->execute();
409
-			$row = $stmt->fetch(\PDO::FETCH_ASSOC);
410
-
411
-			if (!$row) {
412
-				return null;
413
-			}
414
-			if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
415
-				return null;
416
-			}
417
-
418
-			return $this->rowToPrincipal($row)['uri'];
419
-		}
420
-
421
-		if (str_starts_with($uri, 'principal:')) {
422
-			$path = substr($uri, 10);
423
-			if (!str_starts_with($path, $this->principalPrefix)) {
424
-				return null;
425
-			}
426
-
427
-			[, $name] = \Sabre\Uri\split($path);
428
-			[$backendId, $resourceId] = explode('-', $name, 2);
429
-
430
-			$query = $this->db->getQueryBuilder();
431
-			$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
432
-				->from($this->dbTableName)
433
-				->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId)))
434
-				->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId)));
435
-			$stmt = $query->execute();
436
-			$row = $stmt->fetch(\PDO::FETCH_ASSOC);
437
-
438
-			if (!$row) {
439
-				return null;
440
-			}
441
-			if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
442
-				return null;
443
-			}
444
-
445
-			return $this->rowToPrincipal($row)['uri'];
446
-		}
447
-
448
-		return null;
449
-	}
450
-
451
-	/**
452
-	 * convert database row to principal
453
-	 *
454
-	 * @param string[] $row
455
-	 * @param string[] $metadata
456
-	 * @return string[]
457
-	 */
458
-	private function rowToPrincipal(array $row, array $metadata = []): array {
459
-		return array_merge([
460
-			'uri' => $this->principalPrefix . '/' . $row['backend_id'] . '-' . $row['resource_id'],
461
-			'{DAV:}displayname' => $row['displayname'],
462
-			'{http://sabredav.org/ns}email-address' => $row['email'],
463
-			'{urn:ietf:params:xml:ns:caldav}calendar-user-type' => $this->cuType,
464
-		], $metadata);
465
-	}
466
-
467
-	/**
468
-	 * @param array $row
469
-	 * @param array $userGroups
470
-	 * @return bool
471
-	 */
472
-	private function isAllowedToAccessResource(array $row, array $userGroups): bool {
473
-		if (!isset($row['group_restrictions']) ||
474
-			$row['group_restrictions'] === null ||
475
-			$row['group_restrictions'] === '') {
476
-			return true;
477
-		}
478
-
479
-		// group restrictions contains something, but not parsable, deny access and log warning
480
-		$json = json_decode($row['group_restrictions'], null, 512, JSON_THROW_ON_ERROR);
481
-		if (!\is_array($json)) {
482
-			$this->logger->info('group_restrictions field could not be parsed for ' . $this->dbTableName . '::' . $row['id'] . ', denying access to resource');
483
-			return false;
484
-		}
485
-
486
-		// empty array => no group restrictions
487
-		if (empty($json)) {
488
-			return true;
489
-		}
490
-
491
-		return !empty(array_intersect($json, $userGroups));
492
-	}
28
+    /** @var string */
29
+    private $dbTableName;
30
+
31
+    /** @var string */
32
+    private $dbMetaDataTableName;
33
+
34
+    /** @var string */
35
+    private $dbForeignKeyName;
36
+
37
+    public function __construct(
38
+        private IDBConnection $db,
39
+        private IUserSession $userSession,
40
+        private IGroupManager $groupManager,
41
+        private LoggerInterface $logger,
42
+        private ProxyMapper $proxyMapper,
43
+        private string $principalPrefix,
44
+        string $dbPrefix,
45
+        private string $cuType,
46
+    ) {
47
+        $this->dbTableName = 'calendar_' . $dbPrefix . 's';
48
+        $this->dbMetaDataTableName = $this->dbTableName . '_md';
49
+        $this->dbForeignKeyName = $dbPrefix . '_id';
50
+    }
51
+
52
+    use PrincipalProxyTrait;
53
+
54
+    /**
55
+     * Returns a list of principals based on a prefix.
56
+     *
57
+     * This prefix will often contain something like 'principals'. You are only
58
+     * expected to return principals that are in this base path.
59
+     *
60
+     * You are expected to return at least a 'uri' for every user, you can
61
+     * return any additional properties if you wish so. Common properties are:
62
+     *   {DAV:}displayname
63
+     *
64
+     * @param string $prefixPath
65
+     * @return string[]
66
+     */
67
+    public function getPrincipalsByPrefix($prefixPath): array {
68
+        $principals = [];
69
+
70
+        if ($prefixPath === $this->principalPrefix) {
71
+            $query = $this->db->getQueryBuilder();
72
+            $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
73
+                ->from($this->dbTableName);
74
+            $stmt = $query->execute();
75
+
76
+            $metaDataQuery = $this->db->getQueryBuilder();
77
+            $metaDataQuery->select([$this->dbForeignKeyName, 'key', 'value'])
78
+                ->from($this->dbMetaDataTableName);
79
+            $metaDataStmt = $metaDataQuery->execute();
80
+            $metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC);
81
+
82
+            $metaDataById = [];
83
+            foreach ($metaDataRows as $metaDataRow) {
84
+                if (!isset($metaDataById[$metaDataRow[$this->dbForeignKeyName]])) {
85
+                    $metaDataById[$metaDataRow[$this->dbForeignKeyName]] = [];
86
+                }
87
+
88
+                $metaDataById[$metaDataRow[$this->dbForeignKeyName]][$metaDataRow['key']] =
89
+                    $metaDataRow['value'];
90
+            }
91
+
92
+            while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
93
+                $id = $row['id'];
94
+
95
+                if (isset($metaDataById[$id])) {
96
+                    $principals[] = $this->rowToPrincipal($row, $metaDataById[$id]);
97
+                } else {
98
+                    $principals[] = $this->rowToPrincipal($row);
99
+                }
100
+            }
101
+
102
+            $stmt->closeCursor();
103
+        }
104
+
105
+        return $principals;
106
+    }
107
+
108
+    /**
109
+     * Returns a specific principal, specified by its path.
110
+     * The returned structure should be the exact same as from
111
+     * getPrincipalsByPrefix.
112
+     *
113
+     * @param string $prefixPath
114
+     *
115
+     * @return array
116
+     */
117
+    public function getPrincipalByPath($path) {
118
+        if (!str_starts_with($path, $this->principalPrefix)) {
119
+            return null;
120
+        }
121
+        [, $name] = \Sabre\Uri\split($path);
122
+
123
+        [$backendId, $resourceId] = explode('-', $name, 2);
124
+
125
+        $query = $this->db->getQueryBuilder();
126
+        $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
127
+            ->from($this->dbTableName)
128
+            ->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId)))
129
+            ->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId)));
130
+        $stmt = $query->execute();
131
+        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
132
+
133
+        if (!$row) {
134
+            return null;
135
+        }
136
+
137
+        $metaDataQuery = $this->db->getQueryBuilder();
138
+        $metaDataQuery->select(['key', 'value'])
139
+            ->from($this->dbMetaDataTableName)
140
+            ->where($metaDataQuery->expr()->eq($this->dbForeignKeyName, $metaDataQuery->createNamedParameter($row['id'])));
141
+        $metaDataStmt = $metaDataQuery->execute();
142
+        $metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC);
143
+        $metadata = [];
144
+
145
+        foreach ($metaDataRows as $metaDataRow) {
146
+            $metadata[$metaDataRow['key']] = $metaDataRow['value'];
147
+        }
148
+
149
+        return $this->rowToPrincipal($row, $metadata);
150
+    }
151
+
152
+    /**
153
+     * @param int $id
154
+     * @return string[]|null
155
+     */
156
+    public function getPrincipalById($id): ?array {
157
+        $query = $this->db->getQueryBuilder();
158
+        $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
159
+            ->from($this->dbTableName)
160
+            ->where($query->expr()->eq('id', $query->createNamedParameter($id)));
161
+        $stmt = $query->execute();
162
+        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
163
+
164
+        if (!$row) {
165
+            return null;
166
+        }
167
+
168
+        $metaDataQuery = $this->db->getQueryBuilder();
169
+        $metaDataQuery->select(['key', 'value'])
170
+            ->from($this->dbMetaDataTableName)
171
+            ->where($metaDataQuery->expr()->eq($this->dbForeignKeyName, $metaDataQuery->createNamedParameter($row['id'])));
172
+        $metaDataStmt = $metaDataQuery->execute();
173
+        $metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC);
174
+        $metadata = [];
175
+
176
+        foreach ($metaDataRows as $metaDataRow) {
177
+            $metadata[$metaDataRow['key']] = $metaDataRow['value'];
178
+        }
179
+
180
+        return $this->rowToPrincipal($row, $metadata);
181
+    }
182
+
183
+    /**
184
+     * @param string $path
185
+     * @param PropPatch $propPatch
186
+     * @return int
187
+     */
188
+    public function updatePrincipal($path, PropPatch $propPatch): int {
189
+        return 0;
190
+    }
191
+
192
+    /**
193
+     * @param string $prefixPath
194
+     * @param string $test
195
+     *
196
+     * @return array
197
+     */
198
+    public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') {
199
+        $results = [];
200
+        if (\count($searchProperties) === 0) {
201
+            return [];
202
+        }
203
+        if ($prefixPath !== $this->principalPrefix) {
204
+            return [];
205
+        }
206
+
207
+        $user = $this->userSession->getUser();
208
+        if (!$user) {
209
+            return [];
210
+        }
211
+        $usersGroups = $this->groupManager->getUserGroupIds($user);
212
+
213
+        foreach ($searchProperties as $prop => $value) {
214
+            switch ($prop) {
215
+                case '{http://sabredav.org/ns}email-address':
216
+                    $query = $this->db->getQueryBuilder();
217
+                    $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
218
+                        ->from($this->dbTableName)
219
+                        ->where($query->expr()->iLike('email', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
220
+
221
+                    $stmt = $query->execute();
222
+                    $principals = [];
223
+                    while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
224
+                        if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
225
+                            continue;
226
+                        }
227
+                        $principals[] = $this->rowToPrincipal($row)['uri'];
228
+                    }
229
+                    $results[] = $principals;
230
+
231
+                    $stmt->closeCursor();
232
+                    break;
233
+
234
+                case '{DAV:}displayname':
235
+                    $query = $this->db->getQueryBuilder();
236
+                    $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
237
+                        ->from($this->dbTableName)
238
+                        ->where($query->expr()->iLike('displayname', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
239
+
240
+                    $stmt = $query->execute();
241
+                    $principals = [];
242
+                    while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
243
+                        if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
244
+                            continue;
245
+                        }
246
+                        $principals[] = $this->rowToPrincipal($row)['uri'];
247
+                    }
248
+                    $results[] = $principals;
249
+
250
+                    $stmt->closeCursor();
251
+                    break;
252
+
253
+                case '{urn:ietf:params:xml:ns:caldav}calendar-user-address-set':
254
+                    // If you add support for more search properties that qualify as a user-address,
255
+                    // please also add them to the array below
256
+                    $results[] = $this->searchPrincipals($this->principalPrefix, [
257
+                        '{http://sabredav.org/ns}email-address' => $value,
258
+                    ], 'anyof');
259
+                    break;
260
+
261
+                case IRoomMetadata::FEATURES:
262
+                    $results[] = $this->searchPrincipalsByRoomFeature($prop, $value);
263
+                    break;
264
+
265
+                case IRoomMetadata::CAPACITY:
266
+                case IResourceMetadata::VEHICLE_SEATING_CAPACITY:
267
+                    $results[] = $this->searchPrincipalsByCapacity($prop, $value);
268
+                    break;
269
+
270
+                default:
271
+                    $results[] = $this->searchPrincipalsByMetadataKey($prop, $value, $usersGroups);
272
+                    break;
273
+            }
274
+        }
275
+
276
+        // results is an array of arrays, so this is not the first search result
277
+        // but the results of the first searchProperty
278
+        if (count($results) === 1) {
279
+            return $results[0];
280
+        }
281
+
282
+        switch ($test) {
283
+            case 'anyof':
284
+                return array_values(array_unique(array_merge(...$results)));
285
+
286
+            case 'allof':
287
+            default:
288
+                return array_values(array_intersect(...$results));
289
+        }
290
+    }
291
+
292
+    /**
293
+     * @param string $key
294
+     * @return IQueryBuilder
295
+     */
296
+    private function getMetadataQuery(string $key): IQueryBuilder {
297
+        $query = $this->db->getQueryBuilder();
298
+        $query->select([$this->dbForeignKeyName])
299
+            ->from($this->dbMetaDataTableName)
300
+            ->where($query->expr()->eq('key', $query->createNamedParameter($key)));
301
+        return $query;
302
+    }
303
+
304
+    /**
305
+     * Searches principals based on their metadata keys.
306
+     * This allows to search for all principals with a specific key.
307
+     * e.g.:
308
+     * '{http://nextcloud.com/ns}room-building-address' => 'ABC Street 123, ...'
309
+     *
310
+     * @param string $key
311
+     * @param string $value
312
+     * @param string[] $usersGroups
313
+     * @return string[]
314
+     */
315
+    private function searchPrincipalsByMetadataKey(string $key, string $value, array $usersGroups = []): array {
316
+        $query = $this->getMetadataQuery($key);
317
+        $query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
318
+        return $this->getRows($query, $usersGroups);
319
+    }
320
+
321
+    /**
322
+     * Searches principals based on room features
323
+     * e.g.:
324
+     * '{http://nextcloud.com/ns}room-features' => 'TV,PROJECTOR'
325
+     *
326
+     * @param string $key
327
+     * @param string $value
328
+     * @param string[] $usersGroups
329
+     * @return string[]
330
+     */
331
+    private function searchPrincipalsByRoomFeature(string $key, string $value, array $usersGroups = []): array {
332
+        $query = $this->getMetadataQuery($key);
333
+        foreach (explode(',', $value) as $v) {
334
+            $query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($v) . '%')));
335
+        }
336
+        return $this->getRows($query, $usersGroups);
337
+    }
338
+
339
+    /**
340
+     * Searches principals based on room seating capacity or vehicle capacity
341
+     * e.g.:
342
+     * '{http://nextcloud.com/ns}room-seating-capacity' => '100'
343
+     *
344
+     * @param string $key
345
+     * @param string $value
346
+     * @param string[] $usersGroups
347
+     * @return string[]
348
+     */
349
+    private function searchPrincipalsByCapacity(string $key, string $value, array $usersGroups = []): array {
350
+        $query = $this->getMetadataQuery($key);
351
+        $query->andWhere($query->expr()->gte('value', $query->createNamedParameter($value)));
352
+        return $this->getRows($query, $usersGroups);
353
+    }
354
+
355
+    /**
356
+     * @param IQueryBuilder $query
357
+     * @param string[] $usersGroups
358
+     * @return string[]
359
+     */
360
+    private function getRows(IQueryBuilder $query, array $usersGroups): array {
361
+        try {
362
+            $stmt = $query->executeQuery();
363
+        } catch (Exception $e) {
364
+            $this->logger->error('Could not search resources: ' . $e->getMessage(), ['exception' => $e]);
365
+        }
366
+
367
+        $rows = [];
368
+        while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
369
+            $principalRow = $this->getPrincipalById($row[$this->dbForeignKeyName]);
370
+            if (!$principalRow) {
371
+                continue;
372
+            }
373
+
374
+            $rows[] = $principalRow;
375
+        }
376
+
377
+        $stmt->closeCursor();
378
+
379
+        $filteredRows = array_filter($rows, function ($row) use ($usersGroups) {
380
+            return $this->isAllowedToAccessResource($row, $usersGroups);
381
+        });
382
+
383
+        return array_map(static function ($row): string {
384
+            return $row['uri'];
385
+        }, $filteredRows);
386
+    }
387
+
388
+    /**
389
+     * @param string $uri
390
+     * @param string $principalPrefix
391
+     * @return null|string
392
+     * @throws Exception
393
+     */
394
+    public function findByUri($uri, $principalPrefix): ?string {
395
+        $user = $this->userSession->getUser();
396
+        if (!$user) {
397
+            return null;
398
+        }
399
+        $usersGroups = $this->groupManager->getUserGroupIds($user);
400
+
401
+        if (str_starts_with($uri, 'mailto:')) {
402
+            $email = substr($uri, 7);
403
+            $query = $this->db->getQueryBuilder();
404
+            $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
405
+                ->from($this->dbTableName)
406
+                ->where($query->expr()->eq('email', $query->createNamedParameter($email)));
407
+
408
+            $stmt = $query->execute();
409
+            $row = $stmt->fetch(\PDO::FETCH_ASSOC);
410
+
411
+            if (!$row) {
412
+                return null;
413
+            }
414
+            if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
415
+                return null;
416
+            }
417
+
418
+            return $this->rowToPrincipal($row)['uri'];
419
+        }
420
+
421
+        if (str_starts_with($uri, 'principal:')) {
422
+            $path = substr($uri, 10);
423
+            if (!str_starts_with($path, $this->principalPrefix)) {
424
+                return null;
425
+            }
426
+
427
+            [, $name] = \Sabre\Uri\split($path);
428
+            [$backendId, $resourceId] = explode('-', $name, 2);
429
+
430
+            $query = $this->db->getQueryBuilder();
431
+            $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
432
+                ->from($this->dbTableName)
433
+                ->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId)))
434
+                ->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId)));
435
+            $stmt = $query->execute();
436
+            $row = $stmt->fetch(\PDO::FETCH_ASSOC);
437
+
438
+            if (!$row) {
439
+                return null;
440
+            }
441
+            if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
442
+                return null;
443
+            }
444
+
445
+            return $this->rowToPrincipal($row)['uri'];
446
+        }
447
+
448
+        return null;
449
+    }
450
+
451
+    /**
452
+     * convert database row to principal
453
+     *
454
+     * @param string[] $row
455
+     * @param string[] $metadata
456
+     * @return string[]
457
+     */
458
+    private function rowToPrincipal(array $row, array $metadata = []): array {
459
+        return array_merge([
460
+            'uri' => $this->principalPrefix . '/' . $row['backend_id'] . '-' . $row['resource_id'],
461
+            '{DAV:}displayname' => $row['displayname'],
462
+            '{http://sabredav.org/ns}email-address' => $row['email'],
463
+            '{urn:ietf:params:xml:ns:caldav}calendar-user-type' => $this->cuType,
464
+        ], $metadata);
465
+    }
466
+
467
+    /**
468
+     * @param array $row
469
+     * @param array $userGroups
470
+     * @return bool
471
+     */
472
+    private function isAllowedToAccessResource(array $row, array $userGroups): bool {
473
+        if (!isset($row['group_restrictions']) ||
474
+            $row['group_restrictions'] === null ||
475
+            $row['group_restrictions'] === '') {
476
+            return true;
477
+        }
478
+
479
+        // group restrictions contains something, but not parsable, deny access and log warning
480
+        $json = json_decode($row['group_restrictions'], null, 512, JSON_THROW_ON_ERROR);
481
+        if (!\is_array($json)) {
482
+            $this->logger->info('group_restrictions field could not be parsed for ' . $this->dbTableName . '::' . $row['id'] . ', denying access to resource');
483
+            return false;
484
+        }
485
+
486
+        // empty array => no group restrictions
487
+        if (empty($json)) {
488
+            return true;
489
+        }
490
+
491
+        return !empty(array_intersect($json, $userGroups));
492
+    }
493 493
 }
Please login to merge, or discard this patch.
Spacing   +12 added lines, -12 removed lines patch added patch discarded remove patch
@@ -44,9 +44,9 @@  discard block
 block discarded – undo
44 44
 		string $dbPrefix,
45 45
 		private string $cuType,
46 46
 	) {
47
-		$this->dbTableName = 'calendar_' . $dbPrefix . 's';
48
-		$this->dbMetaDataTableName = $this->dbTableName . '_md';
49
-		$this->dbForeignKeyName = $dbPrefix . '_id';
47
+		$this->dbTableName = 'calendar_'.$dbPrefix.'s';
48
+		$this->dbMetaDataTableName = $this->dbTableName.'_md';
49
+		$this->dbForeignKeyName = $dbPrefix.'_id';
50 50
 	}
51 51
 
52 52
 	use PrincipalProxyTrait;
@@ -216,7 +216,7 @@  discard block
 block discarded – undo
216 216
 					$query = $this->db->getQueryBuilder();
217 217
 					$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
218 218
 						->from($this->dbTableName)
219
-						->where($query->expr()->iLike('email', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
219
+						->where($query->expr()->iLike('email', $query->createNamedParameter('%'.$this->db->escapeLikeParameter($value).'%')));
220 220
 
221 221
 					$stmt = $query->execute();
222 222
 					$principals = [];
@@ -235,7 +235,7 @@  discard block
 block discarded – undo
235 235
 					$query = $this->db->getQueryBuilder();
236 236
 					$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
237 237
 						->from($this->dbTableName)
238
-						->where($query->expr()->iLike('displayname', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
238
+						->where($query->expr()->iLike('displayname', $query->createNamedParameter('%'.$this->db->escapeLikeParameter($value).'%')));
239 239
 
240 240
 					$stmt = $query->execute();
241 241
 					$principals = [];
@@ -314,7 +314,7 @@  discard block
 block discarded – undo
314 314
 	 */
315 315
 	private function searchPrincipalsByMetadataKey(string $key, string $value, array $usersGroups = []): array {
316 316
 		$query = $this->getMetadataQuery($key);
317
-		$query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
317
+		$query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%'.$this->db->escapeLikeParameter($value).'%')));
318 318
 		return $this->getRows($query, $usersGroups);
319 319
 	}
320 320
 
@@ -331,7 +331,7 @@  discard block
 block discarded – undo
331 331
 	private function searchPrincipalsByRoomFeature(string $key, string $value, array $usersGroups = []): array {
332 332
 		$query = $this->getMetadataQuery($key);
333 333
 		foreach (explode(',', $value) as $v) {
334
-			$query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($v) . '%')));
334
+			$query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%'.$this->db->escapeLikeParameter($v).'%')));
335 335
 		}
336 336
 		return $this->getRows($query, $usersGroups);
337 337
 	}
@@ -361,7 +361,7 @@  discard block
 block discarded – undo
361 361
 		try {
362 362
 			$stmt = $query->executeQuery();
363 363
 		} catch (Exception $e) {
364
-			$this->logger->error('Could not search resources: ' . $e->getMessage(), ['exception' => $e]);
364
+			$this->logger->error('Could not search resources: '.$e->getMessage(), ['exception' => $e]);
365 365
 		}
366 366
 
367 367
 		$rows = [];
@@ -376,11 +376,11 @@  discard block
 block discarded – undo
376 376
 
377 377
 		$stmt->closeCursor();
378 378
 
379
-		$filteredRows = array_filter($rows, function ($row) use ($usersGroups) {
379
+		$filteredRows = array_filter($rows, function($row) use ($usersGroups) {
380 380
 			return $this->isAllowedToAccessResource($row, $usersGroups);
381 381
 		});
382 382
 
383
-		return array_map(static function ($row): string {
383
+		return array_map(static function($row): string {
384 384
 			return $row['uri'];
385 385
 		}, $filteredRows);
386 386
 	}
@@ -457,7 +457,7 @@  discard block
 block discarded – undo
457 457
 	 */
458 458
 	private function rowToPrincipal(array $row, array $metadata = []): array {
459 459
 		return array_merge([
460
-			'uri' => $this->principalPrefix . '/' . $row['backend_id'] . '-' . $row['resource_id'],
460
+			'uri' => $this->principalPrefix.'/'.$row['backend_id'].'-'.$row['resource_id'],
461 461
 			'{DAV:}displayname' => $row['displayname'],
462 462
 			'{http://sabredav.org/ns}email-address' => $row['email'],
463 463
 			'{urn:ietf:params:xml:ns:caldav}calendar-user-type' => $this->cuType,
@@ -479,7 +479,7 @@  discard block
 block discarded – undo
479 479
 		// group restrictions contains something, but not parsable, deny access and log warning
480 480
 		$json = json_decode($row['group_restrictions'], null, 512, JSON_THROW_ON_ERROR);
481 481
 		if (!\is_array($json)) {
482
-			$this->logger->info('group_restrictions field could not be parsed for ' . $this->dbTableName . '::' . $row['id'] . ', denying access to resource');
482
+			$this->logger->info('group_restrictions field could not be parsed for '.$this->dbTableName.'::'.$row['id'].', denying access to resource');
483 483
 			return false;
484 484
 		}
485 485
 
Please login to merge, or discard this patch.
apps/dav/tests/integration/DAV/Sharing/CalDavSharingBackendTest.php 1 patch
Indentation   +196 added lines, -196 removed lines patch added patch discarded remove patch
@@ -30,222 +30,222 @@
 block discarded – undo
30 30
  */
31 31
 class CalDavSharingBackendTest extends TestCase {
32 32
 
33
-	private IDBConnection $db;
34
-	private IUserManager $userManager;
35
-	private IGroupManager $groupManager;
36
-	private Principal $principalBackend;
37
-	private ICacheFactory $cacheFactory;
38
-	private LoggerInterface $logger;
39
-	private SharingMapper $sharingMapper;
40
-	private SharingService $sharingService;
41
-	private Backend $sharingBackend;
42
-
43
-	private $resourceIds = [10001];
44
-
45
-	protected function setUp(): void {
46
-		parent::setUp();
47
-
48
-		$this->db = Server::get(IDBConnection::class);
49
-
50
-		$this->userManager = $this->createMock(IUserManager::class);
51
-		$this->groupManager = $this->createMock(IGroupManager::class);
52
-		$this->principalBackend = $this->createMock(Principal::class);
53
-		$this->cacheFactory = $this->createMock(ICacheFactory::class);
54
-		$this->cacheFactory->method('createInMemory')
55
-			->willReturn(new NullCache());
56
-		$this->logger = new \Psr\Log\NullLogger();
57
-
58
-		$this->sharingMapper = new SharingMapper($this->db);
59
-		$this->sharingService = new Service($this->sharingMapper);
60
-
61
-		$this->sharingBackend = new \OCA\DAV\CalDAV\Sharing\Backend(
62
-			$this->userManager,
63
-			$this->groupManager,
64
-			$this->principalBackend,
65
-			$this->cacheFactory,
66
-			$this->sharingService,
67
-			$this->logger
68
-		);
69
-
70
-		$this->removeFixtures();
71
-	}
72
-
73
-	protected function tearDown(): void {
74
-		$this->removeFixtures();
75
-	}
76
-
77
-	protected function removeFixtures(): void {
78
-		$qb = $this->db->getQueryBuilder();
79
-		$qb->delete('dav_shares')
80
-			->where($qb->expr()->in('resourceid', $qb->createNamedParameter($this->resourceIds, IQueryBuilder::PARAM_INT_ARRAY)));
81
-		$qb->executeStatement();
82
-	}
83
-
84
-	public function testShareCalendarWithGroup(): void {
85
-		$calendar = $this->createMock(Calendar::class);
86
-		$calendar->method('getResourceId')
87
-			->willReturn(10001);
88
-		$calendar->method('getOwner')
89
-			->willReturn('principals/users/admin');
90
-
91
-		$this->principalBackend->method('findByUri')
92
-			->willReturn('principals/groups/alice_bob');
93
-
94
-		$this->groupManager->method('groupExists')
95
-			->willReturn(true);
96
-
97
-		$this->sharingBackend->updateShares(
98
-			$calendar,
99
-			[['href' => 'principals/groups/alice_bob']],
100
-			[],
101
-			[]
102
-		);
103
-
104
-		$this->assertCount(1, $this->sharingService->getShares(10001));
105
-	}
106
-
107
-	public function testUnshareCalendarFromGroup(): void {
108
-		$calendar = $this->createMock(Calendar::class);
109
-		$calendar->method('getResourceId')
110
-			->willReturn(10001);
111
-		$calendar->method('getOwner')
112
-			->willReturn('principals/users/admin');
113
-
114
-		$this->principalBackend->method('findByUri')
115
-			->willReturn('principals/groups/alice_bob');
116
-
117
-		$this->groupManager->method('groupExists')
118
-			->willReturn(true);
119
-
120
-		$this->sharingBackend->updateShares(
121
-			shareable: $calendar,
122
-			add: [['href' => 'principals/groups/alice_bob']],
123
-			remove: [],
124
-		);
125
-
126
-		$this->assertCount(1, $this->sharingService->getShares(10001));
127
-
128
-		$this->sharingBackend->updateShares(
129
-			shareable: $calendar,
130
-			add: [],
131
-			remove: ['principals/groups/alice_bob'],
132
-		);
133
-
134
-		$this->assertCount(0, $this->sharingService->getShares(10001));
135
-	}
136
-
137
-	public function testShareCalendarWithGroupAndUnshareAsUser(): void {
138
-		$calendar = $this->createMock(Calendar::class);
139
-		$calendar->method('getResourceId')
140
-			->willReturn(10001);
141
-		$calendar->method('getOwner')
142
-			->willReturn('principals/users/admin');
143
-
144
-		$this->principalBackend->method('findByUri')
145
-			->willReturnMap([
146
-				['principals/groups/alice_bob', '', 'principals/groups/alice_bob'],
147
-				['principals/users/bob', '', 'principals/users/bob'],
148
-			]);
149
-		$this->principalBackend->method('getGroupMembership')
150
-			->willReturn([
151
-				'principals/groups/alice_bob',
152
-			]);
153
-		$this->principalBackend->method('getCircleMembership')
154
-			->willReturn([]);
155
-
156
-		$this->groupManager->method('groupExists')
157
-			->willReturn(true);
158
-
159
-		/*
33
+    private IDBConnection $db;
34
+    private IUserManager $userManager;
35
+    private IGroupManager $groupManager;
36
+    private Principal $principalBackend;
37
+    private ICacheFactory $cacheFactory;
38
+    private LoggerInterface $logger;
39
+    private SharingMapper $sharingMapper;
40
+    private SharingService $sharingService;
41
+    private Backend $sharingBackend;
42
+
43
+    private $resourceIds = [10001];
44
+
45
+    protected function setUp(): void {
46
+        parent::setUp();
47
+
48
+        $this->db = Server::get(IDBConnection::class);
49
+
50
+        $this->userManager = $this->createMock(IUserManager::class);
51
+        $this->groupManager = $this->createMock(IGroupManager::class);
52
+        $this->principalBackend = $this->createMock(Principal::class);
53
+        $this->cacheFactory = $this->createMock(ICacheFactory::class);
54
+        $this->cacheFactory->method('createInMemory')
55
+            ->willReturn(new NullCache());
56
+        $this->logger = new \Psr\Log\NullLogger();
57
+
58
+        $this->sharingMapper = new SharingMapper($this->db);
59
+        $this->sharingService = new Service($this->sharingMapper);
60
+
61
+        $this->sharingBackend = new \OCA\DAV\CalDAV\Sharing\Backend(
62
+            $this->userManager,
63
+            $this->groupManager,
64
+            $this->principalBackend,
65
+            $this->cacheFactory,
66
+            $this->sharingService,
67
+            $this->logger
68
+        );
69
+
70
+        $this->removeFixtures();
71
+    }
72
+
73
+    protected function tearDown(): void {
74
+        $this->removeFixtures();
75
+    }
76
+
77
+    protected function removeFixtures(): void {
78
+        $qb = $this->db->getQueryBuilder();
79
+        $qb->delete('dav_shares')
80
+            ->where($qb->expr()->in('resourceid', $qb->createNamedParameter($this->resourceIds, IQueryBuilder::PARAM_INT_ARRAY)));
81
+        $qb->executeStatement();
82
+    }
83
+
84
+    public function testShareCalendarWithGroup(): void {
85
+        $calendar = $this->createMock(Calendar::class);
86
+        $calendar->method('getResourceId')
87
+            ->willReturn(10001);
88
+        $calendar->method('getOwner')
89
+            ->willReturn('principals/users/admin');
90
+
91
+        $this->principalBackend->method('findByUri')
92
+            ->willReturn('principals/groups/alice_bob');
93
+
94
+        $this->groupManager->method('groupExists')
95
+            ->willReturn(true);
96
+
97
+        $this->sharingBackend->updateShares(
98
+            $calendar,
99
+            [['href' => 'principals/groups/alice_bob']],
100
+            [],
101
+            []
102
+        );
103
+
104
+        $this->assertCount(1, $this->sharingService->getShares(10001));
105
+    }
106
+
107
+    public function testUnshareCalendarFromGroup(): void {
108
+        $calendar = $this->createMock(Calendar::class);
109
+        $calendar->method('getResourceId')
110
+            ->willReturn(10001);
111
+        $calendar->method('getOwner')
112
+            ->willReturn('principals/users/admin');
113
+
114
+        $this->principalBackend->method('findByUri')
115
+            ->willReturn('principals/groups/alice_bob');
116
+
117
+        $this->groupManager->method('groupExists')
118
+            ->willReturn(true);
119
+
120
+        $this->sharingBackend->updateShares(
121
+            shareable: $calendar,
122
+            add: [['href' => 'principals/groups/alice_bob']],
123
+            remove: [],
124
+        );
125
+
126
+        $this->assertCount(1, $this->sharingService->getShares(10001));
127
+
128
+        $this->sharingBackend->updateShares(
129
+            shareable: $calendar,
130
+            add: [],
131
+            remove: ['principals/groups/alice_bob'],
132
+        );
133
+
134
+        $this->assertCount(0, $this->sharingService->getShares(10001));
135
+    }
136
+
137
+    public function testShareCalendarWithGroupAndUnshareAsUser(): void {
138
+        $calendar = $this->createMock(Calendar::class);
139
+        $calendar->method('getResourceId')
140
+            ->willReturn(10001);
141
+        $calendar->method('getOwner')
142
+            ->willReturn('principals/users/admin');
143
+
144
+        $this->principalBackend->method('findByUri')
145
+            ->willReturnMap([
146
+                ['principals/groups/alice_bob', '', 'principals/groups/alice_bob'],
147
+                ['principals/users/bob', '', 'principals/users/bob'],
148
+            ]);
149
+        $this->principalBackend->method('getGroupMembership')
150
+            ->willReturn([
151
+                'principals/groups/alice_bob',
152
+            ]);
153
+        $this->principalBackend->method('getCircleMembership')
154
+            ->willReturn([]);
155
+
156
+        $this->groupManager->method('groupExists')
157
+            ->willReturn(true);
158
+
159
+        /*
160 160
 		 * Owner is sharing the calendar with a group.
161 161
 		 */
162
-		$this->sharingBackend->updateShares(
163
-			shareable: $calendar,
164
-			add: [['href' => 'principals/groups/alice_bob']],
165
-			remove: [],
166
-		);
162
+        $this->sharingBackend->updateShares(
163
+            shareable: $calendar,
164
+            add: [['href' => 'principals/groups/alice_bob']],
165
+            remove: [],
166
+        );
167 167
 
168
-		$this->assertCount(1, $this->sharingService->getShares(10001));
168
+        $this->assertCount(1, $this->sharingService->getShares(10001));
169 169
 
170
-		/*
170
+        /*
171 171
 		 * Member of the group unshares the calendar.
172 172
 		 */
173
-		$this->sharingBackend->unshare(
174
-			shareable: $calendar,
175
-			principalUri: 'principals/users/bob'
176
-		);
177
-
178
-		$this->assertCount(1, $this->sharingService->getShares(10001));
179
-		$this->assertCount(1, $this->sharingService->getUnshares(10001));
180
-	}
181
-
182
-	/**
183
-	 * Tests the functionality of sharing a calendar with a user, then with a group (that includes the shared user),
184
-	 * and subsequently unsharing it from the individual user. Verifies that the unshare operation correctly removes the specific user share
185
-	 * without creating an additional unshare entry.
186
-	 */
187
-	public function testShareCalendarWithUserThenGroupThenUnshareUser(): void {
188
-		$calendar = $this->createMock(Calendar::class);
189
-		$calendar->method('getResourceId')
190
-			->willReturn(10001);
191
-		$calendar->method('getOwner')
192
-			->willReturn('principals/users/admin');
193
-
194
-		$this->principalBackend->method('findByUri')
195
-			->willReturnMap([
196
-				['principals/groups/alice_bob', '', 'principals/groups/alice_bob'],
197
-				['principals/users/bob', '', 'principals/users/bob'],
198
-			]);
199
-		$this->principalBackend->method('getGroupMembership')
200
-			->willReturn([
201
-				'principals/groups/alice_bob',
202
-			]);
203
-		$this->principalBackend->method('getCircleMembership')
204
-			->willReturn([]);
205
-
206
-		$this->userManager->method('userExists')
207
-			->willReturn(true);
208
-		$this->groupManager->method('groupExists')
209
-			->willReturn(true);
210
-
211
-		/*
173
+        $this->sharingBackend->unshare(
174
+            shareable: $calendar,
175
+            principalUri: 'principals/users/bob'
176
+        );
177
+
178
+        $this->assertCount(1, $this->sharingService->getShares(10001));
179
+        $this->assertCount(1, $this->sharingService->getUnshares(10001));
180
+    }
181
+
182
+    /**
183
+     * Tests the functionality of sharing a calendar with a user, then with a group (that includes the shared user),
184
+     * and subsequently unsharing it from the individual user. Verifies that the unshare operation correctly removes the specific user share
185
+     * without creating an additional unshare entry.
186
+     */
187
+    public function testShareCalendarWithUserThenGroupThenUnshareUser(): void {
188
+        $calendar = $this->createMock(Calendar::class);
189
+        $calendar->method('getResourceId')
190
+            ->willReturn(10001);
191
+        $calendar->method('getOwner')
192
+            ->willReturn('principals/users/admin');
193
+
194
+        $this->principalBackend->method('findByUri')
195
+            ->willReturnMap([
196
+                ['principals/groups/alice_bob', '', 'principals/groups/alice_bob'],
197
+                ['principals/users/bob', '', 'principals/users/bob'],
198
+            ]);
199
+        $this->principalBackend->method('getGroupMembership')
200
+            ->willReturn([
201
+                'principals/groups/alice_bob',
202
+            ]);
203
+        $this->principalBackend->method('getCircleMembership')
204
+            ->willReturn([]);
205
+
206
+        $this->userManager->method('userExists')
207
+            ->willReturn(true);
208
+        $this->groupManager->method('groupExists')
209
+            ->willReturn(true);
210
+
211
+        /*
212 212
 		 * Step 1) The owner shares the calendar with a user.
213 213
 		 */
214
-		$this->sharingBackend->updateShares(
215
-			shareable: $calendar,
216
-			add: [['href' => 'principals/users/bob']],
217
-			remove: [],
218
-		);
214
+        $this->sharingBackend->updateShares(
215
+            shareable: $calendar,
216
+            add: [['href' => 'principals/users/bob']],
217
+            remove: [],
218
+        );
219 219
 
220
-		$this->assertCount(1, $this->sharingService->getShares(10001));
220
+        $this->assertCount(1, $this->sharingService->getShares(10001));
221 221
 
222
-		/*
222
+        /*
223 223
 		 * Step 2) The owner shares the calendar with a group that includes the
224 224
 		 * user from step 1 as a member.
225 225
 		 */
226
-		$this->sharingBackend->updateShares(
227
-			shareable: $calendar,
228
-			add: [['href' => 'principals/groups/alice_bob']],
229
-			remove: [],
230
-		);
226
+        $this->sharingBackend->updateShares(
227
+            shareable: $calendar,
228
+            add: [['href' => 'principals/groups/alice_bob']],
229
+            remove: [],
230
+        );
231 231
 
232
-		$this->assertCount(2, $this->sharingService->getShares(10001));
232
+        $this->assertCount(2, $this->sharingService->getShares(10001));
233 233
 
234
-		/*
234
+        /*
235 235
 		 * Step 3) Unshare the calendar from user as owner.
236 236
 		 */
237
-		$this->sharingBackend->updateShares(
238
-			shareable: $calendar,
239
-			add: [],
240
-			remove: ['principals/users/bob'],
241
-		);
237
+        $this->sharingBackend->updateShares(
238
+            shareable: $calendar,
239
+            add: [],
240
+            remove: ['principals/users/bob'],
241
+        );
242 242
 
243
-		/*
243
+        /*
244 244
 		 * The purpose of this test is to ensure that removing a user from a share, as the owner, does not result in an "unshare" row being added.
245 245
 		 * Instead, the actual user share should be removed.
246 246
 		 */
247
-		$this->assertCount(1, $this->sharingService->getShares(10001));
248
-		$this->assertCount(0, $this->sharingService->getUnshares(10001));
249
-	}
247
+        $this->assertCount(1, $this->sharingService->getShares(10001));
248
+        $this->assertCount(0, $this->sharingService->getUnshares(10001));
249
+    }
250 250
 
251 251
 }
Please login to merge, or discard this patch.
apps/dav/tests/unit/Connector/Sabre/DirectoryTest.php 1 patch
Indentation   +451 added lines, -451 removed lines patch added patch discarded remove patch
@@ -24,32 +24,32 @@  discard block
 block discarded – undo
24 24
 use Test\Traits\UserTrait;
25 25
 
26 26
 class TestViewDirectory extends View {
27
-	public function __construct(
28
-		private $updatables,
29
-		private $deletables,
30
-		private $canRename = true,
31
-	) {
32
-	}
33
-
34
-	public function isUpdatable($path) {
35
-		return $this->updatables[$path];
36
-	}
37
-
38
-	public function isCreatable($path) {
39
-		return $this->updatables[$path];
40
-	}
41
-
42
-	public function isDeletable($path) {
43
-		return $this->deletables[$path];
44
-	}
45
-
46
-	public function rename($path1, $path2, array $options = []) {
47
-		return $this->canRename;
48
-	}
49
-
50
-	public function getRelativePath($path): ?string {
51
-		return $path;
52
-	}
27
+    public function __construct(
28
+        private $updatables,
29
+        private $deletables,
30
+        private $canRename = true,
31
+    ) {
32
+    }
33
+
34
+    public function isUpdatable($path) {
35
+        return $this->updatables[$path];
36
+    }
37
+
38
+    public function isCreatable($path) {
39
+        return $this->updatables[$path];
40
+    }
41
+
42
+    public function isDeletable($path) {
43
+        return $this->deletables[$path];
44
+    }
45
+
46
+    public function rename($path1, $path2, array $options = []) {
47
+        return $this->canRename;
48
+    }
49
+
50
+    public function getRelativePath($path): ?string {
51
+        return $path;
52
+    }
53 53
 }
54 54
 
55 55
 
@@ -57,431 +57,431 @@  discard block
 block discarded – undo
57 57
  * @group DB
58 58
  */
59 59
 class DirectoryTest extends \Test\TestCase {
60
-	use UserTrait;
60
+    use UserTrait;
61 61
 
62
-	/** @var View|\PHPUnit\Framework\MockObject\MockObject */
63
-	private $view;
64
-	/** @var FileInfo|\PHPUnit\Framework\MockObject\MockObject */
65
-	private $info;
62
+    /** @var View|\PHPUnit\Framework\MockObject\MockObject */
63
+    private $view;
64
+    /** @var FileInfo|\PHPUnit\Framework\MockObject\MockObject */
65
+    private $info;
66 66
 
67
-	protected function setUp(): void {
68
-		parent::setUp();
69
-
70
-		$this->view = $this->createMock('OC\Files\View');
71
-		$this->info = $this->createMock('OC\Files\FileInfo');
72
-		$this->info->method('isReadable')
73
-			->willReturn(true);
74
-		$this->info->method('getType')
75
-			->willReturn(Node::TYPE_FOLDER);
76
-		$this->info->method('getName')
77
-			->willReturn('folder');
78
-		$this->info->method('getPath')
79
-			->willReturn('/admin/files/folder');
80
-		$this->info->method('getPermissions')
81
-			->willReturn(Constants::PERMISSION_READ);
82
-	}
83
-
84
-	private function getDir($path = '/') {
85
-		$this->view->expects($this->once())
86
-			->method('getRelativePath')
87
-			->willReturn($path);
88
-
89
-		$this->info->expects($this->once())
90
-			->method('getPath')
91
-			->willReturn($path);
92
-
93
-		return new Directory($this->view, $this->info);
94
-	}
95
-
96
-
97
-	public function testDeleteRootFolderFails(): void {
98
-		$this->expectException(\Sabre\DAV\Exception\Forbidden::class);
99
-
100
-		$this->info->expects($this->any())
101
-			->method('isDeletable')
102
-			->willReturn(true);
103
-		$this->view->expects($this->never())
104
-			->method('rmdir');
105
-		$dir = $this->getDir();
106
-		$dir->delete();
107
-	}
108
-
109
-
110
-	public function testDeleteForbidden(): void {
111
-		$this->expectException(Forbidden::class);
112
-
113
-		// deletion allowed
114
-		$this->info->expects($this->once())
115
-			->method('isDeletable')
116
-			->willReturn(true);
117
-
118
-		// but fails
119
-		$this->view->expects($this->once())
120
-			->method('rmdir')
121
-			->with('sub')
122
-			->willThrowException(new ForbiddenException('', true));
123
-
124
-		$dir = $this->getDir('sub');
125
-		$dir->delete();
126
-	}
127
-
128
-
129
-	public function testDeleteFolderWhenAllowed(): void {
130
-		// deletion allowed
131
-		$this->info->expects($this->once())
132
-			->method('isDeletable')
133
-			->willReturn(true);
134
-
135
-		// but fails
136
-		$this->view->expects($this->once())
137
-			->method('rmdir')
138
-			->with('sub')
139
-			->willReturn(true);
140
-
141
-		$dir = $this->getDir('sub');
142
-		$dir->delete();
143
-	}
144
-
145
-
146
-	public function testDeleteFolderFailsWhenNotAllowed(): void {
147
-		$this->expectException(\Sabre\DAV\Exception\Forbidden::class);
148
-
149
-		$this->info->expects($this->once())
150
-			->method('isDeletable')
151
-			->willReturn(false);
152
-
153
-		$dir = $this->getDir('sub');
154
-		$dir->delete();
155
-	}
156
-
157
-
158
-	public function testDeleteFolderThrowsWhenDeletionFailed(): void {
159
-		$this->expectException(\Sabre\DAV\Exception\Forbidden::class);
160
-
161
-		// deletion allowed
162
-		$this->info->expects($this->once())
163
-			->method('isDeletable')
164
-			->willReturn(true);
165
-
166
-		// but fails
167
-		$this->view->expects($this->once())
168
-			->method('rmdir')
169
-			->with('sub')
170
-			->willReturn(false);
171
-
172
-		$dir = $this->getDir('sub');
173
-		$dir->delete();
174
-	}
175
-
176
-	public function testGetChildren(): void {
177
-		$info1 = $this->getMockBuilder(FileInfo::class)
178
-			->disableOriginalConstructor()
179
-			->getMock();
180
-		$info2 = $this->getMockBuilder(FileInfo::class)
181
-			->disableOriginalConstructor()
182
-			->getMock();
183
-		$info1->method('getName')
184
-			->willReturn('first');
185
-		$info1->method('getPath')
186
-			->willReturn('folder/first');
187
-		$info1->method('getEtag')
188
-			->willReturn('abc');
189
-		$info2->method('getName')
190
-			->willReturn('second');
191
-		$info2->method('getPath')
192
-			->willReturn('folder/second');
193
-		$info2->method('getEtag')
194
-			->willReturn('def');
195
-
196
-		$this->view->expects($this->once())
197
-			->method('getDirectoryContent')
198
-			->willReturn([$info1, $info2]);
199
-
200
-		$this->view->expects($this->any())
201
-			->method('getRelativePath')
202
-			->willReturnCallback(function ($path) {
203
-				return str_replace('/admin/files/', '', $path);
204
-			});
205
-
206
-		$this->view->expects($this->any())
207
-			->method('getAbsolutePath')
208
-			->willReturnCallback(function ($path) {
209
-				return Filesystem::normalizePath('/admin/files' . $path);
210
-			});
211
-
212
-		$this->overwriteService(View::class, $this->view);
213
-
214
-		$dir = new Directory($this->view, $this->info);
215
-		$nodes = $dir->getChildren();
216
-
217
-		$this->assertEquals(2, count($nodes));
218
-
219
-		// calling a second time just returns the cached values,
220
-		// does not call getDirectoryContents again
221
-		$dir->getChildren();
222
-	}
223
-
224
-
225
-	public function testGetChildrenNoPermission(): void {
226
-		$this->expectException(\Sabre\DAV\Exception\Forbidden::class);
227
-
228
-		$info = $this->createMock(FileInfo::class);
229
-		$info->expects($this->any())
230
-			->method('isReadable')
231
-			->willReturn(false);
232
-
233
-		$dir = new Directory($this->view, $info);
234
-		$dir->getChildren();
235
-	}
236
-
237
-
238
-	public function testGetChildNoPermission(): void {
239
-		$this->expectException(\Sabre\DAV\Exception\NotFound::class);
240
-
241
-		$this->info->expects($this->any())
242
-			->method('isReadable')
243
-			->willReturn(false);
244
-
245
-		$dir = new Directory($this->view, $this->info);
246
-		$dir->getChild('test');
247
-	}
248
-
249
-
250
-	public function testGetChildThrowStorageNotAvailableException(): void {
251
-		$this->expectException(\Sabre\DAV\Exception\ServiceUnavailable::class);
252
-
253
-		$this->view->expects($this->once())
254
-			->method('getFileInfo')
255
-			->willThrowException(new StorageNotAvailableException());
256
-
257
-		$dir = new Directory($this->view, $this->info);
258
-		$dir->getChild('.');
259
-	}
260
-
261
-
262
-	public function testGetChildThrowInvalidPath(): void {
263
-		$this->expectException(InvalidPath::class);
264
-
265
-		$this->view->expects($this->once())
266
-			->method('verifyPath')
267
-			->willThrowException(new InvalidPathException());
268
-		$this->view->expects($this->never())
269
-			->method('getFileInfo');
270
-
271
-		$dir = new Directory($this->view, $this->info);
272
-		$dir->getChild('.');
273
-	}
274
-
275
-	public function testGetQuotaInfoUnlimited(): void {
276
-		self::createUser('user', 'password');
277
-		self::loginAsUser('user');
278
-		$mountPoint = $this->createMock(IMountPoint::class);
279
-		$storage = $this->getMockBuilder(Quota::class)
280
-			->disableOriginalConstructor()
281
-			->getMock();
282
-		$mountPoint->method('getStorage')
283
-			->willReturn($storage);
284
-
285
-		$storage->expects($this->any())
286
-			->method('instanceOfStorage')
287
-			->willReturnMap([
288
-				['\OCA\Files_Sharing\SharedStorage', false],
289
-				['\OC\Files\Storage\Wrapper\Quota', false],
290
-				[Storage::class, false],
291
-			]);
292
-
293
-		$storage->expects($this->once())
294
-			->method('getOwner')
295
-			->willReturn('user');
296
-
297
-		$storage->expects($this->never())
298
-			->method('getQuota');
299
-
300
-		$storage->expects($this->once())
301
-			->method('free_space')
302
-			->willReturn(800);
303
-
304
-		$this->info->expects($this->any())
305
-			->method('getPath')
306
-			->willReturn('/admin/files/foo');
307
-
308
-		$this->info->expects($this->once())
309
-			->method('getSize')
310
-			->willReturn(200);
311
-
312
-		$this->info->expects($this->once())
313
-			->method('getMountPoint')
314
-			->willReturn($mountPoint);
315
-
316
-		$this->view->expects($this->any())
317
-			->method('getRelativePath')
318
-			->willReturn('/foo');
319
-
320
-		$this->info->expects($this->once())
321
-			->method('getInternalPath')
322
-			->willReturn('/foo');
323
-
324
-		$mountPoint->method('getMountPoint')
325
-			->willReturn('/user/files/mymountpoint');
326
-
327
-		$dir = new Directory($this->view, $this->info);
328
-		$this->assertEquals([200, -3], $dir->getQuotaInfo()); //200 used, unlimited
329
-	}
330
-
331
-	public function testGetQuotaInfoSpecific(): void {
332
-		self::createUser('user', 'password');
333
-		self::loginAsUser('user');
334
-		$mountPoint = $this->createMock(IMountPoint::class);
335
-		$storage = $this->getMockBuilder(Quota::class)
336
-			->disableOriginalConstructor()
337
-			->getMock();
338
-		$mountPoint->method('getStorage')
339
-			->willReturn($storage);
340
-
341
-		$storage->expects($this->any())
342
-			->method('instanceOfStorage')
343
-			->willReturnMap([
344
-				['\OCA\Files_Sharing\SharedStorage', false],
345
-				['\OC\Files\Storage\Wrapper\Quota', true],
346
-				[Storage::class, false],
347
-			]);
348
-
349
-		$storage->expects($this->once())
350
-			->method('getOwner')
351
-			->willReturn('user');
352
-
353
-		$storage->expects($this->once())
354
-			->method('getQuota')
355
-			->willReturn(1000);
356
-
357
-		$storage->expects($this->once())
358
-			->method('free_space')
359
-			->willReturn(800);
360
-
361
-		$this->info->expects($this->once())
362
-			->method('getSize')
363
-			->willReturn(200);
364
-
365
-		$this->info->expects($this->once())
366
-			->method('getMountPoint')
367
-			->willReturn($mountPoint);
368
-
369
-		$this->info->expects($this->once())
370
-			->method('getInternalPath')
371
-			->willReturn('/foo');
372
-
373
-		$mountPoint->method('getMountPoint')
374
-			->willReturn('/user/files/mymountpoint');
375
-
376
-		$this->view->expects($this->any())
377
-			->method('getRelativePath')
378
-			->willReturn('/foo');
379
-
380
-		$dir = new Directory($this->view, $this->info);
381
-		$this->assertEquals([200, 800], $dir->getQuotaInfo()); //200 used, 800 free
382
-	}
383
-
384
-	/**
385
-	 * @dataProvider moveFailedProvider
386
-	 */
387
-	public function testMoveFailed($source, $destination, $updatables, $deletables): void {
388
-		$this->expectException(\Sabre\DAV\Exception\Forbidden::class);
389
-
390
-		$this->moveTest($source, $destination, $updatables, $deletables);
391
-	}
392
-
393
-	/**
394
-	 * @dataProvider moveSuccessProvider
395
-	 */
396
-	public function testMoveSuccess($source, $destination, $updatables, $deletables): void {
397
-		$this->moveTest($source, $destination, $updatables, $deletables);
398
-		$this->addToAssertionCount(1);
399
-	}
400
-
401
-	/**
402
-	 * @dataProvider moveFailedInvalidCharsProvider
403
-	 */
404
-	public function testMoveFailedInvalidChars($source, $destination, $updatables, $deletables): void {
405
-		$this->expectException(InvalidPath::class);
406
-
407
-		$this->moveTest($source, $destination, $updatables, $deletables);
408
-	}
409
-
410
-	public function moveFailedInvalidCharsProvider() {
411
-		return [
412
-			['a/valid', "a/i\nvalid", ['a' => true, 'a/valid' => true, 'a/c*' => false], []],
413
-		];
414
-	}
415
-
416
-	public function moveFailedProvider() {
417
-		return [
418
-			['a/b', 'a/c', ['a' => false, 'a/b' => false, 'a/c' => false], []],
419
-			['a/b', 'b/b', ['a' => false, 'a/b' => false, 'b' => false, 'b/b' => false], []],
420
-			['a/b', 'b/b', ['a' => false, 'a/b' => true, 'b' => false, 'b/b' => false], []],
421
-			['a/b', 'b/b', ['a' => true, 'a/b' => true, 'b' => false, 'b/b' => false], []],
422
-			['a/b', 'b/b', ['a' => true, 'a/b' => true, 'b' => true, 'b/b' => false], ['a/b' => false]],
423
-			['a/b', 'a/c', ['a' => false, 'a/b' => true, 'a/c' => false], []],
424
-		];
425
-	}
426
-
427
-	public function moveSuccessProvider() {
428
-		return [
429
-			['a/b', 'b/b', ['a' => true, 'a/b' => true, 'b' => true, 'b/b' => false], ['a/b' => true]],
430
-			// older files with special chars can still be renamed to valid names
431
-			['a/b*', 'b/b', ['a' => true, 'a/b*' => true, 'b' => true, 'b/b' => false], ['a/b*' => true]],
432
-		];
433
-	}
434
-
435
-	/**
436
-	 * @param $source
437
-	 * @param $destination
438
-	 * @param $updatables
439
-	 */
440
-	private function moveTest($source, $destination, $updatables, $deletables): void {
441
-		$view = new TestViewDirectory($updatables, $deletables);
442
-
443
-		$sourceInfo = new FileInfo($source, null, null, [
444
-			'type' => FileInfo::TYPE_FOLDER,
445
-		], null);
446
-		$targetInfo = new FileInfo(dirname($destination), null, null, [
447
-			'type' => FileInfo::TYPE_FOLDER,
448
-		], null);
449
-
450
-		$sourceNode = new Directory($view, $sourceInfo);
451
-		$targetNode = $this->getMockBuilder(Directory::class)
452
-			->setMethods(['childExists'])
453
-			->setConstructorArgs([$view, $targetInfo])
454
-			->getMock();
455
-		$targetNode->expects($this->any())->method('childExists')
456
-			->with(basename($destination))
457
-			->willReturn(false);
458
-		$this->assertTrue($targetNode->moveInto(basename($destination), $source, $sourceNode));
459
-	}
460
-
461
-
462
-	public function testFailingMove(): void {
463
-		$this->expectException(\Sabre\DAV\Exception\Forbidden::class);
464
-		$this->expectExceptionMessage('Could not copy directory b, target exists');
465
-
466
-		$source = 'a/b';
467
-		$destination = 'c/b';
468
-		$updatables = ['a' => true, 'a/b' => true, 'b' => true, 'c/b' => false];
469
-		$deletables = ['a/b' => true];
470
-
471
-		$view = new TestViewDirectory($updatables, $deletables);
472
-
473
-		$sourceInfo = new FileInfo($source, null, null, ['type' => FileInfo::TYPE_FOLDER], null);
474
-		$targetInfo = new FileInfo(dirname($destination), null, null, ['type' => FileInfo::TYPE_FOLDER], null);
475
-
476
-		$sourceNode = new Directory($view, $sourceInfo);
477
-		$targetNode = $this->getMockBuilder(Directory::class)
478
-			->onlyMethods(['childExists'])
479
-			->setConstructorArgs([$view, $targetInfo])
480
-			->getMock();
481
-		$targetNode->expects($this->once())->method('childExists')
482
-			->with(basename($destination))
483
-			->willReturn(true);
484
-
485
-		$targetNode->moveInto(basename($destination), $source, $sourceNode);
486
-	}
67
+    protected function setUp(): void {
68
+        parent::setUp();
69
+
70
+        $this->view = $this->createMock('OC\Files\View');
71
+        $this->info = $this->createMock('OC\Files\FileInfo');
72
+        $this->info->method('isReadable')
73
+            ->willReturn(true);
74
+        $this->info->method('getType')
75
+            ->willReturn(Node::TYPE_FOLDER);
76
+        $this->info->method('getName')
77
+            ->willReturn('folder');
78
+        $this->info->method('getPath')
79
+            ->willReturn('/admin/files/folder');
80
+        $this->info->method('getPermissions')
81
+            ->willReturn(Constants::PERMISSION_READ);
82
+    }
83
+
84
+    private function getDir($path = '/') {
85
+        $this->view->expects($this->once())
86
+            ->method('getRelativePath')
87
+            ->willReturn($path);
88
+
89
+        $this->info->expects($this->once())
90
+            ->method('getPath')
91
+            ->willReturn($path);
92
+
93
+        return new Directory($this->view, $this->info);
94
+    }
95
+
96
+
97
+    public function testDeleteRootFolderFails(): void {
98
+        $this->expectException(\Sabre\DAV\Exception\Forbidden::class);
99
+
100
+        $this->info->expects($this->any())
101
+            ->method('isDeletable')
102
+            ->willReturn(true);
103
+        $this->view->expects($this->never())
104
+            ->method('rmdir');
105
+        $dir = $this->getDir();
106
+        $dir->delete();
107
+    }
108
+
109
+
110
+    public function testDeleteForbidden(): void {
111
+        $this->expectException(Forbidden::class);
112
+
113
+        // deletion allowed
114
+        $this->info->expects($this->once())
115
+            ->method('isDeletable')
116
+            ->willReturn(true);
117
+
118
+        // but fails
119
+        $this->view->expects($this->once())
120
+            ->method('rmdir')
121
+            ->with('sub')
122
+            ->willThrowException(new ForbiddenException('', true));
123
+
124
+        $dir = $this->getDir('sub');
125
+        $dir->delete();
126
+    }
127
+
128
+
129
+    public function testDeleteFolderWhenAllowed(): void {
130
+        // deletion allowed
131
+        $this->info->expects($this->once())
132
+            ->method('isDeletable')
133
+            ->willReturn(true);
134
+
135
+        // but fails
136
+        $this->view->expects($this->once())
137
+            ->method('rmdir')
138
+            ->with('sub')
139
+            ->willReturn(true);
140
+
141
+        $dir = $this->getDir('sub');
142
+        $dir->delete();
143
+    }
144
+
145
+
146
+    public function testDeleteFolderFailsWhenNotAllowed(): void {
147
+        $this->expectException(\Sabre\DAV\Exception\Forbidden::class);
148
+
149
+        $this->info->expects($this->once())
150
+            ->method('isDeletable')
151
+            ->willReturn(false);
152
+
153
+        $dir = $this->getDir('sub');
154
+        $dir->delete();
155
+    }
156
+
157
+
158
+    public function testDeleteFolderThrowsWhenDeletionFailed(): void {
159
+        $this->expectException(\Sabre\DAV\Exception\Forbidden::class);
160
+
161
+        // deletion allowed
162
+        $this->info->expects($this->once())
163
+            ->method('isDeletable')
164
+            ->willReturn(true);
165
+
166
+        // but fails
167
+        $this->view->expects($this->once())
168
+            ->method('rmdir')
169
+            ->with('sub')
170
+            ->willReturn(false);
171
+
172
+        $dir = $this->getDir('sub');
173
+        $dir->delete();
174
+    }
175
+
176
+    public function testGetChildren(): void {
177
+        $info1 = $this->getMockBuilder(FileInfo::class)
178
+            ->disableOriginalConstructor()
179
+            ->getMock();
180
+        $info2 = $this->getMockBuilder(FileInfo::class)
181
+            ->disableOriginalConstructor()
182
+            ->getMock();
183
+        $info1->method('getName')
184
+            ->willReturn('first');
185
+        $info1->method('getPath')
186
+            ->willReturn('folder/first');
187
+        $info1->method('getEtag')
188
+            ->willReturn('abc');
189
+        $info2->method('getName')
190
+            ->willReturn('second');
191
+        $info2->method('getPath')
192
+            ->willReturn('folder/second');
193
+        $info2->method('getEtag')
194
+            ->willReturn('def');
195
+
196
+        $this->view->expects($this->once())
197
+            ->method('getDirectoryContent')
198
+            ->willReturn([$info1, $info2]);
199
+
200
+        $this->view->expects($this->any())
201
+            ->method('getRelativePath')
202
+            ->willReturnCallback(function ($path) {
203
+                return str_replace('/admin/files/', '', $path);
204
+            });
205
+
206
+        $this->view->expects($this->any())
207
+            ->method('getAbsolutePath')
208
+            ->willReturnCallback(function ($path) {
209
+                return Filesystem::normalizePath('/admin/files' . $path);
210
+            });
211
+
212
+        $this->overwriteService(View::class, $this->view);
213
+
214
+        $dir = new Directory($this->view, $this->info);
215
+        $nodes = $dir->getChildren();
216
+
217
+        $this->assertEquals(2, count($nodes));
218
+
219
+        // calling a second time just returns the cached values,
220
+        // does not call getDirectoryContents again
221
+        $dir->getChildren();
222
+    }
223
+
224
+
225
+    public function testGetChildrenNoPermission(): void {
226
+        $this->expectException(\Sabre\DAV\Exception\Forbidden::class);
227
+
228
+        $info = $this->createMock(FileInfo::class);
229
+        $info->expects($this->any())
230
+            ->method('isReadable')
231
+            ->willReturn(false);
232
+
233
+        $dir = new Directory($this->view, $info);
234
+        $dir->getChildren();
235
+    }
236
+
237
+
238
+    public function testGetChildNoPermission(): void {
239
+        $this->expectException(\Sabre\DAV\Exception\NotFound::class);
240
+
241
+        $this->info->expects($this->any())
242
+            ->method('isReadable')
243
+            ->willReturn(false);
244
+
245
+        $dir = new Directory($this->view, $this->info);
246
+        $dir->getChild('test');
247
+    }
248
+
249
+
250
+    public function testGetChildThrowStorageNotAvailableException(): void {
251
+        $this->expectException(\Sabre\DAV\Exception\ServiceUnavailable::class);
252
+
253
+        $this->view->expects($this->once())
254
+            ->method('getFileInfo')
255
+            ->willThrowException(new StorageNotAvailableException());
256
+
257
+        $dir = new Directory($this->view, $this->info);
258
+        $dir->getChild('.');
259
+    }
260
+
261
+
262
+    public function testGetChildThrowInvalidPath(): void {
263
+        $this->expectException(InvalidPath::class);
264
+
265
+        $this->view->expects($this->once())
266
+            ->method('verifyPath')
267
+            ->willThrowException(new InvalidPathException());
268
+        $this->view->expects($this->never())
269
+            ->method('getFileInfo');
270
+
271
+        $dir = new Directory($this->view, $this->info);
272
+        $dir->getChild('.');
273
+    }
274
+
275
+    public function testGetQuotaInfoUnlimited(): void {
276
+        self::createUser('user', 'password');
277
+        self::loginAsUser('user');
278
+        $mountPoint = $this->createMock(IMountPoint::class);
279
+        $storage = $this->getMockBuilder(Quota::class)
280
+            ->disableOriginalConstructor()
281
+            ->getMock();
282
+        $mountPoint->method('getStorage')
283
+            ->willReturn($storage);
284
+
285
+        $storage->expects($this->any())
286
+            ->method('instanceOfStorage')
287
+            ->willReturnMap([
288
+                ['\OCA\Files_Sharing\SharedStorage', false],
289
+                ['\OC\Files\Storage\Wrapper\Quota', false],
290
+                [Storage::class, false],
291
+            ]);
292
+
293
+        $storage->expects($this->once())
294
+            ->method('getOwner')
295
+            ->willReturn('user');
296
+
297
+        $storage->expects($this->never())
298
+            ->method('getQuota');
299
+
300
+        $storage->expects($this->once())
301
+            ->method('free_space')
302
+            ->willReturn(800);
303
+
304
+        $this->info->expects($this->any())
305
+            ->method('getPath')
306
+            ->willReturn('/admin/files/foo');
307
+
308
+        $this->info->expects($this->once())
309
+            ->method('getSize')
310
+            ->willReturn(200);
311
+
312
+        $this->info->expects($this->once())
313
+            ->method('getMountPoint')
314
+            ->willReturn($mountPoint);
315
+
316
+        $this->view->expects($this->any())
317
+            ->method('getRelativePath')
318
+            ->willReturn('/foo');
319
+
320
+        $this->info->expects($this->once())
321
+            ->method('getInternalPath')
322
+            ->willReturn('/foo');
323
+
324
+        $mountPoint->method('getMountPoint')
325
+            ->willReturn('/user/files/mymountpoint');
326
+
327
+        $dir = new Directory($this->view, $this->info);
328
+        $this->assertEquals([200, -3], $dir->getQuotaInfo()); //200 used, unlimited
329
+    }
330
+
331
+    public function testGetQuotaInfoSpecific(): void {
332
+        self::createUser('user', 'password');
333
+        self::loginAsUser('user');
334
+        $mountPoint = $this->createMock(IMountPoint::class);
335
+        $storage = $this->getMockBuilder(Quota::class)
336
+            ->disableOriginalConstructor()
337
+            ->getMock();
338
+        $mountPoint->method('getStorage')
339
+            ->willReturn($storage);
340
+
341
+        $storage->expects($this->any())
342
+            ->method('instanceOfStorage')
343
+            ->willReturnMap([
344
+                ['\OCA\Files_Sharing\SharedStorage', false],
345
+                ['\OC\Files\Storage\Wrapper\Quota', true],
346
+                [Storage::class, false],
347
+            ]);
348
+
349
+        $storage->expects($this->once())
350
+            ->method('getOwner')
351
+            ->willReturn('user');
352
+
353
+        $storage->expects($this->once())
354
+            ->method('getQuota')
355
+            ->willReturn(1000);
356
+
357
+        $storage->expects($this->once())
358
+            ->method('free_space')
359
+            ->willReturn(800);
360
+
361
+        $this->info->expects($this->once())
362
+            ->method('getSize')
363
+            ->willReturn(200);
364
+
365
+        $this->info->expects($this->once())
366
+            ->method('getMountPoint')
367
+            ->willReturn($mountPoint);
368
+
369
+        $this->info->expects($this->once())
370
+            ->method('getInternalPath')
371
+            ->willReturn('/foo');
372
+
373
+        $mountPoint->method('getMountPoint')
374
+            ->willReturn('/user/files/mymountpoint');
375
+
376
+        $this->view->expects($this->any())
377
+            ->method('getRelativePath')
378
+            ->willReturn('/foo');
379
+
380
+        $dir = new Directory($this->view, $this->info);
381
+        $this->assertEquals([200, 800], $dir->getQuotaInfo()); //200 used, 800 free
382
+    }
383
+
384
+    /**
385
+     * @dataProvider moveFailedProvider
386
+     */
387
+    public function testMoveFailed($source, $destination, $updatables, $deletables): void {
388
+        $this->expectException(\Sabre\DAV\Exception\Forbidden::class);
389
+
390
+        $this->moveTest($source, $destination, $updatables, $deletables);
391
+    }
392
+
393
+    /**
394
+     * @dataProvider moveSuccessProvider
395
+     */
396
+    public function testMoveSuccess($source, $destination, $updatables, $deletables): void {
397
+        $this->moveTest($source, $destination, $updatables, $deletables);
398
+        $this->addToAssertionCount(1);
399
+    }
400
+
401
+    /**
402
+     * @dataProvider moveFailedInvalidCharsProvider
403
+     */
404
+    public function testMoveFailedInvalidChars($source, $destination, $updatables, $deletables): void {
405
+        $this->expectException(InvalidPath::class);
406
+
407
+        $this->moveTest($source, $destination, $updatables, $deletables);
408
+    }
409
+
410
+    public function moveFailedInvalidCharsProvider() {
411
+        return [
412
+            ['a/valid', "a/i\nvalid", ['a' => true, 'a/valid' => true, 'a/c*' => false], []],
413
+        ];
414
+    }
415
+
416
+    public function moveFailedProvider() {
417
+        return [
418
+            ['a/b', 'a/c', ['a' => false, 'a/b' => false, 'a/c' => false], []],
419
+            ['a/b', 'b/b', ['a' => false, 'a/b' => false, 'b' => false, 'b/b' => false], []],
420
+            ['a/b', 'b/b', ['a' => false, 'a/b' => true, 'b' => false, 'b/b' => false], []],
421
+            ['a/b', 'b/b', ['a' => true, 'a/b' => true, 'b' => false, 'b/b' => false], []],
422
+            ['a/b', 'b/b', ['a' => true, 'a/b' => true, 'b' => true, 'b/b' => false], ['a/b' => false]],
423
+            ['a/b', 'a/c', ['a' => false, 'a/b' => true, 'a/c' => false], []],
424
+        ];
425
+    }
426
+
427
+    public function moveSuccessProvider() {
428
+        return [
429
+            ['a/b', 'b/b', ['a' => true, 'a/b' => true, 'b' => true, 'b/b' => false], ['a/b' => true]],
430
+            // older files with special chars can still be renamed to valid names
431
+            ['a/b*', 'b/b', ['a' => true, 'a/b*' => true, 'b' => true, 'b/b' => false], ['a/b*' => true]],
432
+        ];
433
+    }
434
+
435
+    /**
436
+     * @param $source
437
+     * @param $destination
438
+     * @param $updatables
439
+     */
440
+    private function moveTest($source, $destination, $updatables, $deletables): void {
441
+        $view = new TestViewDirectory($updatables, $deletables);
442
+
443
+        $sourceInfo = new FileInfo($source, null, null, [
444
+            'type' => FileInfo::TYPE_FOLDER,
445
+        ], null);
446
+        $targetInfo = new FileInfo(dirname($destination), null, null, [
447
+            'type' => FileInfo::TYPE_FOLDER,
448
+        ], null);
449
+
450
+        $sourceNode = new Directory($view, $sourceInfo);
451
+        $targetNode = $this->getMockBuilder(Directory::class)
452
+            ->setMethods(['childExists'])
453
+            ->setConstructorArgs([$view, $targetInfo])
454
+            ->getMock();
455
+        $targetNode->expects($this->any())->method('childExists')
456
+            ->with(basename($destination))
457
+            ->willReturn(false);
458
+        $this->assertTrue($targetNode->moveInto(basename($destination), $source, $sourceNode));
459
+    }
460
+
461
+
462
+    public function testFailingMove(): void {
463
+        $this->expectException(\Sabre\DAV\Exception\Forbidden::class);
464
+        $this->expectExceptionMessage('Could not copy directory b, target exists');
465
+
466
+        $source = 'a/b';
467
+        $destination = 'c/b';
468
+        $updatables = ['a' => true, 'a/b' => true, 'b' => true, 'c/b' => false];
469
+        $deletables = ['a/b' => true];
470
+
471
+        $view = new TestViewDirectory($updatables, $deletables);
472
+
473
+        $sourceInfo = new FileInfo($source, null, null, ['type' => FileInfo::TYPE_FOLDER], null);
474
+        $targetInfo = new FileInfo(dirname($destination), null, null, ['type' => FileInfo::TYPE_FOLDER], null);
475
+
476
+        $sourceNode = new Directory($view, $sourceInfo);
477
+        $targetNode = $this->getMockBuilder(Directory::class)
478
+            ->onlyMethods(['childExists'])
479
+            ->setConstructorArgs([$view, $targetInfo])
480
+            ->getMock();
481
+        $targetNode->expects($this->once())->method('childExists')
482
+            ->with(basename($destination))
483
+            ->willReturn(true);
484
+
485
+        $targetNode->moveInto(basename($destination), $source, $sourceNode);
486
+    }
487 487
 }
Please login to merge, or discard this patch.
apps/user_ldap/lib/User/User.php 1 patch
Indentation   +786 added lines, -786 removed lines patch added patch discarded remove patch
@@ -33,790 +33,790 @@
 block discarded – undo
33 33
  * represents an LDAP user, gets and holds user-specific information from LDAP
34 34
  */
35 35
 class User {
36
-	protected Connection $connection;
37
-	/**
38
-	 * @var array<string,1>
39
-	 */
40
-	protected array $refreshedFeatures = [];
41
-	protected string|false|null $avatarImage = null;
42
-
43
-	protected BirthdateParserService $birthdateParser;
44
-
45
-	/**
46
-	 * DB config keys for user preferences
47
-	 * @var string
48
-	 */
49
-	public const USER_PREFKEY_FIRSTLOGIN = 'firstLoginAccomplished';
50
-
51
-	/**
52
-	 * @brief constructor, make sure the subclasses call this one!
53
-	 */
54
-	public function __construct(
55
-		protected string $uid,
56
-		protected string $dn,
57
-		protected Access $access,
58
-		protected IConfig $config,
59
-		protected Image $image,
60
-		protected LoggerInterface $logger,
61
-		protected IAvatarManager $avatarManager,
62
-		protected IUserManager $userManager,
63
-		protected INotificationManager $notificationManager,
64
-	) {
65
-		if ($uid === '') {
66
-			$logger->error("uid for '$dn' must not be an empty string", ['app' => 'user_ldap']);
67
-			throw new \InvalidArgumentException('uid must not be an empty string!');
68
-		}
69
-		$this->connection = $this->access->getConnection();
70
-		$this->birthdateParser = new BirthdateParserService();
71
-
72
-		Util::connectHook('OC_User', 'post_login', $this, 'handlePasswordExpiry');
73
-	}
74
-
75
-	/**
76
-	 * marks a user as deleted
77
-	 *
78
-	 * @throws PreConditionNotMetException
79
-	 */
80
-	public function markUser(): void {
81
-		$curValue = $this->config->getUserValue($this->getUsername(), 'user_ldap', 'isDeleted', '0');
82
-		if ($curValue === '1') {
83
-			// the user is already marked, do not write to DB again
84
-			return;
85
-		}
86
-		$this->config->setUserValue($this->getUsername(), 'user_ldap', 'isDeleted', '1');
87
-		$this->config->setUserValue($this->getUsername(), 'user_ldap', 'foundDeleted', (string)time());
88
-	}
89
-
90
-	/**
91
-	 * processes results from LDAP for attributes as returned by getAttributesToRead()
92
-	 * @param array $ldapEntry the user entry as retrieved from LDAP
93
-	 */
94
-	public function processAttributes(array $ldapEntry): void {
95
-		//Quota
96
-		$attr = strtolower($this->connection->ldapQuotaAttribute);
97
-		if (isset($ldapEntry[$attr])) {
98
-			$this->updateQuota($ldapEntry[$attr][0]);
99
-		} else {
100
-			if ($this->connection->ldapQuotaDefault !== '') {
101
-				$this->updateQuota();
102
-			}
103
-		}
104
-		unset($attr);
105
-
106
-		//displayName
107
-		$displayName = $displayName2 = '';
108
-		$attr = strtolower($this->connection->ldapUserDisplayName);
109
-		if (isset($ldapEntry[$attr])) {
110
-			$displayName = (string)$ldapEntry[$attr][0];
111
-		}
112
-		$attr = strtolower($this->connection->ldapUserDisplayName2);
113
-		if (isset($ldapEntry[$attr])) {
114
-			$displayName2 = (string)$ldapEntry[$attr][0];
115
-		}
116
-		if ($displayName !== '') {
117
-			$this->composeAndStoreDisplayName($displayName, $displayName2);
118
-			$this->access->cacheUserDisplayName(
119
-				$this->getUsername(),
120
-				$displayName,
121
-				$displayName2
122
-			);
123
-		}
124
-		unset($attr);
125
-
126
-		//Email
127
-		//email must be stored after displayname, because it would cause a user
128
-		//change event that will trigger fetching the display name again
129
-		$attr = strtolower($this->connection->ldapEmailAttribute);
130
-		if (isset($ldapEntry[$attr])) {
131
-			$mailValue = 0;
132
-			for ($x = 0; $x < count($ldapEntry[$attr]); $x++) {
133
-				if (filter_var($ldapEntry[$attr][$x], FILTER_VALIDATE_EMAIL)) {
134
-					$mailValue = $x;
135
-					break;
136
-				}
137
-			}
138
-			$this->updateEmail($ldapEntry[$attr][$mailValue]);
139
-		}
140
-		unset($attr);
141
-
142
-		// LDAP Username, needed for s2s sharing
143
-		if (isset($ldapEntry['uid'])) {
144
-			$this->storeLDAPUserName($ldapEntry['uid'][0]);
145
-		} elseif (isset($ldapEntry['samaccountname'])) {
146
-			$this->storeLDAPUserName($ldapEntry['samaccountname'][0]);
147
-		}
148
-
149
-		//homePath
150
-		if (str_starts_with($this->connection->homeFolderNamingRule, 'attr:')) {
151
-			$attr = strtolower(substr($this->connection->homeFolderNamingRule, strlen('attr:')));
152
-			if (isset($ldapEntry[$attr])) {
153
-				$this->access->cacheUserHome(
154
-					$this->getUsername(), $this->getHomePath($ldapEntry[$attr][0]));
155
-			}
156
-		}
157
-
158
-		//memberOf groups
159
-		$cacheKey = 'getMemberOf' . $this->getUsername();
160
-		$groups = false;
161
-		if (isset($ldapEntry['memberof'])) {
162
-			$groups = $ldapEntry['memberof'];
163
-		}
164
-		$this->connection->writeToCache($cacheKey, $groups);
165
-
166
-		//external storage var
167
-		$attr = strtolower($this->connection->ldapExtStorageHomeAttribute);
168
-		if (isset($ldapEntry[$attr])) {
169
-			$this->updateExtStorageHome($ldapEntry[$attr][0]);
170
-		}
171
-		unset($attr);
172
-
173
-		// check for cached profile data
174
-		$username = $this->getUsername(); // buffer variable, to save resource
175
-		$cacheKey = 'getUserProfile-' . $username;
176
-		$profileCached = $this->connection->getFromCache($cacheKey);
177
-		// honoring profile disabled in config.php and check if user profile was refreshed
178
-		if ($this->config->getSystemValueBool('profile.enabled', true) &&
179
-			($profileCached === null) && // no cache or TTL not expired
180
-			!$this->wasRefreshed('profile')) {
181
-			// check current data
182
-			$profileValues = [];
183
-			//User Profile Field - Phone number
184
-			$attr = strtolower($this->connection->ldapAttributePhone);
185
-			if (!empty($attr)) { // attribute configured
186
-				$profileValues[IAccountManager::PROPERTY_PHONE]
187
-					= $ldapEntry[$attr][0] ?? '';
188
-			}
189
-			//User Profile Field - website
190
-			$attr = strtolower($this->connection->ldapAttributeWebsite);
191
-			if (isset($ldapEntry[$attr])) {
192
-				$cutPosition = strpos($ldapEntry[$attr][0], ' ');
193
-				if ($cutPosition) {
194
-					// drop appended label
195
-					$profileValues[IAccountManager::PROPERTY_WEBSITE]
196
-						= substr($ldapEntry[$attr][0], 0, $cutPosition);
197
-				} else {
198
-					$profileValues[IAccountManager::PROPERTY_WEBSITE]
199
-						= $ldapEntry[$attr][0];
200
-				}
201
-			} elseif (!empty($attr)) {	// configured, but not defined
202
-				$profileValues[IAccountManager::PROPERTY_WEBSITE] = '';
203
-			}
204
-			//User Profile Field - Address
205
-			$attr = strtolower($this->connection->ldapAttributeAddress);
206
-			if (isset($ldapEntry[$attr])) {
207
-				if (str_contains($ldapEntry[$attr][0], '$')) {
208
-					// basic format conversion from postalAddress syntax to commata delimited
209
-					$profileValues[IAccountManager::PROPERTY_ADDRESS]
210
-						= str_replace('$', ', ', $ldapEntry[$attr][0]);
211
-				} else {
212
-					$profileValues[IAccountManager::PROPERTY_ADDRESS]
213
-						= $ldapEntry[$attr][0];
214
-				}
215
-			} elseif (!empty($attr)) {	// configured, but not defined
216
-				$profileValues[IAccountManager::PROPERTY_ADDRESS] = '';
217
-			}
218
-			//User Profile Field - Twitter
219
-			$attr = strtolower($this->connection->ldapAttributeTwitter);
220
-			if (!empty($attr)) {
221
-				$profileValues[IAccountManager::PROPERTY_TWITTER]
222
-					= $ldapEntry[$attr][0] ?? '';
223
-			}
224
-			//User Profile Field - fediverse
225
-			$attr = strtolower($this->connection->ldapAttributeFediverse);
226
-			if (!empty($attr)) {
227
-				$profileValues[IAccountManager::PROPERTY_FEDIVERSE]
228
-					= $ldapEntry[$attr][0] ?? '';
229
-			}
230
-			//User Profile Field - organisation
231
-			$attr = strtolower($this->connection->ldapAttributeOrganisation);
232
-			if (!empty($attr)) {
233
-				$profileValues[IAccountManager::PROPERTY_ORGANISATION]
234
-					= $ldapEntry[$attr][0] ?? '';
235
-			}
236
-			//User Profile Field - role
237
-			$attr = strtolower($this->connection->ldapAttributeRole);
238
-			if (!empty($attr)) {
239
-				$profileValues[IAccountManager::PROPERTY_ROLE]
240
-					= $ldapEntry[$attr][0] ?? '';
241
-			}
242
-			//User Profile Field - headline
243
-			$attr = strtolower($this->connection->ldapAttributeHeadline);
244
-			if (!empty($attr)) {
245
-				$profileValues[IAccountManager::PROPERTY_HEADLINE]
246
-					= $ldapEntry[$attr][0] ?? '';
247
-			}
248
-			//User Profile Field - biography
249
-			$attr = strtolower($this->connection->ldapAttributeBiography);
250
-			if (isset($ldapEntry[$attr])) {
251
-				if (str_contains($ldapEntry[$attr][0], '\r')) {
252
-					// convert line endings
253
-					$profileValues[IAccountManager::PROPERTY_BIOGRAPHY]
254
-						= str_replace(["\r\n","\r"], "\n", $ldapEntry[$attr][0]);
255
-				} else {
256
-					$profileValues[IAccountManager::PROPERTY_BIOGRAPHY]
257
-						= $ldapEntry[$attr][0];
258
-				}
259
-			} elseif (!empty($attr)) {	// configured, but not defined
260
-				$profileValues[IAccountManager::PROPERTY_BIOGRAPHY] = '';
261
-			}
262
-			//User Profile Field - birthday
263
-			$attr = strtolower($this->connection->ldapAttributeBirthDate);
264
-			if (!empty($attr) && !empty($ldapEntry[$attr][0])) {
265
-				$value = $ldapEntry[$attr][0];
266
-				try {
267
-					$birthdate = $this->birthdateParser->parseBirthdate($value);
268
-					$profileValues[IAccountManager::PROPERTY_BIRTHDATE]
269
-						= $birthdate->format('Y-m-d');
270
-				} catch (InvalidArgumentException $e) {
271
-					// Invalid date -> just skip the property
272
-					$this->logger->info("Failed to parse user's birthdate from LDAP: $value", [
273
-						'exception' => $e,
274
-						'userId' => $username,
275
-					]);
276
-				}
277
-			}
278
-			//User Profile Field - pronouns
279
-			$attr = strtolower($this->connection->ldapAttributePronouns);
280
-			if (!empty($attr)) {
281
-				$profileValues[IAccountManager::PROPERTY_PRONOUNS]
282
-					= $ldapEntry[$attr][0] ?? '';
283
-			}
284
-			// check for changed data and cache just for TTL checking
285
-			$checksum = hash('sha256', json_encode($profileValues));
286
-			$this->connection->writeToCache($cacheKey, $checksum // write array to cache. is waste of cache space
287
-				, null); // use ldapCacheTTL from configuration
288
-			// Update user profile
289
-			if ($this->config->getUserValue($username, 'user_ldap', 'lastProfileChecksum', null) !== $checksum) {
290
-				$this->config->setUserValue($username, 'user_ldap', 'lastProfileChecksum', $checksum);
291
-				$this->updateProfile($profileValues);
292
-				$this->logger->info("updated profile uid=$username", ['app' => 'user_ldap']);
293
-			} else {
294
-				$this->logger->debug('profile data from LDAP unchanged', ['app' => 'user_ldap', 'uid' => $username]);
295
-			}
296
-			unset($attr);
297
-		} elseif ($profileCached !== null) { // message delayed, to declutter log
298
-			$this->logger->debug('skipping profile check, while cached data exist', ['app' => 'user_ldap', 'uid' => $username]);
299
-		}
300
-
301
-		//Avatar
302
-		/** @var Connection $connection */
303
-		$connection = $this->access->getConnection();
304
-		$attributes = $connection->resolveRule('avatar');
305
-		foreach ($attributes as $attribute) {
306
-			if (isset($ldapEntry[$attribute])) {
307
-				$this->avatarImage = $ldapEntry[$attribute][0];
308
-				$this->updateAvatar();
309
-				break;
310
-			}
311
-		}
312
-	}
313
-
314
-	/**
315
-	 * @brief returns the LDAP DN of the user
316
-	 * @return string
317
-	 */
318
-	public function getDN() {
319
-		return $this->dn;
320
-	}
321
-
322
-	/**
323
-	 * @brief returns the Nextcloud internal username of the user
324
-	 * @return string
325
-	 */
326
-	public function getUsername() {
327
-		return $this->uid;
328
-	}
329
-
330
-	/**
331
-	 * returns the home directory of the user if specified by LDAP settings
332
-	 * @throws \Exception
333
-	 */
334
-	public function getHomePath(?string $valueFromLDAP = null): string|false {
335
-		$path = (string)$valueFromLDAP;
336
-		$attr = null;
337
-
338
-		if (is_null($valueFromLDAP)
339
-		   && str_starts_with($this->access->connection->homeFolderNamingRule, 'attr:')
340
-		   && $this->access->connection->homeFolderNamingRule !== 'attr:') {
341
-			$attr = substr($this->access->connection->homeFolderNamingRule, strlen('attr:'));
342
-			$dn = $this->access->username2dn($this->getUsername());
343
-			if ($dn === false) {
344
-				return false;
345
-			}
346
-			$homedir = $this->access->readAttribute($dn, $attr);
347
-			if ($homedir !== false && isset($homedir[0])) {
348
-				$path = $homedir[0];
349
-			}
350
-		}
351
-
352
-		if ($path !== '') {
353
-			//if attribute's value is an absolute path take this, otherwise append it to data dir
354
-			//check for / at the beginning or pattern c:\ resp. c:/
355
-			if ($path[0] !== '/'
356
-			   && !(strlen($path) > 3 && ctype_alpha($path[0])
357
-				   && $path[1] === ':' && ($path[2] === '\\' || $path[2] === '/'))
358
-			) {
359
-				$path = $this->config->getSystemValue('datadirectory',
360
-					\OC::$SERVERROOT . '/data') . '/' . $path;
361
-			}
362
-			//we need it to store it in the DB as well in case a user gets
363
-			//deleted so we can clean up afterwards
364
-			$this->config->setUserValue(
365
-				$this->getUsername(), 'user_ldap', 'homePath', $path
366
-			);
367
-			return $path;
368
-		}
369
-
370
-		if (!is_null($attr)
371
-			&& $this->config->getAppValue('user_ldap', 'enforce_home_folder_naming_rule', 'true')
372
-		) {
373
-			// a naming rule attribute is defined, but it doesn't exist for that LDAP user
374
-			throw new \Exception('Home dir attribute can\'t be read from LDAP for uid: ' . $this->getUsername());
375
-		}
376
-
377
-		//false will apply default behaviour as defined and done by OC_User
378
-		$this->config->setUserValue($this->getUsername(), 'user_ldap', 'homePath', '');
379
-		return false;
380
-	}
381
-
382
-	public function getMemberOfGroups(): array|false {
383
-		$cacheKey = 'getMemberOf' . $this->getUsername();
384
-		$memberOfGroups = $this->connection->getFromCache($cacheKey);
385
-		if (!is_null($memberOfGroups)) {
386
-			return $memberOfGroups;
387
-		}
388
-		$groupDNs = $this->access->readAttribute($this->getDN(), 'memberOf');
389
-		$this->connection->writeToCache($cacheKey, $groupDNs);
390
-		return $groupDNs;
391
-	}
392
-
393
-	/**
394
-	 * @brief reads the image from LDAP that shall be used as Avatar
395
-	 * @return string|false data (provided by LDAP)
396
-	 */
397
-	public function getAvatarImage(): string|false {
398
-		if (!is_null($this->avatarImage)) {
399
-			return $this->avatarImage;
400
-		}
401
-
402
-		$this->avatarImage = false;
403
-		/** @var Connection $connection */
404
-		$connection = $this->access->getConnection();
405
-		$attributes = $connection->resolveRule('avatar');
406
-		foreach ($attributes as $attribute) {
407
-			$result = $this->access->readAttribute($this->dn, $attribute);
408
-			if ($result !== false && isset($result[0])) {
409
-				$this->avatarImage = $result[0];
410
-				break;
411
-			}
412
-		}
413
-
414
-		return $this->avatarImage;
415
-	}
416
-
417
-	/**
418
-	 * @brief marks the user as having logged in at least once
419
-	 */
420
-	public function markLogin(): void {
421
-		$this->config->setUserValue(
422
-			$this->uid, 'user_ldap', self::USER_PREFKEY_FIRSTLOGIN, '1');
423
-	}
424
-
425
-	/**
426
-	 * Stores a key-value pair in relation to this user
427
-	 */
428
-	private function store(string $key, string $value): void {
429
-		$this->config->setUserValue($this->uid, 'user_ldap', $key, $value);
430
-	}
431
-
432
-	/**
433
-	 * Composes the display name and stores it in the database. The final
434
-	 * display name is returned.
435
-	 *
436
-	 * @return string the effective display name
437
-	 */
438
-	public function composeAndStoreDisplayName(string $displayName, string $displayName2 = ''): string {
439
-		if ($displayName2 !== '') {
440
-			$displayName .= ' (' . $displayName2 . ')';
441
-		}
442
-		$oldName = $this->config->getUserValue($this->uid, 'user_ldap', 'displayName', null);
443
-		if ($oldName !== $displayName) {
444
-			$this->store('displayName', $displayName);
445
-			$user = $this->userManager->get($this->getUsername());
446
-			if (!empty($oldName) && $user instanceof \OC\User\User) {
447
-				// if it was empty, it would be a new record, not a change emitting the trigger could
448
-				// potentially cause a UniqueConstraintViolationException, depending on some factors.
449
-				$user->triggerChange('displayName', $displayName, $oldName);
450
-			}
451
-		}
452
-		return $displayName;
453
-	}
454
-
455
-	/**
456
-	 * Stores the LDAP Username in the Database
457
-	 */
458
-	public function storeLDAPUserName(string $userName): void {
459
-		$this->store('uid', $userName);
460
-	}
461
-
462
-	/**
463
-	 * @brief checks whether an update method specified by feature was run
464
-	 * already. If not, it will marked like this, because it is expected that
465
-	 * the method will be run, when false is returned.
466
-	 * @param string $feature email | quota | avatar | profile (can be extended)
467
-	 */
468
-	private function wasRefreshed(string $feature): bool {
469
-		if (isset($this->refreshedFeatures[$feature])) {
470
-			return true;
471
-		}
472
-		$this->refreshedFeatures[$feature] = 1;
473
-		return false;
474
-	}
475
-
476
-	/**
477
-	 * fetches the email from LDAP and stores it as Nextcloud user value
478
-	 * @param ?string $valueFromLDAP if known, to save an LDAP read request
479
-	 */
480
-	public function updateEmail(?string $valueFromLDAP = null): void {
481
-		if ($this->wasRefreshed('email')) {
482
-			return;
483
-		}
484
-		$email = (string)$valueFromLDAP;
485
-		if (is_null($valueFromLDAP)) {
486
-			$emailAttribute = $this->connection->ldapEmailAttribute;
487
-			if ($emailAttribute !== '') {
488
-				$aEmail = $this->access->readAttribute($this->dn, $emailAttribute);
489
-				if (is_array($aEmail) && (count($aEmail) > 0)) {
490
-					$email = (string)$aEmail[0];
491
-				}
492
-			}
493
-		}
494
-		if ($email !== '') {
495
-			$user = $this->userManager->get($this->uid);
496
-			if (!is_null($user)) {
497
-				$currentEmail = (string)$user->getSystemEMailAddress();
498
-				if ($currentEmail !== $email) {
499
-					$user->setSystemEMailAddress($email);
500
-				}
501
-			}
502
-		}
503
-	}
504
-
505
-	/**
506
-	 * Overall process goes as follow:
507
-	 * 1. fetch the quota from LDAP and check if it's parseable with the "verifyQuotaValue" function
508
-	 * 2. if the value can't be fetched, is empty or not parseable, use the default LDAP quota
509
-	 * 3. if the default LDAP quota can't be parsed, use the Nextcloud's default quota (use 'default')
510
-	 * 4. check if the target user exists and set the quota for the user.
511
-	 *
512
-	 * In order to improve performance and prevent an unwanted extra LDAP call, the $valueFromLDAP
513
-	 * parameter can be passed with the value of the attribute. This value will be considered as the
514
-	 * quota for the user coming from the LDAP server (step 1 of the process) It can be useful to
515
-	 * fetch all the user's attributes in one call and use the fetched values in this function.
516
-	 * The expected value for that parameter is a string describing the quota for the user. Valid
517
-	 * values are 'none' (unlimited), 'default' (the Nextcloud's default quota), '1234' (quota in
518
-	 * bytes), '1234 MB' (quota in MB - check the \OCP\Util::computerFileSize method for more info)
519
-	 *
520
-	 * fetches the quota from LDAP and stores it as Nextcloud user value
521
-	 * @param ?string $valueFromLDAP the quota attribute's value can be passed,
522
-	 *                               to save the readAttribute request
523
-	 */
524
-	public function updateQuota(?string $valueFromLDAP = null): void {
525
-		if ($this->wasRefreshed('quota')) {
526
-			return;
527
-		}
528
-
529
-		$quotaAttribute = $this->connection->ldapQuotaAttribute;
530
-		$defaultQuota = $this->connection->ldapQuotaDefault;
531
-		if ($quotaAttribute === '' && $defaultQuota === '') {
532
-			return;
533
-		}
534
-
535
-		$quota = false;
536
-		if (is_null($valueFromLDAP) && $quotaAttribute !== '') {
537
-			$aQuota = $this->access->readAttribute($this->dn, $quotaAttribute);
538
-			if ($aQuota !== false && isset($aQuota[0]) && $this->verifyQuotaValue($aQuota[0])) {
539
-				$quota = $aQuota[0];
540
-			} elseif (is_array($aQuota) && isset($aQuota[0])) {
541
-				$this->logger->debug('no suitable LDAP quota found for user ' . $this->uid . ': [' . $aQuota[0] . ']', ['app' => 'user_ldap']);
542
-			}
543
-		} elseif (!is_null($valueFromLDAP) && $this->verifyQuotaValue($valueFromLDAP)) {
544
-			$quota = $valueFromLDAP;
545
-		} else {
546
-			$this->logger->debug('no suitable LDAP quota found for user ' . $this->uid . ': [' . ($valueFromLDAP ?? '') . ']', ['app' => 'user_ldap']);
547
-		}
548
-
549
-		if ($quota === false && $this->verifyQuotaValue($defaultQuota)) {
550
-			// quota not found using the LDAP attribute (or not parseable). Try the default quota
551
-			$quota = $defaultQuota;
552
-		} elseif ($quota === false) {
553
-			$this->logger->debug('no suitable default quota found for user ' . $this->uid . ': [' . $defaultQuota . ']', ['app' => 'user_ldap']);
554
-			return;
555
-		}
556
-
557
-		$targetUser = $this->userManager->get($this->uid);
558
-		if ($targetUser instanceof IUser) {
559
-			$targetUser->setQuota($quota);
560
-		} else {
561
-			$this->logger->info('trying to set a quota for user ' . $this->uid . ' but the user is missing', ['app' => 'user_ldap']);
562
-		}
563
-	}
564
-
565
-	private function verifyQuotaValue(string $quotaValue): bool {
566
-		return $quotaValue === 'none' || $quotaValue === 'default' || Util::computerFileSize($quotaValue) !== false;
567
-	}
568
-
569
-	/**
570
-	 * takes values from LDAP and stores it as Nextcloud user profile value
571
-	 *
572
-	 * @param array $profileValues associative array of property keys and values from LDAP
573
-	 */
574
-	private function updateProfile(array $profileValues): void {
575
-		// check if given array is empty
576
-		if (empty($profileValues)) {
577
-			return; // okay, nothing to do
578
-		}
579
-		// fetch/prepare user
580
-		$user = $this->userManager->get($this->uid);
581
-		if (is_null($user)) {
582
-			$this->logger->error('could not get user for uid=' . $this->uid . '', ['app' => 'user_ldap']);
583
-			return;
584
-		}
585
-		// prepare AccountManager and Account
586
-		$accountManager = Server::get(IAccountManager::class);
587
-		$account = $accountManager->getAccount($user);	// get Account
588
-		$defaultScopes = array_merge(AccountManager::DEFAULT_SCOPES,
589
-			$this->config->getSystemValue('account_manager.default_property_scope', []));
590
-		// loop through the properties and handle them
591
-		foreach ($profileValues as $property => $valueFromLDAP) {
592
-			// check and update profile properties
593
-			$value = (is_array($valueFromLDAP) ? $valueFromLDAP[0] : $valueFromLDAP); // take ONLY the first value, if multiple values specified
594
-			try {
595
-				$accountProperty = $account->getProperty($property);
596
-				$currentValue = $accountProperty->getValue();
597
-				$scope = ($accountProperty->getScope() ?: $defaultScopes[$property]);
598
-			} catch (PropertyDoesNotExistException $e) { // thrown at getProperty
599
-				$this->logger->error('property does not exist: ' . $property
600
-					. ' for uid=' . $this->uid . '', ['app' => 'user_ldap', 'exception' => $e]);
601
-				$currentValue = '';
602
-				$scope = $defaultScopes[$property];
603
-			}
604
-			$verified = IAccountManager::VERIFIED; // trust the LDAP admin knew what they put there
605
-			if ($currentValue !== $value) {
606
-				$account->setProperty($property, $value, $scope, $verified);
607
-				$this->logger->debug('update user profile: ' . $property . '=' . $value
608
-					. ' for uid=' . $this->uid . '', ['app' => 'user_ldap']);
609
-			}
610
-		}
611
-		try {
612
-			$accountManager->updateAccount($account); // may throw InvalidArgumentException
613
-		} catch (\InvalidArgumentException $e) {
614
-			$this->logger->error('invalid data from LDAP: for uid=' . $this->uid . '', ['app' => 'user_ldap', 'func' => 'updateProfile'
615
-				, 'exception' => $e]);
616
-		}
617
-	}
618
-
619
-	/**
620
-	 * @brief attempts to get an image from LDAP and sets it as Nextcloud avatar
621
-	 * @return bool true when the avatar was set successfully or is up to date
622
-	 */
623
-	public function updateAvatar(bool $force = false): bool {
624
-		if (!$force && $this->wasRefreshed('avatar')) {
625
-			return false;
626
-		}
627
-		$avatarImage = $this->getAvatarImage();
628
-		if ($avatarImage === false) {
629
-			//not set, nothing left to do;
630
-			return false;
631
-		}
632
-
633
-		if (!$this->image->loadFromBase64(base64_encode($avatarImage))) {
634
-			return false;
635
-		}
636
-
637
-		// use the checksum before modifications
638
-		$checksum = md5($this->image->data());
639
-
640
-		if ($checksum === $this->config->getUserValue($this->uid, 'user_ldap', 'lastAvatarChecksum', '') && $this->avatarExists()) {
641
-			return true;
642
-		}
643
-
644
-		$isSet = $this->setNextcloudAvatar();
645
-
646
-		if ($isSet) {
647
-			// save checksum only after successful setting
648
-			$this->config->setUserValue($this->uid, 'user_ldap', 'lastAvatarChecksum', $checksum);
649
-		}
650
-
651
-		return $isSet;
652
-	}
653
-
654
-	private function avatarExists(): bool {
655
-		try {
656
-			$currentAvatar = $this->avatarManager->getAvatar($this->uid);
657
-			return $currentAvatar->exists() && $currentAvatar->isCustomAvatar();
658
-		} catch (\Exception $e) {
659
-			return false;
660
-		}
661
-	}
662
-
663
-	/**
664
-	 * @brief sets an image as Nextcloud avatar
665
-	 */
666
-	private function setNextcloudAvatar(): bool {
667
-		if (!$this->image->valid()) {
668
-			$this->logger->error('avatar image data from LDAP invalid for ' . $this->dn, ['app' => 'user_ldap']);
669
-			return false;
670
-		}
671
-
672
-
673
-		//make sure it is a square and not bigger than 512x512
674
-		$size = min([$this->image->width(), $this->image->height(), 512]);
675
-		if (!$this->image->centerCrop($size)) {
676
-			$this->logger->error('croping image for avatar failed for ' . $this->dn, ['app' => 'user_ldap']);
677
-			return false;
678
-		}
679
-
680
-		try {
681
-			$avatar = $this->avatarManager->getAvatar($this->uid);
682
-			$avatar->set($this->image);
683
-			return true;
684
-		} catch (\Exception $e) {
685
-			$this->logger->info('Could not set avatar for ' . $this->dn, ['exception' => $e]);
686
-		}
687
-		return false;
688
-	}
689
-
690
-	/**
691
-	 * @throws AttributeNotSet
692
-	 * @throws \OC\ServerNotAvailableException
693
-	 * @throws PreConditionNotMetException
694
-	 */
695
-	public function getExtStorageHome():string {
696
-		$value = $this->config->getUserValue($this->getUsername(), 'user_ldap', 'extStorageHome', '');
697
-		if ($value !== '') {
698
-			return $value;
699
-		}
700
-
701
-		$value = $this->updateExtStorageHome();
702
-		if ($value !== '') {
703
-			return $value;
704
-		}
705
-
706
-		throw new AttributeNotSet(sprintf(
707
-			'external home storage attribute yield no value for %s', $this->getUsername()
708
-		));
709
-	}
710
-
711
-	/**
712
-	 * @throws PreConditionNotMetException
713
-	 * @throws \OC\ServerNotAvailableException
714
-	 */
715
-	public function updateExtStorageHome(?string $valueFromLDAP = null):string {
716
-		if ($valueFromLDAP === null) {
717
-			$extHomeValues = $this->access->readAttribute($this->getDN(), $this->connection->ldapExtStorageHomeAttribute);
718
-		} else {
719
-			$extHomeValues = [$valueFromLDAP];
720
-		}
721
-		if ($extHomeValues !== false && isset($extHomeValues[0])) {
722
-			$extHome = $extHomeValues[0];
723
-			$this->config->setUserValue($this->getUsername(), 'user_ldap', 'extStorageHome', $extHome);
724
-			return $extHome;
725
-		} else {
726
-			$this->config->deleteUserValue($this->getUsername(), 'user_ldap', 'extStorageHome');
727
-			return '';
728
-		}
729
-	}
730
-
731
-	/**
732
-	 * called by a post_login hook to handle password expiry
733
-	 */
734
-	public function handlePasswordExpiry(array $params): void {
735
-		$ppolicyDN = $this->connection->ldapDefaultPPolicyDN;
736
-		if (empty($ppolicyDN) || ((int)$this->connection->turnOnPasswordChange !== 1)) {
737
-			//password expiry handling disabled
738
-			return;
739
-		}
740
-		$uid = $params['uid'];
741
-		if (isset($uid) && $uid === $this->getUsername()) {
742
-			//retrieve relevant user attributes
743
-			$result = $this->access->search('objectclass=*', $this->dn, ['pwdpolicysubentry', 'pwdgraceusetime', 'pwdreset', 'pwdchangedtime']);
744
-
745
-			if (array_key_exists('pwdpolicysubentry', $result[0])) {
746
-				$pwdPolicySubentry = $result[0]['pwdpolicysubentry'];
747
-				if ($pwdPolicySubentry && (count($pwdPolicySubentry) > 0)) {
748
-					$ppolicyDN = $pwdPolicySubentry[0];//custom ppolicy DN
749
-				}
750
-			}
751
-
752
-			$pwdGraceUseTime = array_key_exists('pwdgraceusetime', $result[0]) ? $result[0]['pwdgraceusetime'] : [];
753
-			$pwdReset = array_key_exists('pwdreset', $result[0]) ? $result[0]['pwdreset'] : [];
754
-			$pwdChangedTime = array_key_exists('pwdchangedtime', $result[0]) ? $result[0]['pwdchangedtime'] : [];
755
-
756
-			//retrieve relevant password policy attributes
757
-			$cacheKey = 'ppolicyAttributes' . $ppolicyDN;
758
-			$result = $this->connection->getFromCache($cacheKey);
759
-			if (is_null($result)) {
760
-				$result = $this->access->search('objectclass=*', $ppolicyDN, ['pwdgraceauthnlimit', 'pwdmaxage', 'pwdexpirewarning']);
761
-				$this->connection->writeToCache($cacheKey, $result);
762
-			}
763
-
764
-			$pwdGraceAuthNLimit = array_key_exists('pwdgraceauthnlimit', $result[0]) ? $result[0]['pwdgraceauthnlimit'] : [];
765
-			$pwdMaxAge = array_key_exists('pwdmaxage', $result[0]) ? $result[0]['pwdmaxage'] : [];
766
-			$pwdExpireWarning = array_key_exists('pwdexpirewarning', $result[0]) ? $result[0]['pwdexpirewarning'] : [];
767
-
768
-			//handle grace login
769
-			if (!empty($pwdGraceUseTime)) { //was this a grace login?
770
-				if (!empty($pwdGraceAuthNLimit)
771
-					&& count($pwdGraceUseTime) < (int)$pwdGraceAuthNLimit[0]) { //at least one more grace login available?
772
-					$this->config->setUserValue($uid, 'user_ldap', 'needsPasswordReset', 'true');
773
-					header('Location: ' . Server::get(IURLGenerator::class)->linkToRouteAbsolute(
774
-						'user_ldap.renewPassword.showRenewPasswordForm', ['user' => $uid]));
775
-				} else { //no more grace login available
776
-					header('Location: ' . Server::get(IURLGenerator::class)->linkToRouteAbsolute(
777
-						'user_ldap.renewPassword.showLoginFormInvalidPassword', ['user' => $uid]));
778
-				}
779
-				exit();
780
-			}
781
-			//handle pwdReset attribute
782
-			if (!empty($pwdReset) && $pwdReset[0] === 'TRUE') { //user must change their password
783
-				$this->config->setUserValue($uid, 'user_ldap', 'needsPasswordReset', 'true');
784
-				header('Location: ' . Server::get(IURLGenerator::class)->linkToRouteAbsolute(
785
-					'user_ldap.renewPassword.showRenewPasswordForm', ['user' => $uid]));
786
-				exit();
787
-			}
788
-			//handle password expiry warning
789
-			if (!empty($pwdChangedTime)) {
790
-				if (!empty($pwdMaxAge)
791
-					&& !empty($pwdExpireWarning)) {
792
-					$pwdMaxAgeInt = (int)$pwdMaxAge[0];
793
-					$pwdExpireWarningInt = (int)$pwdExpireWarning[0];
794
-					if ($pwdMaxAgeInt > 0 && $pwdExpireWarningInt > 0) {
795
-						$pwdChangedTimeDt = \DateTime::createFromFormat('YmdHisZ', $pwdChangedTime[0]);
796
-						$pwdChangedTimeDt->add(new \DateInterval('PT' . $pwdMaxAgeInt . 'S'));
797
-						$currentDateTime = new \DateTime();
798
-						$secondsToExpiry = $pwdChangedTimeDt->getTimestamp() - $currentDateTime->getTimestamp();
799
-						if ($secondsToExpiry <= $pwdExpireWarningInt) {
800
-							//remove last password expiry warning if any
801
-							$notification = $this->notificationManager->createNotification();
802
-							$notification->setApp('user_ldap')
803
-								->setUser($uid)
804
-								->setObject('pwd_exp_warn', $uid)
805
-							;
806
-							$this->notificationManager->markProcessed($notification);
807
-							//create new password expiry warning
808
-							$notification = $this->notificationManager->createNotification();
809
-							$notification->setApp('user_ldap')
810
-								->setUser($uid)
811
-								->setDateTime($currentDateTime)
812
-								->setObject('pwd_exp_warn', $uid)
813
-								->setSubject('pwd_exp_warn_days', [(int)ceil($secondsToExpiry / 60 / 60 / 24)])
814
-							;
815
-							$this->notificationManager->notify($notification);
816
-						}
817
-					}
818
-				}
819
-			}
820
-		}
821
-	}
36
+    protected Connection $connection;
37
+    /**
38
+     * @var array<string,1>
39
+     */
40
+    protected array $refreshedFeatures = [];
41
+    protected string|false|null $avatarImage = null;
42
+
43
+    protected BirthdateParserService $birthdateParser;
44
+
45
+    /**
46
+     * DB config keys for user preferences
47
+     * @var string
48
+     */
49
+    public const USER_PREFKEY_FIRSTLOGIN = 'firstLoginAccomplished';
50
+
51
+    /**
52
+     * @brief constructor, make sure the subclasses call this one!
53
+     */
54
+    public function __construct(
55
+        protected string $uid,
56
+        protected string $dn,
57
+        protected Access $access,
58
+        protected IConfig $config,
59
+        protected Image $image,
60
+        protected LoggerInterface $logger,
61
+        protected IAvatarManager $avatarManager,
62
+        protected IUserManager $userManager,
63
+        protected INotificationManager $notificationManager,
64
+    ) {
65
+        if ($uid === '') {
66
+            $logger->error("uid for '$dn' must not be an empty string", ['app' => 'user_ldap']);
67
+            throw new \InvalidArgumentException('uid must not be an empty string!');
68
+        }
69
+        $this->connection = $this->access->getConnection();
70
+        $this->birthdateParser = new BirthdateParserService();
71
+
72
+        Util::connectHook('OC_User', 'post_login', $this, 'handlePasswordExpiry');
73
+    }
74
+
75
+    /**
76
+     * marks a user as deleted
77
+     *
78
+     * @throws PreConditionNotMetException
79
+     */
80
+    public function markUser(): void {
81
+        $curValue = $this->config->getUserValue($this->getUsername(), 'user_ldap', 'isDeleted', '0');
82
+        if ($curValue === '1') {
83
+            // the user is already marked, do not write to DB again
84
+            return;
85
+        }
86
+        $this->config->setUserValue($this->getUsername(), 'user_ldap', 'isDeleted', '1');
87
+        $this->config->setUserValue($this->getUsername(), 'user_ldap', 'foundDeleted', (string)time());
88
+    }
89
+
90
+    /**
91
+     * processes results from LDAP for attributes as returned by getAttributesToRead()
92
+     * @param array $ldapEntry the user entry as retrieved from LDAP
93
+     */
94
+    public function processAttributes(array $ldapEntry): void {
95
+        //Quota
96
+        $attr = strtolower($this->connection->ldapQuotaAttribute);
97
+        if (isset($ldapEntry[$attr])) {
98
+            $this->updateQuota($ldapEntry[$attr][0]);
99
+        } else {
100
+            if ($this->connection->ldapQuotaDefault !== '') {
101
+                $this->updateQuota();
102
+            }
103
+        }
104
+        unset($attr);
105
+
106
+        //displayName
107
+        $displayName = $displayName2 = '';
108
+        $attr = strtolower($this->connection->ldapUserDisplayName);
109
+        if (isset($ldapEntry[$attr])) {
110
+            $displayName = (string)$ldapEntry[$attr][0];
111
+        }
112
+        $attr = strtolower($this->connection->ldapUserDisplayName2);
113
+        if (isset($ldapEntry[$attr])) {
114
+            $displayName2 = (string)$ldapEntry[$attr][0];
115
+        }
116
+        if ($displayName !== '') {
117
+            $this->composeAndStoreDisplayName($displayName, $displayName2);
118
+            $this->access->cacheUserDisplayName(
119
+                $this->getUsername(),
120
+                $displayName,
121
+                $displayName2
122
+            );
123
+        }
124
+        unset($attr);
125
+
126
+        //Email
127
+        //email must be stored after displayname, because it would cause a user
128
+        //change event that will trigger fetching the display name again
129
+        $attr = strtolower($this->connection->ldapEmailAttribute);
130
+        if (isset($ldapEntry[$attr])) {
131
+            $mailValue = 0;
132
+            for ($x = 0; $x < count($ldapEntry[$attr]); $x++) {
133
+                if (filter_var($ldapEntry[$attr][$x], FILTER_VALIDATE_EMAIL)) {
134
+                    $mailValue = $x;
135
+                    break;
136
+                }
137
+            }
138
+            $this->updateEmail($ldapEntry[$attr][$mailValue]);
139
+        }
140
+        unset($attr);
141
+
142
+        // LDAP Username, needed for s2s sharing
143
+        if (isset($ldapEntry['uid'])) {
144
+            $this->storeLDAPUserName($ldapEntry['uid'][0]);
145
+        } elseif (isset($ldapEntry['samaccountname'])) {
146
+            $this->storeLDAPUserName($ldapEntry['samaccountname'][0]);
147
+        }
148
+
149
+        //homePath
150
+        if (str_starts_with($this->connection->homeFolderNamingRule, 'attr:')) {
151
+            $attr = strtolower(substr($this->connection->homeFolderNamingRule, strlen('attr:')));
152
+            if (isset($ldapEntry[$attr])) {
153
+                $this->access->cacheUserHome(
154
+                    $this->getUsername(), $this->getHomePath($ldapEntry[$attr][0]));
155
+            }
156
+        }
157
+
158
+        //memberOf groups
159
+        $cacheKey = 'getMemberOf' . $this->getUsername();
160
+        $groups = false;
161
+        if (isset($ldapEntry['memberof'])) {
162
+            $groups = $ldapEntry['memberof'];
163
+        }
164
+        $this->connection->writeToCache($cacheKey, $groups);
165
+
166
+        //external storage var
167
+        $attr = strtolower($this->connection->ldapExtStorageHomeAttribute);
168
+        if (isset($ldapEntry[$attr])) {
169
+            $this->updateExtStorageHome($ldapEntry[$attr][0]);
170
+        }
171
+        unset($attr);
172
+
173
+        // check for cached profile data
174
+        $username = $this->getUsername(); // buffer variable, to save resource
175
+        $cacheKey = 'getUserProfile-' . $username;
176
+        $profileCached = $this->connection->getFromCache($cacheKey);
177
+        // honoring profile disabled in config.php and check if user profile was refreshed
178
+        if ($this->config->getSystemValueBool('profile.enabled', true) &&
179
+            ($profileCached === null) && // no cache or TTL not expired
180
+            !$this->wasRefreshed('profile')) {
181
+            // check current data
182
+            $profileValues = [];
183
+            //User Profile Field - Phone number
184
+            $attr = strtolower($this->connection->ldapAttributePhone);
185
+            if (!empty($attr)) { // attribute configured
186
+                $profileValues[IAccountManager::PROPERTY_PHONE]
187
+                    = $ldapEntry[$attr][0] ?? '';
188
+            }
189
+            //User Profile Field - website
190
+            $attr = strtolower($this->connection->ldapAttributeWebsite);
191
+            if (isset($ldapEntry[$attr])) {
192
+                $cutPosition = strpos($ldapEntry[$attr][0], ' ');
193
+                if ($cutPosition) {
194
+                    // drop appended label
195
+                    $profileValues[IAccountManager::PROPERTY_WEBSITE]
196
+                        = substr($ldapEntry[$attr][0], 0, $cutPosition);
197
+                } else {
198
+                    $profileValues[IAccountManager::PROPERTY_WEBSITE]
199
+                        = $ldapEntry[$attr][0];
200
+                }
201
+            } elseif (!empty($attr)) {	// configured, but not defined
202
+                $profileValues[IAccountManager::PROPERTY_WEBSITE] = '';
203
+            }
204
+            //User Profile Field - Address
205
+            $attr = strtolower($this->connection->ldapAttributeAddress);
206
+            if (isset($ldapEntry[$attr])) {
207
+                if (str_contains($ldapEntry[$attr][0], '$')) {
208
+                    // basic format conversion from postalAddress syntax to commata delimited
209
+                    $profileValues[IAccountManager::PROPERTY_ADDRESS]
210
+                        = str_replace('$', ', ', $ldapEntry[$attr][0]);
211
+                } else {
212
+                    $profileValues[IAccountManager::PROPERTY_ADDRESS]
213
+                        = $ldapEntry[$attr][0];
214
+                }
215
+            } elseif (!empty($attr)) {	// configured, but not defined
216
+                $profileValues[IAccountManager::PROPERTY_ADDRESS] = '';
217
+            }
218
+            //User Profile Field - Twitter
219
+            $attr = strtolower($this->connection->ldapAttributeTwitter);
220
+            if (!empty($attr)) {
221
+                $profileValues[IAccountManager::PROPERTY_TWITTER]
222
+                    = $ldapEntry[$attr][0] ?? '';
223
+            }
224
+            //User Profile Field - fediverse
225
+            $attr = strtolower($this->connection->ldapAttributeFediverse);
226
+            if (!empty($attr)) {
227
+                $profileValues[IAccountManager::PROPERTY_FEDIVERSE]
228
+                    = $ldapEntry[$attr][0] ?? '';
229
+            }
230
+            //User Profile Field - organisation
231
+            $attr = strtolower($this->connection->ldapAttributeOrganisation);
232
+            if (!empty($attr)) {
233
+                $profileValues[IAccountManager::PROPERTY_ORGANISATION]
234
+                    = $ldapEntry[$attr][0] ?? '';
235
+            }
236
+            //User Profile Field - role
237
+            $attr = strtolower($this->connection->ldapAttributeRole);
238
+            if (!empty($attr)) {
239
+                $profileValues[IAccountManager::PROPERTY_ROLE]
240
+                    = $ldapEntry[$attr][0] ?? '';
241
+            }
242
+            //User Profile Field - headline
243
+            $attr = strtolower($this->connection->ldapAttributeHeadline);
244
+            if (!empty($attr)) {
245
+                $profileValues[IAccountManager::PROPERTY_HEADLINE]
246
+                    = $ldapEntry[$attr][0] ?? '';
247
+            }
248
+            //User Profile Field - biography
249
+            $attr = strtolower($this->connection->ldapAttributeBiography);
250
+            if (isset($ldapEntry[$attr])) {
251
+                if (str_contains($ldapEntry[$attr][0], '\r')) {
252
+                    // convert line endings
253
+                    $profileValues[IAccountManager::PROPERTY_BIOGRAPHY]
254
+                        = str_replace(["\r\n","\r"], "\n", $ldapEntry[$attr][0]);
255
+                } else {
256
+                    $profileValues[IAccountManager::PROPERTY_BIOGRAPHY]
257
+                        = $ldapEntry[$attr][0];
258
+                }
259
+            } elseif (!empty($attr)) {	// configured, but not defined
260
+                $profileValues[IAccountManager::PROPERTY_BIOGRAPHY] = '';
261
+            }
262
+            //User Profile Field - birthday
263
+            $attr = strtolower($this->connection->ldapAttributeBirthDate);
264
+            if (!empty($attr) && !empty($ldapEntry[$attr][0])) {
265
+                $value = $ldapEntry[$attr][0];
266
+                try {
267
+                    $birthdate = $this->birthdateParser->parseBirthdate($value);
268
+                    $profileValues[IAccountManager::PROPERTY_BIRTHDATE]
269
+                        = $birthdate->format('Y-m-d');
270
+                } catch (InvalidArgumentException $e) {
271
+                    // Invalid date -> just skip the property
272
+                    $this->logger->info("Failed to parse user's birthdate from LDAP: $value", [
273
+                        'exception' => $e,
274
+                        'userId' => $username,
275
+                    ]);
276
+                }
277
+            }
278
+            //User Profile Field - pronouns
279
+            $attr = strtolower($this->connection->ldapAttributePronouns);
280
+            if (!empty($attr)) {
281
+                $profileValues[IAccountManager::PROPERTY_PRONOUNS]
282
+                    = $ldapEntry[$attr][0] ?? '';
283
+            }
284
+            // check for changed data and cache just for TTL checking
285
+            $checksum = hash('sha256', json_encode($profileValues));
286
+            $this->connection->writeToCache($cacheKey, $checksum // write array to cache. is waste of cache space
287
+                , null); // use ldapCacheTTL from configuration
288
+            // Update user profile
289
+            if ($this->config->getUserValue($username, 'user_ldap', 'lastProfileChecksum', null) !== $checksum) {
290
+                $this->config->setUserValue($username, 'user_ldap', 'lastProfileChecksum', $checksum);
291
+                $this->updateProfile($profileValues);
292
+                $this->logger->info("updated profile uid=$username", ['app' => 'user_ldap']);
293
+            } else {
294
+                $this->logger->debug('profile data from LDAP unchanged', ['app' => 'user_ldap', 'uid' => $username]);
295
+            }
296
+            unset($attr);
297
+        } elseif ($profileCached !== null) { // message delayed, to declutter log
298
+            $this->logger->debug('skipping profile check, while cached data exist', ['app' => 'user_ldap', 'uid' => $username]);
299
+        }
300
+
301
+        //Avatar
302
+        /** @var Connection $connection */
303
+        $connection = $this->access->getConnection();
304
+        $attributes = $connection->resolveRule('avatar');
305
+        foreach ($attributes as $attribute) {
306
+            if (isset($ldapEntry[$attribute])) {
307
+                $this->avatarImage = $ldapEntry[$attribute][0];
308
+                $this->updateAvatar();
309
+                break;
310
+            }
311
+        }
312
+    }
313
+
314
+    /**
315
+     * @brief returns the LDAP DN of the user
316
+     * @return string
317
+     */
318
+    public function getDN() {
319
+        return $this->dn;
320
+    }
321
+
322
+    /**
323
+     * @brief returns the Nextcloud internal username of the user
324
+     * @return string
325
+     */
326
+    public function getUsername() {
327
+        return $this->uid;
328
+    }
329
+
330
+    /**
331
+     * returns the home directory of the user if specified by LDAP settings
332
+     * @throws \Exception
333
+     */
334
+    public function getHomePath(?string $valueFromLDAP = null): string|false {
335
+        $path = (string)$valueFromLDAP;
336
+        $attr = null;
337
+
338
+        if (is_null($valueFromLDAP)
339
+           && str_starts_with($this->access->connection->homeFolderNamingRule, 'attr:')
340
+           && $this->access->connection->homeFolderNamingRule !== 'attr:') {
341
+            $attr = substr($this->access->connection->homeFolderNamingRule, strlen('attr:'));
342
+            $dn = $this->access->username2dn($this->getUsername());
343
+            if ($dn === false) {
344
+                return false;
345
+            }
346
+            $homedir = $this->access->readAttribute($dn, $attr);
347
+            if ($homedir !== false && isset($homedir[0])) {
348
+                $path = $homedir[0];
349
+            }
350
+        }
351
+
352
+        if ($path !== '') {
353
+            //if attribute's value is an absolute path take this, otherwise append it to data dir
354
+            //check for / at the beginning or pattern c:\ resp. c:/
355
+            if ($path[0] !== '/'
356
+               && !(strlen($path) > 3 && ctype_alpha($path[0])
357
+                   && $path[1] === ':' && ($path[2] === '\\' || $path[2] === '/'))
358
+            ) {
359
+                $path = $this->config->getSystemValue('datadirectory',
360
+                    \OC::$SERVERROOT . '/data') . '/' . $path;
361
+            }
362
+            //we need it to store it in the DB as well in case a user gets
363
+            //deleted so we can clean up afterwards
364
+            $this->config->setUserValue(
365
+                $this->getUsername(), 'user_ldap', 'homePath', $path
366
+            );
367
+            return $path;
368
+        }
369
+
370
+        if (!is_null($attr)
371
+            && $this->config->getAppValue('user_ldap', 'enforce_home_folder_naming_rule', 'true')
372
+        ) {
373
+            // a naming rule attribute is defined, but it doesn't exist for that LDAP user
374
+            throw new \Exception('Home dir attribute can\'t be read from LDAP for uid: ' . $this->getUsername());
375
+        }
376
+
377
+        //false will apply default behaviour as defined and done by OC_User
378
+        $this->config->setUserValue($this->getUsername(), 'user_ldap', 'homePath', '');
379
+        return false;
380
+    }
381
+
382
+    public function getMemberOfGroups(): array|false {
383
+        $cacheKey = 'getMemberOf' . $this->getUsername();
384
+        $memberOfGroups = $this->connection->getFromCache($cacheKey);
385
+        if (!is_null($memberOfGroups)) {
386
+            return $memberOfGroups;
387
+        }
388
+        $groupDNs = $this->access->readAttribute($this->getDN(), 'memberOf');
389
+        $this->connection->writeToCache($cacheKey, $groupDNs);
390
+        return $groupDNs;
391
+    }
392
+
393
+    /**
394
+     * @brief reads the image from LDAP that shall be used as Avatar
395
+     * @return string|false data (provided by LDAP)
396
+     */
397
+    public function getAvatarImage(): string|false {
398
+        if (!is_null($this->avatarImage)) {
399
+            return $this->avatarImage;
400
+        }
401
+
402
+        $this->avatarImage = false;
403
+        /** @var Connection $connection */
404
+        $connection = $this->access->getConnection();
405
+        $attributes = $connection->resolveRule('avatar');
406
+        foreach ($attributes as $attribute) {
407
+            $result = $this->access->readAttribute($this->dn, $attribute);
408
+            if ($result !== false && isset($result[0])) {
409
+                $this->avatarImage = $result[0];
410
+                break;
411
+            }
412
+        }
413
+
414
+        return $this->avatarImage;
415
+    }
416
+
417
+    /**
418
+     * @brief marks the user as having logged in at least once
419
+     */
420
+    public function markLogin(): void {
421
+        $this->config->setUserValue(
422
+            $this->uid, 'user_ldap', self::USER_PREFKEY_FIRSTLOGIN, '1');
423
+    }
424
+
425
+    /**
426
+     * Stores a key-value pair in relation to this user
427
+     */
428
+    private function store(string $key, string $value): void {
429
+        $this->config->setUserValue($this->uid, 'user_ldap', $key, $value);
430
+    }
431
+
432
+    /**
433
+     * Composes the display name and stores it in the database. The final
434
+     * display name is returned.
435
+     *
436
+     * @return string the effective display name
437
+     */
438
+    public function composeAndStoreDisplayName(string $displayName, string $displayName2 = ''): string {
439
+        if ($displayName2 !== '') {
440
+            $displayName .= ' (' . $displayName2 . ')';
441
+        }
442
+        $oldName = $this->config->getUserValue($this->uid, 'user_ldap', 'displayName', null);
443
+        if ($oldName !== $displayName) {
444
+            $this->store('displayName', $displayName);
445
+            $user = $this->userManager->get($this->getUsername());
446
+            if (!empty($oldName) && $user instanceof \OC\User\User) {
447
+                // if it was empty, it would be a new record, not a change emitting the trigger could
448
+                // potentially cause a UniqueConstraintViolationException, depending on some factors.
449
+                $user->triggerChange('displayName', $displayName, $oldName);
450
+            }
451
+        }
452
+        return $displayName;
453
+    }
454
+
455
+    /**
456
+     * Stores the LDAP Username in the Database
457
+     */
458
+    public function storeLDAPUserName(string $userName): void {
459
+        $this->store('uid', $userName);
460
+    }
461
+
462
+    /**
463
+     * @brief checks whether an update method specified by feature was run
464
+     * already. If not, it will marked like this, because it is expected that
465
+     * the method will be run, when false is returned.
466
+     * @param string $feature email | quota | avatar | profile (can be extended)
467
+     */
468
+    private function wasRefreshed(string $feature): bool {
469
+        if (isset($this->refreshedFeatures[$feature])) {
470
+            return true;
471
+        }
472
+        $this->refreshedFeatures[$feature] = 1;
473
+        return false;
474
+    }
475
+
476
+    /**
477
+     * fetches the email from LDAP and stores it as Nextcloud user value
478
+     * @param ?string $valueFromLDAP if known, to save an LDAP read request
479
+     */
480
+    public function updateEmail(?string $valueFromLDAP = null): void {
481
+        if ($this->wasRefreshed('email')) {
482
+            return;
483
+        }
484
+        $email = (string)$valueFromLDAP;
485
+        if (is_null($valueFromLDAP)) {
486
+            $emailAttribute = $this->connection->ldapEmailAttribute;
487
+            if ($emailAttribute !== '') {
488
+                $aEmail = $this->access->readAttribute($this->dn, $emailAttribute);
489
+                if (is_array($aEmail) && (count($aEmail) > 0)) {
490
+                    $email = (string)$aEmail[0];
491
+                }
492
+            }
493
+        }
494
+        if ($email !== '') {
495
+            $user = $this->userManager->get($this->uid);
496
+            if (!is_null($user)) {
497
+                $currentEmail = (string)$user->getSystemEMailAddress();
498
+                if ($currentEmail !== $email) {
499
+                    $user->setSystemEMailAddress($email);
500
+                }
501
+            }
502
+        }
503
+    }
504
+
505
+    /**
506
+     * Overall process goes as follow:
507
+     * 1. fetch the quota from LDAP and check if it's parseable with the "verifyQuotaValue" function
508
+     * 2. if the value can't be fetched, is empty or not parseable, use the default LDAP quota
509
+     * 3. if the default LDAP quota can't be parsed, use the Nextcloud's default quota (use 'default')
510
+     * 4. check if the target user exists and set the quota for the user.
511
+     *
512
+     * In order to improve performance and prevent an unwanted extra LDAP call, the $valueFromLDAP
513
+     * parameter can be passed with the value of the attribute. This value will be considered as the
514
+     * quota for the user coming from the LDAP server (step 1 of the process) It can be useful to
515
+     * fetch all the user's attributes in one call and use the fetched values in this function.
516
+     * The expected value for that parameter is a string describing the quota for the user. Valid
517
+     * values are 'none' (unlimited), 'default' (the Nextcloud's default quota), '1234' (quota in
518
+     * bytes), '1234 MB' (quota in MB - check the \OCP\Util::computerFileSize method for more info)
519
+     *
520
+     * fetches the quota from LDAP and stores it as Nextcloud user value
521
+     * @param ?string $valueFromLDAP the quota attribute's value can be passed,
522
+     *                               to save the readAttribute request
523
+     */
524
+    public function updateQuota(?string $valueFromLDAP = null): void {
525
+        if ($this->wasRefreshed('quota')) {
526
+            return;
527
+        }
528
+
529
+        $quotaAttribute = $this->connection->ldapQuotaAttribute;
530
+        $defaultQuota = $this->connection->ldapQuotaDefault;
531
+        if ($quotaAttribute === '' && $defaultQuota === '') {
532
+            return;
533
+        }
534
+
535
+        $quota = false;
536
+        if (is_null($valueFromLDAP) && $quotaAttribute !== '') {
537
+            $aQuota = $this->access->readAttribute($this->dn, $quotaAttribute);
538
+            if ($aQuota !== false && isset($aQuota[0]) && $this->verifyQuotaValue($aQuota[0])) {
539
+                $quota = $aQuota[0];
540
+            } elseif (is_array($aQuota) && isset($aQuota[0])) {
541
+                $this->logger->debug('no suitable LDAP quota found for user ' . $this->uid . ': [' . $aQuota[0] . ']', ['app' => 'user_ldap']);
542
+            }
543
+        } elseif (!is_null($valueFromLDAP) && $this->verifyQuotaValue($valueFromLDAP)) {
544
+            $quota = $valueFromLDAP;
545
+        } else {
546
+            $this->logger->debug('no suitable LDAP quota found for user ' . $this->uid . ': [' . ($valueFromLDAP ?? '') . ']', ['app' => 'user_ldap']);
547
+        }
548
+
549
+        if ($quota === false && $this->verifyQuotaValue($defaultQuota)) {
550
+            // quota not found using the LDAP attribute (or not parseable). Try the default quota
551
+            $quota = $defaultQuota;
552
+        } elseif ($quota === false) {
553
+            $this->logger->debug('no suitable default quota found for user ' . $this->uid . ': [' . $defaultQuota . ']', ['app' => 'user_ldap']);
554
+            return;
555
+        }
556
+
557
+        $targetUser = $this->userManager->get($this->uid);
558
+        if ($targetUser instanceof IUser) {
559
+            $targetUser->setQuota($quota);
560
+        } else {
561
+            $this->logger->info('trying to set a quota for user ' . $this->uid . ' but the user is missing', ['app' => 'user_ldap']);
562
+        }
563
+    }
564
+
565
+    private function verifyQuotaValue(string $quotaValue): bool {
566
+        return $quotaValue === 'none' || $quotaValue === 'default' || Util::computerFileSize($quotaValue) !== false;
567
+    }
568
+
569
+    /**
570
+     * takes values from LDAP and stores it as Nextcloud user profile value
571
+     *
572
+     * @param array $profileValues associative array of property keys and values from LDAP
573
+     */
574
+    private function updateProfile(array $profileValues): void {
575
+        // check if given array is empty
576
+        if (empty($profileValues)) {
577
+            return; // okay, nothing to do
578
+        }
579
+        // fetch/prepare user
580
+        $user = $this->userManager->get($this->uid);
581
+        if (is_null($user)) {
582
+            $this->logger->error('could not get user for uid=' . $this->uid . '', ['app' => 'user_ldap']);
583
+            return;
584
+        }
585
+        // prepare AccountManager and Account
586
+        $accountManager = Server::get(IAccountManager::class);
587
+        $account = $accountManager->getAccount($user);	// get Account
588
+        $defaultScopes = array_merge(AccountManager::DEFAULT_SCOPES,
589
+            $this->config->getSystemValue('account_manager.default_property_scope', []));
590
+        // loop through the properties and handle them
591
+        foreach ($profileValues as $property => $valueFromLDAP) {
592
+            // check and update profile properties
593
+            $value = (is_array($valueFromLDAP) ? $valueFromLDAP[0] : $valueFromLDAP); // take ONLY the first value, if multiple values specified
594
+            try {
595
+                $accountProperty = $account->getProperty($property);
596
+                $currentValue = $accountProperty->getValue();
597
+                $scope = ($accountProperty->getScope() ?: $defaultScopes[$property]);
598
+            } catch (PropertyDoesNotExistException $e) { // thrown at getProperty
599
+                $this->logger->error('property does not exist: ' . $property
600
+                    . ' for uid=' . $this->uid . '', ['app' => 'user_ldap', 'exception' => $e]);
601
+                $currentValue = '';
602
+                $scope = $defaultScopes[$property];
603
+            }
604
+            $verified = IAccountManager::VERIFIED; // trust the LDAP admin knew what they put there
605
+            if ($currentValue !== $value) {
606
+                $account->setProperty($property, $value, $scope, $verified);
607
+                $this->logger->debug('update user profile: ' . $property . '=' . $value
608
+                    . ' for uid=' . $this->uid . '', ['app' => 'user_ldap']);
609
+            }
610
+        }
611
+        try {
612
+            $accountManager->updateAccount($account); // may throw InvalidArgumentException
613
+        } catch (\InvalidArgumentException $e) {
614
+            $this->logger->error('invalid data from LDAP: for uid=' . $this->uid . '', ['app' => 'user_ldap', 'func' => 'updateProfile'
615
+                , 'exception' => $e]);
616
+        }
617
+    }
618
+
619
+    /**
620
+     * @brief attempts to get an image from LDAP and sets it as Nextcloud avatar
621
+     * @return bool true when the avatar was set successfully or is up to date
622
+     */
623
+    public function updateAvatar(bool $force = false): bool {
624
+        if (!$force && $this->wasRefreshed('avatar')) {
625
+            return false;
626
+        }
627
+        $avatarImage = $this->getAvatarImage();
628
+        if ($avatarImage === false) {
629
+            //not set, nothing left to do;
630
+            return false;
631
+        }
632
+
633
+        if (!$this->image->loadFromBase64(base64_encode($avatarImage))) {
634
+            return false;
635
+        }
636
+
637
+        // use the checksum before modifications
638
+        $checksum = md5($this->image->data());
639
+
640
+        if ($checksum === $this->config->getUserValue($this->uid, 'user_ldap', 'lastAvatarChecksum', '') && $this->avatarExists()) {
641
+            return true;
642
+        }
643
+
644
+        $isSet = $this->setNextcloudAvatar();
645
+
646
+        if ($isSet) {
647
+            // save checksum only after successful setting
648
+            $this->config->setUserValue($this->uid, 'user_ldap', 'lastAvatarChecksum', $checksum);
649
+        }
650
+
651
+        return $isSet;
652
+    }
653
+
654
+    private function avatarExists(): bool {
655
+        try {
656
+            $currentAvatar = $this->avatarManager->getAvatar($this->uid);
657
+            return $currentAvatar->exists() && $currentAvatar->isCustomAvatar();
658
+        } catch (\Exception $e) {
659
+            return false;
660
+        }
661
+    }
662
+
663
+    /**
664
+     * @brief sets an image as Nextcloud avatar
665
+     */
666
+    private function setNextcloudAvatar(): bool {
667
+        if (!$this->image->valid()) {
668
+            $this->logger->error('avatar image data from LDAP invalid for ' . $this->dn, ['app' => 'user_ldap']);
669
+            return false;
670
+        }
671
+
672
+
673
+        //make sure it is a square and not bigger than 512x512
674
+        $size = min([$this->image->width(), $this->image->height(), 512]);
675
+        if (!$this->image->centerCrop($size)) {
676
+            $this->logger->error('croping image for avatar failed for ' . $this->dn, ['app' => 'user_ldap']);
677
+            return false;
678
+        }
679
+
680
+        try {
681
+            $avatar = $this->avatarManager->getAvatar($this->uid);
682
+            $avatar->set($this->image);
683
+            return true;
684
+        } catch (\Exception $e) {
685
+            $this->logger->info('Could not set avatar for ' . $this->dn, ['exception' => $e]);
686
+        }
687
+        return false;
688
+    }
689
+
690
+    /**
691
+     * @throws AttributeNotSet
692
+     * @throws \OC\ServerNotAvailableException
693
+     * @throws PreConditionNotMetException
694
+     */
695
+    public function getExtStorageHome():string {
696
+        $value = $this->config->getUserValue($this->getUsername(), 'user_ldap', 'extStorageHome', '');
697
+        if ($value !== '') {
698
+            return $value;
699
+        }
700
+
701
+        $value = $this->updateExtStorageHome();
702
+        if ($value !== '') {
703
+            return $value;
704
+        }
705
+
706
+        throw new AttributeNotSet(sprintf(
707
+            'external home storage attribute yield no value for %s', $this->getUsername()
708
+        ));
709
+    }
710
+
711
+    /**
712
+     * @throws PreConditionNotMetException
713
+     * @throws \OC\ServerNotAvailableException
714
+     */
715
+    public function updateExtStorageHome(?string $valueFromLDAP = null):string {
716
+        if ($valueFromLDAP === null) {
717
+            $extHomeValues = $this->access->readAttribute($this->getDN(), $this->connection->ldapExtStorageHomeAttribute);
718
+        } else {
719
+            $extHomeValues = [$valueFromLDAP];
720
+        }
721
+        if ($extHomeValues !== false && isset($extHomeValues[0])) {
722
+            $extHome = $extHomeValues[0];
723
+            $this->config->setUserValue($this->getUsername(), 'user_ldap', 'extStorageHome', $extHome);
724
+            return $extHome;
725
+        } else {
726
+            $this->config->deleteUserValue($this->getUsername(), 'user_ldap', 'extStorageHome');
727
+            return '';
728
+        }
729
+    }
730
+
731
+    /**
732
+     * called by a post_login hook to handle password expiry
733
+     */
734
+    public function handlePasswordExpiry(array $params): void {
735
+        $ppolicyDN = $this->connection->ldapDefaultPPolicyDN;
736
+        if (empty($ppolicyDN) || ((int)$this->connection->turnOnPasswordChange !== 1)) {
737
+            //password expiry handling disabled
738
+            return;
739
+        }
740
+        $uid = $params['uid'];
741
+        if (isset($uid) && $uid === $this->getUsername()) {
742
+            //retrieve relevant user attributes
743
+            $result = $this->access->search('objectclass=*', $this->dn, ['pwdpolicysubentry', 'pwdgraceusetime', 'pwdreset', 'pwdchangedtime']);
744
+
745
+            if (array_key_exists('pwdpolicysubentry', $result[0])) {
746
+                $pwdPolicySubentry = $result[0]['pwdpolicysubentry'];
747
+                if ($pwdPolicySubentry && (count($pwdPolicySubentry) > 0)) {
748
+                    $ppolicyDN = $pwdPolicySubentry[0];//custom ppolicy DN
749
+                }
750
+            }
751
+
752
+            $pwdGraceUseTime = array_key_exists('pwdgraceusetime', $result[0]) ? $result[0]['pwdgraceusetime'] : [];
753
+            $pwdReset = array_key_exists('pwdreset', $result[0]) ? $result[0]['pwdreset'] : [];
754
+            $pwdChangedTime = array_key_exists('pwdchangedtime', $result[0]) ? $result[0]['pwdchangedtime'] : [];
755
+
756
+            //retrieve relevant password policy attributes
757
+            $cacheKey = 'ppolicyAttributes' . $ppolicyDN;
758
+            $result = $this->connection->getFromCache($cacheKey);
759
+            if (is_null($result)) {
760
+                $result = $this->access->search('objectclass=*', $ppolicyDN, ['pwdgraceauthnlimit', 'pwdmaxage', 'pwdexpirewarning']);
761
+                $this->connection->writeToCache($cacheKey, $result);
762
+            }
763
+
764
+            $pwdGraceAuthNLimit = array_key_exists('pwdgraceauthnlimit', $result[0]) ? $result[0]['pwdgraceauthnlimit'] : [];
765
+            $pwdMaxAge = array_key_exists('pwdmaxage', $result[0]) ? $result[0]['pwdmaxage'] : [];
766
+            $pwdExpireWarning = array_key_exists('pwdexpirewarning', $result[0]) ? $result[0]['pwdexpirewarning'] : [];
767
+
768
+            //handle grace login
769
+            if (!empty($pwdGraceUseTime)) { //was this a grace login?
770
+                if (!empty($pwdGraceAuthNLimit)
771
+                    && count($pwdGraceUseTime) < (int)$pwdGraceAuthNLimit[0]) { //at least one more grace login available?
772
+                    $this->config->setUserValue($uid, 'user_ldap', 'needsPasswordReset', 'true');
773
+                    header('Location: ' . Server::get(IURLGenerator::class)->linkToRouteAbsolute(
774
+                        'user_ldap.renewPassword.showRenewPasswordForm', ['user' => $uid]));
775
+                } else { //no more grace login available
776
+                    header('Location: ' . Server::get(IURLGenerator::class)->linkToRouteAbsolute(
777
+                        'user_ldap.renewPassword.showLoginFormInvalidPassword', ['user' => $uid]));
778
+                }
779
+                exit();
780
+            }
781
+            //handle pwdReset attribute
782
+            if (!empty($pwdReset) && $pwdReset[0] === 'TRUE') { //user must change their password
783
+                $this->config->setUserValue($uid, 'user_ldap', 'needsPasswordReset', 'true');
784
+                header('Location: ' . Server::get(IURLGenerator::class)->linkToRouteAbsolute(
785
+                    'user_ldap.renewPassword.showRenewPasswordForm', ['user' => $uid]));
786
+                exit();
787
+            }
788
+            //handle password expiry warning
789
+            if (!empty($pwdChangedTime)) {
790
+                if (!empty($pwdMaxAge)
791
+                    && !empty($pwdExpireWarning)) {
792
+                    $pwdMaxAgeInt = (int)$pwdMaxAge[0];
793
+                    $pwdExpireWarningInt = (int)$pwdExpireWarning[0];
794
+                    if ($pwdMaxAgeInt > 0 && $pwdExpireWarningInt > 0) {
795
+                        $pwdChangedTimeDt = \DateTime::createFromFormat('YmdHisZ', $pwdChangedTime[0]);
796
+                        $pwdChangedTimeDt->add(new \DateInterval('PT' . $pwdMaxAgeInt . 'S'));
797
+                        $currentDateTime = new \DateTime();
798
+                        $secondsToExpiry = $pwdChangedTimeDt->getTimestamp() - $currentDateTime->getTimestamp();
799
+                        if ($secondsToExpiry <= $pwdExpireWarningInt) {
800
+                            //remove last password expiry warning if any
801
+                            $notification = $this->notificationManager->createNotification();
802
+                            $notification->setApp('user_ldap')
803
+                                ->setUser($uid)
804
+                                ->setObject('pwd_exp_warn', $uid)
805
+                            ;
806
+                            $this->notificationManager->markProcessed($notification);
807
+                            //create new password expiry warning
808
+                            $notification = $this->notificationManager->createNotification();
809
+                            $notification->setApp('user_ldap')
810
+                                ->setUser($uid)
811
+                                ->setDateTime($currentDateTime)
812
+                                ->setObject('pwd_exp_warn', $uid)
813
+                                ->setSubject('pwd_exp_warn_days', [(int)ceil($secondsToExpiry / 60 / 60 / 24)])
814
+                            ;
815
+                            $this->notificationManager->notify($notification);
816
+                        }
817
+                    }
818
+                }
819
+            }
820
+        }
821
+    }
822 822
 }
Please login to merge, or discard this patch.