Completed
Push — stable10 ( 7bb91f...379260 )
by Blizzz
73:07 queued 62:02
created

RepairUnmergedShares::findBestTargetName()   B

Complexity

Conditions 4
Paths 6

Size

Total Lines 24
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 1 Features 0
Metric Value
cc 4
eloc 11
nc 6
nop 2
dl 0
loc 24
rs 8.6845
c 3
b 1
f 0
1
<?php
2
/**
3
 * @author Vincent Petry <[email protected]>
4
 *
5
 * @copyright Copyright (c) 2016, ownCloud, Inc.
6
 * @license AGPL-3.0
7
 *
8
 * This code is free software: you can redistribute it and/or modify
9
 * it under the terms of the GNU Affero General Public License, version 3,
10
 * as published by the Free Software Foundation.
11
 *
12
 * This program is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
 * GNU Affero General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU Affero General Public License, version 3,
18
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
19
 *
20
 */
21
22
namespace OC\Repair;
23
24
use OCP\Migration\IOutput;
25
use OCP\Migration\IRepairStep;
26
use OC\Share\Constants;
27
use OCP\DB\QueryBuilder\IQueryBuilder;
28
use OCP\IConfig;
29
use OCP\IDBConnection;
30
use OCP\IUserManager;
31
use OCP\IUser;
32
use OCP\IGroupManager;
33
use OC\Share20\DefaultShareProvider;
34
35
/**
36
 * Repairs shares for which the received folder was not properly deduplicated.
37
 *
38
 * An unmerged share can for example happen when sharing a folder with the same
39
 * user through multiple ways, like several groups and also directly, additionally
40
 * to group shares. Since 9.0.0 these would create duplicate entries "folder (2)",
41
 * one for every share. This repair step rearranges them so they only appear as a single
42
 * folder.
43
 */
