diff options
Diffstat (limited to 'lib/private/Repair')
52 files changed, 3601 insertions, 0 deletions
diff --git a/lib/private/Repair/AddAppConfigLazyMigration.php b/lib/private/Repair/AddAppConfigLazyMigration.php new file mode 100644 index 00000000000..7ae83e87669 --- /dev/null +++ b/lib/private/Repair/AddAppConfigLazyMigration.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair; + +use OCP\IAppConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; + +class AddAppConfigLazyMigration implements IRepairStep { + /** + * Just add config values that needs to be migrated to lazy loading + */ + private static array $lazyAppConfig = [ + 'core' => [ + 'oc.integritycheck.checker', + ], + ]; + + public function __construct( + private IAppConfig $appConfig, + private LoggerInterface $logger, + ) { + } + + public function getName() { + return 'migrate lazy config values'; + } + + public function run(IOutput $output) { + $c = 0; + foreach (self::$lazyAppConfig as $appId => $configKeys) { + foreach ($configKeys as $configKey) { + $c += (int)$this->appConfig->updateLazy($appId, $configKey, true); + } + } + + $this->logger->notice('core/BackgroundJobs/AppConfigLazyMigration: ' . $c . ' config values updated'); + } +} diff --git a/lib/private/Repair/AddBruteForceCleanupJob.php b/lib/private/Repair/AddBruteForceCleanupJob.php new file mode 100644 index 00000000000..dd08e36a597 --- /dev/null +++ b/lib/private/Repair/AddBruteForceCleanupJob.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair; + +use OC\Security\Bruteforce\CleanupJob; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class AddBruteForceCleanupJob implements IRepairStep { + /** @var IJobList */ + protected $jobList; + + public function __construct(IJobList $jobList) { + $this->jobList = $jobList; + } + + public function getName() { + return 'Add job to cleanup the bruteforce entries'; + } + + public function run(IOutput $output) { + $this->jobList->add(CleanupJob::class); + } +} diff --git a/lib/private/Repair/AddCleanupDeletedUsersBackgroundJob.php b/lib/private/Repair/AddCleanupDeletedUsersBackgroundJob.php new file mode 100644 index 00000000000..9713d8595e7 --- /dev/null +++ b/lib/private/Repair/AddCleanupDeletedUsersBackgroundJob.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair; + +use OC\User\BackgroundJobs\CleanupDeletedUsers; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class AddCleanupDeletedUsersBackgroundJob implements IRepairStep { + private IJobList $jobList; + + public function __construct(IJobList $jobList) { + $this->jobList = $jobList; + } + + public function getName(): string { + return 'Add cleanup-deleted-users background job'; + } + + public function run(IOutput $output) { + $this->jobList->add(CleanupDeletedUsers::class); + } +} diff --git a/lib/private/Repair/AddCleanupUpdaterBackupsJob.php b/lib/private/Repair/AddCleanupUpdaterBackupsJob.php new file mode 100644 index 00000000000..e631a3303f1 --- /dev/null +++ b/lib/private/Repair/AddCleanupUpdaterBackupsJob.php @@ -0,0 +1,29 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair; + +use OC\Core\BackgroundJobs\BackgroundCleanupUpdaterBackupsJob; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class AddCleanupUpdaterBackupsJob implements IRepairStep { + /** @var IJobList */ + protected $jobList; + + public function __construct(IJobList $jobList) { + $this->jobList = $jobList; + } + + public function getName() { + return 'Queue a one-time job to cleanup old backups of the updater'; + } + + public function run(IOutput $output) { + $this->jobList->add(BackgroundCleanupUpdaterBackupsJob::class); + } +} diff --git a/lib/private/Repair/AddMetadataGenerationJob.php b/lib/private/Repair/AddMetadataGenerationJob.php new file mode 100644 index 00000000000..76c60f303a7 --- /dev/null +++ b/lib/private/Repair/AddMetadataGenerationJob.php @@ -0,0 +1,27 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair; + +use OC\Core\BackgroundJobs\GenerateMetadataJob; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class AddMetadataGenerationJob implements IRepairStep { + public function __construct( + private IJobList $jobList, + ) { + } + + public function getName() { + return 'Queue a job to generate metadata'; + } + + public function run(IOutput $output) { + $this->jobList->add(GenerateMetadataJob::class); + } +} diff --git a/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php b/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php new file mode 100644 index 00000000000..4ad320a0311 --- /dev/null +++ b/lib/private/Repair/AddRemoveOldTasksBackgroundJob.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair; + +use OC\TaskProcessing\RemoveOldTasksBackgroundJob; +use OC\TextProcessing\RemoveOldTasksBackgroundJob as RemoveOldTextProcessingTasksBackgroundJob; +use OC\TextToImage\RemoveOldTasksBackgroundJob as RemoveOldTextToImageTasksBackgroundJob; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class AddRemoveOldTasksBackgroundJob implements IRepairStep { + private IJobList $jobList; + + public function __construct(IJobList $jobList) { + $this->jobList = $jobList; + } + + public function getName(): string { + return 'Add AI tasks cleanup jobs'; + } + + public function run(IOutput $output) { + $this->jobList->add(RemoveOldTextProcessingTasksBackgroundJob::class); + $this->jobList->add(RemoveOldTextToImageTasksBackgroundJob::class); + $this->jobList->add(RemoveOldTasksBackgroundJob::class); + } +} diff --git a/lib/private/Repair/CleanTags.php b/lib/private/Repair/CleanTags.php new file mode 100644 index 00000000000..ad8fa6235e6 --- /dev/null +++ b/lib/private/Repair/CleanTags.php @@ -0,0 +1,186 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Repair; + +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\IUserManager; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +/** + * Class RepairConfig + * + * @package OC\Repair + */ +class CleanTags implements IRepairStep { + + protected $deletedTags = 0; + + /** + * @param IDBConnection $connection + * @param IUserManager $userManager + */ + public function __construct( + protected IDBConnection $connection, + protected IUserManager $userManager, + ) { + } + + /** + * @return string + */ + public function getName() { + return 'Clean tags and favorites'; + } + + /** + * Updates the configuration after running an update + */ + public function run(IOutput $output) { + $this->deleteOrphanTags($output); + $this->deleteOrphanFileEntries($output); + $this->deleteOrphanTagEntries($output); + $this->deleteOrphanCategoryEntries($output); + } + + /** + * Delete tags for deleted users + */ + protected function deleteOrphanTags(IOutput $output) { + $offset = 0; + while ($this->checkTags($offset)) { + $offset += 50; + } + + $output->info(sprintf('%d tags of deleted users have been removed.', $this->deletedTags)); + } + + protected function checkTags($offset) { + $query = $this->connection->getQueryBuilder(); + $query->select('uid') + ->from('vcategory') + ->groupBy('uid') + ->orderBy('uid') + ->setMaxResults(50) + ->setFirstResult($offset); + $result = $query->executeQuery(); + + $users = []; + $hadResults = false; + while ($row = $result->fetch()) { + $hadResults = true; + if (!$this->userManager->userExists($row['uid'])) { + $users[] = $row['uid']; + } + } + $result->closeCursor(); + + if (!$hadResults) { + // No more tags, stop looping + return false; + } + + if (!empty($users)) { + $query = $this->connection->getQueryBuilder(); + $query->delete('vcategory') + ->where($query->expr()->in('uid', $query->createNamedParameter($users, IQueryBuilder::PARAM_STR_ARRAY))); + $this->deletedTags += $query->executeStatement(); + } + return true; + } + + /** + * Delete tag entries for deleted files + */ + protected function deleteOrphanFileEntries(IOutput $output) { + $this->deleteOrphanEntries( + $output, + '%d tags for delete files have been removed.', + 'vcategory_to_object', 'objid', + 'filecache', 'fileid', 'fileid' + ); + } + + /** + * Delete tag entries for deleted tags + */ + protected function deleteOrphanTagEntries(IOutput $output) { + $this->deleteOrphanEntries( + $output, + '%d tag entries for deleted tags have been removed.', + 'vcategory_to_object', 'categoryid', + 'vcategory', 'id', 'uid' + ); + } + + /** + * Delete tags that have no entries + */ + protected function deleteOrphanCategoryEntries(IOutput $output) { + $this->deleteOrphanEntries( + $output, + '%d tags with no entries have been removed.', + 'vcategory', 'id', + 'vcategory_to_object', 'categoryid', 'type' + ); + } + + /** + * Deletes all entries from $deleteTable that do not have a matching entry in $sourceTable + * + * A query joins $deleteTable.$deleteId = $sourceTable.$sourceId and checks + * whether $sourceNullColumn is null. If it is null, the entry in $deleteTable + * is being deleted. + * + * @param string $repairInfo + * @param string $deleteTable + * @param string $deleteId + * @param string $sourceTable + * @param string $sourceId + * @param string $sourceNullColumn If this column is null in the source table, + * the entry is deleted in the $deleteTable + */ + protected function deleteOrphanEntries(IOutput $output, $repairInfo, $deleteTable, $deleteId, $sourceTable, $sourceId, $sourceNullColumn) { + $qb = $this->connection->getQueryBuilder(); + + $qb->select('d.' . $deleteId) + ->from($deleteTable, 'd') + ->leftJoin('d', $sourceTable, 's', $qb->expr()->eq('d.' . $deleteId, 's.' . $sourceId)) + ->where( + $qb->expr()->eq('d.type', $qb->expr()->literal('files')) + ) + ->andWhere( + $qb->expr()->isNull('s.' . $sourceNullColumn) + ); + $result = $qb->executeQuery(); + + $orphanItems = []; + while ($row = $result->fetch()) { + $orphanItems[] = (int)$row[$deleteId]; + } + + $deleteQuery = $this->connection->getQueryBuilder(); + $deleteQuery->delete($deleteTable) + ->where( + $deleteQuery->expr()->eq('type', $deleteQuery->expr()->literal('files')) + ) + ->andWhere($deleteQuery->expr()->in($deleteId, $deleteQuery->createParameter('ids'))); + if (!empty($orphanItems)) { + $orphanItemsBatch = array_chunk($orphanItems, 200); + foreach ($orphanItemsBatch as $items) { + $deleteQuery->setParameter('ids', $items, IQueryBuilder::PARAM_INT_ARRAY); + $deleteQuery->executeStatement(); + } + } + + if ($repairInfo) { + $output->info(sprintf($repairInfo, count($orphanItems))); + } + } +} diff --git a/lib/private/Repair/CleanUpAbandonedApps.php b/lib/private/Repair/CleanUpAbandonedApps.php new file mode 100644 index 00000000000..718f625be86 --- /dev/null +++ b/lib/private/Repair/CleanUpAbandonedApps.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair; + +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class CleanUpAbandonedApps implements IRepairStep { + protected const ABANDONED_APPS = ['accessibility', 'files_videoplayer']; + private IConfig $config; + + public function __construct(IConfig $config) { + $this->config = $config; + } + + public function getName(): string { + return 'Clean up abandoned apps'; + } + + public function run(IOutput $output): void { + foreach (self::ABANDONED_APPS as $app) { + // only remove global app values + // user prefs of accessibility are dealt with in Theming migration + // videoplayer did not have user prefs + $this->config->deleteAppValues($app); + } + } +} diff --git a/lib/private/Repair/ClearFrontendCaches.php b/lib/private/Repair/ClearFrontendCaches.php new file mode 100644 index 00000000000..5c57a63379d --- /dev/null +++ b/lib/private/Repair/ClearFrontendCaches.php @@ -0,0 +1,43 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair; + +use OC\Template\JSCombiner; +use OCP\ICacheFactory; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class ClearFrontendCaches implements IRepairStep { + /** @var ICacheFactory */ + protected $cacheFactory; + + /** @var JSCombiner */ + protected $jsCombiner; + + public function __construct(ICacheFactory $cacheFactory, + JSCombiner $JSCombiner) { + $this->cacheFactory = $cacheFactory; + $this->jsCombiner = $JSCombiner; + } + + public function getName() { + return 'Clear frontend caches'; + } + + public function run(IOutput $output) { + try { + $c = $this->cacheFactory->createDistributed('imagePath'); + $c->clear(); + $output->info('Image cache cleared'); + + $this->jsCombiner->resetCache(); + $output->info('JS cache cleared'); + } catch (\Exception $e) { + $output->warning('Unable to clear the frontend cache'); + } + } +} diff --git a/lib/private/Repair/ClearGeneratedAvatarCache.php b/lib/private/Repair/ClearGeneratedAvatarCache.php new file mode 100644 index 00000000000..0f743afbb4c --- /dev/null +++ b/lib/private/Repair/ClearGeneratedAvatarCache.php @@ -0,0 +1,51 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair; + +use OC\Avatar\AvatarManager; +use OCP\BackgroundJob\IJobList; +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class ClearGeneratedAvatarCache implements IRepairStep { + protected AvatarManager $avatarManager; + private IConfig $config; + private IJobList $jobList; + + public function __construct(IConfig $config, AvatarManager $avatarManager, IJobList $jobList) { + $this->config = $config; + $this->avatarManager = $avatarManager; + $this->jobList = $jobList; + } + + public function getName(): string { + return 'Clear every generated avatar'; + } + + /** + * Check if this repair step should run + */ + private function shouldRun(): bool { + $versionFromBeforeUpdate = $this->config->getSystemValueString('version', '0.0.0.0'); + + // This job only runs if the server was on a version lower than or equal to 27.0.0 before the upgrade. + // To clear the avatar cache again, bump the version to the currently released version (and change the operator to <= if it's not the master branch) and wait for the next release. + return version_compare($versionFromBeforeUpdate, '27.0.0', '<'); + } + + public function run(IOutput $output): void { + if ($this->shouldRun()) { + try { + $this->jobList->add(ClearGeneratedAvatarCacheJob::class, []); + $output->info('Avatar cache clearing job added'); + } catch (\Exception $e) { + $output->warning('Unable to clear the avatar cache'); + } + } + } +} diff --git a/lib/private/Repair/ClearGeneratedAvatarCacheJob.php b/lib/private/Repair/ClearGeneratedAvatarCacheJob.php new file mode 100644 index 00000000000..524a470e62a --- /dev/null +++ b/lib/private/Repair/ClearGeneratedAvatarCacheJob.php @@ -0,0 +1,24 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair; + +use OC\Avatar\AvatarManager; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\QueuedJob; + +class ClearGeneratedAvatarCacheJob extends QueuedJob { + protected AvatarManager $avatarManager; + + public function __construct(ITimeFactory $timeFactory, AvatarManager $avatarManager) { + parent::__construct($timeFactory); + $this->avatarManager = $avatarManager; + } + + public function run($argument) { + $this->avatarManager->clearCachedAvatars(); + } +} diff --git a/lib/private/Repair/Collation.php b/lib/private/Repair/Collation.php new file mode 100644 index 00000000000..43229792217 --- /dev/null +++ b/lib/private/Repair/Collation.php @@ -0,0 +1,129 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Repair; + +use Doctrine\DBAL\Exception\DriverException; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; + +class Collation implements IRepairStep { + /** @var IConfig */ + protected $config; + + protected LoggerInterface $logger; + + /** @var IDBConnection */ + protected $connection; + + /** @var bool */ + protected $ignoreFailures; + + /** + * @param bool $ignoreFailures + */ + public function __construct( + IConfig $config, + LoggerInterface $logger, + IDBConnection $connection, + $ignoreFailures, + ) { + $this->connection = $connection; + $this->config = $config; + $this->logger = $logger; + $this->ignoreFailures = $ignoreFailures; + } + + public function getName() { + return 'Repair MySQL collation'; + } + + /** + * Fix mime types + */ + public function run(IOutput $output) { + if ($this->connection->getDatabaseProvider() !== IDBConnection::PLATFORM_MYSQL) { + $output->info('Not a mysql database -> nothing to do'); + return; + } + + $characterSet = $this->config->getSystemValueBool('mysql.utf8mb4', false) ? 'utf8mb4' : 'utf8'; + + $tables = $this->getAllNonUTF8BinTables($this->connection); + foreach ($tables as $table) { + $output->info("Change row format for $table ..."); + $query = $this->connection->prepare('ALTER TABLE `' . $table . '` ROW_FORMAT = DYNAMIC;'); + try { + $query->execute(); + } catch (DriverException $e) { + // Just log this + $this->logger->error($e->getMessage(), ['exception' => $e]); + if (!$this->ignoreFailures) { + throw $e; + } + } + + $output->info("Change collation for $table ..."); + $query = $this->connection->prepare('ALTER TABLE `' . $table . '` CONVERT TO CHARACTER SET ' . $characterSet . ' COLLATE ' . $characterSet . '_bin;'); + try { + $query->execute(); + } catch (DriverException $e) { + // Just log this + $this->logger->error($e->getMessage(), ['exception' => $e]); + if (!$this->ignoreFailures) { + throw $e; + } + } + } + if (empty($tables)) { + $output->info('All tables already have the correct collation -> nothing to do'); + } + } + + /** + * @param IDBConnection $connection + * @return string[] + */ + protected function getAllNonUTF8BinTables(IDBConnection $connection) { + $dbName = $this->config->getSystemValueString('dbname'); + $characterSet = $this->config->getSystemValueBool('mysql.utf8mb4', false) ? 'utf8mb4' : 'utf8'; + + // fetch tables by columns + $statement = $connection->executeQuery( + 'SELECT DISTINCT(TABLE_NAME) AS `table`' + . ' FROM INFORMATION_SCHEMA . COLUMNS' + . ' WHERE TABLE_SCHEMA = ?' + . " AND (COLLATION_NAME <> '" . $characterSet . "_bin' OR CHARACTER_SET_NAME <> '" . $characterSet . "')" + . " AND TABLE_NAME LIKE '*PREFIX*%'", + [$dbName] + ); + $rows = $statement->fetchAll(); + $result = []; + foreach ($rows as $row) { + $result[$row['table']] = true; + } + + // fetch tables by collation + $statement = $connection->executeQuery( + 'SELECT DISTINCT(TABLE_NAME) AS `table`' + . ' FROM INFORMATION_SCHEMA . TABLES' + . ' WHERE TABLE_SCHEMA = ?' + . " AND TABLE_COLLATION <> '" . $characterSet . "_bin'" + . " AND TABLE_NAME LIKE '*PREFIX*%'", + [$dbName] + ); + $rows = $statement->fetchAll(); + foreach ($rows as $row) { + $result[$row['table']] = true; + } + + return array_keys($result); + } +} diff --git a/lib/private/Repair/ConfigKeyMigration.php b/lib/private/Repair/ConfigKeyMigration.php new file mode 100644 index 00000000000..da4aa153dc5 --- /dev/null +++ b/lib/private/Repair/ConfigKeyMigration.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Repair; + +use OC\Config\ConfigManager; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class ConfigKeyMigration implements IRepairStep { + public function __construct( + private ConfigManager $configManager, + ) { + } + + public function getName(): string { + return 'Migrate config keys'; + } + + public function run(IOutput $output) { + $this->configManager->migrateConfigLexiconKeys(); + } +} diff --git a/lib/private/Repair/Events/RepairAdvanceEvent.php b/lib/private/Repair/Events/RepairAdvanceEvent.php new file mode 100644 index 00000000000..476db9e4702 --- /dev/null +++ b/lib/private/Repair/Events/RepairAdvanceEvent.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Repair\Events; + +use OCP\EventDispatcher\Event; + +class RepairAdvanceEvent extends Event { + private int $increment; + private string $description; + + public function __construct( + int $increment, + string $description, + ) { + $this->increment = $increment; + $this->description = $description; + } + + public function getIncrement(): int { + return $this->increment; + } + + public function getDescription(): string { + return $this->description; + } +} diff --git a/lib/private/Repair/Events/RepairErrorEvent.php b/lib/private/Repair/Events/RepairErrorEvent.php new file mode 100644 index 00000000000..e5be8a5a031 --- /dev/null +++ b/lib/private/Repair/Events/RepairErrorEvent.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Repair\Events; + +use OCP\EventDispatcher\Event; + +class RepairErrorEvent extends Event { + private string $message; + + public function __construct( + string $message, + ) { + $this->message = $message; + } + + public function getMessage(): string { + return $this->message; + } +} diff --git a/lib/private/Repair/Events/RepairFinishEvent.php b/lib/private/Repair/Events/RepairFinishEvent.php new file mode 100644 index 00000000000..767a7506e6f --- /dev/null +++ b/lib/private/Repair/Events/RepairFinishEvent.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Repair\Events; + +use OCP\EventDispatcher\Event; + +class RepairFinishEvent extends Event { +} diff --git a/lib/private/Repair/Events/RepairInfoEvent.php b/lib/private/Repair/Events/RepairInfoEvent.php new file mode 100644 index 00000000000..ce8eb2f99e6 --- /dev/null +++ b/lib/private/Repair/Events/RepairInfoEvent.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Repair\Events; + +use OCP\EventDispatcher\Event; + +class RepairInfoEvent extends Event { + private string $message; + + public function __construct( + string $message, + ) { + $this->message = $message; + } + + public function getMessage(): string { + return $this->message; + } +} diff --git a/lib/private/Repair/Events/RepairStartEvent.php b/lib/private/Repair/Events/RepairStartEvent.php new file mode 100644 index 00000000000..47e713d57d9 --- /dev/null +++ b/lib/private/Repair/Events/RepairStartEvent.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Repair\Events; + +use OCP\EventDispatcher\Event; + +class RepairStartEvent extends Event { + private int $max; + private string $current; + + public function __construct( + int $max, + string $current, + ) { + $this->max = $max; + $this->current = $current; + } + + public function getMaxStep(): int { + return $this->max; + } + + public function getCurrentStepName(): string { + return $this->current; + } +} diff --git a/lib/private/Repair/Events/RepairStepEvent.php b/lib/private/Repair/Events/RepairStepEvent.php new file mode 100644 index 00000000000..27e1efbdb08 --- /dev/null +++ b/lib/private/Repair/Events/RepairStepEvent.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Repair\Events; + +use OCP\EventDispatcher\Event; + +class RepairStepEvent extends Event { + private string $stepName; + + public function __construct( + string $stepName, + ) { + $this->stepName = $stepName; + } + + public function getStepName(): string { + return $this->stepName; + } +} diff --git a/lib/private/Repair/Events/RepairWarningEvent.php b/lib/private/Repair/Events/RepairWarningEvent.php new file mode 100644 index 00000000000..6893a7212ec --- /dev/null +++ b/lib/private/Repair/Events/RepairWarningEvent.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Repair\Events; + +use OCP\EventDispatcher\Event; + +class RepairWarningEvent extends Event { + private string $message; + + public function __construct( + string $message, + ) { + $this->message = $message; + } + + public function getMessage(): string { + return $this->message; + } +} diff --git a/lib/private/Repair/MoveUpdaterStepFile.php b/lib/private/Repair/MoveUpdaterStepFile.php new file mode 100644 index 00000000000..bb8f9d3acfc --- /dev/null +++ b/lib/private/Repair/MoveUpdaterStepFile.php @@ -0,0 +1,61 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair; + +use OCP\Files; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class MoveUpdaterStepFile implements IRepairStep { + /** @var \OCP\IConfig */ + protected $config; + + /** + * @param \OCP\IConfig $config + */ + public function __construct($config) { + $this->config = $config; + } + + public function getName() { + return 'Move .step file of updater to backup location'; + } + + public function run(IOutput $output) { + $updateDir = $this->config->getSystemValue('updatedirectory', null) ?? $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data'); + $instanceId = $this->config->getSystemValueString('instanceid'); + + if (empty($instanceId)) { + return; + } + + $updaterFolderPath = $updateDir . '/updater-' . $instanceId; + $stepFile = $updaterFolderPath . '/.step'; + if (file_exists($stepFile)) { + $output->info('.step file exists'); + + $previousStepFile = $updaterFolderPath . '/.step-previous-update'; + + // cleanup + if (file_exists($previousStepFile)) { + if (Files::rmdirr($previousStepFile)) { + $output->info('.step-previous-update removed'); + } else { + $output->info('.step-previous-update can\'t be removed - abort move of .step file'); + return; + } + } + + // move step file + if (rename($stepFile, $previousStepFile)) { + $output->info('.step file moved to .step-previous-update'); + } else { + $output->warning('.step file can\'t be moved'); + } + } + } +} diff --git a/lib/private/Repair/NC13/AddLogRotateJob.php b/lib/private/Repair/NC13/AddLogRotateJob.php new file mode 100644 index 00000000000..bd6c510785f --- /dev/null +++ b/lib/private/Repair/NC13/AddLogRotateJob.php @@ -0,0 +1,29 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\NC13; + +use OC\Log\Rotate; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class AddLogRotateJob implements IRepairStep { + /** @var IJobList */ + private $jobList; + + public function __construct(IJobList $jobList) { + $this->jobList = $jobList; + } + + public function getName() { + return 'Add log rotate job'; + } + + public function run(IOutput $output) { + $this->jobList->add(Rotate::class); + } +} diff --git a/lib/private/Repair/NC14/AddPreviewBackgroundCleanupJob.php b/lib/private/Repair/NC14/AddPreviewBackgroundCleanupJob.php new file mode 100644 index 00000000000..417bc5e6adc --- /dev/null +++ b/lib/private/Repair/NC14/AddPreviewBackgroundCleanupJob.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\NC14; + +use OC\Preview\BackgroundCleanupJob; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class AddPreviewBackgroundCleanupJob implements IRepairStep { + /** @var IJobList */ + private $jobList; + + public function __construct(IJobList $jobList) { + $this->jobList = $jobList; + } + + public function getName(): string { + return 'Add preview background cleanup job'; + } + + public function run(IOutput $output) { + $this->jobList->add(BackgroundCleanupJob::class); + } +} diff --git a/lib/private/Repair/NC16/AddClenupLoginFlowV2BackgroundJob.php b/lib/private/Repair/NC16/AddClenupLoginFlowV2BackgroundJob.php new file mode 100644 index 00000000000..ab5f93415fc --- /dev/null +++ b/lib/private/Repair/NC16/AddClenupLoginFlowV2BackgroundJob.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\NC16; + +use OC\Core\BackgroundJobs\CleanupLoginFlowV2; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class AddClenupLoginFlowV2BackgroundJob implements IRepairStep { + /** @var IJobList */ + private $jobList; + + public function __construct(IJobList $jobList) { + $this->jobList = $jobList; + } + + public function getName(): string { + return 'Add background job to cleanup login flow v2 tokens'; + } + + public function run(IOutput $output) { + $this->jobList->add(CleanupLoginFlowV2::class); + } +} diff --git a/lib/private/Repair/NC16/CleanupCardDAVPhotoCache.php b/lib/private/Repair/NC16/CleanupCardDAVPhotoCache.php new file mode 100644 index 00000000000..646dd2c5e83 --- /dev/null +++ b/lib/private/Repair/NC16/CleanupCardDAVPhotoCache.php @@ -0,0 +1,88 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Repair\NC16; + +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\NotFoundException; +use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Class CleanupCardDAVPhotoCache + * + * This repair step removes "photo." files created by photocache + * + * Before https://github.com/nextcloud/server/pull/13843 a "photo." file could be created + * for unsupported image formats by photocache. Because a file is present but not jpg, png or gif no + * photo could be returned for this vcard. These invalid files are removed by this migration step. + */ +class CleanupCardDAVPhotoCache implements IRepairStep { + public function __construct( + private IConfig $config, + private IAppDataFactory $appDataFactory, + private LoggerInterface $logger, + ) { + } + + public function getName(): string { + return 'Cleanup invalid photocache files for carddav'; + } + + private function repair(IOutput $output): void { + $photoCacheAppData = $this->appDataFactory->get('dav-photocache'); + + try { + $folders = $photoCacheAppData->getDirectoryListing(); + } catch (NotFoundException $e) { + return; + } catch (RuntimeException $e) { + $this->logger->error('Failed to fetch directory listing in CleanupCardDAVPhotoCache', ['exception' => $e]); + return; + } + + $folders = array_filter($folders, function (ISimpleFolder $folder) { + return $folder->fileExists('photo.'); + }); + + if (empty($folders)) { + return; + } + + $output->info('Delete ' . count($folders) . ' "photo." files'); + + foreach ($folders as $folder) { + try { + /** @var ISimpleFolder $folder */ + $folder->getFile('photo.')->delete(); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + $output->warning('Could not delete file "dav-photocache/' . $folder->getName() . '/photo."'); + } + } + } + + private function shouldRun(): bool { + return version_compare( + $this->config->getSystemValueString('version', '0.0.0.0'), + '16.0.0.0', + '<=' + ); + } + + public function run(IOutput $output): void { + if ($this->shouldRun()) { + $this->repair($output); + } + } +} diff --git a/lib/private/Repair/NC16/ClearCollectionsAccessCache.php b/lib/private/Repair/NC16/ClearCollectionsAccessCache.php new file mode 100644 index 00000000000..1627ed40b98 --- /dev/null +++ b/lib/private/Repair/NC16/ClearCollectionsAccessCache.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\NC16; + +use OC\Collaboration\Resources\Manager; +use OCP\Collaboration\Resources\IManager; +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class ClearCollectionsAccessCache implements IRepairStep { + /** @var IConfig */ + private $config; + + /** @var Manager */ + private $manager; + + public function __construct(IConfig $config, IManager $manager) { + $this->config = $config; + $this->manager = $manager; + } + + public function getName(): string { + return 'Clear access cache of projects'; + } + + private function shouldRun(): bool { + $versionFromBeforeUpdate = $this->config->getSystemValueString('version', '0.0.0.0'); + return version_compare($versionFromBeforeUpdate, '17.0.0.3', '<='); + } + + public function run(IOutput $output): void { + if ($this->shouldRun()) { + $this->manager->invalidateAccessCacheForAllCollections(); + } + } +} diff --git a/lib/private/Repair/NC18/ResetGeneratedAvatarFlag.php b/lib/private/Repair/NC18/ResetGeneratedAvatarFlag.php new file mode 100644 index 00000000000..b0dfec295e7 --- /dev/null +++ b/lib/private/Repair/NC18/ResetGeneratedAvatarFlag.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\NC18; + +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class ResetGeneratedAvatarFlag implements IRepairStep { + /** @var IConfig */ + private $config; + /** @var IDBConnection */ + private $connection; + + public function __construct(IConfig $config, + IDBConnection $connection) { + $this->config = $config; + $this->connection = $connection; + } + + public function getName(): string { + return 'Reset generated avatar flag'; + } + + private function shouldRun(): bool { + $versionFromBeforeUpdate = $this->config->getSystemValueString('version', '0.0.0.0'); + return version_compare($versionFromBeforeUpdate, '18.0.0.5', '<='); + } + + public function run(IOutput $output): void { + if ($this->shouldRun()) { + $query = $this->connection->getQueryBuilder(); + $query->delete('preferences') + ->where($query->expr()->eq('appid', $query->createNamedParameter('avatar'))) + ->andWhere($query->expr()->eq('configkey', $query->createNamedParameter('generated'))); + } + } +} diff --git a/lib/private/Repair/NC20/EncryptionLegacyCipher.php b/lib/private/Repair/NC20/EncryptionLegacyCipher.php new file mode 100644 index 00000000000..89473ffd5e8 --- /dev/null +++ b/lib/private/Repair/NC20/EncryptionLegacyCipher.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\NC20; + +use OCP\Encryption\IManager; +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class EncryptionLegacyCipher implements IRepairStep { + /** @var IConfig */ + private $config; + /** @var IManager */ + private $manager; + + public function __construct(IConfig $config, + IManager $manager) { + $this->config = $config; + $this->manager = $manager; + } + + public function getName(): string { + return 'Keep legacy encryption enabled'; + } + + private function shouldRun(): bool { + $versionFromBeforeUpdate = $this->config->getSystemValueString('version', '0.0.0.0'); + return version_compare($versionFromBeforeUpdate, '20.0.0.0', '<='); + } + + public function run(IOutput $output): void { + if (!$this->shouldRun()) { + return; + } + + $masterKeyId = $this->config->getAppValue('encryption', 'masterKeyId'); + if ($this->manager->isEnabled() || !empty($masterKeyId)) { + if ($this->config->getSystemValue('encryption.legacy_format_support', '') === '') { + $this->config->setSystemValue('encryption.legacy_format_support', true); + } + } + } +} diff --git a/lib/private/Repair/NC20/EncryptionMigration.php b/lib/private/Repair/NC20/EncryptionMigration.php new file mode 100644 index 00000000000..6de143747b3 --- /dev/null +++ b/lib/private/Repair/NC20/EncryptionMigration.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\NC20; + +use OCP\Encryption\IManager; +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class EncryptionMigration implements IRepairStep { + /** @var IConfig */ + private $config; + /** @var IManager */ + private $manager; + + public function __construct(IConfig $config, + IManager $manager) { + $this->config = $config; + $this->manager = $manager; + } + + public function getName(): string { + return 'Check encryption key format'; + } + + private function shouldRun(): bool { + $versionFromBeforeUpdate = $this->config->getSystemValueString('version', '0.0.0.0'); + return version_compare($versionFromBeforeUpdate, '20.0.0.1', '<='); + } + + public function run(IOutput $output): void { + if (!$this->shouldRun()) { + return; + } + + $masterKeyId = $this->config->getAppValue('encryption', 'masterKeyId'); + if ($this->manager->isEnabled() || !empty($masterKeyId)) { + if ($this->config->getSystemValue('encryption.key_storage_migrated', '') === '') { + $this->config->setSystemValue('encryption.key_storage_migrated', false); + } + } + } +} diff --git a/lib/private/Repair/NC20/ShippedDashboardEnable.php b/lib/private/Repair/NC20/ShippedDashboardEnable.php new file mode 100644 index 00000000000..955011a8c84 --- /dev/null +++ b/lib/private/Repair/NC20/ShippedDashboardEnable.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\NC20; + +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class ShippedDashboardEnable implements IRepairStep { + /** @var IConfig */ + private $config; + + public function __construct(IConfig $config) { + $this->config = $config; + } + + public function getName() { + return 'Remove old dashboard app config data'; + } + + public function run(IOutput $output) { + $version = $this->config->getAppValue('dashboard', 'version', '7.0.0'); + if (version_compare($version, '7.0.0', '<')) { + $this->config->deleteAppValues('dashboard'); + $output->info('Removed old dashboard app config'); + } + } +} diff --git a/lib/private/Repair/NC21/AddCheckForUserCertificatesJob.php b/lib/private/Repair/NC21/AddCheckForUserCertificatesJob.php new file mode 100644 index 00000000000..5cee33b381c --- /dev/null +++ b/lib/private/Repair/NC21/AddCheckForUserCertificatesJob.php @@ -0,0 +1,43 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\NC21; + +use OC\Core\BackgroundJobs\CheckForUserCertificates; +use OCP\BackgroundJob\IJobList; +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class AddCheckForUserCertificatesJob implements IRepairStep { + /** @var IJobList */ + protected $jobList; + /** @var IConfig */ + private $config; + + public function __construct(IConfig $config, IJobList $jobList) { + $this->jobList = $jobList; + $this->config = $config; + } + + public function getName() { + return 'Queue a one-time job to check for user uploaded certificates'; + } + + private function shouldRun() { + $versionFromBeforeUpdate = $this->config->getSystemValueString('version', '0.0.0.0'); + + // was added to 21.0.0.2 + return version_compare($versionFromBeforeUpdate, '21.0.0.2', '<'); + } + + public function run(IOutput $output) { + if ($this->shouldRun()) { + $this->config->setAppValue('files_external', 'user_certificate_scan', 'not-run-yet'); + $this->jobList->add(CheckForUserCertificates::class); + } + } +} diff --git a/lib/private/Repair/NC22/LookupServerSendCheck.php b/lib/private/Repair/NC22/LookupServerSendCheck.php new file mode 100644 index 00000000000..540dc2a730d --- /dev/null +++ b/lib/private/Repair/NC22/LookupServerSendCheck.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\NC22; + +use OC\Core\BackgroundJobs\LookupServerSendCheckBackgroundJob; +use OCP\BackgroundJob\IJobList; +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class LookupServerSendCheck implements IRepairStep { + private IJobList $jobList; + private IConfig $config; + + public function __construct(IJobList $jobList, IConfig $config) { + $this->jobList = $jobList; + $this->config = $config; + } + + public function getName(): string { + return 'Add background job to set the lookup server share state for users'; + } + + public function run(IOutput $output): void { + $this->jobList->add(LookupServerSendCheckBackgroundJob::class); + } +} diff --git a/lib/private/Repair/NC24/AddTokenCleanupJob.php b/lib/private/Repair/NC24/AddTokenCleanupJob.php new file mode 100644 index 00000000000..f1dac2d4e12 --- /dev/null +++ b/lib/private/Repair/NC24/AddTokenCleanupJob.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\NC24; + +use OC\Authentication\Token\TokenCleanupJob; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class AddTokenCleanupJob implements IRepairStep { + private IJobList $jobList; + + public function __construct(IJobList $jobList) { + $this->jobList = $jobList; + } + + public function getName(): string { + return 'Add token cleanup job'; + } + + public function run(IOutput $output) { + $this->jobList->add(TokenCleanupJob::class); + } +} diff --git a/lib/private/Repair/NC25/AddMissingSecretJob.php b/lib/private/Repair/NC25/AddMissingSecretJob.php new file mode 100644 index 00000000000..46b89d5f6f7 --- /dev/null +++ b/lib/private/Repair/NC25/AddMissingSecretJob.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\NC25; + +use OCP\HintException; +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use OCP\Security\ISecureRandom; + +class AddMissingSecretJob implements IRepairStep { + private IConfig $config; + private ISecureRandom $random; + + public function __construct(IConfig $config, ISecureRandom $random) { + $this->config = $config; + $this->random = $random; + } + + public function getName(): string { + return 'Add possibly missing system config'; + } + + public function run(IOutput $output): void { + $passwordSalt = $this->config->getSystemValueString('passwordsalt', ''); + if ($passwordSalt === '') { + try { + $this->config->setSystemValue('passwordsalt', $this->random->generate(30)); + } catch (HintException $e) { + $output->warning('passwordsalt is missing from your config.php and your config.php is read only. Please fix it manually.'); + } + } + + $secret = $this->config->getSystemValueString('secret', ''); + if ($secret === '') { + try { + $this->config->setSystemValue('secret', $this->random->generate(48)); + } catch (HintException $e) { + $output->warning('secret is missing from your config.php and your config.php is read only. Please fix it manually.'); + } + } + } +} diff --git a/lib/private/Repair/NC29/SanitizeAccountProperties.php b/lib/private/Repair/NC29/SanitizeAccountProperties.php new file mode 100644 index 00000000000..412570ba71d --- /dev/null +++ b/lib/private/Repair/NC29/SanitizeAccountProperties.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\NC29; + +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class SanitizeAccountProperties implements IRepairStep { + + public function __construct( + private IJobList $jobList, + ) { + } + + public function getName(): string { + return 'Validate account properties and store phone numbers in a known format for search'; + } + + public function run(IOutput $output): void { + $this->jobList->add(SanitizeAccountPropertiesJob::class, null); + $output->info('Queued background to validate account properties.'); + } +} diff --git a/lib/private/Repair/NC29/SanitizeAccountPropertiesJob.php b/lib/private/Repair/NC29/SanitizeAccountPropertiesJob.php new file mode 100644 index 00000000000..55ec445e9da --- /dev/null +++ b/lib/private/Repair/NC29/SanitizeAccountPropertiesJob.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\NC29; + +use InvalidArgumentException; +use OCP\Accounts\IAccountManager; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\QueuedJob; +use OCP\IUser; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + +class SanitizeAccountPropertiesJob extends QueuedJob { + + private const PROPERTIES_TO_CHECK = [ + IAccountManager::PROPERTY_PHONE, + IAccountManager::PROPERTY_WEBSITE, + IAccountManager::PROPERTY_TWITTER, + IAccountManager::PROPERTY_FEDIVERSE, + ]; + + public function __construct( + ITimeFactory $timeFactory, + private IUserManager $userManager, + private IAccountManager $accountManager, + private LoggerInterface $logger, + ) { + parent::__construct($timeFactory); + $this->setAllowParallelRuns(false); + } + + protected function run(mixed $argument): void { + $numRemoved = 0; + + $this->userManager->callForSeenUsers(function (IUser $user) use (&$numRemoved) { + $account = $this->accountManager->getAccount($user); + $properties = array_keys($account->jsonSerialize()); + + // Check if there are some properties we can sanitize - reduces number of db queries + if (empty(array_intersect($properties, self::PROPERTIES_TO_CHECK))) { + return; + } + + // Limit the loop to the properties we check to ensure there are no infinite loops + // we add one additional loop (+ 1) as we need 1 loop for checking + 1 for update. + $iteration = count(self::PROPERTIES_TO_CHECK) + 1; + while ($iteration-- > 0) { + try { + $this->accountManager->updateAccount($account); + return; + } catch (InvalidArgumentException $e) { + if (in_array($e->getMessage(), IAccountManager::ALLOWED_PROPERTIES)) { + $numRemoved++; + $property = $account->getProperty($e->getMessage()); + $account->setProperty($property->getName(), '', $property->getScope(), IAccountManager::NOT_VERIFIED); + } else { + $this->logger->error('Error while sanitizing account property', ['exception' => $e, 'user' => $user->getUID()]); + return; + } + } + } + $this->logger->error('Iteration limit exceeded while cleaning account properties', ['user' => $user->getUID()]); + }); + + if ($numRemoved > 0) { + $this->logger->info('Cleaned ' . $numRemoved . ' invalid account property entries'); + } + } +} diff --git a/lib/private/Repair/NC30/RemoveLegacyDatadirFile.php b/lib/private/Repair/NC30/RemoveLegacyDatadirFile.php new file mode 100644 index 00000000000..623163927bd --- /dev/null +++ b/lib/private/Repair/NC30/RemoveLegacyDatadirFile.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\NC30; + +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class RemoveLegacyDatadirFile implements IRepairStep { + + public function __construct( + private IConfig $config, + ) { + } + + public function getName(): string { + return 'Remove legacy ".ocdata" file'; + } + + public function run(IOutput $output): void { + $ocdata = $this->config->getSystemValueString('datadirectory', \OC::$SERVERROOT . '/data') . '/.ocdata'; + if (file_exists($ocdata)) { + unlink($ocdata); + } + } +} diff --git a/lib/private/Repair/OldGroupMembershipShares.php b/lib/private/Repair/OldGroupMembershipShares.php new file mode 100644 index 00000000000..003c15cfb88 --- /dev/null +++ b/lib/private/Repair/OldGroupMembershipShares.php @@ -0,0 +1,96 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Repair; + +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use OCP\Share\IShare; + +class OldGroupMembershipShares implements IRepairStep { + /** + * @var array [gid => [uid => (bool)]] + */ + protected $memberships; + + /** + * @param IDBConnection $connection + * @param IGroupManager $groupManager + */ + public function __construct( + protected IDBConnection $connection, + protected IGroupManager $groupManager, + ) { + } + + /** + * Returns the step's name + * + * @return string + */ + public function getName() { + return 'Remove shares of old group memberships'; + } + + /** + * Run repair step. + * Must throw exception on error. + * + * @throws \Exception in case of failure + */ + public function run(IOutput $output) { + $deletedEntries = 0; + + $query = $this->connection->getQueryBuilder(); + $query->select('s1.id')->selectAlias('s1.share_with', 'user')->selectAlias('s2.share_with', 'group') + ->from('share', 's1') + ->where($query->expr()->isNotNull('s1.parent')) + // \OC\Share\Constant::$shareTypeGroupUserUnique === 2 + ->andWhere($query->expr()->eq('s1.share_type', $query->expr()->literal(2))) + ->andWhere($query->expr()->isNotNull('s2.id')) + ->andWhere($query->expr()->eq('s2.share_type', $query->expr()->literal(IShare::TYPE_GROUP))) + ->leftJoin('s1', 'share', 's2', $query->expr()->eq('s1.parent', 's2.id')); + + $deleteQuery = $this->connection->getQueryBuilder(); + $deleteQuery->delete('share') + ->where($query->expr()->eq('id', $deleteQuery->createParameter('share'))); + + $result = $query->executeQuery(); + while ($row = $result->fetch()) { + if (!$this->isMember($row['group'], $row['user'])) { + $deletedEntries += $deleteQuery->setParameter('share', (int)$row['id']) + ->executeStatement(); + } + } + $result->closeCursor(); + + if ($deletedEntries) { + $output->info('Removed ' . $deletedEntries . ' shares where user is not a member of the group anymore'); + } + } + + /** + * @param string $gid + * @param string $uid + * @return bool + */ + protected function isMember($gid, $uid) { + if (isset($this->memberships[$gid][$uid])) { + return $this->memberships[$gid][$uid]; + } + + $isMember = $this->groupManager->isInGroup($uid, $gid); + if (!isset($this->memberships[$gid])) { + $this->memberships[$gid] = []; + } + $this->memberships[$gid][$uid] = $isMember; + + return $isMember; + } +} diff --git a/lib/private/Repair/Owncloud/CleanPreviews.php b/lib/private/Repair/Owncloud/CleanPreviews.php new file mode 100644 index 00000000000..50ee965e087 --- /dev/null +++ b/lib/private/Repair/Owncloud/CleanPreviews.php @@ -0,0 +1,56 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\Owncloud; + +use OCP\BackgroundJob\IJobList; +use OCP\IConfig; +use OCP\IUser; +use OCP\IUserManager; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class CleanPreviews implements IRepairStep { + /** @var IJobList */ + private $jobList; + + /** @var IUserManager */ + private $userManager; + + /** @var IConfig */ + private $config; + + /** + * MoveAvatars constructor. + * + * @param IJobList $jobList + * @param IUserManager $userManager + * @param IConfig $config + */ + public function __construct(IJobList $jobList, + IUserManager $userManager, + IConfig $config) { + $this->jobList = $jobList; + $this->userManager = $userManager; + $this->config = $config; + } + + /** + * @return string + */ + public function getName() { + return 'Add preview cleanup background jobs'; + } + + public function run(IOutput $output) { + if (!$this->config->getAppValue('core', 'previewsCleanedUp', false)) { + $this->userManager->callForSeenUsers(function (IUser $user) { + $this->jobList->add(CleanPreviewsBackgroundJob::class, ['uid' => $user->getUID()]); + }); + $this->config->setAppValue('core', 'previewsCleanedUp', '1'); + } + } +} diff --git a/lib/private/Repair/Owncloud/CleanPreviewsBackgroundJob.php b/lib/private/Repair/Owncloud/CleanPreviewsBackgroundJob.php new file mode 100644 index 00000000000..6c606453bb9 --- /dev/null +++ b/lib/private/Repair/Owncloud/CleanPreviewsBackgroundJob.php @@ -0,0 +1,91 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\Owncloud; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\BackgroundJob\QueuedJob; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + +class CleanPreviewsBackgroundJob extends QueuedJob { + public function __construct( + private IRootFolder $rootFolder, + private LoggerInterface $logger, + private IJobList $jobList, + ITimeFactory $timeFactory, + private IUserManager $userManager, + ) { + parent::__construct($timeFactory); + } + + public function run($arguments) { + $uid = $arguments['uid']; + if (!$this->userManager->userExists($uid)) { + $this->logger->info('User no longer exists, skip user ' . $uid); + return; + } + $this->logger->info('Started preview cleanup for ' . $uid); + $empty = $this->cleanupPreviews($uid); + + if (!$empty) { + $this->jobList->add(self::class, ['uid' => $uid]); + $this->logger->info('New preview cleanup scheduled for ' . $uid); + } else { + $this->logger->info('Preview cleanup done for ' . $uid); + } + } + + /** + * @param string $uid + */ + private function cleanupPreviews($uid): bool { + try { + $userFolder = $this->rootFolder->getUserFolder($uid); + } catch (NotFoundException $e) { + return true; + } + + $userRoot = $userFolder->getParent(); + + try { + /** @var Folder $thumbnailFolder */ + $thumbnailFolder = $userRoot->get('thumbnails'); + } catch (NotFoundException $e) { + return true; + } + + $thumbnails = $thumbnailFolder->getDirectoryListing(); + + $start = $this->time->getTime(); + foreach ($thumbnails as $thumbnail) { + try { + $thumbnail->delete(); + } catch (NotPermittedException $e) { + // Ignore + } + + if (($this->time->getTime() - $start) > 15) { + return false; + } + } + + try { + $thumbnailFolder->delete(); + } catch (NotPermittedException $e) { + // Ignore + } + + return true; + } +} diff --git a/lib/private/Repair/Owncloud/DropAccountTermsTable.php b/lib/private/Repair/Owncloud/DropAccountTermsTable.php new file mode 100644 index 00000000000..534825c146a --- /dev/null +++ b/lib/private/Repair/Owncloud/DropAccountTermsTable.php @@ -0,0 +1,41 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\Owncloud; + +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class DropAccountTermsTable implements IRepairStep { + /** @var IDBConnection */ + protected $db; + + /** + * @param IDBConnection $db + */ + public function __construct(IDBConnection $db) { + $this->db = $db; + } + + /** + * @return string + */ + public function getName() { + return 'Drop account terms table when migrating from ownCloud'; + } + + /** + * @param IOutput $output + */ + public function run(IOutput $output) { + if (!$this->db->tableExists('account_terms')) { + return; + } + + $this->db->dropTable('account_terms'); + } +} diff --git a/lib/private/Repair/Owncloud/MigrateOauthTables.php b/lib/private/Repair/Owncloud/MigrateOauthTables.php new file mode 100644 index 00000000000..de26a907e02 --- /dev/null +++ b/lib/private/Repair/Owncloud/MigrateOauthTables.php @@ -0,0 +1,259 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Repair\Owncloud; + +use OC\Authentication\Token\IProvider as ITokenProvider; +use OC\DB\Connection; +use OC\DB\SchemaWrapper; +use OCA\OAuth2\Db\AccessToken; +use OCA\OAuth2\Db\AccessTokenMapper; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\Token\IToken; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use OCP\Security\ICrypto; +use OCP\Security\ISecureRandom; + +class MigrateOauthTables implements IRepairStep { + + public function __construct( + protected Connection $db, + private AccessTokenMapper $accessTokenMapper, + private ITokenProvider $tokenProvider, + private ISecureRandom $random, + private ITimeFactory $timeFactory, + private ICrypto $crypto, + private IConfig $config, + ) { + } + + /** + * @return string + */ + public function getName() { + return 'Migrate oauth2_clients table to nextcloud schema'; + } + + public function run(IOutput $output) { + $schema = new SchemaWrapper($this->db); + if (!$schema->hasTable('oauth2_clients')) { + $output->info('oauth2_clients table does not exist.'); + return; + } + + // Create column and then migrate before handling unique index. + // So that we can distinguish between legacy (from oc) and new rows (from nc). + $table = $schema->getTable('oauth2_access_tokens'); + if (!$table->hasColumn('hashed_code')) { + $output->info('Prepare the oauth2_access_tokens table schema.'); + $table->addColumn('hashed_code', 'string', [ + 'notnull' => true, + 'length' => 128, + ]); + + // Regenerate schema after migrating to it + $this->db->migrateToSchema($schema->getWrappedSchema()); + $schema = new SchemaWrapper($this->db); + } + + $output->info('Update the oauth2_access_tokens table schema.'); + $table = $schema->getTable('oauth2_access_tokens'); + if (!$table->hasColumn('encrypted_token')) { + $table->addColumn('encrypted_token', 'string', [ + 'notnull' => true, + 'length' => 786, + ]); + } + if (!$table->hasIndex('oauth2_access_hash_idx')) { + // Drop legacy access codes first to prevent integrity constraint violations + $qb = $this->db->getQueryBuilder(); + $qb->delete('oauth2_access_tokens') + ->where($qb->expr()->eq('hashed_code', $qb->createNamedParameter(''))); + $qb->executeStatement(); + + $table->addUniqueIndex(['hashed_code'], 'oauth2_access_hash_idx'); + } + if (!$table->hasIndex('oauth2_access_client_id_idx')) { + $table->addIndex(['client_id'], 'oauth2_access_client_id_idx'); + } + if (!$table->hasColumn('token_id')) { + $table->addColumn('token_id', 'integer', [ + 'notnull' => true, + ]); + } + if ($table->hasColumn('expires')) { + $table->dropColumn('expires'); + } + if ($table->hasColumn('user_id')) { + $table->dropColumn('user_id'); + } + if ($table->hasColumn('token')) { + $table->dropColumn('token'); + } + + $output->info('Update the oauth2_clients table schema.'); + $table = $schema->getTable('oauth2_clients'); + if ($table->getColumn('name')->getLength() !== 64) { + // shorten existing values before resizing the column + $qb = $this->db->getQueryBuilder(); + $qb->update('oauth2_clients') + ->set('name', $qb->createParameter('shortenedName')) + ->where($qb->expr()->eq('id', $qb->createParameter('theId'))); + + $qbSelect = $this->db->getQueryBuilder(); + $qbSelect->select('id', 'name') + ->from('oauth2_clients'); + + $result = $qbSelect->executeQuery(); + while ($row = $result->fetch()) { + $id = $row['id']; + $shortenedName = mb_substr($row['name'], 0, 64); + $qb->setParameter('theId', $id, IQueryBuilder::PARAM_INT); + $qb->setParameter('shortenedName', $shortenedName, IQueryBuilder::PARAM_STR); + $qb->executeStatement(); + } + $result->closeCursor(); + + // safely set the new column length + $table->getColumn('name')->setLength(64); + } + if ($table->hasColumn('allow_subdomains')) { + $table->dropColumn('allow_subdomains'); + } + if ($table->hasColumn('trusted')) { + $table->dropColumn('trusted'); + } + + if (!$schema->getTable('oauth2_clients')->hasColumn('client_identifier')) { + $table->addColumn('client_identifier', 'string', [ + 'notnull' => true, + 'length' => 64, + 'default' => '' + ]); + $table->addIndex(['client_identifier'], 'oauth2_client_id_idx'); + } + + // Regenerate schema after migrating to it + $this->db->migrateToSchema($schema->getWrappedSchema()); + $schema = new SchemaWrapper($this->db); + + if ($schema->getTable('oauth2_clients')->hasColumn('identifier')) { + $output->info("Move identifier column's data to the new client_identifier column."); + // 1. Fetch all [id, identifier] couple. + $selectQuery = $this->db->getQueryBuilder(); + $selectQuery->select('id', 'identifier')->from('oauth2_clients'); + $result = $selectQuery->executeQuery(); + $identifiers = $result->fetchAll(); + $result->closeCursor(); + + // 2. Insert them into the client_identifier column. + foreach ($identifiers as ['id' => $id, 'identifier' => $clientIdentifier]) { + $insertQuery = $this->db->getQueryBuilder(); + $insertQuery->update('oauth2_clients') + ->set('client_identifier', $insertQuery->createNamedParameter($clientIdentifier, IQueryBuilder::PARAM_STR)) + ->where($insertQuery->expr()->eq('id', $insertQuery->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + + $output->info('Drop the identifier column.'); + $table = $schema->getTable('oauth2_clients'); + $table->dropColumn('identifier'); + + // Regenerate schema after migrating to it + $this->db->migrateToSchema($schema->getWrappedSchema()); + $schema = new SchemaWrapper($this->db); + } + + $enableOcClients = $this->config->getSystemValueBool('oauth2.enable_oc_clients', false); + if ($enableOcClients) { + $output->info('Delete clients (and their related access tokens) with the redirect_uri starting with oc://'); + } else { + $output->info('Delete clients (and their related access tokens) with the redirect_uri starting with oc:// or ending with *'); + } + // delete the access tokens + $qbDeleteAccessTokens = $this->db->getQueryBuilder(); + + $qbSelectClientId = $this->db->getQueryBuilder(); + $qbSelectClientId->select('id') + ->from('oauth2_clients') + ->where( + $qbSelectClientId->expr()->iLike('redirect_uri', $qbDeleteAccessTokens->createNamedParameter('oc://%', IQueryBuilder::PARAM_STR)) + ); + if (!$enableOcClients) { + $qbSelectClientId->orWhere( + $qbSelectClientId->expr()->iLike('redirect_uri', $qbDeleteAccessTokens->createNamedParameter('%*', IQueryBuilder::PARAM_STR)) + ); + } + + $qbDeleteAccessTokens->delete('oauth2_access_tokens') + ->where( + $qbSelectClientId->expr()->in('client_id', $qbDeleteAccessTokens->createFunction($qbSelectClientId->getSQL()), IQueryBuilder::PARAM_STR_ARRAY) + ); + $qbDeleteAccessTokens->executeStatement(); + + // delete the clients + $qbDeleteClients = $this->db->getQueryBuilder(); + $qbDeleteClients->delete('oauth2_clients') + ->where( + $qbDeleteClients->expr()->iLike('redirect_uri', $qbDeleteClients->createNamedParameter('oc://%', IQueryBuilder::PARAM_STR)) + ); + if (!$enableOcClients) { + $qbDeleteClients->orWhere( + $qbDeleteClients->expr()->iLike('redirect_uri', $qbDeleteClients->createNamedParameter('%*', IQueryBuilder::PARAM_STR)) + ); + } + $qbDeleteClients->executeStatement(); + + // Migrate legacy refresh tokens from oc + if ($schema->hasTable('oauth2_refresh_tokens')) { + $output->info('Migrate legacy oauth2 refresh tokens.'); + + $qbSelect = $this->db->getQueryBuilder(); + $qbSelect->select('*') + ->from('oauth2_refresh_tokens'); + $result = $qbSelect->executeQuery(); + $now = $this->timeFactory->now()->getTimestamp(); + $index = 0; + while ($row = $result->fetch()) { + $clientId = $row['client_id']; + $refreshToken = $row['token']; + + // Insert expired token so that it can be rotated on the next refresh + $accessToken = $this->random->generate(72, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS); + $authToken = $this->tokenProvider->generateToken( + $accessToken, + $row['user_id'], + $row['user_id'], + null, + "oc_migrated_client{$clientId}_t{$now}_i$index", + IToken::PERMANENT_TOKEN, + IToken::DO_NOT_REMEMBER, + ); + $authToken->setExpires($now - 3600); + $this->tokenProvider->updateToken($authToken); + + $accessTokenEntity = new AccessToken(); + $accessTokenEntity->setTokenId($authToken->getId()); + $accessTokenEntity->setClientId($clientId); + $accessTokenEntity->setHashedCode(hash('sha512', $refreshToken)); + $accessTokenEntity->setEncryptedToken($this->crypto->encrypt($accessToken, $refreshToken)); + $accessTokenEntity->setCodeCreatedAt($now); + $accessTokenEntity->setTokenCount(1); + $this->accessTokenMapper->insert($accessTokenEntity); + + $index++; + } + $result->closeCursor(); + + $schema->dropTable('oauth2_refresh_tokens'); + $schema->performDropTableCalls(); + } + } +} diff --git a/lib/private/Repair/Owncloud/MoveAvatars.php b/lib/private/Repair/Owncloud/MoveAvatars.php new file mode 100644 index 00000000000..9e3f4b89b13 --- /dev/null +++ b/lib/private/Repair/Owncloud/MoveAvatars.php @@ -0,0 +1,55 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\Owncloud; + +use OCP\BackgroundJob\IJobList; +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class MoveAvatars implements IRepairStep { + /** @var IJobList */ + private $jobList; + + /** @var IConfig */ + private $config; + + /** + * MoveAvatars constructor. + * + * @param IJobList $jobList + * @param IConfig $config + */ + public function __construct(IJobList $jobList, + IConfig $config) { + $this->jobList = $jobList; + $this->config = $config; + } + + /** + * @return string + */ + public function getName() { + return 'Add move avatar background job'; + } + + public function run(IOutput $output) { + // only run once + if ($this->config->getAppValue('core', 'moveavatarsdone') === 'yes') { + $output->info('Repair step already executed'); + return; + } + if (!$this->config->getSystemValueBool('enable_avatars', true)) { + $output->info('Avatars are disabled'); + } else { + $output->info('Add background job'); + $this->jobList->add(MoveAvatarsBackgroundJob::class); + // if all were done, no need to redo the repair during next upgrade + $this->config->setAppValue('core', 'moveavatarsdone', 'yes'); + } + } +} diff --git a/lib/private/Repair/Owncloud/MoveAvatarsBackgroundJob.php b/lib/private/Repair/Owncloud/MoveAvatarsBackgroundJob.php new file mode 100644 index 00000000000..e145fb71863 --- /dev/null +++ b/lib/private/Repair/Owncloud/MoveAvatarsBackgroundJob.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\Owncloud; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\QueuedJob; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\Storage\IStorage; +use OCP\IAvatarManager; +use OCP\IUser; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; +use function is_resource; + +class MoveAvatarsBackgroundJob extends QueuedJob { + private ?IStorage $owncloudAvatarStorage = null; + + public function __construct( + private IUserManager $userManager, + private LoggerInterface $logger, + private IAvatarManager $avatarManager, + private IRootFolder $rootFolder, + ITimeFactory $time, + ) { + parent::__construct($time); + try { + $this->owncloudAvatarStorage = $rootFolder->get('avatars')->getStorage(); + } catch (\Exception $e) { + } + } + + public function run($arguments) { + $this->logger->info('Started migrating avatars to AppData folder'); + $this->moveAvatars(); + $this->logger->info('All avatars migrated to AppData folder'); + } + + private function moveAvatars(): void { + if (!$this->owncloudAvatarStorage) { + $this->logger->info('No legacy avatars available, skipping migration'); + return; + } + + $counter = 0; + $this->userManager->callForSeenUsers(function (IUser $user) use (&$counter) { + $uid = $user->getUID(); + + $path = 'avatars/' . $this->buildOwnCloudAvatarPath($uid); + $avatar = $this->avatarManager->getAvatar($uid); + try { + $avatarPath = $path . '/avatar.' . $this->getExtension($path); + $resource = $this->owncloudAvatarStorage->fopen($avatarPath, 'r'); + if (is_resource($resource)) { + $avatar->set($resource); + fclose($resource); + } else { + throw new \Exception('Failed to open old avatar file for reading'); + } + } catch (NotFoundException $e) { + // In case there is no avatar we can just skip + } catch (\Throwable $e) { + $this->logger->error('Failed to migrate avatar for user ' . $uid, ['exception' => $e]); + } + + $counter++; + if ($counter % 100 === 0) { + $this->logger->info('{amount} avatars migrated', ['amount' => $counter]); + } + }); + } + + /** + * @throws NotFoundException + */ + private function getExtension(string $path): string { + if ($this->owncloudAvatarStorage->file_exists("{$path}/avatar.jpg")) { + return 'jpg'; + } + if ($this->owncloudAvatarStorage->file_exists("{$path}/avatar.png")) { + return 'png'; + } + throw new NotFoundException("{$path}/avatar.jpg|png"); + } + + protected function buildOwnCloudAvatarPath(string $userId): string { + return substr_replace(substr_replace(md5($userId), '/', 4, 0), '/', 2, 0); + } +} diff --git a/lib/private/Repair/Owncloud/SaveAccountsTableData.php b/lib/private/Repair/Owncloud/SaveAccountsTableData.php new file mode 100644 index 00000000000..ab1560ddb8d --- /dev/null +++ b/lib/private/Repair/Owncloud/SaveAccountsTableData.php @@ -0,0 +1,181 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\Owncloud; + +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use OCP\PreConditionNotMetException; + +/** + * Copies the email address from the accounts table to the preference table, + * before the data structure is changed and the information is gone + */ +class SaveAccountsTableData implements IRepairStep { + public const BATCH_SIZE = 75; + + /** @var IDBConnection */ + protected $db; + + /** @var IConfig */ + protected $config; + + protected $hasForeignKeyOnPersistentLocks = false; + + /** + * @param IDBConnection $db + * @param IConfig $config + */ + public function __construct(IDBConnection $db, IConfig $config) { + $this->db = $db; + $this->config = $config; + } + + /** + * @return string + */ + public function getName() { + return 'Copy data from accounts table when migrating from ownCloud'; + } + + /** + * @param IOutput $output + */ + public function run(IOutput $output) { + if (!$this->shouldRun()) { + return; + } + + $offset = 0; + $numUsers = $this->runStep($offset); + + while ($numUsers === self::BATCH_SIZE) { + $offset += $numUsers; + $numUsers = $this->runStep($offset); + } + + // oc_persistent_locks will be removed later on anyways so we can just drop and ignore any foreign key constraints here + $tableName = $this->config->getSystemValueString('dbtableprefix', 'oc_') . 'persistent_locks'; + $schema = $this->db->createSchema(); + $table = $schema->getTable($tableName); + foreach ($table->getForeignKeys() as $foreignKey) { + $table->removeForeignKey($foreignKey->getName()); + } + $this->db->migrateToSchema($schema); + + // Remove the table + if ($this->hasForeignKeyOnPersistentLocks) { + $this->db->dropTable('persistent_locks'); + } + $this->db->dropTable('accounts'); + } + + /** + * @return bool + */ + protected function shouldRun() { + $schema = $this->db->createSchema(); + $prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_'); + + $tableName = $prefix . 'accounts'; + if (!$schema->hasTable($tableName)) { + return false; + } + + $table = $schema->getTable($tableName); + if (!$table->hasColumn('user_id')) { + return false; + } + + if ($schema->hasTable($prefix . 'persistent_locks')) { + $locksTable = $schema->getTable($prefix . 'persistent_locks'); + $foreignKeys = $locksTable->getForeignKeys(); + foreach ($foreignKeys as $foreignKey) { + if ($tableName === $foreignKey->getForeignTableName()) { + $this->hasForeignKeyOnPersistentLocks = true; + } + } + } + + return true; + } + + /** + * @param int $offset + * @return int Number of copied users + */ + protected function runStep($offset) { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from('accounts') + ->orderBy('id') + ->setMaxResults(self::BATCH_SIZE); + + if ($offset > 0) { + $query->setFirstResult($offset); + } + + $result = $query->execute(); + + $update = $this->db->getQueryBuilder(); + $update->update('users') + ->set('displayname', $update->createParameter('displayname')) + ->where($update->expr()->eq('uid', $update->createParameter('userid'))); + + $updatedUsers = 0; + while ($row = $result->fetch()) { + try { + $this->migrateUserInfo($update, $row); + } catch (PreConditionNotMetException $e) { + // Ignore and continue + } catch (\UnexpectedValueException $e) { + // Ignore and continue + } + $updatedUsers++; + } + $result->closeCursor(); + + return $updatedUsers; + } + + /** + * @param IQueryBuilder $update + * @param array $userdata + * @throws PreConditionNotMetException + * @throws \UnexpectedValueException + */ + protected function migrateUserInfo(IQueryBuilder $update, $userdata) { + $state = (int)$userdata['state']; + if ($state === 3) { + // Deleted user, ignore + return; + } + + if ($userdata['email'] !== null) { + $this->config->setUserValue($userdata['user_id'], 'settings', 'email', $userdata['email']); + } + if ($userdata['quota'] !== null) { + $this->config->setUserValue($userdata['user_id'], 'files', 'quota', $userdata['quota']); + } + if ($userdata['last_login'] !== null) { + $this->config->setUserValue($userdata['user_id'], 'login', 'lastLogin', $userdata['last_login']); + } + if ($state === 1) { + $this->config->setUserValue($userdata['user_id'], 'core', 'enabled', 'true'); + } elseif ($state === 2) { + $this->config->setUserValue($userdata['user_id'], 'core', 'enabled', 'false'); + } + + if ($userdata['display_name'] !== null) { + $update->setParameter('displayname', $userdata['display_name']) + ->setParameter('userid', $userdata['user_id']); + $update->execute(); + } + } +} diff --git a/lib/private/Repair/Owncloud/UpdateLanguageCodes.php b/lib/private/Repair/Owncloud/UpdateLanguageCodes.php new file mode 100644 index 00000000000..8d9046ad49f --- /dev/null +++ b/lib/private/Repair/Owncloud/UpdateLanguageCodes.php @@ -0,0 +1,72 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair\Owncloud; + +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class UpdateLanguageCodes implements IRepairStep { + /** @var IDBConnection */ + private $connection; + + /** @var IConfig */ + private $config; + + /** + * @param IDBConnection $connection + * @param IConfig $config + */ + public function __construct(IDBConnection $connection, + IConfig $config) { + $this->connection = $connection; + $this->config = $config; + } + + /** + * {@inheritdoc} + */ + public function getName() { + return 'Repair language codes'; + } + + /** + * {@inheritdoc} + */ + public function run(IOutput $output) { + $versionFromBeforeUpdate = $this->config->getSystemValueString('version', '0.0.0'); + + if (version_compare($versionFromBeforeUpdate, '12.0.0.13', '>')) { + return; + } + + $languages = [ + 'bg_BG' => 'bg', + 'cs_CZ' => 'cs', + 'fi_FI' => 'fi', + 'hu_HU' => 'hu', + 'nb_NO' => 'nb', + 'sk_SK' => 'sk', + 'th_TH' => 'th', + ]; + + foreach ($languages as $oldCode => $newCode) { + $qb = $this->connection->getQueryBuilder(); + + $affectedRows = $qb->update('preferences') + ->set('configvalue', $qb->createNamedParameter($newCode)) + ->where($qb->expr()->eq('appid', $qb->createNamedParameter('core'))) + ->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('lang'))) + ->andWhere($qb->expr()->eq('configvalue', $qb->createNamedParameter($oldCode), IQueryBuilder::PARAM_STR)) + ->execute(); + + $output->info('Changed ' . $affectedRows . ' setting(s) from "' . $oldCode . '" to "' . $newCode . '" in preferences table.'); + } + } +} diff --git a/lib/private/Repair/RemoveBrokenProperties.php b/lib/private/Repair/RemoveBrokenProperties.php new file mode 100644 index 00000000000..85939b39e5e --- /dev/null +++ b/lib/private/Repair/RemoveBrokenProperties.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair; + +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class RemoveBrokenProperties implements IRepairStep { + /** + * RemoveBrokenProperties constructor. + * + * @param IDBConnection $db + */ + public function __construct( + private IDBConnection $db, + ) { + } + + /** + * @inheritdoc + */ + public function getName() { + return 'Remove broken DAV object properties'; + } + + /** + * @inheritdoc + */ + public function run(IOutput $output) { + // retrieve all object properties + $qb = $this->db->getQueryBuilder(); + $qb->select('id', 'propertyvalue') + ->from('properties') + ->where($qb->expr()->eq('valuetype', $qb->createNamedParameter('3', IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); + $result = $qb->executeQuery(); + // find broken object properties + $brokenIds = []; + while ($entry = $result->fetch()) { + if (!empty($entry['propertyvalue'])) { + $object = @unserialize(str_replace('\x00', chr(0), $entry['propertyvalue'])); + if ($object === false) { + $brokenIds[] = $entry['id']; + } + } else { + $brokenIds[] = $entry['id']; + } + } + $result->closeCursor(); + // delete broken object properties + $qb = $this->db->getQueryBuilder(); + $qb->delete('properties') + ->where($qb->expr()->in('id', $qb->createParameter('ids'), IQueryBuilder::PARAM_STR_ARRAY)); + foreach (array_chunk($brokenIds, 1000) as $chunkIds) { + $qb->setParameter('ids', $chunkIds, IQueryBuilder::PARAM_STR_ARRAY); + $qb->executeStatement(); + } + $total = count($brokenIds); + $output->info("$total broken object properties removed"); + } +} diff --git a/lib/private/Repair/RemoveLinkShares.php b/lib/private/Repair/RemoveLinkShares.php new file mode 100644 index 00000000000..a07ebdb72c3 --- /dev/null +++ b/lib/private/Repair/RemoveLinkShares.php @@ -0,0 +1,206 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\DB\IResult; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use OCP\Notification\IManager; + +class RemoveLinkShares implements IRepairStep { + /** @var string[] */ + private $userToNotify = []; + + public function __construct( + private IDBConnection $connection, + private IConfig $config, + private IGroupManager $groupManager, + private IManager $notificationManager, + private ITimeFactory $timeFactory, + ) { + } + + public function getName(): string { + return 'Remove potentially over exposing share links'; + } + + private function shouldRun(): bool { + $versionFromBeforeUpdate = $this->config->getSystemValueString('version', '0.0.0'); + + if (version_compare($versionFromBeforeUpdate, '14.0.11', '<')) { + return true; + } + if (version_compare($versionFromBeforeUpdate, '15.0.8', '<')) { + return true; + } + if (version_compare($versionFromBeforeUpdate, '16.0.0', '<=')) { + return true; + } + + return false; + } + + /** + * Delete the share + * + * @param int $id + */ + private function deleteShare(int $id): void { + $qb = $this->connection->getQueryBuilder(); + $qb->delete('share') + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))); + $qb->executeStatement(); + } + + /** + * Get the total of affected shares + * + * @return int + */ + private function getTotal(): int { + $subSubQuery = $this->connection->getQueryBuilder(); + $subSubQuery->select('*') + ->from('share') + ->where($subSubQuery->expr()->isNotNull('parent')) + ->andWhere($subSubQuery->expr()->eq('share_type', $subSubQuery->expr()->literal(3, IQueryBuilder::PARAM_INT))); + + $subQuery = $this->connection->getQueryBuilder(); + $subQuery->select('s1.id') + ->from($subQuery->createFunction('(' . $subSubQuery->getSQL() . ')'), 's1') + ->join( + 's1', 'share', 's2', + $subQuery->expr()->eq('s1.parent', 's2.id') + ) + ->where($subQuery->expr()->orX( + $subQuery->expr()->eq('s2.share_type', $subQuery->expr()->literal(1, IQueryBuilder::PARAM_INT)), + $subQuery->expr()->eq('s2.share_type', $subQuery->expr()->literal(2, IQueryBuilder::PARAM_INT)) + )) + ->andWhere($subQuery->expr()->eq('s1.item_source', 's2.item_source')); + + $query = $this->connection->getQueryBuilder(); + $query->select($query->func()->count('*', 'total')) + ->from('share') + ->where($query->expr()->in('id', $query->createFunction($subQuery->getSQL()))); + + $result = $query->executeQuery(); + $data = $result->fetch(); + $result->closeCursor(); + + return (int)$data['total']; + } + + /** + * Get the cursor to fetch all the shares + */ + private function getShares(): IResult { + $subQuery = $this->connection->getQueryBuilder(); + $subQuery->select('*') + ->from('share') + ->where($subQuery->expr()->isNotNull('parent')) + ->andWhere($subQuery->expr()->eq('share_type', $subQuery->expr()->literal(3, IQueryBuilder::PARAM_INT))); + + $query = $this->connection->getQueryBuilder(); + $query->select('s1.id', 's1.uid_owner', 's1.uid_initiator') + ->from($query->createFunction('(' . $subQuery->getSQL() . ')'), 's1') + ->join( + 's1', 'share', 's2', + $query->expr()->eq('s1.parent', 's2.id') + ) + ->where($query->expr()->orX( + $query->expr()->eq('s2.share_type', $query->expr()->literal(1, IQueryBuilder::PARAM_INT)), + $query->expr()->eq('s2.share_type', $query->expr()->literal(2, IQueryBuilder::PARAM_INT)) + )) + ->andWhere($query->expr()->eq('s1.item_source', 's2.item_source')); + /** @var IResult $result */ + $result = $query->executeQuery(); + return $result; + } + + /** + * Process a single share + * + * @param array $data + */ + private function processShare(array $data): void { + $id = $data['id']; + + $this->addToNotify($data['uid_owner']); + $this->addToNotify($data['uid_initiator']); + + $this->deleteShare((int)$id); + } + + /** + * Update list of users to notify + * + * @param string $uid + */ + private function addToNotify(string $uid): void { + if (!isset($this->userToNotify[$uid])) { + $this->userToNotify[$uid] = true; + } + } + + /** + * Send all notifications + */ + private function sendNotification(): void { + $time = $this->timeFactory->getDateTime(); + + $notification = $this->notificationManager->createNotification(); + $notification->setApp('core') + ->setDateTime($time) + ->setObject('repair', 'exposing_links') + ->setSubject('repair_exposing_links'); + + $users = array_keys($this->userToNotify); + foreach ($users as $user) { + $notification->setUser((string)$user); + $this->notificationManager->notify($notification); + } + } + + private function repair(IOutput $output, int $total): void { + $output->startProgress($total); + + $shareResult = $this->getShares(); + while ($data = $shareResult->fetch()) { + $this->processShare($data); + $output->advance(); + } + $output->finishProgress(); + $shareResult->closeCursor(); + + // Notify all admins + $adminGroup = $this->groupManager->get('admin'); + $adminUsers = $adminGroup->getUsers(); + foreach ($adminUsers as $user) { + $this->addToNotify($user->getUID()); + } + + $output->info('Sending notifications to admins and affected users'); + $this->sendNotification(); + } + + public function run(IOutput $output): void { + if ($this->shouldRun() === false || ($total = $this->getTotal()) === 0) { + $output->info('No need to remove link shares.'); + return; + } + + $output->info('Removing potentially over exposing link shares'); + $this->repair($output, $total); + $output->info('Removed potentially over exposing link shares'); + } +} diff --git a/lib/private/Repair/RepairDavShares.php b/lib/private/Repair/RepairDavShares.php new file mode 100644 index 00000000000..557e2c080ca --- /dev/null +++ b/lib/private/Repair/RepairDavShares.php @@ -0,0 +1,109 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair; + +use OCP\DB\Exception; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; +use function strlen; +use function substr; +use function urldecode; +use function urlencode; + +class RepairDavShares implements IRepairStep { + protected const GROUP_PRINCIPAL_PREFIX = 'principals/groups/'; + + /** @var bool */ + private $hintInvalidShares = false; + + public function __construct( + private IConfig $config, + private IDBConnection $dbc, + private IGroupManager $groupManager, + private LoggerInterface $logger, + ) { + } + + /** + * @inheritDoc + */ + public function getName() { + return 'Repair DAV shares'; + } + + protected function repairUnencodedGroupShares() { + $qb = $this->dbc->getQueryBuilder(); + $qb->select(['id', 'principaluri']) + ->from('dav_shares') + ->where($qb->expr()->like('principaluri', $qb->createNamedParameter(self::GROUP_PRINCIPAL_PREFIX . '%'))); + + $updateQuery = $this->dbc->getQueryBuilder(); + $updateQuery->update('dav_shares') + ->set('principaluri', $updateQuery->createParameter('updatedPrincipalUri')) + ->where($updateQuery->expr()->eq('id', $updateQuery->createParameter('shareId'))); + + $statement = $qb->execute(); + while ($share = $statement->fetch()) { + $gid = substr($share['principaluri'], strlen(self::GROUP_PRINCIPAL_PREFIX)); + $decodedGid = urldecode($gid); + $encodedGid = urlencode($gid); + if ($gid === $encodedGid + || !$this->groupManager->groupExists($gid) + || ($gid !== $decodedGid && $this->groupManager->groupExists($decodedGid)) + ) { + $this->hintInvalidShares = $this->hintInvalidShares || $gid !== $encodedGid; + continue; + } + + // Repair when + // + the group name needs encoding + // + AND it is not encoded yet + // + AND there are no ambivalent groups + + try { + $fixedPrincipal = self::GROUP_PRINCIPAL_PREFIX . $encodedGid; + $logParameters = [ + 'app' => 'core', + 'id' => $share['id'], + 'old' => $share['principaluri'], + 'new' => $fixedPrincipal, + ]; + $updateQuery + ->setParameter('updatedPrincipalUri', $fixedPrincipal) + ->setParameter('shareId', $share['id']) + ->execute(); + $this->logger->info('Repaired principal for dav share {id} from {old} to {new}', $logParameters); + } catch (Exception $e) { + $logParameters['message'] = $e->getMessage(); + $logParameters['exception'] = $e; + $this->logger->info('Could not repair principal for dav share {id} from {old} to {new}: {message}', $logParameters); + } + } + return true; + } + + /** + * @inheritDoc + */ + public function run(IOutput $output) { + $versionFromBeforeUpdate = $this->config->getSystemValueString('version', '0.0.0'); + if (version_compare($versionFromBeforeUpdate, '20.0.8', '<') + && $this->repairUnencodedGroupShares() + ) { + $output->info('Repaired DAV group shares'); + if ($this->hintInvalidShares) { + $output->info('Invalid shares might be left in the database, running "occ dav:remove-invalid-shares" can remove them.'); + } + } + } +} diff --git a/lib/private/Repair/RepairInvalidShares.php b/lib/private/Repair/RepairInvalidShares.php new file mode 100644 index 00000000000..9553f25ee70 --- /dev/null +++ b/lib/private/Repair/RepairInvalidShares.php @@ -0,0 +1,95 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Repair; + +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +/** + * Repairs shares with invalid data + */ +class RepairInvalidShares implements IRepairStep { + public const CHUNK_SIZE = 200; + + public function __construct( + protected IConfig $config, + protected IDBConnection $connection, + ) { + } + + public function getName() { + return 'Repair invalid shares'; + } + + /** + * Adjust file share permissions + */ + private function adjustFileSharePermissions(IOutput $out) { + $mask = \OCP\Constants::PERMISSION_READ | \OCP\Constants::PERMISSION_UPDATE | \OCP\Constants::PERMISSION_SHARE; + $builder = $this->connection->getQueryBuilder(); + + $permsFunc = $builder->expr()->bitwiseAnd('permissions', $mask); + $builder + ->update('share') + ->set('permissions', $permsFunc) + ->where($builder->expr()->eq('item_type', $builder->expr()->literal('file'))) + ->andWhere($builder->expr()->neq('permissions', $permsFunc)); + + $updatedEntries = $builder->executeStatement(); + if ($updatedEntries > 0) { + $out->info('Fixed file share permissions for ' . $updatedEntries . ' shares'); + } + } + + /** + * Remove shares where the parent share does not exist anymore + */ + private function removeSharesNonExistingParent(IOutput $out) { + $deletedEntries = 0; + + $query = $this->connection->getQueryBuilder(); + $query->select('s1.parent') + ->from('share', 's1') + ->where($query->expr()->isNotNull('s1.parent')) + ->andWhere($query->expr()->isNull('s2.id')) + ->leftJoin('s1', 'share', 's2', $query->expr()->eq('s1.parent', 's2.id')) + ->groupBy('s1.parent') + ->setMaxResults(self::CHUNK_SIZE); + + $deleteQuery = $this->connection->getQueryBuilder(); + $deleteQuery->delete('share') + ->where($deleteQuery->expr()->eq('parent', $deleteQuery->createParameter('parent'))); + + $deletedInLastChunk = self::CHUNK_SIZE; + while ($deletedInLastChunk === self::CHUNK_SIZE) { + $deletedInLastChunk = 0; + $result = $query->executeQuery(); + while ($row = $result->fetch()) { + $deletedInLastChunk++; + $deletedEntries += $deleteQuery->setParameter('parent', (int)$row['parent']) + ->executeStatement(); + } + $result->closeCursor(); + } + + if ($deletedEntries) { + $out->info('Removed ' . $deletedEntries . ' shares where the parent did not exist'); + } + } + + public function run(IOutput $out) { + $ocVersionFromBeforeUpdate = $this->config->getSystemValueString('version', '0.0.0'); + if (version_compare($ocVersionFromBeforeUpdate, '12.0.0.11', '<')) { + $this->adjustFileSharePermissions($out); + } + + $this->removeSharesNonExistingParent($out); + } +} diff --git a/lib/private/Repair/RepairLogoDimension.php b/lib/private/Repair/RepairLogoDimension.php new file mode 100644 index 00000000000..854aeb3ab07 --- /dev/null +++ b/lib/private/Repair/RepairLogoDimension.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Repair; + +use OCA\Theming\ImageManager; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\IConfig; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use OCP\Server; + +class RepairLogoDimension implements IRepairStep { + public function __construct( + protected IConfig $config, + ) { + } + + public function getName(): string { + return 'Cache logo dimension to fix size in emails on Outlook'; + } + + public function run(IOutput $output): void { + $logoDimensions = $this->config->getAppValue('theming', 'logoDimensions'); + if (preg_match('/^\d+x\d+$/', $logoDimensions)) { + $output->info('Logo dimensions are already known'); + return; + } + + try { + /** @var ImageManager $imageManager */ + $imageManager = Server::get(ImageManager::class); + } catch (\Throwable) { + $output->info('Theming is disabled'); + return; + } + + if (!$imageManager->hasImage('logo')) { + $output->info('Theming is not used to provide a logo'); + return; + } + + try { + try { + $simpleFile = $imageManager->getImage('logo', false); + $image = @imagecreatefromstring($simpleFile->getContent()); + } catch (NotFoundException|NotPermittedException) { + $simpleFile = $imageManager->getImage('logo'); + $image = false; + } + } catch (NotFoundException|NotPermittedException) { + $output->info('Theming is not used to provide a logo'); + return; + } + + $dimensions = ''; + if ($image !== false) { + $dimensions = imagesx($image) . 'x' . imagesy($image); + } elseif (str_starts_with($simpleFile->getMimeType(), 'image/svg')) { + $matched = preg_match('/viewbox=["\']\d* \d* (\d*\.?\d*) (\d*\.?\d*)["\']/i', $simpleFile->getContent(), $matches); + if ($matched) { + $dimensions = $matches[1] . 'x' . $matches[2]; + } + } + + if (!$dimensions) { + $output->warning('Failed to read dimensions from logo'); + $this->config->deleteAppValue('theming', 'logoDimensions'); + return; + } + + $dimensions = imagesx($image) . 'x' . imagesy($image); + $this->config->setAppValue('theming', 'logoDimensions', $dimensions); + $output->info('Updated logo dimensions: ' . $dimensions); + } +} diff --git a/lib/private/Repair/RepairMimeTypes.php b/lib/private/Repair/RepairMimeTypes.php new file mode 100644 index 00000000000..3c9720b9e91 --- /dev/null +++ b/lib/private/Repair/RepairMimeTypes.php @@ -0,0 +1,473 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Repair; + +use OC\Migration\NullOutput; +use OCP\DB\Exception; +use OCP\DB\IResult; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IAppConfig; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class RepairMimeTypes implements IRepairStep { + private bool $dryRun = false; + private int $changeCount = 0; + + /** @var int */ + protected int $folderMimeTypeId; + + public function __construct( + protected IConfig $config, + protected IAppConfig $appConfig, + protected IDBConnection $connection, + ) { + } + + public function getName(): string { + return 'Repair mime types'; + } + + /** + * @throws Exception + */ + private function updateMimetypes($updatedMimetypes): IResult|int|null { + if ($this->dryRun) { + $this->changeCount += count($updatedMimetypes); + return null; + } + + $query = $this->connection->getQueryBuilder(); + $query->select('id') + ->from('mimetypes') + ->where($query->expr()->eq('mimetype', $query->createParameter('mimetype'), IQueryBuilder::PARAM_INT)); + $insert = $this->connection->getQueryBuilder(); + $insert->insert('mimetypes') + ->setValue('mimetype', $insert->createParameter('mimetype')); + + if (empty($this->folderMimeTypeId)) { + $query->setParameter('mimetype', 'httpd/unix-directory'); + $result = $query->execute(); + $this->folderMimeTypeId = (int)$result->fetchOne(); + $result->closeCursor(); + } + + $update = $this->connection->getQueryBuilder(); + $update->update('filecache') + ->runAcrossAllShards() + ->set('mimetype', $update->createParameter('mimetype')) + ->where($update->expr()->neq('mimetype', $update->createParameter('mimetype'), IQueryBuilder::PARAM_INT)) + ->andWhere($update->expr()->neq('mimetype', $update->createParameter('folder'), IQueryBuilder::PARAM_INT)) + ->andWhere($update->expr()->iLike('name', $update->createParameter('name'))) + ->setParameter('folder', $this->folderMimeTypeId); + + $count = 0; + foreach ($updatedMimetypes as $extension => $mimetype) { + // get target mimetype id + $query->setParameter('mimetype', $mimetype); + $result = $query->execute(); + $mimetypeId = (int)$result->fetchOne(); + $result->closeCursor(); + + if (!$mimetypeId) { + // insert mimetype + $insert->setParameter('mimetype', $mimetype); + $insert->execute(); + $mimetypeId = $insert->getLastInsertId(); + } + + // change mimetype for files with x extension + $update->setParameter('mimetype', $mimetypeId) + ->setParameter('name', '%' . $this->connection->escapeLikeParameter('.' . $extension)); + $count += $update->execute(); + } + + return $count; + } + + /** + * @throws Exception + * @since 12.0.0.14 + */ + private function introduceImageTypes(): IResult|int|null { + $updatedMimetypes = [ + 'jp2' => 'image/jp2', + 'webp' => 'image/webp', + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 12.0.0.13 + */ + private function introduceWindowsProgramTypes(): IResult|int|null { + $updatedMimetypes = [ + 'htaccess' => 'text/plain', + 'bat' => 'application/x-msdos-program', + 'cmd' => 'application/cmd', + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 13.0.0.0 + */ + private function introduceLocationTypes(): IResult|int|null { + $updatedMimetypes = [ + 'gpx' => 'application/gpx+xml', + 'kml' => 'application/vnd.google-earth.kml+xml', + 'kmz' => 'application/vnd.google-earth.kmz', + 'tcx' => 'application/vnd.garmin.tcx+xml', + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 13.0.0.3 + */ + private function introduceInternetShortcutTypes(): IResult|int|null { + $updatedMimetypes = [ + 'url' => 'application/internet-shortcut', + 'webloc' => 'application/internet-shortcut' + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 13.0.0.6 + */ + private function introduceStreamingTypes(): IResult|int|null { + $updatedMimetypes = [ + 'm3u' => 'audio/mpegurl', + 'm3u8' => 'audio/mpegurl', + 'pls' => 'audio/x-scpls' + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 14.0.0.8 + */ + private function introduceVisioTypes(): IResult|int|null { + $updatedMimetypes = [ + 'vsdm' => 'application/vnd.visio', + 'vsdx' => 'application/vnd.visio', + 'vssm' => 'application/vnd.visio', + 'vssx' => 'application/vnd.visio', + 'vstm' => 'application/vnd.visio', + 'vstx' => 'application/vnd.visio', + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 14.0.0.10 + */ + private function introduceComicbookTypes(): IResult|int|null { + $updatedMimetypes = [ + 'cb7' => 'application/comicbook+7z', + 'cba' => 'application/comicbook+ace', + 'cbr' => 'application/comicbook+rar', + 'cbt' => 'application/comicbook+tar', + 'cbtc' => 'application/comicbook+truecrypt', + 'cbz' => 'application/comicbook+zip', + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 20.0.0.5 + */ + private function introduceOpenDocumentTemplates(): IResult|int|null { + $updatedMimetypes = [ + 'ott' => 'application/vnd.oasis.opendocument.text-template', + 'ots' => 'application/vnd.oasis.opendocument.spreadsheet-template', + 'otp' => 'application/vnd.oasis.opendocument.presentation-template', + 'otg' => 'application/vnd.oasis.opendocument.graphics-template', + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 21.0.0.7 + */ + private function introduceOrgModeType(): IResult|int|null { + $updatedMimetypes = [ + 'org' => 'text/org' + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 23.0.0.2 + */ + private function introduceFlatOpenDocumentType(): IResult|int|null { + $updatedMimetypes = [ + 'fodt' => 'application/vnd.oasis.opendocument.text-flat-xml', + 'fods' => 'application/vnd.oasis.opendocument.spreadsheet-flat-xml', + 'fodg' => 'application/vnd.oasis.opendocument.graphics-flat-xml', + 'fodp' => 'application/vnd.oasis.opendocument.presentation-flat-xml', + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 25.0.0.2 + */ + private function introduceOnlyofficeFormType(): IResult|int|null { + $updatedMimetypes = [ + 'oform' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.oform', + 'docxf' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.docxf', + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 26.0.0.1 + */ + private function introduceAsciidocType(): IResult|int|null { + $updatedMimetypes = [ + 'adoc' => 'text/asciidoc', + 'asciidoc' => 'text/asciidoc', + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 28.0.0.5 + */ + private function introduceEnhancedMetafileFormatType(): IResult|int|null { + $updatedMimetypes = [ + 'emf' => 'image/emf', + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 29.0.0.2 + */ + private function introduceEmlAndMsgFormatType(): IResult|int|null { + $updatedMimetypes = [ + 'eml' => 'message/rfc822', + 'msg' => 'application/vnd.ms-outlook', + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 29.0.0.6 + */ + private function introduceAacAudioType(): IResult|int|null { + $updatedMimetypes = [ + 'aac' => 'audio/aac', + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 29.0.10 + */ + private function introduceReStructuredTextFormatType(): IResult|int|null { + $updatedMimetypes = [ + 'rst' => 'text/x-rst', + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 30.0.0 + */ + private function introduceExcalidrawType(): IResult|int|null { + $updatedMimetypes = [ + 'excalidraw' => 'application/vnd.excalidraw+json', + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + + /** + * @throws Exception + * @since 31.0.0 + */ + private function introduceZstType(): IResult|int|null { + $updatedMimetypes = [ + 'zst' => 'application/zstd', + 'nfo' => 'text/x-nfo', + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + /** + * @throws Exception + * @since 32.0.0 + */ + private function introduceMusicxmlType(): IResult|int|null { + $updatedMimetypes = [ + 'mxl' => 'application/vnd.recordare.musicxml', + 'musicxml' => 'application/vnd.recordare.musicxml+xml', + ]; + + return $this->updateMimetypes($updatedMimetypes); + } + + + + /** + * Check if there are any migrations available + * + * @throws Exception + */ + public function migrationsAvailable(): bool { + $this->dryRun = true; + $this->run(new NullOutput()); + $this->dryRun = false; + return $this->changeCount > 0; + } + + /** + * Get the current mimetype version + */ + private function getMimeTypeVersion(): string { + $serverVersion = $this->config->getSystemValueString('version', '0.0.0'); + // 29.0.0.10 is the last version with a mimetype migration before it was moved to a separate version number + if (version_compare($serverVersion, '29.0.0.10', '>')) { + return $this->appConfig->getValueString('files', 'mimetype_version', '29.0.0.10'); + } + + return $serverVersion; + } + + /** + * Fix mime types + * + * @throws Exception + */ + public function run(IOutput $out): void { + $serverVersion = $this->config->getSystemValueString('version', '0.0.0'); + $mimeTypeVersion = $this->getMimeTypeVersion(); + + // NOTE TO DEVELOPERS: when adding new mime types, please make sure to + // add a version comparison to avoid doing it every time + // PLEASE ALSO KEEP THE LIST SORTED BY VERSION NUMBER + + if (version_compare($mimeTypeVersion, '12.0.0.14', '<') && $this->introduceImageTypes()) { + $out->info('Fixed image mime types'); + } + + if (version_compare($mimeTypeVersion, '12.0.0.13', '<') && $this->introduceWindowsProgramTypes()) { + $out->info('Fixed windows program mime types'); + } + + if (version_compare($mimeTypeVersion, '13.0.0.0', '<') && $this->introduceLocationTypes()) { + $out->info('Fixed geospatial mime types'); + } + + if (version_compare($mimeTypeVersion, '13.0.0.3', '<') && $this->introduceInternetShortcutTypes()) { + $out->info('Fixed internet-shortcut mime types'); + } + + if (version_compare($mimeTypeVersion, '13.0.0.6', '<') && $this->introduceStreamingTypes()) { + $out->info('Fixed streaming mime types'); + } + + if (version_compare($mimeTypeVersion, '14.0.0.8', '<') && $this->introduceVisioTypes()) { + $out->info('Fixed visio mime types'); + } + + if (version_compare($mimeTypeVersion, '14.0.0.10', '<') && $this->introduceComicbookTypes()) { + $out->info('Fixed comicbook mime types'); + } + + if (version_compare($mimeTypeVersion, '20.0.0.5', '<') && $this->introduceOpenDocumentTemplates()) { + $out->info('Fixed OpenDocument template mime types'); + } + + if (version_compare($mimeTypeVersion, '21.0.0.7', '<') && $this->introduceOrgModeType()) { + $out->info('Fixed orgmode mime types'); + } + + if (version_compare($mimeTypeVersion, '23.0.0.2', '<') && $this->introduceFlatOpenDocumentType()) { + $out->info('Fixed Flat OpenDocument mime types'); + } + + if (version_compare($mimeTypeVersion, '25.0.0.2', '<') && $this->introduceOnlyofficeFormType()) { + $out->info('Fixed ONLYOFFICE Forms OpenXML mime types'); + } + + if (version_compare($mimeTypeVersion, '26.0.0.1', '<') && $this->introduceAsciidocType()) { + $out->info('Fixed AsciiDoc mime types'); + } + + if (version_compare($mimeTypeVersion, '28.0.0.5', '<') && $this->introduceEnhancedMetafileFormatType()) { + $out->info('Fixed Enhanced Metafile Format mime types'); + } + + if (version_compare($mimeTypeVersion, '29.0.0.2', '<') && $this->introduceEmlAndMsgFormatType()) { + $out->info('Fixed eml and msg mime type'); + } + + if (version_compare($mimeTypeVersion, '29.0.0.6', '<') && $this->introduceAacAudioType()) { + $out->info('Fixed aac mime type'); + } + + if (version_compare($mimeTypeVersion, '29.0.0.10', '<') && $this->introduceReStructuredTextFormatType()) { + $out->info('Fixed ReStructured Text mime type'); + } + + if (version_compare($mimeTypeVersion, '30.0.0.0', '<') && $this->introduceExcalidrawType()) { + $out->info('Fixed Excalidraw mime type'); + } + + if (version_compare($mimeTypeVersion, '31.0.0.0', '<') && $this->introduceZstType()) { + $out->info('Fixed zst mime type'); + } + + if (version_compare($mimeTypeVersion, '32.0.0.0', '<') && $this->introduceMusicxmlType()) { + $out->info('Fixed musicxml mime type'); + } + + if (!$this->dryRun) { + $this->appConfig->setValueString('files', 'mimetype_version', $serverVersion); + } + } +} |