diff options
Diffstat (limited to 'apps/files/lib/Service')
-rw-r--r-- | apps/files/lib/Service/ChunkedUploadConfig.php | 30 | ||||
-rw-r--r-- | apps/files/lib/Service/DirectEditingService.php | 67 | ||||
-rw-r--r-- | apps/files/lib/Service/LivePhotosService.php | 36 | ||||
-rw-r--r-- | apps/files/lib/Service/OwnershipTransferService.php | 624 | ||||
-rw-r--r-- | apps/files/lib/Service/SettingsService.php | 63 | ||||
-rw-r--r-- | apps/files/lib/Service/TagService.php | 119 | ||||
-rw-r--r-- | apps/files/lib/Service/UserConfig.php | 182 | ||||
-rw-r--r-- | apps/files/lib/Service/ViewConfig.php | 168 |
8 files changed, 1189 insertions, 100 deletions
diff --git a/apps/files/lib/Service/ChunkedUploadConfig.php b/apps/files/lib/Service/ChunkedUploadConfig.php new file mode 100644 index 00000000000..29661750f8b --- /dev/null +++ b/apps/files/lib/Service/ChunkedUploadConfig.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 OCA\Files\Service; + +use OCP\IConfig; +use OCP\Server; + +class ChunkedUploadConfig { + private const KEY_MAX_SIZE = 'files.chunked_upload.max_size'; + private const KEY_MAX_PARALLEL_COUNT = 'files.chunked_upload.max_parallel_count'; + + public static function getMaxChunkSize(): int { + return Server::get(IConfig::class)->getSystemValueInt(self::KEY_MAX_SIZE, 100 * 1024 * 1024); + } + + public static function setMaxChunkSize(int $maxChunkSize): void { + Server::get(IConfig::class)->setSystemValue(self::KEY_MAX_SIZE, $maxChunkSize); + } + + public static function getMaxParallelCount(): int { + return Server::get(IConfig::class)->getSystemValueInt(self::KEY_MAX_PARALLEL_COUNT, 5); + } +} diff --git a/apps/files/lib/Service/DirectEditingService.php b/apps/files/lib/Service/DirectEditingService.php new file mode 100644 index 00000000000..3d756ee56fa --- /dev/null +++ b/apps/files/lib/Service/DirectEditingService.php @@ -0,0 +1,67 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Service; + +use OCP\DirectEditing\ACreateEmpty; +use OCP\DirectEditing\ACreateFromTemplate; +use OCP\DirectEditing\IEditor; +use OCP\DirectEditing\IManager; +use OCP\DirectEditing\RegisterDirectEditorEvent; +use OCP\EventDispatcher\IEventDispatcher; + +class DirectEditingService { + + public function __construct( + private IEventDispatcher $eventDispatcher, + private IManager $directEditingManager, + ) { + } + + public function getDirectEditingETag(): string { + return \md5(\json_encode($this->getDirectEditingCapabilitites())); + } + + public function getDirectEditingCapabilitites(): array { + $this->eventDispatcher->dispatchTyped(new RegisterDirectEditorEvent($this->directEditingManager)); + + $capabilities = [ + 'editors' => [], + 'creators' => [] + ]; + + if (!$this->directEditingManager->isEnabled()) { + return $capabilities; + } + + /** + * @var string $id + * @var IEditor $editor + */ + foreach ($this->directEditingManager->getEditors() as $id => $editor) { + $capabilities['editors'][$id] = [ + 'id' => $editor->getId(), + 'name' => $editor->getName(), + 'mimetypes' => $editor->getMimetypes(), + 'optionalMimetypes' => $editor->getMimetypesOptional(), + 'secure' => $editor->isSecure(), + ]; + /** @var ACreateEmpty|ACreateFromTemplate $creator */ + foreach ($editor->getCreators() as $creator) { + $id = $creator->getId(); + $capabilities['creators'][$id] = [ + 'id' => $id, + 'editor' => $editor->getId(), + 'name' => $creator->getName(), + 'extension' => $creator->getExtension(), + 'templates' => $creator instanceof ACreateFromTemplate, + 'mimetype' => $creator->getMimetype() + ]; + } + } + return $capabilities; + } +} diff --git a/apps/files/lib/Service/LivePhotosService.php b/apps/files/lib/Service/LivePhotosService.php new file mode 100644 index 00000000000..3ac6601d5dc --- /dev/null +++ b/apps/files/lib/Service/LivePhotosService.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Service; + +use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException; +use OCP\FilesMetadata\IFilesMetadataManager; + +class LivePhotosService { + public function __construct( + private IFilesMetadataManager $filesMetadataManager, + ) { + } + + /** + * Get the associated live photo for a given file id + */ + public function getLivePhotoPeerId(int $fileId): ?int { + try { + $metadata = $this->filesMetadataManager->getMetadata($fileId); + } catch (FilesMetadataNotFoundException $ex) { + return null; + } + + if (!$metadata->hasKey('files-live-photo')) { + return null; + } + + return (int)$metadata->getString('files-live-photo'); + } +} diff --git a/apps/files/lib/Service/OwnershipTransferService.php b/apps/files/lib/Service/OwnershipTransferService.php new file mode 100644 index 00000000000..afef5d2093d --- /dev/null +++ b/apps/files/lib/Service/OwnershipTransferService.php @@ -0,0 +1,624 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Service; + +use Closure; +use Exception; +use OC\Files\Filesystem; +use OC\Files\View; +use OC\User\NoUserException; +use OCA\Encryption\Util; +use OCA\Files\Exception\TransferOwnershipException; +use OCA\Files_External\Config\ConfigAdapter; +use OCP\Encryption\IManager as IEncryptionManager; +use OCP\Files\Config\IHomeMountProvider; +use OCP\Files\Config\IUserMountCache; +use OCP\Files\File; +use OCP\Files\FileInfo; +use OCP\Files\InvalidPathException; +use OCP\Files\IRootFolder; +use OCP\Files\Mount\IMountManager; +use OCP\Files\NotFoundException; +use OCP\IUser; +use OCP\IUserManager; +use OCP\L10N\IFactory; +use OCP\Server; +use OCP\Share\IManager as IShareManager; +use OCP\Share\IShare; +use Symfony\Component\Console\Helper\ProgressBar; +use Symfony\Component\Console\Output\NullOutput; +use Symfony\Component\Console\Output\OutputInterface; +use function array_merge; +use function basename; +use function count; +use function date; +use function is_dir; +use function rtrim; + +class OwnershipTransferService { + + public function __construct( + private IEncryptionManager $encryptionManager, + private IShareManager $shareManager, + private IMountManager $mountManager, + private IUserMountCache $userMountCache, + private IUserManager $userManager, + private IFactory $l10nFactory, + private IRootFolder $rootFolder, + ) { + } + + /** + * @param IUser $sourceUser + * @param IUser $destinationUser + * @param string $path + * + * @param OutputInterface|null $output + * @param bool $move + * @throws TransferOwnershipException + * @throws NoUserException + */ + public function transfer( + IUser $sourceUser, + IUser $destinationUser, + string $path, + ?OutputInterface $output = null, + bool $move = false, + bool $firstLogin = false, + bool $includeExternalStorage = false, + ): void { + $output = $output ?? new NullOutput(); + $sourceUid = $sourceUser->getUID(); + $destinationUid = $destinationUser->getUID(); + $sourcePath = rtrim($sourceUid . '/files/' . $path, '/'); + + // If encryption is on we have to ensure the user has logged in before and that all encryption modules are ready + if (($this->encryptionManager->isEnabled() && $destinationUser->getLastLogin() === 0) + || !$this->encryptionManager->isReadyForUser($destinationUid)) { + throw new TransferOwnershipException('The target user is not ready to accept files. The user has at least to have logged in once.', 2); + } + + // setup filesystem + // Requesting the user folder will set it up if the user hasn't logged in before + // We need a setupFS for the full filesystem setup before as otherwise we will just return + // a lazy root folder which does not create the destination users folder + \OC_Util::setupFS($sourceUser->getUID()); + \OC_Util::setupFS($destinationUser->getUID()); + $this->rootFolder->getUserFolder($sourceUser->getUID()); + $this->rootFolder->getUserFolder($destinationUser->getUID()); + Filesystem::initMountPoints($sourceUid); + Filesystem::initMountPoints($destinationUid); + + $view = new View(); + + if ($move) { + $finalTarget = "$destinationUid/files/"; + } else { + $l = $this->l10nFactory->get('files', $this->l10nFactory->getUserLanguage($destinationUser)); + $date = date('Y-m-d H-i-s'); + + $cleanUserName = $this->sanitizeFolderName($sourceUser->getDisplayName()) ?: $sourceUid; + $finalTarget = "$destinationUid/files/" . $this->sanitizeFolderName($l->t('Transferred from %1$s on %2$s', [$cleanUserName, $date])); + try { + $view->verifyPath(dirname($finalTarget), basename($finalTarget)); + } catch (InvalidPathException $e) { + $finalTarget = "$destinationUid/files/" . $this->sanitizeFolderName($l->t('Transferred from %1$s on %2$s', [$sourceUid, $date])); + } + } + + if (!($view->is_dir($sourcePath) || $view->is_file($sourcePath))) { + throw new TransferOwnershipException("Unknown path provided: $path", 1); + } + + if ($move && !$view->is_dir($finalTarget)) { + // Initialize storage + \OC_Util::setupFS($destinationUser->getUID()); + } + + if ($move && !$firstLogin && count($view->getDirectoryContent($finalTarget)) > 0) { + throw new TransferOwnershipException('Destination path does not exists or is not empty', 1); + } + + + // analyse source folder + $this->analyse( + $sourceUid, + $destinationUid, + $sourcePath, + $view, + $output + ); + + // collect all the shares + $shares = $this->collectUsersShares( + $sourceUid, + $output, + $view, + $sourcePath + ); + + $sourceSize = $view->getFileInfo($sourcePath)->getSize(); + + // transfer the files + $this->transferFiles( + $sourceUid, + $sourcePath, + $finalTarget, + $view, + $output, + $includeExternalStorage, + ); + $sizeDifference = $sourceSize - $view->getFileInfo($finalTarget)->getSize(); + + // transfer the incoming shares + $sourceShares = $this->collectIncomingShares( + $sourceUid, + $output, + $sourcePath, + ); + $destinationShares = $this->collectIncomingShares( + $destinationUid, + $output, + null, + ); + $this->transferIncomingShares( + $sourceUid, + $destinationUid, + $sourceShares, + $destinationShares, + $output, + $path, + $finalTarget, + $move + ); + + $destinationPath = $finalTarget . '/' . $path; + // restore the shares + $this->restoreShares( + $sourceUid, + $destinationUid, + $destinationPath, + $shares, + $output + ); + if ($sizeDifference !== 0) { + $output->writeln("Transferred folder have a size difference of: $sizeDifference Bytes which means the transfer may be incomplete. Please check the logs if there was any issue during the transfer operation."); + } + } + + private function sanitizeFolderName(string $name): string { + // Remove some characters which are prone to cause errors + $name = str_replace(['\\', '/', ':', '.', '?', '#', '\'', '"'], '-', $name); + // Replace multiple dashes with one dash + return preg_replace('/-{2,}/s', '-', $name); + } + + private function walkFiles(View $view, $path, Closure $callBack) { + foreach ($view->getDirectoryContent($path) as $fileInfo) { + if (!$callBack($fileInfo)) { + return; + } + if ($fileInfo->getType() === FileInfo::TYPE_FOLDER) { + $this->walkFiles($view, $fileInfo->getPath(), $callBack); + } + } + } + + /** + * @param OutputInterface $output + * + * @throws TransferOwnershipException + */ + protected function analyse( + string $sourceUid, + string $destinationUid, + string $sourcePath, + View $view, + OutputInterface $output, + bool $includeExternalStorage = false, + ): void { + $output->writeln('Validating quota'); + $sourceFileInfo = $view->getFileInfo($sourcePath, false); + if ($sourceFileInfo === false) { + throw new TransferOwnershipException("Unknown path provided: $sourcePath", 1); + } + $size = $sourceFileInfo->getSize(false); + $freeSpace = $view->free_space($destinationUid . '/files/'); + if ($size > $freeSpace && $freeSpace !== FileInfo::SPACE_UNKNOWN) { + throw new TransferOwnershipException('Target user does not have enough free space available.', 1); + } + + $output->writeln("Analysing files of $sourceUid ..."); + $progress = new ProgressBar($output); + $progress->start(); + + if ($this->encryptionManager->isEnabled()) { + $masterKeyEnabled = Server::get(Util::class)->isMasterKeyEnabled(); + } else { + $masterKeyEnabled = false; + } + $encryptedFiles = []; + if ($sourceFileInfo->getType() === FileInfo::TYPE_FOLDER) { + if ($sourceFileInfo->isEncrypted()) { + /* Encrypted folder means e2ee encrypted */ + $encryptedFiles[] = $sourceFileInfo; + } else { + $this->walkFiles($view, $sourcePath, + function (FileInfo $fileInfo) use ($progress, $masterKeyEnabled, &$encryptedFiles, $includeExternalStorage) { + if ($fileInfo->getType() === FileInfo::TYPE_FOLDER) { + $mount = $fileInfo->getMountPoint(); + // only analyze into folders from main storage, + if ( + $mount->getMountProvider() instanceof IHomeMountProvider + || ($includeExternalStorage && $mount->getMountProvider() instanceof ConfigAdapter) + ) { + if ($fileInfo->isEncrypted()) { + /* Encrypted folder means e2ee encrypted, we cannot transfer it */ + $encryptedFiles[] = $fileInfo; + } + return true; + } else { + return false; + } + } + $progress->advance(); + if ($fileInfo->isEncrypted() && !$masterKeyEnabled) { + /* Encrypted file means SSE, we can only transfer it if master key is enabled */ + $encryptedFiles[] = $fileInfo; + } + return true; + }); + } + } elseif ($sourceFileInfo->isEncrypted() && !$masterKeyEnabled) { + /* Encrypted file means SSE, we can only transfer it if master key is enabled */ + $encryptedFiles[] = $sourceFileInfo; + } + $progress->finish(); + $output->writeln(''); + + // no file is allowed to be encrypted + if (!empty($encryptedFiles)) { + $output->writeln('<error>Some files are encrypted - please decrypt them first.</error>'); + foreach ($encryptedFiles as $encryptedFile) { + /** @var FileInfo $encryptedFile */ + $output->writeln(' ' . $encryptedFile->getPath()); + } + throw new TransferOwnershipException('Some files are encrypted - please decrypt them first.', 1); + } + } + + /** + * @return array<array{share: IShare, suffix: string}> + */ + private function collectUsersShares( + string $sourceUid, + OutputInterface $output, + View $view, + string $path, + ): array { + $output->writeln("Collecting all share information for files and folders of $sourceUid ..."); + + $shares = []; + $progress = new ProgressBar($output); + + $normalizedPath = Filesystem::normalizePath($path); + + $supportedShareTypes = [ + IShare::TYPE_GROUP, + IShare::TYPE_USER, + IShare::TYPE_LINK, + IShare::TYPE_REMOTE, + IShare::TYPE_ROOM, + IShare::TYPE_EMAIL, + IShare::TYPE_CIRCLE, + IShare::TYPE_DECK, + IShare::TYPE_SCIENCEMESH, + ]; + + foreach ($supportedShareTypes as $shareType) { + $offset = 0; + while (true) { + $sharePage = $this->shareManager->getSharesBy($sourceUid, $shareType, null, true, 50, $offset, onlyValid: false); + $progress->advance(count($sharePage)); + if (empty($sharePage)) { + break; + } + if ($path !== "$sourceUid/files") { + $sharePage = array_filter($sharePage, function (IShare $share) use ($view, $normalizedPath) { + try { + $sourceNode = $share->getNode(); + $relativePath = $view->getRelativePath($sourceNode->getPath()); + + return str_starts_with($relativePath . '/', $normalizedPath . '/'); + } catch (Exception $e) { + return false; + } + }); + } + $shares = array_merge($shares, $sharePage); + $offset += 50; + } + } + + $progress->finish(); + $output->writeln(''); + + return array_values(array_filter(array_map(function (IShare $share) use ($view, $normalizedPath, $output, $sourceUid) { + try { + $nodePath = $view->getRelativePath($share->getNode()->getPath()); + } catch (NotFoundException $e) { + $output->writeln("<error>Failed to find path for shared file {$share->getNodeId()} for user $sourceUid, skipping</error>"); + return null; + } + + return [ + 'share' => $share, + 'suffix' => substr(Filesystem::normalizePath($nodePath), strlen($normalizedPath)), + ]; + }, $shares))); + } + + private function collectIncomingShares( + string $sourceUid, + OutputInterface $output, + ?string $path, + ): array { + $output->writeln("Collecting all incoming share information for files and folders of $sourceUid ..."); + + $shares = []; + $progress = new ProgressBar($output); + $normalizedPath = Filesystem::normalizePath($path); + + $offset = 0; + while (true) { + $sharePage = $this->shareManager->getSharedWith($sourceUid, IShare::TYPE_USER, null, 50, $offset); + $progress->advance(count($sharePage)); + if (empty($sharePage)) { + break; + } + + if ($path !== null && $path !== "$sourceUid/files") { + $sharePage = array_filter($sharePage, static function (IShare $share) use ($sourceUid, $normalizedPath) { + try { + return str_starts_with(Filesystem::normalizePath($sourceUid . '/files' . $share->getTarget() . '/', false), $normalizedPath . '/'); + } catch (Exception) { + return false; + } + }); + } + + foreach ($sharePage as $share) { + $shares[$share->getNodeId()] = $share; + } + + $offset += 50; + } + + + $progress->finish(); + $output->writeln(''); + return $shares; + } + + /** + * @throws TransferOwnershipException + */ + protected function transferFiles( + string $sourceUid, + string $sourcePath, + string $finalTarget, + View $view, + OutputInterface $output, + bool $includeExternalStorage, + ): void { + $output->writeln("Transferring files to $finalTarget ..."); + + // This change will help user to transfer the folder specified using --path option. + // Else only the content inside folder is transferred which is not correct. + if ($sourcePath !== "$sourceUid/files") { + $view->mkdir($finalTarget); + $finalTarget = $finalTarget . '/' . basename($sourcePath); + } + $sourceInfo = $view->getFileInfo($sourcePath); + + /// handle the external storages mounted at the root, or the admin specifying an external storage with --path + if ($sourceInfo->getInternalPath() === '' && $includeExternalStorage) { + $this->moveMountContents($view, $sourcePath, $finalTarget); + } else { + if ($view->rename($sourcePath, $finalTarget, ['checkSubMounts' => false]) === false) { + throw new TransferOwnershipException('Could not transfer files.', 1); + } + } + + if ($includeExternalStorage) { + $nestedMounts = $this->mountManager->findIn($sourcePath); + foreach ($nestedMounts as $mount) { + if ($mount->getMountProvider() === ConfigAdapter::class) { + $relativePath = substr(trim($mount->getMountPoint(), '/'), strlen($sourcePath)); + $this->moveMountContents($view, $mount->getMountPoint(), $finalTarget . $relativePath); + } + } + } + + if (!is_dir("$sourceUid/files")) { + // because the files folder is moved away we need to recreate it + $view->mkdir("$sourceUid/files"); + } + } + + private function moveMountContents(View $rootView, string $source, string $target) { + if ($rootView->copy($source, $target)) { + // just doing `rmdir` on the mountpoint would cause it to try and unmount the storage + // we need to empty the contents instead + $content = $rootView->getDirectoryContent($source); + foreach ($content as $item) { + if ($item->getType() === FileInfo::TYPE_FOLDER) { + $rootView->rmdir($item->getPath()); + } else { + $rootView->unlink($item->getPath()); + } + } + } else { + throw new TransferOwnershipException("Could not transfer $source to $target"); + } + } + + /** + * @param string $targetLocation New location of the transfered node + * @param array<array{share: IShare, suffix: string}> $shares previously collected share information + */ + private function restoreShares( + string $sourceUid, + string $destinationUid, + string $targetLocation, + array $shares, + OutputInterface $output, + ):void { + $output->writeln('Restoring shares ...'); + $progress = new ProgressBar($output, count($shares)); + + foreach ($shares as ['share' => $share, 'suffix' => $suffix]) { + try { + $output->writeln('Transfering share ' . $share->getId() . ' of type ' . $share->getShareType(), OutputInterface::VERBOSITY_VERBOSE); + if ($share->getShareType() === IShare::TYPE_USER + && $share->getSharedWith() === $destinationUid) { + // Unmount the shares before deleting, so we don't try to get the storage later on. + $shareMountPoint = $this->mountManager->find('/' . $destinationUid . '/files' . $share->getTarget()); + if ($shareMountPoint) { + $this->mountManager->removeMount($shareMountPoint->getMountPoint()); + } + $this->shareManager->deleteShare($share); + } else { + if ($share->getShareOwner() === $sourceUid) { + $share->setShareOwner($destinationUid); + } + if ($share->getSharedBy() === $sourceUid) { + $share->setSharedBy($destinationUid); + } + + if ($share->getShareType() === IShare::TYPE_USER + && !$this->userManager->userExists($share->getSharedWith())) { + // stray share with deleted user + $output->writeln('<error>Share with id ' . $share->getId() . ' points at deleted user "' . $share->getSharedWith() . '", deleting</error>'); + $this->shareManager->deleteShare($share); + continue; + } else { + // trigger refetching of the node so that the new owner and mountpoint are taken into account + // otherwise the checks on the share update will fail due to the original node not being available in the new user scope + $this->userMountCache->clear(); + + try { + // Try to get the "old" id. + // Normally the ID is preserved, + // but for transferes between different storages the ID might change + $newNodeId = $share->getNode()->getId(); + } catch (NotFoundException) { + // ID has changed due to transfer between different storages + // Try to get the new ID from the target path and suffix of the share + $node = $this->rootFolder->get(Filesystem::normalizePath($targetLocation . '/' . $suffix)); + $newNodeId = $node->getId(); + $output->writeln('Had to change node id to ' . $newNodeId, OutputInterface::VERBOSITY_VERY_VERBOSE); + } + $share->setNodeId($newNodeId); + + $this->shareManager->updateShare($share, onlyValid: false); + } + } + } catch (NotFoundException $e) { + $output->writeln('<error>Share with id ' . $share->getId() . ' points at deleted file, skipping</error>'); + } catch (\Throwable $e) { + $output->writeln('<error>Could not restore share with id ' . $share->getId() . ':' . $e->getMessage() . ' : ' . $e->getTraceAsString() . '</error>'); + } + $progress->advance(); + } + $progress->finish(); + $output->writeln(''); + } + + private function transferIncomingShares(string $sourceUid, + string $destinationUid, + array $sourceShares, + array $destinationShares, + OutputInterface $output, + string $path, + string $finalTarget, + bool $move): void { + $output->writeln('Restoring incoming shares ...'); + $progress = new ProgressBar($output, count($sourceShares)); + $prefix = "$destinationUid/files"; + $finalShareTarget = ''; + if (str_starts_with($finalTarget, $prefix)) { + $finalShareTarget = substr($finalTarget, strlen($prefix)); + } + foreach ($sourceShares as $share) { + try { + // Only restore if share is in given path. + $pathToCheck = '/'; + if (trim($path, '/') !== '') { + $pathToCheck = '/' . trim($path) . '/'; + } + if (!str_starts_with($share->getTarget(), $pathToCheck)) { + continue; + } + $shareTarget = $share->getTarget(); + $shareTarget = $finalShareTarget . $shareTarget; + if ($share->getShareType() === IShare::TYPE_USER + && $share->getSharedBy() === $destinationUid) { + $this->shareManager->deleteShare($share); + } elseif (isset($destinationShares[$share->getNodeId()])) { + $destinationShare = $destinationShares[$share->getNodeId()]; + // Keep the share which has the most permissions and discard the other one. + if ($destinationShare->getPermissions() < $share->getPermissions()) { + $this->shareManager->deleteShare($destinationShare); + $share->setSharedWith($destinationUid); + // trigger refetching of the node so that the new owner and mountpoint are taken into account + // otherwise the checks on the share update will fail due to the original node not being available in the new user scope + $this->userMountCache->clear(); + $share->setNodeId($share->getNode()->getId()); + $this->shareManager->updateShare($share); + // The share is already transferred. + $progress->advance(); + if ($move) { + continue; + } + $share->setTarget($shareTarget); + $this->shareManager->moveShare($share, $destinationUid); + continue; + } + $this->shareManager->deleteShare($share); + } elseif ($share->getShareOwner() === $destinationUid) { + $this->shareManager->deleteShare($share); + } else { + $share->setSharedWith($destinationUid); + $share->setNodeId($share->getNode()->getId()); + $this->shareManager->updateShare($share); + // trigger refetching of the node so that the new owner and mountpoint are taken into account + // otherwise the checks on the share update will fail due to the original node not being available in the new user scope + $this->userMountCache->clear(); + // The share is already transferred. + $progress->advance(); + if ($move) { + continue; + } + $share->setTarget($shareTarget); + $this->shareManager->moveShare($share, $destinationUid); + continue; + } + } catch (NotFoundException $e) { + $output->writeln('<error>Share with id ' . $share->getId() . ' points at deleted file, skipping</error>'); + } catch (\Throwable $e) { + $output->writeln('<error>Could not restore share with id ' . $share->getId() . ':' . $e->getTraceAsString() . '</error>'); + } + $progress->advance(); + } + $progress->finish(); + $output->writeln(''); + } +} diff --git a/apps/files/lib/Service/SettingsService.php b/apps/files/lib/Service/SettingsService.php new file mode 100644 index 00000000000..d07e907a5f6 --- /dev/null +++ b/apps/files/lib/Service/SettingsService.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Service; + +use OC\Files\FilenameValidator; +use OCP\IConfig; +use Psr\Log\LoggerInterface; + +class SettingsService { + + protected const WINDOWS_EXTENSION = [ + ' ', + '.', + ]; + + protected const WINDOWS_BASENAMES = [ + 'con', 'prn', 'aux', 'nul', 'com0', 'com1', 'com2', 'com3', 'com4', 'com5', + 'com6', 'com7', 'com8', 'com9', 'com¹', 'com²', 'com³', 'lpt0', 'lpt1', 'lpt2', + 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9', 'lpt¹', 'lpt²', 'lpt³', + ]; + + protected const WINDOWS_CHARACTERS = [ + '<', '>', ':', + '"', '|', '?', + '*', + ]; + + public function __construct( + private IConfig $config, + private FilenameValidator $filenameValidator, + private LoggerInterface $logger, + ) { + } + + public function hasFilesWindowsSupport(): bool { + return empty(array_diff(self::WINDOWS_BASENAMES, $this->filenameValidator->getForbiddenBasenames())) + && empty(array_diff(self::WINDOWS_CHARACTERS, $this->filenameValidator->getForbiddenCharacters())) + && empty(array_diff(self::WINDOWS_EXTENSION, $this->filenameValidator->getForbiddenExtensions())); + } + + public function setFilesWindowsSupport(bool $enabled = true): void { + if ($enabled) { + $basenames = array_unique(array_merge(self::WINDOWS_BASENAMES, $this->filenameValidator->getForbiddenBasenames())); + $characters = array_unique(array_merge(self::WINDOWS_CHARACTERS, $this->filenameValidator->getForbiddenCharacters())); + $extensions = array_unique(array_merge(self::WINDOWS_EXTENSION, $this->filenameValidator->getForbiddenExtensions())); + } else { + $basenames = array_unique(array_values(array_diff($this->filenameValidator->getForbiddenBasenames(), self::WINDOWS_BASENAMES))); + $characters = array_unique(array_values(array_diff($this->filenameValidator->getForbiddenCharacters(), self::WINDOWS_CHARACTERS))); + $extensions = array_unique(array_values(array_diff($this->filenameValidator->getForbiddenExtensions(), self::WINDOWS_EXTENSION))); + } + $values = [ + 'forbidden_filename_basenames' => empty($basenames) ? null : $basenames, + 'forbidden_filename_characters' => empty($characters) ? null : $characters, + 'forbidden_filename_extensions' => empty($extensions) ? null : $extensions, + ]; + $this->config->setSystemValues($values); + } +} diff --git a/apps/files/lib/Service/TagService.php b/apps/files/lib/Service/TagService.php index 7437f0c31ad..63c54d01fd0 100644 --- a/apps/files/lib/Service/TagService.php +++ b/apps/files/lib/Service/TagService.php @@ -1,74 +1,29 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Vincent Petry <pvince81@owncloud.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\Service; -use OC\Tags; -use OCA\Files\Activity\FavoriteProvider; use OCP\Activity\IManager; use OCP\Files\Folder; +use OCP\Files\NotFoundException; use OCP\ITags; -use OCP\IUser; use OCP\IUserSession; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\EventDispatcher\GenericEvent; /** * Service class to manage tags on files. */ class TagService { - /** @var IUserSession */ - private $userSession; - /** @var IManager */ - private $activityManager; - /** @var ITags */ - private $tagger; - /** @var Folder */ - private $homeFolder; - /** @var EventDispatcherInterface */ - private $dispatcher; - - /** - * @param IUserSession $userSession - * @param IManager $activityManager - * @param ITags $tagger - * @param Folder $homeFolder - * @param EventDispatcherInterface $dispatcher - */ public function __construct( - IUserSession $userSession, - IManager $activityManager, - ITags $tagger, - Folder $homeFolder, - EventDispatcherInterface $dispatcher + private IUserSession $userSession, + private IManager $activityManager, + private ?ITags $tagger, + private ?Folder $homeFolder, ) { - $this->userSession = $userSession; - $this->activityManager = $activityManager; - $this->tagger = $tagger; - $this->homeFolder = $homeFolder; - $this->dispatcher = $dispatcher; } /** @@ -77,14 +32,21 @@ class TagService { * replace the actual tag selection. * * @param string $path path - * @param array $tags array of tags + * @param array $tags array of tags * @return array list of tags - * @throws \OCP\Files\NotFoundException if the file does not exist + * @throws NotFoundException if the file does not exist */ public function updateFileTags($path, $tags) { + if ($this->tagger === null) { + throw new \RuntimeException('No tagger set'); + } + if ($this->homeFolder === null) { + throw new \RuntimeException('No homeFolder set'); + } + $fileId = $this->homeFolder->get($path)->getId(); - $currentTags = $this->tagger->getTagsForObjects(array($fileId)); + $currentTags = $this->tagger->getTagsForObjects([$fileId]); if (!empty($currentTags)) { $currentTags = current($currentTags); @@ -92,16 +54,10 @@ class TagService { $newTags = array_diff($tags, $currentTags); foreach ($newTags as $tag) { - if ($tag === Tags::TAG_FAVORITE) { - $this->addActivity(true, $fileId, $path); - } $this->tagger->tagAs($fileId, $tag); } $deletedTags = array_diff($currentTags, $tags); foreach ($deletedTags as $tag) { - if ($tag === Tags::TAG_FAVORITE) { - $this->addActivity(false, $fileId, $path); - } $this->tagger->unTag($fileId, $tag); } @@ -109,41 +65,4 @@ class TagService { // list is up to date, in case of concurrent changes ? return $tags; } - - /** - * @param bool $addToFavorite - * @param int $fileId - * @param string $path - */ - protected function addActivity($addToFavorite, $fileId, $path) { - $user = $this->userSession->getUser(); - if (!$user instanceof IUser) { - return; - } - - $eventName = $addToFavorite ? 'addFavorite' : 'removeFavorite'; - $this->dispatcher->dispatch(self::class . '::' . $eventName, new GenericEvent(null, [ - 'userId' => $user->getUID(), - 'fileId' => $fileId, - 'path' => $path, - ])); - - $event = $this->activityManager->generateEvent(); - try { - $event->setApp('files') - ->setObject('files', $fileId, $path) - ->setType('favorite') - ->setAuthor($user->getUID()) - ->setAffectedUser($user->getUID()) - ->setTimestamp(time()) - ->setSubject( - $addToFavorite ? FavoriteProvider::SUBJECT_ADDED : FavoriteProvider::SUBJECT_REMOVED, - ['id' => $fileId, 'path' => $path] - ); - $this->activityManager->publish($event); - } catch (\InvalidArgumentException $e) { - } catch (\BadMethodCallException $e) { - } - } } - diff --git a/apps/files/lib/Service/UserConfig.php b/apps/files/lib/Service/UserConfig.php new file mode 100644 index 00000000000..dcf30b7796d --- /dev/null +++ b/apps/files/lib/Service/UserConfig.php @@ -0,0 +1,182 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Service; + +use OCA\Files\AppInfo\Application; +use OCP\IConfig; +use OCP\IUser; +use OCP\IUserSession; + +class UserConfig { + public const ALLOWED_CONFIGS = [ + [ + // Whether to crop the files previews or not in the files list + 'key' => 'crop_image_previews', + 'default' => true, + 'allowed' => [true, false], + ], + [ + // The view to start the files app in + 'key' => 'default_view', + 'default' => 'files', + 'allowed' => ['files', 'personal'], + ], + [ + // Whether to show the folder tree + 'key' => 'folder_tree', + 'default' => true, + 'allowed' => [true, false], + ], + [ + // Whether to show the files list in grid view or not + 'key' => 'grid_view', + 'default' => false, + 'allowed' => [true, false], + ], + [ + // Whether to show the "confirm file deletion" warning + 'key' => 'show_dialog_deletion', + 'default' => false, + 'allowed' => [true, false], + ], + [ + // Whether to show the "confirm file extension change" warning + 'key' => 'show_dialog_file_extension', + 'default' => true, + 'allowed' => [true, false], + ], + [ + // Whether to show the files extensions in the files list or not + 'key' => 'show_files_extensions', + 'default' => true, + 'allowed' => [true, false], + ], + [ + // Whether to show the hidden files or not in the files list + 'key' => 'show_hidden', + 'default' => false, + 'allowed' => [true, false], + ], + [ + // Whether to show the mime column or not + 'key' => 'show_mime_column', + 'default' => false, + 'allowed' => [true, false], + ], + [ + // Whether to sort favorites first in the list or not + 'key' => 'sort_favorites_first', + 'default' => true, + 'allowed' => [true, false], + ], + [ + // Whether to sort folders before files in the list or not + 'key' => 'sort_folders_first', + 'default' => true, + 'allowed' => [true, false], + ], + ]; + protected ?IUser $user = null; + + public function __construct( + protected IConfig $config, + IUserSession $userSession, + ) { + $this->user = $userSession->getUser(); + } + + /** + * Get the list of all allowed user config keys + * @return string[] + */ + public function getAllowedConfigKeys(): array { + return array_map(function ($config) { + return $config['key']; + }, self::ALLOWED_CONFIGS); + } + + /** + * Get the list of allowed config values for a given key + * + * @param string $key a valid config key + * @return array + */ + private function getAllowedConfigValues(string $key): array { + foreach (self::ALLOWED_CONFIGS as $config) { + if ($config['key'] === $key) { + return $config['allowed']; + } + } + return []; + } + + /** + * Get the default config value for a given key + * + * @param string $key a valid config key + * @return string|bool + */ + private function getDefaultConfigValue(string $key) { + foreach (self::ALLOWED_CONFIGS as $config) { + if ($config['key'] === $key) { + return $config['default']; + } + } + return ''; + } + + /** + * Set a user config + * + * @param string $key + * @param string|bool $value + * @throws \Exception + * @throws \InvalidArgumentException + */ + public function setConfig(string $key, $value): void { + if ($this->user === null) { + throw new \Exception('No user logged in'); + } + + if (!in_array($key, $this->getAllowedConfigKeys())) { + throw new \InvalidArgumentException('Unknown config key'); + } + + if (!in_array($value, $this->getAllowedConfigValues($key))) { + throw new \InvalidArgumentException('Invalid config value'); + } + + if (is_bool($value)) { + $value = $value ? '1' : '0'; + } + + $this->config->setUserValue($this->user->getUID(), Application::APP_ID, $key, $value); + } + + /** + * Get the current user configs array + * + * @return array + */ + public function getConfigs(): array { + if ($this->user === null) { + throw new \Exception('No user logged in'); + } + + $userId = $this->user->getUID(); + $userConfigs = array_map(function (string $key) use ($userId) { + $value = $this->config->getUserValue($userId, Application::APP_ID, $key, $this->getDefaultConfigValue($key)); + // If the default is expected to be a boolean, we need to cast the value + if (is_bool($this->getDefaultConfigValue($key)) && is_string($value)) { + return $value === '1'; + } + return $value; + }, $this->getAllowedConfigKeys()); + + return array_combine($this->getAllowedConfigKeys(), $userConfigs); + } +} diff --git a/apps/files/lib/Service/ViewConfig.php b/apps/files/lib/Service/ViewConfig.php new file mode 100644 index 00000000000..cf8bebd5372 --- /dev/null +++ b/apps/files/lib/Service/ViewConfig.php @@ -0,0 +1,168 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files\Service; + +use OCA\Files\AppInfo\Application; +use OCP\IConfig; +use OCP\IUser; +use OCP\IUserSession; + +class ViewConfig { + public const CONFIG_KEY = 'files_views_configs'; + public const ALLOWED_CONFIGS = [ + [ + // The default sorting key for the files list view + 'key' => 'sorting_mode', + // null by default as views can provide default sorting key + // and will fallback to it if user hasn't change it + 'default' => null, + ], + [ + // The default sorting direction for the files list view + 'key' => 'sorting_direction', + 'default' => 'asc', + 'allowed' => ['asc', 'desc'], + ], + [ + // If the navigation entry for this view is expanded or not + 'key' => 'expanded', + 'default' => true, + 'allowed' => [true, false], + ], + ]; + protected ?IUser $user = null; + + public function __construct( + protected IConfig $config, + IUserSession $userSession, + ) { + $this->user = $userSession->getUser(); + } + + /** + * Get the list of all allowed user config keys + * @return string[] + */ + public function getAllowedConfigKeys(): array { + return array_map(function ($config) { + return $config['key']; + }, self::ALLOWED_CONFIGS); + } + + /** + * Get the list of allowed config values for a given key + * + * @param string $key a valid config key + * @return array + */ + private function getAllowedConfigValues(string $key): array { + foreach (self::ALLOWED_CONFIGS as $config) { + if ($config['key'] === $key) { + return $config['allowed'] ?? []; + } + } + return []; + } + + /** + * Get the default config value for a given key + * + * @param string $key a valid config key + * @return string|bool|null + */ + private function getDefaultConfigValue(string $key) { + foreach (self::ALLOWED_CONFIGS as $config) { + if ($config['key'] === $key) { + return $config['default']; + } + } + return ''; + } + + /** + * Set a user config + * + * @param string $view + * @param string $key + * @param string|bool $value + * @throws \Exception + * @throws \InvalidArgumentException + */ + public function setConfig(string $view, string $key, $value): void { + if ($this->user === null) { + throw new \Exception('No user logged in'); + } + + if (!$view) { + throw new \Exception('Unknown view'); + } + + if (!in_array($key, $this->getAllowedConfigKeys())) { + throw new \InvalidArgumentException('Unknown config key'); + } + + if (!in_array($value, $this->getAllowedConfigValues($key)) + && !empty($this->getAllowedConfigValues($key))) { + throw new \InvalidArgumentException('Invalid config value'); + } + + // Cast boolean values + if (is_bool($this->getDefaultConfigValue($key))) { + $value = $value === '1'; + } + + $config = $this->getConfigs(); + $config[$view][$key] = $value; + + $this->config->setUserValue($this->user->getUID(), Application::APP_ID, self::CONFIG_KEY, json_encode($config)); + } + + /** + * Get the current user configs array for a given view + * + * @return array + */ + public function getConfig(string $view): array { + if ($this->user === null) { + throw new \Exception('No user logged in'); + } + + $userId = $this->user->getUID(); + $configs = json_decode($this->config->getUserValue($userId, Application::APP_ID, self::CONFIG_KEY, '[]'), true); + + if (!isset($configs[$view])) { + $configs[$view] = []; + } + + // Extend undefined values with defaults + return array_reduce(self::ALLOWED_CONFIGS, function ($carry, $config) use ($view, $configs) { + $key = $config['key']; + $carry[$key] = $configs[$view][$key] ?? $this->getDefaultConfigValue($key); + return $carry; + }, []); + } + + /** + * Get the current user configs array + * + * @return array + */ + public function getConfigs(): array { + if ($this->user === null) { + throw new \Exception('No user logged in'); + } + + $userId = $this->user->getUID(); + $configs = json_decode($this->config->getUserValue($userId, Application::APP_ID, self::CONFIG_KEY, '[]'), true); + $views = array_keys($configs); + + return array_reduce($views, function ($carry, $view) use ($configs) { + $carry[$view] = $this->getConfig($view); + return $carry; + }, []); + } +} |