44
class RepairUnmergedShares implements IRepairStep {
45
46
	/** @var \OCP\IConfig */
47
	protected $config;
48
49
	/** @var \OCP\IDBConnection */
50
	protected $connection;
51
52
	/** @var IUserManager */
53
	protected $userManager;
54
55
	/** @var IGroupManager */
56
	protected $groupManager;
57
58
	/** @var IQueryBuilder */
59
	private $queryGetSharesWithUsers;
60
61
	/** @var IQueryBuilder */
62
	private $queryUpdateSharePermissionsAndTarget;
63
64
	/** @var IQueryBuilder */
65
	private $queryUpdateShareInBatch;
66
67
	/**
68
	 * @param \OCP\IConfig $config
69
	 * @param \OCP\IDBConnection $connection
70
	 */
71
	public function __construct(
72
		IConfig $config,
73
		IDBConnection $connection,
74
		IUserManager $userManager,
75
		IGroupManager $groupManager
76
	) {
77
		$this->connection = $connection;
78
		$this->config = $config;
79
		$this->userManager = $userManager;
80
		$this->groupManager = $groupManager;
81
	}
82
83
	public function getName() {
84
		return 'Repair unmerged shares';
85
	}
86
87
	/**
88
	 * Builds prepared queries for reuse
89
	 */
90
	private function buildPreparedQueries() {
91
		/**
92
		 * Retrieve shares for a given user/group and share type
93
		 */
94
		$query = $this->connection->getQueryBuilder();
95
		$query
96
			->select('item_source', 'id', 'file_target', 'permissions', 'parent', 'share_type', 'stime')
0 ignored issues
show
Unused Code introduced by
The call to IQueryBuilder::select() has too many arguments starting with 'id'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
97
			->from('share')
98
			->where($query->expr()->eq('share_type', $query->createParameter('shareType')))
99
			->andWhere($query->expr()->in('share_with', $query->createParameter('shareWiths')))
100
			->andWhere($query->expr()->in('item_type', $query->createParameter('itemTypes')))
101
			->orderBy('item_source', 'ASC')
102
			->addOrderBy('stime', 'ASC');
103
104
		$this->queryGetSharesWithUsers = $query;
105
106
		/**
107
		 * Updates the file_target to the given value for all given share ids.
108
		 *
109
		 * This updates several shares in bulk which is faster than individually.
110
		 */
111
		$query = $this->connection->getQueryBuilder();
112
		$query->update('share')
113
			->set('file_target', $query->createParameter('file_target'))
114
			->where($query->expr()->in('id', $query->createParameter('ids')));
115
116
		$this->queryUpdateShareInBatch = $query;
117
118
		/**
119
		 * Updates the share permissions and target path of a single share.
120
		 */
121
		$query = $this->connection->getQueryBuilder();
122
		$query->update('share')
123
			->set('permissions', $query->createParameter('permissions'))
124
			->set('file_target', $query->createParameter('file_target'))
125
			->where($query->expr()->eq('id', $query->createParameter('shareid')));
126
127
		$this->queryUpdateSharePermissionsAndTarget = $query;
128
129
	}
130
131
	private function getSharesWithUser($shareType, $shareWiths) {
132
		$groupedShares = [];
133
134
		$query = $this->queryGetSharesWithUsers;
135
		$query->setParameter('shareWiths', $shareWiths, IQueryBuilder::PARAM_STR_ARRAY);
136
		$query->setParameter('shareType', $shareType);
137
		$query->setParameter('itemTypes', ['file', 'folder'], IQueryBuilder::PARAM_STR_ARRAY);
138
139
		$shares = $query->execute()->fetchAll();
140
141
		// group by item_source
142
		foreach ($shares as $share) {
143
			if (!isset($groupedShares[$share['item_source']])) {
144
				$groupedShares[$share['item_source']] = [];
145
			}
146
			$groupedShares[$share['item_source']][] = $share;
147
		}
148
		return $groupedShares;
149
	}
150
151
	private function isPotentialDuplicateName($name) {
152
		return (preg_match('/\(\d+\)(\.[^\.]+)?$/', $name) === 1);
153
	}
154
155
	/**
156
	 * Decide on the best target name based on all group shares and subshares,
157
	 * goal is to increase the likeliness that the chosen name matches what
158
	 * the user is expecting.
159
	 *
160
	 * For this, we discard the entries with parenthesis "(2)".
161
	 * In case the user also renamed the duplicates to a legitimate name, this logic
162
	 * will still pick the most recent one as it's the one the user is most likely to
163
	 * remember renaming.
164
	 *
165
	 * If no suitable subshare is found, use the least recent group share instead.
166
	 *
167
	 * @param array $groupShares group share entries
168
	 * @param array $subShares sub share entries
169
	 *
170
	 * @return string chosen target name
171
	 */
172
	private function findBestTargetName($groupShares, $subShares) {
173
		$pickedShare = null;
174
		// sort by stime, this also properly sorts the direct user share if any
175
		@usort($subShares, function($a, $b) {
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
176
			return ((int)$a['stime'] - (int)$b['stime']);
177
		});
178
179
		foreach ($subShares as $subShare) {
180
			// skip entries that have parenthesis with numbers
181
			if ($this->isPotentialDuplicateName($subShare['file_target'])) {
182
				continue;
183
			}
184
			// pick any share found that would match, the last being the most recent
185
			$pickedShare = $subShare;
186
		}
187
188
		// no suitable subshare found
189
		if ($pickedShare === null) {
190
			// use least recent group share target instead
191
			$pickedShare = $groupShares[0];
192
		}
193
194
		return $pickedShare['file_target'];
195
	}
196
197
	/**
198
	 * Fix the given received share represented by the set of group shares
199
	 * and matching sub shares
200
	 *
201
	 * @param array $groupShares group share entries
202
	 * @param array $subShares sub share entries
203
	 *
204
	 * @return boolean false if the share was not repaired, true if it was
205
	 */
206
	private function fixThisShare($groupShares, $subShares) {
207
		if (empty($subShares)) {
208
			return false;
209
		}
210
211
		$groupSharesById = [];
212
		foreach ($groupShares as $groupShare) {
213
			$groupSharesById[$groupShare['id']] = $groupShare;
214
		}
215
216
		if ($this->isThisShareValid($groupSharesById, $subShares)) {
217
			return false;
218
		}
219
220
		$targetPath = $this->findBestTargetName($groupShares, $subShares);
221
222
		// check whether the user opted out completely of all subshares
223
		$optedOut = true;
224
		foreach ($subShares as $subShare) {
225
			if ((int)$subShare['permissions'] !== 0) {
226
				$optedOut = false;
227
				break;
228
			}
229
		}
230
231
		$shareIds = [];
232
		foreach ($subShares as $subShare) {
233
			// only if the user deleted some subshares but not all, adjust the permissions of that subshare
234
			if (!$optedOut && (int)$subShare['permissions'] === 0 && (int)$subShare['share_type'] === DefaultShareProvider::SHARE_TYPE_USERGROUP) {
235
				// set permissions from parent group share
236
				$permissions = $groupSharesById[$subShare['parent']]['permissions'];
237
238
				// fix permissions and target directly
239
				$query = $this->queryUpdateSharePermissionsAndTarget;
240
				$query->setParameter('shareid', $subShare['id']);
241
				$query->setParameter('file_target', $targetPath);
242
				$query->setParameter('permissions', $permissions);
243
				$query->execute();
244
			} else {
245
				// gather share ids for bulk target update
246
				if ($subShare['file_target'] !== $targetPath) {
247
					$shareIds[] = (int)$subShare['id'];
248
				}
249
			}
250
		}
251
252
		if (!empty($shareIds)) {
253
			$query = $this->queryUpdateShareInBatch;
254
			$query->setParameter('ids', $shareIds, IQueryBuilder::PARAM_INT_ARRAY);
255
			$query->setParameter('file_target', $targetPath);
256
			$query->execute();
257
		}
258
259
		return true;
260
	}
261
262
	/**
263
	 * Checks whether the number of group shares is balanced with the child subshares.
264
	 * If all group shares have exactly one subshare, and the target of every subshare
265
	 * is the same, then the share is valid.
266
	 * If however there is a group share entry that has no matching subshare, it means
267
	 * we're in the bogus situation and the whole share must be repaired
268
	 *
269
	 * @param array $groupSharesById
270
	 * @param array $subShares
271
	 *
272
	 * @return true if the share is valid, false if it needs repair
273
	 */
274
	private function isThisShareValid($groupSharesById, $subShares) {
275
		$foundTargets = [];
276
277
		// every group share needs to have exactly one matching subshare
278
		foreach ($subShares as $subShare) {
279
			$foundTargets[$subShare['file_target']] = true;
280
			if (count($foundTargets) > 1) {
281
				// not all the same target path value => invalid
282
				return false;
283
			}
284
			if (isset($groupSharesById[$subShare['parent']])) {
285
				// remove it from the list as we found it
286
				unset($groupSharesById[$subShare['parent']]);
287
			}
288
		}
289
290
		// if we found one subshare per group entry, the set will be empty.
291
		// If not empty, it means that one of the group shares did not have
292
		// a matching subshare entry.
293
		return empty($groupSharesById);
294
	}
295
296
	/**
297
	 * Detect unmerged received shares and merge them properly
298
	 */
299
	private function fixUnmergedShares(IOutput $out, IUser $user) {
0 ignored issues
show
Unused Code introduced by
The parameter $out is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
300
		$groups = $this->groupManager->getUserGroupIds($user);
301
		if (empty($groups)) {
302
			// user is in no groups, so can't have received group shares
303
			return;
304
		}
305
306
		// get all subshares grouped by item source
307
		$subSharesByItemSource = $this->getSharesWithUser(DefaultShareProvider::SHARE_TYPE_USERGROUP, [$user->getUID()]);
308
309
		// because sometimes one wants to give the user more permissions than the group share
310
		$userSharesByItemSource = $this->getSharesWithUser(Constants::SHARE_TYPE_USER, [$user->getUID()]);
311
312
		if (empty($subSharesByItemSource) && empty($userSharesByItemSource)) {
313
			// nothing to repair for this user, no need to do extra queries
314
			return;
315
		}
316
317
		$groupSharesByItemSource = $this->getSharesWithUser(Constants::SHARE_TYPE_GROUP, $groups);
318
		if (empty($groupSharesByItemSource) && empty($userSharesByItemSource)) {
319
			// nothing to repair for this user
320
			return;
321
		}
322
323
		foreach ($groupSharesByItemSource as $itemSource => $groupShares) {
324
			$subShares = [];
325
			if (isset($subSharesByItemSource[$itemSource])) {
326
				$subShares = $subSharesByItemSource[$itemSource];
327
			}
328
329
			if (isset($userSharesByItemSource[$itemSource])) {
330
				// add it to the subshares to get a similar treatment
331
				$subShares = array_merge($subShares, $userSharesByItemSource[$itemSource]);
332
			}
333
334
			$this->fixThisShare($groupShares, $subShares);
335
		}
336
	}
337
338
	/**
339
	 * Count all the users
340
	 *
341
	 * @return int
342
	 */
343 View Code Duplication
	private function countUsers() {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
344
		$allCount = $this->userManager->countUsers();
345
346
		$totalCount = 0;
347
		foreach ($allCount as $backend => $count) {
348
			$totalCount += $count;
349
		}
350
351
		return $totalCount;
352
	}
353
354
	public function run(IOutput $output) {
355
		$ocVersionFromBeforeUpdate = $this->config->getSystemValue('version', '0.0.0');
356
		if (version_compare($ocVersionFromBeforeUpdate, '9.1.0.16', '<')) {
357
			// this situation was only possible between 9.0.0 and 9.0.3 included
358
359
			$function = function(IUser $user) use ($output) {
360
				$this->fixUnmergedShares($output, $user);
361
				$output->advance();
362
			};
363
364
			$this->buildPreparedQueries();
365
366
			$userCount = $this->countUsers();
367
			$output->startProgress($userCount);
368
369
			$this->userManager->callForAllUsers($function);
370
371
			$output->finishProgress();
372
		}
373
	}
374
}
375