Skip to content

Commit a08aec2

Browse files
Merge pull request #57881 from nextcloud/share-target-repair
Add repair step for share targets with excess (2)
2 parents 4a077c3 + 5d0acf8 commit a08aec2

5 files changed

Lines changed: 250 additions & 0 deletions

File tree

apps/files_sharing/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
'OCA\\Files_Sharing\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php',
9393
'OCA\\Files_Sharing\\OpenMetrics\\SharesCountMetric' => $baseDir . '/../lib/OpenMetrics/SharesCountMetric.php',
9494
'OCA\\Files_Sharing\\OrphanHelper' => $baseDir . '/../lib/OrphanHelper.php',
95+
'OCA\\Files_Sharing\\Repair\\CleanupShareTarget' => $baseDir . '/../lib/Repair/CleanupShareTarget.php',
9596
'OCA\\Files_Sharing\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
9697
'OCA\\Files_Sharing\\Scanner' => $baseDir . '/../lib/Scanner.php',
9798
'OCA\\Files_Sharing\\Settings\\Personal' => $baseDir . '/../lib/Settings/Personal.php',

apps/files_sharing/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ class ComposerStaticInitFiles_Sharing
107107
'OCA\\Files_Sharing\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php',
108108
'OCA\\Files_Sharing\\OpenMetrics\\SharesCountMetric' => __DIR__ . '/..' . '/../lib/OpenMetrics/SharesCountMetric.php',
109109
'OCA\\Files_Sharing\\OrphanHelper' => __DIR__ . '/..' . '/../lib/OrphanHelper.php',
110+
'OCA\\Files_Sharing\\Repair\\CleanupShareTarget' => __DIR__ . '/..' . '/../lib/Repair/CleanupShareTarget.php',
110111
'OCA\\Files_Sharing\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
111112
'OCA\\Files_Sharing\\Scanner' => __DIR__ . '/..' . '/../lib/Scanner.php',
112113
'OCA\\Files_Sharing\\Settings\\Personal' => __DIR__ . '/..' . '/../lib/Settings/Personal.php',
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\Files_Sharing\Repair;
10+
11+
use OC\Files\SetupManager;
12+
use OCA\Files_Sharing\ShareTargetValidator;
13+
use OCP\DB\QueryBuilder\IQueryBuilder;
14+
use OCP\Files\Config\ICachedMountInfo;
15+
use OCP\Files\Config\IUserMountCache;
16+
use OCP\IDBConnection;
17+
use OCP\IUserManager;
18+
use OCP\Migration\IOutput;
19+
use OCP\Migration\IRepairStep;
20+
use OCP\Share\IManager;
21+
use OCP\Share\IProviderFactory;
22+
use OCP\Share\IShare;
23+
24+
class CleanupShareTarget implements IRepairStep {
25+
/** we only care about shares with a user target,
26+
* since the underling group/deck/talk share doesn't get moved
27+
*/
28+
private const USER_SHARE_TYPES = [
29+
IShare::TYPE_USER,
30+
IShare::TYPE_USERGROUP,
31+
IShare::TYPE_DECK_USER,
32+
11 // TYPE_USERROOM
33+
];
34+
35+
public function __construct(
36+
private readonly IDBConnection $connection,
37+
private readonly IManager $shareManager,
38+
private readonly IProviderFactory $shareProviderFactory,
39+
private readonly ShareTargetValidator $shareTargetValidator,
40+
private readonly IUserManager $userManager,
41+
private readonly SetupManager $setupManager,
42+
private readonly IUserMountCache $userMountCache,
43+
) {
44+
}
45+
46+
#[\Override]
47+
public function getName() {
48+
return 'Cleanup share names with false conflicts';
49+
}
50+
51+
#[\Override]
52+
public function run(IOutput $output) {
53+
$count = $this->countProblemShares();
54+
if ($count === 0) {
55+
return;
56+
}
57+
$output->startProgress($count);
58+
59+
$lastUser = '';
60+
$userMounts = [];
61+
62+
foreach ($this->getProblemShares() as $shareInfo) {
63+
$recipient = $this->userManager->getExistingUser($shareInfo['share_with']);
64+
$share = $this->shareProviderFactory
65+
->getProviderForType((int)$shareInfo['share_type'])
66+
->getShareById($shareInfo['id'], $recipient->getUID());
67+
68+
// since we ordered the share by user, we can reuse the last data until we get to the next user
69+
if ($lastUser !== $recipient->getUID()) {
70+
$lastUser = $recipient->getUID();
71+
72+
$this->setupManager->tearDown();
73+
$this->setupManager->setupForUser($recipient);
74+
$mounts = $this->userMountCache->getMountsForUser($recipient);
75+
$mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $mounts);
76+
$userMounts = array_combine($mountPoints, $mounts);
77+
}
78+
79+
$oldTarget = $share->getTarget();
80+
$newTarget = $this->cleanTarget($oldTarget);
81+
$share->setTarget($newTarget);
82+
$this->shareManager->moveShare($share, $recipient->getUID());
83+
84+
$this->shareTargetValidator->verifyMountPoint(
85+
$recipient,
86+
$share,
87+
$userMounts,
88+
[$share],
89+
);
90+
91+
$oldMountPoint = "/{$recipient->getUID()}/files$oldTarget/";
92+
$newMountPoint = "/{$recipient->getUID()}/files$newTarget/";
93+
$userMounts[$newMountPoint] = $userMounts[$oldMountPoint];
94+
unset($userMounts[$oldMountPoint]);
95+
96+
$output->advance();
97+
}
98+
$output->finishProgress();
99+
$output->info("Fixed $count shares");
100+
}
101+
102+
private function countProblemShares(): int {
103+
$query = $this->connection->getQueryBuilder();
104+
$query->select($query->func()->count('id'))
105+
->from('share')
106+
->where($query->expr()->like('file_target', $query->createNamedParameter('% (_) (_)%')))
107+
->andWhere($query->expr()->in('share_type', $query->createNamedParameter(self::USER_SHARE_TYPES, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY));
108+
return (int)$query->executeQuery()->fetchOne();
109+
}
110+
111+
/**
112+
* @return \Traversable<array{id: string, share_type: string, share_with: string}>
113+
*/
114+
private function getProblemShares(): \Traversable {
115+
$query = $this->connection->getQueryBuilder();
116+
$query->select('id', 'share_type', 'share_with')
117+
->from('share')
118+
->where($query->expr()->like('file_target', $query->createNamedParameter('% (_) (_)%')))
119+
->andWhere($query->expr()->in('share_type', $query->createNamedParameter(self::USER_SHARE_TYPES, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY))
120+
->orderBy('share_with')
121+
->addOrderBy('id');
122+
$result = $query->executeQuery();
123+
/** @var \Traversable<array{id: string, share_type: string, share_with: string}> $rows */
124+
$rows = $result->iterateAssociative();
125+
return $rows;
126+
}
127+
128+
private function cleanTarget(string $target): string {
129+
return preg_replace('/( \([2-9]\)){2,}/', '', $target);
130+
}
131+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026
5+
* SPDX-License-Identifier: AGPL-3.0-only
6+
*/
7+
namespace OCA\Files_Sharing\Tests\Repair;
8+
9+
use OC\Migration\NullOutput;
10+
use OCA\Files_Sharing\Repair\CleanupShareTarget;
11+
use OCA\Files_Sharing\Tests\TestCase;
12+
use OCP\Files\NotFoundException;
13+
use OCP\Server;
14+
use OCP\Share\IShare;
15+
use PHPUnit\Framework\Attributes\Group;
16+
17+
/**
18+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
19+
* SPDX-License-Identifier: AGPL-3.0-or-later
20+
*/
21+
#[Group(name: 'DB')]
22+
class CleanupShareTargetTest extends TestCase {
23+
public const TEST_FOLDER_NAME = '/folder_share_api_test';
24+
25+
private CleanupShareTarget $cleanupShareTarget;
26+
27+
protected function setUp(): void {
28+
parent::setUp();
29+
$this->cleanupShareTarget = Server::get(CleanupShareTarget::class);
30+
}
31+
32+
private function createUserShare(string $by, string $target = self::TEST_FOLDER_NAME): IShare {
33+
$userFolder = $this->rootFolder->getUserFolder($by);
34+
35+
try {
36+
$node = $userFolder->get(self::TEST_FOLDER_NAME);
37+
} catch (NotFoundException $e) {
38+
$node = $userFolder->newFolder(self::TEST_FOLDER_NAME);
39+
}
40+
$share1 = $this->shareManager->newShare();
41+
$share1->setNode($node)
42+
->setSharedBy($by)
43+
->setSharedWith(self::TEST_FILES_SHARING_API_USER2)
44+
->setShareType(IShare::TYPE_USER)
45+
->setPermissions(31);
46+
$share = $this->shareManager->createShare($share1);
47+
$share->setStatus(IShare::STATUS_ACCEPTED);
48+
$this->shareManager->updateShare($share);
49+
50+
$share->setTarget($target);
51+
$this->shareManager->moveShare($share, self::TEST_FILES_SHARING_API_USER2);
52+
53+
$share = $this->shareManager->getShareById($share->getFullId());
54+
$this->assertEquals($target, $share->getTarget());
55+
56+
return $share;
57+
}
58+
59+
public function testBasicRepair() {
60+
$share = $this->createUserShare(self::TEST_FILES_SHARING_API_USER1, self::TEST_FOLDER_NAME . ' (2) (2) (2) (2)');
61+
62+
$this->cleanupShareTarget->run(new NullOutput());
63+
64+
$share = $this->shareManager->getShareById($share->getFullId());
65+
$this->assertEquals(self::TEST_FOLDER_NAME, $share->getTarget());
66+
}
67+
68+
public function testRepairConflictFile() {
69+
$share = $this->createUserShare(self::TEST_FILES_SHARING_API_USER1, self::TEST_FOLDER_NAME . ' (2) (2) (2) (2)');
70+
71+
$this->loginHelper(self::TEST_FILES_SHARING_API_USER2);
72+
$userFolder2 = $this->rootFolder->getUserFolder(self::TEST_FILES_SHARING_API_USER2);
73+
$folder = $userFolder2->newFolder(self::TEST_FOLDER_NAME);
74+
75+
$this->cleanupShareTarget->run(new NullOutput());
76+
$folder->delete();
77+
78+
$share = $this->shareManager->getShareById($share->getFullId());
79+
$this->assertEquals(self::TEST_FOLDER_NAME . ' (2)', $share->getTarget());
80+
}
81+
82+
public function testRepairConflictShare() {
83+
$share = $this->createUserShare(self::TEST_FILES_SHARING_API_USER1, self::TEST_FOLDER_NAME . ' (2) (2) (2) (2)');
84+
85+
$share2 = $this->createUserShare(self::TEST_FILES_SHARING_API_USER3);
86+
87+
$this->cleanupShareTarget->run(new NullOutput());
88+
89+
$share2 = $this->shareManager->getShareById($share2->getFullId());
90+
$this->assertEquals(self::TEST_FOLDER_NAME, $share2->getTarget());
91+
$share = $this->shareManager->getShareById($share->getFullId());
92+
$this->assertEquals(self::TEST_FOLDER_NAME . ' (2)', $share->getTarget());
93+
}
94+
95+
public function testRepairMultipleConflicting() {
96+
$share = $this->createUserShare(self::TEST_FILES_SHARING_API_USER1, self::TEST_FOLDER_NAME . ' (2) (2) (2) (2)');
97+
$share2 = $this->createUserShare(self::TEST_FILES_SHARING_API_USER3, self::TEST_FOLDER_NAME . ' (2) (2) (2) (2) (2)');
98+
99+
$this->cleanupShareTarget->run(new NullOutput());
100+
101+
$share = $this->shareManager->getShareById($share->getFullId());
102+
$share2 = $this->shareManager->getShareById($share2->getFullId());
103+
104+
// there is no guarantee for what order the 2 shares got repaired by
105+
$targets = [
106+
$share->getTarget(),
107+
$share2->getTarget(),
108+
];
109+
sort($targets);
110+
$this->assertEquals([
111+
self::TEST_FOLDER_NAME,
112+
self::TEST_FOLDER_NAME . ' (2)'
113+
], $targets);
114+
}
115+
}

lib/private/Repair.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
use OC\Template\JSCombiner;
6161
use OCA\DAV\Migration\DeleteSchedulingObjects;
6262
use OCA\DAV\Migration\RemoveObjectProperties;
63+
use OCA\Files_Sharing\Repair\CleanupShareTarget;
6364
use OCP\AppFramework\QueryException;
6465
use OCP\AppFramework\Utility\ITimeFactory;
6566
use OCP\Collaboration\Resources\IManager;
@@ -221,6 +222,7 @@ public static function getExpensiveRepairSteps() {
221222
),
222223
\OCP\Server::get(DeleteSchedulingObjects::class),
223224
\OC::$server->get(RemoveObjectProperties::class),
225+
\OCP\Server::get(CleanupShareTarget::class),
224226
];
225227
}
226228

0 commit comments

Comments
 (0)