1<?php
2/**
3 * @copyright Copyright (c) 2016, ownCloud, Inc.
4 *
5 * @author Aaron Wood <aaronjwood@gmail.com>
6 * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
7 * @author blizzz <blizzz@arthur-schiwon.de>
8 * @author Christoph Wurst <christoph@winzerhof-wurst.at>
9 * @author Joas Schilling <coding@schilljs.com>
10 * @author Roeland Jago Douma <roeland@famdouma.nl>
11 *
12 * @license AGPL-3.0
13 *
14 * This code is free software: you can redistribute it and/or modify
15 * it under the terms of the GNU Affero General Public License, version 3,
16 * as published by the Free Software Foundation.
17 *
18 * This program is distributed in the hope that it will be useful,
19 * but WITHOUT ANY WARRANTY; without even the implied warranty of
20 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 * GNU Affero General Public License for more details.
22 *
23 * You should have received a copy of the GNU Affero General Public License, version 3,
24 * along with this program. If not, see <http://www.gnu.org/licenses/>
25 *
26 */
27namespace OCA\User_LDAP\Mapping;
28
29use Doctrine\DBAL\Exception;
30use OC\DB\QueryBuilder\QueryBuilder;
31use OCP\DB\IPreparedStatement;
32use OCP\DB\QueryBuilder\IQueryBuilder;
33
34/**
35 * Class AbstractMapping
36 *
37 * @package OCA\User_LDAP\Mapping
38 */
39abstract class AbstractMapping {
40	/**
41	 * @var \OCP\IDBConnection $dbc
42	 */
43	protected $dbc;
44
45	/**
46	 * returns the DB table name which holds the mappings
47	 *
48	 * @return string
49	 */
50	abstract protected function getTableName(bool $includePrefix = true);
51
52	/**
53	 * @param \OCP\IDBConnection $dbc
54	 */
55	public function __construct(\OCP\IDBConnection $dbc) {
56		$this->dbc = $dbc;
57	}
58
59	/** @var array caches Names (value) by DN (key) */
60	protected $cache = [];
61
62	/**
63	 * checks whether a provided string represents an existing table col
64	 *
65	 * @param string $col
66	 * @return bool
67	 */
68	public function isColNameValid($col) {
69		switch ($col) {
70			case 'ldap_dn':
71			case 'owncloud_name':
72			case 'directory_uuid':
73				return true;
74			default:
75				return false;
76		}
77	}
78
79	/**
80	 * Gets the value of one column based on a provided value of another column
81	 *
82	 * @param string $fetchCol
83	 * @param string $compareCol
84	 * @param string $search
85	 * @return string|false
86	 * @throws \Exception
87	 */
88	protected function getXbyY($fetchCol, $compareCol, $search) {
89		if (!$this->isColNameValid($fetchCol)) {
90			//this is used internally only, but we don't want to risk
91			//having SQL injection at all.
92			throw new \Exception('Invalid Column Name');
93		}
94		$query = $this->dbc->prepare('
95			SELECT `' . $fetchCol . '`
96			FROM `' . $this->getTableName() . '`
97			WHERE `' . $compareCol . '` = ?
98		');
99
100		try {
101			$res = $query->execute([$search]);
102			$data = $res->fetchOne();
103			$res->closeCursor();
104			return $data;
105		} catch (Exception $e) {
106			return false;
107		}
108	}
109
110	/**
111	 * Performs a DELETE or UPDATE query to the database.
112	 *
113	 * @param IPreparedStatement $statement
114	 * @param array $parameters
115	 * @return bool true if at least one row was modified, false otherwise
116	 */
117	protected function modify(IPreparedStatement $statement, $parameters) {
118		try {
119			$result = $statement->execute($parameters);
120			$updated = $result->rowCount() > 0;
121			$result->closeCursor();
122			return $updated;
123		} catch (Exception $e) {
124			return false;
125		}
126	}
127
128	/**
129	 * Gets the LDAP DN based on the provided name.
130	 * Replaces Access::ocname2dn
131	 *
132	 * @param string $name
133	 * @return string|false
134	 */
135	public function getDNByName($name) {
136		$dn = array_search($name, $this->cache);
137		if ($dn === false && ($dn = $this->getXbyY('ldap_dn', 'owncloud_name', $name)) !== false) {
138			$this->cache[$dn] = $name;
139		}
140		return $dn;
141	}
142
143	/**
144	 * Updates the DN based on the given UUID
145	 *
146	 * @param string $fdn
147	 * @param string $uuid
148	 * @return bool
149	 */
150	public function setDNbyUUID($fdn, $uuid) {
151		$oldDn = $this->getDnByUUID($uuid);
152		$statement = $this->dbc->prepare('
153			UPDATE `' . $this->getTableName() . '`
154			SET `ldap_dn` = ?
155			WHERE `directory_uuid` = ?
156		');
157
158		$r = $this->modify($statement, [$fdn, $uuid]);
159
160		if ($r && is_string($oldDn) && isset($this->cache[$oldDn])) {
161			$this->cache[$fdn] = $this->cache[$oldDn];
162			unset($this->cache[$oldDn]);
163		}
164
165		return $r;
166	}
167
168	/**
169	 * Updates the UUID based on the given DN
170	 *
171	 * required by Migration/UUIDFix
172	 *
173	 * @param $uuid
174	 * @param $fdn
175	 * @return bool
176	 */
177	public function setUUIDbyDN($uuid, $fdn) {
178		$statement = $this->dbc->prepare('
179			UPDATE `' . $this->getTableName() . '`
180			SET `directory_uuid` = ?
181			WHERE `ldap_dn` = ?
182		');
183
184		unset($this->cache[$fdn]);
185
186		return $this->modify($statement, [$uuid, $fdn]);
187	}
188
189	/**
190	 * Gets the name based on the provided LDAP DN.
191	 *
192	 * @param string $fdn
193	 * @return string|false
194	 */
195	public function getNameByDN($fdn) {
196		if (!isset($this->cache[$fdn])) {
197			$this->cache[$fdn] = $this->getXbyY('owncloud_name', 'ldap_dn', $fdn);
198		}
199		return $this->cache[$fdn];
200	}
201
202	protected function prepareListOfIdsQuery(array $dnList): IQueryBuilder {
203		$qb = $this->dbc->getQueryBuilder();
204		$qb->select('owncloud_name', 'ldap_dn')
205			->from($this->getTableName(false))
206			->where($qb->expr()->in('ldap_dn', $qb->createNamedParameter($dnList, QueryBuilder::PARAM_STR_ARRAY)));
207		return $qb;
208	}
209
210	protected function collectResultsFromListOfIdsQuery(IQueryBuilder $qb, array &$results): void {
211		$stmt = $qb->execute();
212		while ($entry = $stmt->fetch(\Doctrine\DBAL\FetchMode::ASSOCIATIVE)) {
213			$results[$entry['ldap_dn']] = $entry['owncloud_name'];
214			$this->cache[$entry['ldap_dn']] = $entry['owncloud_name'];
215		}
216		$stmt->closeCursor();
217	}
218
219	public function getListOfIdsByDn(array $fdns): array {
220		$totalDBParamLimit = 65000;
221		$sliceSize = 1000;
222		$maxSlices = $totalDBParamLimit / $sliceSize;
223		$results = [];
224
225		$slice = 1;
226		$fdnsSlice = count($fdns) > $sliceSize ? array_slice($fdns, 0, $sliceSize) : $fdns;
227		$qb = $this->prepareListOfIdsQuery($fdnsSlice);
228
229		while (isset($fdnsSlice[999])) {
230			// Oracle does not allow more than 1000 values in the IN list,
231			// but allows slicing
232			$slice++;
233			$fdnsSlice = array_slice($fdns, $sliceSize * ($slice - 1), $sliceSize);
234
235			/** @see https://github.com/vimeo/psalm/issues/4995 */
236			/** @psalm-suppress TypeDoesNotContainType */
237			if (!isset($qb)) {
238				$qb = $this->prepareListOfIdsQuery($fdnsSlice);
239				continue;
240			}
241
242			if (!empty($fdnsSlice)) {
243				$qb->orWhere($qb->expr()->in('ldap_dn', $qb->createNamedParameter($fdnsSlice, QueryBuilder::PARAM_STR_ARRAY)));
244			}
245
246			if ($slice % $maxSlices === 0) {
247				$this->collectResultsFromListOfIdsQuery($qb, $results);
248				unset($qb);
249			}
250		}
251
252		if (isset($qb)) {
253			$this->collectResultsFromListOfIdsQuery($qb, $results);
254		}
255
256		return $results;
257	}
258
259	/**
260	 * Searches mapped names by the giving string in the name column
261	 *
262	 * @param string $search
263	 * @param string $prefixMatch
264	 * @param string $postfixMatch
265	 * @return string[]
266	 */
267	public function getNamesBySearch($search, $prefixMatch = "", $postfixMatch = "") {
268		$statement = $this->dbc->prepare('
269			SELECT `owncloud_name`
270			FROM `' . $this->getTableName() . '`
271			WHERE `owncloud_name` LIKE ?
272		');
273
274		try {
275			$res = $statement->execute([$prefixMatch . $this->dbc->escapeLikeParameter($search) . $postfixMatch]);
276		} catch (Exception $e) {
277			return [];
278		}
279		$names = [];
280		while ($row = $res->fetch()) {
281			$names[] = $row['owncloud_name'];
282		}
283		return $names;
284	}
285
286	/**
287	 * Gets the name based on the provided LDAP UUID.
288	 *
289	 * @param string $uuid
290	 * @return string|false
291	 */
292	public function getNameByUUID($uuid) {
293		return $this->getXbyY('owncloud_name', 'directory_uuid', $uuid);
294	}
295
296	public function getDnByUUID($uuid) {
297		return $this->getXbyY('ldap_dn', 'directory_uuid', $uuid);
298	}
299
300	/**
301	 * Gets the UUID based on the provided LDAP DN
302	 *
303	 * @param string $dn
304	 * @return false|string
305	 * @throws \Exception
306	 */
307	public function getUUIDByDN($dn) {
308		return $this->getXbyY('directory_uuid', 'ldap_dn', $dn);
309	}
310
311	/**
312	 * gets a piece of the mapping list
313	 *
314	 * @param int $offset
315	 * @param int $limit
316	 * @return array
317	 */
318	public function getList($offset = null, $limit = null) {
319		$query = $this->dbc->prepare('
320			SELECT
321				`ldap_dn` AS `dn`,
322				`owncloud_name` AS `name`,
323				`directory_uuid` AS `uuid`
324			FROM `' . $this->getTableName() . '`',
325			$limit,
326			$offset
327		);
328
329		$query->execute();
330		return $query->fetchAll();
331	}
332
333	/**
334	 * attempts to map the given entry
335	 *
336	 * @param string $fdn fully distinguished name (from LDAP)
337	 * @param string $name
338	 * @param string $uuid a unique identifier as used in LDAP
339	 * @return bool
340	 */
341	public function map($fdn, $name, $uuid) {
342		if (mb_strlen($fdn) > 255) {
343			\OC::$server->getLogger()->error(
344				'Cannot map, because the DN exceeds 255 characters: {dn}',
345				[
346					'app' => 'user_ldap',
347					'dn' => $fdn,
348				]
349			);
350			return false;
351		}
352
353		$row = [
354			'ldap_dn' => $fdn,
355			'owncloud_name' => $name,
356			'directory_uuid' => $uuid
357		];
358
359		try {
360			$result = $this->dbc->insertIfNotExist($this->getTableName(), $row);
361			if ((bool)$result === true) {
362				$this->cache[$fdn] = $name;
363			}
364			// insertIfNotExist returns values as int
365			return (bool)$result;
366		} catch (\Exception $e) {
367			return false;
368		}
369	}
370
371	/**
372	 * removes a mapping based on the owncloud_name of the entry
373	 *
374	 * @param string $name
375	 * @return bool
376	 */
377	public function unmap($name) {
378		$statement = $this->dbc->prepare('
379			DELETE FROM `' . $this->getTableName() . '`
380			WHERE `owncloud_name` = ?');
381
382		$dn = array_search($name, $this->cache);
383		if ($dn !== false) {
384			unset($this->cache[$dn]);
385		}
386
387		return $this->modify($statement, [$name]);
388	}
389
390	/**
391	 * Truncates the mapping table
392	 *
393	 * @return bool
394	 */
395	public function clear() {
396		$sql = $this->dbc
397			->getDatabasePlatform()
398			->getTruncateTableSQL('`' . $this->getTableName() . '`');
399		try {
400			$this->dbc->executeQuery($sql);
401
402			return true;
403		} catch (Exception $e) {
404			return false;
405		}
406	}
407
408	/**
409	 * clears the mapping table one by one and executing a callback with
410	 * each row's id (=owncloud_name col)
411	 *
412	 * @param callable $preCallback
413	 * @param callable $postCallback
414	 * @return bool true on success, false when at least one row was not
415	 * deleted
416	 */
417	public function clearCb(callable $preCallback, callable $postCallback): bool {
418		$picker = $this->dbc->getQueryBuilder();
419		$picker->select('owncloud_name')
420			->from($this->getTableName());
421		$cursor = $picker->execute();
422		$result = true;
423		while ($id = $cursor->fetchOne()) {
424			$preCallback($id);
425			if ($isUnmapped = $this->unmap($id)) {
426				$postCallback($id);
427			}
428			$result &= $isUnmapped;
429		}
430		$cursor->closeCursor();
431		return $result;
432	}
433
434	/**
435	 * returns the number of entries in the mappings table
436	 *
437	 * @return int
438	 */
439	public function count() {
440		$qb = $this->dbc->getQueryBuilder();
441		$query = $qb->select($qb->func()->count('ldap_dn'))
442			->from($this->getTableName());
443		$res = $query->execute();
444		$count = $res->fetchOne();
445		$res->closeCursor();
446		return (int)$count;
447	}
448}
449