nextcloud/apps/files_sharing/lib/Repair/CleanupShareTarget.php
Robin Appelman f216bf5798
fix: handle circle shares in share target repair
Signed-off-by: Robin Appelman <robin@icewind.nl>
2026-02-11 15:39:11 +01:00

200 lines
6.7 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_Sharing\Repair;
use OC\Files\SetupManager;
use OCA\Files_Sharing\ShareTargetValidator;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\Config\ICachedMountInfo;
use OCP\Files\Config\IUserMountCache;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\ICacheFactory;
use OCP\IDBConnection;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
use OCP\Share\IShare;
use Psr\Log\LoggerInterface;
/**
* @psalm-type ShareInfo = array{id: string|int, share_type: string, share_with: string, file_source: string, file_target: string}
*/
class CleanupShareTarget implements IRepairStep {
/** we only care about shares with a user target,
* since the underling group/deck/talk share doesn't get moved
*/
private const USER_SHARE_TYPES = [
IShare::TYPE_USER,
IShare::TYPE_USERGROUP,
IShare::TYPE_DECK_USER,
11 // TYPE_USERROOM
];
public function __construct(
private readonly IDBConnection $connection,
private readonly ShareTargetValidator $shareTargetValidator,
private readonly IUserManager $userManager,
private readonly SetupManager $setupManager,
private readonly IUserMountCache $userMountCache,
private readonly IRootFolder $rootFolder,
private readonly LoggerInterface $logger,
private readonly ICacheFactory $cacheFactory,
) {
}
#[\Override]
public function getName() {
return 'Cleanup share names with false conflicts';
}
#[\Override]
public function run(IOutput $output) {
$count = $this->countProblemShares();
if ($count === 0) {
return;
}
$output->startProgress($count);
$lastUser = '';
$userMounts = [];
$userFolder = null;
foreach ($this->getProblemShares() as $shareInfo) {
if ($shareInfo['share_type'] === IShare::TYPE_CIRCLE) {
$recipient = $this->getCircleShareOwner($shareInfo['share_with']);
if ($recipient === null) {
continue;
}
} else {
$recipient = $this->userManager->getExistingUser($shareInfo['share_with']);
}
if (!$recipient->isEnabled()) {
continue;
}
// since we ordered the share by user, we can reuse the last data until we get to the next user
if ($lastUser !== $recipient->getUID()) {
$lastUser = $recipient->getUID();
$this->setupManager->tearDown();
$this->setupManager->setupForUser($recipient);
$mounts = $this->userMountCache->getMountsForUser($recipient);
$mountPoints = array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $mounts);
$userMounts = array_combine($mountPoints, $mounts);
$userFolder = $this->rootFolder->getUserFolder($recipient->getUID());
}
$oldTarget = $shareInfo['file_target'];
$newTarget = $this->cleanTarget($oldTarget);
$absoluteNewTarget = $userFolder->getFullPath($newTarget);
try {
$targetParentNode = $this->rootFolder->get(dirname($absoluteNewTarget));
} catch (NotFoundException) {
$absoluteNewTarget = $userFolder->getFullPath(basename($newTarget));
$targetParentNode = $userFolder;
}
try {
$absoluteNewTarget = $this->shareTargetValidator->generateUniqueTarget(
(int)$shareInfo['file_source'],
$absoluteNewTarget,
$targetParentNode->getMountPoint(),
$userMounts,
);
$newTarget = $userFolder->getRelativePath($absoluteNewTarget);
$this->moveShare((string)$shareInfo['id'], $newTarget);
$oldMountPoint = "/{$recipient->getUID()}/files$oldTarget/";
$newMountPoint = "/{$recipient->getUID()}/files$newTarget/";
$userMounts[$newMountPoint] = $userMounts[$oldMountPoint];
unset($userMounts[$oldMountPoint]);
} catch (\Exception $e) {
$msg = 'error cleaning up share target: ' . $e->getMessage();
$this->logger->error($msg, ['exception' => $e, 'app' => 'files_sharing']);
$output->warning($msg);
}
$output->advance();
}
$this->cacheFactory->createDistributed('circles/')->clear();
$output->finishProgress();
$output->info("Fixed $count shares");
}
private function countProblemShares(): int {
$query = $this->connection->getQueryBuilder();
$query->select($query->func()->count('id'))
->from('share')
->where($query->expr()->like('file_target', $query->createNamedParameter('% (_) (_)%')))
->andWhere($query->expr()->in('share_type', $query->createNamedParameter(self::USER_SHARE_TYPES, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY));
return (int)$query->executeQuery()->fetchOne();
}
private function moveShare(string $id, string $target): void {
// since we only process user-specific shares, we can just move them
// without having to check if we need to create a user-specific override
$query = $this->connection->getQueryBuilder();
$query->update('share')
->set('file_target', $query->createNamedParameter($target))
->where($query->expr()->eq('id', $query->createNamedParameter($id)))
->executeStatement();
}
/**
* @return \Traversable<ShareInfo>
*/
private function getProblemShares(): \Traversable {
$query = $this->connection->getQueryBuilder();
$query->select('id', 'share_type', 'share_with', 'file_source', 'file_target')
->from('share')
->where($query->expr()->like('file_target', $query->createNamedParameter('% (_) (_)%')))
->andWhere($query->expr()->orX(
$query->expr()->in('share_type', $query->createNamedParameter(self::USER_SHARE_TYPES, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY),
$query->expr()->andX(
$query->expr()->eq('share_type', $query->createNamedParameter(
IShare::TYPE_CIRCLE, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT),
$query->expr()->isNotNull('parent'),
),
))
->orderBy('share_with')
->addOrderBy('id');
$result = $query->executeQuery();
/** @var \Traversable<ShareInfo> $rows */
$rows = $result->iterateAssociative();
return $rows;
}
private function cleanTarget(string $target): string {
return preg_replace('/( \([2-9]\)){2,}/', '', $target);
}
/**
* Get the user of a federated user from circles, if the circle id belong to a user
*/
private function getCircleShareOwner(string $circleId): ?IUser {
$query = $this->connection->getQueryBuilder();
$query->select('name')
->from('circles_circle')
->where($query->expr()->eq('unique_id', $query->createNamedParameter($circleId)))
->andWhere($query->expr()->eq('source', $query->createNamedParameter(1, IQueryBuilder::PARAM_INT)));
$name = $query->executeQuery()->fetchOne();
if ($name === false) {
return null;
}
[$type, $userId,] = explode(':', $name, 3);
if ($type === 'user') {
return $this->userManager->getExistingUser($userId);
} else {
return null;
}
}
}