mirror of
https://github.com/nextcloud/server.git
synced 2026-10-22 10:16:56 +02:00
The repair step "Cleanup share names with false conflicts" fatals with "Call to a member function getRootId() on null" when a problem share's old target has no entry in the recipient's cached mounts (e.g. the recipient has not logged in since the share was created). Guard the lookup the same way the generateUniqueTarget callback already does and skip the mount cache refresh in that case - the share row is already updated by moveShare() and the cache is rebuilt on the user's next login. Fixes #63494 Signed-off-by: Baki Burak Öğün <63836730+bakiburakogun@users.noreply.github.com>
215 lines
7.2 KiB
PHP
215 lines
7.2 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\Cache\CacheEntry;
|
|
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\IRepairStepExpensive;
|
|
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 IRepairStepExpensive {
|
|
/** we only care about shares with a user target,
|
|
* since the underling group/deck/talk share doesn't get moved
|
|
*/
|
|
private const array 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(),
|
|
fn ($path) => $userMounts[$path] ?? null,
|
|
);
|
|
$newTarget = $userFolder->getRelativePath($absoluteNewTarget);
|
|
|
|
if ($newTarget !== $oldTarget) {
|
|
$this->moveShare((string)$shareInfo['id'], $newTarget);
|
|
|
|
$oldMountPoint = "/{$recipient->getUID()}/files$oldTarget/";
|
|
$newMountPoint = "/{$recipient->getUID()}/files$newTarget/";
|
|
|
|
/** @var ICachedMountInfo|null $mount */
|
|
$mount = $userMounts[$oldMountPoint] ?? null;
|
|
if ($mount !== null) {
|
|
$userMounts[$newMountPoint] = $mount;
|
|
unset($userMounts[$oldMountPoint]);
|
|
|
|
$this->userMountCache->removeMount($oldMountPoint);
|
|
$this->userMountCache->addMount($recipient, $newMountPoint, new CacheEntry([
|
|
'fileid' => $mount->getRootId(),
|
|
'storage' => $mount->getStorageId(),
|
|
]), $mount->getMountProvider(), $mount->getMountId());
|
|
}
|
|
}
|
|
} 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;
|
|
}
|
|
}
|
|
}
|