aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/lib/Command
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/lib/Command')
-rw-r--r--apps/files/lib/Command/Copy.php116
-rw-r--r--apps/files/lib/Command/Delete.php99
-rw-r--r--apps/files/lib/Command/DeleteOrphanedFiles.php162
-rw-r--r--apps/files/lib/Command/Get.php71
-rw-r--r--apps/files/lib/Command/Move.php106
-rw-r--r--apps/files/lib/Command/Object/Delete.php60
-rw-r--r--apps/files/lib/Command/Object/Get.php63
-rw-r--r--apps/files/lib/Command/Object/Info.php80
-rw-r--r--apps/files/lib/Command/Object/ListObject.php50
-rw-r--r--apps/files/lib/Command/Object/Multi/Rename.php108
-rw-r--r--apps/files/lib/Command/Object/Multi/Users.php98
-rw-r--r--apps/files/lib/Command/Object/ObjectUtil.php115
-rw-r--r--apps/files/lib/Command/Object/Orphans.php79
-rw-r--r--apps/files/lib/Command/Object/Put.php68
-rw-r--r--apps/files/lib/Command/Put.php67
-rw-r--r--apps/files/lib/Command/RepairTree.php39
-rw-r--r--apps/files/lib/Command/SanitizeFilenames.php151
-rw-r--r--apps/files/lib/Command/Scan.php263
-rw-r--r--apps/files/lib/Command/ScanAppData.php113
-rw-r--r--apps/files/lib/Command/TransferOwnership.php139
-rw-r--r--apps/files/lib/Command/WindowsCompatibleFilenames.php52
21 files changed, 1755 insertions, 344 deletions
diff --git a/apps/files/lib/Command/Copy.php b/apps/files/lib/Command/Copy.php
new file mode 100644
index 00000000000..ad0dfa90de1
--- /dev/null
+++ b/apps/files/lib/Command/Copy.php
@@ -0,0 +1,116 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command;
+
+use OC\Core\Command\Info\FileUtils;
+use OCP\Files\Folder;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+
+class Copy extends Command {
+ public function __construct(
+ private FileUtils $fileUtils,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:copy')
+ ->setDescription('Copy a file or folder')
+ ->addArgument('source', InputArgument::REQUIRED, 'Source file id or path')
+ ->addArgument('target', InputArgument::REQUIRED, 'Target path')
+ ->addOption('force', 'f', InputOption::VALUE_NONE, "Don't ask for confirmation and don't output any warnings")
+ ->addOption('no-target-directory', 'T', InputOption::VALUE_NONE, 'When target path is folder, overwrite the folder instead of copying into the folder');
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $sourceInput = $input->getArgument('source');
+ $targetInput = $input->getArgument('target');
+ $force = $input->getOption('force');
+ $noTargetDir = $input->getOption('no-target-directory');
+
+ $node = $this->fileUtils->getNode($sourceInput);
+ $targetNode = $this->fileUtils->getNode($targetInput);
+
+ if (!$node) {
+ $output->writeln("<error>file $sourceInput not found</error>");
+ return 1;
+ }
+
+ $targetParentPath = dirname(rtrim($targetInput, '/'));
+ $targetParent = $this->fileUtils->getNode($targetParentPath);
+ if (!$targetParent) {
+ $output->writeln("<error>Target parent path $targetParentPath doesn't exist</error>");
+ return 1;
+ }
+
+ $wouldRequireDelete = false;
+
+ if ($targetNode) {
+ if (!$targetNode->isUpdateable()) {
+ $output->writeln("<error>$targetInput isn't writable</error>");
+ return 1;
+ }
+
+ if ($targetNode instanceof Folder) {
+ if ($noTargetDir) {
+ if (!$force) {
+ $output->writeln("Warning: <info>$sourceInput</info> is a file, but <info>$targetInput</info> is a folder");
+ }
+ $wouldRequireDelete = true;
+ } else {
+ $targetInput = $targetNode->getFullPath($node->getName());
+ $targetNode = $this->fileUtils->getNode($targetInput);
+ }
+ } else {
+ if ($node instanceof Folder) {
+ if (!$force) {
+ $output->writeln("Warning: <info>$sourceInput</info> is a folder, but <info>$targetInput</info> is a file");
+ }
+ $wouldRequireDelete = true;
+ }
+ }
+
+ if ($wouldRequireDelete && $targetNode->getInternalPath() === '') {
+ $output->writeln("<error>Mount root can't be overwritten with a different type</error>");
+ return 1;
+ }
+
+ if ($wouldRequireDelete && !$targetNode->isDeletable()) {
+ $output->writeln("<error>$targetInput can't be deleted to be replaced with $sourceInput</error>");
+ return 1;
+ }
+
+ if (!$force && $targetNode) {
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+
+ $question = new ConfirmationQuestion('<info>' . $targetInput . '</info> already exists, overwrite? [y/N] ', false);
+ if (!$helper->ask($input, $output, $question)) {
+ return 1;
+ }
+ }
+ }
+
+ if ($wouldRequireDelete && $targetNode) {
+ $targetNode->delete();
+ }
+
+ $node->copy($targetInput);
+
+ return 0;
+ }
+
+}
diff --git a/apps/files/lib/Command/Delete.php b/apps/files/lib/Command/Delete.php
new file mode 100644
index 00000000000..d984f839c91
--- /dev/null
+++ b/apps/files/lib/Command/Delete.php
@@ -0,0 +1,99 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command;
+
+use OC\Core\Command\Info\FileUtils;
+use OCA\Files_Sharing\SharedStorage;
+use OCP\Files\Folder;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+
+class Delete extends Command {
+ public function __construct(
+ private FileUtils $fileUtils,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:delete')
+ ->setDescription('Delete a file or folder')
+ ->addArgument('file', InputArgument::REQUIRED, 'File id or path')
+ ->addOption('force', 'f', InputOption::VALUE_NONE, "Don't ask for configuration and don't output any warnings");
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $fileInput = $input->getArgument('file');
+ $inputIsId = is_numeric($fileInput);
+ $force = $input->getOption('force');
+ $node = $this->fileUtils->getNode($fileInput);
+
+ if (!$node) {
+ $output->writeln("<error>file $fileInput not found</error>");
+ return self::FAILURE;
+ }
+
+ $deleteConfirmed = $force;
+ if (!$deleteConfirmed) {
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+ $storage = $node->getStorage();
+ if (!$inputIsId && $storage->instanceOfStorage(SharedStorage::class) && $node->getInternalPath() === '') {
+ /** @var SharedStorage $storage */
+ [,$user] = explode('/', $fileInput, 3);
+ $question = new ConfirmationQuestion("<info>$fileInput</info> in a shared file, do you want to unshare the file from <info>$user</info> instead of deleting the source file? [Y/n] ", true);
+ if ($helper->ask($input, $output, $question)) {
+ $storage->unshareStorage();
+ return self::SUCCESS;
+ } else {
+ $node = $storage->getShare()->getNode();
+ $output->writeln('');
+ }
+ }
+
+ $filesByUsers = $this->fileUtils->getFilesByUser($node);
+ if (count($filesByUsers) > 1) {
+ $output->writeln('Warning: the provided file is accessible by more than one user');
+ $output->writeln(' all of the following users will lose access to the file when deleted:');
+ $output->writeln('');
+ foreach ($filesByUsers as $user => $filesByUser) {
+ $output->writeln($user . ':');
+ foreach ($filesByUser as $file) {
+ $output->writeln(' - ' . $file->getPath());
+ }
+ }
+ $output->writeln('');
+ }
+
+ if ($node instanceof Folder) {
+ $maybeContents = " and all it's contents";
+ } else {
+ $maybeContents = '';
+ }
+ $question = new ConfirmationQuestion('Delete ' . $node->getPath() . $maybeContents . '? [y/N] ', false);
+ $deleteConfirmed = $helper->ask($input, $output, $question);
+ }
+
+ if ($deleteConfirmed) {
+ if ($node->isDeletable()) {
+ $node->delete();
+ } else {
+ $output->writeln('<error>File cannot be deleted, insufficient permissions.</error>');
+ }
+ }
+
+ return self::SUCCESS;
+ }
+}
diff --git a/apps/files/lib/Command/DeleteOrphanedFiles.php b/apps/files/lib/Command/DeleteOrphanedFiles.php
index e3305fe3b9b..37cb3159f4a 100644
--- a/apps/files/lib/Command/DeleteOrphanedFiles.php
+++ b/apps/files/lib/Command/DeleteOrphanedFiles.php
@@ -1,32 +1,17 @@
<?php
+
/**
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Morris Jobke <hey@morrisjobke.de>
- *
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\Files\Command;
+use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
@@ -35,56 +20,123 @@ use Symfony\Component\Console\Output\OutputInterface;
class DeleteOrphanedFiles extends Command {
public const CHUNK_SIZE = 200;
- /**
- * @var IDBConnection
- */
- protected $connection;
-
- public function __construct(IDBConnection $connection) {
- $this->connection = $connection;
+ public function __construct(
+ protected IDBConnection $connection,
+ ) {
parent::__construct();
}
- protected function configure() {
+ protected function configure(): void {
$this
->setName('files:cleanup')
- ->setDescription('cleanup filecache');
+ ->setDescription('Clean up orphaned filecache and mount entries')
+ ->setHelp('Deletes orphaned filecache and mount entries (those without an existing storage).')
+ ->addOption('skip-filecache-extended', null, InputOption::VALUE_NONE, 'don\'t remove orphaned entries from filecache_extended');
}
public function execute(InputInterface $input, OutputInterface $output): int {
- $deletedEntries = 0;
+ $fileIdsByStorage = [];
+
+ $deletedStorages = array_diff($this->getReferencedStorages(), $this->getExistingStorages());
+
+ $deleteExtended = !$input->getOption('skip-filecache-extended');
+ if ($deleteExtended) {
+ $fileIdsByStorage = $this->getFileIdsForStorages($deletedStorages);
+ }
+
+ $deletedEntries = $this->cleanupOrphanedFileCache($deletedStorages);
+ $output->writeln("$deletedEntries orphaned file cache entries deleted");
+ if ($deleteExtended) {
+ $deletedFileCacheExtended = $this->cleanupOrphanedFileCacheExtended($fileIdsByStorage);
+ $output->writeln("$deletedFileCacheExtended orphaned file cache extended entries deleted");
+ }
+
+ $deletedMounts = $this->cleanupOrphanedMounts();
+ $output->writeln("$deletedMounts orphaned mount entries deleted");
+
+ return self::SUCCESS;
+ }
+
+ private function getReferencedStorages(): array {
$query = $this->connection->getQueryBuilder();
- $query->select('fc.fileid')
- ->from('filecache', 'fc')
- ->where($query->expr()->isNull('s.numeric_id'))
- ->leftJoin('fc', 'storages', 's', $query->expr()->eq('fc.storage', 's.numeric_id'))
- ->setMaxResults(self::CHUNK_SIZE);
+ $query->select('storage')
+ ->from('filecache')
+ ->groupBy('storage')
+ ->runAcrossAllShards();
+ return $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN);
+ }
+
+ private function getExistingStorages(): array {
+ $query = $this->connection->getQueryBuilder();
+ $query->select('numeric_id')
+ ->from('storages')
+ ->groupBy('numeric_id');
+ return $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN);
+ }
+
+ /**
+ * @param int[] $storageIds
+ * @return array<int, int[]>
+ */
+ private function getFileIdsForStorages(array $storageIds): array {
+ $query = $this->connection->getQueryBuilder();
+ $query->select('storage', 'fileid')
+ ->from('filecache')
+ ->where($query->expr()->in('storage', $query->createParameter('storage_ids')));
+
+ $result = [];
+ $storageIdChunks = array_chunk($storageIds, self::CHUNK_SIZE);
+ foreach ($storageIdChunks as $storageIdChunk) {
+ $query->setParameter('storage_ids', $storageIdChunk, IQueryBuilder::PARAM_INT_ARRAY);
+ $chunk = $query->executeQuery()->fetchAll();
+ foreach ($chunk as $row) {
+ $result[$row['storage']][] = $row['fileid'];
+ }
+ }
+ return $result;
+ }
+
+ private function cleanupOrphanedFileCache(array $deletedStorages): int {
+ $deletedEntries = 0;
$deleteQuery = $this->connection->getQueryBuilder();
$deleteQuery->delete('filecache')
- ->where($deleteQuery->expr()->eq('fileid', $deleteQuery->createParameter('objectid')));
+ ->where($deleteQuery->expr()->in('storage', $deleteQuery->createParameter('storage_ids')));
- $deletedInLastChunk = self::CHUNK_SIZE;
- while ($deletedInLastChunk === self::CHUNK_SIZE) {
- $deletedInLastChunk = 0;
- $result = $query->execute();
- while ($row = $result->fetch()) {
- $deletedInLastChunk++;
- $deletedEntries += $deleteQuery->setParameter('objectid', (int) $row['fileid'])
- ->execute();
- }
- $result->closeCursor();
+ $deletedStorageChunks = array_chunk($deletedStorages, self::CHUNK_SIZE);
+ foreach ($deletedStorageChunks as $deletedStorageChunk) {
+ $deleteQuery->setParameter('storage_ids', $deletedStorageChunk, IQueryBuilder::PARAM_INT_ARRAY);
+ $deletedEntries += $deleteQuery->executeStatement();
}
- $output->writeln("$deletedEntries orphaned file cache entries deleted");
+ return $deletedEntries;
+ }
- $deletedMounts = $this->cleanupOrphanedMounts();
- $output->writeln("$deletedMounts orphaned mount entries deleted");
- return 0;
+ /**
+ * @param array<int, int[]> $fileIdsByStorage
+ * @return int
+ */
+ private function cleanupOrphanedFileCacheExtended(array $fileIdsByStorage): int {
+ $deletedEntries = 0;
+
+ $deleteQuery = $this->connection->getQueryBuilder();
+ $deleteQuery->delete('filecache_extended')
+ ->where($deleteQuery->expr()->in('fileid', $deleteQuery->createParameter('file_ids')));
+
+ foreach ($fileIdsByStorage as $storageId => $fileIds) {
+ $deleteQuery->hintShardKey('storage', $storageId, true);
+ $fileChunks = array_chunk($fileIds, self::CHUNK_SIZE);
+ foreach ($fileChunks as $fileChunk) {
+ $deleteQuery->setParameter('file_ids', $fileChunk, IQueryBuilder::PARAM_INT_ARRAY);
+ $deletedEntries += $deleteQuery->executeStatement();
+ }
+ }
+
+ return $deletedEntries;
}
- private function cleanupOrphanedMounts() {
+ private function cleanupOrphanedMounts(): int {
$deletedEntries = 0;
$query = $this->connection->getQueryBuilder();
@@ -102,11 +154,11 @@ class DeleteOrphanedFiles extends Command {
$deletedInLastChunk = self::CHUNK_SIZE;
while ($deletedInLastChunk === self::CHUNK_SIZE) {
$deletedInLastChunk = 0;
- $result = $query->execute();
+ $result = $query->executeQuery();
while ($row = $result->fetch()) {
$deletedInLastChunk++;
- $deletedEntries += $deleteQuery->setParameter('storageid', (int) $row['storage_id'])
- ->execute();
+ $deletedEntries += $deleteQuery->setParameter('storageid', (int)$row['storage_id'])
+ ->executeStatement();
}
$result->closeCursor();
}
diff --git a/apps/files/lib/Command/Get.php b/apps/files/lib/Command/Get.php
new file mode 100644
index 00000000000..60e028f615e
--- /dev/null
+++ b/apps/files/lib/Command/Get.php
@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command;
+
+use OC\Core\Command\Info\FileUtils;
+use OCP\Files\File;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Get extends Command {
+ public function __construct(
+ private FileUtils $fileUtils,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:get')
+ ->setDescription('Get the contents of a file')
+ ->addArgument('file', InputArgument::REQUIRED, 'Source file id or Nextcloud path')
+ ->addArgument('output', InputArgument::OPTIONAL, 'Target local file to output to, defaults to STDOUT');
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $fileInput = $input->getArgument('file');
+ $outputName = $input->getArgument('output');
+ $node = $this->fileUtils->getNode($fileInput);
+
+ if (!$node) {
+ $output->writeln("<error>file $fileInput not found</error>");
+ return self::FAILURE;
+ }
+
+ if (!($node instanceof File)) {
+ $output->writeln("<error>$fileInput is a directory</error>");
+ return self::FAILURE;
+ }
+
+ $isTTY = stream_isatty(STDOUT);
+ if ($outputName === null && $isTTY && $node->getMimePart() !== 'text') {
+ $output->writeln([
+ '<error>Warning: Binary output can mess up your terminal</error>',
+ " Use <info>occ files:get $fileInput -</info> to output it to the terminal anyway",
+ " Or <info>occ files:get $fileInput <FILE></info> to save to a file instead"
+ ]);
+ return self::FAILURE;
+ }
+ $source = $node->fopen('r');
+ if (!$source) {
+ $output->writeln("<error>Failed to open $fileInput for reading</error>");
+ return self::FAILURE;
+ }
+ $target = ($outputName === null || $outputName === '-') ? STDOUT : fopen($outputName, 'w');
+ if (!$target) {
+ $output->writeln("<error>Failed to open $outputName for reading</error>");
+ return self::FAILURE;
+ }
+
+ stream_copy_to_stream($source, $target);
+ return self::SUCCESS;
+ }
+}
diff --git a/apps/files/lib/Command/Move.php b/apps/files/lib/Command/Move.php
new file mode 100644
index 00000000000..29dd8860b2a
--- /dev/null
+++ b/apps/files/lib/Command/Move.php
@@ -0,0 +1,106 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command;
+
+use OC\Core\Command\Info\FileUtils;
+use OCP\Files\File;
+use OCP\Files\Folder;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+
+class Move extends Command {
+ public function __construct(
+ private FileUtils $fileUtils,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:move')
+ ->setDescription('Move a file or folder')
+ ->addArgument('source', InputArgument::REQUIRED, 'Source file id or path')
+ ->addArgument('target', InputArgument::REQUIRED, 'Target path')
+ ->addOption('force', 'f', InputOption::VALUE_NONE, "Don't ask for configuration and don't output any warnings");
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $sourceInput = $input->getArgument('source');
+ $targetInput = $input->getArgument('target');
+ $force = $input->getOption('force');
+
+ $node = $this->fileUtils->getNode($sourceInput);
+ $targetNode = $this->fileUtils->getNode($targetInput);
+
+ if (!$node) {
+ $output->writeln("<error>file $sourceInput not found</error>");
+ return 1;
+ }
+
+ $targetParentPath = dirname(rtrim($targetInput, '/'));
+ $targetParent = $this->fileUtils->getNode($targetParentPath);
+ if (!$targetParent) {
+ $output->writeln("<error>Target parent path $targetParentPath doesn't exist</error>");
+ return 1;
+ }
+
+ $wouldRequireDelete = false;
+
+ if ($targetNode) {
+ if (!$targetNode->isUpdateable()) {
+ $output->writeln("<error>$targetInput already exists and isn't writable</error>");
+ return 1;
+ }
+
+ if ($node instanceof Folder && $targetNode instanceof File) {
+ $output->writeln("Warning: <info>$sourceInput</info> is a folder, but <info>$targetInput</info> is a file");
+ $wouldRequireDelete = true;
+ }
+
+ if ($node instanceof File && $targetNode instanceof Folder) {
+ $output->writeln("Warning: <info>$sourceInput</info> is a file, but <info>$targetInput</info> is a folder");
+ $wouldRequireDelete = true;
+ }
+
+ if ($wouldRequireDelete && $targetNode->getInternalPath() === '') {
+ $output->writeln("<error>Mount root can't be overwritten with a different type</error>");
+ return 1;
+ }
+
+ if ($wouldRequireDelete && !$targetNode->isDeletable()) {
+ $output->writeln("<error>$targetInput can't be deleted to be replaced with $sourceInput</error>");
+ return 1;
+ }
+
+ if (!$force) {
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+
+ $question = new ConfirmationQuestion('<info>' . $targetInput . '</info> already exists, overwrite? [y/N] ', false);
+ if (!$helper->ask($input, $output, $question)) {
+ return 1;
+ }
+ }
+ }
+
+ if ($wouldRequireDelete && $targetNode) {
+ $targetNode->delete();
+ }
+
+ $node->move($targetInput);
+
+ return 0;
+ }
+
+}
diff --git a/apps/files/lib/Command/Object/Delete.php b/apps/files/lib/Command/Object/Delete.php
new file mode 100644
index 00000000000..07613ecc616
--- /dev/null
+++ b/apps/files/lib/Command/Object/Delete.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+
+class Delete extends Command {
+ public function __construct(
+ private ObjectUtil $objectUtils,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:object:delete')
+ ->setDescription('Delete an object from the object store')
+ ->addArgument('object', InputArgument::REQUIRED, 'Object to delete')
+ ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to delete the object from, only required in cases where it can't be determined from the config");
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $object = $input->getArgument('object');
+ $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output);
+ if (!$objectStore) {
+ return -1;
+ }
+
+ if ($fileId = $this->objectUtils->objectExistsInDb($object)) {
+ $output->writeln("<error>Warning, object $object belongs to an existing file, deleting the object will lead to unexpected behavior if not replaced</error>");
+ $output->writeln(" Note: use <info>occ files:delete $fileId</info> to delete the file cleanly or <info>occ info:file $fileId</info> for more information about the file");
+ $output->writeln('');
+ }
+
+ if (!$objectStore->objectExists($object)) {
+ $output->writeln("<error>Object $object does not exist</error>");
+ return -1;
+ }
+
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+ $question = new ConfirmationQuestion("Delete $object? [y/N] ", false);
+ if ($helper->ask($input, $output, $question)) {
+ $objectStore->deleteObject($object);
+ }
+ return self::SUCCESS;
+ }
+}
diff --git a/apps/files/lib/Command/Object/Get.php b/apps/files/lib/Command/Object/Get.php
new file mode 100644
index 00000000000..c32de020c5a
--- /dev/null
+++ b/apps/files/lib/Command/Object/Get.php
@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Get extends Command {
+ public function __construct(
+ private ObjectUtil $objectUtils,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:object:get')
+ ->setDescription('Get the contents of an object')
+ ->addArgument('object', InputArgument::REQUIRED, 'Object to get')
+ ->addArgument('output', InputArgument::REQUIRED, 'Target local file to output to, use - for STDOUT')
+ ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to get the object from, only required in cases where it can't be determined from the config");
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $object = $input->getArgument('object');
+ $outputName = $input->getArgument('output');
+ $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output);
+ if (!$objectStore) {
+ return self::FAILURE;
+ }
+
+ if (!$objectStore->objectExists($object)) {
+ $output->writeln("<error>Object $object does not exist</error>");
+ return self::FAILURE;
+ }
+
+ try {
+ $source = $objectStore->readObject($object);
+ } catch (\Exception $e) {
+ $msg = $e->getMessage();
+ $output->writeln("<error>Failed to read $object from object store: $msg</error>");
+ return self::FAILURE;
+ }
+ $target = $outputName === '-' ? STDOUT : fopen($outputName, 'w');
+ if (!$target) {
+ $output->writeln("<error>Failed to open $outputName for writing</error>");
+ return self::FAILURE;
+ }
+
+ stream_copy_to_stream($source, $target);
+ return self::SUCCESS;
+ }
+
+}
diff --git a/apps/files/lib/Command/Object/Info.php b/apps/files/lib/Command/Object/Info.php
new file mode 100644
index 00000000000..6748de37cfe
--- /dev/null
+++ b/apps/files/lib/Command/Object/Info.php
@@ -0,0 +1,80 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object;
+
+use OC\Core\Command\Base;
+use OCP\Files\IMimeTypeDetector;
+use OCP\Files\ObjectStore\IObjectStoreMetaData;
+use OCP\Util;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Info extends Base {
+ public function __construct(
+ private ObjectUtil $objectUtils,
+ private IMimeTypeDetector $mimeTypeDetector,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ parent::configure();
+ $this
+ ->setName('files:object:info')
+ ->setDescription('Get the metadata of an object')
+ ->addArgument('object', InputArgument::REQUIRED, 'Object to get')
+ ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to get the object from, only required in cases where it can't be determined from the config");
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $object = $input->getArgument('object');
+ $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output);
+ if (!$objectStore) {
+ return self::FAILURE;
+ }
+
+ if (!$objectStore instanceof IObjectStoreMetaData) {
+ $output->writeln('<error>Configured object store does currently not support retrieve metadata</error>');
+ return self::FAILURE;
+ }
+
+ if (!$objectStore->objectExists($object)) {
+ $output->writeln("<error>Object $object does not exist</error>");
+ return self::FAILURE;
+ }
+
+ try {
+ $meta = $objectStore->getObjectMetaData($object);
+ } catch (\Exception $e) {
+ $msg = $e->getMessage();
+ $output->writeln("<error>Failed to read $object from object store: $msg</error>");
+ return self::FAILURE;
+ }
+
+ if ($input->getOption('output') === 'plain' && isset($meta['size'])) {
+ $meta['size'] = Util::humanFileSize($meta['size']);
+ }
+ if (isset($meta['mtime'])) {
+ $meta['mtime'] = $meta['mtime']->format(\DateTimeImmutable::ATOM);
+ }
+ if (!isset($meta['mimetype'])) {
+ $handle = $objectStore->readObject($object);
+ $head = fread($handle, 8192);
+ fclose($handle);
+ $meta['mimetype'] = $this->mimeTypeDetector->detectString($head);
+ }
+
+ $this->writeArrayInOutputFormat($input, $output, $meta);
+
+ return self::SUCCESS;
+ }
+
+}
diff --git a/apps/files/lib/Command/Object/ListObject.php b/apps/files/lib/Command/Object/ListObject.php
new file mode 100644
index 00000000000..5d30232e09f
--- /dev/null
+++ b/apps/files/lib/Command/Object/ListObject.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object;
+
+use OC\Core\Command\Base;
+use OCP\Files\ObjectStore\IObjectStoreMetaData;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class ListObject extends Base {
+ private const CHUNK_SIZE = 100;
+
+ public function __construct(
+ private readonly ObjectUtil $objectUtils,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ parent::configure();
+ $this
+ ->setName('files:object:list')
+ ->setDescription('List all objects in the object store')
+ ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to list the objects from, only required in cases where it can't be determined from the config");
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output);
+ if (!$objectStore) {
+ return self::FAILURE;
+ }
+
+ if (!$objectStore instanceof IObjectStoreMetaData) {
+ $output->writeln('<error>Configured object store does currently not support listing objects</error>');
+ return self::FAILURE;
+ }
+ $objects = $objectStore->listObjects();
+ $objects = $this->objectUtils->formatObjects($objects, $input->getOption('output') === self::OUTPUT_FORMAT_PLAIN);
+ $this->writeStreamingTableInOutputFormat($input, $output, $objects, self::CHUNK_SIZE);
+
+ return self::SUCCESS;
+ }
+}
diff --git a/apps/files/lib/Command/Object/Multi/Rename.php b/apps/files/lib/Command/Object/Multi/Rename.php
new file mode 100644
index 00000000000..562c68eb07f
--- /dev/null
+++ b/apps/files/lib/Command/Object/Multi/Rename.php
@@ -0,0 +1,108 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2025 Robin Appelman <robin@icewind.nl>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object\Multi;
+
+use OC\Core\Command\Base;
+use OC\Files\ObjectStore\PrimaryObjectStoreConfig;
+use OCP\IConfig;
+use OCP\IDBConnection;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+
+class Rename extends Base {
+ public function __construct(
+ private readonly IDBConnection $connection,
+ private readonly PrimaryObjectStoreConfig $objectStoreConfig,
+ private readonly IConfig $config,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ parent::configure();
+ $this
+ ->setName('files:object:multi:rename-config')
+ ->setDescription('Rename an object store configuration and move all users over to the new configuration,')
+ ->addArgument('source', InputArgument::REQUIRED, 'Object store configuration to rename')
+ ->addArgument('target', InputArgument::REQUIRED, 'New name for the object store configuration');
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $source = $input->getArgument('source');
+ $target = $input->getArgument('target');
+
+ $configs = $this->objectStoreConfig->getObjectStoreConfigs();
+ if (!isset($configs[$source])) {
+ $output->writeln('<error>Unknown object store configuration: ' . $source . '</error>');
+ return 1;
+ }
+
+ if ($source === 'root') {
+ $output->writeln('<error>Renaming the root configuration is not supported.</error>');
+ return 1;
+ }
+
+ if ($source === 'default') {
+ $output->writeln('<error>Renaming the default configuration is not supported.</error>');
+ return 1;
+ }
+
+ if (!isset($configs[$target])) {
+ $output->writeln('<comment>Target object store configuration ' . $target . ' doesn\'t exist yet.</comment>');
+ $output->writeln('The target configuration can be created automatically.');
+ $output->writeln('However, as this depends on modifying the config.php, this only works as long as the instance runs on a single node or all nodes in a clustered setup have a shared config file (such as from a shared network mount).');
+ $output->writeln('If the different nodes have a separate copy of the config.php file, the automatic object store configuration creation will lead to the configuration going out of sync.');
+ $output->writeln('If these requirements are not met, you can manually create the target object store configuration in each node\'s configuration before running the command.');
+ $output->writeln('');
+ $output->writeln('<error>Failure to check these requirements will lead to data loss for users.</error>');
+
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+ $question = new ConfirmationQuestion('Automatically create target object store configuration? [y/N] ', false);
+ if ($helper->ask($input, $output, $question)) {
+ $configs[$target] = $configs[$source];
+
+ // update all aliases
+ foreach ($configs as &$config) {
+ if ($config === $source) {
+ $config = $target;
+ }
+ }
+ $this->config->setSystemValue('objectstore', $configs);
+ } else {
+ return 0;
+ }
+ } elseif (($configs[$source] !== $configs[$target]) || $configs[$source] !== $target) {
+ $output->writeln('<error>Source and target configuration differ.</error>');
+ $output->writeln('');
+ $output->writeln('To ensure proper migration of users, the source and target configuration must be the same to ensure that the objects for the moved users exist on the target configuration.');
+ $output->writeln('The usual migration process consists of creating a clone of the old configuration, moving the users from the old configuration to the new one, and then adjust the old configuration that is longer used.');
+ return 1;
+ }
+
+ $query = $this->connection->getQueryBuilder();
+ $query->update('preferences')
+ ->set('configvalue', $query->createNamedParameter($target))
+ ->where($query->expr()->eq('appid', $query->createNamedParameter('homeobjectstore')))
+ ->andWhere($query->expr()->eq('configkey', $query->createNamedParameter('objectstore')))
+ ->andWhere($query->expr()->eq('configvalue', $query->createNamedParameter($source)));
+ $count = $query->executeStatement();
+
+ if ($count > 0) {
+ $output->writeln('Moved <info>' . $count . '</info> users');
+ } else {
+ $output->writeln('No users moved');
+ }
+
+ return 0;
+ }
+}
diff --git a/apps/files/lib/Command/Object/Multi/Users.php b/apps/files/lib/Command/Object/Multi/Users.php
new file mode 100644
index 00000000000..e8f7d012641
--- /dev/null
+++ b/apps/files/lib/Command/Object/Multi/Users.php
@@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2025 Robin Appelman <robin@icewind.nl>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object\Multi;
+
+use OC\Core\Command\Base;
+use OC\Files\ObjectStore\PrimaryObjectStoreConfig;
+use OCP\IConfig;
+use OCP\IUser;
+use OCP\IUserManager;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Users extends Base {
+ public function __construct(
+ private readonly IUserManager $userManager,
+ private readonly PrimaryObjectStoreConfig $objectStoreConfig,
+ private readonly IConfig $config,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ parent::configure();
+ $this
+ ->setName('files:object:multi:users')
+ ->setDescription('Get the mapping between users and object store buckets')
+ ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, 'Only list users using the specified bucket')
+ ->addOption('object-store', 'o', InputOption::VALUE_REQUIRED, 'Only list users using the specified object store configuration')
+ ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Only show the mapping for the specified user, ignores all other options');
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ if ($userId = $input->getOption('user')) {
+ $user = $this->userManager->get($userId);
+ if (!$user) {
+ $output->writeln("<error>User $userId not found</error>");
+ return 1;
+ }
+ $users = new \ArrayIterator([$user]);
+ } else {
+ $bucket = (string)$input->getOption('bucket');
+ $objectStore = (string)$input->getOption('object-store');
+ if ($bucket !== '' && $objectStore === '') {
+ $users = $this->getUsers($this->config->getUsersForUserValue('homeobjectstore', 'bucket', $bucket));
+ } elseif ($bucket === '' && $objectStore !== '') {
+ $users = $this->getUsers($this->config->getUsersForUserValue('homeobjectstore', 'objectstore', $objectStore));
+ } elseif ($bucket) {
+ $users = $this->getUsers(array_intersect(
+ $this->config->getUsersForUserValue('homeobjectstore', 'bucket', $bucket),
+ $this->config->getUsersForUserValue('homeobjectstore', 'objectstore', $objectStore)
+ ));
+ } else {
+ $users = $this->userManager->getSeenUsers();
+ }
+ }
+
+ $this->writeStreamingTableInOutputFormat($input, $output, $this->infoForUsers($users), 100);
+ return 0;
+ }
+
+ /**
+ * @param string[] $userIds
+ * @return \Iterator<IUser>
+ */
+ private function getUsers(array $userIds): \Iterator {
+ foreach ($userIds as $userId) {
+ $user = $this->userManager->get($userId);
+ if ($user) {
+ yield $user;
+ }
+ }
+ }
+
+ /**
+ * @param \Iterator<IUser> $users
+ * @return \Iterator<array>
+ */
+ private function infoForUsers(\Iterator $users): \Iterator {
+ foreach ($users as $user) {
+ yield $this->infoForUser($user);
+ }
+ }
+
+ private function infoForUser(IUser $user): array {
+ return [
+ 'user' => $user->getUID(),
+ 'object-store' => $this->objectStoreConfig->getObjectStoreForUser($user),
+ 'bucket' => $this->objectStoreConfig->getSetBucketForUser($user) ?? 'unset',
+ ];
+ }
+}
diff --git a/apps/files/lib/Command/Object/ObjectUtil.php b/apps/files/lib/Command/Object/ObjectUtil.php
new file mode 100644
index 00000000000..5f053c2c42f
--- /dev/null
+++ b/apps/files/lib/Command/Object/ObjectUtil.php
@@ -0,0 +1,115 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object;
+
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\Files\ObjectStore\IObjectStore;
+use OCP\IConfig;
+use OCP\IDBConnection;
+use OCP\Util;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class ObjectUtil {
+ public function __construct(
+ private IConfig $config,
+ private IDBConnection $connection,
+ ) {
+ }
+
+ private function getObjectStoreConfig(): ?array {
+ $config = $this->config->getSystemValue('objectstore_multibucket');
+ if (is_array($config)) {
+ $config['multibucket'] = true;
+ return $config;
+ }
+ $config = $this->config->getSystemValue('objectstore');
+ if (is_array($config)) {
+ if (!isset($config['multibucket'])) {
+ $config['multibucket'] = false;
+ }
+ return $config;
+ }
+
+ return null;
+ }
+
+ public function getObjectStore(?string $bucket, OutputInterface $output): ?IObjectStore {
+ $config = $this->getObjectStoreConfig();
+ if (!$config) {
+ $output->writeln('<error>Instance is not using primary object store</error>');
+ return null;
+ }
+ if ($config['multibucket'] && !$bucket) {
+ $output->writeln('<error>--bucket option required</error> because <info>multi bucket</info> is enabled.');
+ return null;
+ }
+
+ if (!isset($config['arguments'])) {
+ throw new \Exception('no arguments configured for object store configuration');
+ }
+ if (!isset($config['class'])) {
+ throw new \Exception('no class configured for object store configuration');
+ }
+
+ if ($bucket) {
+ // s3, swift
+ $config['arguments']['bucket'] = $bucket;
+ // azure
+ $config['arguments']['container'] = $bucket;
+ }
+
+ $store = new $config['class']($config['arguments']);
+ if (!$store instanceof IObjectStore) {
+ throw new \Exception('configured object store class is not an object store implementation');
+ }
+ return $store;
+ }
+
+ /**
+ * Check if an object is referenced in the database
+ */
+ public function objectExistsInDb(string $object): int|false {
+ if (!str_starts_with($object, 'urn:oid:')) {
+ return false;
+ }
+
+ $fileId = (int)substr($object, strlen('urn:oid:'));
+ $query = $this->connection->getQueryBuilder();
+ $query->select('fileid')
+ ->from('filecache')
+ ->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)));
+ $result = $query->executeQuery();
+
+ if ($result->fetchOne() === false) {
+ return false;
+ }
+
+ return $fileId;
+ }
+
+ public function formatObjects(\Iterator $objects, bool $humanOutput): \Iterator {
+ foreach ($objects as $object) {
+ yield $this->formatObject($object, $humanOutput);
+ }
+ }
+
+ public function formatObject(array $object, bool $humanOutput): array {
+ $row = array_merge([
+ 'urn' => $object['urn'],
+ ], ($object['metadata'] ?? []));
+
+ if ($humanOutput && isset($row['size'])) {
+ $row['size'] = Util::humanFileSize($row['size']);
+ }
+ if (isset($row['mtime'])) {
+ $row['mtime'] = $row['mtime']->format(\DateTimeImmutable::ATOM);
+ }
+ return $row;
+ }
+}
diff --git a/apps/files/lib/Command/Object/Orphans.php b/apps/files/lib/Command/Object/Orphans.php
new file mode 100644
index 00000000000..f7132540fc8
--- /dev/null
+++ b/apps/files/lib/Command/Object/Orphans.php
@@ -0,0 +1,79 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object;
+
+use OC\Core\Command\Base;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\Files\ObjectStore\IObjectStoreMetaData;
+use OCP\IDBConnection;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Orphans extends Base {
+ private const CHUNK_SIZE = 100;
+
+ private ?IQueryBuilder $query = null;
+
+ public function __construct(
+ private readonly ObjectUtil $objectUtils,
+ private readonly IDBConnection $connection,
+ ) {
+ parent::__construct();
+ }
+
+ private function getQuery(): IQueryBuilder {
+ if (!$this->query) {
+ $this->query = $this->connection->getQueryBuilder();
+ $this->query->select('fileid')
+ ->from('filecache')
+ ->where($this->query->expr()->eq('fileid', $this->query->createParameter('file_id')));
+ }
+ return $this->query;
+ }
+
+ protected function configure(): void {
+ parent::configure();
+ $this
+ ->setName('files:object:orphans')
+ ->setDescription('List all objects in the object store that don\'t have a matching entry in the database')
+ ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to list the objects from, only required in cases where it can't be determined from the config");
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output);
+ if (!$objectStore) {
+ return self::FAILURE;
+ }
+
+ if (!$objectStore instanceof IObjectStoreMetaData) {
+ $output->writeln('<error>Configured object store does currently not support listing objects</error>');
+ return self::FAILURE;
+ }
+ $prefixLength = strlen('urn:oid:');
+
+ $objects = $objectStore->listObjects('urn:oid:');
+ $orphans = new \CallbackFilterIterator($objects, function (array $object) use ($prefixLength) {
+ $fileId = (int)substr($object['urn'], $prefixLength);
+ return !$this->fileIdInDb($fileId);
+ });
+
+ $orphans = $this->objectUtils->formatObjects($orphans, $input->getOption('output') === self::OUTPUT_FORMAT_PLAIN);
+ $this->writeStreamingTableInOutputFormat($input, $output, $orphans, self::CHUNK_SIZE);
+
+ return self::SUCCESS;
+ }
+
+ private function fileIdInDb(int $fileId): bool {
+ $query = $this->getQuery();
+ $query->setParameter('file_id', $fileId, IQueryBuilder::PARAM_INT);
+ $result = $query->executeQuery();
+ return $result->fetchOne() !== false;
+ }
+}
diff --git a/apps/files/lib/Command/Object/Put.php b/apps/files/lib/Command/Object/Put.php
new file mode 100644
index 00000000000..8516eb51183
--- /dev/null
+++ b/apps/files/lib/Command/Object/Put.php
@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command\Object;
+
+use OCP\Files\IMimeTypeDetector;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+
+class Put extends Command {
+ public function __construct(
+ private ObjectUtil $objectUtils,
+ private IMimeTypeDetector $mimeTypeDetector,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:object:put')
+ ->setDescription('Write a file to the object store')
+ ->addArgument('input', InputArgument::REQUIRED, 'Source local path, use - to read from STDIN')
+ ->addArgument('object', InputArgument::REQUIRED, 'Object to write')
+ ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket where to store the object, only required in cases where it can't be determined from the config");
+ ;
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $object = $input->getArgument('object');
+ $inputName = (string)$input->getArgument('input');
+ $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output);
+ if (!$objectStore) {
+ return -1;
+ }
+
+ if ($fileId = $this->objectUtils->objectExistsInDb($object)) {
+ $output->writeln("<error>Warning, object $object belongs to an existing file, overwriting the object contents can lead to unexpected behavior.</error>");
+ $output->writeln("You can use <info>occ files:put $inputName $fileId</info> to write to the file safely.");
+ $output->writeln('');
+
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+ $question = new ConfirmationQuestion('Write to the object anyway? [y/N] ', false);
+ if (!$helper->ask($input, $output, $question)) {
+ return -1;
+ }
+ }
+
+ $source = $inputName === '-' ? STDIN : fopen($inputName, 'r');
+ if (!$source) {
+ $output->writeln("<error>Failed to open $inputName</error>");
+ return self::FAILURE;
+ }
+ $objectStore->writeObject($object, $source, $this->mimeTypeDetector->detectPath($inputName));
+ return self::SUCCESS;
+ }
+
+}
diff --git a/apps/files/lib/Command/Put.php b/apps/files/lib/Command/Put.php
new file mode 100644
index 00000000000..fd9d75db78c
--- /dev/null
+++ b/apps/files/lib/Command/Put.php
@@ -0,0 +1,67 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files\Command;
+
+use OC\Core\Command\Info\FileUtils;
+use OCP\Files\File;
+use OCP\Files\Folder;
+use OCP\Files\IRootFolder;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Put extends Command {
+ public function __construct(
+ private FileUtils $fileUtils,
+ private IRootFolder $rootFolder,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('files:put')
+ ->setDescription('Write contents of a file')
+ ->addArgument('input', InputArgument::REQUIRED, 'Source local path, use - to read from STDIN')
+ ->addArgument('file', InputArgument::REQUIRED, 'Target Nextcloud file path to write to or fileid of existing file');
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int {
+ $fileOutput = $input->getArgument('file');
+ $inputName = $input->getArgument('input');
+ $node = $this->fileUtils->getNode($fileOutput);
+
+ if ($node instanceof Folder) {
+ $output->writeln("<error>$fileOutput is a folder</error>");
+ return self::FAILURE;
+ }
+ if (!$node and is_numeric($fileOutput)) {
+ $output->writeln("<error>$fileOutput not found</error>");
+ return self::FAILURE;
+ }
+
+ $source = ($inputName === null || $inputName === '-') ? STDIN : fopen($inputName, 'r');
+ if (!$source) {
+ $output->writeln("<error>Failed to open $inputName</error>");
+ return self::FAILURE;
+ }
+ if ($node instanceof File) {
+ $target = $node->fopen('w');
+ if (!$target) {
+ $output->writeln("<error>Failed to open $fileOutput</error>");
+ return self::FAILURE;
+ }
+ stream_copy_to_stream($source, $target);
+ } else {
+ $this->rootFolder->newFile($fileOutput, $source);
+ }
+ return self::SUCCESS;
+ }
+}
diff --git a/apps/files/lib/Command/RepairTree.php b/apps/files/lib/Command/RepairTree.php
index 521fe3d6a4a..622ccba48a3 100644
--- a/apps/files/lib/Command/RepairTree.php
+++ b/apps/files/lib/Command/RepairTree.php
@@ -3,25 +3,8 @@
declare(strict_types=1);
/**
- * @copyright Copyright (c) 2021 Robin Appelman <robin@icewind.nl>
- *
- * @author Robin Appelman <robin@icewind.nl>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files\Command;
@@ -33,17 +16,13 @@ use Symfony\Component\Console\Output\OutputInterface;
class RepairTree extends Command {
public const CHUNK_SIZE = 200;
- /**
- * @var IDBConnection
- */
- protected $connection;
-
- public function __construct(IDBConnection $connection) {
- $this->connection = $connection;
+ public function __construct(
+ protected IDBConnection $connection,
+ ) {
parent::__construct();
}
- protected function configure() {
+ protected function configure(): void {
$this
->setName('files:repair-tree')
->setDescription('Try and repair malformed filesystem tree structures')
@@ -54,7 +33,7 @@ class RepairTree extends Command {
$rows = $this->findBrokenTreeBits();
$fix = !$input->getOption('dry-run');
- $output->writeln("Found " . count($rows) . " file entries with an invalid path");
+ $output->writeln('Found ' . count($rows) . ' file entries with an invalid path');
if ($fix) {
$this->connection->beginTransaction();
@@ -90,7 +69,7 @@ class RepairTree extends Command {
$this->connection->commit();
}
- return 0;
+ return self::SUCCESS;
}
private function getFileId(int $storage, string $path) {
@@ -102,7 +81,7 @@ class RepairTree extends Command {
return $query->execute()->fetch(\PDO::FETCH_COLUMN);
}
- private function deleteById(int $fileId) {
+ private function deleteById(int $fileId): void {
$query = $this->connection->getQueryBuilder();
$query->delete('filecache')
->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId)));
diff --git a/apps/files/lib/Command/SanitizeFilenames.php b/apps/files/lib/Command/SanitizeFilenames.php
new file mode 100644
index 00000000000..88d41d1cb5e
--- /dev/null
+++ b/apps/files/lib/Command/SanitizeFilenames.php
@@ -0,0 +1,151 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Command;
+
+use Exception;
+use OC\Core\Command\Base;
+use OC\Files\FilenameValidator;
+use OCP\Files\Folder;
+use OCP\Files\IRootFolder;
+use OCP\Files\NotPermittedException;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use OCP\L10N\IFactory;
+use OCP\Lock\LockedException;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class SanitizeFilenames extends Base {
+
+ private OutputInterface $output;
+ private ?string $charReplacement;
+ private bool $dryRun;
+
+ public function __construct(
+ private IUserManager $userManager,
+ private IRootFolder $rootFolder,
+ private IUserSession $session,
+ private IFactory $l10nFactory,
+ private FilenameValidator $filenameValidator,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ parent::configure();
+
+ $this
+ ->setName('files:sanitize-filenames')
+ ->setDescription('Renames files to match naming constraints')
+ ->addArgument(
+ 'user_id',
+ InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
+ 'will only rename files the given user(s) have access to'
+ )
+ ->addOption(
+ 'dry-run',
+ mode: InputOption::VALUE_NONE,
+ description: 'Do not actually rename any files but just check filenames.',
+ )
+ ->addOption(
+ 'char-replacement',
+ 'c',
+ mode: InputOption::VALUE_REQUIRED,
+ description: 'Replacement for invalid character (by default space, underscore or dash is used)',
+ );
+
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $this->charReplacement = $input->getOption('char-replacement');
+ // check if replacement is needed
+ $c = $this->filenameValidator->getForbiddenCharacters();
+ if (count($c) > 0) {
+ try {
+ $this->filenameValidator->sanitizeFilename($c[0], $this->charReplacement);
+ } catch (\InvalidArgumentException) {
+ if ($this->charReplacement === null) {
+ $output->writeln('<error>Character replacement required</error>');
+ } else {
+ $output->writeln('<error>Invalid character replacement given</error>');
+ }
+ return 1;
+ }
+ }
+
+ $this->dryRun = $input->getOption('dry-run');
+ if ($this->dryRun) {
+ $output->writeln('<info>Dry run is enabled, no actual renaming will be applied.</>');
+ }
+
+ $this->output = $output;
+ $users = $input->getArgument('user_id');
+ if (!empty($users)) {
+ foreach ($users as $userId) {
+ $user = $this->userManager->get($userId);
+ if ($user === null) {
+ $output->writeln("<error>User '$userId' does not exist - skipping</>");
+ continue;
+ }
+ $this->sanitizeUserFiles($user);
+ }
+ } else {
+ $this->userManager->callForSeenUsers($this->sanitizeUserFiles(...));
+ }
+ return self::SUCCESS;
+ }
+
+ private function sanitizeUserFiles(IUser $user): void {
+ // Set an active user so that event listeners can correctly work (e.g. files versions)
+ $this->session->setVolatileActiveUser($user);
+
+ $this->output->writeln('<info>Analyzing files of ' . $user->getUID() . '</>');
+
+ $folder = $this->rootFolder->getUserFolder($user->getUID());
+ $this->sanitizeFiles($folder);
+ }
+
+ private function sanitizeFiles(Folder $folder): void {
+ foreach ($folder->getDirectoryListing() as $node) {
+ $this->output->writeln('scanning: ' . $node->getPath(), OutputInterface::VERBOSITY_VERBOSE);
+
+ try {
+ $oldName = $node->getName();
+ $newName = $this->filenameValidator->sanitizeFilename($oldName, $this->charReplacement);
+ if ($oldName !== $newName) {
+ $newName = $folder->getNonExistingName($newName);
+ $path = rtrim(dirname($node->getPath()), '/');
+
+ if (!$this->dryRun) {
+ $node->move("$path/$newName");
+ } elseif (!$folder->isCreatable()) {
+ // simulate error for dry run
+ throw new NotPermittedException();
+ }
+ $this->output->writeln('renamed: "' . $oldName . '" to "' . $newName . '"');
+ }
+ } catch (LockedException) {
+ $this->output->writeln('<comment>skipping: ' . $node->getPath() . ' (file is locked)</>');
+ } catch (NotPermittedException) {
+ $this->output->writeln('<comment>skipping: ' . $node->getPath() . ' (no permissions)</>');
+ } catch (Exception $error) {
+ $this->output->writeln('<error>failed: ' . $node->getPath() . '</>');
+ $this->output->writeln('<error>' . $error->getMessage() . '</>', OutputInterface::OUTPUT_NORMAL | OutputInterface::VERBOSITY_VERBOSE);
+ }
+
+ if ($node instanceof Folder) {
+ $this->sanitizeFiles($node);
+ }
+ }
+ }
+
+}
diff --git a/apps/files/lib/Command/Scan.php b/apps/files/lib/Command/Scan.php
index 710c76de493..b9057139b0e 100644
--- a/apps/files/lib/Command/Scan.php
+++ b/apps/files/lib/Command/Scan.php
@@ -1,35 +1,9 @@
<?php
+
/**
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- *
- * @author Bart Visscher <bartv@thisnet.nl>
- * @author Blaok <i@blaok.me>
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Kesselberg <mail@danielkesselberg.de>
- * @author J0WI <J0WI@users.noreply.github.com>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Joel S <joel.devbox@protonmail.com>
- * @author Jörn Friedrich Dreyer <jfd@butonic.de>
- * @author martin.mattel@diemattels.at <martin.mattel@diemattels.at>
- * @author Robin Appelman <robin@icewind.nl>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- * @author Vincent Petry <vincent@nextcloud.com>
- *
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\Files\Command;
@@ -37,15 +11,22 @@ use OC\Core\Command\Base;
use OC\Core\Command\InterruptedException;
use OC\DB\Connection;
use OC\DB\ConnectionAdapter;
-use OCP\Files\File;
+use OC\Files\Storage\Wrapper\Jail;
+use OC\Files\Utils\Scanner;
+use OC\FilesMetadata\FilesMetadataManager;
use OC\ForbiddenException;
-use OC\Metadata\MetadataManager;
use OCP\EventDispatcher\IEventDispatcher;
+use OCP\Files\Events\FileCacheUpdated;
+use OCP\Files\Events\NodeAddedToCache;
+use OCP\Files\Events\NodeRemovedFromCache;
use OCP\Files\IRootFolder;
use OCP\Files\Mount\IMountPoint;
use OCP\Files\NotFoundException;
use OCP\Files\StorageNotAvailableException;
+use OCP\FilesMetadata\IFilesMetadataManager;
use OCP\IUserManager;
+use OCP\Lock\LockedException;
+use OCP\Server;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
@@ -54,25 +35,25 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class Scan extends Base {
- private IUserManager $userManager;
protected float $execTime = 0;
protected int $foldersCounter = 0;
protected int $filesCounter = 0;
- private IRootFolder $root;
- private MetadataManager $metadataManager;
+ protected int $errorsCounter = 0;
+ protected int $newCounter = 0;
+ protected int $updatedCounter = 0;
+ protected int $removedCounter = 0;
public function __construct(
- IUserManager $userManager,
- IRootFolder $rootFolder,
- MetadataManager $metadataManager
+ private IUserManager $userManager,
+ private IRootFolder $rootFolder,
+ private FilesMetadataManager $filesMetadataManager,
+ private IEventDispatcher $eventDispatcher,
+ private LoggerInterface $logger,
) {
- $this->userManager = $userManager;
parent::__construct();
- $this->root = $rootFolder;
- $this->metadataManager = $metadataManager;
}
- protected function configure() {
+ protected function configure(): void {
parent::configure();
$this
@@ -86,14 +67,15 @@ class Scan extends Base {
->addOption(
'path',
'p',
- InputArgument::OPTIONAL,
+ InputOption::VALUE_REQUIRED,
'limit rescan to this path, eg. --path="/alice/files/Music", the user_id is determined by the path and the user_id parameter and --all are ignored'
)
->addOption(
'generate-metadata',
null,
- InputOption::VALUE_NONE,
- 'Generate metadata for all scanned files'
+ InputOption::VALUE_OPTIONAL,
+ 'Generate metadata for all scanned files; if specified only generate for named value',
+ ''
)
->addOption(
'all',
@@ -118,63 +100,95 @@ class Scan extends Base {
);
}
- protected function scanFiles(string $user, string $path, bool $scanMetadata, OutputInterface $output, bool $backgroundScan = false, bool $recursive = true, bool $homeOnly = false): void {
+ protected function scanFiles(
+ string $user,
+ string $path,
+ ?string $scanMetadata,
+ OutputInterface $output,
+ callable $mountFilter,
+ bool $backgroundScan = false,
+ bool $recursive = true,
+ ): void {
$connection = $this->reconnectToDatabase($output);
- $scanner = new \OC\Files\Utils\Scanner(
+ $scanner = new Scanner(
$user,
new ConnectionAdapter($connection),
- \OC::$server->get(IEventDispatcher::class),
- \OC::$server->get(LoggerInterface::class)
+ Server::get(IEventDispatcher::class),
+ Server::get(LoggerInterface::class)
);
# check on each file/folder if there was a user interrupt (ctrl-c) and throw an exception
- $scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function (string $path) use ($output, $scanMetadata) {
+ $scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function (string $path) use ($output, $scanMetadata): void {
$output->writeln("\tFile\t<info>$path</info>", OutputInterface::VERBOSITY_VERBOSE);
++$this->filesCounter;
$this->abortIfInterrupted();
- if ($scanMetadata) {
- $node = $this->root->get($path);
- if ($node instanceof File) {
- $this->metadataManager->generateMetadata($node, false);
- }
+ if ($scanMetadata !== null) {
+ $node = $this->rootFolder->get($path);
+ $this->filesMetadataManager->refreshMetadata(
+ $node,
+ ($scanMetadata !== '') ? IFilesMetadataManager::PROCESS_NAMED : IFilesMetadataManager::PROCESS_LIVE | IFilesMetadataManager::PROCESS_BACKGROUND,
+ $scanMetadata
+ );
}
});
- $scanner->listen('\OC\Files\Utils\Scanner', 'scanFolder', function ($path) use ($output) {
+ $scanner->listen('\OC\Files\Utils\Scanner', 'scanFolder', function ($path) use ($output): void {
$output->writeln("\tFolder\t<info>$path</info>", OutputInterface::VERBOSITY_VERBOSE);
++$this->foldersCounter;
$this->abortIfInterrupted();
});
- $scanner->listen('\OC\Files\Utils\Scanner', 'StorageNotAvailable', function (StorageNotAvailableException $e) use ($output) {
+ $scanner->listen('\OC\Files\Utils\Scanner', 'StorageNotAvailable', function (StorageNotAvailableException $e) use ($output): void {
$output->writeln('Error while scanning, storage not available (' . $e->getMessage() . ')', OutputInterface::VERBOSITY_VERBOSE);
+ ++$this->errorsCounter;
});
- $scanner->listen('\OC\Files\Utils\Scanner', 'normalizedNameMismatch', function ($fullPath) use ($output) {
+ $scanner->listen('\OC\Files\Utils\Scanner', 'normalizedNameMismatch', function ($fullPath) use ($output): void {
$output->writeln("\t<error>Entry \"" . $fullPath . '" will not be accessible due to incompatible encoding</error>');
+ ++$this->errorsCounter;
+ });
+
+ $this->eventDispatcher->addListener(NodeAddedToCache::class, function (): void {
+ ++$this->newCounter;
+ });
+ $this->eventDispatcher->addListener(FileCacheUpdated::class, function (): void {
+ ++$this->updatedCounter;
+ });
+ $this->eventDispatcher->addListener(NodeRemovedFromCache::class, function (): void {
+ ++$this->removedCounter;
});
try {
if ($backgroundScan) {
$scanner->backgroundScan($path);
} else {
- $scanner->scan($path, $recursive, $homeOnly ? [$this, 'filterHomeMount'] : null);
+ $scanner->scan($path, $recursive, $mountFilter);
}
} catch (ForbiddenException $e) {
$output->writeln("<error>Home storage for user $user not writable or 'files' subdirectory missing</error>");
+ $output->writeln(' ' . $e->getMessage());
$output->writeln('Make sure you\'re running the scan command only as the user the web server runs as');
+ ++$this->errorsCounter;
} catch (InterruptedException $e) {
# exit the function if ctrl-c has been pressed
$output->writeln('Interrupted by user');
} catch (NotFoundException $e) {
$output->writeln('<error>Path not found: ' . $e->getMessage() . '</error>');
+ ++$this->errorsCounter;
+ } catch (LockedException $e) {
+ if (str_starts_with($e->getPath(), 'scanner::')) {
+ $output->writeln('<error>Another process is already scanning \'' . substr($e->getPath(), strlen('scanner::')) . '\'</error>');
+ } else {
+ throw $e;
+ }
} catch (\Exception $e) {
$output->writeln('<error>Exception during scan: ' . $e->getMessage() . '</error>');
$output->writeln('<error>' . $e->getTraceAsString() . '</error>');
+ ++$this->errorsCounter;
}
}
- public function filterHomeMount(IMountPoint $mountPoint) {
+ public function isHomeMount(IMountPoint $mountPoint): bool {
// any mountpoint inside '/$user/files/'
return substr_count($mountPoint->getMountPoint(), '/') <= 3;
}
@@ -191,30 +205,62 @@ class Scan extends Base {
$users = $input->getArgument('user_id');
}
- # restrict the verbosity level to VERBOSITY_VERBOSE
- if ($output->getVerbosity() > OutputInterface::VERBOSITY_VERBOSE) {
- $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE);
- }
-
# check quantity of users to be process and show it on the command line
$users_total = count($users);
if ($users_total === 0) {
$output->writeln('<error>Please specify the user id to scan, --all to scan for all users or --path=...</error>');
- return 1;
+ return self::FAILURE;
+ }
+
+ $this->initTools($output);
+
+ // getOption() logic on VALUE_OPTIONAL
+ $metadata = null; // null if --generate-metadata is not set, empty if option have no value, value if set
+ if ($input->getOption('generate-metadata') !== '') {
+ $metadata = $input->getOption('generate-metadata') ?? '';
}
- $this->initTools();
+ $homeOnly = $input->getOption('home-only');
+ $scannedStorages = [];
+ $mountFilter = function (IMountPoint $mount) use ($homeOnly, &$scannedStorages) {
+ if ($homeOnly && !$this->isHomeMount($mount)) {
+ return false;
+ }
+
+ // when scanning multiple users, the scanner might encounter the same storage multiple times (e.g. external storages, or group folders)
+ // we can filter out any storage we've already scanned to avoid double work
+ $storage = $mount->getStorage();
+ $storageKey = $storage->getId();
+ while ($storage->instanceOfStorage(Jail::class)) {
+ $storageKey .= '/' . $storage->getUnjailedPath('');
+ $storage = $storage->getUnjailedStorage();
+ }
+ if (array_key_exists($storageKey, $scannedStorages)) {
+ return false;
+ }
+
+ $scannedStorages[$storageKey] = true;
+ return true;
+ };
$user_count = 0;
foreach ($users as $user) {
if (is_object($user)) {
$user = $user->getUID();
}
- $path = $inputPath ? $inputPath : '/' . $user;
+ $path = $inputPath ?: '/' . $user;
++$user_count;
if ($this->userManager->userExists($user)) {
$output->writeln("Starting scan for user $user_count out of $users_total ($user)");
- $this->scanFiles($user, $path, $input->getOption('generate-metadata'), $output, $input->getOption('unscanned'), !$input->getOption('shallow'), $input->getOption('home-only'));
+ $this->scanFiles(
+ $user,
+ $path,
+ $metadata,
+ $output,
+ $mountFilter,
+ $input->getOption('unscanned'),
+ !$input->getOption('shallow'),
+ );
$output->writeln('', OutputInterface::VERBOSITY_VERBOSE);
} else {
$output->writeln("<error>Unknown user $user_count $user</error>");
@@ -229,21 +275,25 @@ class Scan extends Base {
}
$this->presentStats($output);
- return 0;
+ return self::SUCCESS;
}
/**
* Initialises some useful tools for the Command
*/
- protected function initTools() {
+ protected function initTools(OutputInterface $output): void {
// Start the timer
$this->execTime = -microtime(true);
// Convert PHP errors to exceptions
- set_error_handler([$this, 'exceptionErrorHandler'], E_ALL);
+ set_error_handler(
+ fn (int $severity, string $message, string $file, int $line): bool
+ => $this->exceptionErrorHandler($output, $severity, $message, $file, $line),
+ E_ALL
+ );
}
/**
- * Processes PHP errors as exceptions in order to be able to keep track of problems
+ * Processes PHP errors in order to be able to show them in the output
*
* @see https://www.php.net/manual/en/function.set-error-handler.php
*
@@ -251,47 +301,44 @@ class Scan extends Base {
* @param string $message
* @param string $file the filename that the error was raised in
* @param int $line the line number the error was raised
- *
- * @throws \ErrorException
*/
- public function exceptionErrorHandler($severity, $message, $file, $line) {
- if (!(error_reporting() & $severity)) {
- // This error code is not included in error_reporting
- return;
+ public function exceptionErrorHandler(OutputInterface $output, int $severity, string $message, string $file, int $line): bool {
+ if (($severity === E_DEPRECATED) || ($severity === E_USER_DEPRECATED)) {
+ // Do not show deprecation warnings
+ return false;
}
- throw new \ErrorException($message, 0, $severity, $file, $line);
+ $e = new \ErrorException($message, 0, $severity, $file, $line);
+ $output->writeln('<error>Error during scan: ' . $e->getMessage() . '</error>');
+ $output->writeln('<error>' . $e->getTraceAsString() . '</error>', OutputInterface::VERBOSITY_VERY_VERBOSE);
+ ++$this->errorsCounter;
+ return true;
}
- /**
- * @param OutputInterface $output
- */
- protected function presentStats(OutputInterface $output) {
+ protected function presentStats(OutputInterface $output): void {
// Stop the timer
$this->execTime += microtime(true);
+ $this->logger->info("Completed scan of {$this->filesCounter} files in {$this->foldersCounter} folder. Found {$this->newCounter} new, {$this->updatedCounter} updated and {$this->removedCounter} removed items");
+
$headers = [
- 'Folders', 'Files', 'Elapsed time'
+ 'Folders',
+ 'Files',
+ 'New',
+ 'Updated',
+ 'Removed',
+ 'Errors',
+ 'Elapsed time',
];
-
- $this->showSummary($headers, null, $output);
- }
-
- /**
- * Shows a summary of operations
- *
- * @param string[] $headers
- * @param string[] $rows
- * @param OutputInterface $output
- */
- protected function showSummary($headers, $rows, OutputInterface $output) {
$niceDate = $this->formatExecTime();
- if (!$rows) {
- $rows = [
- $this->foldersCounter,
- $this->filesCounter,
- $niceDate,
- ];
- }
+ $rows = [
+ $this->foldersCounter,
+ $this->filesCounter,
+ $this->newCounter,
+ $this->updatedCounter,
+ $this->removedCounter,
+ $this->errorsCounter,
+ $niceDate,
+ ];
$table = new Table($output);
$table
->setHeaders($headers)
@@ -301,11 +348,9 @@ class Scan extends Base {
/**
- * Formats microtime into a human readable format
- *
- * @return string
+ * Formats microtime into a human-readable format
*/
- protected function formatExecTime() {
+ protected function formatExecTime(): string {
$secs = (int)round($this->execTime);
# convert seconds into HH:MM:SS form
return sprintf('%02d:%02d:%02d', (int)($secs / 3600), ((int)($secs / 60) % 60), $secs % 60);
@@ -313,7 +358,7 @@ class Scan extends Base {
protected function reconnectToDatabase(OutputInterface $output): Connection {
/** @var Connection $connection */
- $connection = \OC::$server->get(Connection::class);
+ $connection = Server::get(Connection::class);
try {
$connection->close();
} catch (\Exception $ex) {
diff --git a/apps/files/lib/Command/ScanAppData.php b/apps/files/lib/Command/ScanAppData.php
index 63e13733b2a..0e08c6a8cfe 100644
--- a/apps/files/lib/Command/ScanAppData.php
+++ b/apps/files/lib/Command/ScanAppData.php
@@ -1,31 +1,8 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Kesselberg <mail@danielkesselberg.de>
- * @author J0WI <J0WI@users.noreply.github.com>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Joel S <joel.devbox@protonmail.com>
- * @author Morris Jobke <hey@morrisjobke.de>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Erik Wouters <6179932+EWouters@users.noreply.github.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files\Command;
@@ -33,12 +10,16 @@ use OC\Core\Command\Base;
use OC\Core\Command\InterruptedException;
use OC\DB\Connection;
use OC\DB\ConnectionAdapter;
+use OC\Files\Utils\Scanner;
use OC\ForbiddenException;
use OCP\EventDispatcher\IEventDispatcher;
+use OCP\Files\Folder;
use OCP\Files\IRootFolder;
+use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\Files\StorageNotAvailableException;
use OCP\IConfig;
+use OCP\Server;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
@@ -46,26 +27,20 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ScanAppData extends Base {
+ protected float $execTime = 0;
- /** @var IRootFolder */
- protected $root;
- /** @var IConfig */
- protected $config;
- /** @var float */
- protected $execTime = 0;
- /** @var int */
- protected $foldersCounter = 0;
- /** @var int */
- protected $filesCounter = 0;
-
- public function __construct(IRootFolder $rootFolder, IConfig $config) {
- parent::__construct();
+ protected int $foldersCounter = 0;
- $this->root = $rootFolder;
- $this->config = $config;
+ protected int $filesCounter = 0;
+
+ public function __construct(
+ protected IRootFolder $rootFolder,
+ protected IConfig $config,
+ ) {
+ parent::__construct();
}
- protected function configure() {
+ protected function configure(): void {
parent::configure();
$this
@@ -77,10 +52,11 @@ class ScanAppData extends Base {
protected function scanFiles(OutputInterface $output, string $folder): int {
try {
+ /** @var Folder $appData */
$appData = $this->getAppDataFolder();
} catch (NotFoundException $e) {
$output->writeln('<error>NoAppData folder found</error>');
- return 1;
+ return self::FAILURE;
}
if ($folder !== '') {
@@ -88,36 +64,36 @@ class ScanAppData extends Base {
$appData = $appData->get($folder);
} catch (NotFoundException $e) {
$output->writeln('<error>Could not find folder: ' . $folder . '</error>');
- return 1;
+ return self::FAILURE;
}
}
$connection = $this->reconnectToDatabase($output);
- $scanner = new \OC\Files\Utils\Scanner(
+ $scanner = new Scanner(
null,
new ConnectionAdapter($connection),
- \OC::$server->query(IEventDispatcher::class),
- \OC::$server->get(LoggerInterface::class)
+ Server::get(IEventDispatcher::class),
+ Server::get(LoggerInterface::class)
);
# check on each file/folder if there was a user interrupt (ctrl-c) and throw an exception
- $scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function ($path) use ($output) {
+ $scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function ($path) use ($output): void {
$output->writeln("\tFile <info>$path</info>", OutputInterface::VERBOSITY_VERBOSE);
++$this->filesCounter;
$this->abortIfInterrupted();
});
- $scanner->listen('\OC\Files\Utils\Scanner', 'scanFolder', function ($path) use ($output) {
+ $scanner->listen('\OC\Files\Utils\Scanner', 'scanFolder', function ($path) use ($output): void {
$output->writeln("\tFolder <info>$path</info>", OutputInterface::VERBOSITY_VERBOSE);
++$this->foldersCounter;
$this->abortIfInterrupted();
});
- $scanner->listen('\OC\Files\Utils\Scanner', 'StorageNotAvailable', function (StorageNotAvailableException $e) use ($output) {
+ $scanner->listen('\OC\Files\Utils\Scanner', 'StorageNotAvailable', function (StorageNotAvailableException $e) use ($output): void {
$output->writeln('Error while scanning, storage not available (' . $e->getMessage() . ')', OutputInterface::VERBOSITY_VERBOSE);
});
- $scanner->listen('\OC\Files\Utils\Scanner', 'normalizedNameMismatch', function ($fullPath) use ($output) {
+ $scanner->listen('\OC\Files\Utils\Scanner', 'normalizedNameMismatch', function ($fullPath) use ($output): void {
$output->writeln("\t<error>Entry \"" . $fullPath . '" will not be accessible due to incompatible encoding</error>');
});
@@ -126,21 +102,21 @@ class ScanAppData extends Base {
} catch (ForbiddenException $e) {
$output->writeln('<error>Storage not writable</error>');
$output->writeln('<info>Make sure you\'re running the scan command only as the user the web server runs as</info>');
- return 1;
+ return self::FAILURE;
} catch (InterruptedException $e) {
# exit the function if ctrl-c has been pressed
$output->writeln('<info>Interrupted by user</info>');
- return 1;
+ return self::FAILURE;
} catch (NotFoundException $e) {
$output->writeln('<error>Path not found: ' . $e->getMessage() . '</error>');
- return 1;
+ return self::FAILURE;
} catch (\Exception $e) {
$output->writeln('<error>Exception during scan: ' . $e->getMessage() . '</error>');
$output->writeln('<error>' . $e->getTraceAsString() . '</error>');
- return 1;
+ return self::FAILURE;
}
- return 0;
+ return self::SUCCESS;
}
@@ -167,7 +143,7 @@ class ScanAppData extends Base {
/**
* Initialises some useful tools for the Command
*/
- protected function initTools() {
+ protected function initTools(): void {
// Start the timer
$this->execTime = -microtime(true);
// Convert PHP errors to exceptions
@@ -194,10 +170,7 @@ class ScanAppData extends Base {
throw new \ErrorException($message, 0, $severity, $file, $line);
}
- /**
- * @param OutputInterface $output
- */
- protected function presentStats(OutputInterface $output) {
+ protected function presentStats(OutputInterface $output): void {
// Stop the timer
$this->execTime += microtime(true);
@@ -213,9 +186,8 @@ class ScanAppData extends Base {
*
* @param string[] $headers
* @param string[] $rows
- * @param OutputInterface $output
*/
- protected function showSummary($headers, $rows, OutputInterface $output) {
+ protected function showSummary($headers, $rows, OutputInterface $output): void {
$niceDate = $this->formatExecTime();
if (!$rows) {
$rows = [
@@ -233,19 +205,17 @@ class ScanAppData extends Base {
/**
- * Formats microtime into a human readable format
- *
- * @return string
+ * Formats microtime into a human-readable format
*/
- protected function formatExecTime() {
+ protected function formatExecTime(): string {
$secs = round($this->execTime);
# convert seconds into HH:MM:SS form
return sprintf('%02d:%02d:%02d', (int)($secs / 3600), ((int)($secs / 60) % 60), (int)$secs % 60);
}
protected function reconnectToDatabase(OutputInterface $output): Connection {
- /** @var Connection $connection*/
- $connection = \OC::$server->get(Connection::class);
+ /** @var Connection $connection */
+ $connection = Server::get(Connection::class);
try {
$connection->close();
} catch (\Exception $ex) {
@@ -263,16 +233,15 @@ class ScanAppData extends Base {
}
/**
- * @return \OCP\Files\Folder
* @throws NotFoundException
*/
- private function getAppDataFolder() {
+ private function getAppDataFolder(): Node {
$instanceId = $this->config->getSystemValue('instanceid', null);
if ($instanceId === null) {
throw new NotFoundException();
}
- return $this->root->get('appdata_'.$instanceId);
+ return $this->rootFolder->get('appdata_' . $instanceId);
}
}
diff --git a/apps/files/lib/Command/TransferOwnership.php b/apps/files/lib/Command/TransferOwnership.php
index 50aa0b21a5f..f7663e26f28 100644
--- a/apps/files/lib/Command/TransferOwnership.php
+++ b/apps/files/lib/Command/TransferOwnership.php
@@ -1,71 +1,41 @@
<?php
declare(strict_types=1);
-
/**
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- *
- * @author Carla Schroder <carla@owncloud.com>
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Morris Jobke <hey@morrisjobke.de>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Sujith Haridasan <sujith.h@gmail.com>
- * @author Sujith H <sharidasan@owncloud.com>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- * @author Tobia De Koninck <LEDfan@users.noreply.github.com>
- * @author Vincent Petry <vincent@nextcloud.com>
- *
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\Files\Command;
use OCA\Files\Exception\TransferOwnershipException;
use OCA\Files\Service\OwnershipTransferService;
+use OCA\Files_External\Config\ConfigAdapter;
+use OCP\Files\Mount\IMountManager;
+use OCP\Files\Mount\IMountPoint;
+use OCP\IConfig;
use OCP\IUser;
use OCP\IUserManager;
-use OCP\IConfig;
use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
class TransferOwnership extends Command {
-
- /** @var IUserManager */
- private $userManager;
-
- /** @var OwnershipTransferService */
- private $transferService;
-
- /** @var IConfig */
- private $config;
-
- public function __construct(IUserManager $userManager,
- OwnershipTransferService $transferService,
- IConfig $config) {
+ public function __construct(
+ private IUserManager $userManager,
+ private OwnershipTransferService $transferService,
+ private IConfig $config,
+ private IMountManager $mountManager,
+ ) {
parent::__construct();
- $this->userManager = $userManager;
- $this->transferService = $transferService;
- $this->config = $config;
}
- protected function configure() {
+ protected function configure(): void {
$this
->setName('files:transfer-ownership')
->setDescription('All files and folders are moved to another user - outgoing shares and incoming user file shares (optionally) are moved as well.')
@@ -94,9 +64,19 @@ class TransferOwnership extends Command {
'transfer-incoming-shares',
null,
InputOption::VALUE_OPTIONAL,
- 'transfer incoming user file shares to destination user. Usage: --transfer-incoming-shares=1 (value required)',
+ 'Incoming shares are always transferred now, so this option does not affect the ownership transfer anymore',
'2'
- );
+ )->addOption(
+ 'include-external-storage',
+ null,
+ InputOption::VALUE_NONE,
+ 'include files on external storages, this will _not_ setup an external storage for the target user, but instead moves all the files from the external storages into the target users home directory',
+ )->addOption(
+ 'force-include-external-storage',
+ null,
+ InputOption::VALUE_NONE,
+ 'don\'t ask for confirmation for transferring external storages',
+ );
}
protected function execute(InputInterface $input, OutputInterface $output): int {
@@ -107,59 +87,62 @@ class TransferOwnership extends Command {
if ($input->getArgument(('source-user')) === $input->getArgument('destination-user')) {
$output->writeln("<error>Ownership can't be transferred when Source and Destination users are the same user. Please check your input.</error>");
- return 1;
+ return self::FAILURE;
}
$sourceUserObject = $this->userManager->get($input->getArgument('source-user'));
$destinationUserObject = $this->userManager->get($input->getArgument('destination-user'));
if (!$sourceUserObject instanceof IUser) {
- $output->writeln("<error>Unknown source user " . $input->getArgument('source-user') . "</error>");
- return 1;
+ $output->writeln('<error>Unknown source user ' . $input->getArgument('source-user') . '</error>');
+ return self::FAILURE;
}
if (!$destinationUserObject instanceof IUser) {
- $output->writeln("<error>Unknown destination user " . $input->getArgument('destination-user') . "</error>");
- return 1;
+ $output->writeln('<error>Unknown destination user ' . $input->getArgument('destination-user') . '</error>');
+ return self::FAILURE;
}
- try {
- $includeIncomingArgument = $input->getOption('transfer-incoming-shares');
-
- switch ($includeIncomingArgument) {
- case '0':
- $includeIncoming = false;
- break;
- case '1':
- $includeIncoming = true;
- break;
- case '2':
- $includeIncoming = $this->config->getSystemValue('transferIncomingShares', false);
- if (gettype($includeIncoming) !== 'boolean') {
- $output->writeln("<error> config.php: 'transfer-incoming-shares': wrong usage. Transfer aborted.</error>");
- return 1;
+ $path = ltrim($input->getOption('path'), '/');
+ $includeExternalStorage = $input->getOption('include-external-storage');
+ if ($includeExternalStorage) {
+ $mounts = $this->mountManager->findIn('/' . rtrim($sourceUserObject->getUID() . '/files/' . $path, '/'));
+ /** @var IMountPoint[] $mounts */
+ $mounts = array_filter($mounts, fn ($mount) => $mount->getMountProvider() === ConfigAdapter::class);
+ if (count($mounts) > 0) {
+ $output->writeln(count($mounts) . ' external storages will be transferred:');
+ foreach ($mounts as $mount) {
+ $output->writeln(' - <info>' . $mount->getMountPoint() . '</info>');
+ }
+ $output->writeln('');
+ $output->writeln('<comment>Any other users with access to these external storages will lose access to the files.</comment>');
+ $output->writeln('');
+ if (!$input->getOption('force-include-external-storage')) {
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+ $question = new ConfirmationQuestion('Are you sure you want to transfer external storages? (y/N) ', false);
+ if (!$helper->ask($input, $output, $question)) {
+ return self::FAILURE;
}
- break;
- default:
- $output->writeln("<error>Option --transfer-incoming-shares: wrong usage. Transfer aborted.</error>");
- return 1;
- break;
+ }
}
+ }
+ try {
$this->transferService->transfer(
$sourceUserObject,
$destinationUserObject,
- ltrim($input->getOption('path'), '/'),
+ $path,
$output,
$input->getOption('move') === true,
false,
- $includeIncoming
+ $includeExternalStorage,
);
} catch (TransferOwnershipException $e) {
- $output->writeln("<error>" . $e->getMessage() . "</error>");
- return $e->getCode() !== 0 ? $e->getCode() : 1;
+ $output->writeln('<error>' . $e->getMessage() . '</error>');
+ return $e->getCode() !== 0 ? $e->getCode() : self::FAILURE;
}
- return 0;
+ return self::SUCCESS;
}
}
diff --git a/apps/files/lib/Command/WindowsCompatibleFilenames.php b/apps/files/lib/Command/WindowsCompatibleFilenames.php
new file mode 100644
index 00000000000..84a1b277824
--- /dev/null
+++ b/apps/files/lib/Command/WindowsCompatibleFilenames.php
@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\Files\Command;
+
+use OC\Core\Command\Base;
+use OCA\Files\Service\SettingsService;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class WindowsCompatibleFilenames extends Base {
+
+ public function __construct(
+ private SettingsService $service,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ parent::configure();
+
+ $this
+ ->setName('files:windows-compatible-filenames')
+ ->setDescription('Enforce naming constraints for windows compatible filenames')
+ ->addOption('enable', description: 'Enable windows naming constraints')
+ ->addOption('disable', description: 'Disable windows naming constraints');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ if ($input->getOption('enable')) {
+ if ($this->service->hasFilesWindowsSupport()) {
+ $output->writeln('<error>Windows compatible filenames already enforced.</error>', OutputInterface::VERBOSITY_VERBOSE);
+ }
+ $this->service->setFilesWindowsSupport(true);
+ $output->writeln('Windows compatible filenames enforced.');
+ } elseif ($input->getOption('disable')) {
+ if (!$this->service->hasFilesWindowsSupport()) {
+ $output->writeln('<error>Windows compatible filenames already disabled.</error>', OutputInterface::VERBOSITY_VERBOSE);
+ }
+ $this->service->setFilesWindowsSupport(false);
+ $output->writeln('Windows compatible filename constraints removed.');
+ } else {
+ $output->writeln('Windows compatible filenames are ' . ($this->service->hasFilesWindowsSupport() ? 'enforced' : 'disabled'));
+ }
+ return self::SUCCESS;
+ }
+}